AMLR Compliance Controls
Trust Relay's onboarding pipeline is shaped by the EU Anti-Money-Laundering Regulation (AMLR), the EU AI Act, and the GDPR. This page documents three foundational controls landed in the AMLR-compliance sprint that moved the platform from compliant by convention to compliant by construction: the audit trail is now immutable at the database layer, a dissolved entity can no longer be onboarded silently, and the customer-due-diligence (CDD) data model is now a set of typed, monitorable records rather than an untyped fact-bag.
The control set has since grown. Five further controls now ship on top of these three: the maker-checker / four-eyes hold on high-risk decisions (ADR-0070), the SAR/STR lifecycle with the MLRO filing gate and the SAR-first customer-contact (tipping-off) gate (ADR-0071), the fail-closed "not assessed" output contract (ADR-0067), and the audited entity-risk downgrade — a second, ADR-0089-specific four-eyes control that is the only path a persisted entity baseline can go down (§4 below). Three of these are documented in full on SAR/STR Lifecycle & Tipping-Off and summarised as decision gates on the State Machine page; §5 below cross-references them.
Each control follows the same design principle the platform applies everywhere: the system can add scrutiny but never suppress a risk signal, and every deviation a human officer makes is itself recorded on the immutable trail.
The AMLR-readiness programme (epic #528) extends this control set further. The Wave-1/2 determinations — sanctioned-ownership (Art. 20(1)(d), ADR-0127), register-discrepancy reporting (Art. 24, ADR-0129), immediate-on-designation re-screen (Art. 26(4), ADR-0128), and the SMO exhausted-means record (Art. 22(2), ADR-0126) — with the honest live / dark-launched / deferred status of each, are documented on AMLR Readiness. Several are dark-launched behind a default-off flag; this page covers the controls that are live by construction.
Control-disabled coverage records (ADR-0166)
ADR-0067 established the fail-closed contract: a check that did not run must never
read as a check that ran and found nothing. Its trigger list names five conditions —
unconfigured source, empty list, exception, missing tenant context, absent data — and
a deliberately disabled control is not among them. Issue #914 was the live
consequence: activities.reassess_risk_activity wrapped the whole AMLR Art. 20(1)(d)
sanctioned-ownership computation including its own gap emitter inside the feature
flag, so with the flag off the case record said nothing at all about the ownership
dimension. An officer could not distinguish "tested, nothing found" from "never
tested".
app/services/control_coverage.py is the single source of truth for both ends of the
fix. It produces a typed record — status="not_assessed",
reason="control_disabled:<flag>" naming the exact Settings field a deployment
would flip, degraded=False — and it consumes them: collect_control_coverage
discovers any record carrying coverage_kind, so a new producer reaches the officer
surfaces without anyone remembering to wire it.
Two properties are load-bearing:
- Disclosed, not blocking. The record emits no
Findingand noscreening_error, and itsdimensionis deliberately not a member ofmaterial_findings._COVERAGE_STATE_CATEGORIES. Routing it there would make it agap_reason→state="insufficient_data"→ HTTP 409 on every approval, for roughly fifteen deliberately-staged features.blocking=Falseis stamped on every record so the property is machine-checkable rather than conventional. degraded=Falsealongsidestatus="not_assessed"is deliberate.degradedis a provider-health axis;statusis a coverage axis. Nothing failed — the control was never in scope. A genuine failure (control ON, substrate unreachable) usesbuild_dimension_unavailable_coverage, which setsdegraded=True.
Typed absence maps to Outcome.NOT_APPLICABLE, the weakest member of
claims.GAP_PRECEDENCE, so a control-disabled record can never out-rank a real
determination.
1. DB-enforced audit immutability
ADR-0064 · AMLR Art. 77 (record retention) · EU AI Act Art. 12 (automatic logging)
The audit_events table is the system of record for every AI operation and officer
decision. It was append-only by convention — the application never issued an UPDATE
or DELETE — but nothing at the database layer stopped one. Against AMLR's five-year
retention mandate — and the automatic-logging control pattern of EU AI Act Art. 12,
adopted here as defensible good practice rather than because a KYB/AML tool is presumed
high-risk (Recital 58 of Regulation (EU) 2024/1689 explicitly excludes AML and fraud
detection from the Annex III use cases) — convention is not enough. Migration
066_audit_events_immutable enforces immutability in three layers of defence in depth.
Layer 1 — a guard trigger that fires for every role, including superuser. A
BEFORE UPDATE OR DELETE trigger raises unconditionally, so even a connection with
superuser privileges cannot quietly mutate history.
CREATE OR REPLACE FUNCTION audit_events_immutable() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_events is append-only and immutable '
'(AMLR Art. 77 / EU AI Act Art. 12); % blocked', TG_OP;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_audit_events_immutable
BEFORE UPDATE OR DELETE ON audit_events
FOR EACH ROW EXECUTE FUNCTION audit_events_immutable();
Layer 2 — privilege revocation on the application role. The application connects as
the non-superuser trustrelay_app role (the DDL/DML role split from ADR-0050). The
migration revokes the mutating grants outright, so the trigger is never even reached on
the application path:
REVOKE UPDATE, DELETE ON audit_events FROM trustrelay_app;
Layer 3 — a retention-first foreign key. The audit_events.case_id foreign key was
ON DELETE CASCADE. The migration changes it to ON DELETE RESTRICT, so a case that
carries audit history cannot be hard-deleted by cascade — the deletion fails cleanly at
the foreign-key level before the trigger fires mid-cascade. Accordingly,
delete_case returns HTTP 409 for any case with retained audit rows: the trail
outlives the case, as Art. 77 requires.
A legitimate correction therefore demands that a superuser first drop the trigger by
hand — a deliberate, out-of-band act that itself leaves a trace in the server logs.
Immutability is the default; mutability is a conscious, auditable exception. The
downgrade path restores the cascade FK and drops the trigger, but pointedly does not
re-grant UPDATE/DELETE — corrections remain a deliberate superuser action.
2. Dissolved-entity onboarding block
ADR-0065 · AMLR Art. 19/20 (refuse where CDD cannot be performed)
A dissolved, struck-off, or in-liquidation company previously only inflated the risk score — a sufficiently low score still let a dead entity through. AMLR Art. 19/20 require refusing the business relationship where meaningful CDD is impossible, and you cannot perform CDD on an entity that no longer exists. The platform now blocks such onboardings by default at the requirements-review gate.
The decision logic is a pure, side-effect-free function in
dissolved_entity_gate.py. Terminal statuses are matched case-, space-, and
hyphen-tolerantly after normalisation, so "Struck Off", struck-off, and
struck_off all resolve to the same terminal state:
TERMINAL_STATUSES = frozenset({
"dissolved", "struck_off", "struck off", "in_liquidation", "in liquidation",
"liquidation", "liquidated", "ceased", "deregistered", "winding_up", "winding up",
})
def _normalise(status: str | None) -> str:
return (status or "").strip().lower().replace("-", "_").replace(" ", "_")
def is_terminal_status(status: str | None) -> bool:
norm = _normalise(status)
return norm in {_normalise(s) for s in TERMINAL_STATUSES}
The gate is fail-open on unknown status: only a known terminal status blocks. The absence of a terminal status is not evidence of one, and the risk engine still scores the case regardless — so a typo or a registry that does not report status cannot freeze onboarding for the whole population.
The gate is wired into approve_requirements (case_decisions.py). The company status
is read defensively by CaseDecisionsService.get_company_status —
additional_data["company_status"] first (hoisted by persist_workflow_state), then the
latest raw investigation_results[-1]["company_status"] — and any missing field or
exception returns '' (fail-open). The control flow is:
status = get_company_status(workflow_id, tenant_id) # '' on unknown / error
block = evaluate_dissolved_block(status) # blocked only if terminal
if block.blocked and not override_dissolved:
→ HTTP 409 (workflow is NEVER signalled — onboarding stops here)
elif block.blocked and override_dissolved:
if not override_justification.strip():
→ HTTP 400 (override requires a non-empty justification)
persist SignalEvent(
signal_type = "dissolved_entity_override",
safety_class = "non_suppressible", # cannot be hidden from the trail
context_data = {status, justification},
)
→ proceed (signal the workflow)
else:
→ proceed (signal the workflow)
An officer can override a block, but only consciously: the override demands a written
justification and emits a non_suppressible dissolved_entity_override signal onto the
immutable audit trail of §1. The block is fail-closed (no signal to Temporal until the
override is recorded); the override is fully accountable. The risk-score temporal
dimension in eba_risk_matrix is unchanged — the gate complements scoring, it does not
replace it. Blocking at requirements review rather than at raw case creation is
deliberate: company status is typically unknown until pre-investigation has run, so the
gate sits at the first point where the status is reliably available.
3. The typed AMLR data model
ADR-0060 · ADR-0062 · ADR-0063
CDD data — who the people are, what the business is, why it wants the relationship, and
what screening returned — was historically scattered across an untyped CompanyProfile
fact-bag, loose JSONB, and an ephemeral sanctions blob. The sprint replaced the
structurally-missing pieces with first-class typed models. These are structural gaps
that configuration cannot close, and they are independent of the still-draft field-level
RTS under AMLR Art. 28(1).
NaturalPerson — plural nationalities, PEP classification, place of birth (ADR-0060)
The canonical person model (canonical_entities.py) carries three AMLR-mandated fields
that the legacy shape could not express, each kept back-compatible by a validator that
syncs the old singular field and never suppresses a PEP signal:
nationalities: list[str]— multiple nationalities are risk-relevant; the legacy singularnationalityis retained and synced tonationalities[0].- PEP classification via the
PepClassificationenum (not_pep/pep/rca) plusrca_of(the PEP a relative-or-close-associate is linked to), replacing the bare boolean that could not represent RCAs under AMLR Art. 17. The legacyis_pepbool is kept and synced toward the stronger signal. place_of_birth— AMLR-mandatory, previously hardcodedNonein the goAML mapper.
These flow end-to-end into FIU filings: place_of_birth populates goAML birth_place,
and all three of goAML's nationality1/2/3 are carried per the goAML 5.0.2 XSD
sequence, so a dual-national UBO's second nationality is no longer dropped from the
filing. An rca classification maps to is_pep = true for goAML.
SubjectEntity — register name, address divergence, SDD flags (ADR-0062)
SubjectEntity (subject_entity.py) gives the onboarded business (AMLR §1) the typed
fields the fact-bag lacked, with two computed risk signals:
| Field | Purpose |
|---|---|
register_name | First-class official register name, distinct from legal_name. |
registered_address vs principal_place_of_business | Their divergence is a risk signal. |
is_regulated_entity, listed_on_regulated_market | Drive SDD eligibility / UBO exemptions. |
address_divergence (computed) | True when both addresses are set and differ. |
sdd_eligible (computed) | True for a regulated or listed entity (configurable). |
PurposeProfile — stated purpose enum, SoF / SoW (ADR-0062)
PurposeProfile (purpose_profile.py) turns purpose and intended nature of the
relationship (AMLR Art. 20(1)(c)) — previously assembled ad-hoc from loose JSONB at memo
time — into a monitorable baseline. stated_purpose is a StatedPurpose enum (trading,
holding, investment, ecommerce, consulting, real_estate, financial_services,
manufacturing, other) rather than free text; ExpectedActivity captures expected
volumes, values, geographies, and counterparties; and source_of_funds /
source_of_wealth are explicit. A tier-aware helper requires SoF at standard/high risk
and SoW at high risk (configurable). The enum baseline is what transaction monitoring
later checks observed activity against.
ScreeningResult — the persisted ongoing-monitoring evidence trail (ADR-0063)
AMLR ongoing monitoring (§7) needs a recurring, timestamped, queryable screening
record per target and list type — not an ephemeral blob on the investigation.
ScreeningResult (screening_result.py) is the typed model, with list_type constrained
to the ScreeningListType enum: eu_sanctions, un_sanctions, ofac, wanted, pep,
adverse_media. It persists to the append-only, RLS-scoped screening_results table
(FORCE ROW LEVEL SECURITY, ADR-0023), with a composite index on
(tenant_id, case_id, target_ref, list_type).
ScreeningResultService writes one row per result via persist_screening_results and
exposes the queryable trail via list_screening_results (filterable by target_ref and
list_type). It uses the _session_scope pattern — owning the commit in production via
get_tenant_session, or flushing on an injected session under test. This trail is the
backbone consumed by Continuous Monitoring: it pairs with
screen-all-UBOs and periodic re-screening to provide a permanent, per-target evidence
record.
4. Audited entity-risk downgrade — a second, distinct four-eyes control
ADR-0089 (Component E) · reuses ADR-0070 maker-checker · AMLR Art. 77 · EU AI Act Art. 12
Entity risk is now a persisted, cross-case baseline (entity_baselines,
ADR-0066): the platform remembers the worst-known assessment of an entity across every
re-screen, not just the latest one. entity_baseline_service.upsert_baseline enforces a
one-way ratchet — reconcile_baseline_risk(established, incoming) compares tier rank then
score, and a re-screen may only raise or maintain latest_risk_score /
latest_risk_tier. A would-be downgrade (a new run scoring lower than the held
baseline — the failure mode a live OB Holding 1 OÜ re-run actually hit: CRITICAL/90 on one
run, medium/51 on the next, on the same entity) is held: the established value stays
in latest_risk_score/latest_risk_tier, the raw lower run is recorded in
last_run_risk_score/last_run_risk_tier, and a divergence_state JSONB payload is
written recording the standoff. See Continuous Monitoring and
ADR-0089 for the full ratchet
(the deterministic-scoring and findings-persistence components that make the hold
meaningful). This section documents only the control that resolves the hold: the only
path a persisted entity baseline is allowed to go down.
The hold immediately raises an officer task, not a silent state.
MonitoringAlertService.create_risk_divergence writes a MonitoringAlert with
trigger_type=risk_divergence at priority=1 (high, tight SLA) — the alert row is
flushed before the caller's baseline write records divergence_state["alert_id"], so
the officer-facing task always exists before the hold does. A risk_divergence_detected
row lands on the immutable audit_events trail (§1) in the same step, actor_type="system".
Resolving the divergence needs two distinct human actors — a maker and a checker.
This is deliberately a second four-eyes control, not a re-run of the case-level one:
ADR-0070's MakerCheckerService gates an officer decision on a case (it parks it in
PENDING_SECOND_APPROVAL via PendingDecisionApproval until a second officer confirms),
whereas an entity baseline is a cross-case record (entity_baselines, keyed by
country + registration number, ADR-0066) that can be touched by re-screens on any case
against that entity — there is no single case status to park it on. What the two controls
share, rather than duplicate, is the identity guard:
entity_baseline_service.approve_baseline_downgrade calls the same
assert_distinct_approver (maker_checker.py) that the case-level flow uses, so a
missing/blank checker or a same-actor self-approval is rejected identically in both
places. FourEyesContext.override_risk_divergence also already exists as a recognised
requires_four_eyes reason (risk_divergence_override) alongside override_open_discrepancies
/ ADR-0059, override_dissolved / ADR-0065, override_purpose_requirements / ADR-0086
and override_expired_document / ADR-0087, so a future officer-decision surface that
needs to react to an open divergence has the predicate ready — but the downgrade itself is
driven end-to-end by its own dedicated pair of endpoints (app/api/monitoring.py), both
guarded by Permission.MONITORING_DISPOSE:
POST /api/monitoring/baselines/{country}/{registration_number}/downgrade/request (maker)
POST /api/monitoring/baselines/{country}/{registration_number}/downgrade/approve (checker)
request_baseline_downgrade(the maker step) requires an opendivergence_state— fail-closed withBaselineDowngradeError→ HTTP 409 if there is nothing held to downgrade — a non-blank maker identity, and a reason of at least 10 characters. It writesdivergence_state["pending_downgrade"](maker id, name, reason, timestamp) and an immutablerisk_downgrade_requestedaudit event. It does not touchlatest_risk_score/latest_risk_tier— the floor is still held after this call.approve_baseline_downgrade(the checker step) is the only function in the codebase that lowerslatest_risk_score/latest_risk_tier. It re-validates in order: a pending request must exist (409 otherwise); the held-down run's material check must not bematerial_check_incomplete(Component D — a data-gapped low score is a retrieval miss, not evidence of lower risk, so it fail-closed blocks the downgrade with a 409 explaining why, forcing a fresh completed re-screen first); andassert_distinct_approver(maker_user_id, checker_user_id)rejects a missing/blank checker (AmbiguousApproverError) or a same-actor self-approval (SelfApprovalError) — both surface as HTTP 403. Only once all three hold does it lowerlatest_risk_score/latest_risk_tierto the rawlast_run_risk_*values, lowerestablished_findingsto the raw run's material findings (never below the raw run — Component C), cleardivergence_state, re-derivenext_review_due/monitoring_cadence_monthsfrom the now-lower effective tier, and write a single immutablerisk_downgrade_approvedaudit event carrying both actors (maker_user_id/maker_name,checker_user_id/checker_name), the maker's reason, the checker's note, and thefrom/toscore and tier — a complete forensic record of who lowered the risk, why, and by how much.
The net effect: a compliance officer can no longer single-handedly walk a CRITICAL entity back down to medium by re-running the screen until it gets a friendlier answer. Two distinct people, an explicit written reason, and an immutable dual-actor audit event are required — and even then, only after the material check that produced the lower number is confirmed complete.
5. Later controls (four-eyes, SAR/STR, fail-closed)
Three controls shipped after this sprint complete the decision-integrity picture. They are documented in full elsewhere and cross-referenced here so this page stays the index of the onboarding control set.
- Maker-checker / four-eyes (ADR-0070). A high-risk approval — or any override of a
fail-closed gate — is held in the
PENDING_SECOND_APPROVALstate until a second, different approver holdingCASE_DECIDEconfirms it. The distinct-actor guard raises before any side effect, and both actors are written to the immutable trail (app/services/maker_checker.py). Enabled bymaker_checker_enabled(default on). - SAR/STR lifecycle + MLRO filing gate + tipping-off boundary (ADR-0071). A no-skip
draft → pending_mlro → approved → submitted → acknowledgedmachine with MLRO four-eyes on filing, and a runtime SAR-first customer-contact gate that 409-blocks any customer-facing decision/evidence-request/portal/chat action on a case with a criminal predicate until a signed MLRO reportability assessment exists — no officer override (AMLR Art. 73). See SAR/STR Lifecycle & Tipping-Off. - Fail-closed "not assessed" contract (ADR-0067). When a check did not run, outputs say
not assessed — never clear. This is why a
CLEARsanctions result on a network that was not adverse-media-searched surfaces an explicitadverse_media_recall_gapfinding (see Reporting and Case-Pack Export).
Components
| File | Responsibility |
|---|---|
backend/alembic/versions/066_audit_events_immutable.py | Guard trigger + REVOKE UPDATE,DELETE + FK CASCADE→RESTRICT on audit_events. |
backend/app/services/maker_checker.py | Four-eyes hold (PENDING_SECOND_APPROVAL), distinct-approver guard, dual-actor audit (ADR-0070). |
backend/app/services/dissolved_entity_gate.py | Pure evaluate_dissolved_block / is_terminal_status / TERMINAL_STATUSES. |
backend/app/services/case_decisions_service.py | get_company_status / extract_company_status; persist_signal_event for the override. |
backend/app/api/case_decisions.py | approve_requirements 409/400/override wiring; delete_case 409. |
backend/app/services/screening_result_service.py | persist_screening_results / list_screening_results evidence trail. |
backend/app/services/entity_baseline_service.py | reconcile_baseline_risk ratchet; request_baseline_downgrade (maker) / approve_baseline_downgrade (checker) — the only path a baseline goes down (ADR-0089 E). |
backend/app/services/monitoring_alert_service.py | create_risk_divergence — raises the RISK_DIVERGENCE MonitoringAlert (priority=1) that opens the officer task. |
backend/app/api/monitoring.py | POST /baselines/{country}/{registration_number}/downgrade/{request,approve} — Permission.MONITORING_DISPOSE-guarded four-eyes endpoints. |
packages/trustrelay-models/.../screening_result.py | ScreeningResult + ScreeningListType enum (incl. wanted). |
packages/trustrelay-models/.../canonical_entities.py | PersonSchema (nationalities, place_of_birth) + PepStatus / PepClassification. |
packages/trustrelay-models/.../subject_entity.py | Typed SubjectEntity with address_divergence / sdd_eligible. |
packages/trustrelay-models/.../purpose_profile.py | PurposeProfile + StatedPurpose enum + SoF/SoW. |
Related
- ADR-0060 — NaturalPerson AMLR fields (plural nationalities, PEP/RCA, place of birth).
- ADR-0062 — Typed SubjectEntity + PurposeProfile.
- ADR-0063 — Typed persisted ScreeningResult.
- ADR-0064 — DB-enforced
audit_eventsimmutability. - ADR-0065 — Dissolved-entity onboarding block-by-default.
- ADR-0070 — Maker-checker / four-eyes (reused by the entity-risk downgrade in §4).
- ADR-0089 — Deterministic scoring + entity-risk one-way ratchet; Component E is the audited downgrade documented in §4 above.
- Continuous Monitoring — the baseline ratchet (Components A–D) and the ScreeningResult evidence trail this control resolves a hold on.
- UBO Determination — SDD flags drive UBO exemptions.
- Person Verification — verifies the typed NaturalPerson records.
- Sanctions Screening — produces the screening results persisted here.
- AMLR Readiness — the Wave-1/2 AMLR determinations, read surfaces, and their honest rollout status (epic #528).