diff --git a/src/application/ReconstructionObligationLedgerService.ts b/src/application/ReconstructionObligationLedgerService.ts index 745ccb9ce6..bdf67c0ddb 100644 --- a/src/application/ReconstructionObligationLedgerService.ts +++ b/src/application/ReconstructionObligationLedgerService.ts @@ -4,6 +4,7 @@ import { AnalysisProtocolError } from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { DuplicateEvidenceRecordError, + InvalidResidualUnknownGraphError, NonCanonicalEvidenceBundleError, } from "../domain/evidenceBundle.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; @@ -55,6 +56,7 @@ export const buildReconstructionObligationLedgerEvidenceValidated = ( if ( cause instanceof NonCanonicalEvidenceBundleError || cause instanceof DuplicateEvidenceRecordError || + cause instanceof InvalidResidualUnknownGraphError || cause instanceof MissingReviewEvidenceError ) return err(workflowInputError(OPERATION, cause)); diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 1d5d01f848..ba0a47f758 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -69,6 +69,14 @@ export class DuplicateEvidenceRecordError extends TypeError { } } +/** Caller input violates a residual-unknown reference or history constraint. */ +export class InvalidResidualUnknownGraphError extends TypeError { + constructor(message: string) { + super(message); + this.name = "InvalidResidualUnknownGraphError"; + } +} + /** Build and authenticate a sealed bundle while reusing immutable Evidence records. */ export const createImmutableEvidenceBundle = ( records: readonly Evidence[], @@ -352,7 +360,7 @@ const validateUnknownGraph = ( ); for (const [index, revision] of history.entries()) { if (revision.revision !== index + 1) - throw new TypeError( + throw new InvalidResidualUnknownGraphError( `Residual unknown ${id} revision history has a gap`, ); if ( @@ -360,7 +368,9 @@ const validateUnknownGraph = ( revision.previous_revision_digest !== history[index - 1]?.revision_digest ) - throw new TypeError(`Residual unknown ${id} revision chain is broken`); + throw new InvalidResidualUnknownGraphError( + `Residual unknown ${id} revision chain is broken`, + ); } const head = history.at(-1); if (head === undefined) @@ -376,12 +386,12 @@ const validateUnknownGraph = ( ]; for (const evidenceId of referencedEvidence) if (!evidenceById.has(evidenceId)) - throw new TypeError( + throw new InvalidResidualUnknownGraphError( `Residual unknown references missing evidence ${evidenceId}`, ); for (const relationship of unknown.relationships) if (!unknownById.has(relationship.unknown_id)) - throw new TypeError( + throw new InvalidResidualUnknownGraphError( `Residual unknown references missing unknown ${relationship.unknown_id}`, ); if (unknown.resolution?.disposition === "verified") @@ -395,7 +405,7 @@ const validateVerifiedResolution = ( evidenceById: Pick, "get">, ): void => { if (unknown.contradicting_evidence_ids.length > 0) - throw new TypeError( + throw new InvalidResidualUnknownGraphError( `Residual unknown ${unknown.unknown_id} retains contradicting evidence`, ); const qualifies = unknown.resolution?.evidence_ids.some((evidenceId) => { @@ -408,7 +418,7 @@ const validateVerifiedResolution = ( ); }); if (qualifies !== true) - throw new TypeError( + throw new InvalidResidualUnknownGraphError( `Residual unknown ${unknown.unknown_id} has no qualifying resolution evidence`, ); }; @@ -430,12 +440,12 @@ export const validateResidualUnknownAddition = ( : unknown.revision !== current.revision + 1 || unknown.previous_revision_digest !== current.revision_digest ) - throw new TypeError( + throw new InvalidResidualUnknownGraphError( `Residual unknown ${unknown.unknown_id} revision chain is broken`, ); for (const evidenceId of referencedEvidenceIds(unknown)) if (!evidenceById.has(evidenceId)) - throw new TypeError( + throw new InvalidResidualUnknownGraphError( `Residual unknown references missing evidence ${evidenceId}`, ); for (const relationship of unknown.relationships) @@ -443,7 +453,7 @@ export const validateResidualUnknownAddition = ( relationship.unknown_id !== unknown.unknown_id && !currentHeads.has(relationship.unknown_id) ) - throw new TypeError( + throw new InvalidResidualUnknownGraphError( `Residual unknown references missing unknown ${relationship.unknown_id}`, ); if (unknown.resolution?.disposition === "verified") @@ -459,7 +469,9 @@ export const validateResidualUnknownAddition = ( dependencies.length > 0 && reachesUnknown(dependencies, unknown.unknown_id, currentHeads, unknown) ) - throw new TypeError("Residual unknown dependency graph contains a cycle"); + throw new InvalidResidualUnknownGraphError( + "Residual unknown dependency graph contains a cycle", + ); } }; @@ -547,7 +559,7 @@ const rejectDependencyCycles = ( if (relationship?.type !== "depends-on") continue; const dependency = relationship.unknown_id; if (active.has(dependency)) - throw new TypeError( + throw new InvalidResidualUnknownGraphError( "Residual unknown dependency graph contains a cycle", ); if (visited.has(dependency)) continue; diff --git a/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts index dc9795f6ea..56ef8fd3a8 100644 --- a/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts +++ b/tests/boundary/mcp/reconstructionObligationLedgerMcp.test.ts @@ -6,6 +6,11 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { createEvidence } from "../../../src/domain/evidence.js"; +import { + createResidualUnknown, + recordUnknownInputSchema, + updateResidualUnknown, +} from "../../../src/domain/residualUnknown.js"; import { createServer } from "../../../src/server/createServer.js"; import { MIXED_AUTHORITY_SUMMARY, @@ -63,6 +68,112 @@ const inputError = (message: string) => ({ }, }); +describe("reconstruction obligation ledger residual graph input", () => { + it("reports missing residual Evidence references and accepts the repaired bundle", async () => { + const input = recordUnknownInputSchema.parse({ + question: "Does the fixture retain state?", + severity: "low", + domain: "persistence", + required_authority: null, + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: [], + }); + const mutation = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + predicateType: "rea.residual-unknown-mutation", + operation: "record_unknown", + parameters: {}, + result: { question: input.question }, + }, + ); + const unknown = createResidualUnknown(input, mutation.evidence_id, null); + const valid = createEvidenceBundle([mutation], [unknown]); + await withLedgerClient(async (request) => { + expect((await request(valid)).isError).not.toBe(true); + const result = await request(createEvidenceBundle([], [unknown])); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject( + inputError( + `Residual unknown references missing evidence ${mutation.evidence_id}`, + ), + ); + const repaired = await request(valid); + expect(repaired.isError).not.toBe(true); + expect(repaired.structuredContent).toMatchObject({ + normalized_result: { status: "unknown", summary: { total: 0 } }, + }); + }); + }); + + it.each(["history", "relationship", "resolution"] as const)( + "reports residual %s constraints as caller input", + async (constraint) => { + const missingId = `unk_${"a".repeat(64)}`; + const input = recordUnknownInputSchema.parse({ + question: "Does the fixture retain state?", + severity: "low", + domain: "persistence", + required_authority: null, + required_confidence: "observed", + required_environment: null, + recommended_probes: [], + relationships: + constraint === "relationship" + ? [{ type: "related-to", unknown_id: missingId }] + : [], + }); + const mutation = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + predicateType: "rea.residual-unknown-mutation", + operation: "record_unknown", + parameters: {}, + result: { question: input.question }, + }, + ); + const first = createResidualUnknown(input, mutation.evidence_id, null); + const revision = updateResidualUnknown( + first, + { + ...input, + unknown_id: first.unknown_id, + expected_revision: 1, + status: constraint === "resolution" ? "resolved" : "investigating", + resolution: + constraint === "resolution" + ? { + disposition: "verified", + rationale: "A mutation alone does not prove the answer", + evidence_ids: [mutation.evidence_id], + } + : null, + }, + mutation.evidence_id, + ); + const unknowns = + constraint === "history" ? [revision] : [first, revision]; + const reason = + constraint === "history" + ? `Residual unknown ${first.unknown_id} revision history has a gap` + : constraint === "relationship" + ? `Residual unknown references missing unknown ${missingId}` + : `Residual unknown ${first.unknown_id} has no qualifying resolution evidence`; + await withLedgerClient(async (request) => { + const result = await request( + createEvidenceBundle([mutation], unknowns), + ); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result)).toMatchObject(inputError(reason)); + }); + }, + ); +}); + describe("reconstruction obligation ledger MCP parity", () => { it("returns canonical bundle failures as caller input errors", async () => { const records = ["first", "second"].map((label) =>