Skip to main content

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_events row carrying both actors, the from/to states, and the filing references; a blocked illegal transition raises IllegalSARTransitionError with 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":

  1. 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.
  2. 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 via goaml_draft_id.
  • pending_mlro — routed for MLRO sign-off.
  • approved — the MLRO authorized filing. This is the gate on submission: record_submission is only reachable from approved (assert_transition_allowed). The MLRO must be a different actor from the raising officer — the four-eyes assert_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_reference is 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_reference are the artifacts); wiring a real submission channel is additive on approved.

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.