MOD-030 — Stage allocation model¶
System: SD05 — Credit Decisioning & Loan Platform Repo: bank-credit Status (at handoff time): In progress → Built → Deployed (CI-driven) Owner: SD05 implementation agent
Purpose¶
Allocates the IFRS 9 stage (1, 2 or 3) to every active loan facility, end-to-end:
- Stage 1 — performing (12-month ECL).
- Stage 2 — significant increase in credit risk (lifetime ECL).
- Stage 3 — credit-impaired (lifetime ECL on net carrying amount).
MOD-031 reads the latest row per loan from credit.ifrs9_stage_allocations
to compute the ECL provision and post the GL entry. MOD-030 is the only
SD05 module that writes that table.
Functional requirements satisfied¶
| FR | Mode | Implementation |
|---|---|---|
| FR-181 | CALC | DPD-driven Stage 1/2/3 in services/stage-allocator.ts; thresholds 30/90 |
| FR-182 | CALC | PD-doubling SICR (Stage 1→2) + cure (Stage 2→1) in stage-allocator.ts |
| FR-183 | CALC | DEFAULT/WRITE_OFF_PENDING/WRITTEN_OFF loan_status forces Stage 3 |
| FR-184 | GATE | Stage 3 → 1/2 only via stage-3-override Function URL gated by committee_approval_id; biconditional CHECK at DB layer |
| NFR-006 | LATENCY | Daily sweep cron(0 17 * * ? *) UTC = 05:00 NZST, before MOD-031 08:00 ECL run |
| NFR-024 | IDEMPOTENCY | Dual-layer guard — idempotency_keys (per event_id, per sweep day) + (loan_account_id, effective_date) exists check |
| NFR-010 | OBS | ADR-031 structured logs + EMF metrics on every allocation / transition / skipped check |
Architecture¶
┌──────────────────┐ ┌──────────────────────┐
│ MOD-029 / MOD-065│ │ MOD-065 │
│ facility_status_ │ │ arrears_triggered │
│ changed │ │ │
└─────────┬────────┘ └─────────┬────────────┘
│ │
▼ ▼
┌─────────────────────────────────┐
│ EB rule (bank-credit, same-bus) │
│ + SQS facility queue + DLQ │
└────────────────┬────────────────┘
▼
┌──────────────────────────┐
│ consume-facility-event │ Stage allocator (pure)
└──────────────┬───────────┘
▼ ┌────────────────┐
┌──────────────────────────┐ │ daily-stage- │
│ ifrs9_stage_allocations │◀──────▶│ sweep (cron 17 │
│ (Cat 1 immutable) │ │ UTC) │
└──────────────┬───────────┘ └────────────────┘
▼
bank.credit.stage_allocated
│
▼
┌────────────────┐
│ stage-3- │
│ override (URL) │ FR-184 GATE
└────────────────┘
Data model¶
credit.ifrs9_stage_allocations (NEW)¶
Append-only Cat 1 immutable. Latest row per loan
(effective_date DESC, allocated_at DESC) is the current stage.
| column | type | notes |
|---|---|---|
| id | uuid PK | gen_random_uuid() |
| loan_account_id | uuid FK loan_accounts | |
| party_id | uuid | |
| jurisdiction | char(2) NOT NULL | NZ/AU — denormalised so MOD-031 reads without a join (AD k-1) |
| ifrs9_stage | smallint | 1 / 2 / 3 |
| previous_ifrs9_stage | smallint NULL | for transition logging |
| trigger_reason | text | INITIAL_ALLOCATION / DPD_THRESHOLD / WATCHLIST_FLAG / PD_INCREASE / CREDIT_IMPAIRED / CURE_TO_STAGE_1 / MANUAL_OVERRIDE / MONTHLY_REASSESSMENT |
| arrears_days_at_allocation | int | |
| risk_rating | text NULL | A1..E or null when no score on file |
| pd_origination, pd_lifetime | numeric(8,6) NULL | both null when risk_rating null (AD k-4) |
| exposure_at_default | numeric(18,2) | sourced from loan_accounts.outstanding_principal |
| loan_status_at_allocation | text | |
| effective_date | date | YYYY-MM-DD |
| allocated_at | timestamptz | |
| source_event_id | uuid NULL | inbound EB event_id for FACILITY_EVENT |
| source | text | FACILITY_EVENT / DAILY_SWEEP / MANUAL_OVERRIDE / MONTHLY_REASSESSMENT |
| committee_approval_id | text NULL | FR-184 GATE — see CHECK |
| override_reason / override_actor | text NULL | populated on MANUAL_OVERRIDE |
| trace_id | uuid NOT NULL |
chk_manual_override_governance (biconditional):
trigger_reason = 'MANUAL_OVERRIDE' AND committee_approval_id IS NOT NULL AND override_actor IS NOT NULL
OR
trigger_reason <> 'MANUAL_OVERRIDE' AND committee_approval_id IS NULL
This is one of the two FR-184 GATE defence layers. The handler enforces
the same invariant at the API edge (403 COMPLIANCE_BLOCK with error_code
COMMITTEE_APPROVAL_REQUIRED); the DB constraint catches direct-SQL
attempts.
Lambdas¶
| name | trigger | purpose |
|---|---|---|
| consume-facility-event | SQS (EB rule on facility_status_changed + arrears_triggered) | initial allocation on PENDING_DISBURSEMENT→ACTIVE; re-evaluate on every other event |
| daily-stage-sweep | EB Scheduler cron(0 17 * * ? *) UTC = 05:00 NZST |
sweep all active loans daily — picks up missed events + monthly reassessment |
| stage-3-override | Function URL (AWS_IAM) | manual override path (FR-184); biconditional CHECK at DB + COMPLIANCE_BLOCK at handler |
SSM outputs¶
| path | type | purpose |
|---|---|---|
/bank/{stage}/credit/staging/consume-facility-function-arn |
String | Lambda ARN |
/bank/{stage}/credit/staging/consume-facility-function-name |
String | Lambda name |
/bank/{stage}/credit/staging/daily-stage-sweep-function-arn |
String | Lambda ARN |
/bank/{stage}/credit/staging/stage-3-override-function-arn |
String | Lambda ARN |
/bank/{stage}/credit/staging/stage-3-override-function-name |
String | Lambda name |
/bank/{stage}/credit/staging/stage-3-override-api-endpoint |
String | Function URL endpoint |
/bank/{stage}/credit/tables/ifrs9-stage-allocations/name |
String | credit.ifrs9_stage_allocations |
Events¶
Published (1)¶
bank.credit.stage_allocated — emitted on every new row inserted into
credit.ifrs9_stage_allocations. Schema (AD k-7 — risk_rating is the
new field vs the wiki catalogue baseline):
{
"event_id": "uuid",
"event_time": "ISO-8601",
"schema_version": "v1",
"trace_id": "uuid",
"loan_account_id": "uuid",
"customer_id": "uuid",
"jurisdiction": "NZ" | "AU",
"ifrs9_stage": 1 | 2 | 3,
"previous_ifrs9_stage": 1 | 2 | 3 | null,
"trigger_reason": "<TriggerReason>",
"arrears_days": 0,
"exposure_at_default": "decimal-as-string",
"risk_rating": "A1..E | null",
"effective_date": "YYYY-MM-DD",
"idempotency_key": "string"
}
Consumed (2 — same-bus, no cross-bus grant required)¶
bank.credit.facility_status_changed(MOD-029, MOD-065)bank.credit.arrears_triggered(MOD-065)
PD lookup (AD k-4)¶
Per-rating defaults baked into services/pd-lookup.ts:
NULL handling: when the loan has no credit_scores row, risk_rating is
null → lookup returns null → allocator sets pd_sicr_skipped: true,
falls back to DPD-only staging, and emits the PdSicrSkippedCount EMF
metric. Observability lets us watch for un-scored loans without failing the
allocation.
Treasury override: the lookup table accepts an AppConfig-supplied
override map at construction time (v2 wiring; v1 ships with the locked
defaults).
Idempotency (AD k-9 dual-layer)¶
Daily sweep:
- Layer 1 —
idempotency_keyskeyedmod030:sweep:{YYYY-MM-DD}; second invocation in the same UTC day returns cachedStageSweepResult. - Layer 2 —
existsForLoanAndDate(loan_id, effective_date)skip in-loop; even with a cold key cache, a per-loan-per-day duplicate row is not written.
Facility-event consumer: keyed by inbound event_id so SQS redelivery
is a no-op.
Timing contract (AD k-6)¶
04:00 NZST MOD-065 daily-arrears-sweep (arrears_days, schedule MISSED)
05:00 NZST MOD-030 daily-stage-sweep (read MOD-065 outputs → stage)
08:00 NZST MOD-031 ECL run (1st BD of mo) (read MOD-030 latest stage)
If the 05:00 NZST run takes longer than 45 minutes, an alarm should fire to MOD-065 / on-call so MOD-031 doesn't start with stale staging. The alarm itself is filed as a wiki todo (governance-CFO sign-off handoff covers the supervisory note).
CI / build¶
Caller workflow .github/workflows/mod-030.yml delegates to
totara-bank/bank-platform/reusable-lambda.yml@main with
has_postgres: true. CI runs typecheck → unit → policy → flyway validate
→ deploy → flyway migrate → pg_dump snapshot → integration → smoke
(verify-deployment.mjs).
Risks + open items¶
- Stage 2 → 1 cure policy — needs CFO sign-off (CRE-006 requires
documented cure criteria). Filed as
MOD-030-governance-cfo-cure-signoff.handoff.md. risk_scores_mirroris not yet wired up — when MOD-028 finishes the mirror, this allocator will benefit from the SD06 risk update path (already emitted onbank.credit.stage_allocatedconsumers).- Wiki data-model gap —
credit.ifrs9_stage_allocationsis not in the SD05 data model. Filed asMOD-030-data-model-gap.handoff.md. - MOD-031 consumer — MOD-031 (next module) needs a consumer for
bank.credit.stage_allocated. Filed asMOD-031-stage-allocated-consumer.handoff.md.