From dc7594e85f6193b1da0cda9c858bcd3ca923ef56 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:25:36 -0500 Subject: [PATCH] fix(cli): verify partial Evidence closure before publication --- docs/getting-started/cli.md | 9 +- src/application/EvidenceBundleCommands.ts | 12 ++ .../traceNativeValuesCompletenessCli.test.ts | 21 +-- .../directAnalysisSnapshotReplay.test.ts | 29 ++-- .../undeliveredSourceClosure.test.ts | 33 +++- .../oneShotSourceClosure.test.ts | 141 ++++++++++-------- 6 files changed, 148 insertions(+), 97 deletions(-) diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index c2eadceb15..39151094a6 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -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 diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index cbd508cac3..3f4cc35192 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -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> => { 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, diff --git a/tests/boundary/cli/traceNativeValuesCompletenessCli.test.ts b/tests/boundary/cli/traceNativeValuesCompletenessCli.test.ts index d6629a9a93..bf6c881504 100644 --- a/tests/boundary/cli/traceNativeValuesCompletenessCli.test.ts +++ b/tests/boundary/cli/traceNativeValuesCompletenessCli.test.ts @@ -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([ diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 7c16297773..1ad3db81fb 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -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 () => { diff --git a/tests/boundary/filesystem/undeliveredSourceClosure.test.ts b/tests/boundary/filesystem/undeliveredSourceClosure.test.ts index 446d0234ce..ae7444d84b 100644 --- a/tests/boundary/filesystem/undeliveredSourceClosure.test.ts +++ b/tests/boundary/filesystem/undeliveredSourceClosure.test.ts @@ -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, diff --git a/tests/process-global/oneShotSourceClosure.test.ts b/tests/process-global/oneShotSourceClosure.test.ts index 2ce020b86c..a13e187f3d 100644 --- a/tests/process-global/oneShotSourceClosure.test.ts +++ b/tests/process-global/oneShotSourceClosure.test.ts @@ -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();