diff --git a/src/application/investigation/InvestigationEvidence.test.ts b/src/application/investigation/InvestigationEvidence.test.ts index d3ca2fd011..9b03a7f4ae 100644 --- a/src/application/investigation/InvestigationEvidence.test.ts +++ b/src/application/investigation/InvestigationEvidence.test.ts @@ -3,11 +3,7 @@ import { expect, it } from "vitest"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { createEvidenceBundle } from "../../domain/evidenceBundle.js"; import { InvestigationRecords } from "./InvestigationRecords.js"; -import { - investigationEvidenceBundle, - investigationEvidenceLookup, - resolveInvestigationEvidenceClosure, -} from "./InvestigationEvidence.js"; +import { resolveInvestigationEvidenceClosure } from "./InvestigationEvidence.js"; const observation = (path: string, links: readonly string[] = []): Evidence => createEvidence( @@ -21,51 +17,33 @@ const observation = (path: string, links: readonly string[] = []): Evidence => }, ); -it.each(["immutable", "detached"] as const)( - "reads current metadata through a %s reader while preserving earlier snapshots", - (mode) => { - const records = new InvestigationRecords(); - const first = observation("/original"); - const added = records.recordEvidence(first); - if (!added.ok) throw added.error; - const reader = - mode === "immutable" - ? records - : { - evidenceById: records.evidenceById.bind(records), - exportEvidenceBundle: records.exportEvidenceBundle.bind(records), - }; - const lookup = investigationEvidenceLookup(reader); - const snapshot = investigationEvidenceBundle(reader); - const replacement = observation("/moved"); - expect(replacement.evidence_id).toBe(first.evidence_id); - const merged = records.mergeEvidenceBundle( - createEvidenceBundle([replacement]), - ); - if (!merged.ok) throw merged.error; - expect(merged.value.replacements).toEqual([ - { kind: "metadata", previous: first, current: replacement }, - ]); - expect(lookup(first.evidence_id)?.subject?.local_path).toBe("/moved"); - expect( - investigationEvidenceBundle(reader).records[0]?.subject?.local_path, - ).toBe("/moved"); - expect(snapshot.records[0]?.subject?.local_path).toBe("/original"); - if (mode === "immutable") { - expect(Object.isFrozen(snapshot.records[0]?.subject)).toBe(true); - } else { - const subject = snapshot.records[0]?.subject; - if (subject === undefined || subject === null) - throw new Error("Expected detached subject"); - subject.local_path = "/caller-edited"; - expect(lookup(first.evidence_id)?.subject?.local_path).toBe("/moved"); - } - records.clear(); - expect(lookup(first.evidence_id)).toBeUndefined(); - expect(investigationEvidenceBundle(reader).records).toEqual([]); - expect(snapshot.records).toHaveLength(1); - }, -); +it("reads current immutable metadata while preserving earlier snapshots", () => { + const records = new InvestigationRecords(); + const first = observation("/original"); + const added = records.recordEvidence(first); + if (!added.ok) throw added.error; + const lookup = records.evidenceForAnalysis.bind(records); + const snapshot = records.immutableEvidenceBundle(); + const replacement = observation("/moved"); + expect(replacement.evidence_id).toBe(first.evidence_id); + const merged = records.mergeEvidenceBundle( + createEvidenceBundle([replacement]), + ); + if (!merged.ok) throw merged.error; + expect(merged.value.replacements).toEqual([ + { kind: "metadata", previous: first, current: replacement }, + ]); + expect(lookup(first.evidence_id)?.subject?.local_path).toBe("/moved"); + expect( + records.immutableEvidenceBundle().records[0]?.subject?.local_path, + ).toBe("/moved"); + expect(snapshot.records[0]?.subject?.local_path).toBe("/original"); + expect(Object.isFrozen(snapshot.records[0]?.subject)).toBe(true); + records.clear(); + expect(lookup(first.evidence_id)).toBeUndefined(); + expect(records.immutableEvidenceBundle().records).toEqual([]); + expect(snapshot.records).toHaveLength(1); +}); it("keeps a selected transitive closure distinct from unrelated records and rejects a missing dependency", () => { const records = new InvestigationRecords(); @@ -98,7 +76,7 @@ it("rejects a lookup that returns another semantic Evidence identity", () => { const record = observation("/source"); const selectedId = `ev_${"0".repeat(64)}`; expect( - resolveInvestigationEvidenceClosure({ evidenceById: () => record }, [ + resolveInvestigationEvidenceClosure({ evidenceForAnalysis: () => record }, [ selectedId, ]), ).toMatchObject({ diff --git a/src/application/investigation/InvestigationEvidence.ts b/src/application/investigation/InvestigationEvidence.ts index 3837d554e5..a73590a65a 100644 --- a/src/application/investigation/InvestigationEvidence.ts +++ b/src/application/investigation/InvestigationEvidence.ts @@ -1,39 +1,15 @@ -import { - resolveEvidenceInput, - type EvidenceLookup, -} from "../EvidenceInputResolver.js"; +import { resolveEvidenceInput } from "../EvidenceInputResolver.js"; import type { EvidenceReader } from "./InvestigationRecordPort.js"; -import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; import { uniqueSorted } from "../../domain/canonicalOrdering.js"; import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { ok, type Result } from "../../domain/result.js"; -type InvestigationLookup = Pick< - EvidenceReader, - "evidenceForAnalysis" | "evidenceById" ->; - -/** Prefer owned immutable records; detached-only readers retain their contract. */ -export const investigationEvidenceLookup = - (reader: InvestigationLookup): EvidenceLookup => - (id) => - reader.evidenceForAnalysis?.(id) ?? reader.evidenceById(id); - -/** Borrow the owner's immutable snapshot for read-only projection or delivery. */ -export const investigationEvidenceBundle = ( - reader: Pick< - EvidenceReader, - "immutableEvidenceBundle" | "exportEvidenceBundle" - >, -): EvidenceBundle => - reader.immutableEvidenceBundle?.() ?? reader.exportEvidenceBundle(); - /** Follow exact session references without exporting unrelated records or Unknowns. */ export const resolveInvestigationEvidenceClosure = ( - reader: InvestigationLookup, + reader: Pick, seedIds: readonly string[], ): Result => { - const lookup = investigationEvidenceLookup(reader); + const lookup = reader.evidenceForAnalysis.bind(reader); const visited = new Set(); const pending = [...seedIds]; while (pending.length > 0) { diff --git a/src/application/investigation/InvestigationRecordPort.ts b/src/application/investigation/InvestigationRecordPort.ts index 0ceaf21a64..c7c8cf6a98 100644 --- a/src/application/investigation/InvestigationRecordPort.ts +++ b/src/application/investigation/InvestigationRecordPort.ts @@ -16,10 +16,10 @@ import type { Result } from "../../domain/result.js"; export interface EvidenceReader { hasEvidence(evidenceId: string): boolean; evidenceById(evidenceId: string): Evidence | undefined; - /** Optional immutable analysis read; older adapters can keep detached reads. */ - evidenceForAnalysis?(evidenceId: string): Evidence | undefined; - /** Optional immutable bundle snapshot for read-only workflows and serialization. */ - immutableEvidenceBundle?(): EvidenceBundle; + /** Borrow an immutable authenticated record for read-only application workflows. */ + evidenceForAnalysis(evidenceId: string): Evidence | undefined; + /** Borrow an immutable bundle snapshot for read-only workflows and serialization. */ + immutableEvidenceBundle(): EvidenceBundle; exportEvidenceBundle(): EvidenceBundle; } @@ -77,7 +77,7 @@ export interface UnknownRegistryPort { >; } -/** Compatibility record surface; binary execution and snapshots remain separate. */ +/** Investigation record surface; binary execution and snapshots remain separate. */ export interface InvestigationRecordPort extends EvidenceReader, diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index 1a600abe3e..007c41767c 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -34,28 +34,6 @@ export interface ProcessCliErrorOutput extends AnalysisErrorProjection { readonly error: "Process command failed"; } -/** Identify the process workflow's typed diagnostic result for CLI exit status. */ -export const isProcessCliFailure = ( - value: unknown, -): value is ProcessCliErrorOutput => - typeof value === "object" && - value !== null && - "error" in value && - value.error === "Process command failed" && - "category" in value && - typeof value.category === "string" && - "message" in value && - typeof value.message === "string" && - "code" in value && - typeof value.code === "string" && - "retryable" in value && - typeof value.retryable === "boolean" && - "remediation" in value && - typeof value.remediation === "object" && - value.remediation !== null && - "action" in value.remediation && - typeof value.remediation.action === "string"; - /** Capture one JSON scenario through the shared process harness and Evidence contract. */ export const captureProcessScenarioFile = async ( path: string, diff --git a/src/cli/processCommands.ts b/src/cli/processCommands.ts index 09538a6c44..309e4c88ce 100644 --- a/src/cli/processCommands.ts +++ b/src/cli/processCommands.ts @@ -3,7 +3,6 @@ import { Cli, z } from "incur"; import { captureProcessScenarioFile, compareProcessEvidenceFiles, - isProcessCliFailure, } from "../application/process/ProcessCli.js"; import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; @@ -21,11 +20,8 @@ export const registerProcessCommands = ( args: z.object({ scenario: z.string().describe("Scenario JSON path") }), run: ({ args }) => withCommandCancellation((signal) => - logCliCommand( - logger, - "capture-process", - () => captureProcessScenarioFile(args.scenario, environment, signal), - isProcessCliFailure, + logCliCommand(logger, "capture-process", () => + captureProcessScenarioFile(args.scenario, environment, signal), ), ), }); @@ -41,17 +37,13 @@ export const registerProcessCommands = ( }), run: ({ args }) => withCommandCancellation((signal) => - logCliCommand( - logger, - "compare-process-captures", - () => - compareProcessEvidenceFiles( - args.left, - args.right, - args.traceSpec, - signal, - ), - isProcessCliFailure, + logCliCommand(logger, "compare-process-captures", () => + compareProcessEvidenceFiles( + args.left, + args.right, + args.traceSpec, + signal, + ), ), ), }); diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 9ddd4cd8d9..ec65466d03 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -109,7 +109,6 @@ import type { CompactInputSchemaPresentation } from "./EvidenceMcpServer.js"; import { isAbsolute } from "node:path"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; -import { investigationEvidenceLookup } from "../application/investigation/InvestigationEvidence.js"; import type { BrowserObservationPort } from "../application/BrowserObservationPort.js"; import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js"; import type { ElectronActiveObservationPort } from "../application/javascript/ElectronActiveObservationPort.js"; @@ -1157,7 +1156,9 @@ const createSessionRecorders = ( session: BinarySessionPort | undefined, ) => ({ evidenceById: - session === undefined ? undefined : investigationEvidenceLookup(session), + session === undefined + ? undefined + : session.evidenceForAnalysis.bind(session), activeTarget: session === undefined ? undefined : () => session.activeTarget(), recordEvidence: diff --git a/src/server/promptCompletion.ts b/src/server/promptCompletion.ts index f79c1de956..a958a41d81 100644 --- a/src/server/promptCompletion.ts +++ b/src/server/promptCompletion.ts @@ -11,7 +11,6 @@ import { currentMcpRequestSignal } from "./EvidenceMcpServerCore.js"; import { parseArtifactInventoryEvidence } from "../domain/artifactInventoryEvidence.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; import type { Evidence } from "../domain/evidence.js"; -import { investigationEvidenceBundle } from "../application/investigation/InvestigationEvidence.js"; const documentListSchema = z.array(z.string().min(1)); const procedureInventorySchema = z.array( @@ -172,7 +171,7 @@ const evidenceCandidates = ( ): readonly string[] => { if (session === undefined) return []; const candidates: string[] = []; - for (const evidence of investigationEvidenceBundle(session).records) + for (const evidence of session.immutableEvidenceBundle().records) candidates.push(...evidenceValues(kind, evidence)); return candidates; }; diff --git a/src/server/registerArtifactComparisonTool.ts b/src/server/registerArtifactComparisonTool.ts index 81f30ab549..3715ba7834 100644 --- a/src/server/registerArtifactComparisonTool.ts +++ b/src/server/registerArtifactComparisonTool.ts @@ -8,7 +8,6 @@ import { createEvidence } from "../domain/evidence.js"; import { readEvidence } from "../domain/evidenceValidation.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { resolveArtifactComparisonEvidence } from "../application/artifacts/ArtifactComparisonEvidence.js"; -import { investigationEvidenceLookup } from "../application/investigation/InvestigationEvidence.js"; import type { RecordUnknownInput } from "../domain/residualUnknown.js"; import { recordDerivedEvidence } from "./recordDerivedEvidence.js"; import { runDerivedOperation } from "./runDerivedOperation.js"; @@ -24,7 +23,7 @@ export const registerArtifactComparisonTool = ( contract: ReturnType>, ): void => { const admission = withAdmittedAnalysis({ kind: "session", session }); - const evidenceById = investigationEvidenceLookup(session); + const evidenceById = session.evidenceForAnalysis.bind(session); server.registerTool( contract.name, toolRegistrationOptions(contract), diff --git a/src/server/registerFunctionComparisonTool.ts b/src/server/registerFunctionComparisonTool.ts index 1f48465d66..462a098bf5 100644 --- a/src/server/registerFunctionComparisonTool.ts +++ b/src/server/registerFunctionComparisonTool.ts @@ -17,7 +17,6 @@ import { runAdmittedToolOperation } from "./admittedToolOperation.js"; import { compareFunctions } from "../domain/functionComparison.js"; import { uniqueSorted } from "../domain/canonicalOrdering.js"; -import { investigationEvidenceLookup } from "../application/investigation/InvestigationEvidence.js"; /** Register explicit Evidence-backed function comparison. */ export const registerFunctionComparisonTool = ( server: EvidenceMcpServer, @@ -37,7 +36,7 @@ export const registerFunctionComparisonTool = ( async () => { const resolved = resolvePairedEvidenceRequest( input, - investigationEvidenceLookup(session), + session.evidenceForAnalysis.bind(session), ); if (!resolved.ok) return server.delivery.toCallToolResult(resolved, contract); diff --git a/src/server/registerInvestigationTools.ts b/src/server/registerInvestigationTools.ts index e08a8c56f8..689b812e5c 100644 --- a/src/server/registerInvestigationTools.ts +++ b/src/server/registerInvestigationTools.ts @@ -1,7 +1,4 @@ -import { - investigationEvidenceBundle, - resolveInvestigationEvidenceClosure, -} from "../application/investigation/InvestigationEvidence.js"; +import { resolveInvestigationEvidenceClosure } from "../application/investigation/InvestigationEvidence.js"; import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js"; @@ -289,7 +286,7 @@ const registerReconstruction = ( contract.name, context.mcpReq.signal, async () => { - const owned = investigationEvidenceBundle(session); + const owned = session.immutableEvidenceBundle(); const computed = await runDerivedOperation( context, contract.name, diff --git a/src/server/registerNativeFunctionMatchingTool.ts b/src/server/registerNativeFunctionMatchingTool.ts index 73c8bd6346..cd9e783a57 100644 --- a/src/server/registerNativeFunctionMatchingTool.ts +++ b/src/server/registerNativeFunctionMatchingTool.ts @@ -8,7 +8,6 @@ import { createEvidenceToolHandler } from "./contractToolHandler.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { recordSessionEvidenceSources } from "./sessionEvidence.js"; import { ok } from "../domain/result.js"; -import { investigationEvidenceLookup } from "../application/investigation/InvestigationEvidence.js"; import type { Logger } from "pino"; /** Bind one named contract to the same retained-Evidence workflow used by CLI. */ @@ -18,7 +17,7 @@ export const registerNativeFunctionMatchingTool = ( logger: Logger, ): void => { const contract = toolContract("match_native_functions"); - const evidenceById = investigationEvidenceLookup(session); + const evidenceById = session.evidenceForAnalysis.bind(session); const recordEvidence: BinarySessionPort["recordEvidence"] = (evidence) => session.recordEvidence(evidence); const handler = createEvidenceToolHandler( diff --git a/src/server/registerSessionRecordTools.ts b/src/server/registerSessionRecordTools.ts index e1d737dfa6..a3b938391d 100644 --- a/src/server/registerSessionRecordTools.ts +++ b/src/server/registerSessionRecordTools.ts @@ -13,7 +13,6 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j import { err, ok } from "../domain/result.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js"; -import { investigationEvidenceBundle } from "../application/investigation/InvestigationEvidence.js"; import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; import { withAdmittedAnalysis } from "./analysisAdmission.js"; import { runAdmittedToolOperation } from "./admittedToolOperation.js"; @@ -52,7 +51,7 @@ const registerExportEvidenceTool = ({ exportContract.name, toolRegistrationOptions(exportContract), async (input, context) => { - const bundle = investigationEvidenceBundle(session); + const bundle = session.immutableEvidenceBundle(); const written = await writeEvidenceBundle( bundle, input.path, @@ -85,7 +84,7 @@ const registerSnapshotEvidenceTool = ({ toolRegistrationOptions(snapshotContract), (input) => server.delivery.toCallToolResult( - inspectEvidenceBundle(investigationEvidenceBundle(session), input), + inspectEvidenceBundle(session.immutableEvidenceBundle(), input), snapshotContract, ), ); @@ -119,9 +118,9 @@ const registerImportEvidenceTool = ({ if (!loaded.ok) return server.delivery.toCallToolResult(loaded, importContract); const retainedUnknownRevisions = new Set( - investigationEvidenceBundle(session).unknowns.map((unknown) => - unknownRevisionKey(unknown), - ), + session + .immutableEvidenceBundle() + .unknowns.map((unknown) => unknownRevisionKey(unknown)), ); if (signal.aborted) return server.delivery.toCallToolResult( @@ -143,7 +142,7 @@ const registerImportEvidenceTool = ({ unknownRevisionKey(unknown), ), ).length, - total: investigationEvidenceBundle(session).records.length, + total: session.immutableEvidenceBundle().records.length, }), importContract, ) diff --git a/tests/boundary/process/cliEnvironmentSeam.test.ts b/tests/boundary/process/cliEnvironmentSeam.test.ts index fd1f034218..0d46c96c4e 100644 --- a/tests/boundary/process/cliEnvironmentSeam.test.ts +++ b/tests/boundary/process/cliEnvironmentSeam.test.ts @@ -4,10 +4,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { - captureProcessScenarioFile, - isProcessCliFailure, -} from "../../../src/application/process/ProcessCli.js"; +import { captureProcessScenarioFile } from "../../../src/application/process/ProcessCli.js"; +import { isCliOperationFailure } from "../../../src/cliLogging.js"; import { createDirectAnalysis } from "../../../src/composition/directAnalysis.js"; import { probeProcessCaptureCapability } from "../../../src/process/capture/ProcessCaptureCapability.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; @@ -51,7 +49,7 @@ describe("the CLI takes its environment as an input", () => { REA_CAPTURE_INJECTED_MARKER: "injected-value", REA_CAPTURE_SCENARIO_OVERRIDE: "host-value", }); - if (isProcessCliFailure(result)) + if (isCliOperationFailure(result)) throw new Error( `Process capture returned an error: ${JSON.stringify(result)}`, );