fix(reconstruction): classify residual graph failures as invalid input (#6321)
This commit is contained in:
1 parent
09416de0cd
commit
b9ca37b51f
3 files changed
+136
-11
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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<ReadonlyMap<string, Evidence>, "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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user