diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid
index ccef73e63c..badf1a25c2 100644
--- a/docs/architecture.mermaid
+++ b/docs/architecture.mermaid
@@ -16,6 +16,8 @@ graph TD
APP["src/application/
Shared CLI/MCP workflows
enhanced analysis and investigations"]
SESSION["SessionProviderRouter + BinarySession
one immutable deep binding per target
disjoint auxiliary routing"]
REGISTRY["AnalysisProviderRegistry
sorted deep candidates
deterministic selection, no fallback"]
+ RECORDS["src/application/investigation/
composed InvestigationRecords + narrow ports
EvidenceLedger and Unknown ownership"]
+ SNAPSHOT["BinarySessionRecords + AnalysisSnapshotCache
binary target/profile snapshots
compatibility facade and post-commit notifications"]
JS_RECON["src/application/ + src/artifacts/
JavaScript artifact reconstruction
directory/ASAR reads
AST-only bundle projection"]
end
@@ -75,6 +77,11 @@ graph TD
CLI --> ERRORS
APP --> SESSION
SESSION --> REGISTRY
+ SESSION --> SNAPSHOT
+ SNAPSHOT --> RECORDS
+ APP -->|record callbacks| RECORDS
+ SERVER -->|narrow record ports| RECORDS
+ RECORDS --> DOMAIN
REGISTRY --> HOPPER
REGISTRY --> GHIDRA
REGISTRY --> IDA
diff --git a/docs/capability-migration.md b/docs/capability-migration.md
index 97ed800e9f..34d96b3e0e 100644
--- a/docs/capability-migration.md
+++ b/docs/capability-migration.md
@@ -13,7 +13,7 @@ there is no new universal provider interface or fixed module manifest.
| Producer parsing and external tool protocols | provider adapters | Keep behind the relevant typed port |
| Shared analyst workflows | application | Share between CLI and MCP |
| Concrete provider construction | binary runtime, composition, MCP startup | Consolidate one capability at a time in production composition |
-| Investigation Evidence and Unknowns | BinarySessionRecords | Extract one composed record owner after caller migration |
+| Investigation Evidence and Unknowns | InvestigationRecords/EvidenceLedger | Consume narrow read/write/atomic record ports |
| Target/profile snapshots and invalidation | BinarySessionRecords/BinarySession | Keep binary-owned |
| Subprocess ownership and host primitives | process/windows | Reuse; preserve cancellation and cleanup |
| Public translation | CLI/MCP adapters | Delegate to shared workflows and named contracts |
@@ -117,3 +117,31 @@ installs nothing. Core transport failures and shutdown still use their existing
lifecycle. Fault-injection coverage uses the production loading/startup path and
actual MCP SDK calls; recording ports establish composition behavior, while the
real Inspector lane separately establishes runtime behavior.
+
+## Investigation record ownership pilot
+
+`src/application/investigation/InvestigationRecords.ts` owns one existing
+EvidenceLedger per runtime. The production session composition constructs that
+owner explicitly; the direct BinarySession constructor retains a fresh default
+for compatibility. There is no second ledger, global store, persistence or new
+retention policy.
+
+EvidenceReader, EvidenceWriter and EvidenceUnknownWriter describe existing
+read/write/atomic callback needs. UnknownRegistryPort preserves optimistic
+revisions and consistency verification. Non-binary MCP registrars use these
+record types without depending on the binary execution/lifecycle surface.
+BinarySessionPort retains its compatible record methods through composition.
+
+BinarySessionRecords remains the snapshot owner and compatibility facade. It
+passes the actual active target explicitly for recordUnknown/updateUnknown;
+recordEvidenceWithUnknown keeps its target-free mutation subject. Snapshot import
+merges records without notifying midway, then emits after the cache commits.
+Bundle import retains the full change delta so Unknown-only changes notify even
+when recordsAdded is zero. Clearing resets records, cache and invalidation before
+notification; observer failures remain best effort after a committed mutation.
+
+Close and failed-cleanup clearing, ordinary target-switch retention, detached
+reads, Evidence identities and deterministic bundle transfer keep their existing
+behavior. Snapshots still require an active target and concrete analysis profile;
+extracting investigation ownership does not make them a target-free record store.
+#721 remains responsible for any future lifetime-policy change.
diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts
index ba3d92a2e6..928c7a73a5 100644
--- a/src/application/BinarySession.ts
+++ b/src/application/BinarySession.ts
@@ -24,6 +24,7 @@ import {
SessionProviderRouter,
type SessionProviderRoute,
} from "./SessionProviderRouter.js";
+import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
import { BinarySessionRecords } from "./BinarySessionRecords.js";
import { binarySessionStatus } from "./BinarySessionStatus.js";
import {
@@ -78,8 +79,11 @@ export class BinarySession
>();
readonly #availabilityListeners = new Set<() => void | Promise>();
- constructor(providerRouter: SessionProviderRouter) {
- super();
+ constructor(
+ providerRouter: SessionProviderRouter,
+ records: InvestigationRecords = new InvestigationRecords(),
+ ) {
+ super(records);
this.#providerRouter = providerRouter;
}
diff --git a/src/application/BinarySessionComposition.ts b/src/application/BinarySessionComposition.ts
index 55ef75f468..60df888a29 100644
--- a/src/application/BinarySessionComposition.ts
+++ b/src/application/BinarySessionComposition.ts
@@ -1,3 +1,4 @@
+import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
import type { AnalysisProvider } from "./AnalysisProvider.js";
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { BinarySession } from "./BinarySession.js";
@@ -15,4 +16,5 @@ export const composeBinarySession = (
registry instanceof SessionProviderRouter
? registry
: SessionProviderRouter.selectable(registry, auxiliaryProviders),
+ new InvestigationRecords(),
);
diff --git a/src/application/BinarySessionPort.ts b/src/application/BinarySessionPort.ts
index 706fceb925..b223da7cfc 100644
--- a/src/application/BinarySessionPort.ts
+++ b/src/application/BinarySessionPort.ts
@@ -1,21 +1,13 @@
+import type { InvestigationRecordPort } from "./investigation/InvestigationRecordPort.js";
import type { ExecutableFormatHint } from "../domain/dosCom.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { JsonValue } from "../domain/jsonValue.js";
-import type { Evidence } from "../domain/evidence.js";
-import type { EvidenceBundle } from "../domain/evidenceBundle.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
-import type { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
-import type { UnknownRegistryError } from "../domain/unknownRegistryError.js";
import type { Result } from "../domain/result.js";
-import type {
- RecordUnknownInput,
- ResidualUnknown,
- UnknownStatus,
- UpdateUnknownInput,
-} from "../domain/residualUnknown.js";
+
import type {
AnalysisOperation,
AnalysisOperationPort,
@@ -24,7 +16,8 @@ import type {
} from "./AnalysisProvider.js";
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
-export interface BinarySessionPort extends AnalysisOperationPort {
+export interface BinarySessionPort
+ extends AnalysisOperationPort, InvestigationRecordPort {
open(
path: string,
options?: {
@@ -42,40 +35,10 @@ export interface BinarySessionPort extends AnalysisOperationPort {
): Promise>;
status(): JsonValue;
activeTarget(): BinaryTarget | undefined;
- recordEvidence(
- evidence: Evidence,
- ): Result<"added" | "duplicate", EvidenceIntegrityError>;
- hasEvidence(evidenceId: string): boolean;
- evidenceById(evidenceId: string): Evidence | undefined;
- exportEvidenceBundle(): EvidenceBundle;
- importEvidenceBundle(bundle: unknown): Result;
exportAnalysisSnapshot(): Result;
importAnalysisSnapshot(
snapshot: AnalysisSnapshot,
): Result;
- recordUnknown(
- input: RecordUnknownInput,
- ): Result;
- recordEvidenceWithUnknown(
- evidence: Evidence,
- input: RecordUnknownInput,
- ): Result;
- updateUnknown(
- input: UpdateUnknownInput,
- ): Result;
- listUnknowns(filters?: {
- readonly status?: UnknownStatus;
- readonly severity?: ResidualUnknown["severity"];
- readonly domain?: string;
- }): ResidualUnknown[];
- verifyUnknownResolution(unknownId: string): Result<
- {
- readonly valid: boolean;
- readonly truthVerified: boolean;
- readonly unknown: ResidualUnknown;
- },
- UnknownRegistryError
- >;
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
analysisProfile(
operation?: AnalysisOperation,
diff --git a/src/application/BinarySessionRecords.ts b/src/application/BinarySessionRecords.ts
index 8faceaf242..f8ade2509e 100644
--- a/src/application/BinarySessionRecords.ts
+++ b/src/application/BinarySessionRecords.ts
@@ -1,7 +1,7 @@
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
-import { createEvidence, type Evidence } from "../domain/evidence.js";
+import type { Evidence } from "../domain/evidence.js";
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
import { evidenceBundleForTarget } from "../domain/evidenceBundle.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
@@ -19,25 +19,24 @@ import type {
AnalysisOperation,
} from "./AnalysisProvider.js";
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
-import { EvidenceLedger } from "./EvidenceLedger.js";
-import {
- UNKNOWN_REGISTRY_PROVIDER,
- unknownEvidenceLinks,
- unknownMutationEvidence,
-} from "./UnknownEvidence.js";
+import { InvestigationRecords } from "./investigation/InvestigationRecords.js";
export interface ActiveAnalysisBinding {
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment | null;
}
-/** Owns session evidence, snapshots, and residual unknowns. */
+/** Binary snapshot owner and compatibility facade for composed investigation records. */
export abstract class BinarySessionRecords {
- readonly #evidence = new EvidenceLedger();
+ readonly #records: InvestigationRecords;
readonly #snapshot = new AnalysisSnapshotCache();
#snapshotInvalidated = false;
readonly #snapshotListeners = new Set<() => void | Promise>();
+ constructor(records: InvestigationRecords = new InvestigationRecords()) {
+ this.#records = records;
+ }
+
/** Observe changes to the mutable current analysis snapshot resource. */
onAnalysisSnapshotChanged(listener: () => void | Promise): () => void {
this.#snapshotListeners.add(listener);
@@ -47,27 +46,27 @@ export abstract class BinarySessionRecords {
recordEvidence(
evidence: Evidence,
): Result<"added" | "duplicate", EvidenceIntegrityError> {
- const recorded = this.#evidence.record(evidence);
+ const recorded = this.#records.recordEvidence(evidence);
if (recorded.ok && recorded.value === "added") this.#emitSnapshotChanged();
return recorded;
}
hasEvidence(evidenceId: string): boolean {
- return this.#evidence.has(evidenceId);
+ return this.#records.hasEvidence(evidenceId);
}
evidenceById(evidenceId: string): Evidence | undefined {
- return this.#evidence.get(evidenceId);
+ return this.#records.evidenceById(evidenceId);
}
exportEvidenceBundle(): EvidenceBundle {
- return this.#evidence.export();
+ return this.#records.exportEvidenceBundle();
}
importEvidenceBundle(
bundle: unknown,
): Result {
- const imported = this.#evidence.import(bundle);
+ const imported = this.#records.mergeEvidenceBundle(bundle);
if (!imported.ok) return imported;
if (imported.value.changed) this.#emitSnapshotChanged();
return ok(imported.value.recordsAdded);
@@ -95,8 +94,11 @@ export abstract class BinarySessionRecords {
target,
profile,
target === undefined
- ? this.#evidence.export()
- : evidenceBundleForTarget(this.#evidence.export(), target.sha256),
+ ? this.#records.exportEvidenceBundle()
+ : evidenceBundleForTarget(
+ this.#records.exportEvidenceBundle(),
+ target.sha256,
+ ),
);
}
@@ -116,7 +118,7 @@ export abstract class BinarySessionRecords {
? undefined
: { target: active.target, profile: active.profile },
(bundle) => {
- const imported = this.#evidence.import(bundle);
+ const imported = this.#records.mergeEvidenceBundle(bundle);
return imported.ok ? ok(imported.value.recordsAdded) : imported;
},
);
@@ -175,7 +177,7 @@ export abstract class BinarySessionRecords {
}
protected clearSessionRecords(): void {
- this.#evidence.clear();
+ this.#records.clear();
this.#snapshot.clear();
this.#snapshotInvalidated = false;
this.#emitSnapshotChanged();
@@ -202,10 +204,7 @@ export abstract class BinarySessionRecords {
input: RecordUnknownInput,
): Result {
const target = this.activeAnalysisBinding()?.target;
- const recorded = this.#evidence.recordUnknown(
- input,
- unknownMutationEvidence(target, input),
- );
+ const recorded = this.#records.recordUnknown(input, target);
if (recorded.ok) this.#emitSnapshotChanged();
return recorded;
}
@@ -214,11 +213,7 @@ export abstract class BinarySessionRecords {
evidence: Evidence,
input: RecordUnknownInput,
): Result {
- const recorded = this.#evidence.recordWithUnknown(
- evidence,
- input,
- unknownMutationEvidence(undefined, input),
- );
+ const recorded = this.#records.recordEvidenceWithUnknown(evidence, input);
if (recorded.ok) this.#emitSnapshotChanged();
return recorded;
}
@@ -227,22 +222,7 @@ export abstract class BinarySessionRecords {
input: UpdateUnknownInput,
): Result {
const target = this.activeAnalysisBinding()?.target;
- const evidence = createEvidence(target, UNKNOWN_REGISTRY_PROVIDER, {
- predicateType: "rea.residual-unknown-mutation",
- operation: "update_unknown",
- parameters: {
- unknown_id: input.unknown_id,
- expected_revision: input.expected_revision,
- },
- result: { action: "update", status: input.status },
- confidence: "derived",
- authority: "analyst-inference",
- evidenceLinks: unknownEvidenceLinks(input),
- limitations: [
- "Registry mutation evidence records analyst intent, not proof of the answer.",
- ],
- });
- const updated = this.#evidence.updateUnknown(input, evidence);
+ const updated = this.#records.updateUnknown(input, target);
if (updated.ok) this.#emitSnapshotChanged();
return updated;
}
@@ -254,7 +234,7 @@ export abstract class BinarySessionRecords {
readonly domain?: string;
} = {},
): ResidualUnknown[] {
- return this.#evidence.listUnknowns(filters);
+ return this.#records.listUnknowns(filters);
}
verifyUnknownResolution(unknownId: string): Result<
@@ -265,6 +245,6 @@ export abstract class BinarySessionRecords {
},
UnknownRegistryError
> {
- return this.#evidence.verifyUnknownResolution(unknownId);
+ return this.#records.verifyUnknownResolution(unknownId);
}
}
diff --git a/src/application/investigation/InvestigationRecordPort.ts b/src/application/investigation/InvestigationRecordPort.ts
new file mode 100644
index 0000000000..a99542b5cd
--- /dev/null
+++ b/src/application/investigation/InvestigationRecordPort.ts
@@ -0,0 +1,67 @@
+import type { Evidence } from "../../domain/evidence.js";
+import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
+import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import type { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
+import type {
+ RecordUnknownInput,
+ ResidualUnknown,
+ UnknownStatus,
+ UpdateUnknownInput,
+} from "../../domain/residualUnknown.js";
+import type { Result } from "../../domain/result.js";
+
+/** Detached investigation Evidence reads without target execution or lifecycle authority. */
+export interface EvidenceReader {
+ hasEvidence(evidenceId: string): boolean;
+ evidenceById(evidenceId: string): Evidence | undefined;
+ exportEvidenceBundle(): EvidenceBundle;
+}
+
+/** Idempotent investigation Evidence writes without target execution. */
+export interface EvidenceWriter {
+ recordEvidence(
+ evidence: Evidence,
+ ): Result<"added" | "duplicate", EvidenceIntegrityError>;
+}
+
+/** Atomic derived Evidence and linked Unknown writes. */
+export interface EvidenceUnknownWriter {
+ recordEvidenceWithUnknown(
+ evidence: Evidence,
+ input: RecordUnknownInput,
+ ): Result;
+}
+
+/** Optimistic Unknown revisions and consistency verification. */
+export interface UnknownRegistryPort {
+ recordUnknown(
+ input: RecordUnknownInput,
+ ): Result;
+ updateUnknown(
+ input: UpdateUnknownInput,
+ ): Result;
+ listUnknowns(filters?: {
+ readonly status?: UnknownStatus;
+ readonly severity?: ResidualUnknown["severity"];
+ readonly domain?: string;
+ }): ResidualUnknown[];
+ verifyUnknownResolution(unknownId: string): Result<
+ {
+ readonly valid: boolean;
+ readonly truthVerified: boolean;
+ readonly unknown: ResidualUnknown;
+ },
+ UnknownRegistryError
+ >;
+}
+
+/** Compatibility record surface; binary execution and snapshots remain separate. */
+export interface InvestigationRecordPort
+ extends
+ EvidenceReader,
+ EvidenceWriter,
+ EvidenceUnknownWriter,
+ UnknownRegistryPort {
+ importEvidenceBundle(bundle: unknown): Result;
+}
diff --git a/src/application/investigation/InvestigationRecords.test.ts b/src/application/investigation/InvestigationRecords.test.ts
new file mode 100644
index 0000000000..184c10e6a1
--- /dev/null
+++ b/src/application/investigation/InvestigationRecords.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ ANALYSIS_SNAPSHOT_TARGET as target,
+ ANALYSIS_SNAPSHOT_PROVIDER as provider,
+} from "../../domain/analysisSnapshot.fixture.js";
+import { createEvidence } from "../../domain/evidence.js";
+import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
+import {
+ recordUnknownInputSchema,
+ updateUnknownInputSchema,
+} from "../../domain/residualUnknown.js";
+import { unknownMutationEvidence } from "../UnknownEvidence.js";
+import { InvestigationRecords } from "./InvestigationRecords.js";
+
+const unknownInput = (question: string) =>
+ recordUnknownInputSchema.parse({
+ question,
+ severity: "medium",
+ domain: "investigation",
+ required_authority: "analyst-inference",
+ required_confidence: "derived",
+ required_environment: null,
+ recommended_probes: [],
+ relationships: [],
+ });
+const evidence = createEvidence(undefined, provider, {
+ operation: "inspect-fixture",
+ parameters: {},
+ result: true,
+ confidence: "derived",
+ authority: "analyst-inference",
+});
+
+describe("investigation record ownership", () => {
+ it("keeps identity, detached reads and independent stores without a binary target", () => {
+ const records = new InvestigationRecords();
+ expect(records.recordEvidence(evidence)).toEqual({
+ ok: true,
+ value: "added",
+ });
+ expect(records.recordEvidence(evidence)).toEqual({
+ ok: true,
+ value: "duplicate",
+ });
+ const read = records.evidenceById(evidence.evidence_id);
+ if (read === undefined) throw new Error("Missing recorded Evidence");
+ Reflect.set(read, "operation", "forged");
+ expect(records.evidenceById(evidence.evidence_id)).toEqual(evidence);
+ expect(new InvestigationRecords().hasEvidence(evidence.evidence_id)).toBe(
+ false,
+ );
+ });
+
+ it("preserves explicit Unknown subjects and target-free atomic mutation subjects", () => {
+ const records = new InvestigationRecords();
+ const input = unknownInput("Which target supplies this observation?");
+ const bound = records.recordUnknown(input, target);
+ expect(bound).toMatchObject({
+ ok: true,
+ value: {
+ scope_digest: target.sha256,
+ mutation_evidence_ids: [
+ unknownMutationEvidence(target, input).evidence_id,
+ ],
+ },
+ });
+ const independent = records.recordEvidenceWithUnknown(evidence, input);
+ expect(independent).toMatchObject({
+ ok: true,
+ value: {
+ scope_digest: null,
+ mutation_evidence_ids: [
+ unknownMutationEvidence(undefined, input).evidence_id,
+ ],
+ },
+ });
+ const mutation = records.evidenceById(
+ unknownMutationEvidence(undefined, input).evidence_id,
+ );
+ expect(mutation?.subject).toBeNull();
+ expect(records.listUnknowns()).toHaveLength(2);
+ });
+
+ it("rolls back Evidence when the linked Unknown fails validation", () => {
+ const records = new InvestigationRecords();
+ const invalid = {
+ ...unknownInput("Is the missing Evidence present?"),
+ supporting_evidence_ids: [`ev_${"f".repeat(64)}`],
+ };
+ expect(records.recordEvidenceWithUnknown(evidence, invalid).ok).toBe(false);
+ expect(records.hasEvidence(evidence.evidence_id)).toBe(false);
+ expect(records.exportEvidenceBundle()).toEqual(createEvidenceBundle([]));
+ });
+
+ it("preserves optimistic revisions and the explicit update subject", () => {
+ const records = new InvestigationRecords();
+ const created = records.recordUnknown(
+ unknownInput("Can this revision be updated?"),
+ target,
+ );
+ if (!created.ok) throw created.error;
+ const input = updateUnknownInputSchema.parse({
+ ...created.value,
+ expected_revision: created.value.revision,
+ status: "investigating",
+ });
+ const updated = records.updateUnknown(input, target);
+ expect(updated).toMatchObject({
+ ok: true,
+ value: { revision: 2, scope_digest: target.sha256 },
+ });
+ const bundle = records.exportEvidenceBundle();
+ expect(
+ bundle.records.find(({ operation }) => operation === "update_unknown")
+ ?.subject?.digest.sha256,
+ ).toBe(target.sha256);
+ expect(records.updateUnknown(input, target).ok).toBe(false);
+ expect(records.exportEvidenceBundle()).toEqual(bundle);
+ });
+
+ it("reports Unknown-only bundle changes and clears the single record owner", () => {
+ const source = new InvestigationRecords();
+ expect(
+ source.recordUnknown(
+ unknownInput("Can imported Unknowns notify observers?"),
+ ).ok,
+ ).toBe(true);
+ const bundle = source.exportEvidenceBundle();
+ const records = new InvestigationRecords();
+ expect(
+ records.mergeEvidenceBundle(createEvidenceBundle(bundle.records)).ok,
+ ).toBe(true);
+ expect(records.mergeEvidenceBundle(bundle)).toEqual({
+ ok: true,
+ value: { recordsAdded: 0, unknownsAdded: 1, changed: true },
+ });
+ expect(records.mergeEvidenceBundle(bundle)).toEqual({
+ ok: true,
+ value: { recordsAdded: 0, unknownsAdded: 0, changed: false },
+ });
+ records.clear();
+ expect(records.exportEvidenceBundle()).toEqual(createEvidenceBundle([]));
+ });
+});
diff --git a/src/application/investigation/InvestigationRecords.ts b/src/application/investigation/InvestigationRecords.ts
new file mode 100644
index 0000000000..917bd06e08
--- /dev/null
+++ b/src/application/investigation/InvestigationRecords.ts
@@ -0,0 +1,142 @@
+import type { BinaryTarget } from "../../domain/binaryTarget.js";
+import { createEvidence, type Evidence } from "../../domain/evidence.js";
+import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
+import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import type { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
+import type {
+ RecordUnknownInput,
+ ResidualUnknown,
+ UnknownStatus,
+ UpdateUnknownInput,
+} from "../../domain/residualUnknown.js";
+import type { Result } from "../../domain/result.js";
+import { EvidenceLedger, type EvidenceImportDelta } from "../EvidenceLedger.js";
+import {
+ UNKNOWN_REGISTRY_PROVIDER,
+ unknownEvidenceLinks,
+ unknownMutationEvidence,
+} from "../UnknownEvidence.js";
+import type {
+ EvidenceReader,
+ EvidenceWriter,
+ EvidenceUnknownWriter,
+ UnknownRegistryPort,
+} from "./InvestigationRecordPort.js";
+
+/** One investigation's validated record owner, independent of binary execution. */
+export class InvestigationRecords
+ implements
+ EvidenceReader,
+ EvidenceWriter,
+ EvidenceUnknownWriter,
+ UnknownRegistryPort
+{
+ readonly #evidence = new EvidenceLedger();
+
+ /** Record immutable Evidence idempotently; reject conflicting content. */
+ recordEvidence(
+ evidence: Evidence,
+ ): Result<"added" | "duplicate", EvidenceIntegrityError> {
+ return this.#evidence.record(evidence);
+ }
+
+ /** Check one semantic Evidence ID without exposing stored values. */
+ hasEvidence(evidenceId: string): boolean {
+ return this.#evidence.has(evidenceId);
+ }
+
+ /** Return a detached Evidence value for one semantic ID. */
+ evidenceById(evidenceId: string): Evidence | undefined {
+ return this.#evidence.get(evidenceId);
+ }
+
+ /** Export a detached deterministic bundle of Evidence and Unknown revisions. */
+ exportEvidenceBundle(): EvidenceBundle {
+ return this.#evidence.export();
+ }
+
+ /** Atomically merge a bundle and report changes for the owner's post-commit observers. */
+ mergeEvidenceBundle(
+ bundle: unknown,
+ ): Result {
+ return this.#evidence.import(bundle);
+ }
+
+ /** Clear records when the owning runtime applies its existing lifecycle policy. */
+ clear(): void {
+ this.#evidence.clear();
+ }
+
+ /** Record an Unknown with an explicitly supplied Evidence subject, if any. */
+ recordUnknown(
+ input: RecordUnknownInput,
+ target?: BinaryTarget,
+ ): Result {
+ const recorded = this.#evidence.recordUnknown(
+ input,
+ unknownMutationEvidence(target, input),
+ );
+ return recorded;
+ }
+
+ /** Commit derived Evidence and its Unknown atomically without an implicit subject. */
+ recordEvidenceWithUnknown(
+ evidence: Evidence,
+ input: RecordUnknownInput,
+ ): Result {
+ const recorded = this.#evidence.recordWithUnknown(
+ evidence,
+ input,
+ unknownMutationEvidence(undefined, input),
+ );
+ return recorded;
+ }
+
+ /** Update an optimistic revision with an explicitly supplied Evidence subject. */
+ updateUnknown(
+ input: UpdateUnknownInput,
+ target?: BinaryTarget,
+ ): Result {
+ const evidence = createEvidence(target, UNKNOWN_REGISTRY_PROVIDER, {
+ predicateType: "rea.residual-unknown-mutation",
+ operation: "update_unknown",
+ parameters: {
+ unknown_id: input.unknown_id,
+ expected_revision: input.expected_revision,
+ },
+ result: { action: "update", status: input.status },
+ confidence: "derived",
+ authority: "analyst-inference",
+ evidenceLinks: unknownEvidenceLinks(input),
+ limitations: [
+ "Registry mutation evidence records analyst intent, not proof of the answer.",
+ ],
+ });
+ const updated = this.#evidence.updateUnknown(input, evidence);
+ return updated;
+ }
+
+ /** Return detached current Unknown revisions matching the selected filters. */
+ listUnknowns(
+ filters: {
+ readonly status?: UnknownStatus;
+ readonly severity?: ResidualUnknown["severity"];
+ readonly domain?: string;
+ } = {},
+ ): ResidualUnknown[] {
+ return this.#evidence.listUnknowns(filters);
+ }
+
+ /** Verify registry consistency without asserting the answer is true. */
+ verifyUnknownResolution(unknownId: string): Result<
+ {
+ readonly valid: boolean;
+ readonly truthVerified: boolean;
+ readonly unknown: ResidualUnknown;
+ },
+ UnknownRegistryError
+ > {
+ return this.#evidence.verifyUnknownResolution(unknownId);
+ }
+}
diff --git a/src/server/promptCompletion.ts b/src/server/promptCompletion.ts
index 1aa788a527..e56383cc07 100644
--- a/src/server/promptCompletion.ts
+++ b/src/server/promptCompletion.ts
@@ -1,3 +1,4 @@
+import type { EvidenceReader } from "../application/investigation/InvestigationRecordPort.js";
import { z } from "zod";
import { uniqueSorted } from "../domain/canonicalOrdering.js";
@@ -138,7 +139,7 @@ const evidenceCandidates = (
};
type LedgerEvidence = ReturnType<
- BinarySessionPort["exportEvidenceBundle"]
+ EvidenceReader["exportEvidenceBundle"]
>["records"][number];
const evidenceValues = (
diff --git a/src/server/registerAndroidTools.ts b/src/server/registerAndroidTools.ts
index 0227f41f0b..0fa29545dd 100644
--- a/src/server/registerAndroidTools.ts
+++ b/src/server/registerAndroidTools.ts
@@ -1,6 +1,6 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
import { AndroidAnalysisService } from "../application/AndroidAnalysisService.js";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { ToolContract } from "../contracts/toolContractTypes.js";
import type { AndroidOperation } from "../domain/androidAnalysis.js";
@@ -14,7 +14,7 @@ export const registerAndroidTools = (
server: McpServer,
service: AndroidAnalysisService,
logger: Logger,
- recordEvidence?: BinarySessionPort["recordEvidence"],
+ recordEvidence?: EvidenceWriter["recordEvidence"],
): void => {
const handler = (contract: ToolContract) => {
const name = contract.name;
diff --git a/src/server/registerApplicationTools/types.ts b/src/server/registerApplicationTools/types.ts
index 15ca37995e..3ddb44e25c 100644
--- a/src/server/registerApplicationTools/types.ts
+++ b/src/server/registerApplicationTools/types.ts
@@ -1,12 +1,16 @@
-import type { BinarySessionPort } from "../../application/BinarySession.js";
+import type {
+ EvidenceWriter,
+ EvidenceReader,
+ EvidenceUnknownWriter,
+} from "../../application/investigation/InvestigationRecordPort.js";
import type { Logger } from "../../logger.js";
/** Shared services for registering JavaScript application graph workflows. */
export interface ApplicationToolRegistration {
readonly logger: Logger;
- readonly evidenceById: BinarySessionPort["evidenceById"] | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly evidenceById: EvidenceReader["evidenceById"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly recordEvidenceWithUnknown:
- | BinarySessionPort["recordEvidenceWithUnknown"]
+ | EvidenceUnknownWriter["recordEvidenceWithUnknown"]
| undefined;
}
diff --git a/src/server/registerArtifactTools.ts b/src/server/registerArtifactTools.ts
index c5026257a9..5c1d383bb9 100644
--- a/src/server/registerArtifactTools.ts
+++ b/src/server/registerArtifactTools.ts
@@ -1,7 +1,7 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { McpServer } from "@modelcontextprotocol/server";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { Logger } from "../logger.js";
@@ -15,7 +15,7 @@ export const registerArtifactTools = (
options: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
},
): void => {
registerEvidenceTools(server, analysis, ARTIFACT_TOOL_CONTRACTS, {
diff --git a/src/server/registerBrowserScenarioTool.ts b/src/server/registerBrowserScenarioTool.ts
index 875eaa586d..99d016142d 100644
--- a/src/server/registerBrowserScenarioTool.ts
+++ b/src/server/registerBrowserScenarioTool.ts
@@ -1,3 +1,4 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import {
optionalProviderUnavailable,
type OptionalProviderLoadFailure,
@@ -5,7 +6,6 @@ import {
import { err } from "../domain/result.js";
import type { McpServer } from "@modelcontextprotocol/server";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js";
import { captureBrowserScenario } from "../application/BrowserScenarioCaptureService.js";
import { BROWSER_SCENARIO_TOOL_CONTRACTS } from "../contracts/browserScenarioToolContracts.js";
@@ -19,7 +19,7 @@ interface BrowserScenarioToolRegistration {
readonly logger: Logger;
readonly loadFailure?: OptionalProviderLoadFailure | undefined;
readonly provider: BrowserScenarioCapturePort | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
}
/** Register the browser scenario tool with execution-time provider diagnostics. */
diff --git a/src/server/registerBrowserTools.ts b/src/server/registerBrowserTools.ts
index 426ce248bd..63d7ad0340 100644
--- a/src/server/registerBrowserTools.ts
+++ b/src/server/registerBrowserTools.ts
@@ -1,3 +1,4 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import {
optionalProviderUnavailable,
type OptionalProviderLoadFailure,
@@ -5,7 +6,6 @@ import {
import { err } from "../domain/result.js";
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import type { BrowserObservationPort } from "../application/BrowserObservationPort.js";
import {
analyzeWebBundle,
@@ -35,7 +35,7 @@ interface BrowserToolRegistration {
readonly logger: Logger;
readonly loadFailure?: OptionalProviderLoadFailure | undefined;
readonly browser: BrowserObservationPort | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
}
interface BrowserToolContext {
diff --git a/src/server/registerElectronTools.ts b/src/server/registerElectronTools.ts
index ef4f7ce8f6..a3f9bbf1c8 100644
--- a/src/server/registerElectronTools.ts
+++ b/src/server/registerElectronTools.ts
@@ -1,3 +1,4 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import {
optionalProviderUnavailable,
type OptionalProviderLoadFailure,
@@ -5,7 +6,6 @@ import {
import { err } from "../domain/result.js";
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import type { ElectronActiveObservationPort } from "../application/ElectronActiveObservationPort.js";
import { captureElectronScenario } from "../application/ElectronActiveObservationService.js";
import type { ElectronObservationPort } from "../application/ElectronObservationPort.js";
@@ -34,7 +34,7 @@ interface ElectronToolRegistration {
readonly activeLoadFailure?: OptionalProviderLoadFailure | undefined;
readonly electron: ElectronObservationPort | undefined;
readonly electronActive: ElectronActiveObservationPort | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
}
interface ElectronToolContext {
diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts
index 18559e487e..3a6c156afc 100644
--- a/src/server/registerEnhancedTools.ts
+++ b/src/server/registerEnhancedTools.ts
@@ -1,3 +1,7 @@
+import type {
+ EvidenceWriter,
+ UnknownRegistryPort,
+} from "../application/investigation/InvestigationRecordPort.js";
import type {
CallToolResult,
McpServer,
@@ -5,7 +9,6 @@ import type {
} from "@modelcontextprotocol/server";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import {
EnhancedTools,
type ValidatedEnhancedCall,
@@ -34,8 +37,8 @@ export interface EnhancedToolRegistration {
readonly analysisProfile:
| (() => AnalysisProfileCommitment | undefined)
| undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
- readonly recordUnknown: BinarySessionPort["recordUnknown"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
+ readonly recordUnknown: UnknownRegistryPort["recordUnknown"] | undefined;
}
/** Register composed workflows against the same port as direct bridge tools. */
@@ -319,7 +322,7 @@ interface WorkflowUnknownInput {
readonly name: string;
readonly result: JsonValue;
readonly evidenceId: string;
- readonly recordUnknown: BinarySessionPort["recordUnknown"] | undefined;
+ readonly recordUnknown: UnknownRegistryPort["recordUnknown"] | undefined;
}
const recordWorkflowUnknowns = ({
@@ -328,7 +331,7 @@ const recordWorkflowUnknowns = ({
evidenceId,
recordUnknown,
}: WorkflowUnknownInput):
- | ReturnType
+ | ReturnType
| { readonly ok: true; readonly value: null } => {
if (
!["trace_feature", "trace_call_path", "inspect_native_api"].includes(
diff --git a/src/server/registerEvidenceTools.ts b/src/server/registerEvidenceTools.ts
index b374d39669..e906bd8624 100644
--- a/src/server/registerEvidenceTools.ts
+++ b/src/server/registerEvidenceTools.ts
@@ -1,10 +1,10 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { McpServer } from "@modelcontextprotocol/server";
import type {
AnalysisOperation,
AnalysisOperationPort,
} from "../application/AnalysisProvider.js";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import type { ToolContract } from "../contracts/toolContracts.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
@@ -19,7 +19,7 @@ import { createArtifactExtractionDestination } from "../application/ArtifactExtr
interface EvidenceToolRegistration {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly sourceEvidence?: (
operation: Exclude,
result: import("../domain/jsonValue.js").JsonValue,
diff --git a/src/server/registerFirmwareTools.ts b/src/server/registerFirmwareTools.ts
index d0c978b14f..3c5f4a1039 100644
--- a/src/server/registerFirmwareTools.ts
+++ b/src/server/registerFirmwareTools.ts
@@ -1,6 +1,6 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
import type { FirmwareAnalysisService } from "../application/FirmwareAnalysisService.js";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { ToolContract } from "../contracts/toolContractTypes.js";
import type { FirmwareOperation } from "../domain/firmwareAnalysis.js";
@@ -14,7 +14,7 @@ export const registerFirmwareTools = (
server: McpServer,
service: FirmwareAnalysisService,
logger: Logger,
- recordEvidence?: BinarySessionPort["recordEvidence"],
+ recordEvidence?: EvidenceWriter["recordEvidence"],
): void => {
const handler =
(contract: ToolContract) =>
diff --git a/src/server/registerJavaScriptRuntimeObservationTools.ts b/src/server/registerJavaScriptRuntimeObservationTools.ts
index beb4ef62f9..72d19a6cfd 100644
--- a/src/server/registerJavaScriptRuntimeObservationTools.ts
+++ b/src/server/registerJavaScriptRuntimeObservationTools.ts
@@ -1,3 +1,4 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import {
optionalProviderUnavailable,
type OptionalProviderLoadFailure,
@@ -5,7 +6,6 @@ import {
import { err } from "../domain/result.js";
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js";
import {
listJavaScriptRuntimeTargets,
@@ -26,7 +26,7 @@ interface RuntimeToolRegistration {
readonly logger: Logger;
readonly loadFailure?: OptionalProviderLoadFailure | undefined;
readonly runtime: JavaScriptRuntimeObservationPort | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
}
/** Register passive Inspector tools even when policy keeps them unavailable. */
diff --git a/src/server/registerManagedTools.ts b/src/server/registerManagedTools.ts
index 2fe9dcb640..a9fdc2bfe3 100644
--- a/src/server/registerManagedTools.ts
+++ b/src/server/registerManagedTools.ts
@@ -1,3 +1,4 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { McpServer } from "@modelcontextprotocol/server";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
@@ -18,7 +19,7 @@ export const registerManagedTools = (
options: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly session: BinarySessionPort | undefined;
},
): void => {
diff --git a/src/server/registerManagedWorkflowTools/types.ts b/src/server/registerManagedWorkflowTools/types.ts
index c8a0809b26..9943e33e15 100644
--- a/src/server/registerManagedWorkflowTools/types.ts
+++ b/src/server/registerManagedWorkflowTools/types.ts
@@ -1,12 +1,16 @@
+import type {
+ EvidenceWriter,
+ EvidenceUnknownWriter,
+} from "../../application/investigation/InvestigationRecordPort.js";
import type { BinarySessionPort } from "../../application/BinarySession.js";
import type { Logger } from "../../logger.js";
/** Shared services for registering managed-code workflow tools. */
export interface ManagedWorkflowToolRegistration {
readonly logger: Logger;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly recordEvidenceWithUnknown:
- | BinarySessionPort["recordEvidenceWithUnknown"]
+ | EvidenceUnknownWriter["recordEvidenceWithUnknown"]
| undefined;
readonly session: BinarySessionPort;
}
diff --git a/src/server/registerNativeTools.ts b/src/server/registerNativeTools.ts
index 10c37b5a91..187bd5afd3 100644
--- a/src/server/registerNativeTools.ts
+++ b/src/server/registerNativeTools.ts
@@ -1,7 +1,7 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { McpServer } from "@modelcontextprotocol/server";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import { NATIVE_TOOL_CONTRACTS } from "../contracts/nativeToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { Logger } from "../logger.js";
@@ -14,7 +14,7 @@ export const registerNativeTools = (
options: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
},
): void => {
registerEvidenceTools(server, analysis, NATIVE_TOOL_CONTRACTS, options);
diff --git a/src/server/registerOfficialTools.ts b/src/server/registerOfficialTools.ts
index 03cebd269d..2e73af7154 100644
--- a/src/server/registerOfficialTools.ts
+++ b/src/server/registerOfficialTools.ts
@@ -1,3 +1,4 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
import { z } from "zod";
@@ -5,7 +6,6 @@ import type {
AnalysisExecution,
AnalysisOperationPort,
} from "../application/AnalysisProvider.js";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import type { ToolContract } from "../contracts/toolContracts.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js";
@@ -28,7 +28,7 @@ import { toCallToolResult } from "./toolResult.js";
export interface OfficialToolRegistration {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
}
/** Register direct bridge proxies, preserving MCP cancellation and typed errors. */
@@ -53,7 +53,7 @@ const registerOfficialTool = (
registration: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
},
): void => {
server.registerTool(
diff --git a/src/server/registerWebScriptTool.ts b/src/server/registerWebScriptTool.ts
index 9e6bb7079d..c2445a838a 100644
--- a/src/server/registerWebScriptTool.ts
+++ b/src/server/registerWebScriptTool.ts
@@ -1,6 +1,6 @@
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import type { McpServer } from "@modelcontextprotocol/server";
-import type { BinarySessionPort } from "../application/BinarySession.js";
import { exportWebScriptsValidated } from "../application/WebScriptExportService.js";
import { toolContract } from "../contracts/toolContracts.js";
import type { Logger } from "../logger.js";
@@ -13,7 +13,7 @@ export const registerWebScriptTool = (
server: McpServer,
options: {
readonly logger: Logger;
- readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
+ readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
},
): void => {
const contract = toolContract("export_web_scripts");
diff --git a/src/server/sessionEvidence.ts b/src/server/sessionEvidence.ts
index b0669a6db6..a450f31afe 100644
--- a/src/server/sessionEvidence.ts
+++ b/src/server/sessionEvidence.ts
@@ -1,11 +1,11 @@
-import type { BinarySessionPort } from "../application/BinarySession.js";
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
import { EvidenceIntegrityError } from "../domain/evidenceErrors.js";
import type { Evidence } from "../domain/evidence.js";
import { ok, type Result } from "../domain/result.js";
/** Record source evidence in order, stopping at the first session rejection. */
export const recordSessionEvidenceSources = (
- recordEvidence: BinarySessionPort["recordEvidence"] | undefined,
+ recordEvidence: EvidenceWriter["recordEvidence"] | undefined,
sources: readonly Evidence[],
): Result => {
for (const source of sources) {
diff --git a/tests/composition/analysis-sessions/investigationRecords.test.ts b/tests/composition/analysis-sessions/investigationRecords.test.ts
new file mode 100644
index 0000000000..8cff526c25
--- /dev/null
+++ b/tests/composition/analysis-sessions/investigationRecords.test.ts
@@ -0,0 +1,149 @@
+import { expect, it, onTestFinished } from "vitest";
+
+import { BinarySession } from "../../../src/application/BinarySession.js";
+import { SessionProviderRouter } from "../../../src/application/SessionProviderRouter.js";
+import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js";
+import { createEvidence } from "../../../src/domain/evidence.js";
+import { recordUnknownInputSchema } from "../../../src/domain/residualUnknown.js";
+import {
+ createBinarySessionTargets,
+ createCacheProvider,
+ createTestBinarySession,
+} from "../../fixtures/binarySession.js";
+
+const createSession = (
+ records: InvestigationRecords = new InvestigationRecords(),
+) => {
+ const session = new BinarySession(
+ SessionProviderRouter.single(createCacheProvider([])),
+ records,
+ );
+ onTestFinished(async () => {
+ await session.close();
+ });
+ return session;
+};
+
+it("uses the composed record owner and preserves best-effort post-commit notifications", async () => {
+ const records = new InvestigationRecords();
+ const session = createSession(records);
+ const observed: number[] = [];
+ const unsubscribe = session.onAnalysisSnapshotChanged(() => {
+ observed.push(records.exportEvidenceBundle().records.length);
+ });
+ session.onAnalysisSnapshotChanged(() => {
+ throw new Error("Synthetic observer failure");
+ });
+ session.onAnalysisSnapshotChanged(async () => {
+ throw new Error("Synthetic async observer failure");
+ });
+ const evidence = createEvidence(
+ undefined,
+ { id: "fixture", name: "Fixture", version: "1" },
+ { operation: "independent-app", parameters: {}, result: true },
+ );
+ expect(session.recordEvidence(evidence)).toEqual({
+ ok: true,
+ value: "added",
+ });
+ expect(records.evidenceById(evidence.evidence_id)).toEqual(evidence);
+ expect(session.recordEvidence(evidence)).toEqual({
+ ok: true,
+ value: "duplicate",
+ });
+ expect(observed).toEqual([1]);
+ expect((await session.close()).ok).toBe(true);
+ expect(records.exportEvidenceBundle().records).toEqual([]);
+ expect(session.exportAnalysisSnapshot()).toMatchObject({
+ ok: false,
+ error: { _tag: "NoBinaryOpenError" },
+ });
+ expect(observed).toEqual([1, 0]);
+ unsubscribe();
+ await new Promise((resolve) => setImmediate(resolve));
+});
+
+it("notifies after snapshot and Evidence import both commit, and rejects mismatched targets atomically", async () => {
+ const [first, second] = await createBinarySessionTargets();
+ const source = createTestBinarySession(createCacheProvider([]));
+ onTestFinished(async () => {
+ await source.close();
+ });
+ expect((await source.open(first)).ok).toBe(true);
+ expect(
+ (
+ await source.execute("address_name", {
+ address: "0x1000",
+ document: "fixture",
+ })
+ ).ok,
+ ).toBe(true);
+ const exported = source.exportAnalysisSnapshot();
+ if (!exported.ok) throw exported.error;
+ expect(exported.value.entries.length).toBeGreaterThan(0);
+ const recipient = createSession();
+ expect((await recipient.open(first)).ok).toBe(true);
+ const notifications: Array<{ entries: number; evidence: number }> = [];
+ recipient.onAnalysisSnapshotChanged(() => {
+ const snapshot = recipient.exportAnalysisSnapshot();
+ if (!snapshot.ok) throw snapshot.error;
+ notifications.push({
+ entries: snapshot.value.entries.length,
+ evidence: snapshot.value.evidence_bundle.records.length,
+ });
+ });
+ expect(recipient.importAnalysisSnapshot(exported.value).ok).toBe(true);
+ expect(notifications).toEqual([
+ {
+ entries: exported.value.entries.length,
+ evidence: exported.value.evidence_bundle.records.length,
+ },
+ ]);
+ const incompatible = createSession();
+ expect((await incompatible.open(second)).ok).toBe(true);
+ let mismatchNotifications = 0;
+ incompatible.onAnalysisSnapshotChanged(() => {
+ mismatchNotifications += 1;
+ });
+ const before = incompatible.exportEvidenceBundle();
+ expect(incompatible.importAnalysisSnapshot(exported.value).ok).toBe(false);
+ expect(incompatible.exportEvidenceBundle()).toEqual(before);
+ expect(mismatchNotifications).toBe(0);
+});
+
+it("notifies an Unknown-only bundle change even when no new Evidence is added", () => {
+ const source = createSession();
+ expect(
+ source.recordUnknown(
+ recordUnknownInputSchema.parse({
+ question: "Is this import complete?",
+ severity: "medium",
+ domain: "investigation",
+ required_authority: "analyst-inference",
+ required_confidence: "derived",
+ required_environment: null,
+ recommended_probes: [],
+ relationships: [],
+ }),
+ ).ok,
+ ).toBe(true);
+ const bundle = source.exportEvidenceBundle();
+ const recipient = createSession();
+ expect(recipient.importEvidenceBundle({ ...bundle, unknowns: [] }).ok).toBe(
+ true,
+ );
+ const revisions: number[] = [];
+ recipient.onAnalysisSnapshotChanged(() => {
+ revisions.push(recipient.listUnknowns().length);
+ });
+ expect(recipient.importEvidenceBundle(bundle)).toEqual({
+ ok: true,
+ value: 0,
+ });
+ expect(revisions).toEqual([1]);
+ expect(recipient.importEvidenceBundle(bundle)).toEqual({
+ ok: true,
+ value: 0,
+ });
+ expect(revisions).toEqual([1]);
+});