Skip to content

MOD-079 — Snowflake decision publication (operational apply service)

Purpose

Per ADR-036 the only governed Snowflake → Neon write-back path. Receives versioned, idempotent decision payloads on decision_inbox.decision_result_inbox (Neon), validates the contract (DT-001 GATE), dedups on (decision_id, idempotency_key), routes to the correct operational target table, and writes an immutable row to decision_delivery_log (GOV-006 LOG).

FR scope: FR-309 (≤60s publication), FR-310 (validator + DLQ), FR-311 (replay-safe via idempotency), FR-312 (90d publication log), NFR-014 (≤60s budget), NFR-015 (CDC complement), NFR-024 (immutability).

Architecture

   Snowflake decision_curated.decision_result
   (publication path — operator wires Snowflake task → Neon write;
    out of MOD-079 scope V1; integration tests insert directly)
   Neon  decision_inbox.decision_result_inbox  (status=pending)
   ┌──────────┴──────────┐
   │ EventBridge schedule│  rate(1 minute)
   │  → applier Lambda   │
   └──────────┬──────────┘
   ┌─────────────────────────────────────────┐
   │ decision-applier Lambda                 │
   │   1. ensureSchema (DDL)                 │
   │   2. SELECT pending FOR UPDATE          │
   │      SKIP LOCKED LIMIT 50               │
   │   3. for each:                          │
   │      a. validate (DT-001 GATE)          │
   │      b. dedup (delivery_log lookup)     │
   │      c. route → target apply()          │
   │      d. UPDATE inbox status             │
   │      e. INSERT delivery_log (immutable) │
   └────────┬─────────────────────────────┬──┘
            ▼                             ▼
  bank_core.accounts.accounts.status   decision_inbox.decision_delivery_log
  (V1 — only registered target)        (UPDATE/DELETE revoked)

What MOD-079 owns

Resource Purpose
decision_inbox.decision_result_inbox Snowflake-writable inbox; PK (decision_id, idempotency_key); status field tracks pending → applied/duplicate/rejected/failed
decision_inbox.decision_delivery_log Append-only audit; UPDATE/DELETE revoked from app_user (FR-312, NFR-024, GOV-006)
decision-applier Lambda VPC-attached; polls inbox; validates; routes to target; logs
EventBridge schedule (rate 1 min) Triggers the applier
SQS DLQ (KMS, 14d) EB target DLQ for unexpected Lambda panics
3× CloudWatch alarms error rate, p99 latency, DLQ depth
7× SSM downstream contract paths

Decision contract (ADR-036 §Data contract)

Field Type Required
decision_id uuid
idempotency_key string
entity_type enum CUSTOMER|APPLICATION|PAYMENT|ACCOUNT
entity_id string
decision_type string (router lookup)
decision_status enum ACCEPT|REJECT|REFER|HOLD|CLEAR
decision_summary string
score_summary jsonb optional
reasons[] array of optional
produced_by string
policy_refs[] array of policy codes optional
schema_version string (1.0, 1.1) ✓ — DT-001 GATE rejects unknown
effective_at timestamptz ISO
expires_at timestamptz ISO optional

Target registry (src/config/decision-targets.ts)

V1 wires only FRAUD_ACTIONaccounts.accounts.status because that's the only operational target table that exists today (bank-core MOD-001).

Mapping (src/shared/targets/fraud-action-account.ts): - decision_status=REJECT|HOLDstatus='RESTRICTED' - decision_status=CLEARstatus='ACTIVE' (lift restriction) - decision_status=ACCEPT|REFER → no-op (recorded as applied; no status change)

Future targets (per ADR-036 §"Architecture") light up with a small registry addition + corresponding owning-module Flyway:

decision_type Target table Owning module Status
ONBOARDING customers.onboarding_status SD02 not built
RISK_TIER customers.cdd_tier SD02 not built
SCREENING_ACTION aml_cases.status SD03 unknown
CREDIT_DECISION credit_decisions SD05 not built

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}/neon/*, bank-neon/{env}/{database}/* MOD-103

Write

Path Value
/bank/{env}/mod079/lambda-arn, /lambda-name Applier pointers
/bank/{env}/mod079/inbox-table decision_inbox.decision_result_inbox
/bank/{env}/mod079/delivery-log-table decision_inbox.decision_delivery_log
/bank/{env}/mod079/dlq-arn, /dlq-url DLQ for ops drain
/bank/{env}/mod079/schedule-name EventBridge schedule name

FR / Policy coverage

FR / Policy How
FR-309 / NFR-014 (≤60s) 1-minute scheduled poll; ≤50-row batch fits comfortably in the 60s timeout window
FR-310 (validate + DLQ) decision-result.ts validate() enforces ADR-036 §Data contract; failures → status='rejected' + delivery log; Lambda panics → SQS DLQ
FR-311 (replay-safe) PK on (decision_id, idempotency_key) + delivery-log lookup before apply → reapply is silent duplicate
FR-312 / NFR-024 (90d log, immutable) decision_delivery_log UPDATE/DELETE revoked; retention 90d operational (cleanup task TBD)
DT-001 GATE Only schema-version-validated, contract-conformant rows reach apply. Validator drops 9 invariants.
GOV-006 LOG Every applied / duplicate / rejected / failed row in delivery_log; immutable; carries decision_id + schema_version + policy_refs + produced_by + apply_target

V1 deferrals

Item Why Path forward
Snowflake-side publication path Out of scope per design — Snowflake pushes rows via Snowflake task / external function Add to MOD-102's runner: SQL migration that creates a Snowflake task writing to the inbox via Snowflake's external function
customers.cdd_tier / aml_cases.status / credit_decisions targets Owning modules not built Add target to decision-targets.ts + corresponding apply() function when target table ships
notification.dispatched/failed/bounced outbound EB events No consumer yet Wire when MOD-076 dashboards or another consumer surfaces
90-day cleanup task Volume not yet relevant Scheduled Lambda or pg_cron job

Tests

  • 26 unit — decision-result validator (10 cases covering missing fields, schema-version gate, enum gates, malformed timestamp), decision-targets registry invariants (4 cases), fraud-action target router (5 cases including REJECT/HOLD/CLEAR/ACCEPT/non-ACCOUNT/missing-row).
  • 20 live verification via scripts/verify-deployment.mjs.

FR text vs ADR-036 reconciliation

The wiki's FR-309 / FR-310 / FR-311 / FR-312 use Neon → Snowflake wording (predates ADR-036). ADR-036 is signed-off (2026-04-10) and unambiguously defines this module as Snowflake → Neon. The implementation honours ADR-036; FR text should be re-aligned in a wiki amendment. Mapping captured in the FR coverage table above.