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_conclusionsINSERT + oneaudit_eventsINSERT 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 toDecisionRequest. - Debuggability: strictly improves the audit story — every decision gains a queryable, RLS-scoped, immutable rationale row + an
audit_eventsrecord, instead of an optional free-textreasonthat 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.tsxgains required inputs. - Alternative considered: keep
reasonfree-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.
reasonbecomes the mandatory rationale — present and ≥50 chars (trimmed) forapprove,approve_with_restrictions,reject,escalate,follow_up. Reuse the existingreasonfield (already threaded through the override gates,context_data, audit, and frontend) rather than adding a parallelrationale.approve_with_restrictionscontinues to additionally require its Decision Memorandum — the memo is a richer overlay, never a substitute.- Structured fields on
DecisionRequest:risk_factors_considered: list[str],conditions: list[str],regulatory_basis: str | None, aligned to the scaffold. - Fail-closed risk-factor gate: reuse
decision_conclusion.validate_decision— forapprove/approve_with_restrictions, allKYB_RISK_FACTORSmust 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. - Persist an immutable
decision_conclusionsrow (Alembic 083, RLSWITH CHECK,tenant_idset 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 immutableaudit_eventsrow (decision_conclusion_recorded, ADR-0064). - Amendment endpoint
POST /cases/{workflow_id}/decision-amendment: appends a new linked row (never mutates), with its own required rationale, auditeddecision_conclusion_amended. - Second approver (
submit_second_approval, ADR-0070): the checker'snotebecomes a required ≥50-char concurrence rationale onapprove. - PDF report: a "Decision Conclusion" section renders the rationale, acknowledged risk-factor checklist, conditions, regulatory basis, officer, timestamp, and amendment chain.
- Drop the scaffold's
trustrelay_engines.review_schedulerimport —upsert_entity_baselinealready computesnext_review_duefrom the canonicalREVIEW_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_FACTORSset 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 fromOptional[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
rationalefield and leavereasonfor 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; reusingreasonis 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.