Skip to main content

ADR-0086: Expected-Activity & Purpose Baseline Activation (Completing ADR-0062)

Date: 2026-07-03 Status: Proposed Deciders: Adrian (Soft4U), Claude Fable 5 (AMLA remediation design session)

Decision context:

  • Latency: one indexed single-row read (purpose_profiles by case_id) added to the decision path, the memo builder, and the case-pack builder — sub-millisecond against local PostgreSQL, not measured because it is one PK-adjacent lookup on surfaces that already perform dozens of reads. The profile_deviation monitoring check adds one profile read + one financials read per monitored entity per cycle, inside an already-batch Temporal activity.
  • Dependency surface: zero new packages. One new RLS table (next sequential Alembic revision), one new service (purpose_profile_service.py), one new API pair (GET/PUT /api/cases/{id}/purpose-profile), portal question wiring, one new EBA factor, one new monitoring check type. Reuses trustrelay_models.purpose_profile (ADR-0062), missing_purpose_requirements (currently dead), financial_analysis (ADR-0048), MakerCheckerService (ADR-0070).
  • Debuggability: a blocked approval returns a 409 with error: missing_purpose_requirements and the explicit missing-attribute list (same shape as the ADR-0059 open_ubo_discrepancy and ADR-0065 dissolved_entity 409s in case_decisions.py); a deviation alert carries the declared-vs-observed values in its payload; a profile-less monitored entity yields an indeterminate WARNING that names the absent profile, never a silent skip.
  • Reversibility: additive throughout — drop the gate wiring and the check registration and the prior behaviour returns; the table and migration can stay inert. No CaseStatus change, no Temporal workflow signature change. Estimated < 1 day to revert by branch.
  • Blast radius: case_decisions.py approval path (new gate, same pattern as three existing gates), decision_memorandum_service.py §Business Rationale (read source swap with fallback), case_pack_service.py (new section), eba_risk_matrix.py channel dimension (one factor appended under max-of-factors — can only raise, never lower, a dimension score), monitoring check registry (+profile_deviation), portal questions. Onboarding flow for low/medium-risk cases without a profile is behaviourally unchanged except for a new warning finding.
  • Alternative considered: keep the untyped additional_data keys and just add writers — rejected because it reproduces the dual-representation defect class fixed in PR #176 (two unreconciled vocabularies for the same fact) and gives the monitoring deviation check no queryable, validated baseline to diff against.

Context

ADR-0062 (2026-06-25, issue #44) introduced typed purpose/expected-activity models for AMLR (EU) 2024/1624 Art. 20(1)(c) — "assessing and, as appropriate, obtaining information on the purpose and intended nature of the business relationship". The models shipped and nothing else did. The 2026-07-03 evidence-audited gap analysis (docs/research/2026-07-03-amla-ongoing-monitoring-gap-analysis.md §3.1 "Activity baseline", §3.2.5) verified, with file:line evidence:

  • PurposeProfile / ExpectedActivity / StatedPurpose (packages/trustrelay-models/src/trustrelay_models/purpose_profile.py, re-exported at backend/app/models/purpose_profile.py) are never instantiated anywhere in app code. There is no persistence table, no capture endpoint, no portal question.
  • The Officer Decision Memorandum's §Business Rationale (decision_memorandum_service.py:516-549, _build_business_rationale) reads additional_data keys — business_purpose, expected_geography, expected_monthly_turnover_eur, channel_mix, source_of_funds, source_of_wealth, compensating_controlsthat no code path ever writes. The section renders empty strings with a covered flag that can be satisfied by MCC classification alone, so the memo silently presents an unassessed purpose as covered.
  • missing_purpose_requirements (purpose_requirements.py:11-19) — the SoF-at-standard/high, SoW-at-high tier rule — has zero callers. Consequence: a HIGH/CRITICAL (EDD-tier) case can be approved with empty source-of-funds and source-of-wealth, an AMLR Art. 20(1)(c) requirement the codebase encodes as a rule and then never enforces.

The AMLA draft ongoing-monitoring guidelines (Art. 26 AMLR, public hearing 2026-07-02) make this the prerequisite gap: Guideline 2's position is that without an expected-activity baseline captured at CDD, deviations cannot be identified, and that peer-group comparison cannot substitute for an individual customer profile. Trust Relay's monitoring spine (ADR-0066) can therefore never satisfy Guideline 2's deviation-monitoring expectation — there is nothing to deviate from. The baseline is also the handoff artifact a customer's transaction-monitoring system consumes; Trust Relay deliberately is not a TM system (architecture doc §Out of scope), but the CDD-side capture obligation is squarely in scope.

This ADR covers Wave 4 of the AMLA remediation architecture (docs/superpowers/specs/2026-07-03-amla-remediation-architecture.md §2.3 purpose_profiles, §2.4 purpose_profile_service, §3 W4). Companion ADRs: 0083 (trigger taxonomy — defines MonitoringTriggerType.profile_deviation this wave's check emits into), 0084 (alert disposition — where deviation alerts are dispositioned), 0070 (maker-checker — reused for the gate override).

Decision

Activate the ADR-0062 models end-to-end: persist them, capture them, gate on them, render them, and monitor against them. Six parts, all using the architecture doc §2 vocabulary verbatim.

1. Persist: purpose_profiles table (architecture §2.3, W4 migration). Create the next sequential Alembic revision (never a hardcoded revision id) adding purpose_profiles: id, tenant_id, case_id, stated_purpose (the ADR-0062 StatedPurpose enum values), expected_activity JSONB (monthly_volume, monthly_value_eur, geographies[], counterparties[], channels[]), source_of_funds, source_of_wealth, is_intermediary bool, sub_merchant_profile JSONB, captured_by, captured_at. FORCE ROW LEVEL SECURITY, tenant-scoped like screening_results (ADR-0023); every INSERT sets tenant_id explicitly — no server_default (the PR #177 confirm-website lesson, architecture §1.4). The JSONB shape extends ADR-0062's ExpectedActivity with channels[]; the Pydantic model gains that field rather than the table diverging from the model.

2. Capture: purpose_profile_service.py (architecture §2.4) + GET/PUT /api/cases/{id}/purpose-profile (architecture §2.5) + portal questions. Officers capture the profile at case creation (dashboard form on the case-create flow); the customer portal's question step enriches it (expected volumes/values/geographies/channels, SoF/SoW narrative). The service is the single write path (validates against the typed model, stamps captured_by/captured_at, RBAC-gated for the officer endpoint). Portal wording is requirement-stating only — tipping-off discipline (AMLD 2015/849 Art. 39, ADR-0071, architecture §1.6) forbids any suspicion-revealing framing.

3. Gate: wire missing_purpose_requirements into the approval path. In the case_decisions.py decision flow, before the officer_decision signal fires (the Temporal workflow stays contained, exactly as ADR-0059/0065/0070 gate at the service/API layer):

  • High risk (HIGH/CRITICAL, EDD tier): hard 409 when missing_purpose_requirements(profile, tier) is non-empty or no profile exists for the case (fail-closed — an absent profile is missing everything, never treated as nothing-required). Response body carries error: missing_purpose_requirements plus the missing-attribute list. Override exists but is audited maker-checker: FourEyesContext gains an override_purpose_requirements flag and requires_four_eyes (maker_checker.py:82) gains the trigger reason purpose_requirements_override, so the override routes through PENDING_SECOND_APPROVAL with both actors on the immutable audit_events spine (ADR-0064/0070). No single-officer bypass.
  • Standard risk: warning finding, not a block — the missing attributes are recorded as a finding on the case and rendered in memo/pack, preserving officer discretion where AMLR demands SoF but the ADR-0070 SoD rationale does not apply.

4. Render truthfully: memo + case pack. _build_business_rationale (decision_memorandum_service.py:516-549) reads the purpose_profiles row first (typed), falling back to the legacy additional_data keys only when no row exists, and its covered flag becomes honest: covered only when a captured profile (or explicit legacy keys) exists — MCC classification alone no longer marks purpose as covered (ADR-0067 "not assessed ≠ clear"). The regulator case pack (ADR-0069, case_pack_service.py) gains the profile as a rendered section — it is the baseline artifact a supervisor or the buyer's TM team asks for.

5. Intermediary risk factor. is_intermediary + sub_merchant_profile feed a new intermediary factor in the EBA matrix channel dimension (_score_channel_dimension, eba_risk_matrix.py:784), per EBA/GL/2021/02 distribution-channel/intermediation risk factors. Because dimension scores are the max of normalized factor scores, the new factor can only raise or leave unchanged a dimension score — structurally compliant with the never-suppress principle (architecture §1.1). Factor thresholds are tenant-configurable like the existing 21 factors and subject to the calibration-rationale regime (ADR-0088/W6).

6. Monitor: profile_deviation check v1. Append profile_deviation to MonitoringCheckType (trustrelay_models/monitoring.py:20-31) and emit MonitoringTriggerType.profile_deviation (architecture §2.1, ADR-0083) into the W1 trigger router. v1 compares, per monitored entity on the existing ContinuousMonitoringWorkflow cadence (no new schedules, architecture §2.6):

  • Declared turnover vs registry-filed financials: annualized expected_activity.monthly_value_eur against the filed-accounts revenue already retrieved by the financial-analysis layer (ADR-0048; sourced-or-honest-gap per ADR-0079/0080). A divergence beyond the configured band raises a profile_deviation alert carrying both values.
  • Declared geographies vs registry-observed country set: expected_activity.geographies against the countries observed in the investigation/registry evidence (operational and UBO country sets the EBA input already assembles).
  • Fail-closed: no profile captured (the entire pre-W4 back catalogue), or financials unavailable/capability-gapped for the jurisdiction, → indeterminate WARNING naming what could not be assessed — never a benign pass, never a silent skip (ADR-0067/0068 contract).

Explicitly not in v1: payment-flow or volume-count deviation — Trust Relay ingests no transaction data (architecture §Out of scope); the captured baseline is the export the customer's TM system consumes for that half of Guideline 2.

Verification per architecture §4: test_no_false_reassurance.py (or sibling) gains the invariants "empty SoF at high risk blocks" and "no profile → indeterminate WARNING, not pass"; the OB Holding fixture (CRITICAL/90 oracle) exercises the high-risk 409 + maker-checker override path; testcontainers PostgreSQL for the RLS table (no mocks); golden suite stays green.

Consequences

Positive

  • AMLR Art. 20(1)(c) becomes enforced, not merely encoded: a high-risk case can no longer be approved with empty SoF/SoW, and the override that permits it is a two-person, immutably audited event.
  • AMLA Guideline 2's prerequisite exists: an individual, typed, tenant-scoped expected-activity baseline that deviations can be measured against — and the exportable handoff artifact a buyer's TM system needs.
  • The memo's §Business Rationale and the case pack stop presenting unwritten fields as covered — closes a data-honesty defect of the same class as the PR #176 risk-display split.
  • Three pieces of verified dead code (missing_purpose_requirements, the ADR-0062 models, the memo's additional_data reads) gain their intended callers instead of accreting.

Negative

  • Capture friction: officers get new form fields at case creation and high-risk approvals gain a hard prerequisite; until tenants adjust their intake habits, some approvals will bounce off the 409 and need profile completion or a four-eyes override round-trip.
  • Deviation check v1 is deliberately crude: annualized declared turnover vs the latest filed annual accounts ignores growth-stage companies, seasonality, FX, and stale filings — a measurable false-positive rate is expected, and every one demands a W2 disposition with rationale. The band thresholds will need calibration cycles (W6) before the signal is trustworthy.
  • The entire pre-W4 approved back catalogue has no profile, so the monitoring population will emit a wave of indeterminate profile_deviation WARNINGs on day one. This is the honest fail-closed outcome, but it is alert-queue load until CDD-refresh outreach (W3, cdd_refresh_service) back-fills profiles.
  • Two representations of the profile now exist (Pydantic model + table row + a transitional additional_data fallback) that must be kept in sync; the fallback is retirement debt with a named owner (delete once no tenant case relies on legacy keys).

Neutral

  • The Temporal workflow definition is untouched — capture, gate, and render all live at the API/service layer; no workflow.patched() guards needed (zero customers).
  • The EBA intermediary factor rides the existing versioned risk-config machinery; tenants that never flag intermediaries see no scoring change.
  • sub_merchant_profile is captured and rendered in W4 but its downstream use (per-sub-merchant screening, KBC establishment-unit asks) is a later wave; this ADR only reserves the typed slot.

Alternatives Considered

Alternative 1: keep the additional_data keys and just add writers

  • Populate business_purpose / expected_monthly_turnover_eur / etc. on cases.additional_data from the portal and dashboard, leaving the memo reader as-is.
  • Why rejected: untyped JSONB keys have no validation (a free-text turnover string cannot be diffed against filed financials), no RLS-table query surface for the monitoring sweep, and no enum discipline — and it entrenches a second vocabulary for facts ADR-0062 already typed, the exact dual-representation defect class that produced the PR #176 risk-display bug and gap-analysis bug §3.2.4 (three unreconciled cadence tables).

Alternative 2: capture the profile only in the decision memorandum

  • Add memo form fields the officer fills at decision time; the profile lives as prose in the generated PDF and its stored section payload.
  • Why rejected: a document is not a baseline — the profile_deviation check cannot diff declared turnover against a PDF paragraph; the ADR-0082 post-workflow-retrievability lesson showed decision-time-only artifacts are not queryable afterwards; and decision-time capture is too late for the SoF/SoW gate, which must evaluate before the approval it blocks.

Alternative 3: defer deviation detection entirely to the customer's TM system

  • Capture the profile (parts 1–5) but ship no profile_deviation check; hand the baseline to the buyer's transaction-monitoring stack and let it own all deviation logic.
  • Why rejected: the two v1 comparisons are non-transactional — registry-filed financials and registry country sets are data Trust Relay already retrieves and the TM system does not; AMLA Guideline 2 treats behavioural/registry deviation as part of ongoing monitoring (Art. 26), which is exactly the ADR-0066 spine's remit. The scope boundary excludes payment flows, not registry-evidence deviation.

Alternative 4: do nothing

  • Leave the models dead and rely on officer diligence for purpose assessment.
  • Why rejected: not viable — a high-risk approval with empty SoF/SoW is demonstrably possible today (missing_purpose_requirements has zero callers), the memo asserts coverage of fields nothing writes, and comply-or-explain convergence on the AMLA guidelines (NCA declarations Q1 2027, AMLR applying 2027-07-10) makes "no baseline exists" an obligation-not-demonstrable finding for every tenant.