diff --git a/src/application/ReconstructionObligationCandidates.test.ts b/src/application/ReconstructionObligationCandidates.test.ts index 2d01d9c7ee..9cde9c4bb7 100644 --- a/src/application/ReconstructionObligationCandidates.test.ts +++ b/src/application/ReconstructionObligationCandidates.test.ts @@ -11,6 +11,8 @@ import { describe, expect, it } from "vitest"; import { createEvidence } from "../domain/evidence.js"; import { createEvidenceBundle } from "../domain/evidenceBundle.js"; import { emptyUnverifiedProcessCapture } from "../domain/process/processCapture.fixture.js"; +import { FINALIZED_PROCESS_CAPTURE_EXAMPLE } from "../domain/process/processCaptureExample.js"; +import { PROCESS_PROVIDER } from "../domain/process/processEvidenceProvider.js"; import { parseProcessCapture } from "../domain/process/processCaptureParsing.js"; import { digestProcessCommitment, @@ -178,6 +180,40 @@ describe("reconstruction obligation candidates", () => { `Process capture ${evidence.evidence_id} has no validated executable artifact identity; packaged-process lifecycle obligation was not bound to an artifact.`, ); }); + + it("names the first rejected capture field and the remaining issue count", () => { + const { + manifest: { completed_at: _completedAt, ...manifest }, + exit, + ...rest + } = FINALIZED_PROCESS_CAPTURE_EXAMPLE; + const evidence = createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse({ + ...rest, + manifest, + exit: { + ...exit, + finalization: { + ...exit.finalization, + signals: [ + { ...exit.finalization.signals[0], reason: "not allowed here" }, + ], + }, + }, + }), + }); + const generated = deriveReconstructionObligationCandidates( + createEvidenceBundle([evidence]), + [], + ); + + expect(generated.limitations).toEqual([ + `Process Evidence ${evidence.evidence_id} could not generate candidates: at manifest.completed_at: Invalid input: expected string, received undefined (1 more issues)`, + ]); + }); }); it("keeps each semantic candidate bound to its own file and authority context", () => { diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index 45d3cbcec7..fb27bdf60f 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -4,6 +4,7 @@ import canonicalize from "canonicalize"; import type { Evidence } from "../domain/evidence.js"; import { + describeValidationFailure, parseEvidenceBundle, type EvidenceBundle, } from "../domain/evidenceBundle.js"; @@ -166,7 +167,7 @@ const deriveApplicationCandidates = ( ); } catch (cause: unknown) { limitations.add( - `Application Evidence ${evidence.evidence_id} could not generate candidates: ${errorMessage(cause)}`, + `Application Evidence ${evidence.evidence_id} could not generate candidates: ${describeValidationFailure(cause)}`, ); } }; @@ -258,7 +259,7 @@ const deriveProcessCandidate = ( ); } catch (cause: unknown) { limitations.add( - `Process Evidence ${evidence.evidence_id} could not generate candidates: ${errorMessage(cause)}`, + `Process Evidence ${evidence.evidence_id} could not generate candidates: ${describeValidationFailure(cause)}`, ); } }; @@ -464,6 +465,3 @@ const digest = (value: unknown): string => { throw new TypeError("Reconstruction obligation key is not canonical JSON"); return createHash("sha256").update(encoded).digest("hex"); }; - -const errorMessage = (cause: unknown): string => - cause instanceof Error ? cause.message : "unknown validation failure"; diff --git a/src/domain/process/processCapture.validation.test.ts b/src/domain/process/processCapture.validation.test.ts index ac2e9a7ce7..08749de1f2 100644 --- a/src/domain/process/processCapture.validation.test.ts +++ b/src/domain/process/processCapture.validation.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "vitest"; +import { z } from "zod"; import { digestProcessCommitment } from "./processScenario.js"; import { parseProcessCapture } from "./processCaptureParsing.js"; @@ -12,6 +13,18 @@ import { processCaptureIssues, } from "./processCapture.fixture.js"; +/** Name every rejected field reported by the production parser. */ +const rejectedPaths = (input: unknown): readonly string[] => { + try { + parseProcessCapture(input); + } catch (cause: unknown) { + if (cause instanceof z.ZodError) + return cause.issues.map(({ path }) => path.join(".")); + throw cause; + } + throw new Error("Process capture was accepted"); +}; + const captureWithEnumerationOmission = ( omission: FilesystemCoverage["enumeration_omissions"][number], ) => { @@ -71,8 +84,8 @@ it("requires interrupted sampling diagnostics to agree with partial and aggregat truncated: false, }), ).toThrow("truncation_details"); - expect(() => - parseProcessCapture({ + expect( + rejectedPaths({ ...interrupted, truncation_details: { ...interrupted.truncation_details, @@ -82,9 +95,9 @@ it("requires interrupted sampling diagnostics to agree with partial and aggregat }, }, }), - ).toThrow("truncation_details.process"); - expect(() => - parseProcessCapture({ + ).toContain("truncation_details.process"); + expect( + rejectedPaths({ ...interrupted, truncation_details: { ...interrupted.truncation_details, @@ -94,7 +107,7 @@ it("requires interrupted sampling diagnostics to agree with partial and aggregat }, }, }), - ).toThrow("truncation_details.process"); + ).toContain("truncation_details.process"); }); it("limits interruption impact to process comparison", () => { @@ -220,8 +233,8 @@ it("does not infer normalized retention budget from missing scenario defaults", filesystem_depth: 16, }, }; - expect(() => - parseProcessCapture({ + expect( + rejectedPaths({ ...capture, manifest: { ...capture.manifest, @@ -237,7 +250,7 @@ it("does not infer normalized retention budget from missing scenario defaults", }, }, }), - ).toThrow("manifest.scenario.limits.output_bytes"); + ).toContain("manifest.scenario.limits.output_bytes"); }); it("rejects settlement and cleanup combinations that cannot occur", () => { diff --git a/src/domain/process/processCaptureParsing.ts b/src/domain/process/processCaptureParsing.ts index dda070e590..90f3cc6f50 100644 --- a/src/domain/process/processCaptureParsing.ts +++ b/src/domain/process/processCaptureParsing.ts @@ -22,18 +22,9 @@ const attachProof = (capture: UnverifiedProcessCapture): ProcessCapture => { throw new TypeError("Process Capture proof ownership failed"); }; -/** Parse unknown input and reject invalid commitments or semantics. */ -export const parseProcessCapture = (input: unknown): ProcessCapture => { - const parsed = processCaptureSchema.safeParse(input); - if (parsed.success) return attachProof(parsed.data); - const issues = parsed.error.issues.flatMap((issue) => - issue.code === "custom" - ? [{ path: issue.path.join("."), message: issue.message }] - : [], - ); - if (issues.length > 0) - throw new TypeError( - `Invalid process capture: ${issues.map(({ path, message }) => `${path}: ${message}`).join("; ")}`, - ); - throw parsed.error; -}; +/** + * Parse unknown input and reject invalid commitments or semantics. Failures + * throw the schema's ZodError so structural and semantic issues stay together. + */ +export const parseProcessCapture = (input: unknown): ProcessCapture => + attachProof(processCaptureSchema.parse(input)); diff --git a/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts b/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts index f8725a68f0..cff7fda608 100644 --- a/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts +++ b/tests/boundary/mcp/processComparisonDiagnosticsMcp.test.ts @@ -7,7 +7,10 @@ import { expect, it, onTestFinished } from "vitest"; import { compareProcessEvidenceFiles } from "../../../src/application/process/ProcessCli.js"; import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; -import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; +import { + EMPTY_PROCESS_CAPTURE_EXAMPLE, + FINALIZED_PROCESS_CAPTURE_EXAMPLE, +} from "../../../src/domain/process/processCaptureExample.js"; import { capture, partialSpecification, @@ -519,3 +522,53 @@ it("names the side and constraint when Evidence is not a usable capture", async message: expect.stringContaining("Capture evidence is malformed (at "), }); }); + +it("keeps structural capture issues alongside semantic ones through MCP and the CLI", async () => { + const client = await connect(); + const capture = captureEvidence("left"); + const { + manifest: { completed_at: _completedAt, ...manifest }, + exit, + ...rest + } = FINALIZED_PROCESS_CAPTURE_EXAMPLE; + const mixed = createEvidence(undefined, PROCESS_PROVIDER, { + predicateType: "rea.process-capture", + operation: "capture_process_scenario", + parameters: {}, + result: jsonValueSchema.parse({ + ...rest, + manifest, + exit: { + ...exit, + finalization: { + ...exit.finalization, + signals: [ + { ...exit.finalization.signals[0], reason: "not allowed here" }, + ], + }, + }, + }), + }); + const described = + "(at manifest.completed_at: Invalid input: expected string, received undefined (1 more issues))"; + + const response = await client.callTool({ + name: "compare_process_captures", + arguments: { left: capture, right: mixed }, + }); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + code: "evidence_integrity_mismatch", + message: expect.stringContaining( + `The right Evidence has an invalid process capture result ${described}`, + ), + }, + }); + expect(await compareViaCli(capture, mixed)).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + message: expect.stringContaining( + `Capture evidence is malformed ${described}`, + ), + }); +});