# TTL Counters

Sliding-window counters (count/sum over a time horizon, grouped by some field). This is the area
with the most extensive findings — a real, structural design difference confirmed directly against
Jube's source, plus several mw-core-side bugs that compounded on top of it.

## Jube (confirmed directly)

Source: `Jube.Cache/Redis/CacheTtlCounterRepository.cs:78-79`,
`CacheTtlCounterEntryRepository.cs:112-113`,
`ActivationRuleTtlCounterExtensions.cs:88-141`, `Poco.cs:3401-3442`.

- **`TtlCounterDataName` is the grouping field name itself** — not a label, not metadata. If you
  set it to `"IP"`, the engine groups by whatever the `IP` field's value is on each transaction.
  Any payload field can be a grouping key; there's no fixed/hardcoded list of supported
  dimensions.
- **`TtlCounterDataValue` is the field to sum** when `EnableSum` is true. The write path
  literally does `incrementValue =
  context.EntityAnalysisModelInstanceEntryPayload.Payload[foundTtlCounter.TtlCounterDataValue]`.
- **Storage key includes the counter's own GUID**:
  `TtlCounter:{tenant}:{model}:{counterGuid}:{dataName}` — a Redis hash whose **field** is the
  resolved grouping value (e.g. a specific `AccountId`) and whose **value** is that counter's
  single number. Two different counter definitions, even if they happen to group by the same
  field, **never share a Redis key** — the GUID in the key guarantees isolation.
- A second, parallel cache (`TtlCounterEntry:{tenant}:{model}:{counterGuid}:{dataName}:{dataValue}`)
  stores historical entries keyed by timestamp, for windowed range queries.

## mw-core (as found, then fixed)

Source: `apps/mw_risk/lib/mw_risk/ttl_counter_cache.ex`, `apps/mw_risk/lib/mw_risk/velocity_pipeline.ex`,
`apps/mw_risk/lib/mw_risk/feature_hydrator.ex`, `apps/infra_repo/lib/infra_repo/schemas/risk_ttl_counter.ex`.

### As originally found

- `VelocityPipeline` wrote a **small hardcoded set of metric names** (`count_tx`, `sum_eur`,
  `count_success`, etc.) into a **fixed list of ~10 hardcoded entity dimensions**
  (`card`/`merchant`/`ip_device`/etc.), completely ignoring whatever was actually configured in
  `risk_ttl_counters` — of 70 configured rows on one model, only 34 happened to produce real data
  purely because their *name* coincidentally matched a hardcoded metric; the other 36
  (`count_cross_border_tx`, `count_success_tx_with_high_risk_tx_country`, etc.) were configured
  through the UI but functionally inert.
- `TtlCounterCache.load_tenant/1` grouped **all** of a model's counter rows under one ETS bucket
  keyed by the *model's* `entity_type` field — so only rows whose own name happened to start with
  that exact string were ever reachable by `get_horizons/get_specs`; everything else silently fell
  back to the hardcoded entity list above.
- No column existed to express **when** a configured counter should increment — `data_value`
  (see below) was `NULL` on every semantically-named legacy row; there was no way to encode "only
  count this if it's cross-border" anywhere in the schema.
- **`data_name` and `data_value` were swapped relative to Jube.** The UI's "Sum field" control
  wrote to `data_value`; `VelocityPipeline` (and the moduledoc) read `data_name` as the sum field.
  Confirmed against Jube's source: `data_value` is correct (matches `TtlCounterDataValue`); the UI
  was right, the engine code was wrong.
- A horizon-string mapping table (`@horizon_map`, translating `{ttl_interval, ttl_value}` like
  `{"hours", 1}` into a canonical string like `"1h"`) was missing several common combinations
  (notably `{"hours", 24}`), silently falling back to the raw DB string (`"hours"`) instead —
  producing duplicate, inconsistently-named Redis keys for the same logical window.

### What was fixed

1. **Cache grouping** — bucket each counter row by its *own* entity prefix (parsed from
   `"<entity> <horizon> <metric>"`), not the model's `entity_type`.
2. **`condition_expression` column added** — migration + schema + UI (reusing the existing
   `RuleBuilder` component already used for gateway/activation rule expressions) — wired into
   `VelocityPipeline` so a counter can now express a filter condition, finally giving the 36
   previously-inert rows a path to actually work.
3. **`VelocityPipeline` made config-driven** — reads `TtlCounterCache.get_specs/3` instead of
   writing a hardcoded metric list; the metric's Redis field name is derived from the row's own
   `name`.
4. **`data_value` confirmed and fixed as the sum field** (matching Jube), with `data_name`
   correctly understood as something separate.
5. **Jube-style arbitrary grouping field, additively**: rows whose `name` doesn't follow the
   `"<entity> <horizon> <metric>"` convention (e.g. a counter literally named `"IP"`, exactly
   matching real Jube usage) now bucket under their `data_name` directly, and both
   `VelocityPipeline` (write) and `FeatureHydrator` (read) resolve that field's value straight
   from the payload — a second, parallel code path alongside the legacy hardcoded-entity path, with
   zero regression to the 70 existing legacy-convention rows. This is the closest mw-core gets to
   Jube's "any field can be a grouping key" model, **without** adopting Jube's per-counter-GUID
   storage schema (see below).
6. **Horizon-map gaps filled** (`{"hours", 24} → "1d"`, plus several `{"minutes", N}` entries).
7. **Cross-model isolation** added — see [07-MULTI-MODEL-SCOPING.md](07-MULTI-MODEL-SCOPING.md).

### Storage schema — kept different from Jube, by deliberate decision

mw-core's Redis key remains `risk:{tenant}:counter:{entity}:{value}:{horizon}`, a **shared hash
across multiple metrics** for the same entity+value+horizon — not Jube's one-key-per-counter-GUID
schema. This means mw-core still needs the "derive metric name from the row's own name" logic
Jube doesn't need (since Jube's grouping value, not a metric name, is the hash field).

This was evaluated explicitly mid-engagement and **kept as-is**: a full schema rewrite would touch
`FeatureHydrator`, `AbstractionEngine`'s `add_ratios`, and every gateway Type-A feature already
built on the current schema, plus require a migration plan for already-accumulated Redis data —
confirmed *not* required for business correctness (the computed values are correct either way;
the difference is purely about Redis-level operational convenience, e.g. how cheaply you could
bulk-list "every account tracked by counter X"). Deferred as a separate, larger effort if that
specific operational capability is ever needed.

## Gap table

| Aspect | Jube | mw-core | Tag |
|---|---|---|---|
| Grouping field | Arbitrary, per-counter (`TtlCounterDataName`) | Hybrid: fixed list of ~10 hardcoded dimensions (legacy convention) **plus** arbitrary `data_name`-based grouping for non-conforming rows (added this engagement) | 🟡 Intentional partial parity |
| Sum-target field | `TtlCounterDataValue` | `data_value` — was reading the wrong column (`data_name`); fixed | 🔴 Fixed this engagement |
| Storage key schema | One Redis key per counter GUID, grouping value as hash field | Shared hash per entity+value+horizon, metric name as hash field | 🟡 Intentional divergence — confirmed sufficient for correctness, deferred for operational parity |
| Conditional counting | N/A — counters in Jube don't appear to need a separate condition (the grouping/sum mechanism itself is the configuration) | Was impossible to express at all; `condition_expression` column added this engagement | 🔴 Fixed this engagement (net-new capability, not strict parity) |
| Horizon string derivation | N/A (Jube stores interval natively, no string-mapping layer) | `{ttl_interval, ttl_value} → horizon string` mapping table had gaps, silently falling back to raw DB strings | 🔴 Fixed this engagement |
| Cross-model isolation | Structural (GUID in storage key) | Was broken twice over (cache grouping + a hardcoded-fallback read path bypassing the model scope entirely); both fixed | 🔴 Fixed — see [07](07-MULTI-MODEL-SCOPING.md) |

## Open items

- The shared-hash storage schema is the one confirmed, deliberate, **not-yet-closed** structural
  gap versus Jube. Revisit only if Redis-level bulk-listing/operational parity becomes a real
  requirement — not for correctness.
- **O5 closed.** `risk_ttl_counters` got the same `review_status` treatment as gateway/activation/
  abstraction rules: a migration adding the column family (default `"approved"`, matching the
  others), the schema fields, and a filter in `Risks.list_active_ttl_counters/2`. As with the other
  three rule types, there's still no UI workflow to actually change a row's status away from the
  default — only the enforcement-on-read side was in scope.
