Skip to content

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. PK id uuid; immutability trigger trg_credit_scores_immutable reuses credit.fn_immutable_row() from MOD-128 V001. application_id is nullable in v1; MOD-029 V001 will add NOT NULL once credit.credit_applications exists. Indexes on party_id, application_id (partial WHERE NOT NULL), scored_at DESC, risk_rating.
  • credit.risk_scores_mirror — append-only SD06 mirror. PK id uuid; source_event_id text NOT NULL UNIQUE is the idempotency gate (AD-8). trg_risk_scores_mirror_immutable reuses 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.55
  • weight_affordability = 0.30
  • weight_cdd = 0.15
  • bureau_max = 1000
  • model_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.
  • CDDSIMPLIFIED=800, STANDARD=700, ENHANCED=400 (higher friction tier downscores composite). UNKNOWN=500 per 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:

  1. SQS DLQ (14d retention) + main queue (4d retention) with redrivePolicy maxReceiveCount=5.
  2. SQS resource policy allowing events.amazonaws.com from the bank-risk-platform bus to sqs:SendMessage.
  3. EB rule on the foreign bus matching source=bank.risk-platform, detail-type=customer_risk_score_updated → SQS main queue target.
  4. Lambda event-source-mapping over the main queue with FunctionResponseTypes: ["ReportBatchItemFailures"] (partial-batch protocol).
  5. 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/readonly Secrets Manager grant for MOD-028's specific Lambda is pending — until it lands, every row carries cdd_soft_fallback=true. AD-6 keeps the path operational.
  • MOD-104 cross-bus grant for events:PutRule + events:PutTargets on 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_id FK 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.