Technical design — MOD-020 Pre-payment validation suite¶
Module: MOD-020 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-120, FR-121, FR-122, FR-123, FR-124 NFR scope: NFR-021, NFR-022 Policies satisfied: PAY-001 (GATE), AML-007 (GATE), PAY-005 (GATE), CLQ-002 (CALC) Author: AI coding agent (Claude) Date: 2026-05-08
Objective¶
The absolute payment gate. Every rail-integration module
(MOD-119/120/122/124/135/136/141 etc.) calls MOD-020's
POST /internal/v1/payments/validate synchronously before submitting
a payment to the rail. There is no bypass: the methodology and
CLAUDE.md architectural constraint #1 codify this.
The gate runs five checks concurrently, persists the canonical
payments.payments row, and publishes one of three terminal events
(payment_initiated always, plus either payment_validated or
payment_failed) on the bank-payments bus.
Architecture¶
API Gateway HTTP API
POST /internal/v1/payments/validate ─► Mod020ValidatePaymentHandler
bank-core EventBridge bus
account_status_changed ─► Mod020AccountStatusConsumerHandler
└─► payments.account_status_cache UPSERT
Mod020ValidatePaymentHandler:
1. Zod validate request
2. Idempotency replay short-circuit (payments.idempotency_keys, MODULE_ID="MOD-020")
3. Open transaction:
a. INSERT payments.payments at status=VALIDATION_PENDING (skipped on dry_run)
b. runChecks (Promise.allSettled — five concurrent checks, each timeout 175ms):
· BALANCE — sync invoke MOD-003 (fail-closed BALANCE_UNAVAILABLE)
· ACCOUNT_STATUS — local SELECT cache (cache miss → PASS, k-6)
· SANCTIONS — sync invoke MOD-013 (fail-closed SANCTIONS_ERROR)
· FRAUD — sync invoke MOD-023 (fail-closed FRAUD_BLOCK)
· VELOCITY — sync invoke MOD-021 (fail-closed LIMIT_EXCEEDED)
c. evaluateGate(checks) — pure priority resolver (k-1, k-9)
d. UPDATE payments.payments status + failure_reason + fraud_score (skipped on dry_run)
e. INSERT payments.idempotency_keys row
4. Post-commit:
· publishPaymentInitiated (always, non-dry-run)
· publishPaymentValidated (AUTHORISED) OR
publishPaymentFailed (VALIDATION_FAILED)
— neither for PENDING_AUTH (v1; MOD-022 will introduce step-up audit)
5. Respond
The five checks — ordering, timeouts, fail-closed (k-1 / k-7)¶
All five checks fire on Promise.allSettled so total wall time is the
slowest check, not the sum. Each cross-module Lambda invoke is wrapped
in withTimeout at perCheckTimeoutMs (default 175ms). Every check
emits an EMF metric (validate_check_total with dimensions
check_name, outcome) and the orchestrator returns five
CheckResult rows regardless of outcome — FR-136-equivalent audit
trail for the gate.
| # | Check | Source | Timeout | Fail-closed code |
|---|---|---|---|---|
| 1 | BALANCE | MOD-003 sync invoke | 175ms | BALANCE_UNAVAILABLE |
| 2 | ACCOUNT_STATUS | local payments.account_status_cache SELECT |
(in-pool) | INVALID_ACCOUNT |
| 3 | SANCTIONS | MOD-013 sync invoke | 175ms | SANCTIONS_ERROR |
| 4 | FRAUD | MOD-023 sync invoke | 175ms | FRAUD_BLOCK |
| 5 | VELOCITY | MOD-021 sync invoke | 175ms | LIMIT_EXCEEDED |
Priority order (k-1 ruling)¶
The gate evaluator walks the result array in priority order to pick
the wire failure_reason. reason_codes carries the full set —
useful for the case-mgmt queue and customer-facing copy.
- SANCTIONS —
SANCTIONS_MATCH | SANCTIONS_PENDING_REVIEW | SANCTIONS_ERROR(AML-007 regulatory primacy) - ACCOUNT_STATUS —
INVALID_ACCOUNT(CLOSED / RESTRICTED / FROZEN) - FRAUD —
FRAUD_BLOCK(PAY-005) - BALANCE —
INSUFFICIENT_BALANCE | BALANCE_UNAVAILABLE(PAY-001) - VELOCITY —
LIMIT_EXCEEDED(PAY-001 velocity)
STEP_UP and PENDING_AUTH (k-1)¶
MOD-023 returns decision ∈ {PASS, STEP_UP, BLOCK}. STEP_UP becomes
outcome="STEP_UP" on the FRAUD CheckResult. If no other check
FAIL'd or ERROR'd, the gate verdict is decision=PENDING_AUTH —
caller must run strong-customer-auth before retrying. If any other
check FAIL/ERROR'd, that takes precedence (a hard reject is more
decisive than a soft step-up).
SANCTIONS_PENDING_REVIEW = BLOCK (k-9)¶
MOD-013 returning MATCH_PENDING (operator review required) is
mapped to outcome="FAIL" / failure_code="SANCTIONS_PENDING_REVIEW",
NOT a step-up. AML-007 requires the payment to be blocked until the
analyst clears the match.
payments.payments — canonical schema (V001)¶
V001 of this module creates payments.payments. MOD-023 / MOD-025 /
MOD-082 already contain to_regclass guards because they referenced
this table before MOD-020 was built. Those guards are now dead code
but not blocking; cleanup is non-essential.
Cross-domain identifiers (party_id, from_account_id,
to_account_id, sanctions_result_id) are uuid columns
without FK constraints because they target other Neon DBs
(bank_core, bank_kyc). The single same-DB FK is
fx_rate_lock_id → payments.fx_locks(id).
UNIQUE (idempotency_key, party_id) is enforced at the DB level so
duplicate submissions can never produce two rows.
payments.account_status_cache — local cache (V002, k-6)¶
| Column | Type | Notes |
|---|---|---|
| id | uuid PK default gen_random_uuid() | |
| account_id | uuid NOT NULL UNIQUE | bank-core account id |
| account_status | text NOT NULL | enum ACTIVE / RESTRICTED / CLOSED / FROZEN / DORMANT |
| last_event_id | text NOT NULL | bank-core event_id (idempotency token for the consumer) |
| updated_at | timestamptz NOT NULL DEFAULT now() | |
| created_at | timestamptz NOT NULL DEFAULT now() |
The Mod020AccountStatusConsumerHandler upserts this table on every
bank.core.account_status_changed event. Idempotency: the inbound
event_id is stored as last_event_id; redelivery of the same
event_id is a no-op.
The validate hot path performs a single SELECT account_status FROM
payments.account_status_cache WHERE account_id = $1 on the same
pool connection used to insert the payment row — no separate
round-trip.
Cache miss = PASS (k-6): if the row is absent, the conservative
interpretation is "unknown" — but in v1 we treat unknown as PASS
because no account_status_changed event for this account has ever
been observed, meaning it has not transitioned out of ACTIVE since
launch. (When MOD-104's account-bootstrap event lands, this becomes
moot.)
Fail-closed defaults (k-7)¶
On any cross-module Lambda invoke timeout / 5xx / FunctionError, the
SDK client throws a ProviderError or TransientInfra. The
orchestrator catches and produces a CheckResult with
outcome="ERROR" and the appropriate *_UNAVAILABLE | *_ERROR
failure_code. The gate-evaluator treats ERROR identically to FAIL —
priority order applies as normal — so the bank cannot move money
when its risk gates are degraded.
The fail-closed dependency rate is alarmed at 10
ERRORs / 5min on validate_check_total{outcome=ERROR}.
NFR-022 200ms p99 budget¶
Sequential invocation infeasible for five cross-module calls. Resolved via parallel invocation — total wall time bounded by the slowest check, not the sum. Per-check timeout 175ms keeps the budget even under p99 latency on the upstream Lambdas. Reserved concurrency is mandatory in prod for both MOD-020 and all four downstream Lambdas (MOD-003, MOD-013, MOD-021, MOD-023) per k-8.
| Stage | Reserved concurrency (validate-payment) | Reserved concurrency (account-status-consumer) |
|---|---|---|
| dev | unbounded | unbounded |
| uat | 100 | 20 |
| prod | 200 | 30 |
v1 known limitations¶
- No CoP / payee verification — MOD-144 will introduce Confirmation of Payee in Tier F. v1 relies on MOD-023's scam-DB stub for payee risk.
- No retry queue for failed publishes —
payment_initiated / validated / failedpublish failures are logged + alarmed; the payments row is committed before publish so the source of truth is the DB. MOD-022 will introduce the audit trail and reconciler. - No step-up loop —
PENDING_AUTHreturns the decision; the caller (rail module) must request the customer step-up via MOD-068 and re-call validate with a new idempotency_key. v2 may make MOD-020 return astep_up_tokenlinked to a stored partial state. - AppConfig threshold tuning is a v2 concern. v1 has no thresholds because the gate is binary (FAIL / PASS / STEP_UP from MOD-023, which has its own thresholds).
Cross-domain Lambda invoke grants¶
BankPaymentsRole does not have lambda:InvokeFunction on
cross-domain (bank-core, bank-kyc) Lambdas at default. MOD-020
filed docs/handoffs/MOD-104-cross-domain-invoke-grant-mod020.handoff.md
requesting:
- lambda:InvokeFunction on arn:aws:lambda:ap-southeast-2:{account}:function:bank-core-mod-003-*
- lambda:InvokeFunction on arn:aws:lambda:ap-southeast-2:{account}:function:bank-kyc-mod-013-*
Same-domain (MOD-021, MOD-023) use the existing intra-bank-payments grant — no new permission needed.
Cross-bus rule grant (k-10)¶
The bank-core PutRule grant filed by MOD-082
(docs/handoffs/processed/2026-05-05/MOD-104-bank-core-cross-bus-grant.handoff.md)
uses the resource pattern bank-payments-mod-*. MOD-020's rule name
bank-payments-mod-020-core-account-status-changed-${stage} matches
the wildcard. No new MOD-104 handoff required.
SSM outputs published by MOD-020¶
| Path | Value |
|---|---|
/bank/{stage}/mod-020/api/base-url |
API Gateway URL |
/bank/{stage}/mod-020/validate-payment/url |
Full validate-payment endpoint |
/bank/{stage}/mod-020/validate-payment-lambda/arn |
Lambda ARN (sync caller path) |
/bank/{stage}/mod-020/account-status-consumer-lambda/arn |
Lambda ARN (consumer) |
/bank/{stage}/mod-020/payments-table |
payments.payments |
/bank/{stage}/mod-020/account-status-cache-table |
payments.account_status_cache |
Upstream SSM dependencies¶
| Path | Used for |
|---|---|
/bank/{stage}/iam/lambda/bank-payments/arn |
BankPaymentsRole |
/bank/{stage}/eventbridge/bank-payments/arn |
publish bus |
/bank/{stage}/eventbridge/bank-core/arn |
cross-bus consume target |
/bank/{stage}/observability/adot-nodejs-layer-arn |
OTel Lambda layer |
/bank/{stage}/sns/alerts/arn |
alarm SNS topic |
/bank/{stage}/neon/pooler-host |
DB pooler host |
/bank/{stage}/mod-003/balance-query-lambda/arn |
sync invoke target (BALANCE check) |
/bank/{stage}/kyc/sanctions/function-arn |
sync invoke target (SANCTIONS check; MOD-013's domain-prefixed convention) |
/bank/{stage}/mod-021/limits-check-lambda/arn |
sync invoke target (VELOCITY check) |
/bank/{stage}/mod-023/score-payment-lambda/arn |
sync invoke target (FRAUD check) |
Events published¶
| Detail-type | Bus | When |
|---|---|---|
payment_initiated |
bank-payments | Always (non-dry-run) before publishing terminal event |
payment_validated |
bank-payments | AUTHORISED only |
payment_failed |
bank-payments | VALIDATION_FAILED only |
PENDING_AUTH does not publish a terminal event in v1 — MOD-022 will introduce step-up audit semantics.
Events consumed¶
| Detail-type | Bus | Source module |
|---|---|---|
account_status_changed |
bank-core | MOD-003 (publisher) |
Observability¶
- Structured logs (ADR-031):
trace_id, correlation_id, module_id=MOD-020, jurisdiction, event_type, party_id, level, error_code, retryable, timestamp - EMF metrics:
validate_payment_total,validate_payment_duration_ms,validate_check_total,validate_check_duration_ms,account_status_cache_update_total - ADOT Lambda layer attached for OTel traces → CloudWatch + X-Ray
Test surface¶
| Tier | Files | Coverage |
|---|---|---|
| Unit | gate-evaluator (priority order, STEP_UP, MATCH_PENDING), errors, amount, trace, logger, emf, timeout, types, event-publisher, schema-validator | ≥80% line + function |
| Contract | published-events (3 schemas), account-status-consumer (Zod) | one per event |
| Policy | PAY-001 GATE, AML-007 GATE, PAY-005 GATE, CLQ-002 CALC | one per policies_satisfied row |
| Integration | FR-120 validate, FR-121 account state, FR-124 dry-run, idempotency, observability | one per FR + idempotency + log shape |
| Smoke | tests/verify-deployment.mjs |
SSM + Lambda + EventBus + schemas + alarms + dry-run invoke |