fix(cli): verify partial Evidence closure before publication

This commit is contained in:
morluto committed 2026-11-10 15:25:36 -05:00
1 parent 3345019fe1
commit dc7594e85f
6 files changed
+148 -97

No files matched your search

+6 -3
View File
@@ -1419,9 +1419,12 @@ to `--evidence_output` under the same new-file and atomic rules, then keeps its
error code, message and remediation when cleanup is confirmed. With `--detail summary`,
`details.partial_observation` holds the summary view of the partial Evidence
instead of the complete record, and `details.diagnostics.evidence_bundle`
reports the written path and byte count. If publication is refused before
commit, for example because the file already exists, the complete partial
Evidence stays inline and `details.diagnostics.evidence_bundle_error` gives the
reports the written path and byte count. Linked source records must all be
available before publication; missing sources leave the destination unchanged
and are reported in `details.source_closure`. A provider cleanup failure can
still publish its captured Evidence without clearing the cleanup error or its
resource ownership. If publication is refused before commit, for example because
the file already exists, the complete partial Evidence stays inline and `details.diagnostics.evidence_bundle_error` gives the
refusal's code, message and a limitation. A refusal after commit keeps the
receipt in `details.diagnostics.evidence_bundle` and the delivered view: when
staging cleanup stays incomplete, the code becomes `cleanup_incomplete`, the
+12
View File
@@ -1,3 +1,4 @@
import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js";
import { analysisEvidenceSources } from "./AnalysisEvidenceSources.js";
import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
@@ -180,6 +181,17 @@ export const publishEvidenceDelivery = async (input: {
readonly signal?: AbortSignal | undefined;
}): Promise<Result<PublishedEvidenceDelivery, AnalysisError>> => {
const { signal, operation } = input;
// Partial analysis can hold only some of its linked sources. Both delivery
// adapters must establish closure before committing a complete source bundle.
const sources = new Map(
input.bundle.records.map((record) => [record.evidence_id, record]),
);
const closure = resolveInvestigationEvidenceClosure(
{ evidenceForAnalysis: (id) => sources.get(id) },
[input.complete.evidence_id],
);
if (!closure.ok)
return err(closure.error.retainPartialObservation(input.complete));
const written = await persistEvidenceBundleCommand(
input.bundle,
input.outputPath,
@@ -25,6 +25,7 @@ import { parseConfig } from "../../../src/config/parseConfig.js";
import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { err, ok } from "../../../src/domain/result.js";
@@ -164,7 +165,7 @@ it("reports the CLI last page and its observed node budget separately", async ()
});
});
it("preserves the completed trace's source closure when final close fails", async () => {
it("publishes the completed trace with its sources when final close fails", async () => {
const run = await serveTraceCli(
valueTraceDiamond({ sessionSubject: true }).port,
async () =>
@@ -183,16 +184,16 @@ it("preserves the completed trace's source closure when final close fails", asyn
const trace = parseEvidence(failed.details.partial_observation);
expect(trace.evidence_links.length).toBeGreaterThan(0);
expect(failed.details).toMatchObject({
source_closure: {
publication: "not_published",
complete: true,
omitted_count: 0,
records: expect.arrayContaining([
expect.objectContaining({ evidence_id: trace.evidence_links[0] }),
]),
},
resources: ["owned session"],
diagnostics: { evidence_bundle: { path: destination } },
});
await expect(readFile(destination)).rejects.toThrow();
expect(failed.details).not.toHaveProperty("source_closure");
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(destination, "utf8")),
);
expect(bundle.records.map((record) => record.evidence_id)).toEqual(
expect.arrayContaining([trace.evidence_id, ...trace.evidence_links]),
);
});
it.each([
@@ -1,3 +1,4 @@
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
@@ -2245,7 +2246,7 @@ it("retains completed function sources when cancellation arrives as execution re
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("retains function sources when completed parent admission fails", async () => {
it("publishes captured function sources when completed parent admission fails", async () => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-parent-admission-",
);
@@ -2276,17 +2277,21 @@ it("retains function sources when completed parent admission fails", async () =>
detail: "complete",
evidenceOutput: outputPath,
});
const failure = output as {
details: {
partial_observation: Evidence;
source_closure: { records: Evidence[]; complete: boolean };
};
};
expect(failure.details.source_closure.complete).toBe(true);
expect(
failure.details.source_closure.records.map((record) => record.evidence_id),
).toEqual(failure.details.partial_observation.evidence_links);
await expect(readFile(outputPath)).rejects.toMatchObject({ code: "ENOENT" });
expect(output).toMatchObject({
code: "evidence_integrity_mismatch",
details: { diagnostics: { evidence_bundle: { path: outputPath } } },
});
expect(output).not.toHaveProperty("details.source_closure");
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(outputPath, "utf8")),
);
const parent = bundle.records.find(
(record) => record.operation === "analyze_function",
);
expect(parent).toBeDefined();
expect(bundle.records.map((record) => record.evidence_id).sort()).toEqual(
[parent!.evidence_id, ...parent!.evidence_links].sort(),
);
});
it("runs decompile export against the provider after loading a pseudocode snapshot", async () => {
@@ -20,6 +20,7 @@ import {
parseEvidence,
type Evidence,
} from "../../../src/domain/evidence.js";
import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { jsonByteLength } from "../../../src/domain/jsonSerialization.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
@@ -208,7 +209,7 @@ const wideSource = (links: readonly string[]) =>
evidenceLinks: links,
});
it.each(["final-close cancellation", "refused publication"] as const)(
it.each(["final-close cancellation", "missing sources"] as const)(
"keeps the original failure when a captured source has a data-sized link set: %s",
async (failure) => {
const root = await createTestTempDirectory("rea-closure-wide-");
@@ -218,17 +219,18 @@ it.each(["final-close cancellation", "refused publication"] as const)(
const wide = wideSource(links);
const observation = observed([wide.evidence_id]);
const requested = delivery(root, [wide]);
if (failure === "refused publication")
await writeFile(requested.evidenceOutput, "original");
const output = await publishCliAnalysisEvidence(
failure === "refused publication"
failure === "missing sources"
? ok(observation)
: err(cancelledWith(observation)),
requested,
);
expect(output).toMatchObject(
failure === "refused publication"
? { details: { reason: "exists" } }
failure === "missing sources"
? {
code: "evidence_integrity_mismatch",
details: { reason: "missing" },
}
: { code: "cancelled" },
);
const closure = closureOf(output);
@@ -593,7 +595,7 @@ it.each(["interrupt", "cleanup-failure", "interrupt-cleanup-failure"] as const)(
);
it.each(["interrupt", "cleanup-failure"] as const)(
"retains captured source closure when direct finalization fails: %s",
"preserves captured sources when direct finalization fails: %s",
async (scenario) => {
const workflow = await composedWorkflowFixture();
const controller = new AbortController();
@@ -635,6 +637,23 @@ it.each(["interrupt", "cleanup-failure"] as const)(
failure.details.partial_observation,
).evidence_links;
expect(links.length).toBeGreaterThan(0);
if (scenario === "cleanup-failure") {
const path = join(root, "evidence.json");
expect(output).toMatchObject({
details: { diagnostics: { evidence_bundle: { path } } },
});
expect(output).not.toHaveProperty("details.source_closure");
const bundle = parseEvidenceBundle(
JSON.parse(await readFile(path, "utf8")),
);
expect(bundle.records.map((record) => record.evidence_id).sort()).toEqual(
[
parseEvidence(failure.details.partial_observation).evidence_id,
...links,
].sort(),
);
return;
}
expect(closureOf(output)).toMatchObject({
publication: "not_published",
complete: true,
@@ -58,10 +58,7 @@ afterEach(() => {
});
type FinalClose =
| "control"
| "interrupt"
| "cleanup-failure"
| "interrupt-cleanup-failure";
"control" | "interrupt" | "cleanup-failure" | "interrupt-cleanup-failure";
const fixture = async (dependencyText = "Synthetic dependency") => {
const root = await createTestTempDirectory("rea-source-closure-");
@@ -185,8 +182,7 @@ const DATA_SIZED_LINKS = 200_000;
const closureOf = (stdout: string) => {
const failure = analysisErrorProjectionSchema.parse(JSON.parse(stdout));
const closure = failure.details?.source_closure as unknown as
| UndeliveredSourceClosure
| undefined;
UndeliveredSourceClosure | undefined;
if (closure === undefined) throw new Error(`No source closure: ${stdout}`);
return { failure, closure };
};
@@ -199,7 +195,7 @@ it.each([
["cleanup-failure", "cleanup_incomplete", 1],
["interrupt-cleanup-failure", "cleanup_incomplete", 130],
] as const)(
"keeps captured linked sources through final close: %s",
"preserves captured linked sources through final close: %s",
async (finalClose, code, exitCode) => {
const input = await fixture();
const { stdout, exitCode: observedExit } = await runMatching(
@@ -207,8 +203,30 @@ it.each([
finalClose,
);
expect(observedExit).toBe(exitCode);
const { failure, closure } = closureOf(stdout);
const failure = analysisErrorProjectionSchema.parse(JSON.parse(stdout));
expect(failure.code).toBe(code);
if (finalClose === "cleanup-failure") {
expect(failure.details).toMatchObject({
resources: ["owned-root"],
diagnostics: { evidence_bundle: { path: input.output } },
});
expect(failure.details).not.toHaveProperty("source_closure");
const saved = parseEvidenceBundle(
JSON.parse(await readFile(input.output, "utf8")),
);
const observation = parseEvidence(failure.details?.partial_observation);
expect(saved.records.map((record) => record.evidence_id).sort()).toEqual(
[
observation.evidence_id,
input.left.evidence_id,
input.right.evidence_id,
input.dependency.evidence_id,
].sort(),
);
expect(host.listeners.size).toBe(0);
return;
}
const { closure } = closureOf(stdout);
const observation = parseEvidence(failure.details?.partial_observation);
expect(observation.evidence_links).toEqual(
[input.left.evidence_id, input.right.evidence_id].sort(),
@@ -247,63 +265,56 @@ it("keeps the fault on the final close when another scope closes first", async (
expect(closure.complete).toBe(true);
});
it.each([
["interrupt", "cancelled"],
["cleanup-failure", "cleanup_incomplete"],
] as const)(
"keeps the typed failure for a captured source with a data-sized link set: %s",
async (finalClose, code) => {
// Repeated links are valid Evidence, and only one distinct record backs them.
const ancestor = createEvidence(undefined, nativeMatchingProvider, {
operation: "fixture_ancestor",
parameters: {},
result: { source: "Synthetic ancestor" },
});
const wide = createEvidence(undefined, nativeMatchingProvider, {
operation: "fixture_dependency",
parameters: {},
result: { source: "Synthetic wide dependency" },
evidenceLinks: Array.from(
{ length: DATA_SIZED_LINKS },
() => ancestor.evidence_id,
),
});
const input = await fixture();
const left = nativeMatchingInventory(
"1",
[{ address: "0x10", value: "main" }],
[wide.evidence_id],
);
await writeFile(
input.sources,
JSON.stringify(createEvidenceBundle([ancestor, wide])),
);
await writeFile(
input.request,
JSON.stringify({ left, right: input.right, detail: "complete" }),
);
const { stdout } = await runMatching(input, finalClose, {
environment: {
REA_NATIVE_MATCHING_REPRESENTATION_BYTES: String(1024 ** 3),
},
});
const { failure, closure } = closureOf(stdout);
expect(failure.code).toBe(code);
expect(parseEvidence(failure.details?.partial_observation).operation).toBe(
"match_native_functions",
);
// The closure is deduplicated: each record is examined and reported once.
expect(recordIds(closure)).toEqual(
[ancestor.evidence_id, left.evidence_id, input.right.evidence_id].sort(),
);
expect(closure).toMatchObject({
complete: false,
omitted_count: 1,
omitted: [{ evidence_id: wide.evidence_id, reason: "not_inlined" }],
});
},
60_000,
);
it("keeps cancellation typed for a captured source with a data-sized link set", async () => {
// Repeated links are valid Evidence, and only one distinct record backs them.
const ancestor = createEvidence(undefined, nativeMatchingProvider, {
operation: "fixture_ancestor",
parameters: {},
result: { source: "Synthetic ancestor" },
});
const wide = createEvidence(undefined, nativeMatchingProvider, {
operation: "fixture_dependency",
parameters: {},
result: { source: "Synthetic wide dependency" },
evidenceLinks: Array.from(
{ length: DATA_SIZED_LINKS },
() => ancestor.evidence_id,
),
});
const input = await fixture();
const left = nativeMatchingInventory(
"1",
[{ address: "0x10", value: "main" }],
[wide.evidence_id],
);
await writeFile(
input.sources,
JSON.stringify(createEvidenceBundle([ancestor, wide])),
);
await writeFile(
input.request,
JSON.stringify({ left, right: input.right, detail: "complete" }),
);
const { stdout } = await runMatching(input, "interrupt", {
environment: {
REA_NATIVE_MATCHING_REPRESENTATION_BYTES: String(1024 ** 3),
},
});
const { failure, closure } = closureOf(stdout);
expect(failure.code).toBe("cancelled");
expect(parseEvidence(failure.details?.partial_observation).operation).toBe(
"match_native_functions",
);
// The closure is deduplicated: each record is examined and reported once.
expect(recordIds(closure)).toEqual(
[ancestor.evidence_id, left.evidence_id, input.right.evidence_id].sort(),
);
expect(closure).toMatchObject({
complete: false,
omitted_count: 1,
omitted: [{ evidence_id: wide.evidence_id, reason: "not_inlined" }],
});
}, 60_000);
it("hands off the same closure for summary delivery and permits a clean retry", async () => {
const input = await fixture();