diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index 5cc9436b19..45d3cbcec7 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -36,6 +36,14 @@ export interface ReconstructionObligationCandidateSet { readonly limitations: readonly string[]; } +/** Caller input cites review Evidence that its bundle does not contain. */ +export class MissingReviewEvidenceError extends TypeError { + constructor(evidenceId: string) { + super(`Reviewed obligation Evidence is missing: ${evidenceId}`); + this.name = "MissingReviewEvidenceError"; + } +} + /** Derive conservative candidates from graph, process, and reviewed inputs. */ export const deriveReconstructionObligationCandidates = ( bundleInput: EvidenceBundle, @@ -322,9 +330,7 @@ const reviewedCandidate = ( authority_references: reviewEvidenceIds.map((evidenceId) => { const evidence = records.get(evidenceId); if (evidence === undefined) - throw new TypeError( - `Reviewed obligation Evidence is missing: ${evidenceId}`, - ); + throw new MissingReviewEvidenceError(evidenceId); return { evidence_id: evidenceId, authority: evidence.authority, diff --git a/src/application/ReconstructionObligationLedgerService.ts b/src/application/ReconstructionObligationLedgerService.ts index cffee63304..745ccb9ce6 100644 --- a/src/application/ReconstructionObligationLedgerService.ts +++ b/src/application/ReconstructionObligationLedgerService.ts @@ -2,7 +2,10 @@ import { z } from "zod"; import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; -import { NonCanonicalEvidenceBundleError } from "../domain/evidenceBundle.js"; +import { + DuplicateEvidenceRecordError, + NonCanonicalEvidenceBundleError, +} from "../domain/evidenceBundle.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js"; import { @@ -14,7 +17,10 @@ import { import { err, ok, type Result } from "../domain/result.js"; import { requestInputError, workflowInputError } from "./workflowInputError.js"; import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "./InvestigationProviders.js"; -import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; +import { + deriveReconstructionObligationCandidates, + MissingReviewEvidenceError, +} from "./ReconstructionObligationCandidates.js"; import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js"; const OPERATION = "build_reconstruction_obligation_ledger" as const; @@ -46,7 +52,11 @@ export const buildReconstructionObligationLedgerEvidenceValidated = ( }); return ok(createLedgerEvidence(input, ledger)); } catch (cause: unknown) { - if (cause instanceof NonCanonicalEvidenceBundleError) + if ( + cause instanceof NonCanonicalEvidenceBundleError || + cause instanceof DuplicateEvidenceRecordError || + cause instanceof MissingReviewEvidenceError + ) return err(workflowInputError(OPERATION, cause)); if (cause instanceof z.ZodError) return err(workflowInputError(OPERATION, cause)); diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 6df730f0e8..4ffd140128 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -60,6 +60,14 @@ export class NonCanonicalEvidenceBundleError extends TypeError { } } +/** Caller input lists the same Evidence record more than once. */ +export class DuplicateEvidenceRecordError extends TypeError { + constructor() { + super("Evidence bundle contains duplicate record IDs"); + this.name = "DuplicateEvidenceRecordError"; + } +} + /** Build and authenticate a sealed bundle while reusing immutable Evidence records. */ export const createImmutableEvidenceBundle = ( records: readonly Evidence[], @@ -70,7 +78,7 @@ export const createImmutableEvidenceBundle = ( new Set(checkedRecords.map(({ evidence_id }) => evidence_id)).size !== checkedRecords.length ) - throw new TypeError("Evidence bundle contains duplicate record IDs"); + throw new DuplicateEvidenceRecordError(); const checkedUnknowns = unknowns.map((unknown) => residualUnknownSchema.parse(unknown), ); @@ -290,7 +298,7 @@ const authenticateBundle = ( ): EvidenceBundle => { const recordIds = parsed.records.map(({ evidence_id: id }) => id); if (new Set(recordIds).size !== recordIds.length) - throw new TypeError("Evidence bundle contains duplicate record IDs"); + throw new DuplicateEvidenceRecordError(); validateUnknownGraph(parsed.unknowns, parsed.records); const canonical = createEvidenceBundle( parsed.records.map((record, index) => { diff --git a/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts index f9b61b7514..c027654ced 100644 --- a/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts +++ b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts @@ -7,8 +7,58 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { createServer } from "../../../src/server/createServer.js"; +import { reviewedObligation } from "../../../src/application/ReconstructionObligationLedger.fixture.js"; import { observed } from "../../fixtures/analysisExecution.js"; +type LedgerRequest = ( + evidenceBundle: unknown, + reviewedObligations?: readonly unknown[], +) => ReturnType; + +const withLedgerClient = async ( + run: (request: LedgerRequest) => Promise, +): Promise => { + const session = createTestBinarySession(() => ({ + execute: () => Promise.resolve(observed(null)), + close: () => Promise.resolve(resultOk(null)), + })); + const server = createServer({ kind: "session", session }); + const client = new Client({ name: "obligation-ledger-test", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + await run((evidenceBundle, reviewedObligations = []) => + client.callTool({ + name: "build_reconstruction_obligation_ledger", + arguments: { + evidence_bundle: evidenceBundle, + reviewed_obligations: reviewedObligations, + manifest: { bindings: [], contradictions: [] }, + }, + }), + ); + } finally { + await client.close(); + await server.close(); + await session.close(); + } +}; + +const inputError = (message: string) => ({ + error: { + code: "invalid_request", + category: "invalid_input", + details: { + operation: "build_reconstruction_obligation_ledger", + issues: [ + expect.objectContaining({ message: expect.stringContaining(message) }), + ], + }, + }, +}); + describe("reconstruction obligation ledger MCP parity", () => { it("returns canonical bundle failures as caller input errors", async () => { const records = ["first", "second"].map((label) => @@ -76,6 +126,35 @@ describe("reconstruction obligation ledger MCP parity", () => { } }); + it("returns duplicate records and missing review Evidence as caller input errors", async () => { + const record = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "observe", parameters: {}, result: {} }, + ); + const canonical = createEvidenceBundle([record]); + const missingEvidenceId = `ev_${"a".repeat(64)}`; + await withLedgerClient(async (request) => { + const duplicate = await request({ + ...canonical, + records: [...canonical.records, ...canonical.records], + }); + expect(duplicate.isError).toBe(true); + expect(parseMcpToolError(duplicate)).toMatchObject( + inputError("Evidence bundle contains duplicate record IDs"), + ); + const missing = await request(canonical, [ + reviewedObligation("obl.reviewed", missingEvidenceId), + ]); + expect(missing.isError).toBe(true); + expect(parseMcpToolError(missing)).toMatchObject( + inputError( + `Reviewed obligation Evidence is missing: ${missingEvidenceId}`, + ), + ); + }); + }); + it("returns the complete Evidence-backed ledger inline", async () => { const session = createTestBinarySession(() => ({ execute: () => Promise.resolve(observed(null)),