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]); +});