# MercuryPay — Fraud & AML Platform
## Complete Implementation Reference

**Version:** 1.0  
**Date:** 2026-05-22  
**Status:** Production-Ready  
**Phases Delivered:** 1 through 9  

---

## Table of Contents

1. [Executive Summary](#1-executive-summary)
2. [Platform Architecture](#2-platform-architecture)
3. [Data Model](#3-data-model)
4. [Feature Store](#4-feature-store)
5. [Rules Engine](#5-rules-engine)
6. [Machine Learning Models](#6-machine-learning-models)
7. [Case Management & Investigator Workflow](#7-case-management--investigator-workflow)
8. [Sanctions & AML Compliance](#8-sanctions--aml-compliance)
9. [Admin Dashboards (UI Reference)](#9-admin-dashboards-ui-reference)
10. [REST API Reference](#10-rest-api-reference)
11. [Security & Encryption](#11-security--encryption)
12. [Operational Guide](#12-operational-guide)
13. [Data Science Guide](#13-data-science-guide)
14. [ML Engineering Guide](#14-ml-engineering-guide)
15. [Performance & Scalability](#15-performance--scalability)
16. [Configuration Reference](#16-configuration-reference)
17. [Delivery Timeline](#17-delivery-timeline)

---

## 1. Executive Summary

### For the Business Team

MercuryPay's Fraud & AML Platform is a real-time transaction scoring system embedded directly in the payment gateway. Every transaction processed by MercuryPay passes through a multi-layer risk assessment before a payment decision is returned:

```
Transaction arrives → Risk score computed → Decision returned (< 50ms P95)
                           ↓
                    approve / review / decline
```

**Key business outcomes:**

| Outcome | Mechanism |
|---|---|
| Reduce fraud losses | Real-time decline of high-risk transactions |
| Automate analyst workload | Auto-open cases on declines; rule-based triage |
| Meet regulatory obligations | Sanctions screening on every transaction; compliance export API |
| Continuous improvement | ML models self-retrain as new fraud labels arrive |
| Zero-downtime rule changes | Rules updated live via UI; no deployment required |

**What "risk score" means to the business:**  
Each transaction receives a score from 0.0 to 1.0. Scores above configured thresholds trigger a `review` or `decline` decision. Investigators review `review` cases; `decline` transactions are rejected and a case is automatically opened.

---

## 2. Platform Architecture

### 2.1 High-Level Overview

```
                         ┌─────────────────────────────────────┐
  Client / Partner ──────►  Gateway API  (POST /api/v1/route)  │
                         └──────────────┬──────────────────────┘
                                        │  MwRouter pipeline
                                        ▼
                         ┌─────────────────────────────────────┐
                         │       RiskScoringPlug               │
                         │  (plugged into mw_router pipeline)  │
                         └──────────────┬──────────────────────┘
                                        │
                    ┌───────────────────▼────────────────────┐
                    │           ScoringPipeline               │
                    │                                         │
                    │  FeatureHydrator ──► AbstractionEngine  │
                    │         ↓                               │
                    │  ActivationEngine (rules)               │
                    │         ↓                               │
                    │  ModelServer.predict (ML)               │
                    │         ↓                               │
                    │  combine() → {decision, score}          │
                    └───────────┬────────────────────────────┘
                                │ async (fire-and-forget)
              ┌─────────────────┼──────────────────────────┐
              ▼                 ▼                          ▼
     VelocityPipeline    EventBroadcaster          CasesAutomation
     (Redis counters)    (PubSub broadcast)        (Oban: auto-open
                                                    case on DECLINE)
```

### 2.2 Technology Stack

| Layer | Technology | Purpose |
|---|---|---|
| Runtime | Elixir 1.17 / OTP 26 | Concurrent, fault-tolerant platform |
| Web framework | Phoenix 1.7 (LiveView) | Real-time admin UI |
| Database | MySQL 8 (via MyXQL) | Persistent risk data storage |
| Cache tier 1 (L1) | ETS (in-process) | Sub-microsecond feature reads |
| Cache tier 2 (L2) | Redis 7 (via Redix) | Shared velocity counters across nodes |
| Background jobs | Oban 2.22 (Dolphin engine) | ML training, label ingestion, batch jobs |
| ML — unsupervised | Scholar (Elixir) | Isolation Forest anomaly detection |
| ML — supervised | Axon + Nx (Elixir) | Multi-layer Perceptron fraud classifier |
| Circuit breaker | fuse 2.5 | Redis + ModelServer fault isolation |
| Message bus | Phoenix.PubSub | ETS cache invalidation, live UI updates |

### 2.3 Umbrella Application Layout

```
mw-core/
  apps/
    infra_feature_store/     ← Two-tier feature cache (ETS + Redis)
    infra_repo/              ← Ecto schemas, migrations, Oban
    mw_risk/                 ← Scoring engine, ML models, workers
    gateway_api/             ← REST API (JSON), compliance export
    gateway_web/             ← Phoenix LiveView admin UI
    mw_router/               ← Request routing + RiskScoringPlug
```

### 2.4 Scoring Pipeline — Step by Step

```
1. RiskScoringPlug.call/2
   └── Checks RISK_SCORING_ENABLED env gate

2. ScoringPipeline.score/1 (Context)
   ├── FeatureHydrator.hydrate/1
   │     ├── Extract Type A: payload fields (amount, card, merchant, IP, ...)
   │     ├── Extract Type B: velocity counters from FeatureStore (ETS→Redis)
   │     └── Fire-and-forget: LruJournal.touch_many + graph edge recording
   │
   ├── AbstractionEngine.compute/1
   │     ├── Ratio features: ratio_failed, avg_amount per entity+horizon
   │     └── IP features: ip_is_datacenter (IpClassifier)
   │
   ├── SanctionsChecker.check/2
   │     └── Fuzzy match against sanctions list in ETS → short-circuit DECLINE
   │
   ├── ActivationEngine.evaluate/2
   │     └── Evaluates active rules from RuleCache against feature map
   │
   ├── ModelServer.predict/2  [if RISK_MODEL_SERVING=true]
   │     ├── Checks :mw_risk_model_server fuse (circuit breaker)
   │     └── Ensemble.score → IsolationForest + MLP weighted blend
   │
   └── combine/5
         ├── Rules-decline wins (hard block)
         ├── ML score can elevate approve → review if anomaly ≥ 0.7
         └── Returns {decision, score, fired_rules, model_version}

3. Async (fire-and-forget, non-blocking):
   ├── VelocityPipeline.update/1    → Redis counter increments
   ├── EventBroadcaster.broadcast/1 → PubSub for live dashboard
   └── CasesAutomation worker       → Auto-open case if DECLINE
```

---

## 3. Data Model

### 3.1 Database Tables

All tables live in the `infra_repo` umbrella app. All include `tenant_id` for multi-tenant isolation.

#### `risk_scores`
Stores the outcome of every scored transaction.

| Column | Type | Description |
|---|---|---|
| `id` | bigint PK | Auto-generated |
| `tenant_id` | integer | Tenant isolation |
| `transaction_ref` | string | Links to the originating transaction |
| `trace_id` | string | Distributed trace correlation |
| `ml_score` | float | Combined ensemble score (0.0–1.0) |
| `decision` | string | `approve` / `review` / `decline` |
| `fired_rules` | array[string] | Names of rules that triggered |
| `feature_snapshot` | map (JSON) | Encrypted feature vector at scoring time |
| `model_version` | string | e.g. `iforest-v12`, `ensemble-v3` |
| `response_time_ms` | integer | End-to-end scoring latency |
| `label` | string | Fraud/legit label (added post-hoc from TC40) |
| `labeled_at` | datetime | When the label was applied |

#### `risk_labels`
Ground-truth fraud labels from TC40 chargeback feeds and internal review.

| Column | Type | Description |
|---|---|---|
| `external_ref` | string | Unique ID from the label source (TC40 ref, etc.) |
| `transaction_ref` | string | Links to the original transaction |
| `label_type` | string | `tc40` / `chargeback` / `safe` / `dispute` |
| `label_value` | string | `fraud` / `legit` |
| `amount_eur` | float | Transaction amount |
| `received_at` | datetime | When the label was received |
| `matched_score_id` | integer | FK to `risk_scores.id` after matching |
| `raw_payload` | map (JSON) | Original label source payload |

#### `risk_cases`
Investigator work queue for suspicious transactions.

| Column | Type | Description |
|---|---|---|
| `score_id` | integer | FK to `risk_scores.id` |
| `case_ref` | string | Human-readable reference (e.g. `CASE-00042`) |
| `status` | string | `open` / `investigating` / `closed` |
| `priority` | string | `low` / `medium` / `high` / `critical` |
| `assigned_to` | integer | FK to `admin_users.id` |
| `resolution` | string | `fraud_confirmed` / `false_positive` / `inconclusive` |
| `tags` | array[string] | Custom labels for filtering |

#### `risk_activation_rules`
Configurable scoring rules (editable live via the UI).

| Column | Type | Description |
|---|---|---|
| `name` | string | Human-readable rule name |
| `entity_type` | string | `card` / `merchant` / etc. |
| `feature_key` | string | e.g. `card:1h:count_failed` |
| `operator` | string | `gt` / `lt` / `gte` / `lte` / `eq` / `in` |
| `threshold_value` | float | Numeric threshold |
| `decision` | string | `approve` / `review` / `decline` / `flag` |
| `priority` | integer | Lower = evaluated first |
| `active` | boolean | Toggle without deployment |

#### `risk_model_versions`
Tracks all trained ML model artifacts.

| Column | Type | Description |
|---|---|---|
| `algorithm` | string | `scholar_isolation_forest` / `axon_mlp` / `ensemble` |
| `status` | string | `training` / `deployed` / `archived` |
| `artifact_path` | string | Absolute path to serialised model file |
| `hyperparameters` | map | Topology, weights, training config |
| `training_size` | integer | Number of rows used for training |
| `metrics` | map | AUC-ROC, anomaly_rate, mean, std |

#### `risk_sanctions_list`
Sanctioned entity records for AML screening.

| Column | Type | Description |
|---|---|---|
| `list_type` | string | `ofac` / `un` / `eu` / `custom` |
| `entity_name` | string | Full name of sanctioned entity |
| `entity_type` | string | `individual` / `company` / `vessel` |
| `identifiers` | map | Passports, tax IDs, aliases |

---

## 4. Feature Store

### 4.1 Architecture — Two-Tier Cache

The feature store is the data backbone of real-time scoring. It provides sub-millisecond reads by layering two caches in front of Redis:

```
Scoring request
      │
      ▼
┌─────────────┐   HIT   ┌──────────────────────────────────┐
│  L1: ETS    │ ──────► │  Return cached value (< 1 µs)    │
│  (process)  │         └──────────────────────────────────┘
└──────┬──────┘
       │ MISS
       ▼
┌─────────────┐   HIT   ┌──────────────────────────────────┐
│  L2: Redis  │ ──────► │  ETS.put + Return value (< 3ms)  │
│  (cluster)  │         └──────────────────────────────────┘
└──────┬──────┘
       │ MISS
       ▼
   Return 0.0 (entity never seen before — safe default)
```

**Cache invalidation:** When a Redis key is written, a `Phoenix.PubSub` message is broadcast. All nodes subscribed via `SyncSubscriber` drop the corresponding ETS entry so the next read re-fetches from Redis.

**Connection pooling (Phase 8.2):** 4 named Redix connections (`infra_feature_store_redis_0` through `_3`). Each process is routed to a connection by `phash2(self(), 4)` to distribute load.

**Circuit breaker (Phase 8.3):** `:fuse` library with `{:standard, 5, 10_000}` policy (trips after 5 failures in 10s, resets after 30s). When blown, all Redis calls return `{:error, :circuit_open}` and scoring falls back to rules-only mode.

### 4.2 Feature Types

| Store type | Module | Redis structure | Use case |
|---|---|---|---|
| Velocity counter | `TtlCounter` | Hash (HINCRBYFLOAT) | Sliding-window counts and sums |
| Event journal | `Journal` | Sorted Set (ZADD) | Timestamped event history |
| Latest payload | `PayloadLatest` | Hash (HSET) | Last-seen IP, device, location |
| HyperLogLog | `HllCounter` | HLL (PFADD/PFCOUNT) | Distinct count (cards per merchant, etc.) |
| LRU journal | `LruJournal` | Sorted Set (ZADD by ms) | Hot-entity tracking for batch warm-up |
| Graph adjacency | `GraphStore` | Set (SADD) | Entity relationship network |

### 4.3 Key Schema

All Redis keys are namespaced for tenant isolation:

```
risk:<tenant_id>:counter:<entity>:<value>:<horizon>   → TtlCounter Hash
risk:<tenant_id>:journal:<entity>:<value>             → Journal Sorted Set
risk:<tenant_id>:latest:<entity>:<value>              → PayloadLatest Hash
risk:<tenant_id>:hll:<entity>:<value>:<horizon>:<dim> → HyperLogLog
risk:<tenant_id>:lru:<entity>                         → LRU Sorted Set
risk:<tenant_id>:graph:<entity>:<value>:<rel_entity>  → Adjacency Set
risk:<tenant_id>:sanction:<list_type>                 → Sanctions Hash
```

### 4.4 Feature Registry (Phase 9)

`MwRisk.FeatureRegistry` is a compile-time module containing all 258 fraud features derived from the industry `fraud_rules.csv` specification.

| Dimension | Count |
|---|---|
| Total features | 258 |
| Entity types | 12 |
| Time horizons | 10 |
| Distinct metrics | 45 |

**Entity types:** `card`, `card_bin`, `card_merchant`, `card_mcc`, `card_country`, `card_ip`, `merchant`, `acceptor_device`, `mcc`, `cardholder_email`, `ip_device`, `booking_ref`

**Time horizons:** `5m`, `20m`, `1h`, `12h`, `1d`, `1w`, `2w`, `30d`, `90d`, `52w`

**Metric types (45 total, sample):**

| Metric key | Description |
|---|---|
| `count_tx` | Number of transactions |
| `count_success` | Successful transactions |
| `count_failed` | Failed / declined transactions |
| `sum_eur` | Total EUR amount |
| `sum_success_eur` | EUR amount of successful transactions |
| `count_auth` | Authorization attempts |
| `count_failed_auth` | Failed authorization attempts |
| `ratio_failed_auth` | Failed auth ratio |
| `count_atm` | ATM / cash-out transactions |
| `sum_atm_eur` | Total ATM EUR amount |
| `count_refund` | Refund / chargeback count |
| `sum_refund_eur` | Total refund EUR amount |
| `count_deposits` | Merchant deposit count |
| `distinct_cards` | Distinct cards seen at entity |
| `distinct_countries` | Distinct issuer countries |
| `count_cross_border` | Cross-border transactions |
| `count_high_risk_country` | High-risk country transactions |
| `count_geo_mismatch` | Device country ≠ card issuer country |
| `ip_is_datacenter` | IP classified as datacenter / VPN |

**Programmatic access:**
```elixir
MwRisk.FeatureRegistry.all()
# → [{entity, horizon, metric, description}, ...]

MwRisk.FeatureRegistry.horizons_for("card")
# → ["5m", "1h", "12h", "1d", "1w", "2w"]

MwRisk.FeatureRegistry.metrics_for("merchant", "1d")
# → ["count_tx", "sum_eur", "count_auth", ...]
```

### 4.5 Graph Entity Store (Phase 8.7)

After each transaction, bidirectional links are recorded between entities:

```
card ←──────────────→ merchant
card ←──────────────→ ip_device
merchant ←──────────→ ip_device
```

These adjacency sets allow graph-level fraud signals: a card that has visited 500 distinct merchants in 30 days, or an IP address associated with 200 distinct cards, are strong fraud indicators.

```elixir
InfraFeatureStore.GraphStore.degree(tenant_id, "card", card_num, "merchant")
# → {:ok, 47}  (card has visited 47 distinct merchants)

InfraFeatureStore.GraphStore.neighbors(tenant_id, "ip_device", ip, "card")
# → {:ok, ["4111...", "5500...", ...]}
```

---

## 5. Rules Engine

### 5.1 How Rules Work

Rules are evaluated against the feature map produced by `FeatureHydrator` + `AbstractionEngine`. Each rule specifies:

- **Entity type** — which entity the feature belongs to (`card`, `merchant`, etc.)
- **Feature key** — the exact key to evaluate (e.g. `card:1h:count_failed`)
- **Operator** — comparison: `gt`, `lt`, `gte`, `lte`, `eq`, `in`
- **Threshold** — the value to compare against
- **Decision** — what to do if the rule fires: `decline`, `review`, `flag`
- **Priority** — lower number = higher priority (evaluated first)

**Example rules:**

| Name | Feature key | Operator | Threshold | Decision |
|---|---|---|---|---|
| High hourly failures | `card:1h:count_failed` | `gt` | 3 | decline |
| Velocity spike | `card:5m:count_tx` | `gt` | 10 | review |
| Large single amount | `amount` | `gt` | 5000 | review |
| Datacenter IP | `ip_is_datacenter` | `eq` | 1.0 | review |
| New merchant spread | `card:1d:distinct_merchants` | `gt` | 20 | flag |

### 5.2 Rule Cache

`MwRisk.RuleCache` is a GenServer that holds all active rules in an ETS table for O(1) reads on the hot scoring path.

**Live reload:** When a rule is created, updated, or toggled in the UI, a `Phoenix.PubSub` message is broadcast. All nodes invalidate their `RuleCache` and reload from MySQL within milliseconds — no deployment needed.

### 5.3 Explainer

`MwRisk.Explainer` provides human-readable explanations of every score:

```json
{
  "decision": "decline",
  "score": 0.87,
  "fired_rules": ["High hourly failures", "Datacenter IP"],
  "explanation": "Card 411111XXXXXX1111 had 5 failed transactions in the last hour (threshold: 3). Request originated from a datacenter IP address."
}
```

Explanations are returned in the API response and displayed in the Score Detail UI.

---

## 6. Machine Learning Models

### 6.1 Model Architecture Overview

The ML subsystem uses a **two-model ensemble**:

```
Features (258 dimensions)
         │
         ▼
┌─────────────────────┐     ┌─────────────────────────┐
│  IsolationForest    │     │  Axon MLP               │
│  (unsupervised)     │     │  (supervised)            │
│  Scholar library    │     │  Nx + Axon library       │
│  Phase 5            │     │  Phase 6                 │
└──────────┬──────────┘     └────────────┬────────────┘
           │   score 0–1                 │   score 0–1
           └──────────────┬──────────────┘
                          │
                 Ensemble.score/2
                 (iforest_w × score_if + mlp_w × score_mlp)
                          │
                          ▼
                   Anomaly score 0.0–1.0
```

### 6.2 IsolationForest (Unsupervised — Phase 5)

**Purpose:** Detects anomalous transactions without needing labelled data. Ideal for cold-start (no historical fraud labels yet) and for detecting novel fraud patterns.

**Algorithm:** Builds random binary trees that isolate data points. Anomalies are isolated faster (shorter average path length) and receive higher anomaly scores.

**Implementation details:**

| Parameter | Value |
|---|---|
| Library | Scholar (Elixir, native Nx tensors) |
| Backend | BinaryBackend (CPU; EXLA optional) |
| Serialisation | `:erlang.term_to_binary/2` compressed |
| Model server | `MwRisk.ML.ModelServer` — ETS-backed, no GenServer roundtrip on hot path |
| Training worker | `MwRisk.Workers.ModelTrainer` (Oban, `risk_model` queue) |
| Synthetic fallback | 200-sample synthetic data when < 20 historical rows |

**Feature vector:** 24-dimensional normalised feature vector built by `FeatureVectorBuilder`. Min-max normalisation via `Normalizer`.

**Score interpretation:**

| Anomaly score | Meaning |
|---|---|
| < 0.5 | Normal behaviour |
| 0.5–0.69 | Slightly unusual |
| 0.7–0.84 | Anomalous — triggers `review` label in scored result |
| ≥ 0.85 | Highly anomalous |

### 6.3 Axon MLP (Supervised — Phase 6)

**Purpose:** Fraud classifier trained on labelled transactions (TC40 chargebacks, analyst resolutions, PaySim synthetic dataset). Achieves significantly higher precision than the IsolationForest once enough labels are available.

**Architecture search:** `MwRisk.ML.Mlp.search_topology/4` tries four topologies and selects the best by validation AUC-ROC:

| Topology | Parameters |
|---|---|
| `[64]` | 1 hidden layer, 64 neurons |
| `[128, 64]` | 2 hidden layers |
| `[256, 128]` | 2 wider layers |
| `[128, 64, 32]` | 3 hidden layers (default best) |

**Training trigger:** `MwRisk.Workers.AutoRetrainWorker` runs every 30 minutes and triggers a new MLP training run when ≥ 500 new matched labels have accumulated since the last training.

**Minimum labelled data:** 50 rows. Falls back to synthetic data if insufficient.

### 6.4 Ensemble (Phase 6.7)

The final score is a configurable weighted blend:

```
final_score = iforest_weight × iforest_score + mlp_weight × mlp_score
```

Default weights: `iforest = 0.5`, `mlp = 0.5`.  
Weights are configurable per tenant via the **Reprocessing UI** sliders and stored in ETS (`:mw_risk_ensemble_weights` table). Changes take effect on the next scored transaction — no restart required.

**Priority rules:**
1. Rules-engine `decline` always wins (ML cannot override a hard block)
2. ML score ≥ 0.7 elevates `approve` → `review`
3. Both systems agree `approve` → final decision is `approve`

### 6.5 Feature Importance (Phase 6.10)

`MwRisk.ML.FeatureImportance` uses **permutation importance** to rank which features most influence the model score. This is computed after each training run and stored in the model version's `metrics` field:

```json
{
  "feature_importance": {
    "card:1h:count_failed": 0.34,
    "balance_drain_ratio": 0.28,
    "ip_is_datacenter": 0.19,
    "card:5m:count_tx": 0.11,
    "amount": 0.08
  }
}
```

### 6.6 Model Lifecycle

```
1. Training triggered
   ├── AutoRetrainWorker (every 30min if 500+ new labels)
   ├── Manual via ModelTrainingLive UI
   └── PaySimImportWorker (after PaySim ingestion completes)

2. ModelTrainer / MlpTrainer (Oban workers)
   ├── Fetch labelled data from risk_scores + risk_labels
   ├── Build feature vectors
   ├── Train model (IsolationForest or MLP with topology search)
   ├── Compute metrics (AUC-ROC, anomaly_rate, mean, std)
   ├── Serialise artifact to disk
   └── Insert risk_model_versions with status="deployed"

3. ModelServer (GenServer)
   ├── Subscribes to "risk:models" PubSub topic
   ├── On "model_deployed" message: reload from disk into ETS
   └── ETS tables read-concurrently — no lock on predict hot path

4. Circuit breaker (:mw_risk_model_server fuse)
   ├── Trips after 3 consecutive predict failures in 5s
   ├── Resets after 20s
   └── Returns {:error, :model_circuit_open} when blown
       (scoring falls back to rules-only)
```

### 6.7 PaySim Training Dataset (Phase 9.4)

The `PaySimImportWorker` ingests the **PaySim Synthetic Financial Dataset** (Kaggle) to bootstrap the MLP with realistic fraud patterns.

| Dataset property | Value |
|---|---|
| Source | Kaggle: sriharshaeedala/financial-fraud-detection-dataset |
| File | `priv/static/Synthetic_Financial_datasets_log.csv` |
| Total rows | 6,362,620 |
| Fraud rows | 8,213 (0.13%) |
| Transaction types | CASH_OUT, PAYMENT, CASH_IN, TRANSFER, DEBIT |
| Fraud type | 100% TRANSFER + CASH_OUT with account balance drain |

**Import strategy:** Streams the CSV in 5,000-row batches (no OOM risk). All 8,213 fraud rows are imported; legitimate rows sampled at 1-in-50 (≈ 127,000 rows). Total imported: ≈ 135,000 rows.

**Feature snapshot per row (stored in `raw_payload`):**

```json
{
  "type": "TRANSFER",
  "amount": 181.0,
  "balance_drain_ratio": 1.0,
  "is_cashout": false,
  "is_transfer": true,
  "is_payment": false,
  "dest_is_account": true,
  "old_balance_orig": 181.0,
  "new_balance_orig": 0.0
}
```

**Triggering (one-shot, from IEx):**
```elixir
MwRisk.Workers.PaySimImportWorker.enqueue()
```

---

## 7. Case Management & Investigator Workflow

### 7.1 Case Lifecycle

```
Transaction DECLINED
        │
        ▼ (async, Oban CasesAutomation worker)
  RiskCase created
  status: "open"
  priority: derived from ml_score
        │
        ▼ (Investigator action in CaseManagementLive)
  status: "investigating"
  assigned_to: investigator_id
        │
        ▼ (Investigator resolves)
  status: "closed"
  resolution: "fraud_confirmed" | "false_positive" | "inconclusive"
  resolution_notes: free text
        │
        ▼ (if fraud_confirmed → optional)
  LabelIngestionWorker creates risk_label: fraud
  ReprocessingWorker re-scores historical transactions
```

### 7.2 RBAC Roles

Access to the fraud platform is gated by `GatewayWebWeb.FraudAuth` (`on_mount` callback in all fraud LiveViews).

| Role | Access |
|---|---|
| `fraud_analyst` | View scores, open/close cases, view rules (no edit) |
| `fraud_admin` | Full access: create/edit/delete rules, manage models, sanctions upload |
| `compliance` | View scores, cases, sanctions, compliance export |
| `superadmin` | All of the above |

### 7.3 Impossible Travel Detection (Phase 8.10)

`MwRisk.TravelDetector` raises a flag when:
- The same card is used from two **different IP addresses**
- Within a **10-minute window**

The previous IP and timestamp are stored in `PayloadLatest` after each transaction. The check runs **before** the model score and adds the `impossible_travel` signal to the feature map.

---

## 8. Sanctions & AML Compliance

### 8.1 Sanctions Screening

Every transaction is screened against the sanctions list **before** ML scoring. A match immediately short-circuits to `DECLINE` regardless of the ML score.

**Matching algorithm:** Levenshtein distance (Jaro-Winkler similarity ≥ 0.85) against the full name of the counterparty. Implemented in `MwRisk.SanctionsChecker`.

**Data sources supported:**
- OFAC SDN list (CSV upload via UI or SFTP)
- UN Security Council list
- EU consolidated list
- Custom entries added directly via the Sanctions UI

**Refresh:** `MwRisk.Workers.SanctionsLoader` Oban cron runs daily at 01:00 UTC.  
**Cache:** All sanctions entries are loaded into ETS on startup by `MwRisk.SanctionsCache`.

### 8.2 Compliance Export API

```
GET /api/v1/compliance/export
  ?tenant_id=<integer>    (required)
  &from=<ISO8601>         (required, e.g. 2026-01-01T00:00:00Z)
  &to=<ISO8601>           (required)
  &include=scores,cases,labels   (optional, default: all three)
```

**Response:**
```json
{
  "meta": {
    "exported_at": "2026-05-22T10:00:00Z",
    "tenant_id": 1,
    "from": "2026-01-01T00:00:00Z",
    "to": "2026-05-22T00:00:00Z",
    "record_counts": { "scores": 12450, "cases": 87, "labels": 341 }
  },
  "scores": [...],
  "cases":  [...],
  "labels": [...]
}
```

**Notes:**
- Maximum 50,000 records per export per entity type
- `feature_snapshot` is excluded from the export (model IP / PII)
- Requires a valid API key with compliance role

---

## 9. Admin Dashboards (UI Reference)

All dashboards are Phoenix LiveView pages at `/admin/fraud/*`. Access requires authentication and one of the fraud roles (see Section 7.2).

### 9.1 Fraud Dashboard (`/admin/fraud`)
**Audience:** Fraud Analysts, Fraud Admins  
Real-time summary metrics: transaction volume, score distribution, decline rate, recent alerts. Live-updates via PubSub without page refresh.

### 9.2 Score Explorer (`/admin/fraud/scores`)
**Audience:** Fraud Analysts  
Paginated table of all scored transactions with filters by date, decision, score range, tenant. Clicking a row opens Score Detail.

### 9.3 Score Detail (`/admin/fraud/scores/:id`)
**Audience:** Fraud Analysts  
Full breakdown of a single score: feature map, fired rules, ML score, model version, explainer narrative, link to open case.

### 9.4 Case Management (`/admin/fraud/cases`)
**Audience:** Fraud Analysts, Fraud Admins  
Work queue of open and in-progress cases. Supports bulk assignment, status transitions, resolution notes, and priority filtering.

### 9.5 Rules Builder (`/admin/fraud/rules`)
**Audience:** Fraud Admins  
CRUD interface for `risk_activation_rules`. Rules can be created, edited, toggled active/inactive, and deleted. Changes propagate to all scoring nodes within seconds via PubSub — **no deployment required**.

### 9.6 Sanctions (`/admin/fraud/sanctions`)
**Audience:** Fraud Admins, Compliance  
Three panels:
- **List view:** All sanctions entries with search
- **Add entry:** Single-entry form
- **CSV upload:** Bulk import of sanctions lists
- **Fuzzy tester:** Test a name against the current list interactively

### 9.7 Model Training (`/admin/fraud/models`)
**Audience:** ML Engineers, Fraud Admins  
Trigger and monitor ML training runs. Shows 5-stage progress badges (data_loading → training → evaluation → serialising → deploying) updated live. Displays metrics: training_size, mean score, std deviation, anomaly_rate.

### 9.8 Label Reprocessing (`/admin/fraud/labels`)
**Audience:** Data Scientists, ML Engineers  
Three panels:
- **Label ingestion status:** Recent TC40 / chargeback batches
- **Reprocessing:** Re-score historical transactions with updated labels
- **Ensemble weights:** Sliders to adjust IsolationForest vs. MLP blend weights

### 9.9 Feature Explorer (`/admin/fraud/features`)
**Audience:** Data Scientists, ML Engineers  
Three panels:
- **Redis Health:** Memory used, peak memory, fragmentation ratio
- **Entity Feature Lookup:** Search any card/merchant/IP; view all counter windows and HLL distinct counts
- **Hot Entities (LRU):** Top-20 most recently active entities per type — the same list used by `FeaturePrecalcWorker` to prioritise batch warm-up

---

## 10. REST API Reference

### Authentication
All endpoints require `Authorization: Bearer <jwt_token>` issued via `MwAuth`.

### 10.1 Transaction Scoring (inline — no separate call)
Scoring happens automatically inside `POST /api/v1/route` and `POST /api/v1/transactions`. The response includes risk fields:

```json
{
  "transaction_id": "txn_abc123",
  "status": "declined",
  "risk_score": 0.91,
  "risk_decision": "decline",
  "risk_model_version": "ensemble-v5",
  "fired_rules": ["High hourly failures", "Datacenter IP"]
}
```

### 10.2 Label Ingestion
```
POST /api/v1/risk/labels
Content-Type: application/json

{
  "external_ref": "TC40-2026-00123",
  "transaction_ref": "txn_abc123",
  "label_type": "tc40",
  "label_value": "fraud",
  "amount_eur": 250.00,
  "received_at": "2026-05-20T14:00:00Z"
}
```

### 10.3 Compliance Export
```
GET /api/v1/compliance/export?tenant_id=1&from=2026-01-01T00:00:00Z&to=2026-05-22T00:00:00Z&include=scores,cases
```

---

## 11. Security & Encryption

### 11.1 Feature Snapshot Encryption at Rest (Phase 8.11)

The `feature_snapshot` column (MySQL JSON) stores the full feature vector used to produce each score. This contains behavioural signals that must be protected.

**Implementation:** `MwRisk.FeatureSnapshotCrypto` — AES-256-GCM with a random 12-byte nonce per record.

**Storage format:** The encrypted snapshot is stored as a JSON wrapper:
```json
{ "__enc": "v1:BASE64_ENCODED_NONCE+TAG+CIPHERTEXT" }
```

**Key management:**
```bash
# Generate a 32-byte key (Base64-encoded):
openssl rand -base64 32

# Set as environment variable:
export FEATURE_SNAPSHOT_KEY="<output from above>"
```

> ⚠️ **Production requirement:** `FEATURE_SNAPSHOT_KEY` must be set via secrets manager (AWS Secrets Manager, Vault, etc.). If not set, a deterministic dev key is used — **never acceptable in production**.

### 11.2 API Authentication
JWT tokens validated by `MwAuth.Plug`. Tokens include `tenant_id` and `roles` claims. All routes within `/api/v1` (except `/health/*`) require a valid token.

### 11.3 RBAC (UI)
`GatewayWebWeb.FraudAuth` enforces role checks at the LiveView `on_mount` lifecycle hook. Invalid roles receive a 403 redirect to the admin home page.

### 11.4 VPN / Proxy Detection (Phase 8.9)
`MwRisk.IpClassifier` classifies IP addresses against known datacenter and Tor exit node CIDR ranges. The result (`ip_is_datacenter: 0.0 | 1.0`) is added to the feature map and can be used in rules.

> **Production hardening:** Replace the static CIDR list with a daily-refreshed feed from AWS `ip-ranges.json`, GCP, Azure, and MaxMind GeoLite2.

---

## 12. Operational Guide

### 12.1 Environment Variables

| Variable | Default | Description |
|---|---|---|
| `RISK_SCORING_ENABLED` | `false` | Enable real-time scoring |
| `RISK_VELOCITY_PIPELINE` | `false` | Enable velocity counter updates |
| `RISK_MODEL_SERVING` | `false` | Enable ML model predictions |
| `RISK_MODEL_TRAINING` | `false` | Allow training runs |
| `RISK_LABEL_INGESTION` | `false` | Enable TC40 label ingestion |
| `RISK_MLP_SERVING` | `false` | Enable MLP model in ensemble |
| `RISK_SANCTIONS_LOADER` | `false` | Enable daily sanctions refresh |
| `RISK_CACHE_PRUNING` | `false` | Enable 6-hourly LRU pruning |
| `RISK_FEATURE_PRECALC` | `false` | Enable daily batch pre-calculation |
| `RISK_CASES_AUTOMATION` | `false` | Auto-open cases on DECLINE |
| `AUTO_RETRAIN_THRESHOLD` | `500` | New labels to trigger retraining |
| `FEATURE_SNAPSHOT_KEY` | *(dev key)* | AES-256-GCM encryption key (Base64, 32 bytes) |
| `REDIS_URL` | `redis://localhost:6379` | Redis connection string |
| `LABEL_DROP_DIR` | `/tmp/risk_label_drop` | SFTP label file drop directory |
| `PRECALC_WARM_TOP_N` | `500` | Entities to pre-warm in batch |
| `PAY_SIM_CSV_PATH` | `priv/static/Synthetic_Financial_datasets_log.csv` | Training dataset path |

### 12.2 Oban Background Queues

| Queue | Concurrency | Workers |
|---|---|---|
| `risk_scoring` | 20 | Async score writes |
| `risk_velocity` | 50 | Velocity counter updates (Broadway) |
| `risk_model` | 5 | ModelTrainer, MlpTrainer |
| `risk_cases` | 10 | CasesAutomation |
| `risk_labels` | 10 | LabelIngestionWorker, PaySimImportWorker |
| `risk_sanctions` | 5 | SanctionsLoader |
| `risk_precalc` | 3 | FeaturePrecalcWorker |
| `risk_pay_sim` | 1 | PaySimImportWorker (one-shot) |

### 12.3 Scheduled Jobs (Oban Cron)

| Schedule | Worker | Action |
|---|---|---|
| `0 2 * * *` | `FeaturePrecalcWorker` | Daily feature warm-up + HLL rollup (1d→30d) |
| `0 3 * * 0` | `FeaturePrecalcWorker` | Weekly HLL rollup (30d→90d) |
| `0 1 * * *` | `SanctionsLoader` | Refresh sanctions list |
| `0 */6 * * *` | `CachePruning` | LRU prune + Redis memory alert |
| `*/15 * * * *` | `SftpIngestionWorker` | Scan SFTP label drop directory |
| `*/30 * * * *` | `AutoRetrainWorker` | Trigger retraining if 500+ new labels |

### 12.4 Redis Memory Management

**Monitoring:** `InfraFeatureStore.RedisMemory` emits a Telemetry event `[:infra_feature_store, :redis, :memory]` every 6 hours. A warning is logged when usage exceeds 85% of `maxmemory`.

**TTLs by horizon:**

| Horizon | TTL |
|---|---|
| `5m`, `20m`, `1h` | 24 hours |
| `12h`, `1d` | 3 days |
| `1w`, `2w` | 21 days |
| `30d` | 45 days |
| `90d`, `52w` | 120 days |

**LRU pruning:** `CachePruning` worker removes LRU journal entries older than 90 days every 6 hours. Graph adjacency keys expire after 90 days (TTL set on SADD).

### 12.5 Chaos Test Runbooks

- Redis failure: `docs/fraud-extension/runbooks/8.5-chaos-redis.md`
- MySQL write failure: `docs/fraud-extension/runbooks/8.6-chaos-mysql.md`
- Latency profiling at 2000 TPS: `docs/fraud-extension/runbooks/8.1-latency-profiling.md`
- 5000 TPS load test: `docs/fraud-extension/runbooks/8.14-load-test-5000tps.md`
- Penetration testing checklist: `docs/fraud-extension/runbooks/8.13-pentest-checklist.md`

---

## 13. Data Science Guide

### 13.1 Feature Engineering Pipeline

```
Transaction event
      │
      ▼
FeatureHydrator
      ├── Type A: 12 payload fields (amount, card, merchant, ip, email, ...)
      └── Type B: 258 velocity features from FeatureStore
                  (10 entities × up to 10 horizons × 45 metrics)
      │
      ▼
AbstractionEngine
      ├── ratio_failed = count_failed / count_tx
      ├── avg_amount = sum_eur / count_tx
      └── ip_is_datacenter (IpClassifier)
      │
      ▼
FeatureVectorBuilder
      └── Selects 24 numeric features → Normalizer → Nx tensor
```

### 13.2 Ground Truth Labels

Labels come from three sources, in priority order:

1. **TC40 chargebacks** — most reliable; from card schemes
2. **Investigator resolutions** — `fraud_confirmed` from CaseManagementLive
3. **PaySim synthetic** — bootstrapping only; 8,213 fraud + 127k legitimate rows

Labels are matched to `risk_scores` by `transaction_ref`. Matched scores get `label` and `labeled_at` populated.

### 13.3 Model Evaluation Metrics

After each training run, metrics are stored in `risk_model_versions.metrics`:

```json
{
  "training_size": 8500,
  "auc_roc": 0.97,
  "anomaly_rate": 0.023,
  "mean_score": 0.21,
  "std_score": 0.18,
  "feature_importance": { "balance_drain_ratio": 0.34, ... }
}
```

**Target AUC-ROC:** ≥ 0.95 on PaySim data; ≥ 0.85 on production labelled data.

### 13.4 HyperLogLog Distinct Counts

For features like "distinct merchants visited by this card in 30 days", `HllCounter` provides probabilistic distinct counts using Redis PFADD/PFCOUNT.

**Error rate:** ≈ 0.81% standard error (Redis HyperLogLog guarantee).  
**Memory:** 12 KB per key regardless of cardinality.

**Daily rollup via `FeaturePrecalcWorker`:**
```
Daily run:  PFMERGE(1d_keys) → 30d_key
Weekly run: PFMERGE(30d_keys) → 90d_key
```

### 13.5 Label Ingestion Workflow

**Automated (SFTP / API):**
```bash
# CSV format for SFTP drop:
# external_ref,transaction_ref,label_type,label_value,amount_eur,received_at
TC40-001,txn_abc123,tc40,fraud,250.00,2026-05-20T14:00:00Z

# API:
curl -X POST /api/v1/risk/labels -d '{"external_ref":"TC40-001",...}'
```

**Manual (IEx):**
```elixir
InfraRepo.Repo.insert!(%InfraRepo.Schemas.RiskLabel{
  tenant_id: 1,
  external_ref: "manual-001",
  transaction_ref: "txn_abc123",
  label_type: "tc40",
  label_value: "fraud",
  received_at: DateTime.utc_now()
})
```

---

## 14. ML Engineering Guide

### 14.1 ModelServer Architecture

```
ETS table :mw_risk_models
  key: {tenant_id, :iforest}  → {forest, normalizer, feature_set, version_id}
  key: {tenant_id, :mlp}      → {predict_fn, params, normalizer, feature_set, version_id}

ETS table :mw_risk_ensemble_weights
  key: tenant_id              → {iforest_weight, mlp_weight}
```

Both ETS tables use `:read_concurrency: true`. The hot-path `predict/2` call is a pure ETS lookup — no GenServer message-passing, no lock contention.

### 14.2 Training a New Model (manual)

```elixir
# IsolationForest:
%{tenant_id: 1} |> MwRisk.Workers.ModelTrainer.new() |> Oban.insert()

# MLP:
%{tenant_id: 1} |> MwRisk.Workers.MlpTrainer.new() |> Oban.insert()

# Auto-trigger if enough labels:
MwRisk.Workers.AutoRetrainWorker.new(%{}) |> Oban.insert()
```

### 14.3 Model Artifacts

Artifacts are serialised with `:erlang.term_to_binary(term, [:compressed])` and stored at `artifact_path` (configured in `config/prod.exs`). The `ModelArtifact` module handles read/write:

```elixir
# Artifact map structure:
%{
  "forest"       => %{...IsolationForest internal state...},
  "normalizer"   => %{"min" => [...], "max" => [...]},
  "feature_set"  => ["card:1h:count_tx", "amount", ...],
  "topology"     => [128, 64, 32],   # MLP only
  "n_features"   => 24,              # MLP only
  "params"       => %{...Axon params...}  # MLP only
}
```

### 14.4 Ensemble Weight Tuning

Weights can be updated live from the Reprocessing UI or programmatically:

```elixir
# Via GenServer (persists to ETS):
MwRisk.ML.ModelServer.set_ensemble_weights(tenant_id, 0.3, 0.7)
# iforest_weight=0.3, mlp_weight=0.7

# Read current weights:
MwRisk.ML.ModelServer.get_ensemble_weights_or_default(tenant_id)
# → {0.3, 0.7}
```

### 14.5 Circuit Breaker Behaviour

| Fuse | Policy | Blown response |
|---|---|---|
| `:infra_feature_store_redis` | 5 fails / 10s → reset 30s | `{:error, :circuit_open}` — rules-only scoring |
| `:mw_risk_model_server` | 3 fails / 5s → reset 20s | `{:error, :model_circuit_open}` — rules-only scoring |

When either circuit is open, scoring continues with reduced functionality (rules still run). This is logged and observable via Telemetry.

### 14.6 Adding a New Model Type

1. Create a training worker in `apps/mw_risk/lib/mw_risk/workers/`
2. Add an artifact serialisation format to `ModelArtifact`
3. Add a `load_<type>/2` branch in `ModelServer.load_for_tenant/1`
4. Add a `predict_<type>/2` function and update `Ensemble.score/2`
5. Add a `case "<algorithm>"` branch in `load_for_tenant/1`

---

## 15. Performance & Scalability

### 15.1 Latency Budget

| Stage | Target P95 | Notes |
|---|---|---|
| ETS read (L1 hit) | < 1 µs | In-process read |
| Redis read (L2 hit) | < 3 ms | Connection pool (N=4) |
| Rules evaluation | < 2 ms | ETS-backed RuleCache |
| ML predict (ensemble) | < 5 ms | ETS-backed ModelServer |
| **Total scoring** | **< 50 ms** | End-to-end P95 at 2000 TPS |
| **Total scoring** | **< 100 ms** | P99 at 5000 TPS (target) |

### 15.2 Throughput Targets

| Load | Status |
|---|---|
| 500 TPS | ✅ Verified (Phase 3 exit criteria) |
| 2000 TPS P95 < 50ms | 📋 Runbook 8.1 (post-deployment test) |
| 5000 TPS P99 < 100ms | 📋 Runbook 8.14 (hardening target) |

### 15.3 Horizontal Scaling

The scoring pipeline is **stateless** at the request level. State lives in:
- **Redis** (velocity counters, shared across nodes)
- **MySQL** (persistent records, shared across nodes)
- **ETS** (per-node cache, invalidated via PubSub)

To scale horizontally: add application nodes behind a load balancer. Oban queues with MySQL ensure jobs are not duplicated. RuleCache and ModelServer are warmed independently on each node at startup.

### 15.4 Redis Connection Pool

4 named Redix connections (`infra_feature_store_redis_0..3`). Each calling process is assigned a connection by `phash2(self(), 4)`. This provides:
- Pipelining parallelism under high concurrency
- No single connection bottleneck at > 500 TPS

---

## 16. Configuration Reference

### 16.1 Feature Flags (`config/config.exs`)

All fraud/AML features are **disabled by default**. Enable per environment:

```elixir
# config/prod.exs
config :mw_risk,
  risk_scoring_enabled:    true,
  risk_velocity_pipeline:  true,
  risk_model_serving:      true,
  risk_model_training:     true,
  risk_label_ingestion:    true,
  risk_mlp_serving:        true,
  risk_sanctions_loader:   true,
  risk_cache_pruning:      true,
  risk_feature_precalc:    true,
  risk_cases_automation:   true
```

### 16.2 Redis Configuration

```elixir
config :infra_feature_store,
  redis_url:           "redis://redis:6379",  # set via REDIS_URL env
  ets_max_size:        50_000,
  ets_default_ttl_ms:  30_000
```

### 16.3 Oban Queue Tuning (production)

```elixir
config :infra_repo, Oban,
  queues: [
    risk_scoring:   100,   # scale with transaction volume
    risk_velocity:  200,   # highest throughput queue
    risk_model:      10,
    risk_cases:      20,
    risk_labels:     20,
    risk_sanctions:   5,
    risk_precalc:     5,
    risk_pay_sim:     1
  ]
```

---

## 17. Delivery Timeline

| Phase | Target | Status | Key Deliverable |
|---|---|---|---|
| 1 — Feature Store | Weeks 1–4 | ✅ | Two-tier ETS+Redis feature store |
| 2 — Data Model | Weeks 3–5 | ✅ | 7 MySQL tables + Ecto schemas |
| 3 — Rules MVP | Weeks 5–9 | ✅ | Real-time rule-based scoring in pipeline |
| 4 — Case Management | Weeks 9–13 | ✅ | 6 LiveView dashboards + RBAC |
| 5 — IsolationForest | Weeks 13–18 | ✅ | Unsupervised ML in ensemble |
| 6 — MLP + Labels | Weeks 18–24 | ✅ | Supervised ML + TC40 ingestion + auto-retrain |
| 7 — Batch Features | Weeks 22–27 | ✅ | HLL, LRU, batch pre-calc, Redis monitoring |
| 8 — Hardening | Weeks 27–32 | ✅ | Pooling, circuit breakers, encryption, graph store, VPN/travel detection, compliance API |
| 9 — Feature Completeness | Weeks 32–35 | ✅ | 258-feature registry, 10 entities, PaySim training data |

**Total:** 35 weeks  
**First production value (rules-only scoring):** Week 9  
**Full ML ensemble in production:** Week 24  
**Hardened, compliance-ready system:** Week 35

---

## Appendix A — Module Index

### `infra_feature_store` app

| Module | Purpose |
|---|---|
| `InfraFeatureStore.Application` | Starts Redis pool (N=4), installs fuse, EtsTierCache, SyncSubscriber |
| `InfraFeatureStore.FeatureStore` | Public API delegating to all sub-stores |
| `InfraFeatureStore.TtlCounter` | Sliding-window velocity counters (Redis Hash) |
| `InfraFeatureStore.Journal` | Timestamped event log (Redis Sorted Set) |
| `InfraFeatureStore.PayloadLatest` | Last-seen payload fields (Redis Hash) |
| `InfraFeatureStore.HllCounter` | Distinct-count HyperLogLog (Redis PFADD) |
| `InfraFeatureStore.LruJournal` | LRU hot-entity tracking (Redis Sorted Set by ms) |
| `InfraFeatureStore.GraphStore` | Entity adjacency graph (Redis Set) |
| `InfraFeatureStore.EtsTierCache` | L1 ETS read-through cache |
| `InfraFeatureStore.RedisTier` | Redis command wrapper (pool + circuit breaker) |
| `InfraFeatureStore.SyncSubscriber` | PubSub listener for ETS invalidation |
| `InfraFeatureStore.KeyBuilder` | Canonical Redis key construction |
| `InfraFeatureStore.RedisMemory` | Redis INFO memory parser + Telemetry emitter |

### `mw_risk` app — Core

| Module | Purpose |
|---|---|
| `MwRisk.ScoringPipeline` | Orchestrates the full scoring flow |
| `MwRisk.FeatureHydrator` | Extracts Type A + Type B features |
| `MwRisk.AbstractionEngine` | Computes derived ratio + IP features |
| `MwRisk.ActivationEngine` | Evaluates rules against feature map |
| `MwRisk.RuleCache` | ETS-backed active rules cache |
| `MwRisk.Explainer` | Human-readable score explanations |
| `MwRisk.VelocityPipeline` | Async Redis counter updates (10 entities, 13 metrics) |
| `MwRisk.EventBroadcaster` | PubSub broadcast of scoring events |
| `MwRisk.SanctionsChecker` | Fuzzy-match sanctions screening |
| `MwRisk.SanctionsCache` | ETS-backed sanctions list |
| `MwRisk.FeatureRegistry` | Compile-time registry of 258 fraud features |
| `MwRisk.IpClassifier` | Datacenter/Tor IP CIDR classifier |
| `MwRisk.TravelDetector` | Impossible travel (different IP, < 10 min) |
| `MwRisk.FeatureSnapshotCrypto` | AES-256-GCM encryption for feature_snapshot |

### `mw_risk` app — ML

| Module | Purpose |
|---|---|
| `MwRisk.ML.ModelServer` | ETS-backed model holder; predict hot path |
| `MwRisk.ML.Ensemble` | Weighted IsolationForest + MLP blend |
| `MwRisk.ML.IsolationForest` | Scholar isolation forest scoring |
| `MwRisk.ML.Mlp` | Axon MLP build + topology search + train |
| `MwRisk.ML.FeatureVectorBuilder` | 24-dim numeric vector construction |
| `MwRisk.ML.Normalizer` | Min-max normalisation (stored with model) |
| `MwRisk.ML.ModelArtifact` | Erlang term serialisation / deserialisation |
| `MwRisk.ML.FeatureImportance` | Permutation importance post-training |

### `mw_risk` app — Oban Workers

| Worker | Queue | Trigger |
|---|---|---|
| `ModelTrainer` | risk_model | Manual / AutoRetrain |
| `MlpTrainer` | risk_model | Manual / AutoRetrain / PaySim |
| `AutoRetrainWorker` | risk_model | Cron every 30 min |
| `LabelIngestionWorker` | risk_labels | API / SFTP |
| `SftpIngestionWorker` | risk_labels | Cron every 15 min |
| `ReprocessingWorker` | risk_labels | Manual from UI |
| `CasesAutomation` | risk_cases | On DECLINE decision |
| `SanctionsLoader` | risk_sanctions | Cron daily 01:00 |
| `FeaturePrecalcWorker` | risk_precalc | Cron daily 02:00 + weekly 03:00 |
| `CachePruning` | risk_precalc | Cron every 6 hours |
| `PaySimImportWorker` | risk_pay_sim | One-shot manual |

### `gateway_web` app — Fraud LiveViews

| LiveView | Route | Role required |
|---|---|---|
| `FraudDashboardLive` | `/admin/fraud` | Any fraud role |
| `ScoreExplorerLive` | `/admin/fraud/scores` | Any fraud role |
| `ScoreDetailLive` | `/admin/fraud/scores/:id` | Any fraud role |
| `CaseManagementLive` | `/admin/fraud/cases` | Any fraud role |
| `RulesBuilderLive` | `/admin/fraud/rules` | fraud_admin |
| `SanctionsLive` | `/admin/fraud/sanctions` | fraud_admin, compliance |
| `ModelTrainingLive` | `/admin/fraud/models` | fraud_admin |
| `ReprocessingLive` | `/admin/fraud/labels` | fraud_admin |
| `FeatureExplorerLive` | `/admin/fraud/features` | Any fraud role |

---

*Document maintained by: MercuryPay Engineering*  
*Last updated: 2026-05-22*  
*Source of truth: `docs/fraud-extension/FRAUD_AML_PLATFORM.md`*
