diff --git a/docs/cli.md b/docs/cli.md index 7d7f2823bb..307fa6f3d3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -491,6 +491,11 @@ Dispatching a provider metadata write invalidates the active session's snapshot even if its reply fails: the provider may already have applied the write. Close and reopen the target before importing or saving another snapshot. +Saved snapshots include only cache entries whose subject identifies the selected +target. Unbound observations and observations of child artifacts remain available +in the open session and its Evidence bundle; they are excluded from the target's +saved replay entries without changing their subjects. + Snapshots retain eligible target-scoped question histories in full, including mutation Evidence recorded while another target was active. Observations and related questions must still belong to the saved target. If those dependencies diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index dae7084061..93e73104ad 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -8,7 +8,10 @@ import { ANALYSIS_SNAPSHOT_TARGET, } from "../../domain/analysisSnapshot.fixture.js"; import { createEvidence } from "../../domain/evidence.js"; -import { createEvidenceBundle } from "../../domain/evidenceBundle.js"; +import { + createEvidenceBundle, + evidenceBundleForTarget, +} from "../../domain/evidenceBundle.js"; import { ok } from "../../domain/result.js"; import { createAnalysisExecution } from "../AnalysisProvider.js"; import { @@ -291,6 +294,90 @@ it.each(["provider", "workflow"] as const)( }, ); +it.each(["provider", "workflow"] as const)( + "exports target-scoped %s rows without changing retained observations", + (kind) => { + const cache = new AnalysisSnapshotCache(); + const ledger = new EvidenceLedger(); + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const operation = kind === "provider" ? "procedure_info" : "trace_feature"; + const provider = + kind === "provider" ? ANALYSIS_SNAPSHOT_PROVIDER : REA_WORKFLOW_PROVIDER; + const analysisProfile = + kind === "provider" + ? profile + : workflowAnalysisProfile(profile, operation); + const child = { ...target, path: "/tmp/child", sha256: "b".repeat(64) }; + for (const [index, subject] of [target, null, child].entries()) { + const parameters = { address: `0x${index}000` }; + const result = { observation: index }; + const execution = createAnalysisExecution(result, provider, { + subject, + analysisProfile, + }); + const input = { + target, + profile, + operation, + parameters, + execution: { ...execution, subject, analysisProfile }, + }; + if (kind === "provider") + cache.record({ ...input, operation: "procedure_info" }); + else cache.recordWorkflow(input); + expect( + ledger.record( + createEvidence(subject ?? undefined, provider, { + operation, + parameters, + result, + analysisProfile, + confidence: kind === "provider" ? "observed" : "derived", + }), + ).ok, + ).toBe(true); + } + const before = + kind === "provider" ? cache.entries() : cache.workflowEntries(); + expect(before).toHaveLength(3); + const evidence = ledger.export(); + const exported = cache.export( + target, + profile, + evidenceBundleForTarget(evidence, target.sha256), + ); + if (!exported.ok) throw exported.error; + const snapshot = parseAnalysisSnapshot( + JSON.parse(serializeAnalysisSnapshot(exported.value)), + ); + const saved = + kind === "provider" ? snapshot.entries : snapshot.workflow_entries; + expect(saved).toHaveLength(1); + expect(saved[0]?.execution.subject?.sha256).toBe(target.sha256); + expect(snapshot.evidence_bundle.records).toHaveLength(1); + expect( + kind === "provider" ? cache.entries() : cache.workflowEntries(), + ).toEqual(before); + expect(ledger.export()).toEqual(evidence); + const restored = new AnalysisSnapshotCache(); + expect(restored.stage(snapshot)).toBe(1); + expect( + kind === "provider" ? restored.entries() : restored.workflowEntries(), + ).toEqual(saved); + if (kind === "provider") { + expect( + cache.lookup(target, profile, "procedure_info", { address: "0x1000" }) + ?.subject, + ).toBeNull(); + expect( + cache.lookup(target, profile, "procedure_info", { address: "0x2000" }) + ?.subject?.sha256, + ).toBe(child.sha256); + } + }, +); + it("counts newly retained direct and workflow bindings when staging", () => { const source = new AnalysisSnapshotCache(); const target = ANALYSIS_SNAPSHOT_TARGET; diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 99cdc2f0f4..4e90c3d8e6 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -291,13 +291,19 @@ export class AnalysisSnapshotCache { try { const snapshotTargetIdentity = snapshotTarget(target); const binding = snapshotBinding(profile); - const entries = [...this.#entries.values()].sort((left, right) => - compareUnicodeCodePoints(left.query_id, right.query_id), - ); - const workflows = [...this.#workflowEntries.values()].sort( - (left, right) => + // In-memory replay can preserve unbound and child-artifact observations. + // A persisted target snapshot can authenticate only rows whose Evidence + // has that target subject; the record owner applies the same scope to its bundle. + const entries = [...this.#entries.values()] + .filter((entry) => entry.execution.subject?.sha256 === target.sha256) + .sort((left, right) => compareUnicodeCodePoints(left.query_id, right.query_id), - ); + ); + const workflows = [...this.#workflowEntries.values()] + .filter((entry) => entry.execution.subject?.sha256 === target.sha256) + .sort((left, right) => + compareUnicodeCodePoints(left.query_id, right.query_id), + ); // Parsing owns the returned JSON and metadata; pre-cloning the same // payloads here only adds another full materialization. return ok( diff --git a/src/application/binary/BinarySessionLiveness.test.ts b/src/application/binary/BinarySessionLiveness.test.ts index c7455328be..72c1c56f28 100644 --- a/src/application/binary/BinarySessionLiveness.test.ts +++ b/src/application/binary/BinarySessionLiveness.test.ts @@ -73,6 +73,81 @@ it("preserves an explicitly unbound provider observation", async () => { } }); +it("keeps unbound observations out of persisted target snapshots", async () => { + const { session, deep } = openSession(); + const directory = await createTestTempDirectory("rea-unbound-snapshot-"); + const path = join(directory, "snapshot.json"); + const parameters = { document: "0", address: "0x1000" }; + try { + expect((await session.open(process.execPath)).ok).toBe(true); + const bound = await session.execute("address_name", parameters); + expect(bound.ok).toBe(true); + const child = deep.created[0]; + if (child === undefined || !bound.ok) + throw new Error("Expected bound read"); + child.execute = () => + Promise.resolve( + ok( + createAnalysisExecution("unbound observation", deepIdentity, { + subject: null, + }), + ), + ); + const execution = await session.execute("address_name", { + ...parameters, + address: "0x2000", + }); + expect(execution.ok).toBe(true); + if (!execution.ok) return; + expect(execution.value.subject).toBeNull(); + expect(session.exportEvidenceBundle().records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subject: null, + normalized_result: "unbound observation", + }), + ]), + ); + const snapshot = session.exportAnalysisSnapshot(); + expect(snapshot.ok, snapshot.ok ? undefined : snapshot.error.message).toBe( + true, + ); + if (!snapshot.ok) return; + expect(snapshot.value.entries).toHaveLength(1); + expect(snapshot.value.entries[0]?.parameters).toEqual(parameters); + const unboundReplay = await session.execute("address_name", { + ...parameters, + address: "0x2000", + }); + expect(unboundReplay.ok).toBe(true); + if (unboundReplay.ok) { + expect(unboundReplay.value.subject).toBeNull(); + expect(unboundReplay.value.evidenceId).toBe(execution.value.evidenceId); + } + expect( + (await session.writeAnalysisSnapshot(snapshot.value, path, false)).ok, + ).toBe(true); + expect((await session.close()).ok).toBe(true); + expect( + ( + await session.open(process.execPath, { + loadSnapshot: () => session.readAnalysisSnapshot(path), + }) + ).ok, + ).toBe(true); + const replay = await session.execute("address_name", parameters); + expect(replay.ok).toBe(true); + if (replay.ok) { + expect(replay.value.evidenceId).toBe(bound.value.evidenceId); + expect(replay.value.limitations).toContain( + "Loaded from a local REA analysis snapshot; this call did not re-run the provider.", + ); + } + } finally { + await session.close(); + } +}); + describe("BinarySession open liveness", () => { it("retires a locally blocked owner even when its native health is idle", async () => { const { session, deep } = openSession(); diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 13294cfc2a..2d5221fee6 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -213,8 +213,12 @@ const makeProvider = ( }, ], list_documents: ["fixture"], - list_procedures: ["0x1000"], - list_strings: { "0x1000": "fixture" }, + list_procedures: [ + { address: "0x1000", value: "fixture" }, + ] satisfies z.infer, + list_strings: [ + { address: "0x1000", value: "fixture" }, + ] satisfies z.infer, analyze_function: functionDossier, list_names: [ { address: "0x1000", value: "fixture" },