# Multi-Model Scoping and Isolation

How each system prevents model A's rules/state from leaking into model B's scoring. This is a
**structural risk-profile difference**, not a single bug — Jube's design makes the class of bug
mw-core kept finding nearly impossible to write by accident; mw-core's design makes it easy to
reintroduce in any new feature unless model-scoping is deliberately re-applied every time.

## Jube (confirmed directly)

Source: `SyncEntityAnalysisModelGatewayRulesExtensions.cs:33`,
`EntityAnalysisModel/Models/Collections.cs:26-27`, `EntityAnalysisModelInvoke.cs:33`.

- All active models live in `Dictionary<int, EntityAnalysisModel> ActiveEntityAnalysisModels`,
  keyed by model ID.
- **Each model instance owns its own `Collections` object**
  (`Collections.ModelGatewayRules`, `Collections.ModelActivationRules`, and by extension its own
  abstraction/TTL-counter configuration) — there is no shared, tenant-wide collection that
  multiple models read from.
- The top-level entry point, `EntityAnalysisModelInvoke.InvokeAsync(EntityAnalysisModel
  entityAnalysisModel, ...)`, receives **one specific model instance** as a parameter, and every
  downstream call operates against `context.EntityAnalysisModel` bound to that single instance for
  the transaction's entire lifecycle.
- **Result: cross-model leakage is structurally close to impossible** — there's no code path by
  which model B's rule could even be reached while scoring model A, because they live in different
  dictionary entries with separate collection objects. This isn't enforced by a filter that could
  be forgotten; it's enforced by the shape of the data structure itself.

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

mw-core's caches are organized by **tenant**, not by **(tenant, model)** — model-scoping had to be
explicitly added as a filter/key-component to each cache independently, and was found missing in
three separate subsystems over the course of this engagement, each discovered the same way: real
data from one model showing up in another model's `/verify` response.

### 1. Rule caches (gateway/activation/abstraction rules)

- `RuleCache.load_from_db/1` (activation), `GatewayRuleEngine.load_rules/1` (gateway), and the
  abstraction rule loader all originally queried `where: tenant_id == ^tid and active == true` —
  **no `entity_model_id` filter at all.** Every active rule for the tenant was evaluated against
  every model's `/verify` call.
- Fixed by adding `entity_model_id == ^model_id` to all three queries
  (`gateway_rule_engine.ex:72`, `rule_cache.ex:119`, `risks.ex:570`).

### 2. TTL Counter cache

- `TtlCounterCache.load_tenant/1` grouped ETS entries by `{tenant_id, entity}` — model-agnostic.
  Even after the cache itself was made model-aware, `FeatureHydrator`'s **hardcoded
  `@fallback_horizons` map** was a second, independent leak vector: when a model had no configured
  counter for an entity, it fell back to a generic horizon list and read from
  `risk:{tenant}:counter:{entity}:{value}:{horizon}` — a Redis key with **no model component at
  all** — silently returning whatever a *different* model had already written for the same
  entity+value (e.g. the same IP address tested against two different models).
- Fixed by: (a) re-keying the ETS cache to `{tenant_id, model_id, entity}`, with `get_horizons/3`,
  `get_specs/3`, `get_entity_types/2` taking `model_id`; (b) **removing the fallback-horizon
  mechanism entirely** — no configured counter for a model+entity now means "read nothing,"
  matching the (already correctly model-scoped) write side, rather than silently reading another
  model's data.

### 3. Abstraction velocity journal

- `record_abstraction_events/4` wrote to journal entities literally named `"abs_account"`,
  `"abs_card"`, `"abs_ip"` — again, no model component. Two models scoring the same `AccountId`
  shared one Redis sorted-set key (`risk:{tenant}:journal:abs_account:{account_id}`), so
  `Volume1DayUSDForAccountId`-style rules in model A would silently sum model B's transaction
  history for the same account.
- Fixed by suffixing the entity name with `model_id` (`abs_entity/2`, e.g. `"abs_account_257"`),
  applied identically on both the write side (`VelocityPipeline`) and read side
  (`AbstractionEngine.evaluate_rule/4`) — the two must produce matching entity names or the read
  side would simply find nothing.

## Why this kept happening

Every mw-core cache/journal in this system was originally built **tenant-first**, with model
scoping bolted on later (or never). Each subsystem independently had to learn the same lesson:
"tenant_id" alone is not enough, because a tenant can have many models, and nothing about the
storage key shape prevents two models' data from colliding unless `model_id` (or an equivalent
model-derived component) is explicitly part of every cache key and every Redis key. Jube never has
this problem because the *language* of "which model" is baked into the top-level data structure
everything else hangs off; mw-core's caches default to "everything for this tenant" unless someone
remembers to narrow it.

## Gap table

| Subsystem | Jube | mw-core (as found) | mw-core (after fixes) | Tag |
|---|---|---|---|---|
| Gateway rules | Structural (per-model `Collections`) | Tenant-only query filter | `entity_model_id` added to query | 🔴 Fixed |
| Activation rules | Structural | Tenant-only query filter | `entity_model_id` added to query | 🔴 Fixed |
| Abstraction rules | Structural | Tenant-only query filter | `entity_model_id` added to query | 🔴 Fixed |
| TTL counter cache | Structural (counter GUID in storage key) | Tenant-only ETS key + a separate hardcoded-fallback leak vector | 3-element ETS key + fallback removed entirely | 🔴 Fixed |
| Abstraction velocity journal | Structural | Tenant-only Redis key (no model component) | `model_id`-suffixed entity names, write+read sides matched | 🔴 Fixed |

## Open items

- **O6 closed.** Added `apps/mw_risk/test/mw_risk/multi_model_scoping_test.exs` — seeds two
  distinct models in the same tenant and asserts `GatewayRuleEngine.check/1`,
  `Risks.list_active_abstraction_rules/1`, `RuleCache.get_rules/2`, and
  `TtlCounterCache.get_specs/3` all never cross model boundaries (plus a `review_status` exclusion
  check for each). This is a regression guard, not a structural one — it would catch a
  *reintroduction* of this exact bug in these four call paths, not prevent a brand-new subsystem
  from omitting the scoping in the first place. `RuleCache`/`TtlCounterCache` initially couldn't be
  tested at all — `mix test` scoped to just `mw_risk` crashed at boot, since
  `MwRisk.TtlCounterCache.init/1` subscribes to `MwCore.PubSub`, but that registry is owned by
  `infra_cache`'s own supervisor, which `mw_risk` never declared as a dependency (it only worked in
  production because `infra_cache` happened to be started as a sibling app in the full umbrella
  boot). Fixed by adding `{:infra_cache, in_umbrella: true}` to `mw_risk`'s `mix.exs` — a real
  undeclared-dependency bug, not just a test workaround.
- **Still open**: no structural guarantee that a *future* cache/feature will remember to
  model-scope itself in the first place. A shared convention or helper (e.g. a `ModelScopedCache`
  behaviour, or a lint/test asserting every `risk_*` table query filters on `entity_model_id` when
  one exists) would close this fully — not attempted this engagement.
- Not independently audited this engagement: whether any *other* tenant-keyed-only cache exists in
  the codebase beyond the three found here (rule caches, TTL counters, abstraction journal) —
  these three were found because they were directly exercised during this engagement's testing,
  not from an exhaustive audit of every cache in the system.
