Skip to content

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 ratesBASE / BONUS / OVERDRAFT / VARIABLE_LENDING on SAVINGS / TRANSACTION products. FR-516 applies (advance-notice gate on increases). - Other rate typesPENALTY, 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_approvalreviewed_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 patternaccounts.interest_rates is the active master; core.rate_change_proposals is 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_schedule is MOD-110's territory; v1 covers accounts.interest_rates only. Future MOD-006 v2 or MOD-110 enhancement.

  • Retroactive correction is event-driven (orchestrator decision §AD-5) — bank.core.rate_change_activated carries is_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_by are uuid columns with NO FK. DB CHECK no_self_approval enforces 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.ts ComplianceBlock 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-domain agent.action_taken / staff.action_taken to 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_activated for CLQ-004 repricing-gap derivation.
  • MOD-063 retail notice consumer — bank-platform module. The FR-516 bank.core.rate_change_notified event 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 in core.rate_change_events is the source of truth.
  • MOD-110 fee rate propagationcore.fee_schedule is 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_activated with is_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 ScheduleExpressionTimezone for floating local-time cron.
  • MOD-047 cross-domain governance bus integrationagent.action_taken / staff.action_taken events 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):

# bank-wiki processes docs/handoffs/MOD-006-ci-{built,deployed}-{sha}.handoff.md
# automatically — no manual update-wiki.py call needed.