fix(evidence): classify ledger duplicate and missing review Evidence as caller input (#4452)
build_reconstruction_obligation_ledger reported a bundle with duplicate record IDs, or a reviewed obligation citing Evidence absent from the bundle, as unreadable_output and pointed callers at rea doctor. Both are caller input mistakes. Throw dedicated TypeError subclasses at those checks and map them to workflow input errors, following the NonCanonicalEvidenceBundleError approach from #3219 so unrelated TypeErrors stay protocol failures. Fixes #4444
This commit is contained in:
1 parent
2bba39bc2b
commit
4cdf3b056f
4 files changed
+111
-8
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<Client["callTool"]>;
|
||||
|
||||
const withLedgerClient = async (
|
||||
run: (request: LedgerRequest) => Promise<void>,
|
||||
): Promise<void> => {
|
||||
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)),
|
||||
|
||||
Reference in new issue
Block a user