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_atkeepsupdated_atcurrent.credit.credit_decisions— append-only, ADR-048 Cat 1 immutable trigger reusescredit.fn_immutable_row()from MOD-128 V001. FK tocredit.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-256content_hashis 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_acknowledgementsis 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_idFK 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.