# Activation Rules

Final pipeline stage. Both systems agree on the broad shape (last stage, can trigger
cases/notifications/response changes); they diverge sharply on **how multiple fired rules combine
into one outcome**.

## Jube

Source: `Jube.Engine/.../ActivationRules/ActivationRuleResponseElevationExtensions.cs:23-165`,
`ActivationRuleCountsAndArchiveHighWatermarkExtensions.cs:20-49`,
`SyncEntityAnalysisModelActivationRulesExtensions.cs:30-170`.

- **Decision combination is a numeric high-water-mark, not a categorical ladder.** Every fired
  rule carries a `ResponseElevation` (a number). The evaluator keeps the **highest** value seen
  across all fired rules (`responseElevationHighWaterMark`); lower-elevation rules that fire later
  are discarded (`if (responseElevationHighWaterMark > evaluateActivationRule.ResponseElevation)
  { return; }`).
- The winning value is then **clamped** against two ceilings: the model-level
  `Counters.MaxResponseElevation` and the gateway-level `ResponseElevationLimit` set by whichever
  gateway rule matched (see [03](03-GATEWAY-RULES.md)).
- There's no separate "decline/review/approve" enum anywhere in this path — the **number itself**
  is the entire signal; consumers downstream of Jube interpret the scale.
- "Prevailing rule" tracking (`PrevailingEntityAnalysisModelActivationRuleId`) records the **last
  visible rule that fired** in iteration order — not "first match," not "highest elevation match"
  — a separate, distinct bookkeeping field from the elevation high-water-mark.
- **Approval workflow is enforced at *load* time, not evaluation time.** `ReviewStatusId` is
  mapped to a boolean (`SyncEntityAnalysisModelActivationRulesExtensions.cs:89-167`): only status
  `4` → `approval = true`; statuses `0`–`3` → `false`. `if (!active || !approval) { continue; }`
  — unapproved rules are **never added to the in-memory collection the engine iterates**, so
  there's no runtime check to forget or bypass.

## mw-core

Source: `apps/mw_risk/lib/mw_risk/activation_engine.ex`, `apps/mw_risk/lib/mw_risk/rule_cache.ex`.

- **Decision combination is a fixed categorical ladder**: `:decline` > `:review` > `:approve`
  (`escalate_decision/2`), with fixed scores (`0.95` / `0.70` / `0.05`). Evaluation **short-circuits
  on the first rule that escalates to `:decline`**; `:review` rules accumulate (all are collected)
  even after one fires, since nothing outranks `:decline` and `:review` doesn't suppress further
  `:review` collection.
- Three rule "shapes" are supported per row, checked in this priority order
  (`ActivationEngine.rule_fires?/2`): (1) a non-empty `rule_expression` (evaluated via
  `RuleExpression` — see [02](02-RULE-EXPRESSION-ENGINE.md)), (2) `rule_type: "list"` with
  `feature_key`/`list_values`, (3) `feature_key`/`operator`/`threshold_value`. **Clause (1) always
  wins if `rule_expression` is non-empty — clauses (2) and (3)'s columns become silently inert
  once any `rule_expression` is set on the same row**, even if they were also populated (found
  this engagement: a rule had both a `rule_expression` and `threshold_value`/`operator` set, and
  editing the threshold via the UI had zero effect because the expression always took precedence).
- Approval workflow: was unenforced everywhere (see
  [08-FIXED-GAPS-CHANGELOG.md](08-FIXED-GAPS-CHANGELOG.md)); now enforced at **cache-load time**
  via a `review_status == "approved"` filter in `RuleCache.load_from_db/1` — functionally similar
  outcome to Jube's load-time gate, but checked on every cache refresh (every save/60s TTL) rather
  than once at a sync event.
- Side effects per fired rule (case workflow, response elevation, TTL counter increment,
  notification) are configured as boolean+detail-field pairs directly on the activation rule row
  (`enable_case_workflow`, `enable_response_elevation`, `enable_ttl_counter`,
  `enable_notification`, plus their associated detail fields) — broadly mirrors Jube's per-rule
  side-effect configuration, though Jube's response-elevation side effect is the *primary*
  decision mechanism (the number itself), while mw-core's is one optional side effect layered on
  top of the separate decline/review/approve ladder.

## Gap table

| Aspect | Jube | mw-core | Tag |
|---|---|---|---|
| Decision combination model | Numeric high-water-mark (highest `ResponseElevation` wins, clamped by ceilings) | Categorical ladder (`decline > review > approve`) with fixed scores | 🟡 Intentional divergence — fundamentally different decision models, not a bug in either |
| Multiple fired rules | All evaluated; highest elevation wins, others discarded for the *score*, but "prevailing rule" tracks the last visible one separately | All `:review` rules accumulate in `fired_rules`; only the *first* `:decline` short-circuits | 🟡 Intentional divergence |
| `rule_expression` vs. threshold columns precedence | N/A (Jube has one expression mechanism, not parallel threshold/expression paths) | `rule_expression`, if set, always wins over `feature_key`/`operator`/`threshold_value` on the same row — the precedence itself is unchanged, but the detail panel now shows a warning banner naming the inert fields and pointing at "Delete expression" | 🟡 O7 closed — UI surfacing added this engagement, see [08](08-FIXED-GAPS-CHANGELOG.md); precedence still not structurally prevented at the data layer |
| Approval workflow | Enforced at load time, only status `4` (Approved) loads | Was unenforced entirely; now enforced via query filter at every cache reload | 🔴 Fixed this engagement |
| Cross-model isolation | Structural | Was broken (tenant-only scoping); fixed this engagement | 🔴 Fixed — see [07](07-MULTI-MODEL-SCOPING.md) |

## Open items

- **O7 closed.** The detail panel now shows an explicit warning banner (`legacy_threshold_set?/1`
  in `ActivationRulesLive`) whenever a rule has both `rule_expression` and a legacy
  `feature_key`/`operator`/`threshold_value`, naming the inert fields and pointing at "Delete
  expression." The underlying precedence itself is unchanged — a structural guard (changeset
  validation preventing both being set) was judged too risky to add this engagement, since it
  could reject existing rules that legitimately have both populated.
- mw-core has no equivalent of Jube's separate "prevailing rule" bookkeeping (last visible fired
  rule, distinct from the decision-driving rule) — `fired_rules` is the closest analog but doesn't
  distinguish "decision-driving" from "visible but not decision-driving."
