Skip to content

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):

  1. 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.
  2. SAR (Suspicious Activity Report) — produced by the own-bus consumer of bank.aml.case_closed (MOD-018 publisher) when disposition == 'SAR_FILED', filed within 3 business days per FR-117. A safety-net sweeper (4-hour cadence) scans aml.aml_cases.case_status = 'SAR_FILED' rows still missing a submission_id and 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 in vitest.config.ts coverage.include.
  • Contract (tests/contract/regulatory-submission-publisher.test.ts): Zod schema ↔ JSON Schema parity for bank.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 wired
  • fr-118-schema-gate.test.ts — schema gate rejects malformed reports
  • fr-119-content-hash.test.ts — writer hashes deterministically; NULL content_hash is rejected by the DB
  • fr-120-endpoint-alert.test.ts — REGULATOR_ENDPOINT_ERROR classified
  • fr-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 + ack
  • adr-048-invariants.test.ts — UPDATE/DELETE rejected; CHECK constraints; NOT NULL content_hash; UNIQUE submission_reference
  • idempotency.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 check
  • pol-rep-003-log.test.ts — append-only migration assertions + no UPDATE/DELETE in source
  • pol-aml-001-auto.test.ts — sweeper schedule + both consumers' rules referenced in infra

Known v1 limitations

  1. Real regulator client deferred (h-1). MVP wires only the mock client; prod shim throws to prevent silent stubbing.
  2. 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.
  3. Calendar-day sweeper window. "3 business days" from FR-117 is approximated as 3 calendar days; SAR_SWEEPER_LOOKBACK_DAYS env var is the knob (j-2 ruling).
  4. 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).