fix(ledger): authenticate evidence before committing

This commit is contained in:
morluto
2026-10-23 13:32:58 +08:00
parent 6dff37594c
commit 0407bbfbe6
2 changed files with 405 additions and 95 deletions
+94 -95
View File
@@ -4,14 +4,16 @@ import { z } from "zod";
import {
createEvidenceBundle,
createImmutableEvidenceBundle,
safeParseEvidenceBundle,
safeParseImmutableEvidenceBundle,
InvalidResidualUnknownGraphError,
validateResidualUnknownAddition,
type EvidenceBundle,
} from "../../domain/evidenceBundle.js";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import { UnknownRegistryError } from "../../domain/unknownRegistryError.js";
import { immutableEvidence, type Evidence } from "../../domain/evidence.js";
import type { Evidence } from "../../domain/evidence.js";
import { readImmutableEvidence } from "../../domain/evidenceValidation.js";
import {
createResidualUnknown,
updateResidualUnknown,
@@ -43,21 +45,17 @@ type ImportResult = Result<
/** Session-owned set of immutable evidence records. */
export class EvidenceLedger {
readonly #records = new Map<string, Evidence>();
readonly #unknownRevisions = new Map<string, ResidualUnknown>();
readonly #unknownHeads = new Map<string, ResidualUnknown>();
#records = new Map<string, Evidence>();
#unknownRevisions = new Map<string, ResidualUnknown>();
#unknownHeads = new Map<string, ResidualUnknown>();
/** Record evidence idempotently; conflicting content is rejected. */
record(input: Evidence): RecordResult {
let evidence: Evidence;
try {
evidence = immutableEvidence(input);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Evidence record validation failed", {
cause,
}),
);
}
const authenticated = readLedgerEvidence(
input,
"Evidence record validation failed",
);
if (!authenticated.ok) return authenticated;
const evidence = authenticated.value;
const existing = this.#records.get(evidence.evidence_id);
if (existing !== undefined) {
// A borrowed record is already authenticated and deeply immutable. Its
@@ -74,7 +72,7 @@ export class EvidenceLedger {
/** Validate an entire bundle before atomically merging its records. */
import(input: unknown): ImportResult {
const parsed = safeParseEvidenceBundle(input);
const parsed = safeParseImmutableEvidenceBundle(input);
if (!parsed.ok) {
const { kind, message, cause } = parsed.error;
if (kind === "single-record")
@@ -116,9 +114,8 @@ export class EvidenceLedger {
additions.set(evidence.evidence_id, evidence);
}
for (const [id, evidence] of replacements)
this.#records.set(id, immutableEvidence(evidence));
for (const [id, evidence] of additions)
this.#records.set(id, immutableEvidence(evidence));
this.#records.set(id, evidence);
for (const [id, evidence] of additions) this.#records.set(id, evidence);
return ok({
recordsAdded: additions.size,
unknownsAdded: 0,
@@ -154,19 +151,14 @@ export class EvidenceLedger {
}
const checked = this.#validateCandidate(pending, pendingUnknowns);
if (!checked.ok) {
if (checked.error instanceof UnknownRegistryError)
return err(
new EvidenceIntegrityError(
"Evidence bundle unknown registry failed",
{
cause: checked.error,
},
),
);
return err(checked.error);
return err(
new EvidenceIntegrityError("Evidence bundle unknown registry failed", {
cause: checked.error,
}),
);
}
const added = pending.size - this.#records.size;
this.#commit(pending, pendingUnknowns);
this.#commit(checked.value);
return ok({
recordsAdded: added,
unknownsAdded,
@@ -217,16 +209,12 @@ export class EvidenceLedger {
ResidualUnknown,
EvidenceIntegrityError | UnknownRegistryError | AnalysisInputError
> {
let parsedMutation: Evidence;
try {
parsedMutation = immutableEvidence(mutationEvidence);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Unknown mutation evidence is invalid", {
cause,
}),
);
}
const authenticated = readLedgerEvidence(
mutationEvidence,
"Unknown mutation evidence is invalid",
);
if (!authenticated.ok) return authenticated;
const parsedMutation = authenticated.value;
let unknown: ResidualUnknown;
try {
unknown = createResidualUnknown(
@@ -235,7 +223,8 @@ export class EvidenceLedger {
parsedMutation.subject?.digest.sha256 ?? null,
);
} catch (cause: unknown) {
return err(unknownInputError("record_unknown", cause, input));
if (!(cause instanceof z.ZodError)) throw cause;
return err(requestInputError("record_unknown", cause, input));
}
if (this.#unknownHeads.has(unknown.unknown_id))
return err(new UnknownRegistryError("already-exists"));
@@ -251,18 +240,18 @@ export class EvidenceLedger {
ResidualUnknown | null,
EvidenceIntegrityError | UnknownRegistryError
> {
let evidence: Evidence;
let mutation: Evidence;
try {
evidence = immutableEvidence(evidenceInput);
mutation = immutableEvidence(mutationInput);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Atomic Evidence validation failed", {
cause,
}),
);
}
const authenticatedEvidence = readLedgerEvidence(
evidenceInput,
"Atomic Evidence validation failed",
);
if (!authenticatedEvidence.ok) return authenticatedEvidence;
const authenticatedMutation = readLedgerEvidence(
mutationInput,
"Atomic Evidence validation failed",
);
if (!authenticatedMutation.ok) return authenticatedMutation;
const evidence = authenticatedEvidence.value;
const mutation = authenticatedMutation.value;
const existingEvidence = this.#records.get(evidence.evidence_id);
if (
existingEvidence !== undefined &&
@@ -277,6 +266,7 @@ export class EvidenceLedger {
mutation.subject?.digest.sha256 ?? null,
);
} catch (cause: unknown) {
if (!(cause instanceof z.ZodError)) throw cause;
return err(new UnknownRegistryError("invalid-transition", { cause }));
}
const creationUnknown = this.#unknownRevisions.get(
@@ -315,9 +305,15 @@ export class EvidenceLedger {
mutationEvidence.evidence_id,
);
} catch (cause: unknown) {
return err(unknownInputError("update_unknown", cause, input));
if (!(cause instanceof z.ZodError)) throw cause;
return err(requestInputError("update_unknown", cause, input));
}
return this.#appendUnknown(unknown, mutationEvidence);
const authenticated = readLedgerEvidence(
mutationEvidence,
"Unknown mutation evidence is invalid",
);
if (!authenticated.ok) return authenticated;
return this.#appendUnknown(unknown, authenticated.value);
}
/** Query current heads in stable ID order. */
@@ -391,23 +387,14 @@ export class EvidenceLedger {
#appendUnknown(
unknown: ResidualUnknown,
mutationEvidenceInput: Evidence,
mutationEvidence: Evidence,
): Result<ResidualUnknown, EvidenceIntegrityError | UnknownRegistryError> {
let mutationEvidence: Evidence;
try {
mutationEvidence = immutableEvidence(mutationEvidenceInput);
} catch (cause: unknown) {
return err(
new EvidenceIntegrityError("Unknown mutation evidence is invalid", {
cause,
}),
);
}
const checked = this.#appendIncremental([mutationEvidence], unknown);
if (!checked.ok) return checked;
return ok(structuredClone(unknown));
}
/** Incoming records were authenticated and sealed at the ingestion boundary. */
#appendIncremental(
records: readonly Evidence[],
unknown?: ResidualUnknown,
@@ -439,10 +426,10 @@ export class EvidenceLedger {
this.#unknownHeads,
);
} catch (cause: unknown) {
if (!(cause instanceof InvalidResidualUnknownGraphError)) throw cause;
return err(new UnknownRegistryError("integrity", { cause }));
}
for (const [id, evidence] of additions)
this.#records.set(id, immutableEvidence(evidence));
for (const [id, evidence] of additions) this.#records.set(id, evidence);
if (unknown !== undefined && unknownKey !== undefined) {
this.#unknownRevisions.set(unknownKey, unknown);
this.#unknownHeads.set(unknown.unknown_id, unknown);
@@ -450,48 +437,60 @@ export class EvidenceLedger {
return ok(undefined);
}
/** Return the sealed candidate facts that the commit will adopt. */
#validateCandidate(
records: ReadonlyMap<string, Evidence>,
unknowns: ReadonlyMap<string, ResidualUnknown>,
): Result<void, EvidenceIntegrityError | UnknownRegistryError> {
): Result<EvidenceBundle, UnknownRegistryError> {
try {
createImmutableEvidenceBundle(
[...records.values()],
[...unknowns.values()],
return ok(
createImmutableEvidenceBundle(
[...records.values()],
[...unknowns.values()],
),
);
} catch (cause: unknown) {
if (
!(cause instanceof z.ZodError) &&
!(cause instanceof InvalidResidualUnknownGraphError)
)
throw cause;
return err(new UnknownRegistryError("integrity", { cause }));
}
return ok(undefined);
}
#commit(
records: ReadonlyMap<string, Evidence>,
unknowns: ReadonlyMap<string, ResidualUnknown>,
): void {
this.#records.clear();
for (const [id, evidence] of records)
this.#records.set(id, immutableEvidence(evidence));
this.#unknownRevisions.clear();
this.#unknownHeads.clear();
for (const [key, unknown] of unknowns) {
this.#unknownRevisions.set(key, unknown);
const head = this.#unknownHeads.get(unknown.unknown_id);
#commit(bundle: EvidenceBundle): void {
const records = new Map(
bundle.records.map((evidence) => [evidence.evidence_id, evidence]),
);
const unknownRevisions = new Map(
bundle.unknowns.map((unknown) => [unknownRevisionKey(unknown), unknown]),
);
const unknownHeads = new Map<string, ResidualUnknown>();
for (const unknown of bundle.unknowns) {
const head = unknownHeads.get(unknown.unknown_id);
if (head === undefined || head.revision < unknown.revision)
this.#unknownHeads.set(unknown.unknown_id, unknown);
unknownHeads.set(unknown.unknown_id, unknown);
}
this.#records = records;
this.#unknownRevisions = unknownRevisions;
this.#unknownHeads = unknownHeads;
}
}
/** A caller's unknown that fails its schema is an input mistake, not a state change. */
const unknownInputError = (
operation: "record_unknown" | "update_unknown",
cause: unknown,
input: RecordUnknownInput | UpdateUnknownInput,
): UnknownRegistryError | AnalysisInputError =>
cause instanceof z.ZodError
? requestInputError(operation, cause, input)
: new UnknownRegistryError("invalid-transition", { cause });
const readLedgerEvidence = (
input: unknown,
message: string,
): Result<Evidence, EvidenceIntegrityError> => {
const checked = readImmutableEvidence(input);
return checked.ok
? checked
: err(
new EvidenceIntegrityError(message, {
cause: checked.error.cause,
}),
);
};
const unknownRevisionKey = (unknown: ResidualUnknown): string =>
`${unknown.unknown_id}:${String(unknown.revision)}`;
@@ -0,0 +1,311 @@
import { expect, it, onTestFinished, vi } from "vitest";
import * as evidenceDomain from "../../src/domain/evidence.js";
import * as evidenceBundleDomain from "../../src/domain/evidenceBundle.js";
import * as residualUnknownDomain from "../../src/domain/residualUnknown.js";
import {
recordUnknownInputSchema,
updateUnknownInputSchema,
} from "../../src/domain/residualUnknown.js";
import { processEvidence } from "../../src/application/ReconstructionObligationLedger.fixture.js";
import { createApplicationMcpHarness } from "../fixtures/applicationMcpHarness.js";
import { createTestBinarySession } from "../fixtures/binarySession.js";
import { InvestigationRecords } from "../../src/application/investigation/InvestigationRecords.js";
type ApplicationClient = Awaited<
ReturnType<typeof createApplicationMcpHarness>
>["client"];
const unknownInput = (question: string) =>
recordUnknownInputSchema.parse({
question,
domain: "ledger-failure",
severity: "medium",
required_authority: "analyst-inference",
required_confidence: "observed",
required_environment: null,
recommended_probes: [],
relationships: [],
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
});
const updateInput = (unknownId: string, expectedRevision: number) =>
updateUnknownInputSchema.parse({
unknown_id: unknownId,
expected_revision: expectedRevision,
status: "investigating",
severity: "medium",
supporting_evidence_ids: [],
contradicting_evidence_ids: [],
required_authority: "analyst-inference",
required_confidence: "observed",
required_environment: null,
recommended_probes: [],
relationships: [],
resolution: null,
});
const failureFor = (kind: "TypeError" | "Error", message: string): Error =>
kind === "TypeError" ? new TypeError(message) : new Error(message);
const expectSdkFailure = async (
client: ApplicationClient,
name: "record_unknown" | "update_unknown",
args: Record<string, unknown>,
failure: Error,
): Promise<void> => {
const response = await client.callTool({
name,
arguments: args,
});
expect(response).toMatchObject({
isError: true,
content: [{ type: "text", text: failure.message }],
});
expect(response.structuredContent).toBeUndefined();
};
const withHarness = async <T>(
run: (
harness: Awaited<ReturnType<typeof createApplicationMcpHarness>>,
) => Promise<T>,
): Promise<T> => {
const session = createTestBinarySession(() => {
throw new Error("Ledger mutations must not launch a provider");
});
const harness = await createApplicationMcpHarness({ session });
try {
return await run(harness);
} finally {
vi.restoreAllMocks();
await harness.close();
}
};
it.each(
(["recordEvidence", "recordEvidenceWithUnknown"] as const).flatMap(
(operation) =>
(["TypeError", "Error"] as const).map(
(kind) => [operation, kind] as const,
),
),
)(
"lets unexpected authentication failure escape %s (%s) without changing the bundle",
async (operation, kind) =>
withHarness(async ({ session }) => {
const derived = processEvidence();
const input = unknownInput(`Failure during ${operation}`);
const before = session.exportEvidenceBundle();
const failure = failureFor(kind, `${operation} authentication failed`);
const authenticate = vi
.spyOn(evidenceDomain, "immutableEvidence")
.mockImplementation(() => {
throw failure;
});
onTestFinished(() => authenticate.mockRestore());
if (operation === "recordEvidence")
expect(() => session.recordEvidence(derived)).toThrow(failure);
else
expect(() => session.recordEvidenceWithUnknown(derived, input)).toThrow(
failure,
);
expect(session.exportEvidenceBundle()).toEqual(before);
}),
);
it("preserves an unexpected unknown-construction fault through record_unknown", async () =>
withHarness(async ({ client, session }) => {
const before = session.exportEvidenceBundle();
const failure = new TypeError("unknown constructor implementation failed");
const construct = vi
.spyOn(residualUnknownDomain, "createResidualUnknown")
.mockImplementation(() => {
throw failure;
});
onTestFinished(() => construct.mockRestore());
await expectSdkFailure(
client,
"record_unknown",
unknownInput("Unexpected constructor failure"),
failure,
);
expect(session.exportEvidenceBundle()).toEqual(before);
}));
it("preserves an unexpected unknown-revision fault through update_unknown", async () =>
withHarness(async ({ client, session }) => {
const created = session.recordUnknown(unknownInput("Update failure setup"));
if (!created.ok) throw created.error;
const before = session.exportEvidenceBundle();
const failure = new Error("unknown update implementation failed");
const update = vi
.spyOn(residualUnknownDomain, "updateResidualUnknown")
.mockImplementation(() => {
throw failure;
});
onTestFinished(() => update.mockRestore());
await expectSdkFailure(
client,
"update_unknown",
updateInput(created.value.unknown_id, created.value.revision),
failure,
);
expect(session.exportEvidenceBundle()).toEqual(before);
}));
it.each(
(["record_unknown", "update_unknown"] as const).flatMap((operation) =>
(["TypeError", "Error"] as const).map((kind) => [operation, kind] as const),
),
)(
"preserves unexpected mutation Evidence authentication failure through %s (%s)",
async (operation, kind) =>
withHarness(async ({ client, session }) => {
let args: Record<string, unknown>;
if (operation === "record_unknown") {
args = unknownInput("Mutation Evidence authentication failure");
} else {
const created = session.recordUnknown(
unknownInput("Mutation Evidence authentication setup"),
);
if (!created.ok) throw created.error;
args = updateInput(created.value.unknown_id, created.value.revision);
}
const before = session.exportEvidenceBundle();
const failure = failureFor(
kind,
"mutation Evidence authentication failed",
);
const authenticate = vi
.spyOn(evidenceDomain, "immutableEvidence")
.mockImplementation(() => {
throw failure;
});
onTestFinished(() => authenticate.mockRestore());
await expectSdkFailure(client, operation, args, failure);
expect(session.exportEvidenceBundle()).toEqual(before);
}),
);
it.each([
["record_unknown", "record graph failure"],
["update_unknown", "update graph failure"],
] as const)(
"does not convert an unexpected graph-validation fault during %s into a caller error",
async (operation, message) =>
withHarness(async ({ client, session }) => {
let args: Record<string, unknown>;
if (operation === "record_unknown") {
args = unknownInput(`Unexpected graph validation: ${message}`);
} else {
const created = session.recordUnknown(
unknownInput(`Graph validation setup: ${message}`),
);
if (!created.ok) throw created.error;
args = updateInput(created.value.unknown_id, created.value.revision);
}
const before = session.exportEvidenceBundle();
const failure = new Error(message);
const validate = vi
.spyOn(evidenceBundleDomain, "validateResidualUnknownAddition")
.mockImplementation(() => {
throw failure;
});
onTestFinished(() => validate.mockRestore());
await expectSdkFailure(client, operation, args, failure);
expect(session.exportEvidenceBundle()).toEqual(before);
}),
);
it("preserves unexpected full-candidate bundle validation faults and leaves import state unchanged", async () =>
withHarness(async ({ session }) => {
const source = new InvestigationRecords();
const created = source.recordUnknown(unknownInput("Imported history"));
if (!created.ok) throw created.error;
const bundle = source.exportEvidenceBundle();
const before = session.exportEvidenceBundle();
const failure = new TypeError("candidate bundle validation failed");
const validate = vi
.spyOn(evidenceBundleDomain, "createImmutableEvidenceBundle")
.mockImplementation(() => {
throw failure;
});
onTestFinished(() => validate.mockRestore());
expect(() => session.importEvidenceBundle(bundle)).toThrow(failure);
expect(session.exportEvidenceBundle()).toEqual(before);
}));
it("authenticates derived and mutation Evidence before committing recordWithUnknown", async () =>
withHarness(async ({ session }) => {
const input = unknownInput("Atomic record with unknown");
const evidence = processEvidence();
const expectedId = evidence.evidence_id;
const original = evidenceDomain.immutableEvidence;
let evidenceVisibleDuringAuthentication = false;
const authenticate = vi
.spyOn(evidenceDomain, "immutableEvidence")
.mockImplementation((candidate) => {
if (
session
.exportEvidenceBundle()
.records.some((record) => record.evidence_id === expectedId)
)
evidenceVisibleDuringAuthentication = true;
return original(candidate);
});
onTestFinished(() => authenticate.mockRestore());
const recorded = session.recordEvidenceWithUnknown(evidence, input);
authenticate.mockRestore();
expect(recorded.ok).toBe(true);
expect(evidenceVisibleDuringAuthentication).toBe(false);
const committed = session.exportEvidenceBundle();
expect(committed.records.map(({ evidence_id }) => evidence_id)).toContain(
expectedId,
);
expect(committed.unknowns).toHaveLength(1);
expect(committed.records).toHaveLength(2);
expect(
session.immutableEvidenceBundle().records.every(Object.isFrozen),
).toBe(true);
}));
it("authenticates all Evidence before committing an imported unknown history", async () =>
withHarness(async ({ session }) => {
const source = new InvestigationRecords();
expect(source.recordEvidence(processEvidence()).ok).toBe(true);
const created = source.recordUnknown(
unknownInput("Atomic imported history"),
);
if (!created.ok) throw created.error;
const bundle = source.exportEvidenceBundle();
const expectedIds = bundle.records.map(({ evidence_id }) => evidence_id);
const original = evidenceDomain.immutableEvidence;
let incomingVisibleDuringAuthentication = false;
const authenticate = vi
.spyOn(evidenceDomain, "immutableEvidence")
.mockImplementation((input) => {
const visible = session
.exportEvidenceBundle()
.records.some(({ evidence_id }) => expectedIds.includes(evidence_id));
if (visible) incomingVisibleDuringAuthentication = true;
return original(input);
});
onTestFinished(() => authenticate.mockRestore());
const imported = session.importEvidenceBundle(bundle);
authenticate.mockRestore();
expect(imported.ok).toBe(true);
expect(incomingVisibleDuringAuthentication).toBe(false);
expect(session.exportEvidenceBundle()).toEqual(bundle);
expect(
session.immutableEvidenceBundle().records.every(Object.isFrozen),
).toBe(true);
}));