Skip to content

MOD-031 — ECL calculation & GL posting

System: SD05 — Credit Decisioning & Loan Platform Repo: bank-credit Status (at handoff time): In progress → Built → Deployed (CI-driven) Owner: SD05 implementation agent

Purpose

Daily IFRS 9 Expected Credit Loss (ECL) calculation per active loan with GL posting via MOD-001 (the only SD05 module that calls MOD-001 directly).

  • FR-185 — ECL = PD × LGD × EAD per loan, daily; post to GL.
  • FR-186 — Portfolio report by product, stage, jurisdiction by 07:00 next BD.
  • FR-187 — Model + parameter version + per-loan inputs retained ≥7 years.
  • FR-188 — Materiality alert (default ±5% of prior balance) requires senior finance approval before GL posting.

Architecture

                       MOD-030
                bank.credit.stage_allocated
                  ┌───────────────┐
                  │ EB rule (same │
                  │  bus) + SQS   │
                  └──────┬────────┘
                ┌──────────────────────┐
                │ consume-stage-       │
                │ allocated            │   (event-triggered ECL)
                └─────────┬────────────┘
            ┌─────────────┼──────────────┐
            ▼             ▼              ▼
   credit.ecl_runs  credit.ecl_     bank.credit.
   (mutable)        provisions      ecl_updated
                    (Cat 1)          ─┐
                          │            ▼
                    MOD-001 GL    MOD-033 (RWA)
                    ADJUSTMENT    MOD-036 (return)

   ┌─────────────────┐    ┌─────────────────┐
   │ daily-          │    │ monthly-ecl-run │
   │ incremental-    │    │ cron 0 18 1 * ? *│  (1st of month, 06:00 NZST)
   │ sweep           │    │                  │
   │ cron 0 20 *...  │    │ ┌──────────────┐ │
   │ (08:00 NZST)    │    │ │ materiality  │ │
   └─────────────────┘    │ │ check (k-7)  │ │
                          │ └───┬───────┬──┘ │
                          │     │ breach│no  │
                          │     ▼       ▼    │
                          │ PENDING    COMPLETE
                          │ APPROVAL    + report
                          │     │              │
                          │     │ approve-ecl- │
                          │     ▼ run (URL IAM)│
                          │  APPROVED ─→ COMPLETE + report
                          └────────────────────┘

Functional requirements satisfied

FR Mode Implementation
FR-185 CALC EclCalculator (pure) → MOD-001 ADJUSTMENT (k-3) → ecl_provisions row
FR-186 LOG S3ReportWriter generates by_jurisdiction / by_stage / by_jurisdiction_stage groupings → S3 + SNS
FR-187 LOG model_version + parameter_set_version + trace_id NOT NULL on ecl_runs; per-loan inputs in ecl_provisions; Snowflake CDC for 7y retention
FR-188 GATE StaticMaterialityChecker + SNS alarm + approve-ecl-run state-machine gate

Run cadence (k-1)

Path Trigger provision_date Materiality gate?
EVENT_TRIGGERED bank.credit.stage_allocated event date No
DAILY_INCREMENTAL cron 0 20 * * ? * UTC (08:00 NZST) sweep date No
MONTHLY cron 0 18 1 * ? * UTC (06:00 NZST 1st of month) last calendar day of preceding month Yes

Timing chain — first business day of month:

04:00 NZST  MOD-065  daily-arrears-sweep
05:00 NZST  MOD-030  daily-stage-sweep
06:00 NZST  MOD-031  monthly-ecl-run            ← here
07:00 NZST  FR-186 portfolio report available
08:00 NZST  MOD-031  daily-incremental-sweep    ← also fires daily

Data model

credit.ecl_runs (NEW — k-11)

Mutable run-level audit. Status state machine (application-layer):

RUNNING ─┬─→ PENDING_APPROVAL  (monthly path on materiality breach)
         ├─→ COMPLETE           (no breach OR first-run bypass)
         └─→ FAILED             (k-NEW-B — monthly GL post failure)
PENDING_APPROVAL ─→ APPROVED ─→ COMPLETE  (via approve-ecl-run)
COMPLETE / FAILED → *  (rejected with COMPLIANCE_BLOCK)
column type notes
id uuid PK = ecl_run_id in events
run_type text MONTHLY / DAILY_INCREMENTAL / EVENT_TRIGGERED
provision_date date last calendar day of month for MONTHLY; sweep/event date otherwise
status text RUNNING / PENDING_APPROVAL / APPROVED / COMPLETE / FAILED
trigger_loan_account_id uuid NULL populated for EVENT_TRIGGERED only
loans_in_run int
total_provision_movement numeric(18,2) NULL signed; positive = net charge
previous_run_total numeric(18,2) NULL the prior MONTHLY run's recognised total
materiality_breach boolean FR-188
approved_by_staff_id text NULL "SYSTEM_FIRST_RUN" for first-run bypass
approved_at timestamptz NULL
approval_committee_ref text NULL governance ticket reference
model_version text NOT NULL FR-187
parameter_set_version text NOT NULL FR-187 — captures PD/LGD table versions
trace_id uuid NOT NULL
started_at / completed_at timestamptz
failure_reason text NULL populated on FAILED

Touch trigger maintains updated_at; no immutability trigger — status transitions are normal flow. Application-layer rule: rejects UPDATE when status IN (COMPLETE, FAILED) with COMPLIANCE_BLOCK.

credit.ecl_provisions (k-12 additions vs wiki)

Append-only Cat 1 immutable. Wiki additions:

  • jurisdiction CHAR(2) NOT NULL — FR-186 split
  • currency CHAR(3) NOT NULL — needed for the bank.credit.ecl_updated event payload
  • run_type TEXT NOT NULL — distinguishes the three paths
  • ecl_run_id UUID NOT NULL REFERENCES credit.ecl_runs(id) — FR-187 grouping
  • previous_ecl_recognised NUMERIC(18,2) NOT NULL DEFAULT 0 — captures the delta
  • UNIQUE constraint (loan_account_id, provision_date, run_type) — DB-layer idempotency guard (k-12). A MONTHLY snapshot can co-exist with an EVENT_TRIGGERED row for the same loan on the same date.

Lambdas

name trigger purpose
consume-stage-allocated SQS (EB rule on bank.credit.stage_allocated) per-event ECL recompute
daily-incremental-sweep EB Scheduler cron(0 20 * * ? *) UTC = 08:00 NZST catch loans missed by event path
monthly-ecl-run EB Scheduler cron(0 18 1 * ? *) UTC = 06:00 NZST 1st of month full portfolio recompute + materiality gate
approve-ecl-run Function URL (AWS_IAM) FR-188 finance approval; PENDING_APPROVAL → APPROVED → COMPLETE

SSM outputs

path type purpose
/bank/{stage}/credit/ecl/consume-stage-function-arn String Lambda ARN
/bank/{stage}/credit/ecl/consume-stage-function-name String
/bank/{stage}/credit/ecl/daily-sweep-function-arn String
/bank/{stage}/credit/ecl/monthly-run-function-arn String
/bank/{stage}/credit/ecl/approve-run-function-arn String
/bank/{stage}/credit/ecl/approve-run-function-name String
/bank/{stage}/credit/ecl/approve-run-api-endpoint String Function URL endpoint
/bank/{stage}/credit/tables/ecl-runs/name String credit.ecl_runs
/bank/{stage}/credit/tables/ecl-provisions/name String credit.ecl_provisions

Events

Published (1)

bank.credit.ecl_updated — emitted on every ecl_provisions row insert (including movement=0 rows). Schema (AD k-2 — jurisdiction, currency, run_type are NEW vs the wiki catalogue baseline; trace_id REQUIRED):

{
  "event_id": "uuid",
  "event_time": "ISO-8601",
  "schema_version": "v1",
  "trace_id": "uuid",
  "ecl_run_id": "uuid",
  "loan_account_id": "uuid",
  "jurisdiction": "NZ" | "AU",
  "stage": 1 | 2 | 3,
  "ecl_amount": "decimal-as-string",
  "previous_ecl_amount": "decimal-as-string",
  "currency": "ISO 4217",
  "pd": "decimal-as-string",
  "lgd": "decimal-as-string",
  "ead": "decimal-as-string",
  "run_type": "MONTHLY" | "DAILY_INCREMENTAL" | "EVENT_TRIGGERED",
  "idempotency_key": "string"
}

Consumed (1 — same-bus)

bank.credit.stage_allocated (MOD-030). Same-bus, no cross-bus IAM grant required.

Methodology

PD lookup (k-4 — same as MOD-030)

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 risk_rating → conservative fallback PD 0.150 + emit PdSicrSkippedCount-equivalent flag (pd_fallback_applied=true on EclCalculation).

LGD lookup (k-6)

PERSONAL_LOAN  = 0.650
MORTGAGE       = 0.250  (collateralised)
CREDIT_LINE    = 0.750
OVERDRAFT      = 0.850
BUSINESS_LOAN  = 0.550
unknown product = 0.750

Stage recognition (k-NEW-A)

Stage 1 → ecl_recognised = ecl_12m       = pd_12m × lgd × ead
Stage 2 → ecl_recognised = ecl_lifetime  = pd_lifetime × lgd × ead
Stage 3 → ecl_recognised = ecl_lifetime where pd_lifetime forced to 1.0
                          (so ecl = lgd × ead — simplified IFRS 9 §5.5.17,
                           full contractual cash-flow modelling is v2)

pd_lifetime = 1.5 × pd_12m (v1 simplification — v2 will use term-structure).

MOD-001 ADJUSTMENT contract (k-3)

V1 ships with posting_type='ADJUSTMENT', payment_id = ecl_run_id, validation_reference = ecl_run_id (synthetic UUIDs). Filed MOD-001-provision-posting-type.handoff.md for v2's proper PROVISION posting type. The contract test tests/contract/mod001-adjustment-contract.test.ts verifies the request shape; the integration test tests/integration/fr/fr-188-materiality-gate.test.ts runs against deployed dev — if MOD-001 rejects ADJUSTMENT with synthetic UUIDs in CI, that surfaces as a hard CI blocker per k-3.

GL accounts (k-4)

V1 uses env vars per (currency):

env var purpose
GL_ECL_EXPENSE_NZD_ACCOUNT_ID DR on charge / CR on release (P&L)
GL_ECL_EXPENSE_AUD_ACCOUNT_ID as above for AUD
GL_ECL_PROVISION_NZD_ACCOUNT_ID CR on charge / DR on release (BS contra-asset)
GL_ECL_PROVISION_AUD_ACCOUNT_ID as above for AUD

Filed MOD-001-ecl-gl-accounts-seed.handoff.md for SD01 to seed these accounts and publish their account_ids to SSM.

Materiality (k-7)

materiality_ratio = |total_provision_movement| / previous_run_total
                                         AppConfig threshold (default 0.05)

Edge case: previous_run_total = 0 / null → bypass with approved_by_staff_id = 'SYSTEM_FIRST_RUN'. Logged prominently.

On breach: SNS alarm fires FIRST (the alarm IS the trigger for finance), then status flips to PENDING_APPROVAL. approve-ecl-run is the only path PENDING_APPROVAL → APPROVED.

GL posting failure (k-NEW-B)

Path Failure handling
EVENT_TRIGGERED Mark run FAILED; push SQS message to DLQ for retry. Next stage_allocated event for the same loan will try again.
DAILY_INCREMENTAL Per-loan GL_FAILED logged + skipped silently; next sweep retries.
MONTHLY First GL_FAILED breaks the loop; run FAILED; SNS alarm to MOD-076. No automatic retry — finance team manual recovery via runbook.

Operational runbook (k-NEW-B monthly recovery)

If a monthly run lands in status='FAILED':

  1. SNS alarm fires to MOD-076 alarm-intake topic with failure_reason.
  2. Finance/operations triages the root cause (MOD-001 outage, GL account misconfig, NaN/division, etc.).
  3. Once root cause is fixed, manual recovery options:
  4. Re-run by deleting the FAILED run row and triggering the monthly Lambda manually with overridden MATERIALITY_THRESHOLD env var if needed. Idempotency-key cache is per-month — clear mod031:monthly:{YYYY-MM} from credit.idempotency_keys first.
  5. Corrective posting via approve-ecl-run with a special idempotency_key approved by CFO; a follow-up audit memo records the deviation.

Risks + open items

  • Stage 3 simplification — v1 uses pd=1.0 rather than IFRS 9 §5.5.17 contractual cash-flow modelling. CFO must explicitly acknowledge per MOD-031-lgd-governance-cfo-signoff.handoff.md.
  • NZ/AU public-holiday calendar — v1 fires on calendar 1st of month regardless of business day. Filed wiki-todo-working-day-calendar.handoff.md.
  • Wiki data-model gapcredit.ecl_provisions k-12 additions + credit.ecl_runs not in the wiki SD05 data model. Filed MOD-031-data-model-gap.handoff.md.
  • MOD-001 PROVISION endpoint — v1 uses ADJUSTMENT with synthetic UUIDs. Filed MOD-001-provision-posting-type.handoff.md.
  • GL account seeding — env-var stubs in v1. Filed MOD-001-ecl-gl-accounts-seed.handoff.md.