Skip to main content

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:

  1. 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 to insufficient_data after 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.

  2. On the default maker_checker_enabled=True path, the override evidence was not persisted before the four-eyes early return. An override_insufficient_data makes requires_four_eyes return True, so submit_decision returns from create_pending before 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:

  1. The gate. On an APPROVE / APPROVE_WITH_RESTRICTIONS where the live compliance verdict is insufficient_data, block the approval. escalate and clear verdicts are unaffected — the gate only ADDS a block on the one state that means "not cleared" (never-suppress: an escalate is never softened, only insufficient_data is newly binding).

  2. Dark-launch / log-first (approval_insufficient_data_gate_enabled=False by default). Flag OFF: do not block — emit a greppable approval.insufficient_data.would_block structlog event on the approval_gate channel and an immutable approval_insufficient_data_would_block audit 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.

  3. The override. override_insufficient_data on DecisionRequest — 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 an insufficient_data_override trigger 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.

  4. Second-approval revalidation (P1-A). MakerCheckerService._approve re-reads the LIVE compliance verdict when the gate is enforced and the maker's stored decision did NOT carry override_insufficient_data. If the verdict is now insufficient_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 same CaseDecisionsService.get_compliance_verdict composer 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.

  5. Persist the override evidence before the four-eyes early return (P2-B). When the override is recorded, an immutable approval_insufficient_data_override audit row (reason

    • verdict reasons + enforced=True + routes_through_four_eyes=True) is written the moment the override becomes active — before create_pending can short-circuit out of the handler. It reuses the same AuditService.log_event writer 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.

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 DecisionRequest field, one audit helper, one recheck block, one TS field, one UI banner.
  • Debuggability: every gate decision leaves a greppable structlog line on the approval_gate channel + 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_data verdict 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; escalate is 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_data like 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_data signal_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.