diff --git a/docs/reconstruction-obligation-ledgers.md b/docs/reconstruction-obligation-ledgers.md index 6b930c5349..177fe72ae6 100644 --- a/docs/reconstruction-obligation-ledgers.md +++ b/docs/reconstruction-obligation-ledgers.md @@ -315,10 +315,12 @@ A manifest binding names one owner and its exact implementation digest. Both } ``` -All referenced Evidence IDs must exist in the bundle. A green unit test cannot -close a `packaged-process` obligation. A static-only source cannot close a -runtime or process claim. Marking a claim `blocked` or `out-of-scope` records the -disposition but does not silently count it as verified. +A ready ledger requires every retained Evidence reference to resolve in the +bundle, including references on optional case kinds. Unresolved references stay +visible in the ledger and produce diagnostics; they cannot close the obligation. +A green unit test cannot close a `packaged-process` obligation. A static-only +source cannot close a runtime or process claim. Marking a claim `blocked` or +`out-of-scope` records the disposition but does not silently count it as verified. The `summary` counts obligations. `by_status`, `by_application_layer`, and `by_source_state` place each obligation in exactly one bucket. diff --git a/src/application/ReconstructionObligationLedger.closure.test.ts b/src/application/ReconstructionObligationLedger.closure.test.ts index 0d1f881e8a..652d630ad3 100644 --- a/src/application/ReconstructionObligationLedger.closure.test.ts +++ b/src/application/ReconstructionObligationLedger.closure.test.ts @@ -264,10 +264,12 @@ describe("reconstruction obligation manifest references", () => { if (binding === undefined) throw new Error("Expected binding"); const variants: { readonly code: string; + readonly kind: "positive" | "malformed"; readonly binding: typeof binding; }[] = [ { code: "missing-original-case", + kind: "positive", binding: { ...binding, original_cases: [ @@ -278,6 +280,7 @@ describe("reconstruction obligation manifest references", () => { }, { code: "missing-case", + kind: "positive", binding: { ...binding, fixtures: [ @@ -291,17 +294,88 @@ describe("reconstruction obligation manifest references", () => { ], }, }, + { + code: "missing-original-case", + kind: "malformed", + binding: { + ...binding, + original_cases: [ + ...binding.original_cases, + { kind: "malformed", evidence_id: absent, location: "/old" }, + ], + }, + }, + { + code: "missing-case", + kind: "malformed", + binding: { + ...binding, + fixtures: [ + ...binding.fixtures, + { + fixture_id: "packaged.old", + case_kind: "malformed", + authority: "packaged-process", + evidence_ids: [absent], + }, + ], + }, + }, ]; - for (const { code, binding: dangling } of variants) { + for (const { code, kind, binding: dangling } of variants) { const open = build({ ...ledgerRequest, manifest: { ...ledgerRequest.manifest, bindings: [dangling] }, }); expect(open.status).not.toBe("ready"); expect(open.obligations[0]?.status).toBe("implemented"); - expect(open.obligations[0]?.diagnostics).toEqual([ - { code, detail: expect.stringContaining(absent) }, - ]); + expect(open.obligations[0]?.diagnostics).toContainEqual({ + code, + detail: expect.stringMatching(new RegExp(`${kind}.*${absent}`)), + }); + expect(open.evidence_links).toContain(absent); } + + const surplusEvidence = proofEvidence("surplus-case"); + const validSurplus = build( + request([...ledgerRequest.evidence_bundle.records, surplusEvidence], { + manifest: { + ...ledgerRequest.manifest, + bindings: [ + { + ...binding, + original_cases: [ + ...binding.original_cases, + { + kind: "malformed", + evidence_id: surplusEvidence.evidence_id, + location: "/retained", + }, + ], + fixtures: [ + ...binding.fixtures, + { + fixture_id: "packaged.malformed", + case_kind: "malformed", + authority: "packaged-process", + evidence_ids: [surplusEvidence.evidence_id], + }, + ], + }, + ], + }, + }), + ); + expect(validSurplus.status).toBe("ready"); + expect(validSurplus.obligations[0]?.observed_cases).toContainEqual( + expect.objectContaining({ kind: "malformed", location: "/retained" }), + ); + expect(validSurplus.obligations[0]?.binding?.fixtures).toContainEqual( + expect.objectContaining({ + case_kind: "malformed", + fixture_id: "packaged.malformed", + }), + ); + expect(validSurplus.evidence_links).toContain(surplusEvidence.evidence_id); }); }); diff --git a/src/application/ReconstructionObligationLedgerEvaluation.ts b/src/application/ReconstructionObligationLedgerEvaluation.ts index 6b96cf3feb..4475437b2a 100644 --- a/src/application/ReconstructionObligationLedgerEvaluation.ts +++ b/src/application/ReconstructionObligationLedgerEvaluation.ts @@ -250,6 +250,16 @@ const evaluateBinding = ( "missing-parser-type", "This boundary requires an explicit parser, schema, and domain type.", ); + // Optional fixture kinds are retained and linked too, so their Evidence + // references must resolve even though only required kinds close the claim. + for (const fixture of binding.fixtures) + for (const evidenceId of fixture.evidence_ids) + if (!context.evidence.has(evidenceId)) + addDiagnostic( + diagnostics, + "missing-case", + `${fixture.case_kind} fixture ${fixture.fixture_id} references Evidence ${evidenceId}, which is absent from the bundle.`, + ); for (const caseKind of candidate.required_case_kinds) { const fixtures = binding.fixtures.filter( ({ case_kind: fixtureCase }) => fixtureCase === caseKind, @@ -262,14 +272,6 @@ const evaluateBinding = ( ); continue; } - for (const fixture of fixtures) - for (const evidenceId of fixture.evidence_ids) - if (!context.evidence.has(evidenceId)) - addDiagnostic( - diagnostics, - "missing-case", - `${caseKind} fixture ${fixture.fixture_id} references Evidence ${evidenceId}, which is absent from the bundle.`, - ); if ( !fixtures.some( (fixture) => diff --git a/src/application/ReconstructionObligationOriginalCases.ts b/src/application/ReconstructionObligationOriginalCases.ts index 5960907417..0c180214bc 100644 --- a/src/application/ReconstructionObligationOriginalCases.ts +++ b/src/application/ReconstructionObligationOriginalCases.ts @@ -36,6 +36,14 @@ export const originalCaseDiagnostics = ( ): Diagnostic[] => { const diagnostics: Diagnostic[] = []; const originalCases = originalCasesFor(candidate, binding); + // Retain optional case rows, but never leave one of their Evidence links + // unresolved merely because its kind is not required for closure. + for (const { kind, evidence_id: evidenceId } of originalCases) + if (!evidenceById.has(evidenceId)) + diagnostics.push({ + code: "missing-original-case", + detail: `Original ${kind} case references Evidence ${evidenceId}, which is absent from the bundle.`, + }); for (const caseKind of candidate.required_case_kinds) { const cases = originalCases.filter(({ kind }) => kind === caseKind); if (cases.length === 0) { @@ -45,14 +53,6 @@ export const originalCaseDiagnostics = ( }); continue; } - // A valid entry for this case kind does not resolve another entry's - // reference; every referenced Evidence ID must be in the bundle. - for (const { evidence_id: evidenceId } of cases) - if (!evidenceById.has(evidenceId)) - diagnostics.push({ - code: "missing-original-case", - detail: `Original ${caseKind} case references Evidence ${evidenceId}, which is absent from the bundle.`, - }); if ( !cases.some(({ evidence_id: evidenceId }) => { const evidence = evidenceById.get(evidenceId);