Skip to content

MOD-048 — System decision log

Purpose

The append-only ledger of record for every automated system decision (credit approval, fraud block, AML alert, KYC tier assignment, ...). Captures what was decided, by which model/rule, on which input features, and why — so customers can receive explanations, regulators can sample-and-audit, and modellers can audit performance retrospectively.

FR scope: FR-289 (record every automated decision), FR-290 (7-year retention), FR-291 (explanation endpoint p99 ≤ 2 s), FR-292 (append-only at DB layer). NFR-013 (read p99 ≤ 5 ms), NFR-019 (Tier 1 RTO/RPO), NFR-024 (audit log mutability = 0).

Architecture

   Producer modules (KYC, credit, fraud, AML, MOD-079 fanout, ...)
              │   PutEvents on bank-platform bus,
              │   DetailType: bank-platform.system_decision_recorded
   ┌──────────────────────────────────────────┐
   │ EventBridge rule                         │
   │  bank-platform-system-decision-recorded  │
   │   target: decision-recorder Lambda       │
   │   retry: 5 attempts, 1h max age          │
   │   DLQ:   SQS (KMS, 14d)                  │
   └──────────────────┬───────────────────────┘
   ┌──────────────────────────────────────────┐
   │ decision-recorder Lambda (VPC)           │
   │   1. ensureSchema (DDL + REVOKE)         │
   │   2. validate (DT-009/CRE-003/AML-006)   │
   │   3. INSERT ... ON CONFLICT (decision_id)│
   │              DO NOTHING                  │
   │   re-throw on validation failure → DLQ   │
   └──────────────────┬───────────────────────┘
   Neon  decision_log.system_decisions
        (UPDATE/DELETE revoked from app_user)
                      │  PK lookup (FR-291 p99 ≤ 2s)
   ┌──────────────────────────────────────────┐
   │ decision-explanation-api Lambda (VPC)    │
   │   { decision_id } → { features +         │
   │     contributions + model_version + ...} │
   │   readonly Neon role                     │
   │   Behind MOD-075 route once auditor UI   │
   │   ships; direct invoke V1                │
   └──────────────────────────────────────────┘

What MOD-048 owns

Resource Purpose
decision_log.system_decisions One-table ledger. PK on decision_id; UPDATE/DELETE revoked from bank_platform_app_user (FR-292, GOV-006 LOG, NFR-024). Indexes on (entity_type, entity_id, time), (decision_type, time), (model_id, model_version, time), GIN on input_features
decision-recorder Lambda EB-rule target. Validates (DT-009/CRE-003/AML-006 gates) then INSERT ... ON CONFLICT DO NOTHING
decision-explanation-api Lambda FR-291 explanation endpoint. Reads via bank_platform_readonly role for least privilege
EventBridge rule Subscribes to bank-platform.system_decision_recorded
SQS DLQ (KMS, 14d) EB target DLQ + Lambda panic safety net
3× CloudWatch alarms recorder errors, explanation-api p99 latency, DLQ depth
8× SSM downstream contract paths

Decision contract (producer-facing)

Every auditable automated decision MUST be published as an EventBridge event on the bank-platform bus with DetailType: "bank-platform.system_decision_recorded". The detail body is:

Field Type Required Notes
decision_id uuid Producer-generated; PK in the ledger
decision_type string e.g. CREDIT_DECISION, FRAUD_BLOCK, AML_ALERT, AML_ALERT_DISMISSED, KYC_TIER_ASSIGNMENT
entity_type enum CUSTOMER|APPLICATION|PAYMENT|ACCOUNT
entity_id string UUID or producer-domain id
outcome string e.g. APPROVE, BLOCK, RAISE, TIER-2
model_id string conditional Required if decision is ML-driven
model_version string conditional Required when model_id present (DT-009 GATE)
rule_id string conditional Required if decision is rule-only (FR-289 "applicable rule")
score number conditional Required for CREDIT_DECISION (CRE-003 GATE)
threshold number conditional Required for CREDIT_DECISION
input_features jsonb conditional Required when model_id present (DT-009 GATE) and for CREDIT_DECISION
feature_contributions array of {name, value, contribution?} optional Per-feature attribution. AML_ALERT_DISMISSED requires a {name: "reasoning", value: "<text>"} entry (AML-006 GATE)
policy_refs array of strings optional E.g. ["CRE-003", "AML-006"]
produced_by string Module id (MOD-013, MOD-019, ...)
source_event_id string optional Originating EB event id, for cross-referencing
analyst_id uuid conditional Required for AML_ALERT_DISMISSED (AML-006 GATE)
recorded_at ISO timestamp optional Producer wall-clock; defaults to DB now()

Idempotency

(decision_id) is the PK. Re-publishing the same decision_id is silent — ON CONFLICT (decision_id) DO NOTHING. Producers don't need to coordinate retries; the ledger is naturally idempotent.

SSM contract

Read

Path Owner
/bank/{env}/network/vpc-id, /private-subnet-ids MOD-104
/bank/{env}/kms/operational/arn MOD-104
/bank/{env}/sns/alerts/arn MOD-104
/bank/{env}/eventbridge/bank-platform/arn MOD-104
/bank/{env}/neon/*, bank-neon/{env}/bank_platform/* MOD-103

Write

Path Value
/bank/{env}/mod048/recorder-lambda-arn, /recorder-lambda-name Recorder pointers
/bank/{env}/mod048/explanation-api-lambda-arn, /explanation-api-lambda-name Read API pointers
/bank/{env}/mod048/decisions-table decision_log.system_decisions
/bank/{env}/mod048/dlq-arn, /dlq-url DLQ for ops drain
/bank/{env}/mod048/eb-rule-name EB rule name (bank-platform-system-decision-recorded-{env})

FR / Policy coverage

FR / Policy How
FR-289 (record every decision) Producers fire EB; recorder validates + INSERTs. Validator enforces mandatory fields and the policy gates listed below
FR-290 (7-year retention) Documented; cleanup task deferred (V2). The table is otherwise immutable so retention is a deletion-only concern
FR-291 (p99 ≤ 2 s) Single-row PK lookup with jsonb projection. No joins. p99-latency CloudWatch alarm at 1.5s gives 500ms headroom
FR-292 / NFR-024 / GOV-006 LOG UPDATE/DELETE revoked from bank_platform_app_user at SQL level. Live policy test (__tests__/policy/gov-006-immutability.test.ts) confirms permission denied for both verbs
NFR-013 (read p99 ≤ 5 ms) PK lookup on Neon pooler easily under 5 ms; the 2 s FR-291 budget is dominated by Lambda cold-start, not query time
NFR-019 (Tier 1 RTO/RPO) Inherited from Neon. Out of scope for this module
DT-009 LOG Validator: ML decisions (model_id present) MUST carry model_version + non-empty input_features. Rule-only decisions MUST carry rule_id. Negative test in decision-record.test.ts
CRE-003 LOG Validator: decision_type='CREDIT_DECISION' MUST carry score + threshold + input_features. Negative test in decision-record.test.ts
AML-006 LOG Validator: decision_type='AML_ALERT_DISMISSED' MUST carry analyst_id (uuid) + a feature_contributions[] entry {name: "reasoning", value: "<text>"}. DB CHECK constraint mirrors the analyst_id half. Negative test in decision-record.test.ts

V1 deferrals

Item Why Path forward
7-year retention enforcement (FR-290) Volume not yet relevant; the table is otherwise immutable Monthly partitioning + scheduled migrate_user Lambda that DETACHes partitions older than 7y
MOD-075 HTTPS route for explanation endpoint App / auditor UI not built Direct-invoke Lambda is wireable today; MOD-075 route lights up when the UI surfaces
Per-feature SHAP / LIME computation Out of scope — producers compute and publish their own contributions If a producer ships only raw inputs, the explanation surfaces them as-is and notes "contributions not available"
Producer SDK Producers hand-roll the EB call V1 Small @bank-platform/decision-log-producer helper once 2–3 producers are integrated and the contract is settled

Tests

  • 27 unit
  • decision-record.test.ts (19): FR-289 mandatory fields, DT-009 ML explainability gate (model_version, input_features), rule-only path, CRE-003 credit-decision gate, AML-006 dismissal gate (analyst_id, reasoning).
  • recorder.test.ts (5): valid record path, validation re-throw (so EB retries → DLQ), AML dismissal pre-INSERT rejection, duplicate silent path, jsonb stringification.
  • explanation-api.test.ts (5): missing payload 400, non-uuid 400, not-found 404, full projection 200, jsonb shape preservation.
  • 2 live policygov-006-immutability.test.ts: app_user UPDATE / DELETE return permission denied (skipped if app_user not yet seeded).
  • 23 live verification via scripts/verify-deployment.mjs: Lambdas Active + VPC, EB rule ENABLED + correct pattern, DLQ KMS-encrypted with 14d retention, 3 alarms, 8 SSM paths.

Reconciliation

No FR vs ADR mismatch. Producer contract above is the authoritative spec; future producer modules (KYC tiers, AML alerts, credit decisions in bank-credit, fraud blocks in bank-payments) consume this contract and fire EB events accordingly.