MOD-059 — Credit bureau submission 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-059.yaml
Related ADRs: ADR-025, ADR-031, ADR-038, ADR-042, ADR-043, ADR-048, ADR-051, ADR-052, ADR-053.
1. Purpose¶
Outbound bureau interface — MOD-128 pulls credit files; MOD-059 pushes them. (1) consumes bank.credit.credit_decision_made and submits per-record SUBMISSION rows on APPROVE (FR-189; AD k-2), (2) runs a monthly batch on the 5th of each month for FR-190 account-performance reporting, (3) AJV-validates every outbound payload (FR-191; AD k-7), (4) records per-record audit fields including SHA-256 file hash + bureau ack reference + rejection reason for 7 years (FR-192), (5) provides a customer dispute API with auto-CORRECTION on UPHELD outcomes.
2. Architecture¶
bank.credit.credit_decision_made (bank-credit bus)
▼
EB rule + SQS (DLQ x5 retry)
▼
┌───────────────────────────────┐
│ consume-decision Lambda │
│ ├─ idempotency by EB event_id │
│ ├─ load credit_applications + │
│ │ credit_decisions (current)│
│ ├─ build SubmissionEnvelope │
│ ├─ AJV validate (FR-191) │
│ │ ├─ ok → bureau client → │
│ │ row SUCCESS|FAILED│
│ │ └─ fail → row HALTED + │
│ │ SNS alarm-intake │
│ └─ INSERT credit.credit_ │
│ bureau_requests │
└───────────────────────────────┘
bank.credit.facility_created → forward-wired stub Lambda
(no-op until MOD-065 publishes the event; handler logs + metric)
EB Scheduler cron(0 16 5 * ? *) UTC → monthly-batch Lambda
- one credit.bureau_submission_batches row per (bureau, jurisdiction)
- v1 stub: account_count=0, status=VALIDATED — per-account loop pending MOD-065
POST /lodge-dispute (Function URL, IAM_AUTH)
- INSERT credit.bureau_disputes with regulatory_deadline computed
at insert time (NZ +28d / AU +30d) per AD k-5
POST /resolve-dispute (Function URL, IAM_AUTH)
- UPHELD → load decision, build CORRECTION envelope, AJV validate,
transmit, INSERT credit.credit_bureau_requests, link via
correction_submission_id
- REJECTED / WITHDRAWN → flip status only
3. Data plane¶
V001 creates three tables (FK ordering: batches → requests → disputes):
credit.bureau_submission_batches— mutable, header per (bureau, reporting_month, jurisdiction). UNIQUE on three columns (AD k-4).reporting_monthconstrained to first-of-month via CHECK. Touch trigger onupdated_at.credit.credit_bureau_requests— append-only Cat 1 immutable (AD k-1 added trigger).inquiry_typeenum isSUBMISSION|UPDATE|CORRECTIONonly — SOFT_PULL/HARD_ENQUIRY belong to MOD-128'sbureau_enquiries(file wiki gap handoff). FR-192 audit columns added:submission_file_hash,bureau_ack_reference,rejection_reason,submitted_at,s3_payload_key. FK tobureau_submission_batchesisON DELETE SET NULLto preserve audit on batch removal.credit.bureau_disputes— mutable, status lifecycle.regulatory_deadlinepopulated at INSERT viaregulatoryDeadlineFor()(AD k-5). Touch trigger onupdated_at.
Grants: bank_credit_app_user gets SELECT/INSERT (and UPDATE on the two mutable tables); bank_credit_readonly gets SELECT.
V001 asserts MOD-128 + MOD-029 prerequisites at run time.
4. Acceptance criteria mapping¶
| FR / NFR | Mechanism | Test |
|---|---|---|
| FR-189 | EB rule on bank-credit bus → SQS → consume-decision Lambda; APPROVE → SUBMISSION row (AD k-2); PRE_APPROVE excluded | tests/integration/fr/fr-189-decision-trigger.test.ts (rule existence) + tests/unit/consume-decision-handler.test.ts (decision_type matrix) |
| FR-190 | EB Scheduler cron(0 16 5 * ? *) UTC; monthly-batch Lambda writes header rows; v1 stub pending MOD-065 | tests/integration/fr/fr-190-monthly-batch.test.ts + tests/unit/monthly-batch.test.ts |
| FR-191 | AJV-compiled JSON Schemas in src/schemas/{bureau}-submission.schema.json (AD k-7); halt path writes HALTED row + SNS to MOD-076 alarm-intake (AD k-8) |
tests/unit/bureau-validator.test.ts + tests/policy/pol-rep-010-auto.test.ts (halt path) + tests/integration/fr/fr-191-validation-halt.test.ts (env wiring) |
| FR-192 | submission_file_hash, bureau_ack_reference, rejection_reason, submitted_at columns on every row; SHA-256 over canonical envelope; Cat 1 immutability |
tests/integration/fr/fr-192-audit-fields.test.ts (columns exist) + tests/integration/infra/schema-immutability.test.ts (trigger) + tests/unit/payload-builder.test.ts (hash determinism) |
| NFR-010 | AUTO posture; source scan in policy test forbids override/bypass tokens | tests/policy/pol-rep-010-auto.test.ts |
| NFR-019 | SQS DLQ + alarm to MOD-076 alarm-intake (per consumer queue) | tests/integration/infra/sqs-dlq.test.ts |
| NFR-024 | Cat 1 immutability on credit_bureau_requests | schema-immutability test |
5. Policy mode mapping¶
| Policy | Mode | Mechanism | Test |
|---|---|---|---|
| REP-010 | AUTO | Source scan over src/ for override/bypass tokens (a). Negative test: AJV failure → row HALTED + SNS notifier fires + no silent drop (b). |
tests/policy/pol-rep-010-auto.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 |
/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 |
Downstream (published)¶
| Path | Value |
|---|---|
/bank/{env}/credit/bureau-submissions/event-consumer-function-arn |
consume-decision Lambda ARN |
/bank/{env}/credit/bureau-submissions/event-consumer-function-name |
consume-decision Lambda name |
/bank/{env}/credit/bureau-submissions/facility-consumer-function-arn |
consume-facility-created Lambda ARN (stub) |
/bank/{env}/credit/bureau-submissions/monthly-batch-function-arn |
monthly-batch Lambda ARN |
/bank/{env}/credit/bureau-submissions/lodge-dispute-function-arn |
lodge-dispute Lambda ARN |
/bank/{env}/credit/bureau-submissions/lodge-dispute-function-name |
lodge-dispute Lambda name |
/bank/{env}/credit/bureau-submissions/dispute-api-endpoint |
lodge-dispute Function URL |
/bank/{env}/credit/bureau-submissions/resolve-dispute-function-arn |
resolve-dispute Lambda ARN |
/bank/{env}/credit/bureau-submissions/resolve-dispute-api-endpoint |
resolve-dispute Function URL |
/bank/{env}/credit/tables/credit-bureau-requests/name |
credit.credit_bureau_requests |
/bank/{env}/credit/tables/bureau-submission-batches/name |
credit.bureau_submission_batches |
/bank/{env}/credit/tables/bureau-disputes/name |
credit.bureau_disputes |
7. EventBridge¶
Consumed (same-bus, no cross-bus grant required):
- bank.credit.credit_decision_made (live; APPROVE → SUBMISSION).
- bank.credit.facility_created (forward-wired stub; activates when MOD-065 publishes).
Published: none in v1.
The bank-credit bus is BankCreditRole's own bus, so no MOD-104 grant is required for either rule. SQS queues take the EB target by service-principal queue policy (events.amazonaws.com sourced from the bank-credit bus ARN).
8. Bureau adapters + AJV schemas¶
Bureau set v1 (AD k-6)¶
- CENTRIX (NZ) — Centrix NZ Credit Reporting.
- EQUIFAX_AU (AU) — Equifax AU CCR.
Illion / Equifax NZ / Experian AU are v2 scope (handoff filed).
AJV schemas (AD k-7)¶
src/schemas/centrix-submission.schema.json and src/schemas/equifax-au-submission.schema.json — JSON Schema draft-07. Compiled at module init (one-time per Lambda cold start). Per-bureau additionalProperties: false. inquiry_type conditionals enforce performance block on UPDATE and correction block on CORRECTION. Equifax requires ccr_supplier_code + ccr_account_type; Centrix doesn't.
Adapter pattern¶
Stub clients in src/services/bureau-clients/. Deterministic for testing — party UUID ending in 8 returns a STUB rejection; f returns a stub timeout; everything else returns SUCCESS with synthetic ack reference. Real bureau-API integration is a v2 build per provider (mirror of MOD-128's stub→real pattern).
9. CON-004 / dispute workflow¶
POST /credit/bureau-submissions/dispute-api-endpoint lodges a dispute. regulatory_deadline is computed at INSERT time:
- NZ disputes: lodged_at + 28 calendar days (CCCFA s120 20 working day buffer).
- AU disputes: lodged_at + 30 calendar days (CCR statutory window).
POST /credit/bureau-submissions/resolve-dispute-api-endpoint records the outcome. UPHELD requires a correction_reason; the handler then loads the relevant decision row, builds a CORRECTION envelope, runs AJV (halt path identical to consume-decision), transmits to the bureau, and writes the corresponding credit_bureau_requests row. The dispute's correction_submission_id is populated to link the two.
10. Schedules — UTC cron + wall-clock notes (AD k-9)¶
| Schedule | UTC cron | Wall-clock NZST (winter) | Wall-clock NZDT (summer) |
|---|---|---|---|
| monthly-batch | cron(0 16 5 * ? *) |
04:00 (5th) | 05:00 (5th) |
Triggering on calendar day 5 always satisfies the FR-190 5-business-day window regardless of weekend placement. Schedule is state=DISABLED in non-prod; production cutover flips to ENABLED after the team verifies the wall-clock intent. Operational note: when the 5th is a Saturday or Sunday, the Lambda runs unattended on the weekend — DLQ alarms must reach the on-call rotation, not just business-hours email. Captured in the runbook.
11. v1 limitations¶
- Per-account FR-190 loop is a stub (AD k-2 + AD k-5 dependent on MOD-065). Monthly batch writes header rows with
account_count=0andvalidation_outcome.v1_stub=true. Per-account assembly + UPDATE row construction lights up when MOD-065 ships andbank.credit.facility_createdis emitted in earnest. facility_createdconsumer is a no-op stub — wired to its own SQS+DLQ, logs receipt, emits a metric. When MOD-065 ships and starts publishing, the SUBMISSION logic moves from APPROVE-only (consume-decision) to here.- Bureau adapters are stubs with deterministic outputs for testing. Real-API integration is per-bureau v2 work.
- Working-day deadline calc is approximate — NZ 28 calendar days as a CCCFA 20-working-day buffer; fine-grained holiday-calendar work is a v2 concern.
12. Decision log¶
| AD | Decision | Where |
|---|---|---|
| k-1 | Drop SOFT_PULL/HARD_ENQUIRY from inquiry_type; add FR-192 audit columns; FK ON DELETE SET NULL; Cat 1 trigger | V001 |
| k-2 | APPROVE-only FR-189 trigger; PRE_APPROVE excluded; facility_created stub forward-wired | consume-decision + consume-facility-created |
| k-3 | Two dispute Lambdas (lodge + resolve) on separate Function URLs | infra/index.ts |
| k-4 | reporting_month CHECK = first-of-month; UNIQUE (bureau, reporting_month, jurisdiction) | V001 batches |
| k-5 | regulatory_deadline computed at INSERT, NOT NULL | V001 disputes + dispute-store |
| k-6 | Centrix NZ + Equifax AU only in v1 | bureau-clients/registry.ts + AJV schemas |
| k-7 | AJV JSON Schema files in src/schemas | bureau-validator.ts |
| k-8 | Halt path: HALTED row + SNS alarm-intake; SQS message acks (compliance halt is not an infra failure) | consume-decision + halt-notifier |
| k-9 | EB Scheduler cron(0 16 5 * ? *) UTC; DISABLED non-prod; weekend on-call flagged | infra/index.ts schedule |
13. CI¶
.github/workflows/mod-059.yml delegates to totara-bank/bank-platform/reusable-lambda.yml@main with has_postgres: true. Smoke test posts an empty body to the lodge-dispute endpoint and asserts 422 INVALID_REQUEST.
14. Wiki updates requested at handoff time¶
Three SD05 data-model changes + one event-catalogue update (per cross-cutting note). See docs/handoffs/MOD-059-data-model-gap.handoff.md.