Technical design — MOD-016 Rule-based typology engine¶
Module: MOD-016 — Rule-based typology engine
System: SD03 — AML Transaction Monitoring Platform
Repo: bank-aml
FR scope: FR-105, FR-106, FR-107, FR-108
NFR scope: NFR-010, NFR-011, NFR-021, NFR-024
Policies satisfied: AML-005 (AUTO), AML-001 (LOG), AML-008 (AUTO)
Author: AI agent (Claude Opus 4.7)
Date: 2026-05-01
Dependencies: MOD-104 (Built), MOD-103 (Built), MOD-042 (Built), MOD-002 (Deployed)
Stage covered: designed; deploy blocked on the cross-bus IAM grant — see docs/handoffs/MOD-104-cross-bus-grant.handoff.md.
Objective¶
MOD-016 evaluates every committed bank-core posting against a configurable library of FATF AML typology rules — structuring, rapid round-trip movement, high-risk-jurisdiction transfers, cash-threshold breaches — and raises an alert in aml.aml_alerts plus a bank.aml.alert_raised event for every rule breach. Rules and their thresholds/lookback windows are managed in AWS AppConfig per ADR-033 and FR-107 (changes effective within 5 minutes without a code deploy). Every rule execution — pass or alert — is recorded in the immutable aml.rule_executions audit log retained for at least 7 years per FR-108 / AML-001.
Architecture¶
┌───────────────────────────┐
│ bank-core EventBridge bus │ provisioned by MOD-104
└─────────────┬─────────────┘
│ bank.core.posting_completed
│ (cross-bus rule provisioned by THIS module)
▼
┌───────────────────────┐
│ MOD-016 evaluator λ │ ADOT layer; BankAmlRole;
│ src/handlers/ │ reserved concurrency 50;
│ evaluator.ts │ VPC-attached for Neon.
└──────────┬────────────┘
│ withTransaction
▼
┌──────────────────────────────────┐
│ aml.posting_history_cache │ INSERT...ON CONFLICT
│ aml.rule_executions │ per rule, idempotent
│ aml.aml_alerts (when ALERT) │ UNIQUE (payment, rule, version)
│ aml.typology_matches (when ALERT)│
└──────────┬───────────────────────┘
│ COMMIT
│ (publish-last)
▼
┌──────────────────────────┐
│ bank-aml EventBridge bus │
│ bank.aml.alert_raised │ — schema-validated by MOD-043
└──────────────────────────┘
↑
│ AppConfig (live rule parameters)
│ ADR-033 — 5-minute bake = FR-107 SLA
┌──────────┴──────────────┐
│ AppConfig application: │ one Configuration Profile per rule_id;
│ bank-aml-mod-016-{env} │ Lambda fetches via GetLatestConfiguration
│ 4 profiles (one/rule) │ with cold-start cache + AppConfig deploy.
└─────────────────────────┘
The hot path is single-Lambda dual-purpose: the evaluator IS the cache writer. INSERT into aml.posting_history_cache, then read the historical window for the same party_id, then evaluate every enabled rule. All inside one Postgres transaction. The transaction commits before any EventBridge publish.
This is the Path A lookback architecture confirmed by the orchestrator on 2026-04-30. Snowflake/Iceberg is not on the alert-generation path — it's offline rule-tuning only.
FR coverage¶
| FR | Where |
|---|---|
| FR-105 — evaluate every transaction against typology library, p99 ≤ 1 s | evaluator.ts consumes bank.core.posting_completed; rule-engine.evaluatePosting() runs all enabled rules in one transaction; pure-logic typology evaluators are sub-ms; the dominant cost is the posting_history_cache write + index read. Provisioned-concurrency on the evaluator Lambda + reserved concurrency 50 protect cold-start tail. |
| FR-106 — alert includes rule_id, threshold, transaction reference, customer_id | audit-writer.insertAlert populates aml.aml_alerts with party_id (customer_id), typology_code, risk_score, trigger_transactions[], trigger_window_*, rule_version. audit-writer.insertTypologyMatch stores rule_id, observed_value, threshold_value, transaction_ids[]. The bank.aml.alert_raised event payload mirrors these fields. |
| FR-107 — change rule parameters without code deploy, ≤ 5 minutes | AppConfig profile per rule (infra/appconfig.ts); update-rule-config.ts writes to aml.rule_config_history with change_reason NOT NULL; AppConfig deployment strategy is linear with 5-minute final bake. AppConfig deployment itself is initiated via the orchestrator's runbook (a follow-up automation lives in handoff ‘MOD-016-appconfig-deployment-automation’). |
| FR-108 — audit log of every rule execution result, 7-year retention | audit-writer.insertRuleExecution writes one immutable row per (payment_id, rule_id, rule_version) to aml.rule_executions. The V001 BEFORE-row trigger rejects UPDATE/DELETE/TRUNCATE on every role. CloudWatch Logs hot-retention is 90 d; cold archival via MOD-076 to S3 covers the full 7-year window. The Postgres row IS the durable audit record — logs are operational. |
| NFR-010, NFR-011 — automated regulatory submissions | Indirect — rule eval is the trigger upstream of MOD-019. AML-005 AUTO is satisfied here. |
| NFR-021 — fraud-scoring p99 ≤ 200 ms | This module is AML-not-fraud, but the typology evaluators run as pure JS in-process; per-rule eval p99 measured at < 5 ms in unit-test harness (Vitest). |
| NFR-024 — audit log mutability = 0 | Triggers + revoked grants on rule_executions, rule_config_history, posting_history_cache. Verified by pol-aml-001-log-immutability.test.ts. |
Policy coverage¶
AML-005 AUTO — Transaction monitoring policy¶
Mechanism: every bank.core.posting_completed event triggers the evaluator; no per-event sampling, no skip flag, no manual disable path. The cross-bus rule's pattern is {"source":["bank.core"],"detail-type":["posting_completed"]} — no detail filter narrows the set.
AUTO source-level test: tests/policy/pol-aml-005-auto.test.ts walks src/ and grep-rejects sample_rate, skip_evaluation, bypass_rule, disable_rule, monitor_off, skip_typology. Adding a token requires removing it from the contract and from this list — a wiki change, not a code change.
Plus: count-parity check — every cached posting in the last 5 min has at least one aml.rule_executions row per enabled rule.
AML-001 LOG — AML programme; documented monitoring rules; immutable audit¶
Mechanism: rule definitions in aml.rule_definitions; live config in AppConfig with full version history both in AppConfig and in aml.rule_config_history (the latter is bank-native and queryable without AWS console access). Every execution recorded in aml.rule_executions. UPDATE/DELETE on the audit tables raise integrity_constraint_violation from the V001 BEFORE-row trigger.
LOG immutability test: tests/policy/pol-aml-001-log-immutability.test.ts plants tries to UPDATE/DELETE on each of rule_executions, rule_config_history, posting_history_cache and asserts the trigger fires for both DML verbs against bank_aml_app_user.
AML-008 AUTO — Cross-border transfers flagged for IFTI/CMIR¶
Mechanism: HIRISK_GEO_001 rule registered (V002 seed) and enabled by default. Every posting whose counterparty_country is on the FATF higher-risk list (live values in AppConfig) raises an UNUSUAL_CROSS_BORDER alert above the floor amount. The IFTI/CMIR submission itself is MOD-026's responsibility downstream; MOD-016 generates the AML signal that drives the case workflow.
AUTO source-level test: tests/policy/pol-aml-008-auto.test.ts confirms HIRISK_GEO_001 is registered in the V002 seed and that source contains no skip_cross_border / bypass_cross_border tokens.
Database tables (aml schema)¶
| Table | Owner | Read | Write | Policy |
|---|---|---|---|---|
aml.posting_history_cache |
MOD-016 | evaluator | evaluator (INSERT...ON CONFLICT) | append-only trigger |
aml.rule_definitions |
MOD-016 | evaluator (rule list), update-rule-config (audit lookup) | V002 migration; future upsert-rule handler | mutable on enabled/retired_at only |
aml.rule_executions |
MOD-016 | query-executions handler | evaluator | append-only trigger |
aml.rule_config_history |
MOD-016 | query-executions handler (deferred) | update-rule-config | append-only trigger |
aml.aml_alerts |
MOD-016 (creates), MOD-018 (transitions) | evaluator (no read), MOD-018, MOD-074 | INSERT by MOD-016; UPDATE on alert_status, reviewed_at, closed_at, assigned_to, case_id, updated_at by MOD-018 |
mutable status fields |
aml.typology_matches |
MOD-016 | MOD-018 | evaluator (one per ALERT) | append-only trigger |
aml.idempotency_keys |
MOD-016 | sync handlers | sync handlers | TTL sweep |
Migrations: db/migrations/V001__aml_schema_and_mod_016_tables.sql, V002__seed_rule_definitions.sql, V003__adr_048_invariants.sql. The Flyway pipeline runs against the direct Neon host (per MOD-103) using bank_aml_migrate_user.
DB-enforced invariants (ADR-048)¶
Authoritative register lives at SD03-aml-monitoring.md §DB-enforced invariants. MOD-016 owns these constraints:
Immutability triggers (Category 1)¶
| Table | Trigger | Function |
|---|---|---|
aml.rule_executions |
trg_rule_executions_immutable |
aml.fn_immutable_row() (SECURITY DEFINER, owned by bank_aml_migrate_user) |
aml.rule_config_history |
trg_rule_config_history_immutable |
same |
aml.typology_matches |
trg_typology_matches_immutable |
same |
Exception — aml.posting_history_cache: Documented on the wiki as the explicit exception. The retention sweeper (deferred to v2) hard-deletes rows older than the AppConfig-configured window. Insert-only semantics enforced by writePostingToCache, not by a trigger. bank_aml_app_user has DELETE granted on this table only.
CHECK constraints (Category 1)¶
| Table | Constraint | Definition |
|---|---|---|
aml.aml_alerts |
chk_aml_alerts_risk_score |
risk_score IS NULL OR (risk_score >= 0 AND risk_score <= 100) |
aml.typology_matches |
chk_typology_matches_confidence |
confidence IS NULL OR (confidence >= 0 AND confidence <= 100) |
Not DB-enforced (Category 3 — config-driven)¶
- Rule activation gate (
rule_definitions.enabled) — operational toggle, not a structural invariant. - AppConfig live rule parameters — Cat 3 by definition (config-driven).
Negative tests¶
tests/integration/adr-048-invariants.test.ts per ADR-048 §5: every immutability trigger and CHECK constraint has a negative test that attempts the violation inside a transaction, asserts the expected exception, and rolls back.
SSM outputs¶
| Output | SSM path | Consumed by |
|---|---|---|
| Internal API base URL | /bank/{env}/mod-016/api/base-url |
MOD-018 (rule context lookups), MOD-074 (back-office rule UI), MOD-019 (rule version stamp on submissions) |
| Evaluator Lambda ARN | /bank/{env}/mod-016/lambda/evaluator-arn |
Operational tooling, replay scripts |
| Update-config Lambda ARN | /bank/{env}/mod-016/lambda/update-config-arn |
Back-office UI direct invoke (deferred) |
| List-rules Lambda ARN | /bank/{env}/mod-016/lambda/list-rules-arn |
MOD-074 |
| Query-executions Lambda ARN | /bank/{env}/mod-016/lambda/query-executions-arn |
MOD-074 (rule audit query view) |
| AppConfig application ID | /bank/{env}/mod-016/appconfig/application-id |
Lambda env var resolution, ops tooling |
| AppConfig environment ID | /bank/{env}/mod-016/appconfig/environment-id |
Same |
| Error-code enumeration | /bank/{env}/mod-016/error-codes |
MOD-018 alert handler error mapping |
| Current rule package version | /bank/{env}/mod-016/rule-version-current |
MOD-019 (regulatory submission stamping), MOD-074 (display) |
SSM inputs (read at deploy time)¶
| SSM path | Source | Use |
|---|---|---|
/bank/{env}/eventbridge/bank-aml/arn |
MOD-104 | Publish bank.aml.alert_raised |
/bank/{env}/eventbridge/bank-aml/dlq-arn |
MOD-104 | Cross-bus rule DLQ |
/bank/{env}/eventbridge/bank-core/arn |
MOD-104 | Cross-bus rule source bus |
/bank/{env}/iam/lambda/bank-aml/arn |
MOD-104 | BankAmlRole — Lambda execution |
/bank/{env}/network/vpc-id, /private-subnet-ids |
MOD-104 | Lambdas VPC-attach for Neon |
/bank/{env}/observability/adot-layer-arn |
MOD-076 | OTel/X-Ray instrumentation layer |
/bank/{env}/sns/alerts/arn |
MOD-104 | CloudWatch alarm destinations |
/bank/{env}/mod043/schema-registry/name |
MOD-043 | Upload bank.aml.alert_raised JSON Schema |
/bank/{env}/neon/pooler-host |
MOD-103 | Neon connection at runtime |
Secrets Manager bank-neon/{env}/bank_aml/app_user |
MOD-103 | Neon credentials |
EventBridge events¶
Consumed¶
| Event | Source bus | Filter pattern |
|---|---|---|
bank.core.posting_completed |
bank-core |
{"source":["bank.core"],"detail-type":["posting_completed"]} |
Published¶
| Event | Bus | Schema |
|---|---|---|
bank.aml.alert_raised |
bank-aml |
schemas/bank.aml.alert_raised.json (uploaded to MOD-043 schema registry on deploy) |
Module type¶
Application Lambda (with hybrid IaC). Four Lambda handlers, AppConfig, an HTTP API Gateway, a cross-bus EventBridge rule, four CloudWatch alarms, an SNS subscription, and the schema upload — provisioned by SST v3 / Pulumi.
Key design decisions¶
Decision: single Lambda for cache write + rule evaluation¶
Context: The dual-path architecture from the orchestrator's confirmation requires both a cache write (so future evaluations have history) and a rule evaluation (against that history including the new posting).
Choice: One Lambda. INSERT into aml.posting_history_cache first, then read the party history (which includes the just-written row), then evaluate every enabled rule, then COMMIT, then publish.
Reason: Splitting these into two Lambdas would create a race: evaluation could run before the cache write completes. Inside one transaction, INSERT + SELECT see the same data. Single-Lambda is simpler, faster, and removes the consistency window.
Trade-offs: A long-running evaluation blocks the cache update for the same party. At the FR-105 budget (≤1s) this is a non-issue.
Decision: UNIQUE (payment_id, rule_id, rule_version) on rule_executions¶
Context: EventBridge is at-least-once; a duplicate posting_completed event must not produce duplicate audit rows.
Choice: Composite UNIQUE constraint at the schema level + INSERT...ON CONFLICT DO NOTHING in the writer.
Reason: Per the methodology idempotency standard, async event consumer dedup belongs at the data layer, not in a sidecar table. The payment_id UNIQUE on posting_history_cache covers the cache; this composite covers the audit log.
Trade-offs: A rule_version change will produce a new row for the same payment_id — this is correct behaviour (new evaluation, new outcome).
Decision: AppConfig + Postgres history rather than AppConfig alone¶
Context: AppConfig has its own deployment history accessible via console + API. Why duplicate in Postgres?
Choice: Both. AppConfig is the live-config source of truth; aml.rule_config_history is the bank-native audit trail with mandatory change_reason.
Reason: Regulators inspect via SQL queries, not via the AWS console. A queryable, immutable Postgres row with a documented justification per change is the FR-107 + AML-001 evidence; AppConfig is the operational mechanism.
Trade-offs: Two writes per change. Trivial cost; both inside one transaction in the update-rule-config handler.
Decision: jurisdiction column on posting_history_cache (not in wiki schema)¶
Context: The wiki SD03 data model for posting_history_cache (added 2026-04-30) does not include a jurisdiction column. The HIRISK_GEO_001 rule needs it.
Choice: Added jurisdiction char(2) NOT NULL to the table.
Reason: The bank.core.posting_completed event schema (per the schema-registry) carries jurisdiction as required. Storing it in the cache avoids re-deriving it from accounts.jurisdiction (cross-domain DB access — DT-001 GATE blocked). Documented in docs/handoffs/MOD-016-data-model-additions.handoff.md.
Decision: 24-hour LOOKBACK_HOURS as a single global window¶
Context: Each typology has its own window: STRUCT_001=24h, RAPID_MOV_001=60min, HIRISK_GEO_001=single-posting, CASH_THR_001=single-posting.
Choice: Load 24h of history (the longest window). Each evaluator filters within its own window in pure logic.
Reason: A single SQL query is faster than four. For a typical 24h window, an active customer has 10s-100s of postings; the index idx_phc_party_posted_at keeps the query sub-10ms even at the long tail.
Trade-offs: Loads slightly more data than each rule strictly needs. Acceptable given FR-105's 1s budget.
FX rate sourcing (v1 limitation)¶
amount_nzd on posting_history_cache is computed from original_amount * fxRate(original_currency). v1 uses a fallback constant for AUD→NZD held in env var FX_AUD_TO_NZD (default 1.0753). MOD-085 publishes authoritative rates in accounts.fx_rates (cross-domain Postgres — not directly readable by SD03 per DT-001). When MOD-085 ships a write-back into bank-aml's own schema, fx-converter.ts will be re-pointed at it. Documented as a known v1 limitation in the handoff. Until then NZD-only or AUD-only environments are unaffected; mixed-currency typologies use the constant.
Test approach¶
| Tier | Location | Files | What it covers |
|---|---|---|---|
| Unit | tests/unit/ |
typologies (4), lib (logger, trace, errors, emf), types/events | Pure-logic correctness; ADR-031 log format; ZodSchema acceptance |
| Contract | tests/contract/ |
posting-completed-consumer.test.ts, alert-raised-publisher.test.ts |
Schema parity with schemas-registry.md and the JSON Schema in schemas/ |
| Integration | tests/integration/ |
one per FR + observability + idempotency (6 files) | Live AWS + Neon — gated by skipIfNoAws() / skipIfNoDb() |
| Policy | tests/policy/ |
AML-005 AUTO, AML-001 LOG, AML-008 AUTO | Source-token scan + count-parity + immutability triggers |
Coverage scope (vitest.config.ts) — unit-only files in src/lib/* plus src/services/typologies/* and src/services/rule-engine.ts. Integration + policy suites cover the Lambda handler, AppConfig client, EventBridge publisher, and Postgres data-access layers against deployed dev. Threshold ≥ 80% lines / functions.
Run:
- pnpm typecheck — clean
- pnpm test:unit — unit + contract; no AWS
- RUN_INTEGRATION=1 STAGE=dev AWS_PROFILE=bank-dev NEON_APP_PASSWORD=… pnpm test:integration — integration + policy against deployed dev
Operational runbook¶
Deploy¶
The EventBridge Rule on bank-core bus step will fail until BankAmlRole has events:PutRule + events:PutTargets on arn:aws:events:ap-southeast-2:{account}:event-bus/bank-core-{env}. See docs/handoffs/MOD-104-cross-bus-grant.handoff.md. After MOD-104 redeploys with the wider grant, re-run pnpm deploy and the rule provisions cleanly.
Update rule parameters¶
curl -X PUT "$(aws ssm get-parameter --name /bank/dev/mod-016/api/base-url --query Parameter.Value --output text)/internal/v1/rules/STRUCT_001/config" \
-H 'content-type: application/json' \
-d '{
"idempotency_key": "'"$(uuidgen)"'",
"changed_by": "STAFF-0042",
"change_reason": "Tightening structuring threshold post Q2 typology review",
"new_parameters": {
"window_hours": 24,
"min_event_count": 3,
"individual_max_nzd": 9000,
"aggregate_min_nzd": 9500
}
}'
The handler writes aml.rule_config_history. The orchestrator then runs aws appconfig start-deployment ... against the application provisioned by infra/appconfig.ts (a follow-up Lambda will automate this — flagged in the handoff).
Replay¶
aws lambda invoke \
--function-name bank-aml-mod-016-evaluator-dev \
--payload "$(cat replay-event.json | base64)" \
--cli-binary-format raw-in-base64-out \
/tmp/out.json
Replays are absorbed by the UNIQUE constraints — no duplicate audit rows.
Event types emitted in structured logs¶
Registered in src/lib/event-types.ts:
posting_consumed, posting_cache_written, posting_cache_replay_ignored, rule_evaluation_started, rule_evaluation_completed, rule_evaluation_skipped, alert_raised, alert_publish_succeeded, alert_publish_failed, rule_config_changed, rule_definition_registered, rule_definition_disabled, rule_config_history_written, appconfig_loaded, appconfig_unreachable_fallback, trace_id_missing_from_upstream, validation_failed, compliance_block, transient_failure, internal_error, idempotency_replay, schema_validation_failed.
Custom metrics (EMF, namespace bank/modules)¶
| Metric | Unit | Dimensions |
|---|---|---|
rule_evaluation_duration_ms |
Milliseconds | module_id, jurisdiction, environment, rule_id, result |
posting_evaluation_duration_ms |
Milliseconds | module_id, jurisdiction, environment |
alert_raised_total |
Count | module_id, jurisdiction, environment, rule_id, typology_code |
alert_publish_failed_total |
Count | module_id, jurisdiction, environment |
Related artefacts¶
- Wiki spec:
bank-wiki/source/entities/modules/MOD-016.{yaml,md} - Handoffs:
docs/handoffs/MOD-016-complete.handoff.mddocs/handoffs/MOD-104-cross-bus-grant.handoff.mddocs/handoffs/MOD-016-data-model-additions.handoff.md- ADRs in effect: ADR-001, ADR-003, ADR-019, ADR-025, ADR-029, ADR-030, ADR-031, ADR-033, ADR-035, ADR-038, ADR-042, ADR-043