ADR-0091: Risk appetite must not lower the tier from an unaudited source
Date: 2026-07-05 Status: Superseded by ADR-0092 Deciders: Adrian (Soft4U), Claude Opus 4.8
Context
The risk tier (SDD / CDD / EDD) is derived from the composite score by
_determine_tier(score, risk_appetite) (risk_matrix_service.py), which selects a
threshold band from TIER_THRESHOLDS (trustrelay-models/risk_matrix.py):
| appetite | (sdd_max, cdd_max) |
|---|---|
| conservative | (30, 60) |
| moderate | (40, 70) |
| progressive | (50, 80) |
A higher appetite raises the ceilings, so the same score maps to a lower tier —
e.g. a composite of 45 is CDD under moderate but SDD under progressive; 65 is
EDD under conservative but CDD under progressive. Appetite therefore directly reduces
due-diligence scrutiny.
The two workflow paths that determine/re-determine the tier — initial assessment
(case_crud.py risk_reassess) and mid-investigation reassessment
(activities.py reassess_risk_activity) — read risk_appetite from the Letta
org_policy block (get_org_policy). That block is part of the assistance layer: it is
mutable, deletable, writes no audit trail, and is not retained. A Letta edit setting
risk_appetite = "progressive" would lower tiers across every case with no record an MLRO
could reconstruct.
This violates the project's core invariant — "the system may ADD scrutiny but NEVER
suppress a risk signal; any scrutiny-reducing output must be evidence-traceable and
officer-overridable" — and the stated boundary that Letta never feeds the deterministic
risk decision. It is also a gap against EU AI Act Art. 12 (logging/traceability) and
AMLR documented-methodology + 5-year retention: the appetite in effect for a past decision
is unrecoverable. mandatory_checks from the same block is not affected — it can only
add locked checks (OR'd with MANDATORY), so it is scrutiny-additive only.
Decision
Until per-tenant risk appetite lives in the audited, DB-versioned risk_configurations
table, an appetite value sourced from the unaudited Letta org_policy may make the tier
stricter but never more lenient. Introduce
appetite_from_unaudited_source(appetite) in risk_matrix_service.py: it returns
"conservative" for "conservative" (stricter — adding scrutiny is always safe) and
"moderate" for everything else, including "progressive" and unknown/empty values. Both
tier-determining read sites (case_crud.py, activities.py) pass the Letta value through
this clamp before it reaches _determine_tier / reassess.
The full remediation — moving appetite into the audited config so a legitimate progressive appetite can be honored with a retained, versioned record — is tracked separately (see Consequences → Neutral) and will supersede this clamp when it lands.
Decision context:
- Latency: none (a pure string map on an already-fetched value).
- Dependency surface: no new packages; one helper in an existing module, two call sites.
- Debuggability: the clamp is a single pure function with a docstring naming the invariant; the demonstration
score 45: progressive→SDD, clamped→CDDis a one-line repro. - Reversibility: single-flip — delete the helper and inline reads revert. No migration.
- Blast radius: additive/fail-safe — the clamp can only move a tier to equal-or-stricter, so it cannot itself introduce a scrutiny-reduction bug; worst case a tenant that intended progressive gets moderate (stricter) until the audited-config path ships.
- Alternative considered: source appetite from the audited
risk_configurationstable now — rejected as the immediate fix because appetite has no column there yet (needs a migration + data backfill + design); the clamp is the safe interim that closes the suppression path today.
Consequences
Positive
- Closes an unaudited scrutiny-reduction path in the deterministic risk decision — the tier can no longer be silently lowered via a Letta edit.
- Restores the "may add scrutiny, never suppress" invariant on the tier without a migration.
- Fail-safe by construction: the clamp only ever holds or increases scrutiny.
Negative
- A tenant that legitimately wants a progressive (more lenient) appetite cannot express it until the audited-config path ships; they are held at moderate. This is the correct default under the invariant, but it is a temporary loss of an intended configuration knob.
- Appetite is now sourced from two places with different rules (workflow: clamped-Letta; manual
POST /risk-config/recalculate:additional_data) — a consistency debt the full remediation resolves.
Neutral
mandatory_checksandprohibited_jurisdictionsare still read from Letta;mandatory_checksis scrutiny-additive only (safe).prohibited_jurisdictionsconsumption should be reviewed under the same lens in the follow-up.- Full remediation (appetite in the audited
risk_configurationstable, all read sites unified, legitimate progressive honored with a retained record) is the roadmap item that will supersede this ADR.
Alternatives Considered
Alternative 1: Ignore Letta appetite entirely (always "moderate")
- Force
_determine_tierto always use moderate in the workflow paths. - Why rejected: discards conservative, which adds scrutiny and is safe under the invariant. Clamping (allow conservative, block progressive) is strictly better — it preserves the safe direction.
Alternative 2: Move appetite to the audited risk_configurations table now
- The proper end-state: appetite becomes an audited, versioned config field.
- Why rejected as the immediate fix: appetite has no column in
risk_configurationstoday; adding it needs an Alembic migration, a data backfill from existing Letta blocks, unifying all read sites, and a UI to edit it — a designed change. The clamp closes the suppression path today; this alternative is the tracked follow-up.
Alternative 3: Audit the Letta write instead of clamping the read
- Add an audit_events record whenever org_policy.risk_appetite changes.
- Why rejected: Letta blocks are not the system of record and are not retained; bolting an audit trail onto a mutable assistance block is fragile and still leaves the decision reading a mutable source. Sourcing from an audited store (Alt. 2) is the correct end-state; the clamp is the safe interim.