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 controls landed in the AMLR-compliance sprint that move 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.
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.
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. For a high-risk AI system
under EU AI Act Art. 12 and AMLR's five-year retention mandate, 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.
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/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. |
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.
- Continuous Monitoring — consumes the ScreeningResult evidence trail.
- UBO Determination — SDD flags drive UBO exemptions.
- Person Verification — verifies the typed NaturalPerson records.
- Sanctions Screening — produces the screening results persisted here.