MOD-028 — Credit score & risk rating (technical design)¶
System: SD05 Credit Decisioning & Loan Platform
Repo: bank-credit
Status: In progress (this commit) → Built/Deployed via CI per ADR-053
Spec: bank-wiki/source/entities/modules/MOD-028.yaml
Related ADRs: ADR-018 (KYC gates), ADR-025 (SST v3 Ion), ADR-031 (Observability), ADR-038 (Snowflake not on hot path), ADR-042 (single-stack jurisdiction at runtime), ADR-043 (repo structure), ADR-048 (DB-enforced invariants), ADR-051 (EventBridge bus naming), ADR-052 (Neon db naming), ADR-053 (build artefacts + stage promotion).
1. Purpose¶
Synchronously computes a credit risk rating (1–10 scale, encoded as A1–E grade per credit.credit_decisions.risk_rating) from a three-factor composite — bureau score (MOD-128), affordability outcome (MOD-027), and CDD tier (MOD-010 via the SD02 cross-domain identity view). Persists every scoring run to the append-only credit.credit_scores table (FR-175). Re-pulls the bureau report inline when the existing file is older than 30 days (FR-176). Maps the rating × product × jurisdiction → APRA APS 112 / RBNZ BS2A standardised Basel risk weight (CLQ-001 CALC, AD-3) and writes it on the row for MOD-033 RWA consumption.
A second Lambda subscribes (via cross-bus EB rule + SQS) to bank.risk-platform / customer_risk_score_updated and populates credit.risk_scores_mirror — the same write-back pattern as SD02 party.risk_scores_mirror and SD03 aml.risk_scores_mirror. SD06 risk-score lookups stay on the local Postgres, honouring ADR-038.
2. Architecture¶
┌─────────────────────────────────────────────────────────────┐
│ bank-risk-platform bus │
│ source: bank.risk-platform │
│ detail-type: customer_risk_score_updated │
└───────────────────────────────┬─────────────────────────────┘
│ EB rule (MOD-104 cross-bus grant required)
▼
┌────────────────┐
│ SQS main │
│ redrivePolicy │
│ → DLQ (5x) │
└────────┬───────┘
│ batch (10) +
│ ReportBatchItemFailures
▼
┌─────────────────────────────┐
│ mirror-customer-risk-score │
│ Lambda (MOD-028) │
│ - validate detail │
│ - INSERT credit.risk_scores_│
│ mirror │
│ ON CONFLICT │
│ (source_event_id) │
│ DO NOTHING │
└─────────────────────────────┘
POST {compute-credit-score Function URL, IAM_AUTH}
▼
┌─────────────────────────┐ ┌──────────────────────────────┐
│ compute-credit-score │───▶│ MOD-128 Function URL (SigV4) │ FR-173/176
│ Lambda (MOD-028) │ └──────────────────────────────┘
│ - idempotency gate │
│ - load affordability │◀── credit.affordability_assessments (MOD-027)
│ - resolve cdd_tier │◀── banking.customer_relationships_ (SD02 view)
│ (soft fallback AD-6) │ identity_readable
│ - read latest mirror │◀── credit.risk_scores_mirror
│ - compute composite │
│ - basel_risk_weight │
│ - INSERT credit_scores │
└────────┬────────────────┘
▼
credit.credit_scores (ADR-048 Cat 1 immutable)
3. Data plane¶
Tables created by MOD-028 V001 (per wiki SD05-credit data model, 2026-05-07 update):
credit.credit_scores— append-only score history. PKiduuid; immutability triggertrg_credit_scores_immutablereusescredit.fn_immutable_row()from MOD-128 V001.application_idis nullable in v1; MOD-029 V001 will add NOT NULL oncecredit.credit_applicationsexists. Indexes onparty_id,application_id(partial WHERE NOT NULL),scored_at DESC,risk_rating.credit.risk_scores_mirror— append-only SD06 mirror. PKiduuid;source_event_id text NOT NULL UNIQUEis the idempotency gate (AD-8).trg_risk_scores_mirror_immutablereuses the same shared helper.
V001 opens with DO $$ assertions on credit.fn_immutable_row(), credit.idempotency_keys, credit.affordability_assessments, and credit.bureau_enquiries — MOD-128 + MOD-027 must be applied first.
Grants: bank_credit_app_user: SELECT, INSERT on both new tables; bank_credit_readonly: SELECT.
4. Acceptance criteria mapping¶
| FR / NFR | Mechanism | Test |
|---|---|---|
| FR-173 | MOD-128 inline call when no recent enquiry; SLA budget bounded by NFR-007 | tests/integration/fr/fr-173-bureau-retrieval.test.ts (seed-dep skip) + unit handler tests |
| FR-174 | computeScore deterministic 3-factor composite → 1–10 internal rating + A1–E grade; score_components.weights captured |
tests/unit/scoring-model.test.ts + tests/integration/fr/fr-174-internal-rating.test.ts |
| FR-175 | INSERT credit.credit_scores with bureau ref, raw score, internal rating, model_version, basel_risk_weight |
tests/integration/fr/fr-175-audit-record.test.ts + tests/policy/pol-cre-003-log.test.ts |
| FR-176 | bureau-client.ts checks request_at of latest enquiry; >30d → fresh MOD-128 pull; logs bureau_staleness_days + bureau_stale_flag (AD-4) |
tests/unit/handler.test.ts (FR-176 logic via BureauOk fixtures) + tests/integration/fr/fr-176-staleness-refresh.test.ts |
| NFR-007 | arm64 Lambda + ADOT layer + no Snowflake on hot path; round-trip ≤10s validated by integration | latency assertion in fr-173 integration |
| NFR-013 | bank_credit pooled host (PgBouncer) for hot reads; mirror reader uses score_date DESC index |
n/a code-only |
| NFR-024 | ADR-048 Cat 1 immutability triggers on both new tables | tests/integration/infra/schema-immutability.test.ts |
5. Policy mode mapping¶
| Policy | Mode | Mechanism | Test |
|---|---|---|---|
| CRE-001 | AUTO | Source-level absence of override/bypass tokens; deterministic model | tests/policy/pol-cre-001-auto.test.ts (source scan w/ comment-stripping) |
| CRE-003 | LOG | model_version + score_components on every row; immutability |
tests/policy/pol-cre-003-log.test.ts + schema-immutability |
| DT-005 | LOG | score_components jsonb captures per-factor inputs + weights; cdd_soft_fallback traces deviations (AD-6); CDC-eligible structure |
tests/policy/pol-dt-005-log.test.ts (Snowflake-side closure tracked at MOD-042 build time) |
| CLQ-001 | CALC | Deterministic mapper: rating × product × jurisdiction → basel_risk_weight; basel_risk_weight written on the row for MOD-033 (AD-3) |
tests/policy/pol-clq-001-calc.test.ts |
6. SSM I/O¶
Upstream (read at deploy)¶
| Path | Owner |
|---|---|
/bank/{env}/iam/lambda/bank-credit/arn |
MOD-104 |
/bank/{env}/kms/pii/arn |
MOD-104 |
/bank/{env}/observability/adot-nodejs-arm64-arn |
MOD-076 |
/bank/{env}/observability/alarm-intake-topic-arn |
MOD-076 (DLQ alarm target — AD-8) |
/bank/{env}/neon/direct-host (Flyway) |
MOD-103 |
Secret bank-neon/{env}/bank_credit/app_user |
MOD-103 |
Secret bank-neon/{env}/bank_credit/bank_credit_migrate_user |
MOD-103 |
Secret bank-neon/{env}/bank_kyc/readonly (cross-domain identity view; soft-fallback AD-6) |
MOD-103 |
/bank/{env}/kyc/views/identity-readable/name |
bank-kyc (optional; AD-6 soft fallback when absent) |
/bank/{env}/credit/bureau-enquiry/api-endpoint |
MOD-128 (FR-173 + FR-176 fresh pull) |
/bank/{env}/eventbridge/bank-risk-platform/arn |
MOD-104 (cross-bus rule target) |
/bank/{env}/risk-platform/risk-customer/event-source-name |
MOD-039 |
/bank/{env}/risk-platform/risk-customer/event-detail-type |
MOD-039 |
Downstream (published)¶
| Path | Value |
|---|---|
/bank/{env}/credit/scoring/function-arn |
scoring Lambda ARN |
/bank/{env}/credit/scoring/function-name |
scoring Lambda name |
/bank/{env}/credit/scoring/api-endpoint |
scoring Function URL |
/bank/{env}/credit/scoring/mirror-writer-function-arn |
mirror-writer Lambda ARN |
/bank/{env}/credit/tables/credit-scores/name |
credit.credit_scores |
/bank/{env}/credit/tables/risk-scores-mirror/name |
credit.risk_scores_mirror |
7. Scoring model — algorithm + weights¶
Pure function in src/services/scoring-model.ts. Default config (DEFAULT_SCORING_CONFIG):
weight_bureau = 0.55weight_affordability = 0.30weight_cdd = 0.15bureau_max = 1000model_version = "credit-scorecard-v1.0.0"
Component normalisation:
- Bureau →
(min(bureau_score, bureau_max) / bureau_max) * 1000. NULL → 500 conservative midpoint. - Affordability → anchored on outcome (
PASS=900–700,MARGINAL=600–400,FAIL=200–100) refined by DTI; lower DTI is better. - CDD →
SIMPLIFIED=800,STANDARD=700,ENHANCED=400(higher friction tier downscores composite).UNKNOWN=500per AD-6 (cdd_soft_fallback=true).
Composite = weighted sum of the three components (0–1000). Inverted to a 1–10 internal rating (1=best, 10=worst), banded to A1..E:
| Internal rating | Grade |
|---|---|
| 1 | A1 |
| 2 | A2 |
| 3 | B1 |
| 4 | B2 |
| 5 | C1 |
| 6 | C2 |
| 7–8 | D |
| 9–10 | E |
score_components jsonb persisted on every row captures {weights, bureau_component, affordability_component, cdd_component, composite_raw, internal_rating_1_10} for retrospective model validation and quarterly DT-005 review.
8. Basel mapper¶
Pure function in src/services/basel-mapper.ts. APRA APS 112 / RBNZ BS2A standardised approach (AD-3). Defaults:
| Product | Rating bucket | basel_risk_weight |
|---|---|---|
| PERSONAL_LOAN, CREDIT_LINE, OVERDRAFT | A1..C2 | 0.75 (retail unsecured baseline) |
| PERSONAL_LOAN, CREDIT_LINE, OVERDRAFT | D, E | 1.50 (past-due) |
| MORTGAGE | any (v1) | 0.50 (conservative; MOD-115 will refine by LVR) |
| BUSINESS_LOAN | any | 1.00 (corporate SME) |
Framework label written alongside the weight: APS_112 for AU, RBNZ_BS2A for NZ. AppConfig-overridable shape (BaselConfig) — same pattern as MOD-027's stress/DTI configs.
9. Cross-bus EB consumption (mirror writer)¶
The bank-risk-platform bus is foreign to BankCreditRole. Subscribing requires events:PutRule + events:PutTargets on the bank-risk-platform bus ARN; that grant is requested via docs/handoffs/MOD-104-mod-028-cross-bus-grant.handoff.md.
The infra wires:
- SQS DLQ (14d retention) + main queue (4d retention) with
redrivePolicymaxReceiveCount=5. - SQS resource policy allowing
events.amazonaws.comfrom the bank-risk-platform bus tosqs:SendMessage. - EB rule on the foreign bus matching
source=bank.risk-platform,detail-type=customer_risk_score_updated→ SQS main queue target. - Lambda event-source-mapping over the main queue with
FunctionResponseTypes: ["ReportBatchItemFailures"](partial-batch protocol). - CloudWatch alarm on DLQ
ApproximateNumberOfMessagesVisible >= 1, action SNS to MOD-076 alarm-intake topic. AD-8 satisfied.
The mirror handler is idempotent on source_event_id UNIQUE. Duplicates return outcome=duplicate and report no batch failure (no DLQ for legitimate redelivery).
10. AD-6 soft-fallback details¶
When KYC_IDENTITY_VIEW_NAME env var is empty OR getKycReadonlyPool() throws (Secrets Manager grant denied for this Lambda — pending MOD-104 follow-on grant), the handler logs kyc_pool_init_failed, sets cdd_tier=UNKNOWN, cdd_soft_fallback=true, and proceeds with the conservative midpoint cdd_component. The row carries the deviation flag for DT-005 governance.
When the grant lands, cdd_soft_fallback should drop to false on subsequent invocations. The structured log event identity.lookup records identity_view_configured and identity_found for diagnostics.
11. Decision log¶
| AD | Decision | Where |
|---|---|---|
| AD-1 | Dedicated credit.credit_scores (not columns on credit_applications) |
V001 |
| AD-2 | 3-factor composite (bureau + affordability + CDD) | scoring-model.ts |
| AD-3 | 0.75 retail baseline, basel_risk_weight on the row | basel-mapper.ts + V001 column default |
| AD-4 | bureau_staleness_days + bureau_stale_flag separate columns |
V001 + bureau-client.ts |
| AD-5 | Mirror writer = second Lambda + own SQS + DLQ | infra/index.ts |
| AD-6 | CDD soft-fallback; cdd_soft_fallback flag captures deviation |
handler + V001 + scoring-model UNKNOWN tier |
| AD-7 | No outbound scoring events in v1 | (no PutEvents call) |
| AD-8 | SQS DLQ + alarm wired to MOD-076 alarm-intake | infra/index.ts MetricAlarm |
| AD-9 | application_id nullable v1; MOD-029 V001 backfills | V001 column nullability |
12. v1 limitations¶
- Cross-domain
bank_kyc/readonlySecrets Manager grant for MOD-028's specific Lambda is pending — until it lands, every row carriescdd_soft_fallback=true. AD-6 keeps the path operational. - MOD-104 cross-bus grant for
events:PutRule + events:PutTargetson the bank-risk-platform bus must be applied before this module's CI deploy succeeds. SST deploy will fail loudly at the EB-rule provisioning step until the grant lands. - MOD-042 transaction-CDC features are not consumed in v1; the behavioural-score component derives from MOD-027 outputs + cdd_tier only. v2 layers in transaction features.
- MORTGAGE basel_risk_weight is a conservative 0.50 v1 default; MOD-115 LVR engine refines this when it lands.
application_idFK NOT NULL is added by MOD-029 V001.
13. CI¶
.github/workflows/mod-028.yml delegates to totara-bank/bank-platform/reusable-lambda.yml@main with has_postgres: true. Pipeline runs typecheck → unit (≥80%) → policy → flyway validate → sst deploy → flyway migrate → pg_dump → integration → smoke → handoff.
The smoke test (tests/verify-deployment.mjs) posts an empty body to the scoring Function URL with SigV4 and asserts 422 INVALID_REQUEST.