MOD-128 — Credit bureau enquiry & CCR integration (technical design)¶
Module: MOD-128 — Credit bureau enquiry and CCR integration
System: SD05 — Credit Decisioning & Loan Platform
Repo: bank-credit
Type: Application Lambda + IaC + Flyway
Status (at build): In progress → Built (CI-driven advance)
FR scope: FR-577, FR-578, FR-579, FR-580
Policies satisfied: CRE-003 (GATE), PRI-001 (GATE), CON-004 (LOG), REP-010 (LOG)
ADRs in effect: ADR-001 (Postgres OLTP), ADR-007 (Lambda compute), ADR-022 (SERIALIZABLE), ADR-023 (PgBouncer pooling), ADR-031 (observability), ADR-038 (Tier 1 hot path), ADR-042 (jurisdiction runtime), ADR-043 (repo+module structure), ADR-044 (TypeScript), ADR-045 (Vitest), ADR-048 (DB-enforced invariants), ADR-051 (EventBridge bus naming), ADR-052 (Neon db naming), ADR-053 (build artefact promotion).
1. Purpose¶
MOD-128 is the single integration point for inbound credit bureau enquiries by SD05. SD05 credit decisioning callers (MOD-029 once built — also future MOD-027 affordability and MOD-059 outbound CCR submissions) invoke MOD-128 to retrieve a credit file/score for an applicant. MOD-128 enforces consent, identity, and duplicate-suppression gates, routes to the correct bureau by jurisdiction, normalises the response, and persists an immutable enquiry record in credit.bureau_enquiries.
Consumer-facing contract is a Function URL (IAM-auth) returning a structured (enquiry_id, bureau_name, credit_score, adverse_flags, requires_disclosure, suppressed, primary_bureau_failed, fallback_bureau_used, responded_at, trace_id) shape. Adverse-action disclosure (MOD-050) is the caller's responsibility per the orchestrator's CON-004 LOG ruling — MOD-128 surfaces adverse flags + bureau name + a requires_disclosure flag; MOD-029 routes to MOD-050.
2. Architecture¶
caller (MOD-029 etc.)
│ POST {Function URL} (SigV4-signed, BankCreditRole-bearing)
▼
MOD-128 Lambda (arm64, ADOT layer, BankCreditRole)
│
├── Identity gate ────► AP-010 pattern 1: SELECT from
│ banking.customer_relationships_identity_readable
│ (bank_kyc_readonly, direct host)
│
├── Consent gate ─────► SELECT from credit.bureau_consents
│ (stand-in for MOD-049, V001 migration)
│
├── Suppression ──────► SELECT from credit.bureau_enquiries
│ (last 30 days, suppressed=false, success)
│
├── Bureau routing ───► SELECT from credit.bureau_config
│ (jurisdiction, product_type, enquiry_purpose)
│
├── Bureau call ──────► HTTP → primary bureau (Equifax AU / Centrix / …)
│ fallback to illion on no_file/timeout (FR-579)
│
└── Persist ──────────► INSERT into credit.bureau_enquiries
(immutable; ADR-048 Cat 1 trigger)
Module classification¶
Application Lambda — synchronous request/response, exposed via Function URL with auth_type=AWS_IAM. v1 ships without API Gateway; when MOD-075 (internal API gateway) lands, the URL endpoint is replaced with an APIGW route via SSM resolution and the published /bank/{env}/credit/bureau-enquiry/api-endpoint is unchanged.
Jurisdiction model¶
Per ADR-042, jurisdiction is data — supplied in the request body and persisted on every row. One Lambda runs both NZ and AU. No per-jurisdiction stack split.
3. Data plane¶
Tables owned¶
| Table | Mutability | Owner of writes | Notes |
|---|---|---|---|
credit.bureau_enquiries |
Append-only (ADR-048 Cat 1) | MOD-128 | Inbound bureau enquiry log. Distinct from MOD-059's credit.credit_bureau_requests (outbound CCR submissions). Trigger trg_credit_bureau_enquiries_immutable blocks UPDATE/DELETE. |
credit.bureau_consents |
Mutable (revocations) | MOD-128 (stand-in) | Stand-in for MOD-049 (Not started). Replaced by AP-010-pattern-1 read from MOD-049's published consent contract when it ships. |
credit.bureau_config |
Mutable (operator) | MOD-128 | Per-(jurisdiction, product_type, enquiry_purpose) primary + fallback routing. Seed loaded for AU credit_assessment (Equifax AU primary, illion fallback) and NZ credit_assessment (Centrix primary). |
credit.idempotency_keys |
Mutable (auto-expire) | MOD-128 (created here; reused by MOD-027/028/029/031 etc.) | Shared SD05 idempotency store. PK (key, module_id). 24h TTL. |
Tables read (cross-domain via AP-010 pattern 1)¶
| Owning domain | View | Purpose |
|---|---|---|
SD02 (bank_kyc) |
banking.customer_relationships_identity_readable (party_id, kyc_status, cdd_tier, last_verified_at) |
FR-577 identity gate. Read via bank_kyc_readonly direct host. View is being added by docs/handoffs/MOD-104-bank-kyc-identity-view.handoff.md (request to bank-kyc). Until view ships, env var KYC_IDENTITY_VIEW_NAME is empty and the gate fails closed. |
Schema (V001)¶
V001 creates the credit schema (first SD05 module to land in bank_credit Neon database) plus:
- credit.fn_immutable_row() — shared ADR-048 Cat 1 helper used by MOD-128's enquiry trigger and reused by MOD-027/028/031/059 when those modules land.
- credit.bureau_enquiries — primary write target. PK enquiry_id. Indexes on customer_id, (application_id) WHERE NOT NULL, request_at DESC, and a partial index for FR-578 suppression lookup.
- credit.bureau_consents — stand-in.
- credit.bureau_config — UNIQUE (jurisdiction, product_type, enquiry_purpose).
V002 creates credit.idempotency_keys.
Grants (MOD-103 role model)¶
bank_credit_app_user: SELECT, INSERT on credit.bureau_enquiries
SELECT, INSERT, UPDATE on credit.bureau_consents
SELECT on credit.bureau_config
SELECT, INSERT, DELETE on credit.idempotency_keys
bank_credit_readonly: SELECT on all four tables
bank_credit_migrate_user: ALL (granted by MOD-103 default + V001/V002 DDL)
bank_credit_app_user deliberately does NOT have UPDATE or DELETE on credit.bureau_enquiries — defence-in-depth alongside the immutability trigger.
4. SSM outputs¶
| SSM path | Value | Consumed by |
|---|---|---|
/bank/{env}/credit/bureau-enquiry/function-arn |
Lambda ARN | MOD-029 (when built), ops |
/bank/{env}/credit/bureau-enquiry/function-name |
Lambda function name | MOD-076 dashboard ingest, ops |
/bank/{env}/credit/bureau-enquiry/api-endpoint |
Function URL (v1) / API Gateway URL (post-MOD-075) | MOD-029 (when built) |
/bank/{env}/credit/tables/bureau-enquiries/name |
credit.bureau_enquiries |
MOD-029, MOD-042 (CDC) |
/bank/{env}/credit/tables/idempotency-keys/name |
credit.idempotency_keys |
All SD05 SD05 Lambdas writing to GL |
5. SSM inputs (upstream contract)¶
Eagerly resolved at IaC plan time. Missing values throw with the path AND the owning upstream module BEFORE any resource is queued.
| Path | Owner |
|---|---|
/bank/{env}/iam/lambda/bank-credit/arn |
MOD-104 |
/bank/{env}/eventbridge/bank-credit/arn |
MOD-104 |
/bank/{env}/kms/pii/arn |
MOD-104 |
/bank/{env}/observability/adot-nodejs-arm64-arn |
MOD-076 |
Secrets Manager bank-neon/{env}/bank_credit/app_user |
MOD-103 |
Secrets Manager bank-neon/{env}/bank_kyc/readonly |
MOD-103 |
Optional (v1 unblocked when absent):
| Path | Owner |
|---|---|
/bank/{env}/kyc/views/identity-readable/name |
bank-kyc (handoff MOD-104-bank-kyc-identity-view.handoff.md) |
/bank/{env}/secrets/rotation/fn-arn |
MOD-045 |
Secrets owned by MOD-128¶
| Secret | KMS | Rotation |
|---|---|---|
bank-credit/{env}/equifax-au-credentials |
PII CMK | 90d via MOD-045 rotation Lambda (when available) |
bank-credit/{env}/illion-credentials |
PII CMK | 90d |
bank-credit/{env}/centrix-credentials |
PII CMK | 90d |
bank-credit/{env}/equifax-nz-credentials |
PII CMK | 90d |
bank-credit/{env}/experian-au-credentials |
PII CMK | 90d |
Each tagged module=MOD-128, category=bureau-credential, rotation_enabled=true, secret_type=API_KEY. dev/uat ship with placeholder JSON; prod values rotated in by orchestrator.
6. Events¶
- Published: none in v1 (ruling #4).
- Consumed: none.
- Schema registry: no schema additions.
If MOD-076 wants bank.credit.bureau_enquiry_logged for dashboard fan-out, it lands at MOD-029 build time when the event payload shape can include the full caller context.
7. Acceptance criteria¶
| FR / Policy | Mode | Test file | Verification |
|---|---|---|---|
| FR-577 (consent gate) | — | tests/integration/fr/fr-577-consent-gate.test.ts |
Negative cases (CONSENT_MISSING, CONSENT_EXPIRED) + identity gate negative (IDENTITY_NOT_VERIFIED). Positive complement gated on view-configured flag. |
| FR-578 (duplicate suppression) | — | tests/integration/fr/fr-578-duplicate-suppression.test.ts |
Second enquiry within window returns suppressed=true and the cached score; bureau client not called twice. |
| FR-579 (multi-bureau fallback) | — | tests/integration/fr/fr-579-fallback.test.ts |
AU primary returns no_file → handler routes to illion; persisted row records primary_bureau_failed=true and fallback_bureau_used=illion. |
| FR-580 (adverse-action shape) | — | tests/integration/fr/fr-580-adverse-disclosure.test.ts |
Response shape includes adverse_flags, bureau_name, requires_disclosure=true on adverse outcome. (MOD-050 invocation is MOD-029's job — not exercised here.) |
| CRE-003 | GATE | tests/policy/pol-cre-003-gate.test.ts |
Contract-shape proof for the FK MOD-029 will gate on. End-to-end gate test lands at MOD-029 build. |
| PRI-001 | GATE | tests/policy/pol-pri-001-gate.test.ts |
Negative: missing/expired/revoked consent → 403, no bureau call. Source scan: no consent-bypass tokens. |
| CON-004 | LOG | tests/policy/pol-con-004-log.test.ts |
Captured + returned. Source scan: no MOD-050 invocation in MOD-128. |
| REP-010 | LOG | tests/policy/pol-rep-010-log.test.ts (unit) + tests/integration/infra/schema-immutability.test.ts (DB-level) |
Every code path persists a row; UPDATE/DELETE on bureau_enquiries fails. |
Plus the standard unit suite (≥80% line coverage gate per ADR-045) and the contract test for the place-enquiry API.
8. v1 limitations & deferred work¶
-
KYC identity view dependency. Until
banking.customer_relationships_identity_readableis published by bank-kyc and the SSM path/bank/{env}/kyc/views/identity-readable/nameis populated, MOD-128's identity gate fails closed (every enquiry returnsIDENTITY_NOT_VERIFIED). Tracked indocs/handoffs/MOD-104-bank-kyc-identity-view.handoff.md. No MOD-128 redeploy required when the view ships — the env var is populated, the gate becomes live. -
Consent storage stand-in.
credit.bureau_consentsis owned by MOD-128 as a stand-in until MOD-049 (SD08 consent capture, Not started) ships and publishes a consent contract. Replacement is a one-file change toservices/consent.ts. Tracked indocs/handoffs/MOD-128-complete.handoff.md. -
Real bureau API integration. All five bureau clients ship with deterministic stubs gated on placeholder credentials (same pattern as MOD-009). Real provider integration is a follow-up build (one client per provider) once production credentials are rotated in.
-
No EventBridge events. v1 doesn't publish to the bank-credit bus. If observability dashboards need bureau-enquiry events, they're added at MOD-029 build time.
-
Wiki SD05 data model gap.
credit.bureau_enquiriesis not in the published SD05 data model. Wiki update handoff filed indocs/handoffs/MOD-128-data-model-gap.handoff.md. -
Function URL surface. When MOD-075 internal API gateway ships, MOD-128 swaps Function URL for an APIGW target. Published
/bank/{env}/credit/bureau-enquiry/api-endpointstays the same.
9. Operational notes¶
- Reserved concurrency: 10 dev / 30 prod (orchestrator ruling). Tunable per IaC.
- Architecture: arm64 with ADOT arm64 layer (CLAUDE.md mandate).
- Idempotency window: 24h. Same
idempotency_keyreturns the stored response. - Bureau suppression window: 30 days (default) — configurable via
BUREAU_SUPPRESSION_WINDOW_DAYSenv var. - Logging retention: 1 year (prod) / 1 month (dev) per CloudWatch retention. Long-term archive via MOD-076 subscription filter.
- Error classification (per error-handling-standard): consent and identity blocks →
COMPLIANCE_BLOCK(HTTP 403, non-retryable). Validation →VALIDATION_FAILURE(422). Bureau timeouts →PROVIDER_ERROR(503, retryable). DB unavailable →TRANSIENT_INFRA(503, retryable).
10. Decision log¶
- 2026-05-06 — Tier A build assignment (orchestrator). MOD-029 + MOD-050 removed from MOD-128.yaml deps after wiki correction. MOD-128 build proceeds independently of MOD-029.
- 2026-05-06 — Identity-gate architecture decided as AP-010 pattern 1 (cross-domain published view) rather than HTTP API to MOD-009. Reason: MOD-009 exposes only a CREATE endpoint (POST /verify), not a status lookup; pattern 1 is AP-010-preferred and cheaper. Implementation includes a fail-closed adapter so MOD-128 stays unblocked while bank-kyc publishes the view.
- 2026-05-06 — CON-004 mode confirmed LOG (not AUTO). MOD-128 captures adverse findings; MOD-029 invokes MOD-050. Wiki yaml updated by orchestrator.
- 2026-05-06 — Bureau coverage: 5 clients implemented, 3 wired into default config (Equifax AU primary AU, illion fallback AU, Centrix primary NZ). Equifax NZ + Experian AU implemented with stub credentials; selectable per product type via
credit.bureau_config. - 2026-05-06 — v1 surface = Function URL with AWS_IAM auth. Swaps to MOD-075 APIGW when that ships.
- 2026-05-06 — Reserved concurrency 10 dev / 30 prod (per orchestrator).
Related artefacts¶
- Wiki spec:
bank-wiki/source/entities/modules/MOD-128.{yaml,md} - Wiki design (MOD-128 long-form):
bank-wiki/source/pages/design/modules/MOD-128.md - FR register:
bank-wiki/source/pages/goals/fr-register.md— FR-577..580 - Wiki update handoff (data model gap):
docs/handoffs/MOD-128-data-model-gap.handoff.md - Cross-domain handoff (KYC identity view):
docs/handoffs/MOD-104-bank-kyc-identity-view.handoff.md - Module completion handoff:
docs/handoffs/MOD-128-complete.handoff.md