diff --git a/scripts/drill-runbook.ts b/scripts/drill-runbook.ts index b89488d..059e2ca 100644 --- a/scripts/drill-runbook.ts +++ b/scripts/drill-runbook.ts @@ -1,4 +1,5 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; import { shouldRetry } from "../src/adapters/http/retry-policy.ts"; import { createTelemetryAdapter } from "../src/adapters/telemetry/best-effort-telemetry.ts"; @@ -6,8 +7,17 @@ import { verifyCompatibilityTuple } from "../src/application/policies/compatibil import type { StoragePort } from "../src/application/ports/storage-port.ts"; import { decideChunkRecovery } from "../src/application/use-cases/decide-chunk-recovery.ts"; import { validateRuntimeConfig } from "../src/bootstrap/runtime-config-schema.ts"; -import { compareReleaseToRuntime } from "../src/contracts/release-tokens.ts"; +import { + parseReleaseArtifact, + parseRuntimeConfigArtifact, + type ReleaseArtifact, +} from "../src/contracts/release-artifacts.ts"; import { projectTelemetryEvent } from "../src/contracts/telemetry.ts"; +import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts"; +import { + verifyReleaseRuntimeCoherence, + type ReleaseRuntimeCoherenceInput, +} from "./lib/release-runtime-coherence.ts"; type RecoveryAssertion = Readonly<{ assertion: string; @@ -38,35 +48,15 @@ type RunbookDocument = Readonly<{ runbooks: Record; }>; -type ReleaseManifest = Record & { - buildId: string; - configSchemaVersion: string; - apiContractVersion: string; - assetManifestHash: string; - releaseId: string; -}; - -const runbookId = process.argv - .slice(2) - .find((argument) => /^FE-RB-00[1-5]$/.test(argument)); -const document = JSON.parse( - await readFile("config/runbooks/runbooks.json", "utf8"), -) as RunbookDocument; -const specification = runbookId ? document.runbooks[runbookId] : undefined; -if (!runbookId || !specification) { - process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n"); - process.exit(2); -} - -async function releaseManifest(): Promise { +async function releaseManifest(): Promise { for (const candidate of [ "dist/release-manifest.json", "public/release-manifest.json", ]) { try { - return JSON.parse( - await readFile(candidate, "utf8"), - ) as ReleaseManifest; + return parseReleaseArtifact( + JSON.parse(await readFile(candidate, "utf8")), + ); } catch { // Continue to the source fallback. } @@ -238,15 +228,29 @@ async function drillTelemetry(): Promise { async function drillRollback(): Promise { const release = await releaseManifest(); - const runtime = JSON.parse( - await readFile( - (await access("dist/config.json").then(() => true).catch(() => false)) - ? "dist/config.json" - : "public/config.json", - "utf8", + const runtimeArtifact = parseRuntimeConfigArtifact( + JSON.parse( + await readFile( + (await access("dist/config.json").then(() => true).catch(() => false)) + ? "dist/config.json" + : "public/config.json", + "utf8", + ), ), ); - const coherent = compareReleaseToRuntime(release, runtime); + const runtime = { + ...runtimeArtifact, + BUILD_ID: requireIdentity(runtimeArtifact.BUILD_ID, "runtime BUILD_ID"), + RELEASE_ID: requireIdentity( + runtimeArtifact.RELEASE_ID, + "runtime RELEASE_ID", + ), + }; + const coherent = await verifyRollbackReleaseCoherence({ + release, + runtime, + contractPackages: EXPECTED_CONTRACT_SET_PACKAGES, + }); const mixed = verifyCompatibilityTuple({ frontend: { buildId: "build-a", @@ -284,42 +288,78 @@ const drillById: Record Promise> = { "FE-RB-004": drillTelemetry, "FE-RB-005": drillRollback, }; -const drill = await drillById[runbookId](); -const escalationPathAsserted = specification.escalation.length >= 2; -const passed = - drill.triggerAsserted && - drill.containmentAsserted && - escalationPathAsserted && - drill.recoveryAssertions.every((item) => item.passed) && - drill.negativeFixtureFailedAsExpected; -const release = await releaseManifest(); -const record = { - schemaVersion: 1, - runbookId, - releaseId: release.releaseId, - drillTimestamp: new Date().toISOString(), - triggerInjected: specification.triggerKinds[0], - triggerAsserted: drill.triggerAsserted, - containmentAsserted: drill.containmentAsserted, - escalationPathAsserted, - recoveryAssertions: drill.recoveryAssertions, - negativeFixtureFailedAsExpected: drill.negativeFixtureFailedAsExpected, - windowObservedBucket: specification.window, - providerVerificationRequired: drill.providerVerificationRequired, - passed, -}; -const artifactDirectory = `artifacts/runbooks/${runbookId}`; -await mkdir(artifactDirectory, { recursive: true }); -await writeFile( - `${artifactDirectory}/record.json`, - `${JSON.stringify(record, null, 2)}\n`, -); -if (!passed) { - process.stderr.write(`${runbookId} drill failed.\n`); - process.exit(1); + +export async function verifyRollbackReleaseCoherence( + input: ReleaseRuntimeCoherenceInput, +) { + return verifyReleaseRuntimeCoherence(input); +} + +async function main(): Promise { + const runbookId = process.argv + .slice(2) + .find((argument) => /^FE-RB-00[1-5]$/.test(argument)); + const document = JSON.parse( + await readFile("config/runbooks/runbooks.json", "utf8"), + ) as RunbookDocument; + const specification = runbookId ? document.runbooks[runbookId] : undefined; + if (!runbookId || !specification) { + process.stderr.write("Usage: drill:runbook -- FE-RB-001..FE-RB-005\n"); + process.exit(2); + } + + const drill = await drillById[runbookId](); + const escalationPathAsserted = specification.escalation.length >= 2; + const passed = + drill.triggerAsserted && + drill.containmentAsserted && + escalationPathAsserted && + drill.recoveryAssertions.every((item) => item.passed) && + drill.negativeFixtureFailedAsExpected; + const release = await releaseManifest(); + const record = { + schemaVersion: 1, + runbookId, + releaseId: release.releaseId, + drillTimestamp: new Date().toISOString(), + triggerInjected: specification.triggerKinds[0], + triggerAsserted: drill.triggerAsserted, + containmentAsserted: drill.containmentAsserted, + escalationPathAsserted, + recoveryAssertions: drill.recoveryAssertions, + negativeFixtureFailedAsExpected: drill.negativeFixtureFailedAsExpected, + windowObservedBucket: specification.window, + providerVerificationRequired: drill.providerVerificationRequired, + passed, + }; + const artifactDirectory = `artifacts/runbooks/${runbookId}`; + await mkdir(artifactDirectory, { recursive: true }); + await writeFile( + `${artifactDirectory}/record.json`, + `${JSON.stringify(record, null, 2)}\n`, + ); + if (!passed) { + process.stderr.write(`${runbookId} drill failed.\n`); + process.exit(1); + } + process.stdout.write( + `${runbookId} drill: PASS (${specification.gateId}; provider verification ${ + drill.providerVerificationRequired ? "still required" : "not required" + })\n`, + ); +} + +function requireIdentity(value: string | undefined, label: string): string { + if (value === undefined || value.length === 0) { + throw new TypeError(`${label} must be a non-empty string`); + } + return value; +} + +const invokedPath = process.argv[1]; +if ( + invokedPath !== undefined && + import.meta.url === pathToFileURL(invokedPath).href +) { + await main(); } -process.stdout.write( - `${runbookId} drill: PASS (${specification.gateId}; provider verification ${ - drill.providerVerificationRequired ? "still required" : "not required" - })\n`, -); diff --git a/scripts/lib/release-runtime-coherence.ts b/scripts/lib/release-runtime-coherence.ts new file mode 100644 index 0000000..35f103f --- /dev/null +++ b/scripts/lib/release-runtime-coherence.ts @@ -0,0 +1,85 @@ +import { isVersionCompatible } from "../../src/application/policies/compatibility.ts"; +import { verifyContractSet } from "../../src/contracts/contract-set.ts"; +import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts"; +import type { ReleaseArtifact } from "../../src/contracts/release-artifacts.ts"; +import { compareReleaseToRuntime } from "../../src/contracts/release-tokens.ts"; + +export type ReleaseRuntimeCoherenceInput = Readonly<{ + release: ReleaseArtifact; + runtime: Readonly<{ + BUILD_ID: string; + CONFIG_SCHEMA_VERSION: string; + API_CONTRACT_VERSION?: string; + RELEASE_ID: string; + }>; + contractPackages: readonly InstalledContractPackageIdentity[]; +}>; + +export type ReleaseRuntimeCoherence = Readonly<{ + compatible: boolean; + mismatches: readonly string[]; + warnings: readonly string[]; +}>; + +/** + * Verifies the runtime identity using the release schema's own contract model. + * V1 retains the scalar compatibility policy. V2 has no scalar projection: its + * identity is the exact compiled package tuple set and canonical set digest. + */ +export async function verifyReleaseRuntimeCoherence( + input: ReleaseRuntimeCoherenceInput, +): Promise { + if (input.release.schemaVersion === 1) { + if (input.runtime.API_CONTRACT_VERSION === undefined) { + return compareWithoutContractScalar(input, ["apiContractVersion"]); + } + return compareReleaseToRuntime(input.release, { + ...input.runtime, + API_CONTRACT_VERSION: input.runtime.API_CONTRACT_VERSION, + }); + } + + const comparison = compareWithoutContractScalar(input); + const contractSet = await verifyContractSet({ + expected: input.contractPackages, + manifest: input.release.contractSet, + }); + if (contractSet.ok) return comparison; + + const mismatches = Object.freeze([ + ...comparison.mismatches, + contractSet.code, + ]); + return Object.freeze({ + compatible: false, + mismatches, + warnings: comparison.warnings, + }); +} + +function compareWithoutContractScalar( + input: Pick, + initialMismatches: readonly string[] = [], +): ReleaseRuntimeCoherence { + const mismatches = [...initialMismatches]; + if (input.release.buildId !== input.runtime.BUILD_ID) { + mismatches.push("buildId"); + } + if ( + !isVersionCompatible( + input.release.configSchemaVersion, + input.runtime.CONFIG_SCHEMA_VERSION, + ) + ) { + mismatches.push("configSchemaVersion"); + } + const warnings = + input.release.releaseId === input.runtime.RELEASE_ID + ? [] + : ["releaseId"]; + return Object.freeze({ + compatible: mismatches.length === 0, + mismatches: Object.freeze(mismatches), + warnings: Object.freeze(warnings), + }); +} diff --git a/scripts/verify-release.ts b/scripts/verify-release.ts index a5b78cd..a16f913 100644 --- a/scripts/verify-release.ts +++ b/scripts/verify-release.ts @@ -5,9 +5,6 @@ import { verifyCompatibilityTuple, type CompatibilityTuple, } from "../src/application/policies/compatibility.ts"; -import { - compareReleaseToRuntime, -} from "../src/contracts/release-tokens.ts"; import { parseBuildManifestArtifact, parseReleaseArtifact, @@ -17,13 +14,13 @@ import { type ReleaseArtifact, type RuntimeConfigArtifact, } from "../src/contracts/release-artifacts.ts"; -import { verifyContractSet } from "../src/contracts/contract-set.ts"; import { EXPECTED_CONTRACT_SET_PACKAGES } from "../src/features/installed-contract-contributions.ts"; import { ROUTE_REGISTRY, ROUTE_RUNTIME_CONTRACT, } from "../src/features/installed-feature-contracts.ts"; import { assertMatchesJsonSchema } from "./lib/json-schema.ts"; +import { verifyReleaseRuntimeCoherence } from "./lib/release-runtime-coherence.ts"; type CoherenceFixture = Readonly<{ name: string; @@ -71,22 +68,17 @@ const actualAssetManifestHash = createHash("sha256") .update(viteManifest) .digest("hex"); -const artifactComparison = compareReleaseToRuntime(release, runtimeConfig); +const artifactComparison = await verifyReleaseRuntimeCoherence({ + release, + runtime: runtimeConfig, + contractPackages: EXPECTED_CONTRACT_SET_PACKAGES, +}); const artifactMismatches: string[] = [...artifactComparison.mismatches]; for (const [token, value] of Object.entries(projectReleaseTokens(release))) { if (token !== "schemaVersion" && (typeof value !== "string" || value.length === 0)) { artifactMismatches.push(`releaseToken:${token}`); } } -if (release.schemaVersion === 2) { - const contractSetVerification = await verifyContractSet({ - expected: EXPECTED_CONTRACT_SET_PACKAGES, - manifest: release.contractSet, - }); - if (!contractSetVerification.ok) { - artifactMismatches.push(contractSetVerification.code); - } -} if ( typeof release.builtAt !== "string" || !Number.isFinite(Date.parse(release.builtAt)) diff --git a/src/contracts/release-tokens.ts b/src/contracts/release-tokens.ts index 3895402..437db64 100644 --- a/src/contracts/release-tokens.ts +++ b/src/contracts/release-tokens.ts @@ -42,33 +42,23 @@ export function compareReleaseToRuntime( release: Readonly<{ buildId: string; configSchemaVersion: string; - apiContractVersion?: string; + apiContractVersion: string; assetManifestHash: string; releaseId: string; }>, runtimeConfig: Readonly<{ BUILD_ID: string; CONFIG_SCHEMA_VERSION: string; - /** - * §5.1. Removed from Runtime Config V2. When neither side declares it there - * is nothing to disagree about: contract identity is verified by the - * Release Manifest V2 `contractSet` check instead. - */ - API_CONTRACT_VERSION?: string; + API_CONTRACT_VERSION: string; RELEASE_ID: string; }>, ) { - const declaredContractVersion = - runtimeConfig.API_CONTRACT_VERSION ?? release.apiContractVersion ?? "0"; return verifyCompatibilityTuple({ - frontend: { - ...release, - apiContractVersion: release.apiContractVersion ?? declaredContractVersion, - }, + frontend: release, runtime: { buildId: runtimeConfig.BUILD_ID, configSchemaVersion: runtimeConfig.CONFIG_SCHEMA_VERSION, - apiContractVersion: declaredContractVersion, + apiContractVersion: runtimeConfig.API_CONTRACT_VERSION, assetManifestHash: release.assetManifestHash, releaseId: runtimeConfig.RELEASE_ID, }, diff --git a/tests/unit/release-coherence.test.ts b/tests/unit/release-coherence.test.ts index 12e8977..9cad675 100644 --- a/tests/unit/release-coherence.test.ts +++ b/tests/unit/release-coherence.test.ts @@ -1,10 +1,103 @@ import { describe, expect, it } from "vitest"; +import { verifyRollbackReleaseCoherence } from "../../scripts/drill-runbook.ts"; +import { verifyReleaseRuntimeCoherence } from "../../scripts/lib/release-runtime-coherence.ts"; +import type { InstalledContractPackageIdentity } from "../../src/contracts/external-contract-runtime.ts"; +import { computeContractSetDigest } from "../../src/contracts/contract-set-canonical.ts"; +import type { + ReleaseArtifact, + RuntimeConfigArtifact, +} from "../../src/contracts/release-artifacts.ts"; import { compareReleaseToRuntime, RELEASE_TOKEN_REGISTRY, } from "../../src/contracts/release-tokens.ts"; +const contractPackages = [ + { + packageId: "@example/accounts", + version: "1.2.3", + digest: `sha256:${"1".repeat(64)}`, + runtimeProtocolVersion: 1, + sourceRevision: "a".repeat(40), + }, + { + packageId: "@example/billing", + version: "2.3.4", + digest: `sha256:${"2".repeat(64)}`, + runtimeProtocolVersion: 1, + sourceRevision: "b".repeat(40), + }, +] as const satisfies readonly InstalledContractPackageIdentity[]; + +const releaseV1 = { + schemaVersion: 1, + appVersion: "1.0.0", + buildId: "build-a", + commitSha: "abc1234", + configSchemaVersion: "1", + apiContractVersion: "1.4.0", + assetManifestHash: "assets-a", + releaseId: "release-a", + builtAt: "2026-08-01T00:00:00.000Z", + routeChunks: {}, +} as const satisfies ReleaseArtifact; + +const runtimeV1 = { + APP_ENV: "local", + API_BASE_URL: "http://localhost:8080/", + REQUEST_TIMEOUT_MS: 10_000, + MAX_RETRY_ATTEMPTS: 2, + TELEMETRY_ENABLED: false, + AUTH_MODE: "external", + CONFIG_SCHEMA_VERSION: "1", + API_CONTRACT_VERSION: "1.6.0", + RELEASE_MANIFEST_URL: "/release-manifest.json", + BUILD_ID: "build-a", + RELEASE_ID: "release-a", +} as const satisfies RuntimeConfigArtifact; + +const runtimeV2 = { + APP_ENV: "local", + API_BASE_URL: "http://localhost:8080/", + REQUEST_TIMEOUT_MS: 10_000, + MAX_RETRY_ATTEMPTS: 2, + TELEMETRY_ENABLED: false, + AUTH_MODE: "external", + CONFIG_SCHEMA_VERSION: "2.0", + RELEASE_MANIFEST_URL: "/release-manifest.json", + BUILD_ID: "build-a", + RELEASE_ID: "release-a", + CAPABILITY_OVERRIDES: { + REALTIME: "DEFAULT", + WEB_WORKER: "DEFAULT", + SERVICE_WORKER: "DEFAULT", + OFFLINE_COMMANDS: "DEFAULT", + }, +} as const satisfies RuntimeConfigArtifact; + +async function releaseV2With( + packages: readonly InstalledContractPackageIdentity[], + setDigest?: `sha256:${string}`, +): Promise { + return { + schemaVersion: 2, + appVersion: "1.0.0", + buildId: "build-a", + commitSha: "abc1234", + configSchemaVersion: "2.0", + assetManifestHash: "assets-a", + releaseId: "release-a", + builtAt: "2026-08-01T00:00:00.000Z", + routeChunks: {}, + contractSet: { + setAlgorithm: "CA_CONTRACT_SET_V1", + setDigest: setDigest ?? (await computeContractSetDigest(packages)), + packages: [...packages], + }, + }; +} + describe("release coherence", () => { it("owns all nine release tokens and keeps builtAt diagnostic-only", () => { // §5.2 adds contractSetDigest beside the legacy apiContractVersion scalar. @@ -55,4 +148,112 @@ describe("release coherence", () => { mismatches: ["buildId", "configSchemaVersion", "apiContractVersion"], }); }); + + it("gives release verification and rollback drills the same V1/V2 tamper verdicts", async () => { + const packageAdded = [ + ...contractPackages, + { + packageId: "@example/notifications", + version: "3.0.0", + digest: `sha256:${"3".repeat(64)}`, + runtimeProtocolVersion: 1, + sourceRevision: "c".repeat(40), + }, + ] as const satisfies readonly InstalledContractPackageIdentity[]; + const packageRemoved = contractPackages.slice(0, 1); + const versionChanged = [ + { ...contractPackages[0], version: "1.2.4" }, + contractPackages[1], + ] as const satisfies readonly InstalledContractPackageIdentity[]; + const packageDigestChanged = [ + { + ...contractPackages[0], + digest: `sha256:${"f".repeat(64)}`, + }, + contractPackages[1], + ] as const satisfies readonly InstalledContractPackageIdentity[]; + const exactV2 = await releaseV2With(contractPackages); + const matrix = [ + { + name: "V1 scalar success", + release: releaseV1, + runtime: runtimeV1, + expectedCompatible: true, + }, + { + name: "V1 scalar mismatch", + release: releaseV1, + runtime: { ...runtimeV1, API_CONTRACT_VERSION: "2.0.0" }, + expectedCompatible: false, + }, + { + name: "V2 exact package set", + release: exactV2, + runtime: runtimeV2, + expectedCompatible: true, + }, + { + name: "V2 exact package set in non-canonical manifest order", + release: await releaseV2With([...contractPackages].reverse()), + runtime: runtimeV2, + expectedCompatible: true, + }, + { + name: "V2 package added", + release: await releaseV2With(packageAdded), + runtime: runtimeV2, + expectedCompatible: false, + }, + { + name: "V2 package removed", + release: await releaseV2With(packageRemoved), + runtime: runtimeV2, + expectedCompatible: false, + }, + { + name: "V2 package version tampered", + release: await releaseV2With(versionChanged), + runtime: runtimeV2, + expectedCompatible: false, + }, + { + name: "V2 package digest tampered", + release: await releaseV2With(packageDigestChanged), + runtime: runtimeV2, + expectedCompatible: false, + }, + { + name: "V2 set digest tampered", + release: await releaseV2With( + contractPackages, + `sha256:${"0".repeat(64)}`, + ), + runtime: runtimeV2, + expectedCompatible: false, + }, + ]; + + for (const fixture of matrix) { + const input = { + release: fixture.release, + runtime: fixture.runtime, + contractPackages, + }; + const verifierVerdict = ( + await verifyReleaseRuntimeCoherence(input) + ).compatible; + const rollbackDrillVerdict = ( + await verifyRollbackReleaseCoherence(input) + ).compatible; + expect(verifierVerdict, `${fixture.name}: verifier`).toBe( + fixture.expectedCompatible, + ); + expect(rollbackDrillVerdict, `${fixture.name}: rollback drill`).toBe( + fixture.expectedCompatible, + ); + expect(rollbackDrillVerdict, `${fixture.name}: identical verdict`).toBe( + verifierVerdict, + ); + } + }); });