fix(snapshot): export only target-bound observations

This commit is contained in:
morluto
2026-10-27 07:55:06 +08:00
parent 7fa54e2d62
commit 2ad599dace
5 changed files with 186 additions and 9 deletions
+5
View File
@@ -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
@@ -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;
@@ -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(
@@ -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();
@@ -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<typeof nativeInventoryRowsSchemas.names>,
list_strings: [
{ address: "0x1000", value: "fixture" },
] satisfies z.infer<typeof nativeInventoryRowsSchemas.strings>,
analyze_function: functionDossier,
list_names: [
{ address: "0x1000", value: "fixture" },