Technical design — MOD-082 Nostro & FX treasury management¶
Module: MOD-082 System: SD04 Payments Processing Repo: bank-payments FR scope: FR-161, FR-162, FR-163, FR-164 NFR scope: NFR-013, NFR-019, NFR-024 Policies satisfied: CLQ-002 (CALC), PAY-002 (GATE), PAY-008 (AUTO) Author: AI coding agent (Claude) Date: 2026-05-04
Objective¶
MOD-082 maintains the bank's correspondent-bank ("nostro") account
positions in real time. Every settlement / FX conversion event flows
into the position-updater Lambda via EventBridge and reduces or
increases the relevant payments.nostro_positions row within ≤1
minute (FR-161). It exposes a synchronous balance-check API that
MOD-020 calls before allowing any outbound cross-border payment
(PAY-002 GATE). It snapshots positions daily for FR-163 reporting
and records every manual treasury adjustment in an immutable audit
log (FR-164).
The module is treasury-facing — invisible to customers — and complements MOD-025's customer-facing FX surface.
Architecture¶
API Gateway HTTP API
POST /internal/v1/nostro/check ─▶ Mod082CheckBalanceHandler
GET /internal/v1/nostro/positions ─▶ Mod082PositionsQueryHandler
POST /internal/v1/nostro/adjustments ─▶ Mod082AdjustmentHandler
EventBridge bank-payments
fx_conversion_completed ─┐
fx_rate_locked ─┼─▶ Mod082PositionUpdaterHandler
settlement_file_received ─┘
EventBridge bank-core (cross-bus)
balance_updated ─▶ Mod082PositionUpdaterHandler
EventBridge schedules
cron(55 11 * * ? *) NZ EOD 23:55 NZST ─▶ Mod082EodSnapshotHandler { jurisdiction: "NZ" }
cron(55 13 * * ? *) AU EOD 23:55 AEST ─▶ Mod082EodSnapshotHandler { jurisdiction: "AU" }
EventBridge bank-payments (publish)
nostro_threshold_breached ◀─ Mod082PositionUpdaterHandler (on threshold cross)
Tables¶
| Table | Migration | Purpose | Mutability |
|---|---|---|---|
payments.nostro_positions |
V001 | Per-corridor live position. SD04 data model defines the table; V001 adds maximum_threshold (proposed for wiki update). |
Mutable |
payments.nostro_adjustment_audit |
V002 | Per-adjustment immutable log (FR-164). | Append-only via V003 trigger |
payments.nostro_position_eod_snapshots |
V004 | Daily snapshot for FR-163 reporting. | Append-only via V005 trigger |
payments.idempotency_keys |
(MOD-021 V005) | Shared SD04 idempotency store. | INSERT/SELECT/DELETE per existing grants |
Append-only enforcement on the two audit tables follows ADR-048
Category 1 — BEFORE UPDATE / DELETE / TRUNCATE triggers raise
insufficient_privilege.
Dev / UAT seed (V900)¶
Three corridors seeded for integration tests in dev/uat (no-op in prod):
| correspondent_bank | nostro_account_ref | currency | minimum | maximum |
|---|---|---|---|---|
| CUSCAL | NSTRO-NZD-001 | NZD | 2,000,000.00 | 10,000,000.00 |
| CUSCAL | NSTRO-AUD-001 | AUD | 1,500,000.00 | 9,000,000.00 |
| CITIBANK | NSTRO-USD-001 | USD | 500,000.00 | 5,000,000.00 |
Lambdas¶
| Function | Trigger | Purpose |
|---|---|---|
Mod082CheckBalanceHandler |
HTTP POST /internal/v1/nostro/check |
PAY-002 GATE pre-check called by MOD-020 |
Mod082PositionsQueryHandler |
HTTP GET /internal/v1/nostro/positions |
Treasury read API |
Mod082AdjustmentHandler |
HTTP POST /internal/v1/nostro/adjustments |
Manual adjustment + immutable audit (FR-164) |
Mod082PositionUpdaterHandler |
EventBridge consumer | Apply event deltas + threshold-breach publish |
Mod082EodSnapshotHandler |
Cron (NZ + AU EOD) | Daily snapshot writes (FR-163) |
Memory: 512 MB. Timeouts: hot path 10s, consumer 30s, EOD 120s. Reserved concurrency tiered (prod / uat / dev).
EventBridge¶
Consumes
| Event | Source bus | Notes |
|---|---|---|
bank.payments.fx_conversion_completed |
bank-payments | Source-side inflow + target-side outflow. |
bank.payments.fx_rate_locked |
bank-payments | Hedging signal; soft pending reservation. |
bank.payments.settlement_file_received |
bank-payments | Position update post-scheme settlement (MOD-081 not deployed; handler is a no-op until then). |
bank.core.balance_updated |
bank-core (cross-bus) | Filtered to internal nostro account product codes. Cross-bus rule requires the MOD-104 grant filed in docs/handoffs/MOD-104-bank-core-cross-bus-grant.handoff.md. |
Publishes
| Event | Notes |
|---|---|
bank.payments.nostro_threshold_breached |
NEW — pending wiki catalogue add. Schema in schemas/. |
SSM contract¶
Reads¶
| Path | From |
|---|---|
/bank/{stage}/neon/pooler-host, /bank/{stage}/neon/direct-host |
MOD-103 |
/bank/{stage}/eventbridge/bank-payments/arn |
MOD-104 |
/bank/{stage}/eventbridge/bank-core/arn |
MOD-104 |
/bank/{stage}/iam/lambda/bank-payments/arn |
MOD-104 |
/bank/{stage}/observability/adot-nodejs-layer-arn |
MOD-076 |
/bank/{stage}/sns/alerts/arn |
MOD-104 |
Writes¶
| Path | Value |
|---|---|
/bank/{stage}/mod-082/api/base-url |
API Gateway base URL |
/bank/{stage}/mod-082/check-balance/url |
Full /internal/v1/nostro/check URL |
/bank/{stage}/mod-082/positions/url |
Full /internal/v1/nostro/positions URL |
/bank/{stage}/mod-082/adjustments/url |
Full /internal/v1/nostro/adjustments URL |
/bank/{stage}/mod-082/check-balance-lambda/arn |
Lambda ARN |
/bank/{stage}/mod-082/positions-query-lambda/arn |
Lambda ARN |
/bank/{stage}/mod-082/adjustment-lambda/arn |
Lambda ARN |
/bank/{stage}/mod-082/position-updater-lambda/arn |
Lambda ARN |
/bank/{stage}/mod-082/eod-snapshot-lambda/arn |
Lambda ARN |
/bank/{stage}/mod-082/nostro-positions-table |
payments.nostro_positions |
/bank/{stage}/mod-082/nostro-adjustment-audit-table |
payments.nostro_adjustment_audit |
/bank/{stage}/mod-082/nostro-position-eod-snapshots-table |
payments.nostro_position_eod_snapshots |
Configuration¶
| Env | Default | Purpose |
|---|---|---|
MAX_RATE_AGE_SECONDS |
86400 |
Stale-rate guard for EOD P&L computation |
LOG_LEVEL |
info (prod) / debug (non-prod) |
Log gate |
PAYMENTS_BUS_NAME |
bank-payments |
Publish target |
Policy mapping¶
| Policy | Mode | How satisfied | Test |
|---|---|---|---|
| CLQ-002 | CALC | Position deltas resolve arithmetically: source-side inflow + target-side outflow on every fx_conversion_completed; balance_updated deltas applied to internal nostro accounts. | tests/policy/clq-002-calc.test.ts (unit math) + tests/integration/fr-161-real-time-position.test.ts (live event-driven) |
| PAY-002 | GATE | The check-balance API returns the truthful available_balance and sufficient flag MOD-020 reads for its gate. Refuses to lie about balance; rejects unknown currencies with position_id: null. No bypass tokens. |
tests/policy/pay-002-gate.test.ts (compare logic + token scan) + tests/integration/check-balance.test.ts (live) |
| PAY-008 | AUTO | Position-updater consumes all four event types automatically; no manual ETL step in the position path. Manual adjustments go through nostro_adjustment_audit so the auto-vs-manual distinction is reconcilable. External correspondent statement reconciliation = v2 follow-up. |
tests/policy/pay-008-auto.test.ts (handler + event-rules wiring scan) |
Performance approach¶
- NFR-013 ≤ 5 ms p99 on the check-balance hot path: a single
aggregateAvailableForCurrencySELECT againstpayments.nostro_positionsindexed on(currency, position_date DESC).withConnection(no BEGIN/COMMIT) for read-only. - FR-161 ≤ 1 min position-update latency: EventBridge delivery is sub-second; only Lambda cold-start + DB UPDATE counts. Comfortable inside the budget. Alarm trips at p99 ≥ 10s.
Error handling¶
- Sync HTTP paths — standard error envelope per
bank-wiki/source/pages/design/system/error-handling-standard.md(HTTP 422 / 503 / 500). - EventBridge consumer — re-raise on transient failures so EB retries; bank-payments / bank-core DLQ catches after retry exhaustion.
- Scheduled paths (EOD) — re-raise on transient failures; alarm trips on errors ≥ 1 in 5 min over 3 evaluation periods.
Event types emitted in structured logs¶
Registered in src/lib/logger.ts (EVENT_TYPES):
position_updated, position_update_failed, threshold_breached,
threshold_breach_publish_failed, balance_check_passed,
balance_check_failed, adjustment_recorded, adjustment_rejected,
eod_snapshot_completed, eod_snapshot_partial,
reconciliation_run_completed, reconciliation_discrepancy_detected,
idempotency_replay, trace_id_missing_from_upstream,
validation_failed, internal_error.
Test approach¶
| Tier | Files | Status |
|---|---|---|
| Unit (≥80% gate) | tests/unit/{amount,errors,trace,logger,emf,position-math,threshold-monitor}.test.ts |
36 / 36 |
| Contract | tests/contract/{nostro-threshold-breached-schema,api-contract}.test.ts |
14 / 14 |
| Policy satisfaction | tests/policy/{clq-002-calc,pay-002-gate,pay-008-auto}.test.ts |
10 / 10 |
| FR integration (one per FR + check-balance + idempotency + observability + NFR-024) | tests/integration/*.test.ts |
runs in CI under RUN_INTEGRATION=1 |
| Smoke (verify-deployment.mjs) | tests/verify-deployment.mjs |
runs after smoke phase |
skipIfNoDb() guards keep the integration tier green-with-skips
locally; CI sets RUN_INTEGRATION=1 so the guards never fire and
probe failures surface as test failures (per ADR-053).
Security and data handling¶
- No customer PII flows through MOD-082. Position data is by correspondent bank + nostro account ref + currency — all internal treasury constructs.
- The two audit tables (
nostro_adjustment_audit,nostro_position_eod_snapshots) have INSERT-only role grants + trigger-level append-only enforcement. - The adjustment API trusts caller-asserted
operator_id/operator_role— the actual role-based authorisation lives in MOD-068 / MOD-064 once those modules ship. Until then, the audit row records the asserted identity for downstream investigation.
Open items / handoff follow-ups¶
-
bank.payments.nostro_threshold_breached— wiki catalogue add. New event; schema inschemas/. Consumer hint: MOD-022 (audit trail) and MOD-064 (treasury work queue, future). Add tobank-wiki/source/pages/design/system/event-catalogue.md. -
SD04 data model — two new tables. Add to
bank-wiki/source/pages/design/system/data-models/SD04-payments.md: payments.nostro_adjustment_audit(immutable per ADR-048 Cat 1)-
payments.nostro_position_eod_snapshots(immutable per ADR-048 Cat 1) -
SD04 data model —
nostro_positions.maximum_thresholdcolumn. V001 addsmaximum_threshold numeric(18,2) NULLto the table. Document this column in the wiki data model (the existing entry only listsminimum_threshold). -
Cross-bus IAM grant. BankPaymentsRole needs
events:PutRule -
events:PutTargetson the bank-core bus. Filed indocs/handoffs/MOD-104-bank-core-cross-bus-grant.handoff.md. SST deploy is red until that grant lands. -
Lock-expiry release (FR-141 follow-up). MOD-025's expirer writes EXPIRED audit rows but emits no event. The hedge pending reservation MOD-082 applies on
fx_rate_lockedis therefore not automatically released when the lock expires unused. v2: emit afx_lock_expiredevent from MOD-025; MOD-082 releases the reservation on receipt. -
External correspondent statement reconciliation (PAY-008 v2). No module in the bank ingests MT940 / camt.052 today. v1 reconciles nostro positions against the internal MOD-001/MOD-004 ledger only.
-
DST handling on EOD cron. Same pending follow-up as MOD-003 / MOD-004 — both crons run on standard-time offsets and accept the 1-hour slip across DST. Future maintenance pass to add DST-aware cron rules.