ADR-0125: Fail-closed insufficient-data approval gate (dark-launched, four-eyes override, second-approval revalidation)
Date: 2026-07-24 Status: Accepted Deciders: Adrian (Soft4U BV), Claude (Opus 4.8) Extends: ADR-0067 (fail-closed compliance outputs), ADR-0113 (fail-closed per-case compliance verdict), ADR-0070 (maker-checker four-eyes), ADR-0064 (immutable audit_events) Pattern: ADR-0074 (phased/log-first dark launch)
Context
The per-case compliance verdict (compliance_verdict.py, ADR-0113) composes a fail-closed
state — escalate > insufficient_data > clear. insufficient_data means the evidence
required to CLEAR the case was never collected (sanctions screening not assessed, no
persisted rule evaluation, missing investigation completeness, an adverse-recall coverage
gap). Approving on that state is exactly the "false clear" ADR-0067 exists to prevent: the
system would report a clean onboarding it never actually cleared.
Before #532, nothing on the APPROVE path read that verdict. An officer could approve a case
whose verdict was insufficient_data and the decision would fire — the missing-evidence
signal was computed and surfaced (GET /rule-evaluations) but never binding. This is the
recurring claim-vs-check class: an artifact reporting a control's shape (the verdict is
displayed) without its state (the verdict gates nothing).
#532 introduced a gate. A Codex review of that first implementation then found four real issues, of which two are correctness defects that this ADR records the resolution of:
-
The gate ran only on the maker's initial
POST /decision. The four-eyes replay (MakerCheckerService._approve, invoked by/second-approval) revalidated case status + entity disposition but not the compliance verdict. A case that slid toinsufficient_dataafter the maker submitted (a monitoring re-screen, a new finding, a document expiry) — or one that routed to four-eyes for a different high-risk reason while already insufficient — could be finalized by the checker with the gate never firing. The same TOCTOU hole ADR-0070's entity-disposition recheck already closes for the disposition dimension existed for the verdict dimension. -
On the default
maker_checker_enabled=Truepath, the override evidence was not persisted before the four-eyes early return. Anoverride_insufficient_datamakesrequires_four_eyesreturn True, sosubmit_decisionreturns fromcreate_pendingbefore reaching the override-audit block below it — the override's justification lived only in the generic maker-checker pending record, not in a purpose-specific immutable audit row.
Decision
A fail-closed insufficient_data approval gate, phased/log-first exactly like the ADR-0074
RBAC rollout, binding at both the maker and the checker:
-
The gate. On an APPROVE / APPROVE_WITH_RESTRICTIONS where the live compliance verdict is
insufficient_data, block the approval.escalateandclearverdicts are unaffected — the gate only ADDS a block on the one state that means "not cleared" (never-suppress: anescalateis never softened, onlyinsufficient_datais newly binding). -
Dark-launch / log-first (
approval_insufficient_data_gate_enabled=Falseby default). Flag OFF: do not block — emit a greppableapproval.insufficient_data.would_blockstructlog event on theapproval_gatechannel and an immutableapproval_insufficient_data_would_blockaudit row, then let the approval proceed unchanged (zero behaviour change). Flag ON (Phase 2, Calibration-Review-gated): 409 with the verdict's insufficiency reasons unless the officer records an override. -
The override.
override_insufficient_dataonDecisionRequest— an explicit, reason-required (≥1 non-empty char, on top of the ADR-0097 ≥50-char rationale) flag. It is audited (its reason + verdict reasons written to the immutable audit trail) and routes through four-eyes (ADR-0070): the override adds aninsufficient_data_overridetrigger reason to the maker-checker verdict, so a second, DIFFERENT approver must confirm it. This is identical to how the dissolved-entity / entity-disposition / purpose overrides are handled — an audited exception, never a silent bypass. -
Second-approval revalidation (P1-A).
MakerCheckerService._approvere-reads the LIVE compliance verdict when the gate is enforced and the maker's stored decision did NOT carryoverride_insufficient_data. If the verdict is nowinsufficient_data, the pending row is superseded and the approval refused (StalePendingApprovalError→ 409) — the decision never fires. A recorded override is honored (it was audited at submit time; the checker is confirming it), skipping the recheck. This reuses the existing supersede-and-refuse TOCTOU machinery (_supersede_pending, ADR-0064-audited) that the entity-disposition recheck already uses. The verdict lookup delegates to the sameCaseDecisionsService.get_compliance_verdictcomposer as the submit path (single source of truth); its own fail-closed posture (any read/compute error →insufficient_data) means an unreadable verdict refuses rather than waves through. -
Persist the override evidence before the four-eyes early return (P2-B). When the override is recorded, an immutable
approval_insufficient_data_overrideaudit row (reason- verdict reasons +
enforced=True+routes_through_four_eyes=True) is written the moment the override becomes active — beforecreate_pendingcan short-circuit out of the handler. It reuses the sameAuditService.log_eventwriter as the would-block telemetry and is guard-and-swallow (auditing never breaks the approval it records). Written this early, the override is auditable on both the four-eyes and single-officer paths.
- verdict reasons +
Decision context:
- Latency: the gate reuses the already-computed verdict composer (three indexed queries in the open tenant session, ADR-0113); the second-approval recheck adds one more verdict composition on the checker action only (officer-interactive, single case) — not on any batch path. Sub-100 ms, not measured under load.
- Dependency surface: zero new packages, zero migrations. One config flag, one
DecisionRequestfield, one audit helper, one recheck block, one TS field, one UI banner. - Debuggability: every gate decision leaves a greppable structlog line on the
approval_gatechannel + an immutable audit row naming the verdict state and reasons; a superseded pending row records WHY (fail-closed traceability). - Reversibility: a single flag (
approval_insufficient_data_gate_enabled=False) restores the telemetry-only behaviour; the second-approval recheck and the override-audit write are both gated behind it, so flag-off is byte-identical to the pre-#532 replay path. Minutes to undo. - Blast radius: additive at both the maker and checker; the maker path was already shipped by #532, this ADR records the two Codex-found correctness fixes (P1-A, P2-B) + the UI wiring (P2-D) + the ADR itself (P1-C).
- Alternative considered: revalidate the verdict only at the maker (rejected — leaves the four-eyes replay able to finalize a case that became insufficient after submission, the exact hole Codex found).
Consequences
Positive
- The
insufficient_dataverdict becomes binding, not merely displayed — closing a claim-vs-check gap on the highest-stakes action (approve). - The gate is enforced at BOTH the maker and the checker; a case that degrades between submission and second approval cannot be finalized behind the gate's back.
- Every enforced override is doubly recorded — a purpose-specific immutable audit row and the four-eyes pending/granted records — regardless of routing path.
- Never-suppress holds: the gate only adds scrutiny on
insufficient_data;escalateis untouched, the override is audited + four-eyes-routed + officer-reversible. - Dark-launched: the would-block telemetry validates real-world firing before Phase 2 flips enforcement on, exactly as ADR-0074 did for RBAC.
Negative
- Two verdict compositions on the approve path when the gate is on (maker + checker) instead of one — accepted; both are single-case, officer-interactive, not batch.
- A case that legitimately cannot collect the missing evidence now requires an audited four-eyes override to approve — deliberate friction, the intended "is this really clear?" checkpoint.
- The flag is off in production until Calibration Review signs off Phase 2; until then the gate only observes, it does not protect.
Neutral
- The verdict composer (
compliance_verdict.py, ADR-0113) is unchanged; only new consumers (the maker gate + the checker recheck) read it. - The override joins the existing family of audited, four-eyes-routed approval overrides (dissolved-entity, entity-disposition, purpose, expired-document) — same posture, same machinery, no new pattern.
Alternatives Considered
Alternative 1: Gate at the maker only
- Read the verdict on
POST /decision, trust the pending payload at second approval. - Why rejected: the maker's verdict is a point-in-time read; the four-eyes replay can happen
minutes/hours later after a monitoring re-screen, a new finding, or a document expiry moves
the case to
insufficient_data. Trusting the stale payload lets the checker finalize a case that is no longer clearable — the exact defect Codex found. The checker must revalidate the live verdict (fail-closed), honoring only an audited maker override.
Alternative 2: Hard block with no override
- Treat
insufficient_datalike the SAR-first gate (no officer override, ADR-0071). - Why rejected: unlike a criminal/enforcement predicate, an evidence-collection gap can be a legitimate, documented business decision (a source is genuinely unavailable). The correct posture is an audited, four-eyes exception (like the dissolved-entity / entity-disposition overrides), not an absolute block — scrutiny added, judgment preserved, both actors on record.
Alternative 3: Persist the override only on the single-officer path
- Rely on the existing single-officer
approval_override_insufficient_datasignal_event. - Why rejected: with
maker_checker_enabled=True(the default) an override always routes to four-eyes and returns before that block — the override's evidence would be captured only in the generic pending record. A purpose-specific immutable audit row, written before the early return, guarantees the override is auditable on every path.