diff --git a/src/application/investigation/EvidenceLedger.ts b/src/application/investigation/EvidenceLedger.ts index 5a83f8c718..a8fc6da9b6 100644 --- a/src/application/investigation/EvidenceLedger.ts +++ b/src/application/investigation/EvidenceLedger.ts @@ -4,14 +4,16 @@ import { z } from "zod"; import { createEvidenceBundle, createImmutableEvidenceBundle, - safeParseEvidenceBundle, + safeParseImmutableEvidenceBundle, + InvalidResidualUnknownGraphError, validateResidualUnknownAddition, type EvidenceBundle, } from "../../domain/evidenceBundle.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; import { UnknownRegistryError } from "../../domain/unknownRegistryError.js"; -import { immutableEvidence, type Evidence } from "../../domain/evidence.js"; +import type { Evidence } from "../../domain/evidence.js"; +import { readImmutableEvidence } from "../../domain/evidenceValidation.js"; import { createResidualUnknown, updateResidualUnknown, @@ -43,21 +45,17 @@ type ImportResult = Result< /** Session-owned set of immutable evidence records. */ export class EvidenceLedger { - readonly #records = new Map(); - readonly #unknownRevisions = new Map(); - readonly #unknownHeads = new Map(); + #records = new Map(); + #unknownRevisions = new Map(); + #unknownHeads = new Map(); /** Record evidence idempotently; conflicting content is rejected. */ record(input: Evidence): RecordResult { - let evidence: Evidence; - try { - evidence = immutableEvidence(input); - } catch (cause: unknown) { - return err( - new EvidenceIntegrityError("Evidence record validation failed", { - cause, - }), - ); - } + const authenticated = readLedgerEvidence( + input, + "Evidence record validation failed", + ); + if (!authenticated.ok) return authenticated; + const evidence = authenticated.value; const existing = this.#records.get(evidence.evidence_id); if (existing !== undefined) { // A borrowed record is already authenticated and deeply immutable. Its @@ -74,7 +72,7 @@ export class EvidenceLedger { /** Validate an entire bundle before atomically merging its records. */ import(input: unknown): ImportResult { - const parsed = safeParseEvidenceBundle(input); + const parsed = safeParseImmutableEvidenceBundle(input); if (!parsed.ok) { const { kind, message, cause } = parsed.error; if (kind === "single-record") @@ -116,9 +114,8 @@ export class EvidenceLedger { additions.set(evidence.evidence_id, evidence); } for (const [id, evidence] of replacements) - this.#records.set(id, immutableEvidence(evidence)); - for (const [id, evidence] of additions) - this.#records.set(id, immutableEvidence(evidence)); + this.#records.set(id, evidence); + for (const [id, evidence] of additions) this.#records.set(id, evidence); return ok({ recordsAdded: additions.size, unknownsAdded: 0, @@ -154,19 +151,14 @@ export class EvidenceLedger { } const checked = this.#validateCandidate(pending, pendingUnknowns); if (!checked.ok) { - if (checked.error instanceof UnknownRegistryError) - return err( - new EvidenceIntegrityError( - "Evidence bundle unknown registry failed", - { - cause: checked.error, - }, - ), - ); - return err(checked.error); + return err( + new EvidenceIntegrityError("Evidence bundle unknown registry failed", { + cause: checked.error, + }), + ); } const added = pending.size - this.#records.size; - this.#commit(pending, pendingUnknowns); + this.#commit(checked.value); return ok({ recordsAdded: added, unknownsAdded, @@ -217,16 +209,12 @@ export class EvidenceLedger { ResidualUnknown, EvidenceIntegrityError | UnknownRegistryError | AnalysisInputError > { - let parsedMutation: Evidence; - try { - parsedMutation = immutableEvidence(mutationEvidence); - } catch (cause: unknown) { - return err( - new EvidenceIntegrityError("Unknown mutation evidence is invalid", { - cause, - }), - ); - } + const authenticated = readLedgerEvidence( + mutationEvidence, + "Unknown mutation evidence is invalid", + ); + if (!authenticated.ok) return authenticated; + const parsedMutation = authenticated.value; let unknown: ResidualUnknown; try { unknown = createResidualUnknown( @@ -235,7 +223,8 @@ export class EvidenceLedger { parsedMutation.subject?.digest.sha256 ?? null, ); } catch (cause: unknown) { - return err(unknownInputError("record_unknown", cause, input)); + if (!(cause instanceof z.ZodError)) throw cause; + return err(requestInputError("record_unknown", cause, input)); } if (this.#unknownHeads.has(unknown.unknown_id)) return err(new UnknownRegistryError("already-exists")); @@ -251,18 +240,18 @@ export class EvidenceLedger { ResidualUnknown | null, EvidenceIntegrityError | UnknownRegistryError > { - let evidence: Evidence; - let mutation: Evidence; - try { - evidence = immutableEvidence(evidenceInput); - mutation = immutableEvidence(mutationInput); - } catch (cause: unknown) { - return err( - new EvidenceIntegrityError("Atomic Evidence validation failed", { - cause, - }), - ); - } + const authenticatedEvidence = readLedgerEvidence( + evidenceInput, + "Atomic Evidence validation failed", + ); + if (!authenticatedEvidence.ok) return authenticatedEvidence; + const authenticatedMutation = readLedgerEvidence( + mutationInput, + "Atomic Evidence validation failed", + ); + if (!authenticatedMutation.ok) return authenticatedMutation; + const evidence = authenticatedEvidence.value; + const mutation = authenticatedMutation.value; const existingEvidence = this.#records.get(evidence.evidence_id); if ( existingEvidence !== undefined && @@ -277,6 +266,7 @@ export class EvidenceLedger { mutation.subject?.digest.sha256 ?? null, ); } catch (cause: unknown) { + if (!(cause instanceof z.ZodError)) throw cause; return err(new UnknownRegistryError("invalid-transition", { cause })); } const creationUnknown = this.#unknownRevisions.get( @@ -315,9 +305,15 @@ export class EvidenceLedger { mutationEvidence.evidence_id, ); } catch (cause: unknown) { - return err(unknownInputError("update_unknown", cause, input)); + if (!(cause instanceof z.ZodError)) throw cause; + return err(requestInputError("update_unknown", cause, input)); } - return this.#appendUnknown(unknown, mutationEvidence); + const authenticated = readLedgerEvidence( + mutationEvidence, + "Unknown mutation evidence is invalid", + ); + if (!authenticated.ok) return authenticated; + return this.#appendUnknown(unknown, authenticated.value); } /** Query current heads in stable ID order. */ @@ -391,23 +387,14 @@ export class EvidenceLedger { #appendUnknown( unknown: ResidualUnknown, - mutationEvidenceInput: Evidence, + mutationEvidence: Evidence, ): Result { - let mutationEvidence: Evidence; - try { - mutationEvidence = immutableEvidence(mutationEvidenceInput); - } catch (cause: unknown) { - return err( - new EvidenceIntegrityError("Unknown mutation evidence is invalid", { - cause, - }), - ); - } const checked = this.#appendIncremental([mutationEvidence], unknown); if (!checked.ok) return checked; return ok(structuredClone(unknown)); } + /** Incoming records were authenticated and sealed at the ingestion boundary. */ #appendIncremental( records: readonly Evidence[], unknown?: ResidualUnknown, @@ -439,10 +426,10 @@ export class EvidenceLedger { this.#unknownHeads, ); } catch (cause: unknown) { + if (!(cause instanceof InvalidResidualUnknownGraphError)) throw cause; return err(new UnknownRegistryError("integrity", { cause })); } - for (const [id, evidence] of additions) - this.#records.set(id, immutableEvidence(evidence)); + for (const [id, evidence] of additions) this.#records.set(id, evidence); if (unknown !== undefined && unknownKey !== undefined) { this.#unknownRevisions.set(unknownKey, unknown); this.#unknownHeads.set(unknown.unknown_id, unknown); @@ -450,48 +437,60 @@ export class EvidenceLedger { return ok(undefined); } + /** Return the sealed candidate facts that the commit will adopt. */ #validateCandidate( records: ReadonlyMap, unknowns: ReadonlyMap, - ): Result { + ): Result { try { - createImmutableEvidenceBundle( - [...records.values()], - [...unknowns.values()], + return ok( + createImmutableEvidenceBundle( + [...records.values()], + [...unknowns.values()], + ), ); } catch (cause: unknown) { + if ( + !(cause instanceof z.ZodError) && + !(cause instanceof InvalidResidualUnknownGraphError) + ) + throw cause; return err(new UnknownRegistryError("integrity", { cause })); } - return ok(undefined); } - #commit( - records: ReadonlyMap, - unknowns: ReadonlyMap, - ): void { - this.#records.clear(); - for (const [id, evidence] of records) - this.#records.set(id, immutableEvidence(evidence)); - this.#unknownRevisions.clear(); - this.#unknownHeads.clear(); - for (const [key, unknown] of unknowns) { - this.#unknownRevisions.set(key, unknown); - const head = this.#unknownHeads.get(unknown.unknown_id); + #commit(bundle: EvidenceBundle): void { + const records = new Map( + bundle.records.map((evidence) => [evidence.evidence_id, evidence]), + ); + const unknownRevisions = new Map( + bundle.unknowns.map((unknown) => [unknownRevisionKey(unknown), unknown]), + ); + const unknownHeads = new Map(); + for (const unknown of bundle.unknowns) { + const head = unknownHeads.get(unknown.unknown_id); if (head === undefined || head.revision < unknown.revision) - this.#unknownHeads.set(unknown.unknown_id, unknown); + unknownHeads.set(unknown.unknown_id, unknown); } + this.#records = records; + this.#unknownRevisions = unknownRevisions; + this.#unknownHeads = unknownHeads; } } -/** A caller's unknown that fails its schema is an input mistake, not a state change. */ -const unknownInputError = ( - operation: "record_unknown" | "update_unknown", - cause: unknown, - input: RecordUnknownInput | UpdateUnknownInput, -): UnknownRegistryError | AnalysisInputError => - cause instanceof z.ZodError - ? requestInputError(operation, cause, input) - : new UnknownRegistryError("invalid-transition", { cause }); +const readLedgerEvidence = ( + input: unknown, + message: string, +): Result => { + const checked = readImmutableEvidence(input); + return checked.ok + ? checked + : err( + new EvidenceIntegrityError(message, { + cause: checked.error.cause, + }), + ); +}; const unknownRevisionKey = (unknown: ResidualUnknown): string => `${unknown.unknown_id}:${String(unknown.revision)}`; diff --git a/tests/process-global/ledgerMutationFailures.test.ts b/tests/process-global/ledgerMutationFailures.test.ts new file mode 100644 index 0000000000..15d01321b1 --- /dev/null +++ b/tests/process-global/ledgerMutationFailures.test.ts @@ -0,0 +1,311 @@ +import { expect, it, onTestFinished, vi } from "vitest"; + +import * as evidenceDomain from "../../src/domain/evidence.js"; +import * as evidenceBundleDomain from "../../src/domain/evidenceBundle.js"; +import * as residualUnknownDomain from "../../src/domain/residualUnknown.js"; +import { + recordUnknownInputSchema, + updateUnknownInputSchema, +} from "../../src/domain/residualUnknown.js"; +import { processEvidence } from "../../src/application/ReconstructionObligationLedger.fixture.js"; +import { createApplicationMcpHarness } from "../fixtures/applicationMcpHarness.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; +import { InvestigationRecords } from "../../src/application/investigation/InvestigationRecords.js"; + +type ApplicationClient = Awaited< + ReturnType +>["client"]; + +const unknownInput = (question: string) => + recordUnknownInputSchema.parse({ + question, + domain: "ledger-failure", + severity: "medium", + required_authority: "analyst-inference", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + }); + +const updateInput = (unknownId: string, expectedRevision: number) => + updateUnknownInputSchema.parse({ + unknown_id: unknownId, + expected_revision: expectedRevision, + status: "investigating", + severity: "medium", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "analyst-inference", + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + resolution: null, + }); + +const failureFor = (kind: "TypeError" | "Error", message: string): Error => + kind === "TypeError" ? new TypeError(message) : new Error(message); + +const expectSdkFailure = async ( + client: ApplicationClient, + name: "record_unknown" | "update_unknown", + args: Record, + failure: Error, +): Promise => { + const response = await client.callTool({ + name, + arguments: args, + }); + expect(response).toMatchObject({ + isError: true, + content: [{ type: "text", text: failure.message }], + }); + expect(response.structuredContent).toBeUndefined(); +}; + +const withHarness = async ( + run: ( + harness: Awaited>, + ) => Promise, +): Promise => { + const session = createTestBinarySession(() => { + throw new Error("Ledger mutations must not launch a provider"); + }); + const harness = await createApplicationMcpHarness({ session }); + try { + return await run(harness); + } finally { + vi.restoreAllMocks(); + await harness.close(); + } +}; + +it.each( + (["recordEvidence", "recordEvidenceWithUnknown"] as const).flatMap( + (operation) => + (["TypeError", "Error"] as const).map( + (kind) => [operation, kind] as const, + ), + ), +)( + "lets unexpected authentication failure escape %s (%s) without changing the bundle", + async (operation, kind) => + withHarness(async ({ session }) => { + const derived = processEvidence(); + const input = unknownInput(`Failure during ${operation}`); + const before = session.exportEvidenceBundle(); + const failure = failureFor(kind, `${operation} authentication failed`); + const authenticate = vi + .spyOn(evidenceDomain, "immutableEvidence") + .mockImplementation(() => { + throw failure; + }); + onTestFinished(() => authenticate.mockRestore()); + + if (operation === "recordEvidence") + expect(() => session.recordEvidence(derived)).toThrow(failure); + else + expect(() => session.recordEvidenceWithUnknown(derived, input)).toThrow( + failure, + ); + expect(session.exportEvidenceBundle()).toEqual(before); + }), +); + +it("preserves an unexpected unknown-construction fault through record_unknown", async () => + withHarness(async ({ client, session }) => { + const before = session.exportEvidenceBundle(); + const failure = new TypeError("unknown constructor implementation failed"); + const construct = vi + .spyOn(residualUnknownDomain, "createResidualUnknown") + .mockImplementation(() => { + throw failure; + }); + onTestFinished(() => construct.mockRestore()); + + await expectSdkFailure( + client, + "record_unknown", + unknownInput("Unexpected constructor failure"), + failure, + ); + expect(session.exportEvidenceBundle()).toEqual(before); + })); + +it("preserves an unexpected unknown-revision fault through update_unknown", async () => + withHarness(async ({ client, session }) => { + const created = session.recordUnknown(unknownInput("Update failure setup")); + if (!created.ok) throw created.error; + const before = session.exportEvidenceBundle(); + const failure = new Error("unknown update implementation failed"); + const update = vi + .spyOn(residualUnknownDomain, "updateResidualUnknown") + .mockImplementation(() => { + throw failure; + }); + onTestFinished(() => update.mockRestore()); + + await expectSdkFailure( + client, + "update_unknown", + updateInput(created.value.unknown_id, created.value.revision), + failure, + ); + expect(session.exportEvidenceBundle()).toEqual(before); + })); + +it.each( + (["record_unknown", "update_unknown"] as const).flatMap((operation) => + (["TypeError", "Error"] as const).map((kind) => [operation, kind] as const), + ), +)( + "preserves unexpected mutation Evidence authentication failure through %s (%s)", + async (operation, kind) => + withHarness(async ({ client, session }) => { + let args: Record; + if (operation === "record_unknown") { + args = unknownInput("Mutation Evidence authentication failure"); + } else { + const created = session.recordUnknown( + unknownInput("Mutation Evidence authentication setup"), + ); + if (!created.ok) throw created.error; + args = updateInput(created.value.unknown_id, created.value.revision); + } + const before = session.exportEvidenceBundle(); + const failure = failureFor( + kind, + "mutation Evidence authentication failed", + ); + const authenticate = vi + .spyOn(evidenceDomain, "immutableEvidence") + .mockImplementation(() => { + throw failure; + }); + onTestFinished(() => authenticate.mockRestore()); + + await expectSdkFailure(client, operation, args, failure); + expect(session.exportEvidenceBundle()).toEqual(before); + }), +); + +it.each([ + ["record_unknown", "record graph failure"], + ["update_unknown", "update graph failure"], +] as const)( + "does not convert an unexpected graph-validation fault during %s into a caller error", + async (operation, message) => + withHarness(async ({ client, session }) => { + let args: Record; + if (operation === "record_unknown") { + args = unknownInput(`Unexpected graph validation: ${message}`); + } else { + const created = session.recordUnknown( + unknownInput(`Graph validation setup: ${message}`), + ); + if (!created.ok) throw created.error; + args = updateInput(created.value.unknown_id, created.value.revision); + } + const before = session.exportEvidenceBundle(); + const failure = new Error(message); + const validate = vi + .spyOn(evidenceBundleDomain, "validateResidualUnknownAddition") + .mockImplementation(() => { + throw failure; + }); + onTestFinished(() => validate.mockRestore()); + + await expectSdkFailure(client, operation, args, failure); + expect(session.exportEvidenceBundle()).toEqual(before); + }), +); + +it("preserves unexpected full-candidate bundle validation faults and leaves import state unchanged", async () => + withHarness(async ({ session }) => { + const source = new InvestigationRecords(); + const created = source.recordUnknown(unknownInput("Imported history")); + if (!created.ok) throw created.error; + const bundle = source.exportEvidenceBundle(); + const before = session.exportEvidenceBundle(); + const failure = new TypeError("candidate bundle validation failed"); + const validate = vi + .spyOn(evidenceBundleDomain, "createImmutableEvidenceBundle") + .mockImplementation(() => { + throw failure; + }); + onTestFinished(() => validate.mockRestore()); + + expect(() => session.importEvidenceBundle(bundle)).toThrow(failure); + expect(session.exportEvidenceBundle()).toEqual(before); + })); + +it("authenticates derived and mutation Evidence before committing recordWithUnknown", async () => + withHarness(async ({ session }) => { + const input = unknownInput("Atomic record with unknown"); + const evidence = processEvidence(); + const expectedId = evidence.evidence_id; + const original = evidenceDomain.immutableEvidence; + let evidenceVisibleDuringAuthentication = false; + const authenticate = vi + .spyOn(evidenceDomain, "immutableEvidence") + .mockImplementation((candidate) => { + if ( + session + .exportEvidenceBundle() + .records.some((record) => record.evidence_id === expectedId) + ) + evidenceVisibleDuringAuthentication = true; + return original(candidate); + }); + onTestFinished(() => authenticate.mockRestore()); + + const recorded = session.recordEvidenceWithUnknown(evidence, input); + authenticate.mockRestore(); + expect(recorded.ok).toBe(true); + expect(evidenceVisibleDuringAuthentication).toBe(false); + const committed = session.exportEvidenceBundle(); + expect(committed.records.map(({ evidence_id }) => evidence_id)).toContain( + expectedId, + ); + expect(committed.unknowns).toHaveLength(1); + expect(committed.records).toHaveLength(2); + expect( + session.immutableEvidenceBundle().records.every(Object.isFrozen), + ).toBe(true); + })); + +it("authenticates all Evidence before committing an imported unknown history", async () => + withHarness(async ({ session }) => { + const source = new InvestigationRecords(); + expect(source.recordEvidence(processEvidence()).ok).toBe(true); + const created = source.recordUnknown( + unknownInput("Atomic imported history"), + ); + if (!created.ok) throw created.error; + const bundle = source.exportEvidenceBundle(); + const expectedIds = bundle.records.map(({ evidence_id }) => evidence_id); + const original = evidenceDomain.immutableEvidence; + let incomingVisibleDuringAuthentication = false; + const authenticate = vi + .spyOn(evidenceDomain, "immutableEvidence") + .mockImplementation((input) => { + const visible = session + .exportEvidenceBundle() + .records.some(({ evidence_id }) => expectedIds.includes(evidence_id)); + if (visible) incomingVisibleDuringAuthentication = true; + return original(input); + }); + onTestFinished(() => authenticate.mockRestore()); + + const imported = session.importEvidenceBundle(bundle); + authenticate.mockRestore(); + expect(imported.ok).toBe(true); + expect(incomingVisibleDuringAuthentication).toBe(false); + expect(session.exportEvidenceBundle()).toEqual(bundle); + expect( + session.immutableEvidenceBundle().records.every(Object.isFrozen), + ).toBe(true); + }));