# Mercury Shukria — Transaction Monitoring rule mapping

Maps every row of `TransactionMonitoringRules_csv.csv` (47 rules) onto the
objects created by
[`apps/infra_repo/priv/repo/seeds/shukria_model_config_seed.exs`](../../apps/infra_repo/priv/repo/seeds/shukria_model_config_seed.exs).

**Calling the model?** The wire contract for the transaction backend team is a
separate document: [Shukria API integration guide](./mercury-shukria-integration-guide.md).

```
mix run apps/infra_repo/priv/repo/seeds/shukria_model_config_seed.exs
```

The seed matches every row on its natural key and **updates it in place**, so
re-running it after editing the file brings an already-seeded model up to date
rather than leaving stale rows behind. It also removes objects superseded by an
earlier revision. This file is the source of truth for the model — hand-tuning a
seeded row in the admin UI will be overwritten on the next run.

It prints the model's GUID; that GUID is the scoring endpoint:

```
POST /admin/api/models/<guid>/verify
```

---

## 1. What the seed creates

| Object | Count | Table |
|---|---:|---|
| Entity model | 1 | `risk_entity_models` |
| Case workflow + statuses | 1 + 8 | `risk_case_workflows`, `risk_case_workflow_statuses` |
| Request xpaths (payload contract) | 23 | `risk_request_xpaths` |
| Reference lists | 10 | `risk_lists`, `risk_list_values` |
| Dictionary (merchant own-card register) | 1 | `risk_dictionaries`, `risk_dictionary_kvps` |
| TTL counters | 36 | `risk_ttl_counters` |
| Abstraction rules | 3 | `risk_abstraction_rules` |
| Abstraction calculations | 2 | `risk_abstraction_calculations` |
| HTTP adaptation + mappings | 1 + 3 | `risk_http_adaptations`, `risk_http_adaptation_mappings` |
| Gateway rules | 4 | `risk_gateway_rules` |
| Tags | 5 | `risk_tags` |
| Activation rules | 46 | `risk_activation_rules` |

Everything is scoped to `tenant_id = 1` and to the new `entity_model_id`, so it
cannot interfere with the existing Card Transaction (id 1) or Merchant (id 251)
models. The one exception is reference **lists and dictionaries**, which
`MwRisk.ReferenceDataCache` keys by tenant, not by model — hence the mandatory
`Shukria ` prefix on all of their names.

SR26 and SR27 (volume-drop detection) are deliberately **not configured**, and
SR08 is seeded **inactive** — see §7 for both.

---

## 2. Hold vs Alert

The CSV's *Execution Status* column has two values, and they map to two
different rule shapes:

| CSV status | `decision` | Case status | `response_elevation` | Case key |
|---|---|---|---|---|
| **Hold** | `review` — escalates the scored decision | `Hold` (ultra_high) | 8.0–10.0 | `@Payload.TxnId` — one case per held transaction |
| **Alert generation** | *none* — the rule fires and opens a case without escalating the decision | `Alert` (high) | 3.0–6.0 | `@Payload.MerchantId` for merchant-behaviour rules, `@Payload.TxnId` for transaction-level ones |

Nothing in this rule set declines. These are settlement-side holds and
scrutiny alerts, not authorisation decisions — the CSV wording ("kept on hold
… if to be put on hold or release in merchant account") is about the merchant
funding leg, which happens after the authorisation has already been answered.

Merchant-behaviour alerts key on `MerchantId` deliberately: repeat firings
elevate the single open case for that merchant instead of spawning one case per
transaction (`InfraRepo.Risk.Cases.maybe_elevate_case_status/3`).

---

## 3. Payload contract

The rules read these fields from the request body. As with every model in
mw-core, the body must nest them under a `"Payload"` key:

```json
{ "Payload": { "TxnId": "...", "MerchantId": "...", ... } }
```

| Field | Type | Notes |
|---|---|---|
| `TxnId` | string | Case key for transaction-level rules |
| `MerchantId` | string | Model entry key; merchant-scoped counters |
| `AcceptorId` | string | Terminal; terminal-scoped counters |
| `MerchantName` | string | |
| `MerchantActivationDate` | date | **Drives SR08, SR31, SR41, SR44.** ISO-8601 date or datetime. Elapsed days are derived at scoring time by the `age` op |
| `MerchantCategory` | string | **Drives SR33a/SR33b.** `CAT-A` / `CAT-B` / … |
| `CardPan` | string | Card-scoped counters + abstraction journal key |
| `CardBin` | string | |
| `CurrencyAmount` | float | **AED**. Every threshold in this config is in AED |
| `CurrencyCode` | string | |
| `TransactionType` | string | `SALE` / `REFUND` / `REVERSAL` / `CHARGEBACK` |
| `ResponseCode` | string | `"00"` = approved |
| `EntryMode` | string | `80` fallback, `90` magstripe, `01` keyed |
| `MCC` | string | |
| `IssuerCountry` | string | Drives local-vs-international |
| `BillingCountry` | string | |
| `TransactionCountry` | string | Drives SR42 |
| `MerchantCountry` | string | |
| `TransactionCity` | string | Carried for case search; see SR04/SR12 caveats |
| `TransactionDateTime` | date | Model reference date |
| `LocalHour` | integer | **0–23. Must be sent** — SR03 depends on it and the engine does no timezone arithmetic on `TransactionDateTime` |
| `CardExpiryDate` | string | Carried for case review |
| `Is3DSUsed` | boolean | Carried for case review |

### What happens if the two merchant fields are omitted

They fail in opposite directions, deliberately:

* **`MerchantActivationDate` missing** — the `age` op evaluates `false`, so
  SR08, SR31, SR41 and SR44 go **silent**. No false holds, but also no new-merchant
  or dormancy coverage. Send it on every transaction, including refunds.
* **`MerchantCategory` missing** — `MerchantCategory != "CAT-A"` is true against
  a null, so SR33a **fires** and SR33b does not. An unknown category is treated
  as non-CAT-A and therefore held at AED 100K, which is the safe side of that
  carve-out.

---

## 4. Counter semantics (read this before tuning thresholds)

`MwRisk.VelocityPipeline` writes counters **after** scoring
(`MwRisk.Verifier` → `run_pipeline` → `update_sync`). At rule-evaluation time,
every `Features.*` value therefore reflects **prior activity, excluding the
transaction in flight**.

That is why "2 or more transactions in 30 minutes" is configured as
`count >= 1`, and why cumulative-value rules compare against
`threshold - Payload.CurrencyAmount` rather than against the threshold
directly. Both forms are exact, not approximations.

A counter that has never been written returns *no* feature key at all — a
missing key makes any numeric comparison evaluate `false`. This is why:

* Rate rules are always paired with a minimum-volume gate, so a cold merchant
  cannot trip a "20% decline rate" on 1-of-1.
* SR08 (dormancy) is expressed as `NOT (count >= 1)` rather than `count == 0` —
  `== 0` against an absent key is `false`, which would silently never fire.

---

## 5. Rule-by-rule mapping

Feature keys below use the runtime form `<entity>:<horizon>:<metric>`, which is
the counter's `name` with spaces replaced by colons.

### Hold rules

| SR | Rule | Expression | Depends on |
|---|---|---|---|
| 01 | Suspicious International Transaction | `NOT tag(IssuerCountry ∈ Domestic)` ∧ `CurrencyAmount >= 10000` | — |
| 02 | Multiple Low-Value International Card Txns | international ∧ `2000 <= amt <= 5000` ∧ `AcceptorId:30m:count_intl_2k_5k >= 1` | counter |
| 03 | Abnormal Hour Transaction | `LocalHour < 7` ∧ `NOT tag(MCC ∈ Exempt MCCs)` | `LocalHour` on payload |
| 04 | Split Transaction | `card_acceptor:10m:count_tx >= 1` ∧ `card_acceptor:10m:sum_amount >= 10000 - amt` | counters |
| 05 | Duplicate Transaction | `card_acceptor_amount:10m:count_tx >= 1` | counter |
| 06 | High Velocity Transaction | `amt >= 500` ∧ `AcceptorId:2m:count_tx >= 1` | counter |
| 07 | Above Average Ticket Size | `tag(MCC ∈ AvgTicket MCCs)` ∧ `merchant:90d:count_tx >= 30` ∧ `amt >= AvgTicket90d * 4` | calculation |
| 08 | Dormant Merchant Reactivation | `NOT (merchant:30d:count_tx >= 1)` ∧ `age(MerchantActivationDate) >= 30d` | `MerchantActivationDate` — **seeded inactive, see §7** |
| 09 | High Value Transaction | `amt >= 10000` | — |
| 10 | High Frequency Chargeback Merchant | `merchant:30d:count_chargebacks >= 2` **OR** `merchant_chargeback_count_30d >= 2` | counter *or* acquirer lookup |
| 11 | High Decline Rate Merchant | `merchant:30d:count_tx >= 50` ∧ `count_declined >= count_tx * 0.2` | counters |
| 12 | Round Figure, Same Card, Multiple Merchants | `amt multiple_of 100` ∧ `CardDistinctMerchants1d >= 2` ∧ `card:1d:sum_round_amount >= 2000 - amt` | abstraction + counters |
| 13 | Round Figure Rate On Same Terminal | `merchant:90d:count_tx >= 100` ∧ `count_round_tx > count_tx * 0.25` | counters |
| 14 | Follows Fraud-Suspect Decline, Same Card | `card:1h:count_fraud_declined >= 1` | counter |
| 15 | Follows Fraud-Suspect Decline, Different Card Same Amount | `acceptor_amount:1h:count_fraud_declined >= 1` ∧ *other-card guard* | counters |
| 16 | Follows Wrong PIN Attempts | `card:1d:count_wrong_pin >= 4` | counter |
| 17 | Daily Volume Above Merchant Average | `merchant:90d:count_tx >= 90` ∧ `merchant:1d:sum_amount >= AvgDailyAmount90d * 4` | calculation |
| 18 | Multiple Transactions Same Amount | `acceptor_amount:1d:count_tx >= 5` ∧ `count >= 50000 / amt` ∧ *other-card guard* | counters |
| 19 | Merchant Self Card Usage | `dict(Merchant Own Card Pans)[MerchantId] == Payload.CardPan` | **dictionary** |
| 20 | Out-of-Profile Geographic Shift (Hold) | `merchant:1d:count_intl_card >= 5` ∧ `merchant:90d:count_tx >= 100` ∧ `count_intl_card <= count_tx * 0.05` | counters |
| 33a | AML Local > 100K, non CAT-A | local ∧ `amt > 100000` ∧ `MerchantCategory != "CAT-A"` | `MerchantCategory` |
| 34 | AML Local > 400K | local ∧ `amt > 400000` | — |
| 35 | AML International > 100K | international ∧ `amt > 100000` | — |

The `50000 / amt` form in SR18 is exact for the rule as written: every
transaction in that bucket has the same amount by construction (the counter is
keyed on `AcceptorId:CurrencyAmount`), so `count × amount >= 50000` is
equivalent to `count >= 50000 / amount`.

### Alert rules

| SR | Rule | Expression | Depends on |
|---|---|---|---|
| 21 | Excessive Refunds or Reversals | `merchant:30d:count_tx >= 50` ∧ `count_refunds > count_tx * 0.1` | counters |
| 22 | High Chargeback Rate | (`merchant:30d:count_tx >= 100` ∧ `count_chargebacks > count_tx * 0.01`) **OR** `merchant_chargeback_rate_30d > 0.01` | counter *or* acquirer lookup |
| 23 | High Fraud Rate | `merchant:30d:count_tx >= 200` ∧ `count_fraud_declined > count_tx * 0.005` | see caveat below |
| 24 | Round or Unusual Amount Patterns | `merchant:90d:count_tx >= 30` ∧ `count_round_tx > count_tx * 0.25` | counters |
| 25 | High Decline / Auth Failure Rate | `merchant:30d:count_tx >= 20` ∧ `count_declined > count_tx * 0.2` | counters |
| 28 | Out-of-Profile Geographic Shift (Alert) | `merchant:1d:count_intl_card >= 1` ∧ `merchant:90d:count_tx >= 50` ∧ `count_intl_card <= count_tx * 0.1` | counters |
| 29 | Declined High-Value Fallback | `amt > 501` ∧ `tag(EntryMode ∈ Fallback)` ∧ `ResponseCode != "00"` | — |
| 30 | Decline Transaction Monitoring | `merchant:20m:count_declined >= 10` | counter |
| 31 | New Merchant High Decline | `age(MerchantActivationDate) < 90d` ∧ `merchant:20m:count_declined >= 5` | `MerchantActivationDate` |
| 32 | AML Local > 50K | local ∧ `amt > 50000` | — |
| 33b | AML Local > 100K, CAT-A | local ∧ `amt > 100000` ∧ `MerchantCategory == "CAT-A"` | `MerchantCategory` |
| 36 | AML International > 25K | international ∧ `amt > 25000` | — |
| 37 | Same BIN 10× Same MID / 30 min | `bin_acceptor:30m:count_tx >= 10` | counter |
| 38 | Same BIN 20× Same MID / 30 min | `bin_acceptor:30m:count_tx >= 20` | counter |
| 39 | Daily Amount Limit Per Card | `card:1d:sum_amount > 25000 - amt` | counter |
| 40 | International BIN 100% in One Day | international ∧ `AcceptorId:1d:count_tx >= 10` ∧ `count_intl_card >= count_tx` | counters |
| 41 | High Value on New Merchant | `amt > 25000` ∧ `age(MerchantActivationDate) < 90d` | `MerchantActivationDate` |
| 42 | AML Suspicious International Origin | `tag(TransactionCountry ∈ IQ/EG/LY/MZ)` ∧ `amt > 5000` | — |
| 43 | High Value Manual Key Entry | `tag(EntryMode ∈ Manual)` ∧ `amt > 5000` | — |
| 44 | New Merchant International Card | `age(MerchantActivationDate) < 90d` ∧ international ∧ `amt >= 20000` | `MerchantActivationDate` |
| 45 | Expired Card Repeated Attempts | `ResponseCode == "51"` ∧ `card_acceptor:1d:count_rc51 >= 3` | counter |
| 46 | Aggregate Refunds Burst | `AcceptorId:15m:count_refunds >= 10` ∧ `sum_refund_amount > 5000` | counters |
| 47 | High Value Refund | `TransactionType == "REFUND"` ∧ `amt > 5000` | — |

---

## 6. Approximations — what does *not* match the CSV exactly

These are the places where the configured rule is deliberately narrower or
wider than the CSV wording. Each is a modelling decision, not an oversight.

| SR | CSV says | Configured as | Why |
|---|---|---|---|
| 04, 12 | "in the same city" | City is **not** in the key | Counters key on card × terminal (SR04) and card × day (SR12). A terminal is fixed to one city, so SR04 is unaffected in practice; SR12 is wider than the CSV (spans cities). `TransactionCity` is captured on the payload if you later want to narrow it. |
| 18 | "using different cards" | ">5 same-amount transactions at the terminal, **not all on one card**" | Reading confirmed with the customer. A literal "5 or more *distinct* cards" would need exact distinct counting — see §6.2. |
| 23 | "Fraud rates exceeding 0.5%" | Fraud-**suspect decline** rate | mw-core has no confirmed-fraud marking feed. This is a proxy. When a fraud feed lands, replace with a `merchant 30d count_confirmed_fraud` counter or a dictionary. |
| 37, 38 | "10 / 20 **cards** from same BIN" | 10 / 20 **transactions** from same BIN at the terminal | Distinct counting, not an other-card guard — the subtraction in §6.1 cannot express it. See §6.2. Wider (10 transactions on 3 cards also fires). |
| 20, 28 | "compare the current cardholder country mix to prior history" | Today's international count vs. the 90-day international share | A full country-mix comparison needs per-country counters; the international/domestic split captures the described behaviour (domestic → international shift). |
| 43 | POS entry mode manual (Key-IN) | `tag(EntryMode ∈ Shukria Manual Entry Modes)` | The list is seeded with `01, KEYED, MANUAL, MANUAL_KEY`. Trim it to whatever the acquirer actually sends. |

### 6.1 The other-card guard (SR15, SR18)

"A *different* card did X" is a question about the complement of your own
bucket, which a counter cannot answer directly — it returns a number, not the
set of cards behind it. It is expressible as a subtraction across two buckets:

```
wide = (terminal, amount)              →  acceptor_amount:…
mine = (terminal, amount, this card)   →  card_acceptor_amount:…
```

Every event in `mine` is also in `wide`, so `wide > mine` means at least one
event came from a different card. Exact, not an approximation.

The catch is that a counter never written returns **no feature key at all** —
`nil`, not `0` — and `1 > nil` is `false`. A plain `wide > mine` would therefore
go silent in precisely the case the rule exists for: this card has no history
and every event belongs to someone else. Both rules use a guarded form:

```
(NOT (mine >= 1))  OR  (wide > mine)
```

Verified against the persisted expressions — other-card-only fires, same-card-
only does not, mixed fires.

### 6.2 What a literal distinct count would cost

If "different cards" is ever re-read as "5 or more *distinct* cards", the
subtraction is not enough. A TTL counter is a Redis `HINCRBYFLOAT` — a running
total that forgets which values it has seen, so distinct counting is impossible
from a counter by construction, at any key.

Exact distinct counting exists in the engine (`function_type: 2` abstraction
rules, which walk a per-entity event journal — SR12 uses one). It cannot reach
this case because journals are written only for `AccountId` / `CardPan` /
`IpAddress`, `search_key` is whitelisted to those same three fields, and the
journalled event record carries neither `CardPan` nor `AcceptorId`. Supporting
it means a terminal-keyed journal, extra fields on the event record, a wider
whitelist, and an extra per-terminal journal write on every transaction.

`InfraFeatureStore.HllCounter` (HyperLogLog approximate distinct counting) is
fully implemented but has no producer on the scoring path — nothing calls
`add_hll`. It would be the cheap route to approximate answers.

---

## 7. Rules that need work outside this config

### SR08 — seeded inactive (cold start)

SR08 is the only rule in the set that fires because data is **absent**: no
merchant activity in 30 days. At go-live every counter is empty, and an empty
counter is indistinguishable from 30 days of dormancy — so SR08 would hold the
first transaction of every established merchant on day one.

Verified against the configured expression: an established merchant
(`MerchantActivationDate` 2024-01-10) with no counters evaluates **true**; the
same merchant with `merchant:30d:count_tx = 140` evaluates false.

Every other window-reading rule is paired with a minimum-volume gate and so
fails closed on cold data. SR08 cannot be gated the same way — any counter used
as the guard has the identical cold-start problem, just on a longer horizon.

**Enable it after either** 30 days of live counter accumulation, **or** a replay
of 30 days of history through `/verify`. Flip `active` on the rule; no other
change is needed. Expect hold volumes to step up when it goes on.

### SR26, SR27 — not configured

Both detect the **absence** of activity persisting over days ("volume drops by
more than 50% … and this drop persists for 3 consecutive days"). An activation
rule only runs when a transaction arrives, so a rule about a merchant that has
gone quiet can never fire from the transaction path — it needs a scheduled job
walking the merchant book, which is a reporting concern rather than a
transaction-monitoring one.

Confirmed out of scope for the risk system, so the rules are removed along with
the two counters (`merchant 1w count_tx`, `merchant 4w count_tx`) and two
calculations (`ShukriaMerchantAvgDailyTx30d`, `ShukriaMerchantAvgWeeklyTx4w`)
that existed only to serve them. If they are ever wanted, they belong in the
reporting/BI layer against `risk_transaction_archive`.

### Chargebacks — acquirer lookup via HTTP adaptation

Chargebacks do not arrive on the authorisation stream, so
`merchant 30d count_chargebacks` only fills if chargebacks are replayed through
`/verify`. The alternative, seeded as `ShukriaAcquirerChargebackLookup`, is an
HTTP adaptation: `MwRisk.HttpAdaptationEngine` calls the acquirer per merchant
between feature hydration and rule evaluation, and merges the response into the
feature map.

```
GET https://<acquirer>/api/merchants/@merchant/chargebacks?window=30d
```

`@merchant` is substituted from the hydrated feature map (`MerchantId`).
Expected JSON, with `response_xpath` a dot path into it:

```json
{ "chargeback_count_30d": 3, "chargeback_rate_30d": 0.014, "transaction_count_30d": 214 }
```

| `response_xpath` | Feature exposed to rules | Type |
|---|---|---|
| `chargeback_count_30d` | `Features.merchant_chargeback_count_30d` | float |
| `chargeback_rate_30d` | `Features.merchant_chargeback_rate_30d` | float |
| `transaction_count_30d` | `Features.merchant_acquirer_tx_count_30d` | float |

SR10 and SR22 are configured as an **OR** across both sources, so whichever is
available wins and neither has to be chosen up front.

Operational settings on the seeded row: cached per merchant for 1 hour
(`cache_key_expression: "@merchant"`), 2s latency limit, 32 KB response limit,
`enable_failure_fallback: true`. That last flag matters — on any failure the
features are simply absent, the adaptation branch of SR10/SR22 evaluates false,
and scoring continues on the counter branch alone.

**Seeded `active: false` with a placeholder URL.** To activate: set
`url_template` and, if needed, `auth_type` + credentials in the admin UI, then
flip `active`. The per-transaction callout is worth watching once live — it sits
in the scoring path, so the latency limit is doing real work.

### The one remaining dictionary — SR19

`Shukria Merchant Own Card Pans` (`MerchantId` → the merchant's own PAN) stays a
dictionary rather than moving onto the payload, unlike merchant tenure and
category. The reason is failure behaviour: a missing dictionary key compares
`false`, but two *absent* payload fields would compare **equal to each other**,
making SR19 fire on every transaction that omits them. An equality check between
two optional payload fields is not safe; a dictionary lookup is.

Seeded with one illustrative row; populate it from the merchant onboarding
record. SR19 does not fire until it is populated.

---

## 8. Engine changes this config required

Four additive changes to shared runtime code. None alters behaviour for the
existing Card Transaction or Merchant models.

| Change | File | Why |
|---|---|---|
| Horizons `1m`, `2m`, `10m`, `30m`; plus the missing `20d` / `26w` TTLs | `MwRisk.TtlCounterCache`, `InfraFeatureStore.TtlCounter` | The CSV needs 2-, 10-, 15-, 20- and 30-minute windows; only `5m`/`15m`/`20m`/`1h` existed. A horizon absent from `@horizon_ttls` silently fell back to a 1-hour expiry. |
| Composite entities `card_acceptor`, `bin_acceptor`, `acceptor_amount`, `card_acceptor_amount` | `MwRisk.VelocityPipeline`, `MwRisk.FeatureHydrator` | Terminal-scoped velocity (SR02, SR04, SR05, SR15, SR18, SR37, SR38, SR45) needs card × terminal, BIN × terminal, amount × terminal and card × terminal × amount keys. Composite grouping dimensions are code, not configuration — the TTL Counter screen can pick horizon, metric and condition, but not a new grouping key. Added to both the write and read side so they stay symmetric. |
| `multiple_of` comparator | `MwRisk.RuleExpression` | Round-figure detection (SR12, SR13, SR24). Amounts arrive as floats, so neither the numeric comparators nor `ends_with "00"` can express divisibility. |
| `dict` nodes resolve their `value` operand | `MwRisk.RuleExpression` | SR19 compares a dictionary entry against `Payload.CardPan`. `cond` nodes already resolved field references on the value side; `dict` did not. Plain literals such as `"CAT-A"` are unaffected. |
| `age` op — elapsed time since a date on the payload | `MwRisk.RuleExpression` | SR08, SR31, SR41, SR44 need merchant tenure in days. Deriving it from `MerchantActivationDate` at scoring time avoids a pre-computed day count that goes stale in cache. Absent or unparseable dates evaluate `false`, the same as a missing dictionary key. |
| `multiple_of` added to the visual rule builder's comparator list | `GatewayWebWeb.Components.RuleBuilder` | Without it, opening a round-figure rule in the visual builder showed a comparator `<select>` with no matching option, and saving could silently rewrite `multiple_of` to `==`. |

```json
{"op": "age", "field": "MerchantActivationDate", "unit": "days", "cmp": "<", "value": 90}
```

`unit` accepts `days` / `hours` / `minutes` / `seconds`; `cmp` is any standard
comparator. The field is read bare (no `Payload.` prefix), like `dict` and `tag`.

**Editing caveat:** the visual rule builder renders `cond`, `and`, `or` and
`not` nodes only — `dict`, `tag`, `sanction` and `age` nodes are invisible in it
(a pre-existing catch-all that returns empty markup). Rules using them must be
edited through the raw JSON view. This affects SR01, SR02, SR03, SR07, SR08,
SR19, SR29, SR31, SR32–SR36, SR40, SR41–SR44 and SR47.

---

## 9. After seeding

Caches are populated at boot and refreshed by the admin UI's save broadcasts.
A raw seed run writes rows without broadcasting, so restart the application (or
trigger the reloads) before the model will score:

* `MwRisk.TtlCounterCache` — counters
* `MwRisk.RuleCache` / `MwRisk.AbstractionRuleCache` / `MwRisk.AbstractionCalculationCache` — rules
* `MwRisk.ReferenceDataCache` — lists and dictionaries

Then verify with a transaction that should trip a known rule, e.g. SR09
(`CurrencyAmount >= 10000`), and confirm the response's `Activation` section
names it and `CreateCase` shows a case opened in the `Hold` status.
