Technical design — MOD-019 Regulatory report submission¶
Module: MOD-019 — Regulatory report submission
System: SD03 — AML Transaction Monitoring Platform
Repo: bank-aml
FR scope: FR-117, FR-118, FR-119, FR-120, FR-146, FR-147, FR-148
NFR scope: NFR-010, NFR-019, NFR-024
Policies satisfied: AML-008 (AUTO), REP-003 (LOG), AML-001 (AUTO)
Author: AI agent (Claude Opus 4.7)
Date: 2026-05-18
Dependencies: MOD-018 (Built — bank-aml), MOD-026 (Built — bank-payments, SD04), MOD-103 (Built — Neon), MOD-104 (Built — AWS bootstrap)
Stage covered: designed; deploy gated on two handoffs (MOD-103-cross-schema-grant-ifti-cmir-queue.handoff.md, MOD-104-bank-payments-events-putrule-grant.handoff.md).
Objective¶
MOD-019 is the regulatory report submission system for SD03. It composes and files three classes of report to AUSTRAC (Australia) / RBNZ (New Zealand):
- IFTI (International Funds Transfer Instruction) and CMIR
(Cross-Border Movement of Monetary Instruments) — produced by the
cross-bus consumer of
bank.payments.ifti_threshold_crossed(SD04 MOD-026 publisher), filed next business day (IFTI) / 10 business days (CMIR) per FR-146. - SAR (Suspicious Activity Report) — produced by the own-bus
consumer of
bank.aml.case_closed(MOD-018 publisher) whendisposition == 'SAR_FILED', filed within 3 business days per FR-117. A safety-net sweeper (4-hour cadence) scansaml.aml_cases.case_status = 'SAR_FILED'rows still missing asubmission_idand re-runs the SAR pipeline — recovery from any bus-failure window.
Every submission lands as an immutable row in aml.regulatory_submissions
(ADR-048 append-only via trg_regulatory_submissions_immutable),
records submission_reference, submitted_at, regulator_acknowledged_at,
and a SHA-256 content_hash over the canonicalised payload (FR-119 /
j-1 amendment 2026-05-18 wiki commit 94eaabb7). MOD-019 then publishes
bank.aml.regulatory_submission_completed to the bank-aml bus for
downstream consumers (MOD-076 observability primary; MOD-047, MOD-048,
MOD-042 in catalogue).
Architecture¶
┌────────────────────────────┐
│ bank-payments EventBridge │ cross-bus rule on the
│ bus (SD04 MOD-026) │ producer bus → fans into
└────────────┬───────────────┘ bank-aml domain
│ bank.payments.ifti_threshold_crossed
▼
┌────────────────────────────┐
│ ifti-cmir-consumer Lambda │ ADOT; BankAmlRole;
│ src/handlers/ │ Q-prime makeIdempotent on
│ ifti-cmir-consumer.ts │ inner impl (Postgres layer
└────────┬───────────────────┘ via @bank-core/idempotency)
│ withTransaction
▼
┌───────────────────────────────────────────────────────────┐
│ buildIftiReport / buildCmirReport (pure) │
│ ↳ validateReport (FR-118/147 Zod gate) │
│ ↳ regulatorClient.submit + fetchAck │
│ ↳ insertSubmission (computeContentHash SHA-256) │
│ ↳ markIftiCmirQueueSubmitted (CROSS-SCHEMA UPDATE) │
│ ↳ publishRegulatorySubmissionCompleted │
└───────────────────────────────────────────────────────────┘
┌────────────────────────────┐
│ bank-aml EventBridge bus │ own bus — no IAM widening
└────────────┬───────────────┘
│ bank.aml.case_closed
▼
┌────────────────────────────┐
│ sar-consumer Lambda │ acts only on
│ src/handlers/ │ disposition=='SAR_FILED';
│ sar-consumer.ts │ drops other dispositions
└────────┬───────────────────┘ with no-sar-required log
│
▼
┌───────────────────────────────────────────────────────────┐
│ loadSarSourceCase → buildSarReport → validateReport │
│ ↳ submit / fetchAck / insertSubmission │
│ ↳ publishRegulatorySubmissionCompleted │
└───────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ EventBridge schedule rate(15 minutes) ENABLED │
│ → regulator-ack-poller Lambda │
│ DLQ + CloudWatch alarm on Errors > 0 │
│ Polls regulator for pending acks (mock = no-op MVP) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ EventBridge schedule rate(4 hours) ENABLED │
│ → sar-sweeper Lambda │
│ DLQ + CloudWatch alarm on Errors > 0 │
│ WHERE case_status = 'SAR_FILED' │
│ AND submission_id IS NULL │
│ AND thirty_day_deadline > now() - 3 days │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────┐
│ bank-aml EventBridge bus │
│ bank.aml.regulatory_ │ → MOD-076 (primary),
│ submission_completed │ MOD-047, MOD-048, MOD-042
└──────────────────────────┘
Regulator client (h-1 ruling)¶
The MVP wires only a mock RegulatorClient (regulator-client-mock.ts)
plus a prod shim (regulator-client-factory.ts) that throws
PROD_REGULATOR_CLIENT_NOT_IMPLEMENTED if STAGE === 'prod' and
REGULATOR_CLIENT !== 'mock'. Wiring the real AUSTRAC / RBNZ HTTP
endpoints is deferred (h-1) — the shim is a guardrail preventing the
mock from quietly accepting prod submissions.
Append-only model (V2 split)¶
aml.regulatory_submissions is append-only (ADR-048). Mock ack is
returned synchronously by mockRegulatorClient.fetchAck, so rows land
as submission_status = 'ACKNOWLEDGED' at INSERT. For a real (async)
regulator a V2 migration will split into aml.submission_events so the
ack-poller can append rows; for MVP the poller logs progress only.
FR coverage¶
| FR | Where |
|---|---|
| FR-117 — file SAR within 3 business days of SAR decision | sar-consumer.ts consumes bank.aml.case_closed and immediately files. Safety-net: sar-sweeper.ts runs every 4 hours, scans aml.aml_cases.case_status = 'SAR_FILED' rows with submission_id IS NULL and thirty_day_deadline > now() - 3 days (j-2 ruling: case_status not disposition; calendar days approximate business days; AppConfig knob SAR_SWEEPER_LOOKBACK_DAYS defaults to 3). |
| FR-118 — generate report in regulator-mandated format and validate against published schema before submission | services/report-validator.ts runs Zod schemas (IftiReportSchema, CmirReportSchema, SarReportSchema) at the boundary. Failure → REPORT_SCHEMA_VALIDATION_FAILED + log report_schema_validation_failed. Real AUSTRAC/RBNZ XSD validation lands with the real client (h-1 deferred). |
| FR-119 — record submission timestamp, reference, and content hash for every report, retain ≥7 years | services/regulatory-submission-writer.ts::computeContentHash (SHA-256 over canonical-JSON(payload)) is stored in aml.regulatory_submissions.content_hash NOT NULL (j-1 amendment). Append-only trigger + Iceberg CDC (MOD-042) cover the 7-year retention. |
| FR-120 — alert compliance team within 1 hour if endpoint returns an error or ack timeout | CloudWatch alarm bank-aml-mod-019-regulator-endpoint-error-{stage} on the regulator_endpoint_error_total EMF metric (1-hour SLA). Plus per-Lambda Errors > 0 alarms on all four functions. |
| FR-146 — submit IFTI/CMIR to AUSTRAC (AU) / RBNZ (NZ) in regulator format within statutory window | ifti-cmir-consumer.ts fires on each cross-bus event — no batching delay. Jurisdiction → regulator mapping is in ifti-builder.ts / cmir-builder.ts (AU → AUSTRAC, NZ → RBNZ). Statutory windows enforced upstream by MOD-026's threshold logic. |
| FR-147 — validate every report against regulator schema; reject invalid + alert | Same gate as FR-118. A schema rejection throws ValidationError(REPORT_SCHEMA_VALIDATION_FAILED) from the handler, which classifies as non-retryable; the EventBridge target's DLQ catches it, and the per-function Errors > 0 alarm fires. |
| FR-148 — record submission reference, timestamp, and ack receipt for IFTI/CMIR; retain ≥7 years | Same writer service as FR-119. The three audit fields are mandatory columns: submission_reference UNIQUE, submitted_at, regulator_acknowledged_at. |
Policy coverage¶
AML-008 AUTO — IFTI/CMIR auto-submission¶
Mechanism: Both the IFTI/CMIR path and the SAR path are fully
event-driven — EventBridge rules invoke Lambdas with no manual
intervention. No "manual review" hook exists in source; the source-token
scan in tests/policy/pol-aml-008-auto.test.ts enforces the absence of
manual_submit / await_human_review / require_approval / skip_auto_submit
patterns.
REP-003 LOG — Regulatory submission records maintained¶
Mechanism: aml.regulatory_submissions is append-only via
trg_regulatory_submissions_immutable (V001 migration). submitted_at,
regulator_acknowledged_at, and regulator_reference are persisted at
INSERT. tests/policy/pol-rep-003-log.test.ts asserts the migration
installs the trigger and that no source path UPDATEs / DELETEs the
table; tests/integration/adr-048-invariants.test.ts exercises the
trigger against deployed Postgres.
AML-001 AUTO — Reporting obligations without manual prompts¶
Mechanism: Both consumers are EventBridge-triggered; the SAR
sweeper (rate(4 hours)) is the safety net that closes any
bus-failure-window gap. No reminder-the-analyst pattern in source —
tests/policy/pol-aml-001-auto.test.ts enforces this and verifies the
infra includes the sweeper schedule + both consumers' rules.
Database tables (aml schema additions)¶
aml.regulatory_submissions — MOD-019 V001 (NEW)¶
Authoritative shape from SD03 data model with the j-1 (2026-05-18 wiki
commit 94eaabb7) content_hash text NOT NULL amendment.
| column | type | notes |
|---|---|---|
id |
uuid PK | |
submission_reference |
text UNIQUE NOT NULL | idempotency anchor ({IFTI\|CMIR\|SAR}-{ref}) |
report_type |
text CHECK | IFTI / CMIR / SAR / SMR / TTR / SUSPICIOUS_MATTER |
jurisdiction |
char(2) CHECK | NZ / AU |
regulator |
text | AUSTRAC / RBNZ / FMA |
subject_party_id |
uuid | cross-domain ref to SD02 (nullable for bulk reports) |
related_case_id |
uuid FK → aml.aml_cases(id) |
SAR/SMR only |
payment_ids |
jsonb | |
submitted_at |
timestamptz | |
submission_status |
text CHECK | DRAFT / PENDING_REVIEW / APPROVED / SUBMITTED / ACKNOWLEDGED / REJECTED |
regulator_reference |
text | |
regulator_acknowledged_at |
timestamptz | |
prepared_by |
text NOT NULL | usually "MOD-019" |
approved_by |
text | |
report_payload |
jsonb NOT NULL | |
content_hash |
text NOT NULL | j-1 / FR-119 — SHA-256 hex over canonical-JSON(payload) |
amends_submission_id |
uuid FK self | |
created_at |
timestamptz |
Indexes: report_type+jurisdiction, submitted_at (partial WHERE submitted_at IS NOT NULL), party_id (partial), status (partial WHERE status IN DRAFT/PENDING_REVIEW/APPROVED), idx_regulatory_submissions_awaiting_ack (ack-poller hot path), idx_regulatory_submissions_related_case_id (SAR-sweeper hot path).
Trigger: trg_regulatory_submissions_immutable BEFORE UPDATE OR DELETE — rejects via aml.fn_immutable_row() (re-defined defensively in case MOD-016 V003 hasn't run yet — same parallel-CI hardening pattern as MOD-018 V001 reject_mutation).
Retro-FK: V001 also adds aml_cases.submission_id REFERENCES aml.regulatory_submissions(id) DEFERRABLE INITIALLY DEFERRED in a DO block guarded by IF EXISTS / NOT EXISTS — order-tolerant under parallel deploys.
Grants: SELECT, INSERT to aml_app_user; SELECT to aml_readonly.
Cross-schema dependency (handoff required)¶
MOD-019 reads + UPDATEs payments.ifti_cmir_queue (owned by SD04
MOD-026). The required GRANT is filed at
docs/handoffs/MOD-103-cross-schema-grant-ifti-cmir-queue.handoff.md.
Event types¶
Registered in src/lib/event-types.ts per ADR-031 (declared registry,
no free-text strings):
| event_type | when |
|---|---|
ifti_threshold_consumed |
top of ifti-cmir-consumer |
ifti_submission_started / _succeeded / _failed |
IFTI submission lifecycle |
cmir_submission_started / _succeeded / _failed |
CMIR submission lifecycle |
case_closed_consumed |
top of sar-consumer |
case_closed_no_sar_required |
disposition ≠ SAR_FILED (drop with log) |
sar_submission_started / _succeeded / _failed |
SAR submission lifecycle |
ack_poll_started / _completed / _failed, ack_received, ack_pending |
ack-poller |
sar_sweeper_started / _completed / _recovered |
sweeper |
ifti_cmir_queue_updated / _update_failed |
cross-domain UPDATE |
submission_event_publish_succeeded / _failed |
publish bank.aml.regulatory_submission_completed |
report_schema_validation_failed |
FR-118/147 gate trip |
trace_id_missing_from_upstream, validation_failed, transient_failure, internal_error, schema_validation_failed, idempotency_replay |
observability |
SSM outputs¶
Published by infra/ssm-outputs.ts under the canonical
/bank/{env}/... namespace (j-non-blocking-2 ruling — {env} not
{stage}):
| Path | Value | Consumers |
|---|---|---|
/bank/{env}/mod-019/lambda/ifti-cmir-consumer-arn |
function ARN | ops, smoke tests |
/bank/{env}/mod-019/lambda/sar-consumer-arn |
function ARN | ops, smoke tests |
/bank/{env}/mod-019/lambda/regulator-ack-poller-arn |
function ARN | ops |
/bank/{env}/mod-019/lambda/sar-sweeper-arn |
function ARN | ops |
/bank/{env}/mod-019/db/submissions-table |
aml.regulatory_submissions |
dbt / observability |
Events published¶
bank.aml.regulatory_submission_completed — see
contract/events/regulatory-submission-events.ts and
schemas/bank.aml.regulatory_submission_completed.json. Per ADR-031 the
schema includes trace_id (j-3 ruling — even though the wiki catalogue
field list omits it for historical reasons).
Events consumed¶
| Event | Source bus | Producer |
|---|---|---|
bank.payments.ifti_threshold_crossed |
bank-payments | SD04 MOD-026 |
bank.aml.case_closed |
bank-aml | MOD-018 |
The cross-bus EventBridge rule on bank-payments requires the IAM
grant filed at
docs/handoffs/MOD-104-bank-payments-events-putrule-grant.handoff.md.
Test approach¶
Per the test approach standard:
- Unit (
tests/unit/services/*): pure builders + validator + mock client. Coverage target ≥80% line, ≥80% function on the five files invitest.config.ts coverage.include. - Contract (
tests/contract/regulatory-submission-publisher.test.ts): Zod schema ↔ JSON Schema parity forbank.aml.regulatory_submission_completed(required fields, additionalProperties false, enum agreement). - Integration (
tests/integration/): fr-117-sar-submission.test.ts— plant a case_status=SAR_FILED row, fire the consumer, assert SAR row landed with related_case_id wiredfr-118-schema-gate.test.ts— schema gate rejects malformed reportsfr-119-content-hash.test.ts— writer hashes deterministically; NULL content_hash is rejected by the DBfr-120-endpoint-alert.test.ts— REGULATOR_ENDPOINT_ERROR classifiedfr-146-ifti-cmir-submission.test.ts— full IFTI/CMIR pipeline incl. cross-schema UPDATE (will fail until MOD-103 grant lands — expected)fr-148-record-keeping.test.ts— writer stores ref + submitted_at + ackadr-048-invariants.test.ts— UPDATE/DELETE rejected; CHECK constraints; NOT NULL content_hash; UNIQUE submission_referenceidempotency.test.ts— duplicate delivery of the same event is a no-op (PostgresPersistenceLayer /aml.idempotency_records)observability.test.ts— sar-consumer log group exposes ADR-031 mandatory fields- Policy (
tests/policy/): pol-aml-008-auto.test.ts— AUTO source-token scan + handler checkpol-rep-003-log.test.ts— append-only migration assertions + no UPDATE/DELETE in sourcepol-aml-001-auto.test.ts— sweeper schedule + both consumers' rules referenced in infra
Known v1 limitations¶
- Real regulator client deferred (h-1). MVP wires only the mock client; prod shim throws to prevent silent stubbing.
- Single-table ack model. The append-only trigger blocks UPDATE,
so async ack from a real regulator (when prod client lands) needs a
V2 migration to split into
aml.submission_events. MVP relies on the mock client's synchronous ack. - Calendar-day sweeper window. "3 business days" from FR-117 is
approximated as 3 calendar days;
SAR_SWEEPER_LOOKBACK_DAYSenv var is the knob (j-2 ruling). - trace_id on published event is in the schema but the wiki event catalogue field list omits it. Catalogue will be updated separately (j-3 ruling).