Skip to main content

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_APPROVAL with the explicit trigger reasons; both the maker submission and the checker authorization land as audit_events rows (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_decision signal 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_approval
  • dd_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 (pendingapproved/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 CaseStatus transient state the dashboard must render.

Neutral

  • Behind maker_checker_enabled; the legacy single-officer override signal_events entry 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 (pendingapproved/rejected), so it gets its own table; the immutable maker/checker record still lands in audit_events.