refactor(investigation): share immutable evidence reads

This commit is contained in:
morluto committed 2026-10-23 08:20:42 +08:00
1 parent b7f8bbb49f
commit 74ed67da7f
15 files changed
+211 -82

No files matched your search

@@ -107,9 +107,9 @@ export abstract class BinarySessionRecords {
return this.#records.exportEvidenceBundle();
}
/** Borrow a sealed bundle for complete serialization without cloning retained graphs. */
evidenceBundleForSerialization(): EvidenceBundle {
return this.#records.evidenceBundleForSerialization();
/** Borrow the record owner's immutable bundle for read-only workflows. */
immutableEvidenceBundle(): EvidenceBundle {
return this.#records.immutableEvidenceBundle();
}
/** Persist one provider-neutral snapshot under this session's resource owner. */
@@ -138,13 +138,13 @@ describe("evidence ledger recording", () => {
expect(Reflect.set(detached, "operation", "forged")).toBe(true);
expect(ledger.record(detached).ok).toBe(false);
expect(ledger.forAnalysis(evidence.evidence_id)).toBe(borrowed);
const snapshot = ledger.forSerialization();
const snapshot = ledger.immutableBundle();
expect(snapshot).toEqual(ledger.export());
expect(snapshot.records[0]).toBe(borrowed);
expect(Object.isFrozen(snapshot.records)).toBe(true);
ledger.clear();
expect(ledger.forAnalysis(evidence.evidence_id)).toBeUndefined();
expect(ledger.forSerialization().records).toEqual([]);
expect(ledger.immutableBundle().records).toEqual([]);
expect(snapshot.records).toEqual([evidence]);
});
});
@@ -187,8 +187,8 @@ export class EvidenceLedger {
);
}
/** Borrow a sealed canonical bundle for serialization without copying its records. */
forSerialization(): EvidenceBundle {
/** Borrow an authenticated immutable bundle without copying its records. */
immutableBundle(): EvidenceBundle {
return createImmutableEvidenceBundle(
[...this.#records.values()],
[...this.#unknownRevisions.values()],
@@ -0,0 +1,111 @@
import { expect, it } from "vitest";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
import { InvestigationRecords } from "./InvestigationRecords.js";
import {
investigationEvidenceBundle,
investigationEvidenceLookup,
resolveInvestigationEvidenceClosure,
} from "./InvestigationEvidence.js";
const observation = (path: string, links: readonly string[] = []): Evidence =>
createEvidence(
{ path, sha256: "a".repeat(64), format: "elf" },
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "observe",
parameters: {},
result: { value: "observed" },
evidenceLinks: [...links],
},
);
it.each(["immutable", "detached"] as const)(
"reads current metadata through a %s reader while preserving earlier snapshots",
(mode) => {
const records = new InvestigationRecords();
const first = observation("/original");
const added = records.recordEvidence(first);
if (!added.ok) throw added.error;
const reader =
mode === "immutable"
? records
: {
evidenceById: records.evidenceById.bind(records),
exportEvidenceBundle: records.exportEvidenceBundle.bind(records),
};
const lookup = investigationEvidenceLookup(reader);
const snapshot = investigationEvidenceBundle(reader);
const replacement = observation("/moved");
expect(replacement.evidence_id).toBe(first.evidence_id);
const merged = records.mergeEvidenceBundle(
createEvidenceBundle([replacement]),
);
if (!merged.ok) throw merged.error;
expect(merged.value.metadataChanged).toBe(true);
expect(lookup(first.evidence_id)?.subject?.local_path).toBe("/moved");
expect(
investigationEvidenceBundle(reader).records[0]?.subject?.local_path,
).toBe("/moved");
expect(snapshot.records[0]?.subject?.local_path).toBe("/original");
if (mode === "immutable") {
expect(Object.isFrozen(snapshot.records[0]?.subject)).toBe(true);
} else {
const subject = snapshot.records[0]?.subject;
if (subject === undefined || subject === null)
throw new Error("Expected detached subject");
subject.local_path = "/caller-edited";
expect(lookup(first.evidence_id)?.subject?.local_path).toBe("/moved");
}
records.clear();
expect(lookup(first.evidence_id)).toBeUndefined();
expect(investigationEvidenceBundle(reader).records).toEqual([]);
expect(snapshot.records).toHaveLength(1);
},
);
it("keeps a selected transitive closure distinct from unrelated records and rejects a missing dependency", () => {
const records = new InvestigationRecords();
const leaf = observation("/leaf");
const middle = observation("/middle", [leaf.evidence_id]);
const root = observation("/root", [middle.evidence_id, leaf.evidence_id]);
const unrelated = observation("/unrelated", [root.evidence_id]);
for (const record of [leaf, middle, root, unrelated]) {
const added = records.recordEvidence(record);
if (!added.ok) throw added.error;
}
expect(
resolveInvestigationEvidenceClosure(records, [root.evidence_id]),
).toEqual({
ok: true,
value: [leaf.evidence_id, middle.evidence_id, root.evidence_id].sort(),
});
records.clear();
const added = records.recordEvidence(root);
if (!added.ok) throw added.error;
expect(
resolveInvestigationEvidenceClosure(records, [root.evidence_id]),
).toMatchObject({
ok: false,
error: { _tag: "EvidenceIntegrityError" },
});
});
it("rejects a lookup that returns another semantic Evidence identity", () => {
const record = observation("/source");
const selectedId = `ev_${"0".repeat(64)}`;
expect(
resolveInvestigationEvidenceClosure({ evidenceById: () => record }, [
selectedId,
]),
).toMatchObject({
ok: false,
error: {
_tag: "EvidenceIntegrityError",
reason: "identity_mismatch",
evidenceId: selectedId,
actual: record.evidence_id,
},
});
});
@@ -0,0 +1,51 @@
import {
resolveEvidenceInput,
type EvidenceLookup,
} from "../EvidenceInputResolver.js";
import type { EvidenceReader } from "./InvestigationRecordPort.js";
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
import { uniqueSorted } from "../../domain/canonicalOrdering.js";
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { ok, type Result } from "../../domain/result.js";
type InvestigationLookup = Pick<
EvidenceReader,
"evidenceForAnalysis" | "evidenceById"
>;
/** Prefer owned immutable records; detached-only readers retain their contract. */
export const investigationEvidenceLookup =
(reader: InvestigationLookup): EvidenceLookup =>
(id) =>
reader.evidenceForAnalysis?.(id) ?? reader.evidenceById(id);
/** Borrow the owner's immutable snapshot for read-only projection or delivery. */
export const investigationEvidenceBundle = (
reader: Pick<
EvidenceReader,
"immutableEvidenceBundle" | "exportEvidenceBundle"
>,
): EvidenceBundle =>
reader.immutableEvidenceBundle?.() ?? reader.exportEvidenceBundle();
/** Follow exact session references without exporting unrelated records or Unknowns. */
export const resolveInvestigationEvidenceClosure = (
reader: InvestigationLookup,
seedIds: readonly string[],
): Result<string[], EvidenceIntegrityError> => {
const lookup = investigationEvidenceLookup(reader);
const visited = new Set<string>();
const pending = [...seedIds];
while (pending.length > 0) {
const evidenceId = pending.pop();
if (evidenceId === undefined || visited.has(evidenceId)) continue;
const evidence = resolveEvidenceInput(
{ kind: "retained-evidence", evidence_id: evidenceId },
lookup,
);
if (!evidence.ok) return evidence;
visited.add(evidenceId);
pending.push(...evidence.value.evidence_links);
}
return ok(uniqueSorted([...visited]));
};
@@ -12,14 +12,14 @@ import type {
} from "../../domain/residualUnknown.js";
import type { Result } from "../../domain/result.js";
/** Detached investigation Evidence reads without target execution or lifecycle authority. */
/** Read-only investigation Evidence access without target execution or lifecycle authority. */
export interface EvidenceReader {
hasEvidence(evidenceId: string): boolean;
evidenceById(evidenceId: string): Evidence | undefined;
/** Optional immutable analysis read; older adapters can keep detached reads. */
evidenceForAnalysis?(evidenceId: string): Evidence | undefined;
/** Optional immutable bundle snapshot for complete serialization without graph copies. */
evidenceBundleForSerialization?(): EvidenceBundle;
/** Optional immutable bundle snapshot for read-only workflows and serialization. */
immutableEvidenceBundle?(): EvidenceBundle;
exportEvidenceBundle(): EvidenceBundle;
}
@@ -60,9 +60,9 @@ export class InvestigationRecords
return this.#evidence.export();
}
/** Borrow a sealed bundle snapshot for complete JSON serialization. */
evidenceBundleForSerialization(): EvidenceBundle {
return this.#evidence.forSerialization();
/** Borrow an authenticated immutable bundle snapshot without copying its records. */
immutableEvidenceBundle(): EvidenceBundle {
return this.#evidence.immutableBundle();
}
/** Atomically merge a bundle and report changes for the owner's post-commit observers. */
+2 -5
View File
@@ -95,6 +95,7 @@ import type { CompactInputSchemaPresentation } from "./EvidenceMcpServer.js";
import { isAbsolute } from "node:path";
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
import { investigationEvidenceLookup } from "../application/investigation/InvestigationEvidence.js";
import type { BrowserObservationPort } from "../application/BrowserObservationPort.js";
import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js";
import type { ElectronActiveObservationPort } from "../application/javascript/ElectronActiveObservationPort.js";
@@ -1078,11 +1079,7 @@ const createSessionRecorders = (
session: BinarySessionPort | undefined,
) => ({
evidenceById:
session === undefined
? undefined
: (evidenceId: string) =>
session.evidenceForAnalysis?.(evidenceId) ??
session.evidenceById(evidenceId),
session === undefined ? undefined : investigationEvidenceLookup(session),
activeTarget:
session === undefined ? undefined : () => session.activeTarget(),
recordEvidence:
+5 -8
View File
@@ -1,4 +1,3 @@
import type { EvidenceReader } from "../application/investigation/InvestigationRecordPort.js";
import { z } from "zod";
import { uniqueSorted } from "../domain/canonicalOrdering.js";
@@ -11,6 +10,8 @@ import { addressedValueSchema } from "../domain/native/nativeInventory.js";
import { currentMcpRequestSignal } from "./EvidenceMcpServerCore.js";
import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import type { Evidence } from "../domain/evidence.js";
import { investigationEvidenceBundle } from "../application/investigation/InvestigationEvidence.js";
const documentListSchema = z.array(z.string().min(1));
const procedureInventorySchema = z.array(
@@ -171,18 +172,14 @@ const evidenceCandidates = (
): readonly string[] => {
if (session === undefined) return [];
const candidates: string[] = [];
for (const evidence of session.exportEvidenceBundle().records)
for (const evidence of investigationEvidenceBundle(session).records)
candidates.push(...evidenceValues(kind, evidence));
return candidates;
};
type LedgerEvidence = ReturnType<
EvidenceReader["exportEvidenceBundle"]
>["records"][number];
const evidenceValues = (
kind: "evidence" | "capture" | "manifest" | "occurrence",
evidence: LedgerEvidence,
evidence: Evidence,
): readonly string[] => {
if (kind === "evidence") return [evidence.evidence_id];
if (kind === "capture")
@@ -210,7 +207,7 @@ const artifactInventoryValues = (
}
};
const isProcessCaptureEvidence = (evidence: LedgerEvidence): boolean => {
const isProcessCaptureEvidence = (evidence: Evidence): boolean => {
const parsed = parseProcessCaptureEvidence(evidence);
if (parsed.ok) return true;
switch (parsed.error.kind) {
+2 -3
View File
@@ -8,6 +8,7 @@ import { createEvidence } from "../domain/evidence.js";
import { readEvidence } from "../domain/evidenceValidation.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { resolveArtifactComparisonEvidence } from "../application/artifacts/ArtifactComparisonEvidence.js";
import { investigationEvidenceLookup } from "../application/investigation/InvestigationEvidence.js";
import type { RecordUnknownInput } from "../domain/residualUnknown.js";
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
@@ -23,9 +24,7 @@ export const registerArtifactComparisonTool = (
contract: ReturnType<typeof toolContract<"compare_artifacts">>,
): void => {
const admission = withAdmittedAnalysis({ kind: "session", session });
const evidenceById = (evidenceId: string) =>
session.evidenceForAnalysis?.(evidenceId) ??
session.evidenceById(evidenceId);
const evidenceById = investigationEvidenceLookup(session);
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
+2 -2
View File
@@ -17,6 +17,7 @@ import { runAdmittedToolOperation } from "./admittedToolOperation.js";
import { compareFunctions } from "../domain/functionComparison.js";
import { uniqueSorted } from "../domain/canonicalOrdering.js";
import { investigationEvidenceLookup } from "../application/investigation/InvestigationEvidence.js";
/** Register explicit Evidence-backed function comparison. */
export const registerFunctionComparisonTool = (
server: EvidenceMcpServer,
@@ -36,8 +37,7 @@ export const registerFunctionComparisonTool = (
async () => {
const resolved = resolvePairedEvidenceRequest(
input,
(id) =>
session.evidenceForAnalysis?.(id) ?? session.evidenceById(id),
investigationEvidenceLookup(session),
);
if (!resolved.ok)
return server.delivery.toCallToolResult(resolved, contract);
+15 -6
View File
@@ -1,3 +1,7 @@
import {
investigationEvidenceBundle,
resolveInvestigationEvidenceClosure,
} from "../application/investigation/InvestigationEvidence.js";
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
@@ -9,7 +13,6 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
import { correlateStaticAndRuntime } from "../domain/staticRuntimeCorrelation.js";
import {
comparisonClosure,
evidenceClosure,
isIncomplete,
recordWorkflowEvidence,
} from "./registerInvestigationTools/helpers.js";
@@ -75,7 +78,10 @@ const registerChangedBehavior = (
if (!computed.ok)
return server.delivery.toCallToolResult(computed, contract);
const { result, comparisonEvidenceIds } = computed.value;
const closure = evidenceClosure(session, result.evidence_links);
const closure = resolveInvestigationEvidenceClosure(
session,
result.evidence_links,
);
if (!closure.ok)
return server.delivery.toCallToolResult(closure, contract);
const links = closure.value;
@@ -136,7 +142,7 @@ const registerCallPath = (
contract.name,
context.mcpReq.signal,
async () => {
const closure = evidenceClosure(
const closure = resolveInvestigationEvidenceClosure(
session,
input.functions.map(({ evidence_id }) => evidence_id),
);
@@ -209,7 +215,7 @@ const registerStaticRuntime = (
contract.name,
context.mcpReq.signal,
async () => {
const closure = evidenceClosure(
const closure = resolveInvestigationEvidenceClosure(
session,
comparisonClosure([
...input.static_comparisons,
@@ -283,7 +289,7 @@ const registerReconstruction = (
contract.name,
context.mcpReq.signal,
async () => {
const owned = session.exportEvidenceBundle();
const owned = investigationEvidenceBundle(session);
const computed = await runDerivedOperation(
context,
contract.name,
@@ -292,7 +298,10 @@ const registerReconstruction = (
if (!computed.ok)
return server.delivery.toCallToolResult(computed, contract);
const result = computed.value;
const closure = evidenceClosure(session, result.evidence_links);
const closure = resolveInvestigationEvidenceClosure(
session,
result.evidence_links,
);
if (!closure.ok)
return server.delivery.toCallToolResult(closure, contract);
const links = closure.value;
@@ -1,56 +1,23 @@
import type { BinarySessionPort } from "../../application/binary/BinarySessionPort.js";
import type { Evidence } from "../../domain/evidence.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { Result } from "../../domain/result.js";
import { recordDerivedEvidence } from "../recordDerivedEvidence.js";
import { workflowUnknownInput } from "../../application/investigation/WorkflowUnknown.js";
import type { WorkflowUnknownInput } from "./types.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import { uniqueSorted } from "../../domain/canonicalOrdering.js";
export const isIncomplete = (status: string): boolean =>
status === "unknown" || status === "truncated";
export const comparisonClosure = (comparisons: readonly Evidence[]): string[] =>
uniqueIds(
uniqueSorted(
comparisons.flatMap((evidence) => [
evidence.evidence_id,
...evidence.evidence_links,
]),
);
const uniqueIds = (ids: readonly string[]): string[] =>
[...new Set(ids)].sort((left, right) =>
compareUnicodeCodePoints(left, right),
);
export const evidenceClosure = (
session: BinarySessionPort,
seedIds: readonly string[],
): Result<string[], EvidenceIntegrityError> => {
const records = new Map(
session
.exportEvidenceBundle()
.records.map((evidence) => [evidence.evidence_id, evidence]),
);
const visited = new Set<string>();
const pending = [...seedIds];
while (pending.length > 0) {
const evidenceId = pending.pop();
if (evidenceId === undefined || visited.has(evidenceId)) continue;
const evidence = records.get(evidenceId);
if (evidence === undefined)
return err(
new EvidenceIntegrityError(
"Investigation input has a dangling Evidence link",
),
);
visited.add(evidenceId);
pending.push(...evidence.evidence_links);
}
return ok(uniqueIds([...visited]));
};
export const recordWorkflowEvidence = (
...[session, evidence, unresolved, input]: readonly [
session: BinarySessionPort,
@@ -8,6 +8,7 @@ import { createEvidenceToolHandler } from "./contractToolHandler.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { recordSessionEvidenceSources } from "./sessionEvidence.js";
import { ok } from "../domain/result.js";
import { investigationEvidenceLookup } from "../application/investigation/InvestigationEvidence.js";
import type { Logger } from "pino";
/** Bind one named contract to the same retained-Evidence workflow used by CLI. */
@@ -17,8 +18,7 @@ export const registerNativeFunctionMatchingTool = (
logger: Logger,
): void => {
const contract = toolContract("match_native_functions");
const evidenceById = (id: string) =>
session.evidenceForAnalysis?.(id) ?? session.evidenceById(id);
const evidenceById = investigationEvidenceLookup(session);
const recordEvidence: BinarySessionPort["recordEvidence"] = (evidence) =>
session.recordEvidence(evidence);
const handler = createEvidenceToolHandler(
+5 -7
View File
@@ -11,6 +11,7 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
import { err, ok } from "../domain/result.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js";
import { investigationEvidenceBundle } from "../application/investigation/InvestigationEvidence.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { withAdmittedAnalysis } from "./analysisAdmission.js";
import { runAdmittedToolOperation } from "./admittedToolOperation.js";
@@ -49,7 +50,7 @@ const registerExportEvidenceTool = ({
exportContract.name,
toolRegistrationOptions(exportContract),
async (input, context) => {
const bundle = bundleForSerialization(session);
const bundle = investigationEvidenceBundle(session);
const written = await writeEvidenceBundle(
bundle,
input.path,
@@ -82,7 +83,7 @@ const registerSnapshotEvidenceTool = ({
toolRegistrationOptions(snapshotContract),
(input) =>
server.delivery.toCallToolResult(
inspectEvidenceBundle(bundleForSerialization(session), input),
inspectEvidenceBundle(investigationEvidenceBundle(session), input),
snapshotContract,
),
);
@@ -116,7 +117,7 @@ const registerImportEvidenceTool = ({
if (!loaded.ok)
return server.delivery.toCallToolResult(loaded, importContract);
const retainedUnknownRevisions = new Set(
bundleForSerialization(session).unknowns.map((unknown) =>
investigationEvidenceBundle(session).unknowns.map((unknown) =>
unknownRevisionKey(unknown),
),
);
@@ -136,7 +137,7 @@ const registerImportEvidenceTool = ({
unknownRevisionKey(unknown),
),
).length,
total: bundleForSerialization(session).records.length,
total: investigationEvidenceBundle(session).records.length,
}),
importContract,
)
@@ -146,9 +147,6 @@ const registerImportEvidenceTool = ({
);
};
const bundleForSerialization = (session: BinarySessionPort): EvidenceBundle =>
session.evidenceBundleForSerialization?.() ?? session.exportEvidenceBundle();
const unknownRevisionKey = (
unknown: EvidenceBundle["unknowns"][number],
): string => `${unknown.unknown_id}:${String(unknown.revision)}`;