# Phase L — Maker-Checker Approval Flow

**Branch**: `feat/fraud-maker-checker`
**Parent**: `feat/fraud-rules-jube-parity`
**Jube reference**: `ReviewStatusId` enum on every rule POCO + Image-8 "Pending Review" page.

---

## 1. Goal

Today any user with rule-edit permission can change `active = true` and the
new rule starts firing immediately. Maker-Checker enforces:

1. **Maker** edits a rule → it lands in `pending_review`, *not* published.
2. **Checker** (different user, RBAC-enforced) sees a unified review queue
   showing the diff vs the live version → Approve / Reject with comment.
3. Approved rule enters the runtime cache; rejected rule reverts.

Applies uniformly to Gateway, Abstraction and Activation rules (and later to
Request XPaths, TTL Counters, Case Workflows — but L scopes to the three
core rule types only).

---

## 2. Source-of-truth field mapping

Jube `ReviewStatusId` (integer enum 0–5). We model as a string for clarity:

| status         | meaning |
|---|---|
| `draft`            | created but not yet submitted |
| `pending_review`   | awaiting approver |
| `approved`         | active, in cache |
| `rejected`         | sent back to maker with comments |
| `withdrawn`        | maker pulled it before review |
| `superseded`       | a newer version was approved |

A `review_status` column is added (default `approved` for back-compat so
existing rules keep firing during the migration window — a follow-up sets
all to `approved` then flips the default to `draft`).

---

## 3. Architectural pieces

| Piece | Module |
|---|---|
| Status column            | `review_status` + `review_*` audit fields on all three rule tables |
| Version history table    | `risk_rule_versions` (rule_type, rule_id, version, snapshot, diff, by) |
| Review queue context     | `InfraRepo.Risk.Reviews` |
| Review queue LiveView    | `/admin/fraud/reviews` |
| Embedded reviewer drawer | per-rule (when status = pending_review) |
| RBAC                     | `Auth.can?(user, :approve_rule)` reusing existing role table |

---

## 4. Work breakdown

### 4.1 Migration

Add to each of `risk_gateway_rules`, `risk_abstraction_rules`, `risk_activation_rules`:

```elixir
add :review_status,        :string,  null: false, default: "approved"
add :review_submitted_by,  :string
add :review_submitted_at,  :utc_datetime
add :review_decided_by,    :string
add :review_decided_at,    :utc_datetime
add :review_comment,       :string,  size: 2000
```

New table `risk_rule_versions`:
```elixir
add :tenant_id,   :integer, null: false
add :rule_type,   :string,  null: false   # gateway | abstraction | activation
add :rule_id,     :integer, null: false
add :version,     :integer, null: false
add :snapshot,    :map,     null: false   # the full row as a map
add :diff,        :map                    # vs previous approved version
add :review_status, :string,  null: false
add :created_by,  :string
timestamps(updated_at: false)
```

Indexed on `(tenant_id, rule_type, rule_id, version desc)`.

### 4.2 Workflow rules

On any rule changeset:

| Old status | Action | New status |
|---|---|---|
| draft / approved (with changes) | maker hits "Submit for review" | pending_review |
| pending_review | checker approves | approved (bumps `version`, supersedes previous approved row's `review_status` → `superseded`) |
| pending_review | checker rejects (with comment) | rejected |
| rejected | maker resubmits after edits | pending_review |
| any except approved | maker hits "Withdraw" | withdrawn |

Implementation: a single `Reviews.transition/3` function that wraps an
Ecto.Multi (changeset + snapshot insert + cache invalidation), so all three
rule contexts call into it instead of duplicating logic.

### 4.3 Cache integration

`MwRisk.RuleCache` currently loads `WHERE active = true`. Change to
`WHERE active = true AND review_status = 'approved'`. This is the only
runtime touchpoint — pending/rejected rules never load.

### 4.4 LiveView

`/admin/fraud/reviews`:
- Three-tab filter: All · Pending · Decided (last 30 d).
- Row per pending rule: type chip · name · submitted by · submitted at · diff size badge ("3 fields changed").
- Drawer shows:
  - Header: rule name + submitter + ago.
  - Side-by-side diff (live vs proposed) using simple key/value comparison rendered as a 2-col HEEx table with `+` / `−` line gutters.
  - Comment textbox.
  - **Approve** / **Reject** buttons (disabled if `current_user.id == rule.review_submitted_by` — enforces separation of duties).
- After decision: PubSub `"reviews:#{tenant_id}"` notifies other open queues.

### 4.5 Per-rule drawer changes

In `GatewayRulesLive` / `AbstractionRulesLive` / `ActivationRulesLive`:
- Replace **Save** + **Save and Publish** buttons with **Save Draft** + **Submit for Review**.
- Show a status pill at the top of the drawer.
- If `review_status == "rejected"`, surface `review_comment` as a yellow callout above the form.

### 4.6 RBAC

Add seed role:
- `fraud_rule_maker`    — can create / edit / submit
- `fraud_rule_checker`  — can approve / reject
- `fraud_rule_admin`    — both, but **`Reviews.transition`** still enforces "submitter ≠ approver" even for admins.

### 4.7 Tests

- `reviews_transition_test.exs` — covers every legal + illegal transition.
- Cache exclusion test — pending rule does NOT load into `RuleCache`.
- Self-approval rejected (maker == checker).
- LiveView: submit a rule edit, switch to a different user session, approve, assert rule is in cache.

### 4.8 Backfill

In the migration's `change/0`, after adding the column, run:

```elixir
execute(
  "UPDATE risk_gateway_rules    SET review_status = 'approved' WHERE review_status IS NULL",
  "UPDATE risk_gateway_rules    SET review_status = NULL       WHERE review_status = 'approved'"
)
# repeat for abstraction + activation
```

Then update the default to `'draft'` in a second migration once ops confirm.

---

## 5. Acceptance criteria

- [ ] Editing a previously approved rule moves it to `pending_review` and removes it from `RuleCache`.
- [ ] Reviewer ≠ submitter is enforced (server-side, not just UI).
- [ ] Approve: rule re-enters cache; `risk_rule_versions` snapshot is appended; previous approved version flips to `superseded`.
- [ ] Reject: rule stays out of cache; comment is visible to maker.
- [ ] Audit query `SELECT review_status, count(*) FROM risk_*_rules GROUP BY 1` matches expectations after a scripted maker/checker cycle.

---

## 6. Out of scope

- Multi-level approvals (maker → 2 checkers).
- Time-bound auto-approval ("if not reviewed in 24 h, auto-approve").
- Review delegation / vacation deputies.
- Bulk approve.
- Maker-Checker on Request XPaths / TTL Counters / Case Workflows (separate follow-up — same `transition/3` helper, different `rule_type` value).
