fix(reconstruction): classify residual graph failures as invalid input (#6321)

This commit is contained in:
OrdoAbChao authored and GitHub committed 2026-10-23 01:28:58 +08:00
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));
+23 -11
View File
@@ -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) =>