diff --git a/src/application/ReconstructionObligationLedger.closure.test.ts b/src/application/ReconstructionObligationLedger.closure.test.ts index d3404ad13f..a0a2ec970a 100644 --- a/src/application/ReconstructionObligationLedger.closure.test.ts +++ b/src/application/ReconstructionObligationLedger.closure.test.ts @@ -1,73 +1,17 @@ import { describe, expect, it } from "vitest"; import { - boundProofEvidence, build, originalCases, processEvidence, proofEvidence, + readyProcessLedgerRequest, request, reviewedObligation, } from "./ReconstructionObligationLedger.fixture.js"; import { deriveReconstructionObligationCandidates } from "./ReconstructionObligationCandidates.js"; import { evaluateReconstructionObligationLedger } from "./ReconstructionObligationLedgerEvaluation.js"; -/** Build a request whose packaged-process obligation closes as ready. */ -const readyProcessLedgerRequest = () => { - const capture = processEvidence(); - const initial = build(request([capture])); - const obligation = initial.obligations[0]; - if (obligation === undefined) throw new Error("Expected process obligation"); - const fixtureIds = obligation.required_case_kinds.map( - (caseKind) => `packaged.${caseKind}`, - ); - const proof = boundProofEvidence("packaged", { - obligation_ids: [obligation.obligation_id], - fixture_ids: fixtureIds, - case_kinds: obligation.required_case_kinds, - verifier_ids: ["verifier.packaged"], - claim_ids: ["claim.process"], - }); - const ledgerRequest = request([capture, proof], { - manifest: { - bindings: [ - { - obligation_id: obligation.obligation_id, - owner: { - module_path: "src/processOwner.ts", - symbol: "runProcess", - owner_sha256: "2".repeat(64), - }, - parser_type: null, - original_cases: originalCases(obligation, capture.evidence_id), - fixtures: obligation.required_case_kinds.map((caseKind) => ({ - fixture_id: `packaged.${caseKind}`, - case_kind: caseKind, - authority: "packaged-process", - evidence_ids: [proof.evidence_id], - })), - verifier: { - verifier_id: "verifier.packaged", - claim_id: "claim.process", - command: "npm run verify:package -- process", - authority: "packaged-process", - status: "pass", - result_evidence_id: proof.evidence_id, - enumerated_obligation_ids: [obligation.obligation_id], - nondeterminism: { - mode: "partial-order", - specification: - "Startup precedes teardown; worker completion is unordered.", - }, - }, - }, - ], - contradictions: [], - }, - }); - return ledgerRequest; -}; - describe("reconstruction obligation ledger", () => { it("emits the complete deterministic ledger and closure identity", () => { const evidence = proofEvidence("review"); diff --git a/src/application/ReconstructionObligationLedger.fixture.ts b/src/application/ReconstructionObligationLedger.fixture.ts index ae535025fe..9d87ea709f 100644 --- a/src/application/ReconstructionObligationLedger.fixture.ts +++ b/src/application/ReconstructionObligationLedger.fixture.ts @@ -120,6 +120,63 @@ export const originalCases = ( location: `/normalized_result/cases/${caseKind}`, })); +/** Build a request whose packaged-process obligation closes as ready. */ +export const readyProcessLedgerRequest = + (): ReconstructionObligationLedgerInput => { + const capture = processEvidence(); + const initial = build(request([capture])); + const obligation = initial.obligations[0]; + if (obligation === undefined) + throw new Error("Expected process obligation"); + const fixtureIds = obligation.required_case_kinds.map( + (caseKind) => `packaged.${caseKind}`, + ); + const proof = boundProofEvidence("packaged", { + obligation_ids: [obligation.obligation_id], + fixture_ids: fixtureIds, + case_kinds: obligation.required_case_kinds, + verifier_ids: ["verifier.packaged"], + claim_ids: ["claim.process"], + }); + return request([capture, proof], { + manifest: { + bindings: [ + { + obligation_id: obligation.obligation_id, + owner: { + module_path: "src/processOwner.ts", + symbol: "runProcess", + owner_sha256: "2".repeat(64), + }, + parser_type: null, + original_cases: originalCases(obligation, capture.evidence_id), + fixtures: obligation.required_case_kinds.map((caseKind) => ({ + fixture_id: `packaged.${caseKind}`, + case_kind: caseKind, + authority: "packaged-process", + evidence_ids: [proof.evidence_id], + })), + verifier: { + verifier_id: "verifier.packaged", + claim_id: "claim.process", + command: "npm run verify:package -- process", + authority: "packaged-process", + status: "pass", + result_evidence_id: proof.evidence_id, + enumerated_obligation_ids: [obligation.obligation_id], + nondeterminism: { + mode: "partial-order", + specification: + "Startup precedes teardown; worker completion is unordered.", + }, + }, + }, + ], + contradictions: [], + }, + }); + }; + /** Create a minimal reviewed obligation for ledger closure scenarios. */ export const reviewedObligation = ( obligationId: string, diff --git a/src/application/ReconstructionObligationLedgerDiagnostics.ts b/src/application/ReconstructionObligationLedgerDiagnostics.ts index 5d53d8f4ac..25a2f523f6 100644 --- a/src/application/ReconstructionObligationLedgerDiagnostics.ts +++ b/src/application/ReconstructionObligationLedgerDiagnostics.ts @@ -7,6 +7,7 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; type Diagnostic = ReconstructionObligation["diagnostics"][number]; type Binding = NonNullable; +type Contradiction = ReconstructionObligationManifest["contradictions"][number]; export const groupManifestBindings = ( manifest: ReconstructionObligationManifest, @@ -22,12 +23,12 @@ export const groupManifestBindings = ( export const groupManifestContradictions = ( manifest: ReconstructionObligationManifest, -): ReadonlyMap => { - const grouped = new Map(); +): ReadonlyMap => { + const grouped = new Map(); for (const contradiction of manifest.contradictions) grouped.set(contradiction.obligation_id, [ ...(grouped.get(contradiction.obligation_id) ?? []), - ...contradiction.evidence_ids, + contradiction, ]); return grouped; }; @@ -59,26 +60,33 @@ export const uniqueObligationDiagnostics = ( export const contradictionDiagnostics = ( obligationId: string, - contradictions: ReadonlyMap, + contradictions: ReadonlyMap, evidenceById: ReadonlyMap, ): Diagnostic[] => { - const evidenceIds = contradictions.get(obligationId) ?? []; - if (evidenceIds.length === 0) return []; - const missing = evidenceIds.filter( - (evidenceId) => !evidenceById.has(evidenceId), + // Each manifest entry is a separate claim. Absent Evidence leaves only its + // own entry unestablished; it must not hide a sibling whose Evidence exists. + const entries = contradictions.get(obligationId) ?? []; + const missing = new Set( + entries.flatMap(({ evidence_ids: ids }) => + ids.filter((evidenceId) => !evidenceById.has(evidenceId)), + ), ); - return missing.length > 0 - ? [ - { - code: "contradiction-evidence-missing", - detail: `Contradiction references missing Evidence: ${missing.join(", ")}.`, - }, - ] - : [ - { - code: "contradiction", - detail: - "Authenticated contradictory Evidence reopens this obligation.", - }, - ]; + const diagnostics: Diagnostic[] = []; + if (missing.size > 0) + addObligationDiagnostic( + diagnostics, + "contradiction-evidence-missing", + `Contradiction references missing Evidence: ${[...missing].join(", ")}.`, + ); + if ( + entries.some(({ evidence_ids: ids }) => + ids.every((evidenceId) => evidenceById.has(evidenceId)), + ) + ) + addObligationDiagnostic( + diagnostics, + "contradiction", + "Authenticated contradictory Evidence reopens this obligation.", + ); + return diagnostics; }; diff --git a/src/application/ReconstructionObligationLedgerEvaluation.ts b/src/application/ReconstructionObligationLedgerEvaluation.ts index ea54a3608b..af6be2b371 100644 --- a/src/application/ReconstructionObligationLedgerEvaluation.ts +++ b/src/application/ReconstructionObligationLedgerEvaluation.ts @@ -47,7 +47,7 @@ interface EvaluationContext { readonly candidates: ReadonlyMap; readonly evidence: ReadonlyMap; readonly bindings: ReadonlyMap; - readonly contradictions: ReadonlyMap; + readonly contradictions: ReturnType; } export const evaluateReconstructionObligationLedger = ({ diff --git a/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts index 532707c594..1392ecbf8c 100644 --- a/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts +++ b/tests/boundary/cli/reconstructionObligationLedgerCli.test.ts @@ -8,6 +8,7 @@ import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; import { MIXED_AUTHORITY_SUMMARY, mixedAuthorityRequest, + readyProcessLedgerRequest, } from "../../../src/application/ReconstructionObligationLedger.fixture.js"; const execute = promisify(execFile); @@ -161,3 +162,64 @@ describe("reconstruction obligation ledger CLI parity", () => { ]); }, 20_000); }); + +describe("reconstruction obligation ledger CLI contradictions", () => { + it("keeps a complete contradiction beside an entry naming absent Evidence", async () => { + const input = readyProcessLedgerRequest(); + const obligationId = input.manifest.bindings[0]?.obligation_id; + const [present, other] = input.evidence_bundle.records.map( + ({ evidence_id: id }) => id, + ); + if ( + obligationId === undefined || + present === undefined || + other === undefined + ) + throw new Error("Expected a bound obligation and two Evidence records"); + const absent = `ev_${"f".repeat(64)}`; + const complete = { + obligation_id: obligationId, + evidence_ids: [present, other], + }; + const incomplete = { + obligation_id: obligationId, + evidence_ids: [present, absent], + }; + // Each entry is its own claim; entry order must not decide the outcome. + for (const contradictions of [ + [complete, incomplete], + [incomplete, complete], + ]) { + const { stdout } = await execute( + process.execPath, + [ + "scripts/rea.mjs", + "build-reconstruction-obligation-ledger", + JSON.stringify({ + ...input, + manifest: { ...input.manifest, contradictions }, + }), + "--json", + ], + { cwd: process.cwd(), env: process.env, maxBuffer: 16 * 1_024 * 1_024 }, + ); + const ledger = JSON.parse(stdout).normalized_result; + expect(ledger).toMatchObject({ + status: "failed", + reports: { contradicted_obligation_ids: [obligationId] }, + obligations: [ + { + status: "contradicted", + diagnostics: [ + { + code: "contradiction-evidence-missing", + detail: `Contradiction references missing Evidence: ${absent}.`, + }, + { code: "contradiction" }, + ], + }, + ], + }); + } + }, 20_000); +});