From 473cfa36b8ae62c82500ce7b8dbfccc3cae3488e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:34:31 +0800 Subject: [PATCH] fix(evidence)!: distinguish inline records from bundles (#7895) * fix(evidence): distinguish inline records from bundles * fix(evidence): preserve bundle integrity failure reasons --- .../ReconstructionObligationLedgerRequest.ts | 5 +- src/domain/evidenceBundle.ts | 19 +++- src/domain/reconstructionCoverage.ts | 5 +- src/domain/reconstructionVerification.ts | 5 +- .../cli/inlineEvidenceBundleKind.test.ts | 106 ++++++++++++++++++ 5 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 tests/boundary/cli/inlineEvidenceBundleKind.test.ts diff --git a/src/application/ReconstructionObligationLedgerRequest.ts b/src/application/ReconstructionObligationLedgerRequest.ts index 98e0f7e043..73f25656b8 100644 --- a/src/application/ReconstructionObligationLedgerRequest.ts +++ b/src/application/ReconstructionObligationLedgerRequest.ts @@ -38,7 +38,10 @@ export const resolveReconstructionObligationLedgerRequest = ( ): Result => { const frame = parseAnalysisInput(OPERATION, input, requestSchema); if (!frame.ok) return frame; - const bundle = readEvidenceBundle(frame.value.evidence_bundle); + const bundle = readEvidenceBundle(frame.value.evidence_bundle, { + operation: OPERATION, + path: ["evidence_bundle"], + }); if (!bundle.ok) return bundle; const records = new Map( bundle.value.records.map((record) => [record.evidence_id, record]), diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index e36a4b97be..8539c49a98 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -2,6 +2,7 @@ import { InvalidResidualUnknownGraphError } from "./residualUnknownGraphError.js import { withEvidenceSubjectDeclaration } from "./evidenceSubject.js"; import { digestSchema } from "./digests.js"; import { EvidenceIntegrityError } from "./evidenceErrors.js"; +import { AnalysisInputError } from "./analysisErrorCore.js"; import { err, ok, type Result } from "./result.js"; import { describeValidationFailure } from "./validationFailure.js"; import { equalJsonText } from "./jsonSerialization.js"; @@ -295,14 +296,26 @@ const parseBundle = (input: unknown, seal: boolean): EvidenceBundle => { /** Read a source bundle while preserving unexpected implementation failures. */ export const readEvidenceBundle = ( input: unknown, -): Result => { + context: { + readonly operation: string; + readonly path: readonly (string | number)[]; + }, +): Result => { const parsed = safeParseEvidenceBundle(input); if (parsed.ok) return parsed; - const { cause } = parsed.error; + const { cause, kind, message } = parsed.error; + if (kind === "single-record") + return err( + new AnalysisInputError( + context.operation, + { cause, userMessage: message, remediationAction: message }, + [{ path: context.path, reason: "invalid_value", message }], + ), + ); return err( new EvidenceIntegrityError(cause.message, { cause, - userMessage: cause.message, + userMessage: message, }), ); }; diff --git a/src/domain/reconstructionCoverage.ts b/src/domain/reconstructionCoverage.ts index 54106e599f..68faa9fe85 100644 --- a/src/domain/reconstructionCoverage.ts +++ b/src/domain/reconstructionCoverage.ts @@ -234,7 +234,10 @@ export const readReconstructionCoverageData = ( })), ), ); - const bundle = readEvidenceBundle(parsed.data.evidence_bundle); + const bundle = readEvidenceBundle(parsed.data.evidence_bundle, { + operation: "evaluate_reconstruction_coverage", + path: [...path, "evidence_bundle"], + }); if (!bundle.ok) return bundle; const coverage = { ...parsed.data, evidence_bundle: bundle.value }; try { diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index f151b0a233..2b5d024fe4 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -60,7 +60,10 @@ export const verifyReconstruction = ( { cause: parsedSpecification.error }, ); const specification = parsedSpecification.data; - const bundleResult = readEvidenceBundle(bundleInput); + const bundleResult = readEvidenceBundle(bundleInput, { + operation: "verify_reconstruction", + path: ["evidence_bundle"], + }); if (!bundleResult.ok) throw bundleResult.error; const bundle = bundleResult.value; const records = new Map( diff --git a/tests/boundary/cli/inlineEvidenceBundleKind.test.ts b/tests/boundary/cli/inlineEvidenceBundleKind.test.ts new file mode 100644 index 0000000000..04dcb8ef32 --- /dev/null +++ b/tests/boundary/cli/inlineEvidenceBundleKind.test.ts @@ -0,0 +1,106 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createEvidence } from "../../../src/domain/evidence.js"; +import { completeReconstructionCoverageData } from "../../../src/domain/reconstructionCoverage.fixture.js"; +import { readyProcessLedgerRequest } from "../../../src/application/ReconstructionObligationLedger.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "inline reconstruction distinguishes an authenticated record from a corrupt bundle", + async ({ cli }) => { + const record = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "observe", + parameters: {}, + result: { observed: true }, + }, + ); + const root = await createTestTempDirectory("rea-inline-bundle-kind-"); + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected MCP session"); + const cases = [ + { + command: "build-reconstruction-obligation-ledger", + tool: "build_reconstruction_obligation_ledger", + path: ["evidence_bundle"], + input: { ...readyProcessLedgerRequest(), evidence_bundle: record }, + }, + { + command: "evaluate-reconstruction-coverage", + tool: "evaluate_reconstruction_coverage", + path: ["coverage", "evidence_bundle"], + input: { + coverage: { + ...completeReconstructionCoverageData(), + evidence_bundle: record, + }, + boundary_id: "replacement.cli", + }, + }, + ]; + for (const scenario of cases) { + const request = join(root, `${scenario.command}.json`); + await writeFile(request, JSON.stringify(scenario.input)); + const result = await cli.run({ + arguments: [scenario.command, request, "--json"], + cwd: root, + }); + const response = await call(scenario.tool, scenario.input); + expect(result.exitCode).toBe(1); + const expected = { + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + { + path: scenario.path, + reason: "invalid_value", + message: expect.stringContaining("one Evidence record"), + }, + ], + }, + remediation: { + action: expect.stringContaining("export_evidence_bundle"), + }, + }; + expect(result.json).toMatchObject(expected); + expect(response.isError).toBe(true); + // The advertised MCP schema rejects the wrong shape before the domain reader. + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + }); + } + + // A record with a changed payload is not authenticated as a standalone record. + const altered = { ...record, normalized_result: { observed: false } }; + const malformed = { + ...readyProcessLedgerRequest(), + evidence_bundle: altered, + }; + const request = join(root, "altered.json"); + await writeFile(request, JSON.stringify(malformed)); + const result = await cli.run({ + arguments: ["build-reconstruction-obligation-ledger", request, "--json"], + cwd: root, + }); + const response = await call( + "build_reconstruction_obligation_ledger", + malformed, + ); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "evidence_integrity_mismatch", + category: "integrity_mismatch", + }); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + }); + }, +);