MOD-006 — Rate change propagation¶
System: SD01 Core Banking · Repo: bank-core · Phase: 6 Status: Built (pre-deploy) Authoritative spec: https://bank-wiki.pages.dev/systems/SD01-core-banking/modules/MOD-006-rate-change-propagation/ Related ADRs: ADR-001, ADR-025, ADR-029, ADR-030, ADR-031, ADR-042, ADR-048, ADR-053
1. Purpose¶
MOD-006 is the canonical writer for accounts.interest_rates. It runs
a maker/checker proposal workflow for rate changes, rejects backdated
effective dates without an explicit retroactive flag + supervisor
approval, maintains an immutable history of every rate change with the
authorising user ID, enforces the FR-516 14-day advance-notice window
for variable retail-product increases, and on activation publishes
bank.core.rate_change_activated so MOD-005 (and other downstream
consumers) pick up the new rate within FR-068's 30-second budget.
v1 product scope:
- Variable retail rates — BASE / BONUS / OVERDRAFT /
VARIABLE_LENDING on SAVINGS / TRANSACTION products. FR-516
applies (advance-notice gate on increases).
- Other rate types — PENALTY, FIXED_LENDING and non-retail
product types still flow through the proposal/review/activation
pipeline but do not trigger FR-516 customer notifications.
- Out of v1 — fee rate changes (core.fee_schedule is MOD-110's
territory; deferred per orchestrator decision §13).
2. Architecture¶
HTTP POST /internal/v1/rate-changes/proposals ─▶ Mod006ProposeRateChangeHandler
─▶ proposal-store.proposeRateChange
1. Replay check by idempotency_key
2. Pure shape validation (FR-067 backdate gate inline)
3. Resolve product + load active rate
4. Classify INCREASE/DECREASE
5. Resolve customer_notice_required
6. FR-516 advance-notice window gate
7. INSERT core.rate_change_proposals
8. INSERT core.rate_change_events (PROPOSAL_CREATED)
─▶ post-commit publish bank.core.rate_change_proposed v1
HTTP POST /internal/v1/rate-changes/proposals/{id}/approve ─▶ Mod006ReviewApproveHandler
─▶ proposal-store.reviewApprove
1. FOR UPDATE lock the proposal
2. App-layer FOUR_EYES_VIOLATION check (DB CHECK no_self_approval is the floor)
3. FR-516 re-gate using TODAY (proposal might have aged in queue)
4. UPDATE proposal status='approved' + reviewed_by + customer_notice_published_at
5. INSERT PROPOSAL_APPROVED governance row
6. INSERT RATE_CHANGE_NOTIFIED governance row (if notice_required)
─▶ post-commit publish bank.core.rate_change_approved v1
─▶ post-commit publish bank.core.rate_change_notified v1 (FR-516 → MOD-063)
HTTP POST /internal/v1/rate-changes/proposals/{id}/reject ─▶ Mod006ReviewRejectHandler
─▶ proposal-store.reviewReject
(same shape as approve; review_comment mandatory)
─▶ post-commit publish bank.core.rate_change_rejected v1
HTTP GET /internal/v1/rate-changes/proposals?status= ─▶ Mod006ListProposalsHandler
HTTP GET /internal/v1/rate-changes/proposals/{id} ─▶ Mod006GetProposalHandler
EventBridge schedule cron(0 13 * * ? *) UTC ─▶ Mod006ApplyEffectiveRateChangesHandler
= 01:00 NZDT / 02:00 NZST │
▼
rate-activator.applyEffectiveRateChanges
per proposal (own tx):
1. FOR UPDATE re-check status='approved'
2. FOR UPDATE prior accounts.interest_rates row
3. activate: close prior + insert new
4. UPDATE proposal status='live' + applied_at + rate_id
5. INSERT RATE_CHANGE_ACTIVATED governance row
─▶ post-commit publish bank.core.rate_change_activated v1
(FR-068 30s budget; MOD-005 + MOD-031 consumers)
Module type¶
Application Lambda module. 6 handlers (5 HTTP + 1 EventBridge-scheduled).
3. Data model¶
accounts.interest_rates (pre-existing — owned by MOD-005 V001)¶
MOD-006 takes UPDATE writes (close prior row by setting effective_to)
+ INSERT writes (new active row). MOD-005 stays read-only. Schema is
documented in SD01 data model + MOD-005 design §3.
core.rate_change_proposals (V001) — maker/checker workflow¶
| Column | Notes |
|---|---|
proposal_id |
uuid PK, gen_uuidv7 |
product_code, rate_type |
identity tuple (FK to accounts.account_products) |
new_annual_rate, previous_annual_rate |
numeric(8,6); previous nullable for first-ever rate |
change_kind |
CHECK ∈ |
tp_rate_bps, margin_bps, lvr_min, lvr_max, credit_tier |
lending-only (mirror SD01 shape) |
change_reason, is_retroactive |
FR-067 supervisor-approval flag |
proposed_by (uuid, no FK), proposed_at |
maker identity |
status |
CHECK ∈ |
reviewed_by (uuid, no FK), reviewed_at, review_comment |
checker identity |
effective_from |
activation date |
applied_at, rate_id |
populated when status='live' |
customer_notice_required |
true iff FR-516 applies |
customer_notice_published_at |
stamped at approval when notify event ships |
before_state |
jsonb snapshot of prior accounts.interest_rates row |
idempotency_key UNIQUE NOT NULL, trace_id, correlation_id |
ADR-048 / observability |
Constraints:
- no_self_approval — reviewed_by IS NULL OR reviewed_by <> proposed_by
- chk_reviewed_at_iff_reviewed_by — coherence
- chk_applied_at_iff_live — coherence
- chk_rate_id_iff_live — coherence
- chk_advance_notice_window — FR-516: when customer_notice_required, effective_from >= proposed_at::date + 14 days
- chk_decrease_no_notice — FR-517: DECREASE never triggers notice
- chk_notice_published_iff_required — coherence
- chk_rate_proposal_effective_window (V003) — FR-067: forward-dated requires effective_from >= proposed_at::date; retroactive bypasses
core.rate_change_events (V002) — append-only audit log¶
| Column | Notes |
|---|---|
event_id |
uuid PK, gen_uuidv7 |
event_type |
CHECK ∈ |
proposal_id, product_code, rate_type |
denormalised for SQL audit queries |
actor_kind, actor_id |
agent / staff / system |
metadata, before_state, after_state |
jsonb |
idempotency_key UNIQUE NOT NULL |
retry-safe writes |
trace_id, correlation_id, occurred_at |
observability |
Append-only enforced by V003 immutability triggers + privilege revoke.
4. ADR-048 DB-enforced invariants register¶
| Item | Migration | Negative test |
|---|---|---|
trg_rate_change_events_no_{update,delete,truncate} |
V003 | tests/integration/db-trigger-rate-events-immutable.test.ts |
chk_rate_proposal_effective_window (FR-067 backdate gate) |
V003 | tests/integration/db-check-rate-temporal.test.ts |
chk_advance_notice_window (FR-516) |
V001 | tests/integration/db-check-rate-temporal.test.ts |
chk_decrease_no_notice (FR-517) |
V001 | tests/integration/db-check-rate-temporal.test.ts |
uniq_rate_change_events_idempotency_key |
V002 | tests/integration/db-unique-rate-events-idempotency-key.test.ts |
no_self_approval (FR-067 four-eyes) |
V001 | tests/integration/db-check-no-self-approval.test.ts |
accounts.interest_rates.annual_rate >= 0 (Cat 1) |
MOD-005 V001 | covered by rate-validator-pure unit tests |
5. FR mapping¶
| FR | Mode | Implementation |
|---|---|---|
| FR-065 (5-min propagation) | event-driven | Activator emits bank.core.rate_change_activated post-commit; consumers (MOD-005 daily accrual) read accounts.interest_rates on demand. The 5-min budget is the time between commit and EventBridge delivery. |
| FR-066 (versioned history, no deletion) | structural | Two-table pattern: accounts.interest_rates (current + closed periods) + core.rate_change_events (append-only audit). Immutability triggers + UNIQUE NOT NULL idempotency_key. |
| FR-067 (backdate supervisor approval) | gated | is_retroactive flag; pure validator rejects past-dated effective_from when the flag is false. The four-eyes review is the supervisor approval per FR wording. DB CHECK chk_rate_proposal_effective_window is defence in depth. |
| FR-068 (30s notify after activation) | event-driven | Activator emits bank.core.rate_change_activated post-commit. The audit row is the source of truth; the bus event is the propagation contract. |
| FR-516 (14-day advance notice) | gated + event | Pure validator → service-layer ComplianceBlock + DB CHECK chk_advance_notice_window. Event bank.core.rate_change_notified published at approval time for retail variable-rate increases (consumed by MOD-063). |
| FR-517 (decreases immediate, increases gated) | structural | classifyChangeKind + requiresCustomerNotice. DECREASE bypasses the 14-day window. DB CHECK chk_decrease_no_notice ensures structural correctness. |
| NFR-012 (write p99 ≤ 10ms) | perf | Single-INSERT path on the hot route (proposeRateChange + reviewApprove). Indexes tuned for the daily activator scan + back-office UI list. |
| NFR-015 (CDC ≤ 5min) | infra | accounts.interest_rates + core.rate_change_events flow through MOD-042 CDC; no MOD-006 code change. |
| NFR-024 (audit log = 0 modifications) | structural | V003 immutability triggers reject UPDATE/DELETE/TRUNCATE on core.rate_change_events. |
6. Policies satisfied¶
| Policy | Mode | How satisfied | Test |
|---|---|---|---|
| CON-005 | AUTO | accounts.interest_rates is the canonical source for product-level rates (FR-062 → MOD-005 reads here every accrual run). Activation closes the prior row + inserts the new in one tx — exactly one active row per (product, rate_type) at any moment. | tests/policy/con-005-auto-rate-propagation.test.ts |
| CON-004 | ALERT | INCREASE on retail variable products sets customer_notice_required=true, stamps customer_notice_published_at at approval, writes RATE_CHANGE_NOTIFIED audit row, publishes bank.core.rate_change_notified to MOD-063. DECREASE bypasses (FR-517). |
tests/policy/con-004-alert-notification-flag.test.ts |
| CLQ-004 | CALC | Every rate change carries product_code + rate_type + annual_rate triple on accounts.interest_rates, on core.rate_change_proposals, on core.rate_change_events.metadata, and on bank.core.rate_change_activated. MOD-031 (when it ships) can derive the IRRBB repricing position from the rate-change stream alone. |
tests/policy/clq-004-calc-irrbb-metadata.test.ts |
7. SSM outputs¶
| Path | Value |
|---|---|
/bank/{stage}/mod-006/api/base-url |
API Gateway base URL |
/bank/{stage}/mod-006/lambdas/{propose-rate-change,review-approve,review-reject,list-proposals,get-proposal,apply-effective-rate-changes}/arn |
per-handler ARN |
/bank/{stage}/mod-006/tables/{rate-change-proposals,rate-change-events,interest-rates}/name |
table FQNs |
8. Cross-module touches¶
None. MOD-006 ships two new tables (core.rate_change_proposals,
core.rate_change_events), five new EventBridge events on the existing
bank-core bus, and 6 new Lambdas. No ALTERs on existing tables, no
schema changes to accounts.interest_rates (MOD-005 V001 created it),
no source-code changes to MOD-005 / MOD-001 / others.
9. Test approach + results¶
| Tier | Files | Local result |
|---|---|---|
| Unit | tests/unit/{errors, logger, emf, rate-validator-pure}.test.ts |
43 / 43 |
| Contract | tests/contract/rate-events.test.ts |
8 / 8 |
| FR integration | tests/integration/fr-{065,066,067,068,516,517}-*.test.ts |
run in CI |
| ADR-048 negative | tests/integration/db-{trigger,check,unique}-*.test.ts |
run in CI |
| Policy | tests/policy/{con-005, con-004, clq-004}-*.test.ts |
run in CI |
Local total: 51 / 51 unit + contract.
10. Architectural decisions captured here¶
-
Two-table proposal pattern —
accounts.interest_ratesis the active master;core.rate_change_proposalsis the workflow row. before_state snapshot on the proposal carries the prior rate so the audit log is self-contained. -
Maker/checker for ALL rate changes (orchestrator decision §AD-2) — the four-eyes pattern from MOD-140 is applied uniformly. FR-067's "supervisor approval" maps directly to the reviewer role.
-
Default chart-style cron (orchestrator decision §AD-3) —
cron(0 13 * * ? *)UTC = 01:00 NZDT / 02:00 NZST, mirroring MOD-140's daily activator. DST-aware EventBridge Scheduler is named tech debt (same item as MOD-005 + MOD-140). -
Fee rate changes deferred (orchestrator decision §AD-4) —
core.fee_scheduleis MOD-110's territory; v1 coversaccounts.interest_ratesonly. Future MOD-006 v2 or MOD-110 enhancement. -
Retroactive correction is event-driven (orchestrator decision §AD-5) —
bank.core.rate_change_activatedcarriesis_retroactive=true -
effective_from; MOD-005 consumes and triggers its own FR-064 correction pathway. MOD-006 does not directly invoke MOD-005's HTTP API. -
Cross-domain UUIDs without FK (orchestrator decision §AD-6) —
proposed_by,reviewed_byare uuid columns with NO FK. DB CHECKno_self_approvalenforces non-equality. Identity ownership stays in future staff-domain modules. -
FR-516 enforced at three layers — pure validator (
rate-validator-pure.ts), service layer (proposal-store.tsComplianceBlock at submit time + re-gate at approval time), DB CHECK (chk_advance_notice_window). Defence in depth. -
Per-module governance log + cross-domain MOD-047 deferred — v1 writes to
core.rate_change_events; cross-domainagent.action_taken/staff.action_takento bank-platform bus is named debt for the next cross-cutting audit cycle (same item as MOD-125, MOD-133, MOD-134, MOD-140).
11. Required wiki updates (apply via separate bank-wiki commit)¶
11.1 MOD-006.yaml¶
Per orchestrator decision §AD-1 (FR-516/517 in v1 scope):
requirements:
- FR-065
- FR-066
- FR-067
- FR-068
- FR-516 # ADD
- FR-517 # ADD
- NFR-012
- NFR-015
- NFR-024
dependencies:
- module: MOD-002
optional: false
reason: Established the bank-core EventBridge bus contract and per-module Lambda observability patterns. (Reading 2A — wiki dep reason reworded; rate-change events transit the same bus but are not ledger postings, so no MOD-002 service-call dependency.)
- module: MOD-005
optional: false
reason: ...
- module: MOD-063 # ADD
optional: false
reason: FR-516 customer notification of rate increases is dispatched via the notification orchestration module (bank-platform bus, bank.core.rate_change_notified consumer).
- module: MOD-104
optional: false
reason: ...
- module: MOD-103
optional: false
reason: ...
11.2 SD01 data model¶
Add two new tables under the existing core schema:
- core.rate_change_proposals (maker/checker workflow; CHECK no_self_approval; chk_advance_notice_window)
- core.rate_change_events (append-only audit; immutability triggers; idempotency_key UNIQUE NOT NULL)
11.3 Event catalogue¶
Add five new events under bank.core.*. All schema_version = "1".
| DetailType | Producer | Consumers (anticipated) |
|---|---|---|
bank.core.rate_change_proposed |
MOD-006 | (audit + dashboards) |
bank.core.rate_change_approved |
MOD-006 | (audit + dashboards) |
bank.core.rate_change_rejected |
MOD-006 | (audit + dashboards) |
bank.core.rate_change_notified |
MOD-006 | MOD-063 (FR-516 customer notification) |
bank.core.rate_change_activated |
MOD-006 | MOD-005 (FR-062 rate read), MOD-031 (CLQ-004 IRRBB), MOD-042 (CDC) |
Schemas materialised in MOD-006-rate-change-propagation/src/types/rate.ts and verified against zod in tests/contract/rate-events.test.ts.
12. Verification results (2026-05-05)¶
| Gate | Result |
|---|---|
pnpm install |
clean |
pnpm typecheck (workspace, 14 packages) |
clean |
pnpm test:unit MOD-006 |
51 / 51 |
pnpm test:integration MOD-006 |
(run in CI under RUN_INTEGRATION=1) |
| MOD-006 V001-V003 migrate against dev Neon | (run by reusable-lambda.yml) |
13. Known follow-ups for subsequent modules¶
- MOD-031 IRRBB write-back — Not started. Will subscribe to
bank.core.rate_change_activatedfor CLQ-004 repricing-gap derivation. - MOD-063 retail notice consumer — bank-platform module. The
FR-516
bank.core.rate_change_notifiedevent subscription wiring lands when MOD-063 is enhanced for rate-change notice templates. v1 of MOD-006 publishes the event into the void; the audit row incore.rate_change_eventsis the source of truth. - MOD-110 fee rate propagation —
core.fee_scheduleis MOD-110's territory. A future MOD-110 enhancement (or a MOD-006 v2 expansion) applies the same proposal/review/activation pattern to fee rate changes. - MOD-111 / MOD-112 term-deposit + amortisation re-rate — when Phase 6 / Phase 7 land, the rate-rollover / re-amortisation semantics will need coordinated wiring through MOD-006 (term deposits especially: at maturity the new rate proposal cycle kicks off automatically, not via the back-office UI).
- MOD-005 retroactive correction — when MOD-006 emits
rate_change_activatedwithis_retroactive=true, MOD-005's consumer wires into its FR-064 correction flow. v1 of MOD-006 ships without that consumer in place; the event payload is already shaped for it. - DST-aware EventBridge schedules — current cron pinned to
standard time; will fire at 22:55 local during DST (+1 hour). Same
named tech debt as MOD-005 + MOD-140; switch to EventBridge
Scheduler
ScheduleExpressionTimezonefor floating local-time cron. - MOD-047 cross-domain governance bus integration —
agent.action_taken/staff.action_takenevents to bank-platform bus are deferred (same as MOD-125 / 133 / 134 / 140).
14. Build status update¶
Once MOD-006 reaches the full ADR-053 pipeline (Built + Deployed via the reusable workflow's CI handoff):