mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 16:26:36 +00:00
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:
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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user