From db2a3174f6f41d5fbec286b1a227df1abc383863 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:15:38 +0800 Subject: [PATCH] fix(reconstruction): share authenticated obligation ledger sources --- ...ctionObligationCandidates.semantic.test.ts | 12 +- ...ReconstructionObligationCandidates.test.ts | 22 +- .../ReconstructionObligationCandidates.ts | 375 +++++++++--------- ...nstructionObligationLedger.closure.test.ts | 15 +- .../ReconstructionObligationLedger.fixture.ts | 38 +- ...econstructionObligationLedgerEvaluation.ts | 26 +- .../ReconstructionObligationLedgerRequest.ts | 75 ++++ .../ReconstructionObligationLedgerService.ts | 109 ++--- src/application/workflowInputError.ts | 26 +- src/cli/applicationCommands.ts | 11 +- .../obligationLedger.ts | 15 +- .../reconstructionObligationLedgerCli.test.ts | 15 +- .../reconstructionObligationLedgerMcp.test.ts | 62 ++- .../derivedOperationFailures.test.ts | 82 +++- 14 files changed, 467 insertions(+), 416 deletions(-) create mode 100644 src/application/ReconstructionObligationLedgerRequest.ts diff --git a/src/application/ReconstructionObligationCandidates.semantic.test.ts b/src/application/ReconstructionObligationCandidates.semantic.test.ts index 81362c87b4..a56652033f 100644 --- a/src/application/ReconstructionObligationCandidates.semantic.test.ts +++ b/src/application/ReconstructionObligationCandidates.semantic.test.ts @@ -12,8 +12,11 @@ import { createJavaScriptSemanticGraphUnknown, JavaScriptSemanticEvidenceContextRegistry, } from "../domain/javascript/javascriptSemanticGraph.js"; -import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; -import { build, request } from "./ReconstructionObligationLedger.fixture.js"; +import { + build, + deriveCandidates, + request, +} from "./ReconstructionObligationLedger.fixture.js"; const semanticApplication = ( frontiers: readonly { @@ -162,10 +165,7 @@ describe("semantic uncertainty at the obligation candidate boundary", () => { ), ].sort(); for (const order of [records, [...records].reverse()]) { - const generated = deriveReconstructionObligationCandidates( - createEvidenceBundle(order), - [], - ); + const generated = deriveCandidates(createEvidenceBundle(order), []); expect(generated.candidates).toHaveLength(1); expect(generated.candidates[0]).toMatchObject({ definitionCount: 1, diff --git a/src/application/ReconstructionObligationCandidates.test.ts b/src/application/ReconstructionObligationCandidates.test.ts index 89f9714da8..78c528876a 100644 --- a/src/application/ReconstructionObligationCandidates.test.ts +++ b/src/application/ReconstructionObligationCandidates.test.ts @@ -5,7 +5,10 @@ import { createJavaScriptSemanticGraphNode, JavaScriptSemanticEvidenceContextRegistry, } from "../domain/javascript/javascriptSemanticGraph.js"; -import { proofEvidence } from "./ReconstructionObligationLedger.fixture.js"; +import { + deriveCandidates, + proofEvidence, +} from "./ReconstructionObligationLedger.fixture.js"; import { describe, expect, it } from "vitest"; import { createEvidence } from "../domain/evidence.js"; @@ -33,7 +36,6 @@ import { import { jsonValueSchema } from "../domain/jsonValue.js"; import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js"; import { createProcessCaptureEvidence } from "./process/ProcessEvidence.js"; -import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; const applicationWithUnknowns = ( keys: readonly string[], @@ -119,7 +121,7 @@ const applicationWithUnknowns = ( return { graph, unknownNodes, - generated: deriveReconstructionObligationCandidates( + generated: deriveCandidates( createEvidenceBundle([source, application]), [], ), @@ -192,10 +194,7 @@ describe("reconstruction obligation candidates", () => { }, }); const evidence = createProcessCaptureEvidence(scenario, capture); - const generated = deriveReconstructionObligationCandidates( - createEvidenceBundle([evidence]), - [], - ); + const generated = deriveCandidates(createEvidenceBundle([evidence]), []); expect(generated.candidates).toEqual([]); expect(generated.limitations).toContain( @@ -227,10 +226,7 @@ describe("reconstruction obligation candidates", () => { }, }), }); - const generated = deriveReconstructionObligationCandidates( - createEvidenceBundle([evidence]), - [], - ); + const generated = deriveCandidates(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)`, @@ -318,7 +314,7 @@ it("keeps each semantic candidate bound to its own file and authority context", evidenceLinks: sourceRecords.map(({ evidence_id }) => evidence_id), }, ); - const generated = deriveReconstructionObligationCandidates( + const generated = deriveCandidates( createEvidenceBundle([...sourceRecords, application]), [], ); @@ -425,7 +421,7 @@ it("falls back to a kind-based title when a semantic node label is blank", () => evidenceLinks: [source.evidence_id], }, ); - const generated = deriveReconstructionObligationCandidates( + const generated = deriveCandidates( createEvidenceBundle([source, application]), [], ); diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index a6bae5d1d2..2ae0f654d2 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -3,11 +3,11 @@ import { digestCanonicalValue } from "../domain/canonicalDigest.js"; import type { Evidence } from "../domain/evidence.js"; import { describeValidationFailure } from "../domain/validationFailure.js"; +import { z } from "zod"; import { - parseEvidenceBundle, - type EvidenceBundle, -} from "../domain/evidenceBundle.js"; -import { parseProcessCapture } from "../domain/process/processCaptureParsing.js"; + parseProcessCapture, + type ProcessCapture, +} from "../domain/process/processCaptureParsing.js"; import type { ReconstructionObligation, ReviewedReconstructionObligation, @@ -18,6 +18,7 @@ import { semanticObligationPolicy, type ReconstructionObligationCandidatePolicy, } from "./ReconstructionObligationPolicies.js"; +import type { ResolvedReconstructionObligationRequest } from "./ReconstructionObligationLedgerRequest.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; type CandidateFields = Omit< @@ -36,30 +37,21 @@ export interface ReconstructionObligationCandidateSet { readonly limitations: readonly string[]; } -/** Caller input cites review Evidence that its bundle does not contain. */ -export class MissingReviewEvidenceError extends TypeError { - constructor(evidenceId: string) { - super(`Reviewed obligation Evidence is missing: ${evidenceId}`); - this.name = "MissingReviewEvidenceError"; - } -} - /** Derive conservative candidates from graph, process, and reviewed inputs. */ export const deriveReconstructionObligationCandidates = ( - bundleInput: EvidenceBundle, - reviewed: readonly ReviewedReconstructionObligation[], + request: Pick< + ResolvedReconstructionObligationRequest, + "sources" | "reviewedObligations" + >, ): ReconstructionObligationCandidateSet => { - const bundle = parseEvidenceBundle(bundleInput); - const records = new Map( - bundle.records.map((record) => [record.evidence_id, record]), - ); + const { bundle, records } = request.sources; const candidates = new Map(); const limitations = new Set(); for (const evidence of bundle.records) { deriveApplicationCandidates(evidence, records, candidates, limitations); deriveProcessCandidate(evidence, candidates, limitations); } - for (const obligation of reviewed) + for (const obligation of request.reviewedObligations) addCandidate(candidates, reviewedCandidate(obligation, records)); return { candidates: [...candidates.values()].sort((left, right) => @@ -83,115 +75,109 @@ const deriveApplicationCandidates = ( ].includes(evidence.operation) ) return; - try { - const parsed = parseApplicationGraphEvidence(evidence); - if (!parsed.ok) { - limitations.add( - `Application Evidence ${evidence.evidence_id} could not generate candidates because it failed graph validation.`, - ); - return; + const parsed = parseApplicationGraphEvidence(evidence); + if (!parsed.ok) { + limitations.add( + `Application Evidence ${evidence.evidence_id} could not generate candidates because it failed graph validation.`, + ); + return; + } + const source = parsed.value; + for (const node of source.graph.nodes) { + const candidatePolicy = applicationObligationPolicy(node.kind); + if (candidatePolicy === undefined) { + if (node.kind === "unknown") + limitations.add( + `Application graph ${source.graph.graph_id} contains unresolved node ${node.node_id}; reconstruction obligations remain unknown.`, + ); + continue; } - const source = parsed.value; - for (const node of source.graph.nodes) { - const candidatePolicy = applicationObligationPolicy(node.kind); - if (candidatePolicy === undefined) { - if (node.kind === "unknown") - limitations.add( - `Application graph ${source.graph.graph_id} contains unresolved node ${node.node_id}; reconstruction obligations remain unknown.`, - ); - continue; + const reference = node.observations.map((observation) => + graphReference( + evidence, + records, + observation.evidence, + `${source.graph.graph_id}/node/${node.node_id}/observation/${observation.observation_id}`, + ), + ); + addCandidate( + candidates, + generatedCandidate({ + key: `application:${node.node_id}:${candidatePolicy.family}`, + title: node.observations[0]?.label?.trim() || `${node.kind} boundary`, + policy: candidatePolicy, + artifactSha256: source.rootArtifactSha256, + applicationNodeId: node.node_id, + semanticNodeId: null, + location: `application-graph:${source.graph.graph_id}/nodes/${node.node_id}`, + authorityReferences: reference, + }), + ); + } + if (source.semanticGraph !== null) { + const semanticGraph = source.semanticGraph; + const unknownIdsByNode = new Map(); + for (const unknown of semanticGraph.unknowns) { + // Candidate targets are possibilities, not established node bindings. + if (unknown.node_id !== null) { + const ids = unknownIdsByNode.get(unknown.node_id) ?? []; + ids.push(unknown.unknown_id); + unknownIdsByNode.set(unknown.node_id, ids); } - const reference = node.observations.map((observation) => - graphReference( - evidence, - records, - observation.evidence, - `${source.graph.graph_id}/node/${node.node_id}/observation/${observation.observation_id}`, - ), + // Keep every frontier visible, including families without a policy. + limitations.add( + `Semantic graph ${semanticGraph.graph_id} contains unresolved ${unknown.family} unknown ${unknown.unknown_id}: ${unknown.detail}`, ); + } + if (semanticGraph.coverage.status !== "complete") + limitations.add( + `Semantic graph ${semanticGraph.graph_id} coverage is ${semanticGraph.coverage.status}; omitted candidates remain unknown.`, + ); + for (const limitation of semanticGraph.limitations) + limitations.add( + `Semantic graph ${semanticGraph.graph_id}: ${limitation}`, + ); + const evidenceContexts = new Map( + semanticGraph.evidence_contexts.map((context) => [ + context.context_id, + context, + ]), + ); + for (const node of semanticGraph.nodes) { + const candidatePolicy = semanticObligationPolicy(node.kind); + if (candidatePolicy === undefined) continue; + const evidenceContext = evidenceContexts.get(node.evidence.context_id); + if (evidenceContext === undefined) + throw new TypeError( + "Semantic evidence reference names an absent context", + ); addCandidate( candidates, generatedCandidate({ - key: `application:${node.node_id}:${candidatePolicy.family}`, - title: node.observations[0]?.label?.trim() || `${node.kind} boundary`, + key: `semantic:${node.node_id}:${candidatePolicy.family}`, + title: node.label?.trim() || `${node.kind} semantic boundary`, policy: candidatePolicy, - artifactSha256: source.rootArtifactSha256, - applicationNodeId: node.node_id, - semanticNodeId: null, - location: `application-graph:${source.graph.graph_id}/nodes/${node.node_id}`, - authorityReferences: reference, + artifactSha256: node.identity.artifact_sha256, + applicationNodeId: node.application_node_ids[0] ?? null, + semanticNodeId: node.node_id, + residualUnknownIds: unknownIdsByNode.get(node.node_id) ?? [], + location: `semantic-graph:${semanticGraph.graph_id}/nodes/${node.node_id}`, + authorityReferences: [ + graphReference( + evidence, + records, + evidenceContext, + `${semanticGraph.graph_id}/node/${node.node_id}`, + ), + ], }), ); } - if (source.semanticGraph !== null) { - const semanticGraph = source.semanticGraph; - const unknownIdsByNode = new Map(); - for (const unknown of semanticGraph.unknowns) { - // Candidate targets are possibilities, not established node bindings. - if (unknown.node_id !== null) { - const ids = unknownIdsByNode.get(unknown.node_id) ?? []; - ids.push(unknown.unknown_id); - unknownIdsByNode.set(unknown.node_id, ids); - } - // Keep every frontier visible, including families without a policy. - limitations.add( - `Semantic graph ${semanticGraph.graph_id} contains unresolved ${unknown.family} unknown ${unknown.unknown_id}: ${unknown.detail}`, - ); - } - if (semanticGraph.coverage.status !== "complete") - limitations.add( - `Semantic graph ${semanticGraph.graph_id} coverage is ${semanticGraph.coverage.status}; omitted candidates remain unknown.`, - ); - for (const limitation of semanticGraph.limitations) - limitations.add( - `Semantic graph ${semanticGraph.graph_id}: ${limitation}`, - ); - const evidenceContexts = new Map( - semanticGraph.evidence_contexts.map((context) => [ - context.context_id, - context, - ]), - ); - for (const node of semanticGraph.nodes) { - const candidatePolicy = semanticObligationPolicy(node.kind); - if (candidatePolicy === undefined) continue; - const evidenceContext = evidenceContexts.get(node.evidence.context_id); - if (evidenceContext === undefined) - throw new TypeError( - "Semantic evidence reference names an absent context", - ); - addCandidate( - candidates, - generatedCandidate({ - key: `semantic:${node.node_id}:${candidatePolicy.family}`, - title: node.label?.trim() || `${node.kind} semantic boundary`, - policy: candidatePolicy, - artifactSha256: node.identity.artifact_sha256, - applicationNodeId: node.application_node_ids[0] ?? null, - semanticNodeId: node.node_id, - residualUnknownIds: unknownIdsByNode.get(node.node_id) ?? [], - location: `semantic-graph:${semanticGraph.graph_id}/nodes/${node.node_id}`, - authorityReferences: [ - graphReference( - evidence, - records, - evidenceContext, - `${semanticGraph.graph_id}/node/${node.node_id}`, - ), - ], - }), - ); - } - } - if (source.graph.coverage.status !== "complete") - limitations.add( - `Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`, - ); - } catch (cause: unknown) { - limitations.add( - `Application Evidence ${evidence.evidence_id} could not generate candidates: ${describeValidationFailure(cause)}`, - ); } + if (source.graph.coverage.status !== "complete") + limitations.add( + `Application graph ${source.graph.graph_id} coverage is ${source.graph.coverage.status}; omitted candidates remain unknown.`, + ); }; const deriveProcessCandidate = ( @@ -204,86 +190,89 @@ const deriveProcessCandidate = ( evidence.predicate_type !== "rea.process-capture" ) return; + let capture: ProcessCapture; try { - const capture = parseProcessCapture(evidence.normalized_result); - const observedCases: ReconstructionObligation["observed_cases"] = [ - { - kind: capture.exit.code === 0 ? "positive" : "negative", - evidence_id: evidence.evidence_id, - location: "/normalized_result/exit", - }, - { - kind: "teardown", - evidence_id: evidence.evidence_id, - location: "/normalized_result/settlement", - }, - ...(capture.exit.signal !== null || capture.exit.reason !== "exited" - ? [ - { - kind: "cancellation" as const, - evidence_id: evidence.evidence_id, - location: "/normalized_result/exit", - }, - ] - : []), - ]; - if (capture.manifest.executable_sha256 === null) { - limitations.add( - `Process capture ${evidence.evidence_id} has no validated executable artifact identity; packaged-process lifecycle obligation was not bound to an artifact.`, - ); - return; - } - addCandidate(candidates, { - obligation_id: obligationId( - `process:${capture.manifest.executable_sha256}:${capture.manifest.full_scenario_sha256}`, - ), - obligation_version: 1, - title: "Captured packaged-process lifecycle", - origin: "generated", - application_layer: "process", - family: "packaged-process-lifecycle", - target: { - artifact_sha256: capture.manifest.executable_sha256, - application_node_id: null, - semantic_node_id: null, - location: `/evidence/${evidence.evidence_id}/normalized_result`, - }, - authority_references: [ - { - evidence_id: evidence.evidence_id, - authority: evidence.authority, - state: "observed", - location: "/normalized_result", - }, - ], - source_state: "observed", - observed_cases: observedCases, - required: true, - required_case_kinds: ["positive", "negative", "cancellation", "teardown"], - required_original_authority: "process", - required_fixture_authority: "packaged-process", - required_verifier_authority: "packaged-process", - requires_parser_type: false, - dependency_obligation_ids: [], - residual_unknown_ids: [], - unavailable_authority: capture.residual_unknowns.map( - ({ scope, reason }) => `${scope}: ${reason}`, - ), - required_next_evidence: capture.truncated - ? ["Repeat the packaged-process capture without truncation."] - : [], - definitionCount: 1, - disposition: "active", - }); - if (capture.truncated) - limitations.add( - `Process Evidence ${evidence.evidence_id} is truncated; absence and upper bounds remain unknown.`, - ); + capture = parseProcessCapture(evidence.normalized_result); } catch (cause: unknown) { + if (!(cause instanceof z.ZodError)) throw cause; limitations.add( `Process Evidence ${evidence.evidence_id} could not generate candidates: ${describeValidationFailure(cause)}`, ); + return; } + const observedCases: ReconstructionObligation["observed_cases"] = [ + { + kind: capture.exit.code === 0 ? "positive" : "negative", + evidence_id: evidence.evidence_id, + location: "/normalized_result/exit", + }, + { + kind: "teardown", + evidence_id: evidence.evidence_id, + location: "/normalized_result/settlement", + }, + ...(capture.exit.signal !== null || capture.exit.reason !== "exited" + ? [ + { + kind: "cancellation" as const, + evidence_id: evidence.evidence_id, + location: "/normalized_result/exit", + }, + ] + : []), + ]; + if (capture.manifest.executable_sha256 === null) { + limitations.add( + `Process capture ${evidence.evidence_id} has no validated executable artifact identity; packaged-process lifecycle obligation was not bound to an artifact.`, + ); + return; + } + addCandidate(candidates, { + obligation_id: obligationId( + `process:${capture.manifest.executable_sha256}:${capture.manifest.full_scenario_sha256}`, + ), + obligation_version: 1, + title: "Captured packaged-process lifecycle", + origin: "generated", + application_layer: "process", + family: "packaged-process-lifecycle", + target: { + artifact_sha256: capture.manifest.executable_sha256, + application_node_id: null, + semantic_node_id: null, + location: `/evidence/${evidence.evidence_id}/normalized_result`, + }, + authority_references: [ + { + evidence_id: evidence.evidence_id, + authority: evidence.authority, + state: "observed", + location: "/normalized_result", + }, + ], + source_state: "observed", + observed_cases: observedCases, + required: true, + required_case_kinds: ["positive", "negative", "cancellation", "teardown"], + required_original_authority: "process", + required_fixture_authority: "packaged-process", + required_verifier_authority: "packaged-process", + requires_parser_type: false, + dependency_obligation_ids: [], + residual_unknown_ids: [], + unavailable_authority: capture.residual_unknowns.map( + ({ scope, reason }) => `${scope}: ${reason}`, + ), + required_next_evidence: capture.truncated + ? ["Repeat the packaged-process capture without truncation."] + : [], + definitionCount: 1, + disposition: "active", + }); + if (capture.truncated) + limitations.add( + `Process Evidence ${evidence.evidence_id} is truncated; absence and upper bounds remain unknown.`, + ); }; interface GeneratedCandidateInput { @@ -355,7 +344,9 @@ const reviewedCandidate = ( authority_references: reviewEvidenceIds.map((evidenceId) => { const evidence = records.get(evidenceId); if (evidence === undefined) - throw new MissingReviewEvidenceError(evidenceId); + throw new Error( + `Resolved reviewed obligation references absent Evidence: ${evidenceId}`, + ); return { evidence_id: evidenceId, authority: evidence.authority, diff --git a/src/application/ReconstructionObligationLedger.closure.test.ts b/src/application/ReconstructionObligationLedger.closure.test.ts index a0a2ec970a..0d1f881e8a 100644 --- a/src/application/ReconstructionObligationLedger.closure.test.ts +++ b/src/application/ReconstructionObligationLedger.closure.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { build, + deriveCandidates, + resolveRequest, originalCases, processEvidence, proofEvidence, @@ -9,7 +11,6 @@ import { request, reviewedObligation, } from "./ReconstructionObligationLedger.fixture.js"; -import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js"; describe("reconstruction obligation ledger", () => { @@ -136,7 +137,7 @@ describe("reconstruction obligation ledger Evidence provenance", () => { const capture = processEvidence(); const caseEvidence = proofEvidence("distinct-original-case"); const bundle = request([capture, caseEvidence]).evidence_bundle; - const generated = deriveReconstructionObligationCandidates(bundle, []); + const generated = deriveCandidates(bundle, []); const candidates = generated.candidates.map((candidate) => ({ ...candidate, observed_cases: candidate.observed_cases.map((observedCase) => ({ @@ -147,7 +148,11 @@ describe("reconstruction obligation ledger Evidence provenance", () => { const result = evaluateReconstructionObligationLedger({ candidates, - bundle, + sources: resolveRequest({ + evidence_bundle: bundle, + reviewed_obligations: [], + manifest: { bindings: [], contradictions: [] }, + }).sources, manifest: { bindings: [], contradictions: [] }, generationLimitations: [], }); @@ -234,13 +239,13 @@ describe("reconstruction obligation ledger case closure", () => { expect(result.status).toBe("ready"); expect(result.summary.required_open).toBe(0); - const generated = deriveReconstructionObligationCandidates( + const generated = deriveCandidates( ledgerRequest.evidence_bundle, ledgerRequest.reviewed_obligations, ); const limited = evaluateReconstructionObligationLedger({ candidates: generated.candidates, - bundle: ledgerRequest.evidence_bundle, + sources: resolveRequest(ledgerRequest).sources, manifest: ledgerRequest.manifest, generationLimitations: ["A supplementary source was unavailable."], }); diff --git a/src/application/ReconstructionObligationLedger.fixture.ts b/src/application/ReconstructionObligationLedger.fixture.ts index 9d87ea709f..47b5878e45 100644 --- a/src/application/ReconstructionObligationLedger.fixture.ts +++ b/src/application/ReconstructionObligationLedger.fixture.ts @@ -1,11 +1,13 @@ import { PROCESS_PROVIDER } from "../domain/process/processEvidenceProvider.js"; -import { - buildReconstructionObligationLedgerEvidenceValidated, - resolveReconstructionObligationLedgerRequest, -} from "./ReconstructionObligationLedgerService.js"; +import { buildReconstructionObligationLedgerEvidence } from "./ReconstructionObligationLedgerService.js"; +import { resolveReconstructionObligationLedgerRequest } from "./ReconstructionObligationLedgerRequest.js"; +import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../domain/process/processCaptureExample.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; -import { createEvidenceBundle } from "../domain/evidenceBundle.js"; +import { + createEvidenceBundle, + type EvidenceBundle, +} from "../domain/evidenceBundle.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { reconstructionObligationLedgerSchema, @@ -98,11 +100,7 @@ export const request = ( export const build = ( input: ReconstructionObligationLedgerInput, ): ReconstructionObligationLedger => { - const parsed = resolveReconstructionObligationLedgerRequest(input); - if (!parsed.ok) throw parsed.error; - const result = buildReconstructionObligationLedgerEvidenceValidated( - parsed.value, - ); + const result = buildReconstructionObligationLedgerEvidence(input); if (!result.ok) throw result.error; return reconstructionObligationLedgerSchema.parse( result.value.normalized_result, @@ -266,3 +264,23 @@ export const MIXED_AUTHORITY_SUMMARY = { { key: "unknown", count: 0 }, ], } as const; + +/** Resolve fixture sources through the production request owner. */ +export const resolveRequest = (input: ReconstructionObligationLedgerInput) => { + const resolved = resolveReconstructionObligationLedgerRequest(input); + if (!resolved.ok) throw resolved.error; + return resolved.value; +}; + +/** Derive candidates from fixture requests after authenticating their sources. */ +export const deriveCandidates = ( + bundle: EvidenceBundle, + reviewed: readonly ReviewedReconstructionObligation[], +) => + deriveReconstructionObligationCandidates( + resolveRequest({ + evidence_bundle: bundle, + reviewed_obligations: [...reviewed], + manifest: { bindings: [], contradictions: [] }, + }), + ); diff --git a/src/application/ReconstructionObligationLedgerEvaluation.ts b/src/application/ReconstructionObligationLedgerEvaluation.ts index af6be2b371..6b96cf3feb 100644 --- a/src/application/ReconstructionObligationLedgerEvaluation.ts +++ b/src/application/ReconstructionObligationLedgerEvaluation.ts @@ -1,8 +1,5 @@ import type { Evidence } from "../domain/evidence.js"; -import { - parseEvidenceBundle, - type EvidenceBundle, -} from "../domain/evidenceBundle.js"; +import type { ReconstructionObligationSources } from "./ReconstructionObligationLedgerRequest.js"; import { reconstructionObligationLedgerSchema, type ReconstructionObligation, @@ -52,22 +49,21 @@ interface EvaluationContext { export const evaluateReconstructionObligationLedger = ({ candidates: candidatesInput, - bundle: bundleInput, + sources, manifest, generationLimitations, }: { readonly candidates: readonly ReconstructionObligationCandidate[]; - readonly bundle: EvidenceBundle; + readonly sources: ReconstructionObligationSources; readonly manifest: ReconstructionObligationManifest; readonly generationLimitations: readonly string[]; }): ReconstructionObligationLedger => { - const bundle = parseEvidenceBundle(bundleInput); - const allCandidates = [...candidatesInput].sort((left, right) => + const { bundle, records } = sources; + const candidates = [...candidatesInput].sort((left, right) => compareUnicodeCodePoints(left.obligation_id, right.obligation_id), ); - const orphanManifestIds = findOrphanManifestIds(allCandidates, manifest); - const candidates = allCandidates; - const context = createEvaluationContext(candidates, bundle, manifest); + const orphanManifestIds = findOrphanManifestIds(candidates, manifest); + const context = createEvaluationContext(candidates, records, manifest); const obligations = applyDependencyDiagnostics( candidates.map((candidate) => evaluateObligation(candidate, context)), context, @@ -77,7 +73,7 @@ export const evaluateReconstructionObligationLedger = ({ const evidenceLinks = obligationLedgerEvidenceLinks(obligations, manifest); const limitations = ledgerLimitations( generationLimitations, - allCandidates.length, + candidates.length, orphanManifestIds, ); const coverage = reconstructionObligationCoverage(limitations); @@ -115,15 +111,13 @@ export const evaluateReconstructionObligationLedger = ({ const createEvaluationContext = ( candidates: readonly ReconstructionObligationCandidate[], - bundle: EvidenceBundle, + evidence: ReadonlyMap, manifest: ReconstructionObligationManifest, ): EvaluationContext => ({ candidates: new Map( candidates.map((candidate) => [candidate.obligation_id, candidate]), ), - evidence: new Map( - bundle.records.map((evidence) => [evidence.evidence_id, evidence]), - ), + evidence, bindings: groupManifestBindings(manifest), contradictions: groupManifestContradictions(manifest), }); diff --git a/src/application/ReconstructionObligationLedgerRequest.ts b/src/application/ReconstructionObligationLedgerRequest.ts new file mode 100644 index 0000000000..283df620e0 --- /dev/null +++ b/src/application/ReconstructionObligationLedgerRequest.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { Evidence } from "../domain/evidence.js"; +import { + readEvidenceBundle, + type EvidenceBundle, +} from "../domain/evidenceBundle.js"; +import { + reconstructionObligationLedgerInputSchema, + type ReviewedReconstructionObligation, + type ReconstructionObligationManifest, +} from "../domain/reconstructionObligationLedgerSchemas.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { requestInputError } from "./workflowInputError.js"; + +const OPERATION = "build_reconstruction_obligation_ledger"; +const requestSchema = reconstructionObligationLedgerInputSchema.extend({ + evidence_bundle: z.unknown(), +}); + +/** Authenticated bundle and its single request-owned record index. */ +export interface ReconstructionObligationSources { + readonly bundle: EvidenceBundle; + readonly records: ReadonlyMap; +} + +export interface ResolvedReconstructionObligationRequest { + readonly sources: ReconstructionObligationSources; + readonly reviewedObligations: readonly ReviewedReconstructionObligation[]; + readonly manifest: ReconstructionObligationManifest; +} + +/** Authenticate the source bundle and bind every selected review reference. */ +export const resolveReconstructionObligationLedgerRequest = ( + input: unknown, +): Result => { + const frame = requestSchema.safeParse(input); + if (!frame.success) + return err(requestInputError(OPERATION, frame.error, input)); + const bundle = readEvidenceBundle(frame.data.evidence_bundle); + if (!bundle.ok) return bundle; + const records = new Map( + bundle.value.records.map((record) => [record.evidence_id, record]), + ); + for (const [index, obligation] of frame.data.reviewed_obligations.entries()) { + for (const [ + referenceIndex, + id, + ] of obligation.review_evidence_ids.entries()) { + if (records.has(id)) continue; + const message = `Reviewed obligation Evidence is missing: ${id}`; + return err( + new AnalysisInputError(OPERATION, { userMessage: message }, [ + { + path: [ + "reviewed_obligations", + index, + "review_evidence_ids", + referenceIndex, + ], + reason: "invalid_value", + message, + }, + ]), + ); + } + } + return ok({ + sources: { bundle: bundle.value, records }, + reviewedObligations: frame.data.reviewed_obligations, + manifest: frame.data.manifest, + }); +}; diff --git a/src/application/ReconstructionObligationLedgerService.ts b/src/application/ReconstructionObligationLedgerService.ts index bdf67c0ddb..c87fd9490f 100644 --- a/src/application/ReconstructionObligationLedgerService.ts +++ b/src/application/ReconstructionObligationLedgerService.ts @@ -1,94 +1,55 @@ -import { z } from "zod"; - -import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { - DuplicateEvidenceRecordError, - InvalidResidualUnknownGraphError, - NonCanonicalEvidenceBundleError, -} from "../domain/evidenceBundle.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; -import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js"; -import { - reconstructionObligationLedgerInputSchema, - reconstructionObligationLedgerSchema, - type ReconstructionObligationLedgerInput, - type ReconstructionObligationLedger, -} from "../domain/reconstructionObligationLedgerSchemas.js"; -import { err, ok, type Result } from "../domain/result.js"; -import { requestInputError, workflowInputError } from "./workflowInputError.js"; +import type { ReconstructionObligationLedger } from "../domain/reconstructionObligationLedgerSchemas.js"; +import { ok, type Result } from "../domain/result.js"; import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js"; -import { - deriveReconstructionObligationCandidates, - MissingReviewEvidenceError, -} from "./ReconstructionObligationCandidates.js"; +import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js"; +import { + resolveReconstructionObligationLedgerRequest, + type ResolvedReconstructionObligationRequest, +} from "./ReconstructionObligationLedgerRequest.js"; -const OPERATION = "build_reconstruction_obligation_ledger" as const; +const OPERATION = "build_reconstruction_obligation_ledger"; -/** Parse one portable ledger request for both CLI and MCP adapters. */ -export const resolveReconstructionObligationLedgerRequest = ( +/** Build portable ledger Evidence from a caller-selected request. */ +export const buildReconstructionObligationLedgerEvidence = ( input: unknown, -): Result => { - const parsed = reconstructionObligationLedgerInputSchema.safeParse(input); - return parsed.success - ? ok(parsed.data) - : err(requestInputError(OPERATION, parsed.error, input)); +): Result => { + const request = resolveReconstructionObligationLedgerRequest(input); + if (!request.ok) return request; + return ok(buildResolvedReconstructionObligationLedgerEvidence(request.value)); }; -/** Build the complete deterministic ledger and wrap it in portable Evidence. */ -export const buildReconstructionObligationLedgerEvidenceValidated = ( - input: ReconstructionObligationLedgerInput, -): Result => { - try { - const generated = deriveReconstructionObligationCandidates( - input.evidence_bundle, - input.reviewed_obligations, - ); - const ledger = evaluateReconstructionObligationLedger({ - candidates: generated.candidates, - bundle: input.evidence_bundle, - manifest: input.manifest, - generationLimitations: generated.limitations, - }); - return ok(createLedgerEvidence(input, ledger)); - } catch (cause: unknown) { - if ( - cause instanceof NonCanonicalEvidenceBundleError || - cause instanceof DuplicateEvidenceRecordError || - cause instanceof InvalidResidualUnknownGraphError || - cause instanceof MissingReviewEvidenceError - ) - return err(workflowInputError(OPERATION, cause)); - if (cause instanceof z.ZodError) - return err(workflowInputError(OPERATION, cause)); - return err( - new AnalysisProtocolError( - "Reconstruction obligation ledger generation failed", - { cause }, - ), - ); - } +/** Generate and evaluate against the same authenticated bundle and record index. */ +export const buildResolvedReconstructionObligationLedgerEvidence = ( + request: ResolvedReconstructionObligationRequest, +): Evidence => { + const generated = deriveReconstructionObligationCandidates(request); + const ledger = evaluateReconstructionObligationLedger({ + candidates: generated.candidates, + sources: request.sources, + manifest: request.manifest, + generationLimitations: generated.limitations, + }); + return createLedgerEvidence(ledger); }; const createLedgerEvidence = ( - input: ReconstructionObligationLedgerInput, ledger: ReconstructionObligationLedger, -): Evidence => { - const completeLedger = reconstructionObligationLedgerSchema.parse(ledger); - return createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { +): Evidence => + createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, { predicateType: "rea.reconstruction-obligation-ledger", operation: OPERATION, - parameters: jsonObjectSchema.parse({ - ledger_id: completeLedger.ledger_id, - closure_digest: completeLedger.closure_digest, - }), - result: jsonValueSchema.parse(completeLedger), + parameters: { + ledger_id: ledger.ledger_id, + closure_digest: ledger.closure_digest, + }, + result: ledger, rawResult: null, confidence: "inferred", authority: "analyst-inference", environment: null, - limitations: completeLedger.limitations, - evidenceLinks: completeLedger.evidence_links, + limitations: ledger.limitations, + evidenceLinks: ledger.evidence_links, }); -}; diff --git a/src/application/workflowInputError.ts b/src/application/workflowInputError.ts index 8fe6eecafa..cd3cc73ed4 100644 --- a/src/application/workflowInputError.ts +++ b/src/application/workflowInputError.ts @@ -1,6 +1,6 @@ -import { z } from "zod"; +import type { z } from "zod"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import type { AnalysisInputError } from "../domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; /** Project schema issues of the raw request onto their request paths. */ @@ -10,25 +10,3 @@ export const requestInputError = ( input: unknown, ): AnalysisInputError => analysisInputErrorFromIssues(operation, cause.issues, input, { cause }); - -/** - * Keep the failed constraint when a parsed request is rejected later. Nested - * values such as Evidence results are parsed on their own, so their issue - * paths are relative to that value and are reported in the message, not as - * request paths. - */ -export const workflowInputError = ( - operation: string, - cause: z.ZodError | TypeError, -): AnalysisInputError => - new AnalysisInputError( - operation, - { cause }, - cause instanceof z.ZodError - ? cause.issues.map((issue) => ({ - path: [], - reason: "invalid_value" as const, - message: `A nested value failed validation at ${issue.path.length === 0 ? "its root" : issue.path.map(String).join(".")}: ${issue.message}`, - })) - : [{ path: [], reason: "invalid_value", message: cause.message }], - ); diff --git a/src/cli/applicationCommands.ts b/src/cli/applicationCommands.ts index b14ed0699a..4b9f3f620b 100644 --- a/src/cli/applicationCommands.ts +++ b/src/cli/applicationCommands.ts @@ -17,7 +17,6 @@ import { compareApplicationVersionsInputSchema } from "../domain/javascript/java import { registerSourceToBundleCommand } from "./sourceToBundleCommand.js"; import { compareJavaScriptExportShapesInputSchema } from "../domain/javascript/javascriptExportShapeComparisonSchemas.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; -import { reconstructionObligationLedgerInputSchema } from "../domain/reconstructionObligationLedgerSchemas.js"; type CliInstance = ReturnType; @@ -141,15 +140,11 @@ const registerObligationLedgerCommand = ( name: CLI_COMMANDS.buildReconstructionObligationLedger, description: "Generate a deterministic Evidence-backed reconstruction obligation ledger page", - inputSchema: reconstructionObligationLedgerInputSchema, + inputSchema: z.unknown(), workflow: async (input) => { - const { buildReconstructionObligationLedgerEvidenceValidated } = + const { buildReconstructionObligationLedgerEvidence } = await import("../application/ReconstructionObligationLedgerService.js"); - const result = - buildReconstructionObligationLedgerEvidenceValidated(input); - return result.ok - ? { ok: true, value: jsonValueSchema.parse(result.value) } - : result; + return buildReconstructionObligationLedgerEvidence(input); }, }); diff --git a/src/server/registerApplicationTools/obligationLedger.ts b/src/server/registerApplicationTools/obligationLedger.ts index 5899d1798d..95e0caa995 100644 --- a/src/server/registerApplicationTools/obligationLedger.ts +++ b/src/server/registerApplicationTools/obligationLedger.ts @@ -2,10 +2,9 @@ import type { EvidenceMcpServer } from "../EvidenceMcpServer.js"; import { runAdmittedToolOperation } from "../admittedToolOperation.js"; import { recordSessionEvidenceSources } from "../sessionEvidence.js"; -import { - buildReconstructionObligationLedgerEvidenceValidated, - resolveReconstructionObligationLedgerRequest, -} from "../../application/ReconstructionObligationLedgerService.js"; +import { buildResolvedReconstructionObligationLedgerEvidence } from "../../application/ReconstructionObligationLedgerService.js"; +import { resolveReconstructionObligationLedgerRequest } from "../../application/ReconstructionObligationLedgerRequest.js"; +import { ok } from "../../domain/result.js"; import { applicationToolContract } from "../../contracts/applicationToolContracts.js"; import { logToolExecution } from "../toolLogging.js"; import { toolRegistrationOptions } from "../toolRegistrationOptions.js"; @@ -39,8 +38,10 @@ export const registerReconstructionObligationLedgerTool = ( contract.name, () => Promise.resolve( - buildReconstructionObligationLedgerEvidenceValidated( - resolved.value, + ok( + buildResolvedReconstructionObligationLedgerEvidence( + resolved.value, + ), ), ), ); @@ -48,7 +49,7 @@ export const registerReconstructionObligationLedgerTool = ( return server.delivery.toCallToolResult(result, contract); const recorded = recordSessionEvidenceSources( options.recordEvidence, - resolved.value.evidence_bundle.records, + resolved.value.sources.bundle.records, ); if (!recorded.ok) return server.delivery.toCallToolResult(recorded, contract); diff --git a/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts index 57516d3353..46e2be1929 100644 --- a/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts +++ b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts @@ -79,18 +79,13 @@ describe("reconstruction obligation ledger CLI parity", () => { stdout = cause.stdout; } expect(JSON.parse(stdout)).toMatchObject({ - code: "invalid_request", - category: "invalid_input", + code: "evidence_integrity_mismatch", + category: "integrity_mismatch", retryable: false, details: { - operation: "build_reconstruction_obligation_ledger", - issues: [ - expect.objectContaining({ - message: expect.stringContaining( - "Evidence bundle manifests are not canonical", - ), - }), - ], + reason: expect.stringContaining( + "Evidence bundle manifests are not canonical", + ), }, }); }, 20_000); diff --git a/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts index 56ef8fd3a8..99da0df391 100644 --- a/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts +++ b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts @@ -55,16 +55,11 @@ const withLedgerClient = async ( } }; -const inputError = (message: string) => ({ +const integrityError = (message: string) => ({ error: { - code: "invalid_request", - category: "invalid_input", - details: { - operation: "build_reconstruction_obligation_ledger", - issues: [ - expect.objectContaining({ message: expect.stringContaining(message) }), - ], - }, + code: "evidence_integrity_mismatch", + category: "integrity_mismatch", + details: { reason: expect.stringContaining(message) }, }, }); @@ -97,7 +92,7 @@ describe("reconstruction obligation ledger residual graph input", () => { const result = await request(createEvidenceBundle([], [unknown])); expect(result.isError).toBe(true); expect(parseMcpToolError(result)).toMatchObject( - inputError( + integrityError( `Residual unknown references missing evidence ${mutation.evidence_id}`, ), ); @@ -110,7 +105,7 @@ describe("reconstruction obligation ledger residual graph input", () => { }); it.each(["history", "relationship", "resolution"] as const)( - "reports residual %s constraints as caller input", + "reports residual %s source integrity constraints", async (constraint) => { const missingId = `unk_${"a".repeat(64)}`; const input = recordUnknownInputSchema.parse({ @@ -168,14 +163,14 @@ describe("reconstruction obligation ledger residual graph input", () => { createEvidenceBundle([mutation], unknowns), ); expect(result.isError).toBe(true); - expect(parseMcpToolError(result)).toMatchObject(inputError(reason)); + expect(parseMcpToolError(result)).toMatchObject(integrityError(reason)); }); }, ); }); describe("reconstruction obligation ledger MCP parity", () => { - it("returns canonical bundle failures as caller input errors", async () => { + it("rejects noncanonical source bundles before ledger generation", async () => { const records = ["first", "second"].map((label) => createEvidence( undefined, @@ -218,22 +213,9 @@ describe("reconstruction obligation ledger MCP parity", () => { records: [...canonical.records].reverse(), }); expect(result.isError).toBe(true); - expect(parseMcpToolError(result)).toMatchObject({ - error: { - code: "invalid_request", - category: "invalid_input", - details: { - operation: "build_reconstruction_obligation_ledger", - issues: [ - expect.objectContaining({ - message: expect.stringContaining( - "Evidence bundle manifests are not canonical", - ), - }), - ], - }, - }, - }); + expect(parseMcpToolError(result)).toMatchObject( + integrityError("Evidence bundle manifests are not canonical"), + ); } finally { await client.close(); await server.close(); @@ -241,7 +223,7 @@ describe("reconstruction obligation ledger MCP parity", () => { } }); - it("returns duplicate records and missing review Evidence as caller input errors", async () => { + it("distinguishes corrupt source bundles from missing caller-selected review references", async () => { const record = createEvidence( undefined, { id: "fixture", name: "Fixture", version: "1" }, @@ -256,17 +238,25 @@ describe("reconstruction obligation ledger MCP parity", () => { }); expect(duplicate.isError).toBe(true); expect(parseMcpToolError(duplicate)).toMatchObject( - inputError("Evidence bundle contains duplicate record IDs"), + integrityError("Evidence bundle contains duplicate record IDs"), ); const missing = await request(canonical, [ reviewedObligation("obl.reviewed", missingEvidenceId), ]); expect(missing.isError).toBe(true); - expect(parseMcpToolError(missing)).toMatchObject( - inputError( - `Reviewed obligation Evidence is missing: ${missingEvidenceId}`, - ), - ); + expect(parseMcpToolError(missing)).toMatchObject({ + error: { + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + path: ["reviewed_obligations", 0, "review_evidence_ids", 0], + message: `Reviewed obligation Evidence is missing: ${missingEvidenceId}`, + }), + ], + }, + }, + }); }); }); diff --git a/tests/process-global/derivedOperationFailures.test.ts b/tests/process-global/derivedOperationFailures.test.ts index 7457569b9a..fb151326b6 100644 --- a/tests/process-global/derivedOperationFailures.test.ts +++ b/tests/process-global/derivedOperationFailures.test.ts @@ -30,6 +30,25 @@ import { compareManagedMemberPaths } from "../../src/application/managed/Managed import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { reconstructionObligationLedgerSchema } from "../../src/domain/reconstructionObligationLedgerSchemas.js"; +import { + request as ledgerRequest, + processEvidence, +} from "../../src/application/ReconstructionObligationLedger.fixture.js"; +import { processCaptureSchema } from "../../src/domain/process/processCapture.js"; + +/** Unexpected implementation failures retain the SDK text surface, without a typed caller error. */ +const expectUnexpectedToolFailure = ( + response: Awaited>, + failure: Error, +): void => { + expect(response).toMatchObject({ + isError: true, + content: [{ type: "text", text: failure.message }], + }); + expect(response.structuredContent).toBeUndefined(); +}; + const unexpectedResultFailures = [ ["TypeError", () => new TypeError("injected result parser TypeError")], ["Error", () => new Error("injected result parser Error")], @@ -226,11 +245,7 @@ describe("derived operation failures through the public SDK", () => { }, }); - expect(response).toMatchObject({ - isError: true, - content: [{ type: "text", text: failure.message }], - }); - expect(response.structuredContent).toBeUndefined(); + expectUnexpectedToolFailure(response, failure); expect(session.exportEvidenceBundle().records).toEqual([]); }); }, @@ -251,11 +266,7 @@ describe("derived operation failures through the public SDK", () => { name: "evaluate_reconstruction_coverage", arguments: { coverage, boundary_id: "replacement.cli" }, }); - expect(response).toMatchObject({ - isError: true, - content: [{ type: "text", text: failure.message }], - }); - expect(response.structuredContent).toBeUndefined(); + expectUnexpectedToolFailure(response, failure); expect(session.exportEvidenceBundle().records).toEqual([]); }); }, @@ -366,11 +377,7 @@ describe.each(managedWorkflows)( name: workflow.name, arguments: workflow.input, }); - expect(response).toMatchObject({ - isError: true, - content: [{ type: "text", text: failure.message }], - }); - expect(response.structuredContent).toBeUndefined(); + expectUnexpectedToolFailure(response, failure); expect(session.exportEvidenceBundle().records).toEqual([]); }); }, @@ -416,3 +423,48 @@ describe("managed comparison output failure with completed source reads", () => }); }); }); + +describe("ledger generation failure ownership through the public SDK", () => { + afterEach(() => vi.restoreAllMocks()); + + it.each(unexpectedResultFailures)( + "preserves an unexpected %s from generated ledger validation", + async (_label, createFailure) => { + await withSdk(async ({ client, session }) => { + const input = ledgerRequest([processEvidence()]); + const failure = createFailure(); + vi.spyOn( + reconstructionObligationLedgerSchema, + "parse", + ).mockImplementation(() => { + throw failure; + }); + const response = await client.callTool({ + name: "build_reconstruction_obligation_ledger", + arguments: input, + }); + expectUnexpectedToolFailure(response, failure); + expect(session.exportEvidenceBundle().records).toEqual([]); + }); + }, + ); + + it.each(unexpectedResultFailures.filter(([kind]) => kind !== "ZodError"))( + "preserves an unexpected %s while deriving process candidates", + async (_label, createFailure) => { + await withSdk(async ({ client, session }) => { + const input = ledgerRequest([processEvidence()]); + const failure = createFailure(); + vi.spyOn(processCaptureSchema, "parse").mockImplementation(() => { + throw failure; + }); + const response = await client.callTool({ + name: "build_reconstruction_obligation_ledger", + arguments: input, + }); + expectUnexpectedToolFailure(response, failure); + expect(session.exportEvidenceBundle().records).toEqual([]); + }); + }, + ); +});