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.
This commit is contained in:
morluto
2026-10-25 02:44:21 +08:00
parent 3f168af2e8
commit faf219e90b
13 changed files with 64 additions and 149 deletions
@@ -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({
@@ -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<EvidenceReader, "evidenceForAnalysis">,
seedIds: readonly string[],
): Result<string[], EvidenceIntegrityError> => {
const lookup = investigationEvidenceLookup(reader);
const lookup = reader.evidenceForAnalysis.bind(reader);
const visited = new Set<string>();
const pending = [...seedIds];
while (pending.length > 0) {
@@ -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,
-22
View File
@@ -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,
+9 -17
View File
@@ -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,
),
),
),
});
+3 -2
View File
@@ -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:
+1 -2
View File
@@ -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;
};
+1 -2
View File
@@ -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<typeof toolContract<"compare_artifacts">>,
): void => {
const admission = withAdmittedAnalysis({ kind: "session", session });
const evidenceById = investigationEvidenceLookup(session);
const evidenceById = session.evidenceForAnalysis.bind(session);
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
+1 -2
View File
@@ -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);
+2 -5
View File
@@ -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,
@@ -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(
+6 -7
View File
@@ -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,
)
@@ -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)}`,
);