Skip to main content

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

  1. Discrepancy lifecycle (investigation.py): status: open|resolved|escalated|reported + sar_reference; a validator keeps status/resolved consistent. reported is the SAR terminal state and carries the SAR reference (the resolve endpoint enforces it).
  2. Hard approval gate (discrepancy_approval_gate.py): evaluate_approval_block blocks when any discrepancy is OPEN and UBO/identity (by field) or CRITICAL (any field). Pure, dict/object-tolerant.
  3. Wiring (submit_decision): for approve decisions, fetch the case's discrepancies and reconcile resolution state (matching discrepancy_resolutions.discrepancy_id on the discrepancy's id OR field — the cross_reference FieldDiscrepancy shape has no id), then evaluate. Blocked + no override ⇒ HTTP 409, the workflow is never signalled. Blocked + override_open_discrepancies + a reason ⇒ emit a non-suppressible approval_override_open_discrepancy signal, then proceed. Override without a reason ⇒ 400.
  4. 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 try so its bugs surface loudly.

Consequences

Positive

  • An open critical UBO discrepancy blocks approval; overrides are explicit and audited; the reported state 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 resolved bool and FieldDiscrepancy shape 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.