fix: preserve source closure during snapshot replay (#8870)

* fix: preserve source closure during snapshot replay

* fix: revalidate restored snapshot sources lazily

* fix: bound and share source-closure checks
This commit is contained in:
morluto authored and GitHub committed 2026-11-07 16:22:21 +08:00
1 parent b31fc5d5fb
commit 441ba94086
9 files changed
+661 -63

No files matched your search

@@ -7,7 +7,8 @@ import {
ANALYSIS_SNAPSHOT_PROVIDER,
ANALYSIS_SNAPSHOT_TARGET,
} from "../../domain/analysisSnapshot.fixture.js";
import { createEvidence } from "../../domain/evidence.js";
import { createEvidence, immutableEvidence } from "../../domain/evidence.js";
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
import {
createEvidenceBundle,
evidenceBundleForTarget,
@@ -1304,6 +1305,154 @@ it("declines source-bearing snapshot entries while replaying omitted and empty s
expect(cache.entries()).toHaveLength(2);
});
it("replays source-bearing entries only when their complete retained closure exists", () => {
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const leaf = immutableEvidence(
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "leaf",
parameters: {},
result: "leaf observation",
}),
);
const source = immutableEvidence(
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "source",
parameters: {},
result: "source observation",
evidenceLinks: [leaf.evidence_id],
}),
);
const parameters = { address: "0x1000", document: "fixture" };
const execution = createAnalysisExecution(
"parent observation",
ANALYSIS_SNAPSHOT_PROVIDER,
{
subject: target,
analysisProfile: profile,
sourceEvidence: [source],
},
);
const parent = immutableEvidence(
createAnalysisExecutionEvidence(
"procedure_info",
parameters,
execution,
"observed",
),
);
const complete = new Map(
[parent, source, leaf].map((record) => [record.evidence_id, record]),
);
const input = {
target,
profile,
operation: "procedure_info" as const,
parameters,
execution: { ...execution, evidenceId: parent.evidence_id },
};
const missingLeaf = new AnalysisSnapshotCache();
missingLeaf.record(input, (id) =>
id === leaf.evidence_id ? undefined : complete.get(id),
);
expect(
missingLeaf.lookup(target, profile, "procedure_info", parameters),
).toBeUndefined();
const cache = new AnalysisSnapshotCache();
cache.record(input, (id) => complete.get(id));
expect(
cache
.lookup(target, profile, "procedure_info", parameters)
?.sourceEvidence?.map((record) => record.evidence_id),
).toEqual([source.evidence_id, parent.evidence_id]);
if (source.subject === null) throw new Error("Expected source subject");
const ledger = new EvidenceLedger();
for (const record of [parent, source, leaf])
expect(ledger.record(record).ok).toBe(true);
const movedSource = {
...source,
subject: {
...source.subject,
local_path: "/relocated/source",
name: "source",
},
};
const replacement = ledger.import(createEvidenceBundle([movedSource]));
if (!replacement.ok) throw replacement.error;
cache.refreshEvidenceMetadata(replacement.value.replacements);
expect(
cache.lookup(target, profile, "procedure_info", parameters)
?.sourceEvidence?.[0]?.subject?.local_path,
).toBe("/relocated/source");
const exported = cache.export(target, profile, ledger.export());
if (!exported.ok) throw exported.error;
const imported = new AnalysisSnapshotCache();
imported.stage(exported.value);
expect(
imported
.lookup(target, profile, "procedure_info", parameters)
?.sourceEvidence?.map((record) => record.evidence_id),
).toEqual([source.evidence_id, parent.evidence_id]);
expect(
imported.lookup(target, profile, "procedure_info", parameters)
?.sourceEvidence?.[0]?.subject?.local_path,
).toBe("/relocated/source");
const pruned = parseAnalysisSnapshot({
...exported.value,
evidence_bundle: createEvidenceBundle([parent, movedSource]),
});
const incomplete = new AnalysisSnapshotCache();
incomplete.stage(pruned);
const partial = new Map(
[parent, movedSource].map((record) => [record.evidence_id, record]),
);
expect(
incomplete.lookup(target, profile, "procedure_info", parameters, (id) =>
partial.get(id),
),
).toBeUndefined();
partial.set(leaf.evidence_id, leaf);
expect(
incomplete
.lookup(target, profile, "procedure_info", parameters, (id) =>
partial.get(id),
)
?.sourceEvidence?.map((record) => record.evidence_id),
).toEqual([source.evidence_id, parent.evidence_id]);
const otherSource = immutableEvidence(
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "other_source",
parameters: {},
result: "different source",
}),
);
const otherParent = immutableEvidence(
createAnalysisExecutionEvidence(
"procedure_info",
parameters,
{ ...execution, sourceEvidence: [otherSource] },
"observed",
),
);
const records = [parent, movedSource, leaf, otherParent, otherSource];
const ambiguous = new AnalysisSnapshotCache();
ambiguous.stage(
parseAnalysisSnapshot({
...exported.value,
evidence_bundle: createEvidenceBundle(records),
}),
);
const allRecords = new Map(
records.map((record) => [record.evidence_id, record]),
);
expect(
ambiguous.lookup(target, profile, "procedure_info", parameters, (id) =>
allRecords.get(id),
),
).toBeUndefined();
});
describe("executions that report truncated references", () => {
const record = (referencesTruncated: boolean | undefined) => {
const cache = new AnalysisSnapshotCache();
+63 -25
View File
@@ -46,6 +46,7 @@ import {
snapshotReplayBinding,
type SnapshotReplayBinding,
} from "./AnalysisSnapshotReplayBinding.js";
import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js";
const RELATIONSHIP_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
"procedure_callers",
@@ -162,9 +163,12 @@ export const SNAPSHOT_REPLAY_LIMITATION =
/** Bounded in-memory cache for one immutable binary identity. */
export class AnalysisSnapshotCache {
readonly #entries = new Map<string, AnalysisSnapshotEntry>();
// Null means imported provenance cannot be faithfully replayed. It is distinct
// from an entry recorded before an Evidence ID was assigned.
readonly #replayBindings = new Map<string, SnapshotReplayBinding | null>();
// Null is ambiguous provenance; a pending ID is an exact parent whose source
// closure was missing and can be checked again against the owning ledger.
readonly #replayBindings = new Map<
string,
SnapshotReplayBinding | { readonly pendingEvidenceId: string } | null
>();
readonly #workflowEntries = new Map<string, AnalysisSnapshotWorkflowEntry>();
#target: AnalysisSnapshotTarget | undefined;
#binding: AnalysisSnapshotBinding | undefined;
@@ -233,6 +237,10 @@ export class AnalysisSnapshotCache {
record,
]),
);
const lookup = (id: string) => records.get(id);
const checkClosure = createInvestigationEvidenceClosureChecker({
evidenceForAnalysis: lookup,
});
const ambiguous = new Set<string>();
for (const evidence of snapshot.evidence_bundle.records) {
const queryId = analysisQueryId(
@@ -257,11 +265,15 @@ export class AnalysisSnapshotCache {
else evidenceByQuery.set(queryId, evidence);
}
for (const [queryId, evidence] of evidenceByQuery) {
if (ambiguous.has(queryId)) {
this.#replayBindings.set(queryId, null);
continue;
}
this.#replayBindings.set(
queryId,
ambiguous.has(queryId)
? null
: snapshotReplayBinding(evidence, records),
snapshotReplayBinding(evidence, lookup, checkClosure) ?? {
pendingEvidenceId: evidence.evidence_id,
},
);
}
for (const entry of snapshot.workflow_entries) {
@@ -287,7 +299,12 @@ export class AnalysisSnapshotCache {
replacements.map(({ current }) => [current.evidence_id, current]),
);
for (const [queryId, binding] of this.#replayBindings) {
if (binding === null || binding.sourceEvidence === undefined) continue;
if (
binding === null ||
"pendingEvidenceId" in binding ||
binding.sourceEvidence === undefined
)
continue;
const sources = binding.sourceEvidence;
if (!sources.some((source) => currentRecords.has(source.evidence_id)))
continue;
@@ -490,6 +507,7 @@ export class AnalysisSnapshotCache {
profile: AnalysisProfileCommitment,
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
lookup?: (id: string) => Evidence | undefined,
): AnalysisExecution | undefined {
const queryId = analysisQueryId(
snapshotTarget(target),
@@ -499,10 +517,18 @@ export class AnalysisSnapshotCache {
);
const cached = this.#entries.get(queryId);
if (cached === undefined) return undefined;
const binding = this.#replayBindings.get(queryId);
let binding = this.#replayBindings.get(queryId);
// Do not turn a refused provenance selection into a fresh, unlinked record.
// The provider must produce a new observation before this query can replay.
if (binding === null) return undefined;
if (binding !== undefined && "pendingEvidenceId" in binding) {
const parent = lookup?.(binding.pendingEvidenceId);
if (parent === undefined || lookup === undefined) return undefined;
const restored = snapshotReplayBinding(parent, lookup);
if (restored === null) return undefined;
this.#replayBindings.set(queryId, restored);
binding = restored;
}
const cachedSubject = cached.execution.subject;
const subject =
cachedSubject == null
@@ -550,22 +576,31 @@ export class AnalysisSnapshotCache {
}
/** Record one successful immutable call unless the cache is full. */
record(input: {
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment;
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly execution: AnalysisExecution;
readonly evidence?: Evidence;
}): void {
record(
input: {
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment;
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly execution: AnalysisExecution;
readonly evidence?: Evidence;
},
lookup?: (id: string) => Evidence | undefined,
): void {
const { target, profile, operation, parameters, execution } = input;
this.select(target, profile);
// The snapshot format does not carry reference truncation; a replay would
// present a capped read as complete, so it is not recorded.
if (execution.referencesTruncated === true) return;
// Provider snapshot entries do not carry execution source records. Replay
// must not discard the source closure that supplies the parent's Evidence links.
if ((execution.sourceEvidence?.length ?? 0) > 0) return;
let sourceBinding: SnapshotReplayBinding | undefined;
if ((execution.sourceEvidence?.length ?? 0) > 0) {
if (execution.evidenceId === undefined || lookup === undefined) return;
const parent = lookup(execution.evidenceId);
if (parent === undefined) return;
const closed = snapshotReplayBinding(parent, lookup);
if (closed === null) return;
sourceBinding = closed;
}
const queryId = analysisQueryId(
snapshotTarget(target),
snapshotBinding(profile),
@@ -597,12 +632,15 @@ export class AnalysisSnapshotCache {
if (execution.evidenceId === undefined)
this.#replayBindings.delete(entry.query_id);
else
this.#replayBindings.set(entry.query_id, {
evidenceId: execution.evidenceId,
...(input.evidence === undefined
? {}
: { sourceEvidence: Object.freeze([input.evidence]) }),
});
this.#replayBindings.set(
entry.query_id,
sourceBinding ?? {
evidenceId: execution.evidenceId,
...(input.evidence === undefined
? {}
: { sourceEvidence: Object.freeze([input.evidence]) }),
},
);
}
/** Forget all target-bound entries. */
@@ -1,4 +1,5 @@
import type { Evidence } from "../../domain/evidence.js";
import { createInvestigationEvidenceClosureChecker } from "../investigation/InvestigationEvidence.js";
/** Authenticated observation identity and its immediate source records. */
export interface SnapshotReplayBinding {
@@ -9,11 +10,15 @@ export interface SnapshotReplayBinding {
/** Preserve direct links using the snapshot's authenticated immutable records. */
export const snapshotReplayBinding = (
evidence: Evidence,
records: ReadonlyMap<string, Evidence>,
lookup: (id: string) => Evidence | undefined,
checkClosure = createInvestigationEvidenceClosureChecker({
evidenceForAnalysis: lookup,
}),
): SnapshotReplayBinding | null => {
if (!checkClosure(evidence.evidence_links)) return null;
const sources: Evidence[] = [];
for (const id of evidence.evidence_links) {
const source = records.get(id);
const source = lookup(id);
// An external link without its source cannot be represented by sourceEvidence.
if (source === undefined) return null;
sources.push(source);
@@ -323,14 +323,16 @@ export abstract class BinarySessionRecords {
>,
): AnalysisExecution | undefined {
if (this.#snapshotInvalidated) return undefined;
return this.#snapshot.lookup(target, profile, operation, parameters);
return this.#snapshot.lookup(target, profile, operation, parameters, (id) =>
this.evidenceForAnalysis(id),
);
}
protected recordSnapshot(
input: Parameters<AnalysisSnapshotCache["record"]>[0],
): void {
if (this.#snapshotInvalidated) return;
this.#snapshot.record(input);
this.#snapshot.record(input, (id) => this.evidenceForAnalysis(id));
this.#emitSnapshotChanged();
}