ADR-0059: Block approval on open UBO discrepancy + discrepancy SAR lifecycle
Status: Accepted Date: 2026-06-15 Supersedes: none Superseded by: none Deciders: Adrian (Soft4U), Claude Opus 4.8
Decision context:
- Latency: one extra DB read (cross_reference_result + discrepancy_resolutions) on the approve path only.
- Dependency surface: no new packages. Adds a lifecycle field + a pure gate + a service method + endpoint wiring.
- Debuggability: a blocked approval returns a 409 listing the blocking discrepancies; the override emits a non-suppressible audit signal; the gate rule is pure + unit-tested.
- Reversibility: branch revert; the model/request changes are additive.
- Blast radius: the approve/approve_with_restrictions path gains a pre-check; reject/escalate/follow_up are unaffected.
- Alternative considered: keep the confidence-score deduction as the only signal (rejected — a soft score still lets an officer approve over an open critical UBO discrepancy).
Context
AMLR §6: an unresolved discrepancy on UBO data must block verified/approval and may be reportable.
Previously discrepancies only deducted a confidence score — submit_decision never checked them, so an
officer could approve with an open CRITICAL UBO discrepancy. Discrepancy.resolved was a bare bool with
no reported (SAR) state, so the reportable AMLR outcome was not representable.
Decision
- Discrepancy lifecycle (
investigation.py):status: open|resolved|escalated|reported+sar_reference; a validator keepsstatus/resolvedconsistent.reportedis the SAR terminal state and carries the SAR reference (the resolve endpoint enforces it). - Hard approval gate (
discrepancy_approval_gate.py):evaluate_approval_blockblocks when any discrepancy is OPEN and UBO/identity (by field) or CRITICAL (any field). Pure, dict/object-tolerant. - Wiring (
submit_decision): for approve decisions, fetch the case's discrepancies and reconcile resolution state (matchingdiscrepancy_resolutions.discrepancy_idon the discrepancy's id OR field — the cross_referenceFieldDiscrepancyshape has no id), then evaluate. Blocked + no override ⇒ HTTP 409, the workflow is never signalled. Blocked +override_open_discrepancies+ a reason ⇒ emit a non-suppressibleapproval_override_open_discrepancysignal, then proceed. Override without a reason ⇒ 400. - Fail-closed: a DB/fetch failure in the gate blocks (overridable), never fails open — a query
bug must not silently suppress an open UBO discrepancy (no-suppression principle, EU AI Act Art. 14
robustness). The pure gate logic runs outside the fetch's
tryso its bugs surface loudly.
Consequences
Positive
- An open critical UBO discrepancy blocks approval; overrides are explicit and audited; the
reportedstate makes the SAR outcome representable and linkable.
Negative
- A pre-approval DB read on the approve path; stricter flow; a fail-closed gate can block approvals if the discrepancy store is unavailable (mitigated by the audited override).
Neutral
- The legacy
resolvedbool andFieldDiscrepancyshape coexist; the gate reads both defensively.
Alternatives Considered
Alternative 1: Keep the confidence-score deduction as the only signal
Rejected — a low score still passes; AMLR requires a hard block an officer cannot silently bypass.
Alternative 2: Block on ALL open discrepancies regardless of field
Rejected — over-blocks on minor non-identity variances; the hard requirement is UBO/identity (+ any CRITICAL).
Alternative 3: Fail-open on a gate error (don't risk bricking approvals)
Rejected as the default — silent fail-open on a compliance gate is exactly the suppression the architecture forbids. Fail-closed-but-overridable (the override is audited) is the correct posture.