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 splitcurrency CHAR(3) NOT NULL— needed for the bank.credit.ecl_updated event payloadrun_type TEXT NOT NULL— distinguishes the three pathsecl_run_id UUID NOT NULL REFERENCES credit.ecl_runs(id)— FR-187 groupingprevious_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)¶
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':
- SNS alarm fires to MOD-076 alarm-intake topic with
failure_reason. - Finance/operations triages the root cause (MOD-001 outage, GL account misconfig, NaN/division, etc.).
- Once root cause is fixed, manual recovery options:
- Re-run by deleting the FAILED run row and triggering the monthly Lambda manually with overridden
MATERIALITY_THRESHOLDenv var if needed. Idempotency-key cache is per-month — clearmod031:monthly:{YYYY-MM}fromcredit.idempotency_keysfirst. - 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.0rather than IFRS 9 §5.5.17 contractual cash-flow modelling. CFO must explicitly acknowledge perMOD-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 gap —
credit.ecl_provisionsk-12 additions +credit.ecl_runsnot in the wiki SD05 data model. FiledMOD-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.