diff --git a/src/contracts/reconstructionReadinessExample.ts b/src/contracts/reconstructionReadinessExample.ts index 35e2860079..b96f5dcbf8 100644 --- a/src/contracts/reconstructionReadinessExample.ts +++ b/src/contracts/reconstructionReadinessExample.ts @@ -1,15 +1,15 @@ import { createEvidence } from "../domain/evidence.js"; import { createEvidenceBundle } from "../domain/evidenceBundle.js"; -import { - READINESS_REQUIRED_CHECKS, - READINESS_STAGE_IDS, -} from "../domain/reconstructionReadinessJourneyFindings.js"; +import { READINESS_REQUIRED_CHECKS } from "../domain/reconstructionReadinessJourneyFindings.js"; import { reconstructionObligationLedgerClosureDigest, summarizeReconstructionObligations, } from "../domain/reconstructionObligationLedgerIntegrity.js"; import type { ReconstructionObligationLedger } from "../domain/reconstructionObligationLedgerSchemas.js"; -import type { ReconstructionReadinessInput } from "../domain/reconstructionReadinessSchemas.js"; +import { + readinessStageIdSchema, + type ReconstructionReadinessInput, +} from "../domain/reconstructionReadinessSchemas.js"; const HASH = "a".repeat(64); const sourceEvidence = (variant: string) => @@ -397,7 +397,7 @@ export const RECONSTRUCTION_READINESS_EXAMPLE: ReconstructionReadinessInput = { stale_input_detected: true, evidence_ids: [EVIDENCE_ID], }, - stages: READINESS_STAGE_IDS.map((stageId) => ({ + stages: readinessStageIdSchema.options.map((stageId) => ({ stage_id: stageId, required: true, status: "pass", diff --git a/src/domain/reconstructionReadiness.test.ts b/src/domain/reconstructionReadiness.test.ts index 2e53f1e688..bc7ef0c349 100644 --- a/src/domain/reconstructionReadiness.test.ts +++ b/src/domain/reconstructionReadiness.test.ts @@ -9,6 +9,7 @@ import { } from "./reconstructionObligationLedgerIntegrity.js"; import { reconstructionObligationLedgerSchema } from "./reconstructionObligationLedgerSchemas.js"; import { + reconstructionReadinessReportSchema, reconstructionReadinessInputSchema, type ReconstructionReadinessInput, } from "./reconstructionReadinessSchemas.js"; @@ -181,6 +182,31 @@ describe("readiness obligation ledger integrity", () => { ); }); +describe("readiness stage identity", () => { + it.each(["input", "report"] as const)( + "rejects repeated stage IDs in the %s contract", + (kind) => { + const value = + kind === "input" + ? input() + : createReconstructionReadinessReport(input()); + value.stages[1] = { ...value.stages[0]! }; + const schema = + kind === "input" + ? reconstructionReadinessInputSchema + : reconstructionReadinessReportSchema; + const parsed = schema.safeParse(value); + expect(parsed.success).toBe(false); + if (parsed.success) throw new Error("Duplicate stage ID was accepted"); + expect(parsed.error.issues).toContainEqual({ + code: "custom", + path: ["stages", 1, "stage_id"], + message: "Readiness stage IDs must be unique", + }); + }, + ); +}); + describe("readiness missing Evidence references", () => { it("reports each absent Evidence ID once per stage across stage and check references", () => { const value = input(); diff --git a/src/domain/reconstructionReadiness.ts b/src/domain/reconstructionReadiness.ts index 13a51e67c4..26228108ba 100644 --- a/src/domain/reconstructionReadiness.ts +++ b/src/domain/reconstructionReadiness.ts @@ -1,12 +1,10 @@ import { digestCanonicalValue } from "./canonicalDigest.js"; import { authenticateParsedEvidenceBundle } from "./evidenceBundle.js"; import { readinessFindingOrder } from "./reconstructionReadinessFindingHelpers.js"; -import { - READINESS_STAGE_IDS, - readinessJourneyFindings, -} from "./reconstructionReadinessJourneyFindings.js"; +import { readinessJourneyFindings } from "./reconstructionReadinessJourneyFindings.js"; import { readinessIntegrityFindings } from "./reconstructionReadinessIntegrityFindings.js"; import { + readinessStageIdSchema, reconstructionReadinessInputSchema, reconstructionReadinessReportSchema, type ReadinessFinding, @@ -71,7 +69,7 @@ const effectiveStages = ( input: ReconstructionReadinessInput, findings: readonly ReadinessFinding[], ): ReconstructionReadinessReport["stages"] => - READINESS_STAGE_IDS.map((stageId) => { + readinessStageIdSchema.options.map((stageId) => { const stage = input.stages.find(({ stage_id: id }) => id === stageId); const stageFindings = findings.filter(({ stage_id: id }) => id === stageId); const status = stageFindings.some( diff --git a/src/domain/reconstructionReadinessJourneyFindings.ts b/src/domain/reconstructionReadinessJourneyFindings.ts index 4b8a908efc..78e11655d0 100644 --- a/src/domain/reconstructionReadinessJourneyFindings.ts +++ b/src/domain/reconstructionReadinessJourneyFindings.ts @@ -64,8 +64,6 @@ export const READINESS_REQUIRED_CHECKS: Readonly< ], }; -export const READINESS_STAGE_IDS = readinessStageIdSchema.options; - export const readinessJourneyFindings = ( input: ReconstructionReadinessInput, evidenceIds: ReadonlySet, @@ -142,7 +140,7 @@ const stageFindings = ( evidenceIds: ReadonlySet, ): ReadinessFinding[] => { const findings: ReadinessFinding[] = []; - for (const stageId of READINESS_STAGE_IDS) { + for (const stageId of readinessStageIdSchema.options) { const matching = input.stages.filter(({ stage_id: id }) => id === stageId); if (matching.length !== 1) { findings.push( diff --git a/src/domain/reconstructionReadinessSchemas.ts b/src/domain/reconstructionReadinessSchemas.ts index d79dfcaf73..00ef598b4f 100644 --- a/src/domain/reconstructionReadinessSchemas.ts +++ b/src/domain/reconstructionReadinessSchemas.ts @@ -4,8 +4,7 @@ import { STABLE_IDENTIFIER_PATTERN } from "./stringPatterns.js"; import { evidenceBundleSchema } from "./evidenceBundle.js"; import { reconstructionObligationLedgerSchema } from "./reconstructionObligationLedgerSchemas.js"; -import { digestSchema } from "./../domain/digests.js"; -import { prefixedDigestSchema } from "./../domain/digests.js"; +import { digestSchema, prefixedDigestSchema } from "./digests.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const stableIdSchema = z.string().regex(STABLE_IDENTIFIER_PATTERN); @@ -185,6 +184,22 @@ const stageSchema = z.strictObject({ checks: z.array(stageCheckSchema), }); +const readinessStagesSchema = z + .array(stageSchema) + .length(readinessStageIdSchema.options.length) + .superRefine((stages, context) => { + const seen = new Set(); + stages.forEach((stage, index) => { + if (seen.has(stage.stage_id)) + context.addIssue({ + code: "custom", + path: [index, "stage_id"], + message: "Readiness stage IDs must be unique", + }); + seen.add(stage.stage_id); + }); + }); + const replaySchema = z.strictObject({ expected_source_digest: digestSchema.nullable(), deterministic: z.boolean(), @@ -220,21 +235,7 @@ export const reconstructionReadinessInputSchema = z.strictObject({ closure_history: z.array(closureObservationSchema).min(2), delegation_checks: z.array(delegationCheckSchema).min(1), replay: replaySchema, - stages: z - .array(stageSchema) - .length(9) - .superRefine((stages, context) => { - const seen = new Set(); - stages.forEach((stage, index) => { - if (seen.has(stage.stage_id)) - context.addIssue({ - code: "custom", - path: [index, "stage_id"], - message: "Readiness stage IDs must be unique", - }); - seen.add(stage.stage_id); - }); - }), + stages: readinessStagesSchema, }); const findingSchema = z.strictObject({ @@ -271,21 +272,7 @@ export const reconstructionReadinessReportSchema = z.strictObject({ cleanup_leak_count: z.number().int().min(0), nondeterministic_replay_count: z.number().int().min(0), }), - stages: z - .array(stageSchema) - .length(9) - .superRefine((stages, context) => { - const seen = new Set(); - stages.forEach((stage, index) => { - if (seen.has(stage.stage_id)) - context.addIssue({ - code: "custom", - path: [index, "stage_id"], - message: "Readiness stage IDs must be unique", - }); - seen.add(stage.stage_id); - }); - }), + stages: readinessStagesSchema, findings: z.array(findingSchema), evidence_links: z.array(evidenceIdSchema), snapshot: reconstructionReadinessInputSchema,