MOD-132 — Loan restructure & variation workflow¶
System: SD05 Credit Decisioning · Repo: bank-credit Status: In progress (will advance via CI handoff) ADRs: ADR-031, ADR-048, ADR-053, ADR-063, ADR-064 FRs: FR-589, FR-590, FR-591, FR-592 · NFR: NFR-007 Policies: CRE-001 GATE, CON-004 AUTO, CON-005 GATE, REP-004 LOG
1. Purpose¶
Orchestrates customer-initiated changes to the terms of an existing loan — term extension, repayment frequency change, fixed↔variable rate switch, repayment restructure, arrears capitalisation, partial / full early repayment. Distinct from MOD-139 hardship; this is a routine commercial product feature governed by a structured workflow.
Two regulatory gates:
- Creditworthiness re-assessment via MOD-029 for material variations
(CRE-001 GATE). Materiality is decided by the in-module rules table
(k-4 ratification —
MATERIALITY_RULES_VERSION='v1.0.0'). - Break-cost disclosure ack via MOD-050 for any fixed-rate-period exit (CON-005 GATE). MOD-163 is the canonical break-cost calculator (k-2 — the wiki spec said MOD-116 but the actual engine is MOD-163; MOD-116 wraps MOD-163 for mortgage-specific flows).
2. Architecture¶
HTTP POST request-variation ─▶ Lambda (URL/IAM) 202 async
EventBridge bank.credit.credit_decision_made (same-bus, SQS+DLQ)
─▶ Lambda (SQS consumer)
HTTP POST issue-break-cost-disclosure ─▶ Lambda (URL/IAM) synchronous (MOD-163 + MOD-050)
HTTP POST confirm-variation ─▶ Lambda (URL/IAM) synchronous, CON-004 + CON-005 GATE + MOD-112 stub
HTTP POST reject-variation ─▶ Lambda (URL/IAM)
HTTP POST escalate-to-case ─▶ Lambda (URL/IAM) agent-only, k-10
EventBridge Scheduler cron(0 13 * * ? *) UTC
─▶ Lambda (expire-disclosed-sweep)
7 Lambdas in total. The SQS+DLQ pair handles MOD-029 decision results (EB → SQS → consumer; ReportBatchItemFailures). DLQ retention 14 days, queue retention 4 days, visibility 60s, max receive 5.
The expire-disclosed sweep cron is DISABLED in non-prod (matches MOD-162 / MOD-163 patterns); prod enabled at 13:00 UTC = 01:00 NZST.
Cross-service integration¶
| Upstream | Pattern | SSM path | Behaviour |
|---|---|---|---|
| MOD-029 | same-bus EB consumer + same-DB lookup of credit.credit_decisions |
n/a | Decisions arrive on bank.credit.credit_decision_made filtered on idempotency_key. |
| MOD-050 | SigV4 HTTP lambda |
/bank/{env}/customer/disclosures/{issue,lookup}-api |
Template id loan-variation-revised-terms-v1. Synchronous lookup on confirm; CON-004 AUTO. |
| MOD-053 | SigV4 HTTP lambda |
/bank/{env}/customer/cases/create-api |
Agent-only escalation path (k-10). Optional dep. |
| MOD-163 | SigV4 HTTP lambda |
/bank/{env}/credit/break-cost/{calculate-binding,confirm-acknowledgement}-api-endpoint |
Break-cost calc + ack confirm. Hash includes formula_version (k-4 on MOD-163). |
| MOD-112 | SigV4 HTTP lambda (k-3 STUB) |
/bank/{env}/core/loans/recalc-variation-api (not yet published) |
Placeholder URL → schedule-client returns UNAVAILABLE. Non-fatal. bank-core issue #36 carries the upstream decision. |
3. Data model¶
Two tables in the credit schema of the consolidated bank Neon DB:
credit.loan_variations — mutable workflow row¶
Frozen at INSERT, status + lifecycle reference fields UPDATE-able:
| Column | Type | Purpose |
|---|---|---|
variation_id |
uuid PK | |
loan_account_id |
uuid NOT NULL FK → credit.loan_accounts(id) | k-1 |
variation_type |
text CHECK IN (6 enum values) | |
previous_terms / proposed_terms |
jsonb NOT NULL | Snapshot of pre/post terms |
assessment_required / break_cost_required |
bool | Routing flags from materiality (k-4) |
materiality_rules_version |
text NOT NULL | 'v1.0.0' for v1 |
status |
text CHECK IN (requested, assessing, assessed, disclosed, confirmed, rejected, expired) |
k-5 |
credit_check_id / disclosure_id / break_cost_calculation_id / break_cost_acknowledgement_id / case_id |
nullable refs | Lifecycle collection |
schedule_regen_status |
text CHECK IN (PENDING,SCHEDULED,UNAVAILABLE,APPLIED) |
k-3 stub state |
schedule_regen_ref |
text NULL | MOD-112 ref when applied |
requested_by_party_id / requested_by_type / agent_id |
k-11 discriminator | |
rejection_reason / rejection_source (CHECK 4 values) |
nullable | k-5 |
idempotency_key (UNIQUE) / trace_id |
text | k-7 + observability |
expires_at |
timestamptz NULL | Set on disclosed (5 NZ business days) |
created_at / updated_at |
timestamptz | Audit |
Partial UNIQUE (k-8): UNIQUE (loan_account_id) WHERE status IN
('requested','assessing','assessed','disclosed') — only one in-flight
variation per loan_account at a time.
Frozen-column trigger: fn_loan_variations_frozen_columns blocks
DELETE outright and refuses UPDATE on any column other than the lifecycle
set (status, ref IDs, rejection fields, expires_at, schedule_regen_status,
schedule_regen_ref, acknowledged_at, updated_at).
credit.loan_variation_events — Cat 1 immutable¶
Append-only event log satisfying REP-004 LOG:
| Column | Notes |
|---|---|
event_id uuid PK |
|
variation_id FK → loan_variations |
|
event_type text CHECK IN (13 values: REQUESTED, ASSESSMENT_INVOKED, ASSESSMENT_APPROVED, ASSESSMENT_DECLINED, BREAK_COST_CALCULATED, BREAK_COST_ACKNOWLEDGED, DISCLOSURE_DISPATCHED, CONFIRMED, REJECTED, EXPIRED, CASE_ESCALATED, SCHEDULE_REGEN_REQUESTED, SCHEDULE_REGEN_FAILED) |
|
actor_party_id + actor_type (CUSTOMER/AGENT/SYSTEM) |
|
detail jsonb |
Before/after deltas, reason codes, refs |
trace_id text |
|
created_at timestamptz |
Cat 1 trigger trg_loan_variation_events_immutable reuses the shared
credit.fn_immutable_row() from MOD-128. UPDATE and DELETE both blocked
at the DB layer.
Shared credit.idempotency_keys (MOD-128 V002) is used with
module_id='MOD-132', 24h TTL (k-7).
4. State machine (k-5)¶
requested
├─ assessment_required → assessing
├─ break_cost_required (no asmt) → disclosed (via issue-break-cost-disclosure)
└─ neither → disclosed (via consume-credit-decision: not used here; direct path)
assessing
├─ APPROVED + no break_cost → disclosed (immediately by consume-credit-decision)
├─ APPROVED + break_cost_required → assessed (then → disclosed via issue-break-cost-disclosure)
└─ DECLINED / REFERRED → rejected (terminal)
assessed
├─ break-cost calc + disclosure → disclosed (via issue-break-cost-disclosure)
└─ rejection → rejected (terminal)
disclosed
├─ confirm (ack verified) → confirmed (terminal)
├─ customer rejects → rejected (terminal)
└─ expires_at elapses (cron) → expired (terminal)
Three terminal states: confirmed, rejected, expired.
5. Materiality rules (k-4 — v1.0.0)¶
Hard-coded table in src/lib/materiality.ts. Versioned via
MATERIALITY_RULES_VERSION = 'v1.0.0' constant; persisted on every
variation row for audit. Bumping the version is a code release. AppConfig-
driven materiality deferred to v2.
| Variation type | Assessment required | Break cost required |
|---|---|---|
term_extension if months ≥ 12 |
yes | no |
term_extension if months < 12 |
no | no |
frequency_change |
no | no |
rate_type_switch FIXED→FLOATING |
no | yes |
rate_type_switch FLOATING→FIXED |
no | no |
capitalisation_of_arrears |
yes | no |
repayment_restructure |
yes | no |
early_repayment on FIXED |
no | yes |
early_repayment on FLOATING |
no | no |
Materiality decisions are logged in the REQUESTED event row of the event log (alongside before/after terms).
6. NFR-007 latency budget¶
| Endpoint | Mode | Budget (p99) |
|---|---|---|
request-variation |
URL/IAM, 202 async | 500 ms (no upstream sync calls — DB writes only) |
confirm-variation |
URL/IAM, sync | 500 ms (MOD-050 lookup + MOD-112 stub + 1 DB tx) |
issue-break-cost-disclosure |
URL/IAM, sync | 1000 ms (MOD-163 calc + MOD-050 issue) — bounded by upstreams |
reject-variation / escalate-to-case |
URL/IAM, sync | 500 ms |
request-variation is async per k-13: returns 202 immediately, lets
consume-credit-decision resolve the MOD-029 result out-of-band.
7. Events published¶
| Detail-type | Trigger | Consumers |
|---|---|---|
bank.credit.loan_variation_requested |
request-variation handler | MOD-076 (obs), MOD-063 (notify customer of receipt) |
bank.credit.loan_variation_confirmed |
confirm-variation handler | MOD-112 (schedule regen), MOD-063 (notify), MOD-076, MOD-042 (CDC) |
bank.credit.loan_variation_rejected |
reject-variation handler | MOD-063 (notify), MOD-076 |
Schema version v1; all three detail shapes documented in
src/types/events.ts with field-level types. Contract test in
tests/contract/events.test.ts asserts every required key is emitted.
Consumed¶
| Detail-type | Source bus | Filter | Action |
|---|---|---|---|
bank.credit.credit_decision_made |
bank-credit (same bus) | downstream by idempotency_key match |
consume-credit-decision transitions assessing → assessed / disclosed / rejected |
No cross-bus consumers (k-12).
8. k-N ratifications¶
| # | Topic | Decision |
|---|---|---|
| k-1 | FK target | loan_account_id REFERENCES credit.loan_accounts(id) (wiki spec said credit.loans — stale; data-model gap handoff filed). |
| k-2 | Break cost caller | MOD-132 → MOD-163 directly (calculate-binding + confirm-acknowledgement). MOD-116 is mortgage-only. FR-591 wiki addendum filed. |
| k-3 | MOD-112 recalc | STUB + HANDOFF. SCHEDULE_REGEN_URL placeholder → UNAVAILABLE. Non-blocking. bank-core issue #36 tracks. |
| k-4 | Materiality | Hard-coded v1.0.0; AppConfig deferred to v2; materiality_rules_version persisted on every row. |
| k-5 | State machine | 7-state machine ratified. Terminal: confirmed / rejected / expired. |
| k-6 | Component-level | Deferred to v2 — v1 supports loan_accounts-level variations only. |
| k-7 | Idempotency | Mandatory on request-variation; credit.idempotency_keys, 24h TTL. |
| k-8 | In-flight lock | Partial UNIQUE (loan_account_id, status) WHERE in-flight; second submission → 403 IN_FLIGHT_VARIATION_EXISTS. |
| k-9 | Two-table split | loan_variations mutable + loan_variation_events Cat 1 immutable. |
| k-10 | MOD-053 escalation | Agent-only endpoint; no auto-escalation in v1. |
| k-11 | Requester discriminator | requested_by_party_id + requested_by_type + agent_id. |
| k-12 | Cross-bus consumers | None in v1. |
| k-13 | NFR-007 | request-variation 202 async ≤ 500 ms; confirm-variation sync ≤ 500 ms. |
9. Known gaps (v2)¶
- MOD-112 schedule regen — k-3 stub until bank-core issue #36
resolves.
schedule_regen_statuswill start landing asSCHEDULED/APPLIEDonce the URL goes live. - Component-level variations — k-6 deferred. The variation row
currently FKs to
loan_accounts(id). Component-level requires a separate variation_type or a polymorphic FK; v2 design TBD. - AppConfig-driven materiality — k-4 deferred. Currently a code release to change the rules.
- Automated case escalation — k-10 ratification restricts to agent-triggered only; future auto-escalation policy (e.g. on ambiguous decisions) is a v2 product decision.
10. SSM outputs¶
/bank/{env}/credit/variation/request-function-arn
/bank/{env}/credit/variation/request-api-endpoint
/bank/{env}/credit/variation/issue-break-cost-function-arn
/bank/{env}/credit/variation/issue-break-cost-api-endpoint
/bank/{env}/credit/variation/confirm-function-arn
/bank/{env}/credit/variation/confirm-api-endpoint
/bank/{env}/credit/variation/reject-function-arn
/bank/{env}/credit/variation/reject-api-endpoint
/bank/{env}/credit/variation/escalate-function-arn
/bank/{env}/credit/variation/escalate-api-endpoint
/bank/{env}/credit/variation/expire-sweep-function-arn
/bank/{env}/credit/variation/consume-credit-decision-function-arn
/bank/{env}/credit/tables/loan-variations/name -> "credit.loan_variations"
/bank/{env}/credit/tables/loan-variation-events/name -> "credit.loan_variation_events"
Contract package @bank-credit/mod-132-contracts@1.0.0 exposes the
minimum-viable shape (MODULE_ID + ssmPath helper). Cross-repo consumers
adopt incrementally as integrations materialise.