SAR/STR Lifecycle & Tipping-Off Boundary
Trust Relay implements the Suspicious Activity / Suspicious Transaction Report (SAR/STR) lifecycle as a fail-closed, no-skip state machine with MLRO four-eyes on the filing decision, plus a runtime tipping-off boundary that keeps investigation internals off every customer-facing surface. This page documents both, and the SAR-first customer-contact gate that ties them together.
Regulatory basis: AMLD Article 33 (obligation to report), Article 39 (prohibition of tipping-off), and AMLR/6AMLD record-keeping (5-year retention of the SAR audit trail). Design decisions: ADR-0071 (lifecycle + gate) and ADR-0070 (four-eyes, reused for the MLRO filing decision).
Lifecycle state machine
SARStatus (app/services/sar_service.py) is a five-state forward-only machine plus a
reject branch:
The machine is no-skip: an unknown or out-of-order transition resolves to nothing
(the permitted-transitions set is <none — terminal> for terminal states), so a SAR can
never be auto-submitted or jump from draft to submitted. Every transition writes an
immutable audit_events row (ADR-0064) recording the actor and both parties on the
four-eyes steps.
Endpoints (app/api/sar.py)
All state-changing routes are per-case and RBAC-gated (app/api/deps/permissions.py):
officers hold SAR_RAISE; SAR_APPROVE and SAR_FILE are mlro+ only. The
reportability assessment — the determination that unlocks the SAR-first
customer-contact gate — is deliberately SAR_APPROVE (MLRO-level), so an officer cannot
self-clear the gate. The GET list is authenticated but not permission-gated (any
tenant user may read); tighten it to CASE_READ if SAR visibility must be role-limited.
| Method | Path | Permission | Purpose |
|---|---|---|---|
POST | /cases/{case_id}/sar | SAR_RAISE | Raise a draft SAR |
GET | /cases/{case_id}/sar | authenticated (no permission gate) | List SARs for the case |
POST | /cases/{case_id}/sar/{sar_id}/submit-for-mlro | SAR_RAISE | draft → pending_mlro |
POST | /cases/{case_id}/sar/{sar_id}/assessment | SAR_APPROVE (mlro+) | Record the structured reportability assessment (MLRO determination) |
POST | /cases/{case_id}/sar/{sar_id}/mlro-approve | SAR_APPROVE (mlro+) | pending_mlro → approved (four-eyes) |
POST | /cases/{case_id}/sar/{sar_id}/mlro-reject | SAR_APPROVE (mlro+) | pending_mlro → rejected (four-eyes) |
POST | /cases/{case_id}/sar/{sar_id}/record-submission | SAR_FILE (mlro+) | approved → submitted, capture FIU channel |
POST | /cases/{case_id}/sar/{sar_id}/acknowledge | SAR_FILE (mlro+) | submitted → acknowledged, capture FIU reference |
The MLRO approve/reject steps call assert_distinct_approver (reusing
app/services/maker_checker.py), so the person who approves the filing must differ from the
officer who raised it — the SAR filing decision is itself four-eyes.
Structured reportability assessment
Before a decline/follow-up can reach the customer, an MLRO must record a structured
reportability assessment (app/services/sar_assessment.py, DB columns via migration
071). Its AssessmentOutcome is one of required, not_required, or
further_info_needed, and it captures the onboarding_interaction disposition
(decline_no_sar, decline_sar_filed, defer_edd, other). This artifact is rendered
into the case pack (templates/sar_assessment.html) and its inclusion is fail-closed:
if the assessment cannot be built, the pack build fails rather than shipping a pack that
silently omits it.
SAR-first customer-contact gate
The single most important control here is that no customer contact happens on a case with an open SAR predicate until a signed MLRO assessment exists. Contacting a customer whose case shows a criminal/enforcement signal — asking for "just one more document" — risks tipping off a subject under investigation, which is a criminal offence under AMLD Art. 39.
The trigger and the unlock are single-sourced in app/services/sar_assessment.py
(has_sar_trigger_signal / the unlock predicate) and enforced by
app/services/customer_contact_gate.py (assert_customer_contact_allowed, raising
SARFirstGateError → HTTP 409/423) at four surfaces:
- Officer
reject/follow_updecisions (app/api/case_decisions.py:187) - Evidence-request PDF issuance (
app/api/compliance_docs.py:113) - The customer portal (
app/api/portal.py— hold sites return 423) - The portal chat agent (
app/api/agent.py)
There is no officer override — the gate unlocks only when an MLRO determination (assessment or a decided lifecycle state) exists. This is unlock-by-determination, not unlock-by-permission.
Tipping-off boundary (default-deny scrub)
Independently of the gate, every customer-facing payload passes through a default-deny
field scrubber (app/services/tipping_off.py). scrub_customer_payload removes every
SAR-indicative key at any depth of the response, and assert_customer_payload_sar_free
runs on the scrubbed payload as a tripwire — it normally passes, but raises
TippingOffViolation the instant the scrubber's vocabulary and a leaking field diverge.
Because the boundary is default-deny on the field name, a newly-added internal field is
withheld from the customer by default rather than leaking until someone remembers to add it
to a denylist.
:::note Denylist vs. allowlist The scrubber keys off SAR-indicative field names. A safer-still posture is an explicit allowlist of customer-safe fields; see the known gaps register for the residual items the review flagged around portal payload shaping. :::
Officer UI
The officer drives the SAR flow from SarPanel.tsx (Regulatory Filing tab) — raise → submit
to MLRO → record FIU reference → acknowledgement — and the maker-checker hold renders in
PendingSecondApproval.tsx. See State Machine → Decision Gates
for how the SAR-first gate blocks the decision transition, and
Case-Pack Export for how the SAR assessment is sealed into the
regulator pack.