From dc53c11f6d68790a0046159be59f5bdd839ffcf4 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 21 Oct 2026 08:05:30 -0700 Subject: [PATCH] fix(process): keep structural capture issues alongside semantic ones (#6020) parseProcessCapture re-threw only the custom (semantic) schema issues as a TypeError whenever any existed, discarding structural issues found in the same parse. Throw the schema's ZodError instead so every issue is retained and describeValidationFailure reports the first issue with the remaining count. Reconstruction obligation candidates now use that same formatter instead of the raw error message. Fixes #5957 Co-authored-by: Claude Opus 5.5 --- ...ReconstructionObligationCandidates.test.ts | 36 ++++++++++++ .../ReconstructionObligationCandidates.ts | 8 +-- .../process/processCapture.validation.test.ts | 31 ++++++++--- src/domain/process/processCaptureParsing.ts | 21 ++----- .../processComparisonDiagnosticsMcp.test.ts | 55 ++++++++++++++++++- 5 files changed, 121 insertions(+), 30 deletions(-) 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}`, + ), + }); +});