Skip to main content

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→CDD is 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_configurations table 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_checks and prohibited_jurisdictions are still read from Letta; mandatory_checks is scrutiny-additive only (safe). prohibited_jurisdictions consumption should be reviewed under the same lens in the follow-up.
  • Full remediation (appetite in the audited risk_configurations table, 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_tier to 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_configurations today; 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.