Skip to main content

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_statusadditional_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 singular nationality is retained and synced to nationalities[0].
  • PEP classification via the PepClassification enum (not_pep / pep / rca) plus rca_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 legacy is_pep bool is kept and synced toward the stronger signal.
  • place_of_birth — AMLR-mandatory, previously hardcoded None in 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:

FieldPurpose
register_nameFirst-class official register name, distinct from legal_name.
registered_address vs principal_place_of_businessTheir divergence is a risk signal.
is_regulated_entity, listed_on_regulated_marketDrive 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

FileResponsibility
backend/alembic/versions/066_audit_events_immutable.pyGuard trigger + REVOKE UPDATE,DELETE + FK CASCADERESTRICT on audit_events.
backend/app/services/dissolved_entity_gate.pyPure evaluate_dissolved_block / is_terminal_status / TERMINAL_STATUSES.
backend/app/services/case_decisions_service.pyget_company_status / extract_company_status; persist_signal_event for the override.
backend/app/api/case_decisions.pyapprove_requirements 409/400/override wiring; delete_case 409.
backend/app/services/screening_result_service.pypersist_screening_results / list_screening_results evidence trail.
packages/trustrelay-models/.../screening_result.pyScreeningResult + ScreeningListType enum (incl. wanted).
packages/trustrelay-models/.../canonical_entities.pyPersonSchema (nationalities, place_of_birth) + PepStatus / PepClassification.
packages/trustrelay-models/.../subject_entity.pyTyped SubjectEntity with address_divergence / sdd_eligible.
packages/trustrelay-models/.../purpose_profile.pyPurposeProfile + StatedPurpose enum + SoF/SoW.
  • 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_events immutability.
  • 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.