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 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-21 08:05:30 -07:00
1 parent d4c637f4cf
commit dc53c11f6d
5 files changed
+121 -30

No files matched your search

@@ -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", () => {
@@ -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";
@@ -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", () => {
+6 -15
View File
@@ -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));
@@ -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}`,
),
});
});