Skip to content

MOD-029 — Pre-approval engine (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-029.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

The credit-decision engine. On each call it (1) reads the affordability assessment (MOD-027) + credit score (MOD-028) referenced in the request, (2) reads cdd_tier from the SD02 cross-domain identity view (load-bearing GATE — unlike MOD-027/MOD-028 where this was soft-fallback), (3) applies CRE-002 (responsible lending) and a risk-rating floor on retail unsecured products, (4) caps the approved amount per FR-178 (lower of policy cap, affordability cap, requested), (5) builds an offer with full disclosure inputs (rate, term, total interest, total cost, validity), (6) writes immutable credit.credit_decisions + mutable credit.credit_applications, and (7) publishes bank.credit.application_received + bank.credit.credit_decision_made on the bank-credit bus.

A separate accept-application Lambda enforces the load-bearing CON-004 GATE: customer acceptance is recorded only when a SHA-256 disclosure_acknowledgement matches the offer's exact terms.

A nightly scanner (CAP-065) enumerates eligible parties for pre-approval per AD k-5; an expiry sweeper (FR-179) flips stale offers to EXPIRED daily. Both are EventBridge Scheduler-driven.

2. Architecture

                 ┌────────────────────────────────────────────────────┐
POST /credit/pre-approval/decide-api-endpoint  (IAM_AUTH)
            ┌─────────────────────────────────────┐
            │  compute-credit-decision Lambda     │
            │   - idempotency gate                │
            │   - load affordability + score      │
            │   - cdd_tier GATE (load-bearing)    │
            │   - CRE-002 + score-floor           │
            │   - FR-178 cap (policy + DSR)       │
            │   - INSERT credit_applications +    │
            │       credit_decisions              │
            │   - PutEvents bank-credit bus:      │
            │       application_received +        │
            │       credit_decision_made          │
            └─────────────────┬───────────────────┘
                  Consumers: MOD-007, MOD-062, MOD-063, MOD-033

POST /credit/pre-approval/accept-api-endpoint  (IAM_AUTH)
            ┌─────────────────────────────────────┐
            │  accept-application Lambda          │
            │   - load application + check status │
            │   - recompute expected hash from    │
            │     decision row + offer-builder    │
            │   - CON-004 GATE: hash + version    │
            │   - INSERT disclosure_acknowledgements│
            │   - UPDATE applications.status →    │
            │       'ACCEPTED'                    │
            └─────────────────────────────────────┘

EventBridge Scheduler (UTC cron — wall-clock NZST per AD k-9):
  cron(0 14 * * ? *)  →  nightly-scan Lambda  (CAP-065)
  cron(0 15 * * ? *)  →  expire-offers Lambda (FR-179)

3. Data plane

Tables created by MOD-029 V001 (all per the wiki SD05 data model):

  • credit.credit_applications — mutable, status transitions through the lifecycle. trg_credit_applications_touch_updated_at keeps updated_at current.
  • credit.credit_decisions — append-only, ADR-048 Cat 1 immutable trigger reuses credit.fn_immutable_row() from MOD-128 V001. FK to credit.credit_applications, credit.affordability_assessments (MOD-027), credit.credit_scores (MOD-028).
  • credit.disclosure_acknowledgements — append-only, Cat 1 immutable. Stand-in for MOD-050 (filed handoff); SHA-256 content_hash is the audit-proof CON-004 GATE evidence.

V001 prerequisite assertions: credit.fn_immutable_row() (MOD-128), credit.idempotency_keys (MOD-128), credit.affordability_assessments (MOD-027), credit.credit_scores (MOD-028), credit.bureau_enquiries (MOD-128). V001 fails loud if any is missing.

V002 forward FKs (per AD k-7): adds FOREIGN KEY (application_id) REFERENCES credit.credit_applications(id) to credit.affordability_assessments and credit.credit_scores. NOT NULL deferred to V003 once the historical NULL rows from MOD-027/MOD-028 dev runs are aged out.

Grants per MOD-103 role model — bank_credit_app_user gets SELECT/INSERT/UPDATE on credit.credit_applications and SELECT/INSERT on the two append-only tables; bank_credit_readonly gets SELECT on all three.

4. Acceptance criteria mapping

FR / NFR Mechanism Test
FR-177 Synchronous compute-credit-decision Lambda; arm64 + ADOT layer; cdd_tier read against the cross-domain view; idempotent on idempotency_key tests/integration/fr/fr-177-decision-shape.test.ts (≤10s assertion)
FR-178 applyProductCap returns the lowest of (requested, affordability, policy); no override path (CRE-002 AUTO source scan) tests/unit/product-policy.test.ts + tests/unit/decide-handler.test.ts (FR-178 cases)
FR-179 EventBridge Schedule → expire-offers Lambda; applicationStore.listExpiring + setStatus('EXPIRED'); accept handler also returns OFFER_EXPIRED when a stale offer is presented tests/unit/scheduled-handlers.test.ts + tests/unit/accept-handler.test.ts (OFFER_EXPIRED case) + tests/integration/infra/schedules-attached.test.ts
FR-180 Every decision INSERTs a credit.credit_decisions row with affordability_assessment_id + credit_score_id + risk_rating + policy_refs + decided_by; immutable trigger; 7-year retention via Snowflake replication tests/integration/fr/fr-180-audit-trail.test.ts + tests/policy/pol-cre-003-log.test.ts
NFR-007 arm64 + ADOT layer; pooled Postgres (PgBouncer); reserved concurrency 30 prod tests/integration/infra/lambdas-deployed.test.ts
NFR-005 AUTO posture; no operator override path (source scan + integration negative) CRE-002 AUTO test
NFR-024 Cat 1 immutability triggers on credit_decisions + disclosure_acknowledgements tests/integration/infra/schema-immutability.test.ts

5. Policy mode mapping

Policy Mode Mechanism Test
CRE-002 AUTO Source-level absence of override/bypass tokens; deterministic gate (FAIL affordability → DECLINE; missing assessment → 422 AFFORDABILITY_NOT_FOUND) tests/policy/pol-cre-002-auto.test.ts
CON-004 GATE Acceptance requires disclosure_acknowledgement with SHA-256 content_hash matching the recomputed expected hash; mismatch → 403 DISCLOSURE_HASH_MISMATCH; missing field → 422 MISSING_FIELD; positive path returns 200 + disclosure_acknowledgement_id tests/policy/pol-con-004-gate.test.ts
CRE-003 LOG Every decision row carries affordability_assessment_id + credit_score_id + risk_rating + policy_refs; Cat 1 immutability tests/policy/pol-cre-003-log.test.ts + schema-immutability infra test

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}/eventbridge/bank-credit/arn MOD-104
/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 (load-bearing CDD GATE) MOD-103
/bank/{env}/kyc/views/identity-readable/name bank-kyc

Downstream (published)

Path Value
/bank/{env}/credit/pre-approval/decide-function-arn decide Lambda ARN
/bank/{env}/credit/pre-approval/decide-function-name decide Lambda name
/bank/{env}/credit/pre-approval/decide-api-endpoint decide Function URL
/bank/{env}/credit/pre-approval/accept-function-arn accept Lambda ARN
/bank/{env}/credit/pre-approval/accept-function-name accept Lambda name
/bank/{env}/credit/pre-approval/accept-api-endpoint accept Function URL
/bank/{env}/credit/pre-approval/nightly-scanner-function-arn scanner Lambda ARN
/bank/{env}/credit/pre-approval/expiry-sweeper-function-arn sweeper Lambda ARN
/bank/{env}/credit/tables/credit-applications/name credit.credit_applications
/bank/{env}/credit/tables/credit-decisions/name credit.credit_decisions
/bank/{env}/credit/tables/disclosure-acknowledgements/name credit.disclosure_acknowledgements

7. EventBridge — published events

Per the wiki event catalogue (updated 2026-05-07 by the orchestrator to reflect MOD-029 as the producer of credit_decision_made).

bank.credit.application_received v1 — emitted on submission (every decide call). Consumers: MOD-027, MOD-028, MOD-062.

bank.credit.credit_decision_made v1 — emitted on decision recorded. Fields: decision_type (full enum), decision_source (AUTO/MANUAL/PRE_APPROVAL_ENGINE), approved_amount/approved_interest_rate/approved_term_months/validity_period_days (present on APPROVE/PRE_APPROVE), risk_rating, decline_reason_codes (present on DECLINE), model_version. Consumers: MOD-007, MOD-063, MOD-033, MOD-048.

No inbound EB consumption in v1 (the conditional-approval-on-KYC-verified pattern is deferred — every decide call gates on cdd_tier inline).

8. FR-178 product policy + scoring model

DEFAULT_PRODUCT_POLICY (AppConfig-overridable per AD k-4 ruling — values are floors to prevent accidental omission, not regulatory limits):

Product AU cap NZ cap Default term Default rate
PERSONAL_LOAN 50,000 50,000 60 months 9.9%
CREDIT_LINE 20,000 20,000 revolving 17.9%
OVERDRAFT 5,000 5,000 revolving 17.9%
MORTGAGE 2,000,000 1,500,000 360 months 6.9%
BUSINESS_LOAN 250,000 250,000 84 months 11.9%

DSR cap: 0.45 (45% of NDI). Affordability cap = present value of (NDI × DSR) over the product's default term at the default rate. applyProductCap returns the lowest of (requested, affordability cap, policy cap) — no override path.

Risk-rating floor: D and E ratings auto-DECLINE for retail unsecured products (PERSONAL_LOAN, CREDIT_LINE, OVERDRAFT). MORTGAGE and BUSINESS_LOAN aren't bound by the floor at this layer (LVR/security drives those decisions in MOD-115/MOD-116).

9. CON-004 GATE — disclosure hash details

buildOffer produces a deterministic SHA-256 over the canonical JSON of {approved_amount, approved_currency, approved_term_months, interest_rate, proposed_repayment_monthly, total_interest_payable, total_cost_of_credit, validity_period_days} (sorted keys; expires_at and disclosure_content_hash itself are excluded — only the customer-visible monetary terms contribute to the hash).

The accept handler recomputes the same hash from the live credit_decisions row + offer-builder. If the offer terms changed between issuance and acceptance, the hashes mismatch and acceptance is blocked with 403 DISCLOSURE_HASH_MISMATCH. Tampering, version drift, or AppConfig changes that affect rate/term mid-flight all surface as the same hard error.

When MOD-050 ships, the disclosure capture moves to MOD-050's store and credit.disclosure_acknowledgements is dropped (mirror handoff filed).

10. CAP-065 nightly scan — eligibility filter (AD k-5)

Per the orchestrator's ruling (AD k-5):

WHERE cdd_tier IN ('SIMPLIFIED','STANDARD')
  AND kyc_status = 'VERIFIED'
  AND NOT EXISTS (
    SELECT 1 FROM credit.credit_applications live
     WHERE live.party_id = candidate.party_id
       AND live.application_status IN ('PRE_APPROVED','APPROVED','CONDITIONALLY_APPROVED')
       AND (live.expires_at IS NULL OR live.expires_at > now())
  )
  AND NOT EXISTS (
    SELECT 1 FROM credit.credit_applications recent
     WHERE recent.party_id = candidate.party_id
       AND recent.application_status = 'APPROVED'
       AND recent.created_at > now() - interval '90 days'
  )
LIMIT $batch_limit

Batch caps (env): dev=1,000, prod=10,000. AppConfig-overridable.

v1 limitation: the scanner emits a candidate count metric and stops. Per-party decide loop requires up-front affordability + score rows for the candidate population, which a feature pipeline owns; that's a v2 follow-on. CAP-065 lands as a structural module today.

11. Schedules — UTC cron + wall-clock notes (AD k-9)

Schedule UTC cron Wall-clock NZST (winter) Wall-clock NZDT (summer)
nightly-scan cron(0 14 * * ? *) 02:00 03:00
expire-offers cron(0 15 * * ? *) 03:00 04:00

Both schedules are created in state=DISABLED for non-prod environments — they don't fire automatically in dev/uat. Prod deploys flip to state=ENABLED. The team should verify the wall-clock intent against the production change-window before prod cutover; the cron expressions can be flipped via AppConfig or a follow-on commit.

12. v1 limitations

  • MOD-050 not yet built: credit.disclosure_acknowledgements is the stand-in. CON-004 enforcement is local to MOD-029 today; when MOD-050 ships the disclosure capture moves there. Mirror handoff filed.
  • MOD-049 not yet built: consent capture is unaffected at the MOD-029 layer (the consent stand-in is owned by MOD-128's credit.bureau_consents).
  • CAP-065 nightly per-party loop: stub today — feature pipeline lands in v2.
  • credit.credit_decisions.application_id FK is NOT NULL because the FK is created in V001; MOD-027/MOD-028 historical FKs in V002 leave application_id NULLable (per AD k-7 — V003 follow-on).
  • No inbound EB consumption: conditional-approval-on-KYC-verified is deferred. Every decide call inline-gates on cdd_tier.
  • No GL posting: facility creation + GL postings are MOD-065's responsibility (Tier D). MOD-029 stops at decision recording.

13. Decision log

AD Decision Where
k-1 Use catalogue event names verbatim — application_received + credit_decision_made (MOD-029 producer). event-publisher.ts
k-2 credit.disclosure_acknowledgements stand-in with SHA-256 content_hash; Cat 1 immutable. V001 + offer-builder.ts + accept handler
k-3 decision_source/decision_type: live=AUTO+APPROVE/CONDITIONALLY_APPROVE/DECLINE; nightly=PRE_APPROVAL_ENGINE+PRE_APPROVE/DECLINE decide handler
k-4 Product caps; AU MORTGAGE raised to $2M product-policy.ts
k-5 Nightly scanner filter incl. 90-day APPROVED exclusion scanner.ts
k-6 Single Function URL, IAM auth (decide path) infra/index.ts
k-7 V002 adds FKs only; NOT NULL deferred to V003 V002
k-8 Separate accept Lambda + Function URL infra/index.ts
k-9 EventBridge Scheduler; UTC crons; wall-clock NZST documented infra/index.ts schedules

14. CI

.github/workflows/mod-029.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 → integration → smoke → handoff.

The smoke test (tests/verify-deployment.mjs) posts an empty body to the decide Function URL with SigV4 and asserts 422 INVALID_REQUEST.