Skip to content

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

cd MOD-016-typology-engine
AWS_PROFILE=bank-dev pnpm run deploy --stage dev

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