# Phase H — Rule Builder Data Nodes (Sanctions / Tags / Dictionary)

**Branch**: `feat/fraud-rule-builder-data-nodes`
**Parent**: `feat/fraud-rules-jube-parity`
**Jube reference**: `EntityAnalysisModelDictionary`, `RiskTag`, `Sanction` lookups inside the rule predicate AST.

---

## 1. Goal

Extend `MwRisk.RuleExpression` (Phase D) to recognise three new node types
in the AST so rule authors can write predicates like:

- *"Customer name matches OFAC sanction list with ≥ 90 % fuzzy match"*
- *"Card BIN is tagged `prepaid` or `corporate`"*
- *"MCC code's risk-weight dictionary value ≥ 0.7"*

All three target tables already exist:
- [`risk_sanctions_list`](../../../apps/infra_repo/lib/infra_repo/schemas/risk_sanction.ex)
- [`risk_tags`](../../../apps/infra_repo/lib/infra_repo/schemas/risk_tag.ex)
- [`risk_dictionaries`](../../../apps/infra_repo/lib/infra_repo/schemas/risk_dictionary.ex) + `risk_dictionary_kvps`

---

## 2. AST extensions

Add three new `op` values to the parser:

```jsonc
// Sanction check
{"op":"sanction", "field":"customer_name",
 "list_type":"ofac", "entity_type":"person",
 "match":"fuzzy", "threshold":0.9}

// Tag membership
{"op":"tag", "field":"card_bin",
 "tag":"prepaid", "any_of":["prepaid","corporate"]}

// Dictionary lookup with comparator
{"op":"dict", "field":"mcc", "dictionary":"mcc_risk_weights",
 "cmp":">=", "value":0.7}
```

All three are leaf ops (no `children`). They reduce to a boolean in
`evaluate/2`, identical contract to `cond`.

---

## 3. Work breakdown

### 3.1 Evaluator

`apps/mw_risk/lib/mw_risk/rule_expression.ex`:

- Extend `normalise/1` to recognise the three ops and pass them through.
- Add three private `eval_node/2` clauses:

```elixir
defp eval_node(%{"op" => "sanction", "field" => f} = node, payload) do
  val = get_field(payload, f)
  MwRisk.SanctionsChecker.matches?(val,
    list_type:  node["list_type"],
    entity_type: node["entity_type"],
    match:      node["match"]     || "exact",
    threshold:  node["threshold"] || 1.0)
end
```

(`SanctionsChecker.fuzzy_check/1` already exists at
`apps/mw_risk/lib/mw_risk/sanctions_checker.ex`; just add the missing
`matches?/2` wrapper.)

For `tag` / `dict`, add cache-fronted lookups:
- `MwRisk.TagCache.has_tag?(field_value, tag, any_of)`
- `MwRisk.DictionaryCache.lookup(dict_name, key)` returning a numeric value
  that the `cmp/value` pair compares against using existing `compare/3` helper.

Both caches use `Cachex` with 5-min TTL and PubSub invalidation on CRUD writes
from the Lists/Sanctions/Dictionaries LiveViews.

### 3.2 RuleBuilder LiveComponent

`MwRiskWeb.RuleBuilderComponent`:

- Add three new buttons next to "+ AND / + OR / + NOT / + Condition":
  - `+ Sanction Check`
  - `+ Tag Check`
  - `+ Dictionary Lookup`
- Each opens an inline mini-form bound to the AST node:
  - **Sanction**: field selector, list_type dropdown, entity_type dropdown, match (exact/fuzzy), threshold slider.
  - **Tag**: field selector, tag picker (multi-select from `Risks.list_tags/1`).
  - **Dict**: field selector, dictionary picker, comparator, value.
- Render-only render-function `render_node/1` extends with the three new shapes,
  showing a coloured chip + summary string (e.g. *"customer_name ⟶ OFAC fuzzy ≥0.9"*).

### 3.3 Pipeline integration

No changes to `MwRisk.Pipeline.run/2` itself — the evaluator change is fully
backward compatible. The three new node types are transparent to
GatewayRuleEngine / AbstractionEngine / ActivationEngine.

### 3.4 Tests

- Property tests for evaluator:
  - sanction exact / fuzzy positive / fuzzy negative
  - tag single + `any_of` membership
  - dict missing-key fallback (returns false, never raises)
- RuleBuilder LiveComponent test: add a sanction node, save, reload → AST round-trips.

### 3.5 Seeds

Append demo rules using each new op (one gateway rule with a `sanction` check,
one activation rule with a `dict` lookup, one abstraction filter with `tag`).

---

## 4. Acceptance criteria

- [ ] All four existing AST ops still evaluate identically (regression-tested).
- [ ] A rule with a `{"op":"sanction", ...}` node fires when the payload field matches an active sanction row.
- [ ] Editing a rule with a mix of `cond` / `sanction` / `tag` / `dict` nodes round-trips through the LiveComponent.
- [ ] Cache invalidation: editing a tag in `TagsLive` invalidates `TagCache` within 1s.

---

## 5. Out of scope

- Sanction list ingestion pipelines (OFAC SDN parser, EU XML feed) — separate effort.
- Nested AND/OR inside a sanction/tag/dict node (they remain leaves).
- Multi-field sanction checks (compose at AND-level instead).
- Per-tenant dictionary overrides.
