Skip to main content

ADR-0097: Structured decision conclusion with a mandatory rationale

Date: 2026-07-06 Status: Accepted Deciders: Adrian (Soft4U), Claude Opus 4.8

Decision context:

  • Latency: one extra decision_conclusions INSERT + one audit_events INSERT on the already-non-hot officer-decision path (POST /cases/{id}/decision, seconds-scale, off any customer request). Not measured because the decision endpoint is officer-interactive, not throughput-bound.
  • Dependency surface: no new packages. Wires the already-present, already-tested app/services/decision_conclusion.py (pure Pydantic + validation) into the production path; one new RLS table (Alembic 083); four fields added to DecisionRequest.
  • Debuggability: strictly improves the audit story — every decision gains a queryable, RLS-scoped, immutable rationale row + an audit_events record, instead of an optional free-text reason that was only frontend-enforced for REJECT. A blocked approval returns a specific 422 naming the missing risk factors.
  • Reversibility: additive. Revert = relax the reason/risk-factor validation and stop writing the row (~4-file change); the table can be left in place (append-only, no reads block). A single feature flag is not warranted (zero customers, no backward-compat obligation).
  • Blast radius: additive on the decision path — the existing gate stack (ADR-0059/0065/0086/0087), maker-checker (ADR-0070), and workflow signal are unchanged; validation runs before them. Frontend DecisionActions.tsx gains required inputs.
  • Alternative considered: keep reason free-text and optional (status quo) — rejected because an inspector cannot reconstruct "why was this approved" from an optional one-liner, the exact AMLR/§3.8 gap this closes.

Context

An officer's decision (POST /cases/{workflow_id}/decision) carries an optional reason (DecisionRequest.reason: Optional[str] = None), enforced only for REJECT in the frontend and used incidentally as the justification string for the override gates. There is no structured, mandatory reasoning on approve/escalate/follow-up, and nothing records which risk factors the officer considered. AMLR and the Belgian AML law (§3.8: "a brief report of a few sentences why he has accepted or rejected") require a documented rationale per decision, retained for five years (ADR-0064). An inspector asking "why did you approve this high-risk entity?" currently finds, at best, a free-text one-liner disconnected from the investigation.

A tested domain module — app/services/decision_conclusion.py — already models exactly this: a DecisionConclusion with a mandatory ≥50-char rationale, a KYB_RISK_FACTORS checklist, a regulatory_basis, and validate_decision/submit_decision logic. It has unit tests (tests/test_decision_conclusion.py) but is imported by nothing in app/. The heavyweight path (a 9-section signed Decision Memorandum, ADR-0070) only fires for approve_with_restrictions and high-risk four-eyes approvals — there is no always-on, lightweight structured rationale on the common verbs.

Decision

Wire the existing module into the production decision path and make a structured rationale mandatory on every terminal decision.

  1. reason becomes the mandatory rationale — present and ≥50 chars (trimmed) for approve, approve_with_restrictions, reject, escalate, follow_up. Reuse the existing reason field (already threaded through the override gates, context_data, audit, and frontend) rather than adding a parallel rationale. approve_with_restrictions continues to additionally require its Decision Memorandum — the memo is a richer overlay, never a substitute.
  2. Structured fields on DecisionRequest: risk_factors_considered: list[str], conditions: list[str], regulatory_basis: str | None, aligned to the scaffold.
  3. Fail-closed risk-factor gate: reuse decision_conclusion.validate_decision — for approve/approve_with_restrictions, all KYB_RISK_FACTORS must be acknowledged (else 422); for the other verbs, unreviewed factors are warnings. This gate only ever adds scrutiny (it can block an approval, never suppress a signal), consistent with the never-suppress invariant.
  4. Persist an immutable decision_conclusions row (Alembic 083, RLS WITH CHECK, tenant_id set explicitly): decision, rationale, risk factors (JSONB), conditions (JSONB), regulatory basis, officer id, decided-at, amends_conclusion_id (self-FK for amendment lineage). Append-only; each insert writes an immutable audit_events row (decision_conclusion_recorded, ADR-0064).
  5. Amendment endpoint POST /cases/{workflow_id}/decision-amendment: appends a new linked row (never mutates), with its own required rationale, audited decision_conclusion_amended.
  6. Second approver (submit_second_approval, ADR-0070): the checker's note becomes a required ≥50-char concurrence rationale on approve.
  7. PDF report: a "Decision Conclusion" section renders the rationale, acknowledged risk-factor checklist, conditions, regulatory basis, officer, timestamp, and amendment chain.
  8. Drop the scaffold's trustrelay_engines.review_scheduler import — upsert_entity_baseline already computes next_review_due from the canonical REVIEW_CADENCE_MONTHS_BY_TIER (ADR-0083 §6); a second scheduler would reintroduce the multi-cadence defect ADR-0083 killed.

Consequences

Positive

  • Every decision carries an audit-grade, structured, retained rationale — the AMLR/§3.8 gap closes, and the decision is reconnected to the investigation in the audit trail.
  • The fail-closed risk-factor checklist forces documented consideration of each factor before an approval, an AMLR documented-methodology control.
  • A tested-but-orphaned module is finished rather than left as latent debt.

Negative

  • Added officer friction: an approval now requires a ≥50-char rationale and all eight risk factors acknowledged — deliberate, but real UX cost on every approval.
  • A fixed KYB_RISK_FACTORS set does not yet support per-segment factor lists or a per-factor "N/A"; a low-risk domestic case still ticks all eight.
  • One more table + endpoint on the decision surface to maintain.

Neutral

  • The heavyweight Decision Memorandum (ADR-0070) is unchanged; the new rationale is a strict subset of its ≥200-char justification, so restrictions decisions see no double-entry.
  • reason's type tightens from Optional[str] to required; acceptable under the project's zero-customer, no-backward-compat stance.

Alternatives Considered

Alternative 1: keep reason optional and free-text (status quo)

  • Leave decisions with an optional one-liner, frontend-enforced only for REJECT.
  • Why rejected: it is the exact defect — an inspector cannot reconstruct the reasoning for a high-risk approval, and there is no record of which risk factors were considered.

Alternative 2: add a parallel rationale field alongside reason

  • Introduce a new rationale field and leave reason for override contexts.
  • Why rejected: two overlapping justification fields on one decision invites divergence and requires re-threading a second field through every gate, context_data, audit, and the frontend; reusing reason is less surface and unambiguous.

Alternative 3: extend the Decision Memorandum to every verb

  • Require the 9-section signed memo on all decisions, not just restrictions/high-risk.
  • Why rejected: disproportionate friction for a low-risk approve/follow-up; the memo is the right tool for the heavyweight path, and a ≥50-char structured rationale is the right always-on baseline.