Technical design — MOD-021 Payment limit & velocity controller¶
Module: MOD-021 — Payment limit & velocity controller
System: SD04 — Payments Processing Platform
Repo: bank-payments
FR scope: FR-125, FR-126, FR-127, FR-128
NFR scope: NFR-020, NFR-024, NFR-025
Policies satisfied: PAY-005 (GATE), AML-005 (ALERT), CON-005 (AUTO)
Author: AI agent (Claude Opus 4.7)
Date: 2026-05-02
Dependencies: MOD-002 (Deployed, de846f1a), MOD-103 (Built), MOD-104 (Built)
Objective¶
MOD-021 enforces per-customer payment limits and velocity controls on the synchronous pre-payment path. It is invoked by MOD-020 (when MOD-020 is built) once per payment instruction, and returns PASS / FAIL / APPROVAL_REQUIRED within a ≤20 ms p99 budget. It also serves the back-office and customer-facing limit-management API (FR-126), publishes bank.payments.limit_breach_detected and bank.payments.approval_required to the bank-payments EventBridge bus, and owns the payments schema bootstrap in bank_payments (Neon).
This document covers the as-built source tree. Acceptance is measured against the wiki's policy/FR/NFR set and verified by the test suites below.
Architecture¶
API Gateway HTTP API
│
POST /internal/v1/limits/check ─▶ Mod021LimitsCheckHandler (hot path)
│
├─ idempotency lookup
├─ fetchActiveLimits ── payments.payment_limits
├─ fetchPriorTotals ── payments.payments (FR-127 filter)
├─ resolveLimits + decide
├─ EventBridge publish ── bank-payments bus
│ · limit_breach_detected (on FAIL)
│ · approval_required (on APPROVAL_REQUIRED)
├─ idempotency store
└─ EMF + structured log
POST /internal/v1/limits ─▶ Mod021LimitsSetHandler (FR-126 audit-trailed)
GET /internal/v1/limits/{id} ─▶ Mod021LimitsListHandler
EventBridge bank-payments bus
┌─ Mod021LimitBreachToNotificationRule ─▶ MOD-063 (FR-128)
limit_breach_detected ─────┤
└─ (existing consumers: MOD-020 future, MOD-022 future, MOD-016 future)
approval_required ─────▶ MOD-062 (workflow orchestration, Built)
Three Lambda handlers, one HTTP API, two EventBridge rules (one same-bus target binding plus the rule for limit_breach), four CloudWatch alarms, one CloudWatch dashboard, six SSM parameter outputs.
Key design decisions¶
Decision: velocity reads from payments.payments, not core.transaction_log¶
The MOD-021.yaml dependency on MOD-002 is loose phrasing; FR-127 mandates counting payment attempts (including VALIDATION_FAILED and REVERSED, but not CANCELLED). The data shape that satisfies that is payments.payments in the same bank_payments database — every customer-initiated payment is recorded there at MOD-020's pre-validation step regardless of outcome. Reading from core.transaction_log (bank-core) would undercount because the log only contains committed postings, missing FAILED/REVERSED attempts.
Source confirmation: SD04-payments.md payments.payments.status enum includes SUBMITTED, VALIDATION_FAILED, REVERSED, etc. The DB query lives in src/services/limit-store.ts and applies status NOT IN ('CANCELLED').
This was confirmed with the orchestrator before build (Q1).
Decision: schema extensions to the SD04 data model¶
The wiki's SD04-payments data model defines payment_limits with a four-value limit_type CHECK and no channel column. FR-125 requires both rolling-30-day and per-channel enforcement. The migration adds:
limit_typeCHECK acceptsROLLING_30_DAY(FR-125 explicit) andAPPROVAL_THRESHOLD(CAP-090 detection surface, owned by MOD-062 thereafter).- New
channelcolumn,text NOT NULL DEFAULT 'ALL' CHECK (channel IN ('APP','API','OPEN_BANKING','AGENT','ALL'))— same axis as the interface contract.
These are additive — they extend the data model without breaking the published contract. The wiki's SD04-payments.md should be updated to reflect them; flagged in the handoff.
Decision: APPROVAL_THRESHOLD lives inside payment_limits, not a separate table¶
Per the orchestrator's Q3 resolution, MOD-021 does detection + hold + event emission for high-value payments; MOD-062 owns the approval state machine. The simplest realisation: APPROVAL_THRESHOLD is just another row in payment_limits with a special limit_type. The velocity decide() function checks it first and returns APPROVAL_REQUIRED (distinct from FAIL) when breached. No new table, no additional schema surface.
Decision: no in-memory limit cache¶
CON-005 AUTO requires "no delay between setting and enforcement". The straightforward way to honour that is to not cache. Every limits-check call SELECTs from payments.payment_limits against a covering partial index (idx_payment_limits_party_active). The query is a single-key index scan — sub-ms even at high cardinality. The cost is well under the 20 ms p99 budget.
The CON-005 policy test in tests/policy/con-005-auto.test.ts includes a no-bypass token scan that fails the build if any cache-related identifier is added (e.g. LIMIT_CACHE_TTL).
Decision: idempotency on a shared payments.idempotency_keys table¶
Following the methodology pattern, MOD-021 introduces payments.idempotency_keys to the schema with PK on (key, module_id). Future SD04 modules (MOD-020, MOD-022, etc.) reuse the same table.
Decision: same-bus rule wires MOD-063 directly¶
FR-128 customer notification routing is a same-bus EventBridge rule from bank.payments.limit_breach_detected to MOD-063's notification Lambda. Because BankPaymentsRole already has events:PutRule on the bank-payments bus (per MOD-104), no IAM widening is needed. The rule is idempotent and tolerates MOD-063's SSM ARN being unset on a fresh stage (it logs a deferred-target warning and skips the binding).
Decision: payment_limits rows are append-only-by-convention, not by trigger¶
Limit lifecycle is implemented as: insert new row, set effective_to on the previous active row. There is no UPDATE-of-limit_amount. This preserves history (FR-126 audit trail context) without adding an immutability trigger that would block legitimate effective_to updates. The audit table limit_change_audit is the immutable surface (V004 trigger).
Schema extensions¶
| Object | Change vs SD04 data model | Rationale |
|---|---|---|
payment_limits.limit_type CHECK |
Adds ROLLING_30_DAY, APPROVAL_THRESHOLD |
FR-125 explicit; CAP-090 detection |
payment_limits.channel |
New column text NOT NULL DEFAULT 'ALL' |
FR-125 per-channel enforcement |
idx_payment_limits_party_active |
Index keys extended to include channel, limit_type |
Hot-path index scan covers the limit_resolver lookup |
payments.limit_change_audit |
New table — not in the SD04 data model | FR-126 mandates the audit trail; surface owned by MOD-021 |
payments.idempotency_keys |
New table — methodology standard | Shared SD04 idempotency store |
The wiki SD04-payments.md should be updated; flagged in the handoff.
SSM outputs (consumer contract)¶
All under arn:aws:ssm:ap-southeast-2:{account}:parameter. Path convention /bank/{env}/mod-021/....
| SSM path | Value | Consumed by |
|---|---|---|
/bank/{env}/mod-021/limits-check-lambda/arn |
Velocity check Lambda ARN | MOD-020 (when built) — sync sub-call on payment validation |
/bank/{env}/mod-021/limits-admin-api/url |
API Gateway HTTP base URL | MOD-074 (back-office customer 360), MOD-078 (customer-facing controls) |
/bank/{env}/mod-021/limits-set-lambda/arn |
Set/update Lambda ARN | Audit dashboards |
/bank/{env}/mod-021/limits-list-lambda/arn |
List Lambda ARN | Audit dashboards |
/bank/{env}/mod-021/payment-limits-table |
payments.payment_limits |
MOD-042 CDC pipeline |
/bank/{env}/mod-021/limit-change-audit-table |
payments.limit_change_audit |
MOD-042 CDC pipeline |
/bank/{env}/mod-021/idempotency-keys-table |
payments.idempotency_keys |
MOD-022, MOD-020 (when built) — shared idempotency surface |
Postgres schema (owned by this module)¶
| Table | Purpose | Mutability |
|---|---|---|
payments.payment_limits |
Per-customer, per-(payment_type, channel, limit_type) limit configuration | Mutable (effective_to set on close) |
payments.limit_change_audit |
Append-only audit of limit changes (FR-126) | Immutable (V004 trigger) |
payments.idempotency_keys |
Shared SD04 idempotency store, 24-hour TTL | Mutable (DELETE on TTL cleanup) |
DB-enforced invariants: V004 immutability trigger on limit_change_audit. CHECK constraints per ADR-048 on enum-domain columns.
Events published¶
| Event | Source | Detail-Type | Schema | Consumers |
|---|---|---|---|---|
bank.payments.limit_breach_detected |
bank.payments |
limit_breach_detected |
schemas/bank.payments.limit_breach_detected.json |
MOD-020 (block), MOD-022 (audit), MOD-016 (structuring), MOD-063 (notification — FR-128) |
bank.payments.approval_required |
bank.payments |
approval_required |
schemas/bank.payments.approval_required.json |
MOD-062 (approval workflow), MOD-022 (audit), MOD-063 (second-approver notification) |
Schemas are uploaded to the EventBridge Schema Registry (bank-events-{env}) by infra/schemas.ts on every deploy.
Event types in structured logs¶
Registered in src/lib/logger.ts (EVENT_TYPES):
limit_check_passed,limit_check_failed,limit_breach_detected,approval_requiredlimits_listed,limits_changed,limits_change_failedidempotency_replaytrace_id_missing_from_upstream(ADR-031 standard WARN)validation_failed,internal_error
Test approach¶
| Tier | Location | What it covers |
|---|---|---|
| Unit | tests/unit/ |
Pure logic — amount math, error vocab, trace, logger ADR-031 fields, EMF, limit-resolver specificity, velocity decide() boundaries |
| Contract | tests/contract/ |
JSON Schema validation of both published events; Zod request/response shapes vs interface-contracts.md |
| Integration | tests/integration/ |
One per FR (125/126/127/128), NFR-020/024/025, observability fields, idempotency. Skip cleanly via skipIfNoDb / skipIfNoAws guards |
| Policy | tests/policy/ |
PAY-005 GATE (negative + bypass-token scan), AML-005 ALERT (latency assertion), CON-005 AUTO (immediate-effect + cache-token scan) |
Run with pnpm test:unit (no AWS) or pnpm test:integration (against deployed dev).
Coverage gate: vitest threshold ≥ 80% lines / functions, ≥ 75% branches on the unit-testable surface (src/lib/* + src/services/{velocity,limit-resolver}.ts). Handler and DB-touching service files are exercised by the integration suite, not the unit gate.
Acceptance criteria status¶
To be filled in by CI on first deploy. Until then:
| FR / Policy | Mode | Tests | Status |
|---|---|---|---|
| FR-125 | — | tests/integration/fr-125-limits.test.ts; tests/unit/velocity.test.ts | Implemented |
| FR-126 | — | tests/integration/fr-126-audit.test.ts | Implemented |
| FR-127 | — | tests/integration/fr-127-failed-counted.test.ts | Implemented |
| FR-128 | — | tests/integration/fr-128-event.test.ts; infra/event-rules.ts MOD-063 wiring | Implemented |
| NFR-020 | — | tests/integration/nfr-020-availability.test.ts; alarms.ts MetricAlarm | Implemented |
| NFR-024 | — | tests/integration/nfr-024-audit-immutability.test.ts; V004 trigger | Implemented |
| NFR-025 | — | tests/integration/nfr-025-latency.test.ts; alarms.ts p99 alarm | Implemented |
| PAY-005 | GATE | tests/policy/pay-005-gate.test.ts (negative + no-bypass) | Implemented |
| AML-005 | ALERT | tests/policy/aml-005-alert.test.ts (latency assertion) | Implemented |
| CON-005 | AUTO | tests/policy/con-005-auto.test.ts (no-cache + immediacy) | Implemented |
Operational notes¶
- Deploy:
pnpm install --frozen-lockfile && pnpm typecheck && pnpm test:unit --coverage && pnpm run deploy --stage <env>from the module dir. - Migrations apply BEFORE deploy via the
flywayCI job in.github/workflows/mod-021.yml. Direct host from SSM (/bank/{env}/neon/direct-host), credentials from Secrets Manager (bank-neon/{env}/bank_payments/bank_payments_migrate_user). - Local dev:
pnpm test:unitruns without AWS or Postgres; integration and policy suites skip with a clear marker if AWS or Neon are not reachable. - Removing the SCHEMA_REGISTRY_NAME env var or the EventBridge bus ARN SSM parameter will fail closed — no payload reaches the bus. Required for both PAY-005 GATE and AML-005 ALERT.
Related artefacts¶
- Wiki spec:
bank-wiki/source/entities/modules/MOD-021.{yaml,md} - Handoff:
docs/handoffs/MOD-021-complete.handoff.md - Interface contract:
bank-wiki/source/pages/design/system/interface-contracts.md§SD04 internal — MOD-020 → MOD-021 - Event catalogue:
bank-wiki/source/pages/design/system/event-catalogue.md(bank.payments.limit_breach_detected) - Methodology / standards: observability-standard.md, error-handling-standard.md, schema-registry.md, seed-consumers-guide.md
- ADRs in effect: ADR-001, ADR-024, ADR-025, ADR-029, ADR-030, ADR-031, ADR-038, ADR-042, ADR-043, ADR-044, ADR-045, ADR-048, ADR-051, ADR-052