refactor(evidence): own bundle validation failures

This commit is contained in:
morluto committed 2026-10-23 12:55:44 +08:00
1 parent e32a0494d4
commit 8b1e3143a2
6 files changed
+277 -76

No files matched your search

+24 -19
View File
@@ -3,10 +3,10 @@ import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { describeValidationFailure } from "../domain/validationFailure.js";
import {
describeEvidenceBundleFailure,
parseImmutableEvidenceBundle,
parseEvidenceBundle,
safeParseImmutableEvidenceBundle,
safeParseEvidenceBundle,
type EvidenceBundle,
type EvidenceBundleFailure,
} from "../domain/evidenceBundle.js";
import {
EvidenceFileError,
@@ -42,7 +42,7 @@ export const readEvidenceBundle = (
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
readBundle(path, parseEvidenceBundle, signal, resources, operation);
readBundle(path, safeParseEvidenceBundle, signal, resources, operation);
/** Read an authenticated immutable snapshot for a subsequent session import. */
export const readImmutableEvidenceBundle = (
@@ -51,11 +51,19 @@ export const readImmutableEvidenceBundle = (
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> =>
readBundle(path, parseImmutableEvidenceBundle, signal, resources, operation);
readBundle(
path,
safeParseImmutableEvidenceBundle,
signal,
resources,
operation,
);
const readBundle = async (
path: string,
parseBundle: (input: unknown) => EvidenceBundle,
parseBundle: (
input: unknown,
) => Result<EvidenceBundle, EvidenceBundleFailure>,
signal?: AbortSignal,
resources?: ArtifactResourceScope,
operation = "import_evidence_bundle",
@@ -81,26 +89,25 @@ const readBundle = async (
"Insufficient heap headroom to authenticate the Evidence bundle",
});
if (!headroom.ok) return headroom;
let bundle: EvidenceBundle;
try {
bundle = parseBundle(loaded.value);
} catch (cause: unknown) {
const failure = describeEvidenceBundleFailure(loaded.value, cause);
const parsed = parseBundle(loaded.value);
if (!parsed.ok) {
const failure = parsed.error;
if (failure.kind === "single-record")
return err(
new EvidenceFileError("read", "wrong-kind", {
path: absoluteCallerPath(path),
cause,
cause: failure.cause,
userMessage: failure.message,
}),
);
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause,
cause: failure.cause,
userMessage: failure.message,
}),
);
}
const bundle = parsed.value;
for (const record of bundle.records) {
if (record.predicate_type !== "rea.process-capture") continue;
const parsedCapture = safeParseProcessCapture(record.normalized_result);
@@ -136,18 +143,16 @@ export const writeEvidenceBundle = async (
cause: signal.reason,
}),
);
let checked: EvidenceBundle;
try {
checked = parseEvidenceBundle(bundle);
} catch (cause: unknown) {
const checked = safeParseEvidenceBundle(bundle);
if (!checked.ok) {
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause,
cause: checked.error.cause,
}),
);
}
return writeTextParts(
bufferedJsonParts(canonicalJsonParts(checked)),
bufferedJsonParts(canonicalJsonParts(checked.value)),
path,
overwrite,
resources,
@@ -4,8 +4,7 @@ import { z } from "zod";
import {
createEvidenceBundle,
createImmutableEvidenceBundle,
describeEvidenceBundleFailure,
parseEvidenceBundle,
safeParseEvidenceBundle,
validateResidualUnknownAddition,
type EvidenceBundle,
} from "../../domain/evidenceBundle.js";
@@ -75,25 +74,24 @@ export class EvidenceLedger {
/** Validate an entire bundle before atomically merging its records. */
import(input: unknown): ImportResult {
let bundle: EvidenceBundle;
try {
bundle = parseEvidenceBundle(input);
} catch (cause: unknown) {
const failure = describeEvidenceBundleFailure(input, cause);
if (failure.kind === "single-record")
const parsed = safeParseEvidenceBundle(input);
if (!parsed.ok) {
const { kind, message, cause } = parsed.error;
if (kind === "single-record")
return err(
new AnalysisInputError("import_evidence_bundle", {
cause,
userMessage: failure.message,
userMessage: message,
}),
);
return err(
new EvidenceIntegrityError("Evidence bundle validation failed", {
cause,
userMessage: failure.message,
userMessage: message,
}),
);
}
const bundle = parsed.value;
// A record-only bundle cannot change the already-valid unknown graph. The
// bundle parser has validated every incoming record and its manifests, so
// checking ID conflicts and committing only the additions preserves the
+20 -14
View File
@@ -6,9 +6,9 @@ import {
createEvidenceBundle,
createImmutableEvidenceBundle,
NonCanonicalEvidenceBundleError,
describeEvidenceBundleFailure,
evidenceBundleForTarget,
parseEvidenceBundle,
safeParseEvidenceBundle,
} from "./evidenceBundle.js";
import {
createResidualUnknown,
@@ -30,12 +30,14 @@ describe("evidence bundle failure classification", () => {
}),
);
const canonical = createEvidenceBundle(records);
expect(() =>
parseEvidenceBundle({
...canonical,
records: [...canonical.records].reverse(),
}),
).toThrow(NonCanonicalEvidenceBundleError);
const parsed = safeParseEvidenceBundle({
...canonical,
records: [...canonical.records].reverse(),
});
expect(parsed.ok).toBe(false);
if (parsed.ok) return;
expect(parsed.error.kind).toBe("invalid-bundle");
expect(parsed.error.cause).toBeInstanceOf(NonCanonicalEvidenceBundleError);
});
it("treats only authenticated standalone Evidence as the wrong input kind", () => {
@@ -44,14 +46,18 @@ describe("evidence bundle failure classification", () => {
parameters: {},
result: { observed: true },
});
const single = describeEvidenceBundleFailure(record, new Error("bundle"));
expect(single).toMatchObject({ kind: "single-record" });
const single = safeParseEvidenceBundle(record);
expect(single.ok).toBe(false);
if (single.ok) return;
expect(single.error.kind).toBe("single-record");
const tampered = describeEvidenceBundleFailure(
{ ...record, normalized_result: { observed: false } },
new Error("bundle"),
);
expect(tampered).toMatchObject({ kind: "invalid-bundle" });
const tampered = safeParseEvidenceBundle({
...record,
normalized_result: { observed: false },
});
expect(tampered.ok).toBe(false);
if (tampered.ok) return;
expect(tampered.error.kind).toBe("invalid-bundle");
});
});
+50 -23
View File
@@ -238,27 +238,35 @@ const referencedEvidenceIds = (unknown: ResidualUnknown): readonly string[] => [
...(unknown.resolution?.evidence_ids ?? []),
];
/**
* Name the bundle constraint that failed and how to recover. A single
* Evidence record, such as one command result, is not a bundle.
*/
export type EvidenceBundleFailureDescription =
| { readonly kind: "single-record"; readonly message: string }
| { readonly kind: "invalid-bundle"; readonly message: string };
type EvidenceBundleValidationFailure =
| z.ZodError
| DuplicateEvidenceRecordError
| InvalidResidualUnknownGraphError
| NonCanonicalEvidenceBundleError;
export const describeEvidenceBundleFailure = (
/** Expected source rejection, including authenticated standalone Evidence. */
export type EvidenceBundleFailure = {
readonly kind: "single-record" | "invalid-bundle";
readonly message: string;
readonly cause: EvidenceBundleValidationFailure;
};
const describeEvidenceBundleFailure = (
input: unknown,
cause: unknown,
): EvidenceBundleFailureDescription => {
cause: EvidenceBundleValidationFailure,
): EvidenceBundleFailure => {
try {
const record = parseEvidence(input);
return {
kind: "single-record",
cause,
message: `Expected an Evidence bundle with records and manifest arrays, but this JSON is one Evidence record (${record.evidence_id}). Export a session bundle with the MCP export_evidence_bundle tool and supply that file.`,
};
} catch {
} catch (recordFailure: unknown) {
if (!(recordFailure instanceof z.ZodError)) throw recordFailure;
return {
kind: "invalid-bundle",
cause,
message:
cause instanceof z.ZodError
? `Evidence bundle does not match the bundle schema ${describeValidationFailure(cause)}. Supply an unmodified bundle written by export_evidence_bundle or rea evidence-export.`
@@ -272,12 +280,19 @@ export const parseEvidenceBundle = (input: unknown): EvidenceBundle => {
return parseBundle(input, false);
};
/** Authenticate and seal file-owned payloads once for downstream zero-copy reuse. */
export const parseImmutableEvidenceBundle = (
/** Authenticate and seal snapshot-owned payloads, throwing validation failures. */
export const parseImmutableEvidenceBundle = (input: unknown): EvidenceBundle =>
parseBundle(input, true);
/** Read expected source failures as facts; implementation failures still throw. */
export const safeParseEvidenceBundle = (
input: unknown,
): EvidenceBundle => {
return parseBundle(input, true);
};
): Result<EvidenceBundle, EvidenceBundleFailure> => readBundle(input, false);
/** Authenticate and seal file-owned payloads once for downstream zero-copy reuse. */
export const safeParseImmutableEvidenceBundle = (
input: unknown,
): Result<EvidenceBundle, EvidenceBundleFailure> => readBundle(input, true);
const parseBundle = (input: unknown, seal: boolean): EvidenceBundle => {
if (typeof input === "object" && input !== null) {
@@ -291,8 +306,25 @@ const parseBundle = (input: unknown, seal: boolean): EvidenceBundle => {
export const readEvidenceBundle = (
input: unknown,
): Result<EvidenceBundle, EvidenceIntegrityError> => {
const parsed = safeParseEvidenceBundle(input);
if (parsed.ok) return parsed;
const { cause } = parsed.error;
return err(
new EvidenceIntegrityError(cause.message, {
cause,
userMessage: cause.message,
}),
);
};
const readBundle = (
input: unknown,
seal: boolean,
): Result<EvidenceBundle, EvidenceBundleFailure> => {
try {
return ok(parseEvidenceBundle(input));
return ok(
seal ? parseImmutableEvidenceBundle(input) : parseEvidenceBundle(input),
);
} catch (cause: unknown) {
if (
!(cause instanceof z.ZodError) &&
@@ -301,12 +333,7 @@ export const readEvidenceBundle = (
!(cause instanceof NonCanonicalEvidenceBundleError)
)
throw cause;
return err(
new EvidenceIntegrityError(cause.message, {
cause,
userMessage: cause.message,
}),
);
return err(describeEvidenceBundleFailure(input, cause));
}
};
@@ -1,20 +1,48 @@
import { writeFile } from "node:fs/promises";
import { readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished, vi } from "vitest";
import { z } from "zod";
import { createCli } from "../../src/cli.js";
import { compareEvidenceBundlesCommand } from "../../src/application/EvidenceBundleCommands.js";
import {
readEvidenceBundle,
readImmutableEvidenceBundle,
writeEvidenceBundle,
} from "../../src/application/EvidenceBundleFiles.js";
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
import { bundleComparisonResultSchema } from "../../src/domain/bundleComparison.js";
import { processEvidence } from "../../src/application/ReconstructionObligationLedger.fixture.js";
import { processCaptureSchema } from "../../src/domain/process/processCapture.js";
import {
createEvidenceBundle,
serializeEvidenceBundle,
} from "../../src/domain/evidenceBundle.js";
import * as evidenceBundleDomain from "../../src/domain/evidenceBundle.js";
import * as evidenceDomain from "../../src/domain/evidence.js";
import { createServer } from "../../src/server/createServer.js";
import { createTestBinarySession } from "../fixtures/binarySession.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const unexpectedBundleFailures = [
new TypeError("bundle authentication TypeError"),
new Error("bundle authentication Error"),
] as const;
const spyOnBundleParse = (failure: Error): void => {
const parser = vi
.spyOn(evidenceBundleDomain.evidenceBundleSchema, "parse")
.mockImplementation(() => {
throw failure;
});
onTestFinished(() => parser.mockRestore());
};
const validBundleFile = async (directory: string, name = "bundle.json") => {
const path = join(directory, name);
const bundle = evidenceBundleDomain.createEvidenceBundle([processEvidence()]);
await writeFile(path, evidenceBundleDomain.serializeEvidenceBundle(bundle));
return { bundle, path };
};
it.each([
new TypeError("capture validation implementation failed"),
new Error("capture validator failed"),
@@ -30,7 +58,9 @@ it.each([
const path = join(directory, "bundle.json");
await writeFile(
path,
serializeEvidenceBundle(createEvidenceBundle([processEvidence()])),
evidenceBundleDomain.serializeEvidenceBundle(
evidenceBundleDomain.createEvidenceBundle([processEvidence()]),
),
);
const validator = vi
.spyOn(processCaptureSchema, "safeParse")
@@ -45,6 +75,136 @@ it.each([
},
);
it.each(unexpectedBundleFailures)(
"preserves unexpected bundle authentication failures in mutable and immutable readers: $name",
async (failure) => {
const directory = await createTestTempDirectory(
"rea-bundle-reader-failure-",
);
const { path } = await validBundleFile(directory);
spyOnBundleParse(failure);
await expect(readEvidenceBundle(path)).rejects.toBe(failure);
await expect(readImmutableEvidenceBundle(path)).rejects.toBe(failure);
await expect(
compareEvidenceBundlesCommand({ leftPath: path, rightPath: path }),
).rejects.toBe(failure);
},
);
it.each(unexpectedBundleFailures)(
"does not publish output or retain resources when bundle authentication throws: $name",
async (failure) => {
const directory = await createTestTempDirectory(
"rea-bundle-write-failure-",
);
const { bundle } = await validBundleFile(directory, "input.json");
const outputPath = join(directory, "output.json");
const resources = new ArtifactResourceScope();
onTestFinished(async () => {
await resources.close();
});
spyOnBundleParse(failure);
await expect(
writeEvidenceBundle(bundle, outputPath, false, resources),
).rejects.toBe(failure);
expect(await readdir(directory)).toEqual(["input.json"]);
expect(resources.hasPending).toBe(false);
await resources.close();
},
);
it.each(unexpectedBundleFailures)(
"preserves unexpected bundle authentication failures through SDK import: $name",
async (failure) => {
const directory = await createTestTempDirectory(
"rea-bundle-import-failure-",
);
const { path } = await validBundleFile(directory);
const session = createTestBinarySession(() => {
throw new Error("Evidence import must not launch a provider");
});
const server = createServer({ kind: "session", session });
const client = new Client({ name: "bundle-import-failure", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
spyOnBundleParse(failure);
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const response = await client.callTool({
name: "import_evidence_bundle",
arguments: { path },
});
expect(response).toMatchObject({
isError: true,
content: [{ type: "text", text: failure.message }],
});
expect(response.structuredContent).toBeUndefined();
expect(session.exportEvidenceBundle().records).toEqual([]);
expect(session.exportEvidenceBundle().unknowns).toEqual([]);
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
},
);
it.each(unexpectedBundleFailures)(
"preserves the session ledger when bundle authentication throws: $name",
async (failure) => {
const directory = await createTestTempDirectory(
"rea-ledger-import-failure-",
);
const { bundle } = await validBundleFile(directory);
const existing = evidenceDomain.createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "observe",
parameters: {},
result: { retained: true },
},
);
const session = createTestBinarySession(() => {
throw new Error("Evidence import must not launch a provider");
});
onTestFinished(async () => {
await session.close();
});
expect(session.recordEvidence(existing).ok).toBe(true);
const before = session.exportEvidenceBundle();
spyOnBundleParse(failure);
expect(() => session.importEvidenceBundle(bundle)).toThrow(failure);
expect(session.exportEvidenceBundle()).toEqual(before);
},
);
it.each(unexpectedBundleFailures)(
"propagates unexpected standalone-record classification failures: $name",
async (failure) => {
const directory = await createTestTempDirectory(
"rea-bundle-classifier-failure-",
);
const path = join(directory, "record.json");
const record = processEvidence();
await writeFile(path, JSON.stringify(record));
const parser = vi
.spyOn(evidenceDomain, "parseEvidence")
.mockImplementation(() => {
throw failure;
});
onTestFinished(() => parser.mockRestore());
await expect(readImmutableEvidenceBundle(path)).rejects.toBe(failure);
},
);
it.each([
new TypeError("comparison output invariant failed"),
new RangeError("comparison computation failed"),
@@ -58,7 +218,12 @@ it.each([
"rea-bundle-compute-failure-",
);
const path = join(directory, "bundle.json");
await writeFile(path, serializeEvidenceBundle(createEvidenceBundle([])));
await writeFile(
path,
evidenceBundleDomain.serializeEvidenceBundle(
evidenceBundleDomain.createEvidenceBundle([]),
),
);
// Reach output validation through the actual command, reader, authentication
// and comparison. This fault is generated output, not the caller's bundle.
const output = vi
@@ -25,7 +25,7 @@ import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import {
createImmutableEvidenceBundle,
parseImmutableEvidenceBundle,
safeParseImmutableEvidenceBundle,
} from "../../src/domain/evidenceBundle.js";
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
import { deliverAnalysisEvidence } from "../../src/application/EvidenceBundleCommands.js";
@@ -190,7 +190,7 @@ it("admits bundle authentication before cloning and hashing retained records", a
availableBytes: 0,
}),
);
vi.mocked(parseImmutableEvidenceBundle).mockClear();
vi.mocked(safeParseImmutableEvidenceBundle).mockClear();
const resources = new ArtifactResourceScope();
try {
expect(
@@ -199,7 +199,7 @@ it("admits bundle authentication before cloning and hashing retained records", a
ok: false,
error: { _tag: "AnalysisResourceConstraintError", resource: "memory" },
});
expect(parseImmutableEvidenceBundle).not.toHaveBeenCalled();
expect(safeParseImmutableEvidenceBundle).not.toHaveBeenCalled();
} finally {
await resources.close();
}