Skip to content

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:

A1 = 0.005   A2 = 0.010
B1 = 0.020   B2 = 0.040
C1 = 0.070   C2 = 0.120
D  = 0.180   E  = 0.280

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:

  1. Layer 1idempotency_keys keyed mod030:sweep:{YYYY-MM-DD}; second invocation in the same UTC day returns cached StageSweepResult.
  2. Layer 2existsForLoanAndDate(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_mirror is not yet wired up — when MOD-028 finishes the mirror, this allocator will benefit from the SD06 risk update path (already emitted on bank.credit.stage_allocated consumers).
  • Wiki data-model gapcredit.ifrs9_stage_allocations is not in the SD05 data model. Filed as MOD-030-data-model-gap.handoff.md.
  • MOD-031 consumer — MOD-031 (next module) needs a consumer for bank.credit.stage_allocated. Filed as MOD-031-stage-allocated-consumer.handoff.md.