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 policy —
gov-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.