ADR-0070: Maker-checker / four-eyes control for high-risk decisions
Status: Accepted Date: 2026-06-26 Supersedes: none Superseded by: none Deciders: Adrian (Soft4U), Claude Opus 4.8
Decision context:
- Latency: two defensive reads on the decision path for approvals only (open-discrepancy block — already present — plus a company-status read); a single insert + status update when four-eyes triggers. Reject / escalate / follow-up and low-risk approvals are untouched.
- Dependency surface: no new packages. One pure predicate module + one stateful service + one append-only state table (migration 067) + endpoint wiring.
- Debuggability: a gated decision returns
PENDING_SECOND_APPROVALwith the explicit trigger reasons; both the maker submission and the checker authorization land asaudit_eventsrows (immutable, ADR-0064). - Reversibility: branch revert; additive. The gate is behind
maker_checker_enabled(default on) — flipping it off restores the prior single-officer flow with no schema change. - Blast radius: the decision endpoint gains a pre-signal gate; a new second-approval endpoint; nothing in the
Temporal workflow changes (the
officer_decisionsignal only fires once a valid second approval lands).
Context
Segregation of duties (SoD) is a baseline AML/EBA internal-controls expectation: a single officer must not be
able to unilaterally let a high-risk relationship through, nor to unilaterally override a fail-closed gate.
Before this change, a single POST /cases/{id}/decision immediately fired the officer_decision Temporal
signal — including for an APPROVE on a HIGH/CRITICAL / EDD-tier case, and including an approve that overrides
the open-UBO-discrepancy gate (ADR-0059) or onboards a dissolved entity (ADR-0065). One person, one click, no
second pair of eyes.
Decision
Introduce a maker-checker / four-eyes gate enforced at the decision-service / API layer (the Temporal
workflow stays contained — it still only advances on the officer_decision signal).
1. High-risk predicate (pure, configurable-ready). requires_four_eyes(FourEyesContext) -> FourEyesVerdict
in app/services/maker_checker.py. Four-eyes is required when, and only when, the decision is an approval
(approve / approve_with_restrictions) and at least one trigger holds:
risk_level ∈ {high, critical}→high_risk_approvaldd_tier ∈ {edd, enhanced, enhanced_due_diligence}→edd_tier_approval- the approve overrides the open-UBO-discrepancy gate →
ubo_discrepancy_override - the approve onboards a terminal-status (dissolved) entity →
dissolved_entity_override
Reject / escalate / follow-up and low-risk (SDD/CDD/low/medium) approvals are never gated — no friction
where SoD is not required. The trigger sets (HIGH_RISK_LEVELS, EDD_TIERS) and the master switch
(settings.maker_checker_enabled, default True) are explicit and configuration-ready.
2. State model. A new append-only pending_decision_approvals table (migration 067; FORCE ROW LEVEL
SECURITY, tenant-scoped exactly like screening_results/signal_events, ADR-0023). It records the maker
(maker_user_id/maker_name/created_at), the intended decision + justification, the verbatim decision
payload (replayed on approval), the trigger_reasons, and — once resolved — the checker
(checker_user_id/checker_name/checker_note/resolved_at) and status (pending→approved/rejected).
A gated decision sets cases.status = PENDING_SECOND_APPROVAL (new CaseStatus member) and does not fire
officer_decision.
3. Second approver (different actor, fail-closed). POST /cases/{id}/decision/second-approval
{action: approve|reject, note}. A pure guard assert_distinct_approver(maker, checker) runs before any
side effect: an empty/missing checker id raises AmbiguousApproverError (→ 403, fail-closed — never default
to allowing), and checker == maker raises SelfApprovalError (→ 403). Only a valid, different approver's
approve reconstructs the stored DecisionRequest and delegates to DecisionService.process_decision (which
fires the officer_decision signal). A reject cancels the pending decision and returns the case to
REVIEW_PENDING.
4. Both actors audited (immutable). Maker submission (decision_pending_second_approval) and checker
authorization (decision_second_approval_granted / …_rejected) are written to audit_events (ADR-0064,
append-only). The granted event carries both ids, both names, timestamps, the decision, the justification,
the checker note, and the trigger reasons — fully reconstructable for a regulator.
Consequences
Positive
- A single officer can no longer unilaterally approve a high-risk relationship or override a fail-closed gate (EBA SoD; AMLR internal-controls expectation). Self-approval is structurally impossible.
- The complete two-person trail (who decided, who authorized, why) is on the immutable audit spine.
Negative
- High-risk approvals now take two officers and an extra round-trip; an extra status read per approval.
- A second
CaseStatustransient state the dashboard must render.
Neutral
- Behind
maker_checker_enabled; the legacy single-officer overridesignal_eventsentry is still emitted when the flag is off, so disabling the feature is a clean rollback with no audit regression.
Alternatives Considered
Alternative 1: enforce inside the Temporal workflow
Rejected — it would couple SoD to workflow history/versioning and make the gate hard to reconfigure. Keeping it at the service/API layer keeps the workflow contained and the predicate trivially testable and tunable.
Alternative 2: role-based gate only (a senior role approves)
Deferred to M5 RBAC. Four-eyes is distinct-actor SoD; it must hold even between two officers of the same role. RBAC (which roles may check) layers on top later without changing this state model.
Alternative 3: reuse signal_events for the pending state
Rejected — signal_events is an append-only signal log, not a mutable state machine. The pending record needs
in-place resolution (pending→approved/rejected), so it gets its own table; the immutable maker/checker
record still lands in audit_events.