ADR-0071: SAR/STR lifecycle + tipping-off controls
Status: Accepted Date: 2026-06-26 Supersedes: none Superseded by: none Deciders: Adrian (Soft4U), Claude Opus 4.8
Decision context:
- Latency: the lifecycle adds one mutable state row + one immutable audit row per transition; no hot-path cost (SAR work is officer/MLRO-initiated, not per-request). The customer boundary adds one pure scrub + one assert per portal response (a recursive walk of a small dict) — negligible, and a no-op in the normal case because the portal never reads the SAR table.
- Dependency surface: no new packages. Reuses goAML export (ADR-0027) for the artifact, the four-eyes guard (ADR-0070) for MLRO sign-off, and the immutable audit spine (ADR-0064). One state table (migration 068), one lifecycle service, one pure tipping-off module, one officer/MLRO router.
- Debuggability: every transition is an
audit_eventsrow carrying both actors, the from/to states, and the filing references; a blocked illegal transition raisesIllegalSARTransitionErrorwith the permitted set. - Reversibility: branch revert; additive. The table downgrade is clean (verified 068→067→068). No existing behaviour changes — the portal payload is unchanged for real customers (the scrubber removes only SAR-named fields, which the portal never produced).
- Blast radius: a new tenant-scoped table + a new officer/MLRO router; the customer portal gains a fail-closed output funnel. Nothing in the Temporal workflow changes.
Context
When a case is suspicious, an MLRO must escalate it into a Suspicious Activity / Transaction Report, obtain internal sign-off, and file it to the FIU — then defend that filing to a regulator years later. Two hard requirements sit on top of "generate the report":
- An auditable lifecycle. A SAR is not a one-shot document; it moves draft → internal review → approval → filing → acknowledgement, and who did what, when must be reconstructable (AMLR 5-year retention; EU AI Act Art. 12). The goAML report generation already existed (ADR-0027) but only as an artifact, with no lifecycle or sign-off around it.
- The tipping-off prohibition (AMLD Art. 39). Disclosing to the customer that a SAR exists — or even that one is being prepared — is a criminal offence. The customer-facing portal must therefore be structurally incapable of revealing SAR existence or state.
Decision
Build the lifecycle around the existing goAML report and enforce the tipping-off boundary, in three parts.
1. An explicit, no-skip SAR state machine (app/services/sar_service.py):
draft → pending_mlro → approved → submitted → acknowledged
└────────────────→ rejected
- draft — an officer raised the SAR;
GoAMLExportService.create_draft(ADR-0027) generates the goAML report as the draft artifact, linked viagoaml_draft_id. - pending_mlro — routed for MLRO sign-off.
- approved — the MLRO authorized filing. This is the gate on submission:
record_submissionis only reachable fromapproved(assert_transition_allowed). The MLRO must be a different actor from the raising officer — the four-eyesassert_distinct_approver(ADR-0070) is reused, so self-approval (SelfApprovalError) and a missing identity (AmbiguousApproverError) both fail closed before any state change. - submitted — a filing to the FIU is recorded (a non-empty
fiu_referenceis required — an unidentified filing is never recorded) with a timestamp. - acknowledged — the FIU acknowledged receipt.
- rejected — the MLRO declined; terminal.
assert_transition_allowed(current, target) is a pure, total guard over a transition table. It is fail
closed: an unknown/blank/indeterminate current state transitions to nothing, so an ambiguous SAR can never
be auto-advanced to submitted; and no state may be skipped (draft cannot jump to approved/submitted,
pending_mlro cannot be filed without an approval, approved cannot be acknowledged before submission). The
state row lives in a new suspicious_activity_reports table (migration 068; FORCE ROW LEVEL SECURITY,
tenant-scoped per ADR-0023, exactly like pending_decision_approvals / screening_results).
2. Tipping-off boundary (app/services/tipping_off.py), fail-closed default-deny. A customer surface must
never include SAR data. The boundary is a pure module: is_sar_field recognises the SAR/STR vocabulary
(sar*, suspicious_activity, fiu_reference, mlro_*, goaml_*, tipping_off, …) with _-delimited
token boundaries so innocent look-alikes ("fiduciary", "calendar", "registrar") never match;
scrub_customer_payload removes any SAR-named key at any depth (default-deny); and
assert_customer_payload_sar_free is the tripwire that raises TippingOffViolation if any SAR field
remains. The portal (get_portal_state) routes every customer response through customer_safe
(scrub-then-assert). The SAR table is read only by officer/MLRO surfaces; the portal never queries it, so
the funnel is a no-op in normal operation and a hard backstop against a future regression that merges SAR
state into a customer payload. Officer/MLRO endpoints (app/api/sar.py, all behind the officer JWT) expose
the full SAR via serialize_sar_for_officer — the only place SAR state is visible.
3. Both actors, every transition, audited (immutable). raise, submit_for_mlro, mlro_approve,
mlro_reject, record_submission, and acknowledge each append an audit_events row (ADR-0064) — the
approval/rejection events carry both the raising officer and the MLRO, the from/to states, and the
note/reason, fully reconstructable for a regulator.
Consequences
Positive
- A SAR now has a defensible end-to-end lifecycle with four-eyes-gated filing and a complete immutable trail (AMLR retention; EU AI Act Art. 12).
- The customer cannot be tipped off: SAR data is structurally barred from customer responses, fail-closed (AMLD Art. 39). The boundary is tested directly (an officer SAR view scrubbed to nothing in a customer payload) — a regulator-defensible control, not a convention.
- Reuses, rather than reinvents, the goAML artifact (ADR-0027), four-eyes (ADR-0070), and the audit spine.
Negative
- A second mutable state table the dashboard/MLRO UI must render and drive.
- The customer boundary scrubs by field-name; an officer-authored free-text field that narrated a SAR would
not be caught by key-scrubbing (free-text portal fields are separately sanitised —
_sanitize_task_for_portal— but officers must still not write SAR references into customer-visible text).
Neutral
- FIU submission is recorded, not transmitted — the actual FAÚ/CFI transport is out of scope (the goAML XML
- a recorded
fiu_referenceare the artifacts); wiring a real submission channel is additive onapproved.
- a recorded
Alternatives Considered
Alternative 1: fold the lifecycle into the existing goAML draft status column
Rejected — the goAML draft status (draft/validated/exported) is about the artifact, not the
regulatory filing lifecycle (MLRO sign-off, FIU submission, acknowledgement). Conflating them would couple
report editing to filing state and lose the four-eyes gate. The SAR gets its own state row; the goAML draft
remains the artifact it links to.
Alternative 2: enforce tipping-off by auditing/redacting at the API gateway
Rejected — a gateway redactor is opaque and easy to bypass when a new customer endpoint is added. A pure, unit-tested default-deny funnel applied at the customer serializer is explicit, cheap, and fails closed and loud; the structural guarantee (the portal never reads the SAR table) is the primary control, the funnel the backstop.
Alternative 3: allow MLRO approval and FIU submission in one step
Rejected — collapsing approved → submitted hides the moment of authorization from the moment of filing,
which a regulator may need distinguished (and which a real FIU transport will need as separate, retryable
steps). Keeping each transition discrete preserves the audit granularity and the fail-closed gate.