From faf219e90bc20debdbce0c0e40c9e68fc39de755 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:44:21 +0800 Subject: [PATCH] refactor(application): use current Evidence and CLI contracts Require the immutable Evidence read APIs already implemented by production owners. Remove the detached-only compatibility fallbacks and forwarding helpers, and bind consumers directly to their owner. Preserve editable detached exports and existing snapshot semantics. Use the shared canonical CLI error classifier for process commands instead of reinterpreting their projection. Validation: 172 focused application and process tests, build, static checks and documentation checks passed using local fixtures. --- .../InvestigationEvidence.test.ts | 80 +++++++------------ .../investigation/InvestigationEvidence.ts | 30 +------ .../investigation/InvestigationRecordPort.ts | 10 +-- src/application/process/ProcessCli.ts | 22 ----- src/cli/processCommands.ts | 26 +++--- src/server/createServer.ts | 5 +- src/server/promptCompletion.ts | 3 +- src/server/registerArtifactComparisonTool.ts | 3 +- src/server/registerFunctionComparisonTool.ts | 3 +- src/server/registerInvestigationTools.ts | 7 +- .../registerNativeFunctionMatchingTool.ts | 3 +- src/server/registerSessionRecordTools.ts | 13 ++- .../process/cliEnvironmentSeam.test.ts | 8 +- 13 files changed, 64 insertions(+), 149 deletions(-) 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)}`, );