Skip to content

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_month constrained to first-of-month via CHECK. Touch trigger on updated_at.
  • credit.credit_bureau_requests — append-only Cat 1 immutable (AD k-1 added trigger). inquiry_type enum is SUBMISSION|UPDATE|CORRECTION only — SOFT_PULL/HARD_ENQUIRY belong to MOD-128's bureau_enquiries (file wiki gap handoff). FR-192 audit columns added: submission_file_hash, bureau_ack_reference, rejection_reason, submitted_at, s3_payload_key. FK to bureau_submission_batches is ON DELETE SET NULL to preserve audit on batch removal.
  • credit.bureau_disputes — mutable, status lifecycle. regulatory_deadline populated at INSERT via regulatoryDeadlineFor() (AD k-5). Touch trigger on updated_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=0 and validation_outcome.v1_stub=true. Per-account assembly + UPDATE row construction lights up when MOD-065 ships and bank.credit.facility_created is emitted in earnest.
  • facility_created consumer 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.