fix(evidence): align bundle receipts and conflict recovery (#7849)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 05:09:54 +08:00
1 parent 4e663beeb8
commit 6c205d8a79
7 files changed
+198 -14

No files matched your search

+2 -5
View File
@@ -33,6 +33,7 @@ import {
readImmutableEvidenceBundle,
readComparisonEvidenceBundles,
writeEvidenceBundle,
evidenceBundleExportReceipt,
} from "./EvidenceBundleFiles.js";
import { inspectEvidenceBundle } from "./investigation/InspectEvidenceBundle.js";
import type { EvidenceRecordFilters } from "../domain/evidenceRecordSummary.js";
@@ -284,11 +285,7 @@ const projectWrite = (
written: Result<PublishedEvidenceFile, AnalysisError>,
): Result<JsonValue, AnalysisError> =>
written.ok
? ok({
path: written.value.path,
bytes: written.value.bytes,
records: bundle.records.length,
})
? ok(evidenceBundleExportReceipt(bundle, written.value))
: err(written.error);
/** Standalone bundle discovery shares the same read owner as import and comparison. */
+12
View File
@@ -12,6 +12,7 @@ import {
import {
EvidenceFileError,
EvidenceIntegrityError,
type PublishedEvidenceFile,
} from "../domain/evidenceErrors.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -36,6 +37,17 @@ type EvidenceWriteFailure =
| EvidenceIntegrityError
| AnalysisCancelledError;
/** Project a published bundle with record and residual-unknown revision counts. */
export const evidenceBundleExportReceipt = (
bundle: EvidenceBundle,
published: PublishedEvidenceFile,
) => ({
path: published.path,
bytes: published.bytes,
records: bundle.records.length,
unknowns: bundle.unknowns.length,
});
/** Read and validate an evidence bundle at the caller-supplied path. */
export const readEvidenceBundle = (
path: string,
@@ -4,6 +4,7 @@ import canonicalize from "canonicalize";
import { describe, expect, it } from "vitest";
import { EvidenceLedger } from "./EvidenceLedger.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import {
createEvidence,
withEvidenceCallerInputPath,
@@ -60,6 +61,21 @@ const input = (
...overrides,
});
const mutation = (label: string) => evidence(`mutation-${label}`);
const expectHistoryConflict = (
error: import("../../domain/analysisErrorBase.js").AnalysisError,
unknownId: string,
) => {
expect(projectAnalysisError(error)).toMatchObject({
code: "evidence_integrity_mismatch",
retryable: false,
message: expect.stringContaining("conflicts with a revision retained"),
remediation: { action: expect.stringContaining("cannot reconcile") },
details: {
reason: "Conflicting unknown revision",
diagnostics: { unknown_id: unknownId, revision: 2 },
},
});
};
const update = (
unknown: ResidualUnknown,
overrides: Readonly<Record<string, unknown>> = {},
@@ -432,7 +448,10 @@ describe("residual unknown registry resolution and history", () => {
).ok,
).toBe(true);
const before = serializeEvidenceBundle(base.export());
expect(base.import(branch.export()).ok).toBe(false);
const conflict = base.import(branch.export());
expect(conflict.ok).toBe(false);
if (conflict.ok) throw new Error("Expected divergent history refusal");
expectHistoryConflict(conflict.error, created.value.unknown_id);
expect(base.get(source.evidence_id)?.caller_input_path).toBe("./original");
expect(serializeEvidenceBundle(base.export())).toBe(before);
});
@@ -146,7 +146,16 @@ export class EvidenceLedger {
existing !== undefined &&
existing.revision_digest !== unknown.revision_digest
)
return err(new EvidenceIntegrityError("Conflicting unknown revision"));
return err(
new EvidenceIntegrityError("Conflicting unknown revision", {
userMessage:
"The incoming residual-unknown history conflicts with a revision retained in this session. Inspect each history in a separate session; importing the unchanged bundle into this session cannot reconcile them.",
diagnostics: {
unknown_id: unknown.unknown_id,
revision: unknown.revision,
},
}),
);
pendingUnknowns.set(key, unknown);
if (existing === undefined) unknownsAdded += 1;
}
+2 -6
View File
@@ -6,6 +6,7 @@ import type { BinarySessionPort } from "../application/binary/BinarySessionPort.
import {
readImmutableEvidenceBundle,
writeEvidenceBundle,
evidenceBundleExportReceipt,
} from "../application/EvidenceBundleFiles.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
@@ -60,12 +61,7 @@ const registerExportEvidenceTool = ({
);
return written.ok
? server.delivery.toCallToolResult(
ok({
path: written.value.path,
bytes: written.value.bytes,
records: bundle.records.length,
unknowns: bundle.unknowns.length,
}),
ok(evidenceBundleExportReceipt(bundle, written.value)),
exportContract,
)
: server.delivery.toCallToolResult(written, exportContract);
@@ -213,6 +213,42 @@ cliTest(
},
);
cliTest(
"reports exported residual-unknown revision counts",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-evidence-export-counts-");
for (const [label, bundle] of [
["empty", createEvidenceBundle([])],
["revisions", revisionBundle()],
] as const) {
const source = join(root, `${label}.json`);
await writeFile(source, JSON.stringify(bundle));
for (const full of [false, true]) {
const output = join(root, `${label}-${String(full)}.json`);
const result = await cli.run({
arguments: [
"evidence-export",
source,
output,
"--json",
...(full ? ["--full-output"] : []),
],
});
expect(result.exitCode, result.stderr).toBe(0);
const receipt = full
? (result.json as { data: unknown }).data
: result.json;
expect(receipt).toMatchObject({
path: output,
records: bundle.records.length,
unknowns: bundle.unknowns.length,
});
expect(JSON.parse(await readFile(output, "utf8"))).toEqual(bundle);
}
}
},
);
cliTest(
"reports a standalone Evidence record as the wrong input file kind",
async ({ cli }) => {
+116 -1
View File
@@ -19,7 +19,17 @@ import { err, ok as resultOk } from "../../../src/domain/result.js";
import { observed as ok } from "../../fixtures/analysisExecution.js";
import { createServer } from "../../../src/server/createServer.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import {
createEvidenceBundle,
parseEvidenceBundle,
} from "../../../src/domain/evidenceBundle.js";
import {
createResidualUnknown,
updateResidualUnknown,
} from "../../../src/domain/residualUnknown.js";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js";
import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js";
@@ -92,6 +102,111 @@ const structured = (result: CallToolResult): Record<string, unknown> => {
return Object.fromEntries(Object.entries(result.structuredContent));
};
it("rejects divergent imported histories without suggesting an unchanged retry", async () => {
const root = await createTestTempDirectory("rea-unknown-divergent-");
const provider = { id: "fixture", name: "Fixture", version: "1" };
const records = ["create", "left", "right"].map((operation) =>
createEvidence(undefined, provider, {
operation,
parameters: {},
result: {},
}),
);
const [created, left, right] = records;
if (created === undefined || left === undefined || right === undefined)
throw new Error("Missing fixture records");
const fields = {
question: "Does this branch conflict?",
domain: "fixture",
severity: "medium" as const,
required_authority: null,
required_confidence: "observed" as const,
required_environment: null,
recommended_probes: [],
relationships: [],
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
};
const initial = createResidualUnknown(fields, created.evidence_id, null);
const update = {
...fields,
unknown_id: initial.unknown_id,
expected_revision: 1,
status: "investigating" as const,
resolution: null,
};
const first = createEvidenceBundle(records, [
initial,
updateResidualUnknown(initial, update, left.evidence_id),
]);
const second = createEvidenceBundle(records, [
initial,
updateResidualUnknown(
initial,
{ ...update, status: "blocked" },
right.evidence_id,
),
]);
const paths = [join(root, "first.json"), join(root, "second.json")];
await Promise.all([
writeFile(paths[0]!, JSON.stringify(first)),
writeFile(paths[1]!, JSON.stringify(second)),
]);
const session = createTestBinarySession(() => {
throw new Error("no target");
});
const server = createServer({ kind: "session", session });
const client = new Client({ name: "conflicting-history", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(client, server);
await server.connect(serverTransport);
await client.connect(clientTransport);
const emptyExport = await client.callTool({
name: "export_evidence_bundle",
arguments: { path: join(root, "empty-export.json") },
});
expect(emptyExport.isError).not.toBe(true);
expect(emptyExport.structuredContent).toMatchObject({
result: { records: 0, unknowns: 0 },
});
const imported = await client.callTool({
name: "import_evidence_bundle",
arguments: { path: paths[0]! },
});
expect(imported.isError).not.toBe(true);
const exportResult = await client.callTool({
name: "export_evidence_bundle",
arguments: { path: join(root, "revisions-export.json") },
});
expect(exportResult.isError).not.toBe(true);
expect(exportResult.structuredContent).toMatchObject({
result: { records: records.length, unknowns: 2 },
});
const before = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
for (let attempt = 0; attempt < 2; attempt += 1) {
const conflict = await client.callTool({
name: "import_evidence_bundle",
arguments: { path: paths[1]! },
});
expect(conflict.isError).toBe(true);
expect(parseMcpToolError(conflict).error).toMatchObject({
code: "evidence_integrity_mismatch",
retryable: false,
remediation: { action: expect.stringContaining("cannot reconcile") },
details: { diagnostics: { unknown_id: initial.unknown_id, revision: 2 } },
});
const after = await client.callTool({
name: "get_evidence_bundle",
arguments: {},
});
expect(after.structuredContent).toEqual(before.structuredContent);
}
});
it("does not record capability unavailability without supporting Evidence", async () => {
const session = createTestBinarySession(
providerWithCapabilities(["procedure_pseudo_code"]),