Skip to main content

cairn_mod/policy/
evaluator.rs

1//! Pure-function policy evaluator (§F22, #72).
2//!
3//! Translates a strike-state transition (the recompute pre and
4//! post the latest action) plus the operator's
5//! [`PolicyAutomationPolicy`] (#71) into the rule that should fire
6//! — or `None` if no rule matches. Mirrors the v1.4 strike
7//! calculator (#49), v1.4 decay calculator (#50), and v1.5 label
8//! emission resolver (#59) shape: no I/O, no async, no DB. The
9//! recorder (#73) loads state, calls into here, and acts on the
10//! returned rule (insert a `subject_actions` row for `auto`,
11//! insert a `pending_policy_actions` row for `flag`).
12//!
13//! # Algorithm
14//!
15//! [`resolve_firing_rule`]:
16//!
17//! 1. `policy.enabled = false` → `None`. Engine fully off.
18//! 2. Subject is takendown (any unrevoked `Takedown` in history)
19//!    → `None`. Takedown is terminal; no further policy fires.
20//! 3. Iterate [`PolicyAutomationPolicy::rules_in_severity_order`]
21//!    (#71) — takedown first, then indef > temp > warning > note,
22//!    ties broken by higher threshold first. Pick the first rule
23//!    where:
24//!    - [`rule_matches_crossing`] is `true` (the precipitating
25//!      action *crossed* the threshold), and
26//!    - [`rule_already_fired_for_window`] is `false` (no
27//!      unrevoked firing or unresolved pending exists for this
28//!      `(subject, rule)` pair).
29//! 4. Return `Some(rule)` for the first matching rule, or `None`
30//!    if no rule matches.
31//!
32//! Single rule fires per recordAction. The "first matching rule
33//! in severity order wins" semantic avoids double jeopardy (one
34//! threshold-cross producing multiple auto-actions).
35//!
36//! # Crossing semantic
37//!
38//! [`rule_matches_crossing`] is *strict*: pre-action count must
39//! be strictly less than the threshold, post-action count must be
40//! at-or-above. An action that bumps an already-above-threshold
41//! subject further does NOT count as a crossing — the threshold
42//! was crossed by some earlier action, and that crossing already
43//! fired (or didn't, if it was suppressed by idempotency).
44//!
45//! Edge case: pre-action count exactly AT threshold. Not a
46//! crossing (`pre < threshold` is false). The first action that
47//! pushes the count strictly above threshold is the crossing
48//! event. This boundary matches the strike calculator's
49//! "good-standing-at-or-below-threshold" convention from v1.4
50//! #49.
51//!
52//! # Idempotency: conservative v1.6 stance
53//!
54//! [`rule_already_fired_for_window`] returns `true` if EITHER:
55//! - the subject has an unrevoked `subject_actions` row whose
56//!   `triggered_by_policy_rule` matches the rule's name, OR
57//! - the subject has an unresolved `pending_policy_actions` row
58//!   whose `triggered_by_policy_rule` matches.
59//!
60//! v1.6 ships the *conservative* version: a rule fires once per
61//! subject, ever, until that firing is explicitly revoked
62//! (subject_actions side) or dismissed (pending side). After
63//! revocation/dismissal, the next threshold-crossing fires the
64//! rule again.
65//!
66//! The kickoff design's "decay-then-recross fires the rule
67//! again" semantic — automated re-firing when the subject's
68//! strike count drops below threshold and rises back without
69//! operator intervention — is more permissive but harder to
70//! implement correctly. The conservative version is
71//! operationally safer (operators won't get surprised by
72//! automated re-firing) and trivially correct. v1.7+ may add the
73//! permissive variant if real demand surfaces.
74//!
75//! Confirmed pendings are intentionally not double-counted: when
76//! a pending action is confirmed, the resulting `subject_actions`
77//! row carries `triggered_by_policy_rule` (#74's confirm flow),
78//! so the subject-side check picks it up. Pending rows with
79//! `resolution = 'confirmed'` are skipped to avoid counting the
80//! same firing twice.
81
82use std::time::SystemTime;
83
84use crate::moderation::decay::StrikeState;
85use crate::moderation::types::ActionType;
86
87use super::automation::{PolicyAutomationPolicy, PolicyRule};
88
89/// Subject-action projection for the policy evaluator. Distinct
90/// from [`crate::moderation::types::ActionRecord`] (the v1.4 #50
91/// calculator-input projection) because this projection carries
92/// `triggered_by_policy_rule`, which the evaluator needs for
93/// idempotency detection but the strike + decay calculators
94/// don't. Same naming convention as v1.5 #59's
95/// [`crate::labels::emission::ActionForEmission`].
96///
97/// Recorder (#73) builds this projection from each
98/// `subject_actions` row when loading subject history at
99/// recordAction time.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ActionForPolicyEval {
102    /// Wall-clock at which the action took effect. Used for
103    /// chronological ordering when scanning history.
104    pub effective_at: SystemTime,
105    /// Action type — needed to detect takedown (terminal state)
106    /// during evaluation.
107    pub action_type: ActionType,
108    /// `Some` iff the action was revoked. The idempotency check
109    /// treats revoked policy-firings as "window open" — re-fire
110    /// allowed after revocation.
111    pub revoked_at: Option<SystemTime>,
112    /// `Some(rule_name)` iff the action was produced by the
113    /// policy engine. `None` for moderator-recorded actions.
114    /// Idempotency detection scans for matches against
115    /// `rule.name`.
116    pub triggered_by_policy_rule: Option<String>,
117}
118
119/// Pending-action projection for the policy evaluator. Mirrors
120/// [`ActionForPolicyEval`]'s narrow shape but for
121/// `pending_policy_actions` rows (#70's schema).
122///
123/// Recorder (#73) loads this from `pending_policy_actions`
124/// alongside subject_actions when evaluating.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct PendingActionForPolicyEval {
127    /// Rule name that produced the pending row. Idempotency
128    /// detection matches against [`PolicyRule::name`].
129    pub triggered_by_policy_rule: String,
130    /// `None` while pending; `Some` once confirmed or dismissed.
131    /// Confirmed rows are skipped by the idempotency check —
132    /// their corresponding `subject_actions` row (linked via
133    /// `confirmed_action_id` from #74's confirm flow) carries
134    /// `triggered_by_policy_rule` and is picked up by the
135    /// subject-side scan. Dismissed rows open the window so the
136    /// rule can re-fire on a future crossing.
137    pub resolution: Option<PendingResolution>,
138}
139
140/// Resolution state of a pending policy action. Matches the
141/// `pending_policy_actions.resolution` column's CHECK values
142/// (#70).
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum PendingResolution {
145    /// Moderator confirmed; a real `subject_actions` row was
146    /// created and linked via `confirmed_action_id`.
147    Confirmed,
148    /// Moderator dismissed; no `subject_actions` row created. The
149    /// pending row stays as forensic record.
150    Dismissed,
151}
152
153/// Top-level evaluator entry point. Returns the rule that should
154/// fire as a result of the just-recorded action, or `None` if no
155/// rule matches.
156///
157/// See module docs for the full algorithm and idempotency stance.
158pub fn resolve_firing_rule<'a>(
159    state_before: &StrikeState,
160    state_after: &StrikeState,
161    subject_history: &[ActionForPolicyEval],
162    pending_actions: &[PendingActionForPolicyEval],
163    policy: &'a PolicyAutomationPolicy,
164) -> Option<&'a PolicyRule> {
165    if !policy.enabled {
166        return None;
167    }
168    if subject_is_takendown(subject_history) {
169        return None;
170    }
171
172    for rule in policy.rules_in_severity_order() {
173        if !rule_matches_crossing(rule, state_before, state_after) {
174            continue;
175        }
176        if rule_already_fired_for_window(rule, subject_history, pending_actions) {
177            continue;
178        }
179        return Some(rule);
180    }
181    None
182}
183
184/// Whether the precipitating action *crossed* the rule's
185/// threshold. Strict: pre-action count must be strictly less
186/// than the threshold, post-action count must be at-or-above.
187/// See module docs for the boundary edge cases.
188pub fn rule_matches_crossing(
189    rule: &PolicyRule,
190    state_before: &StrikeState,
191    state_after: &StrikeState,
192) -> bool {
193    let before = i64::from(state_before.current_count);
194    let after = i64::from(state_after.current_count);
195    before < rule.threshold_strikes && after >= rule.threshold_strikes
196}
197
198/// Whether the rule has already fired against this subject
199/// without being explicitly resolved (revoked or dismissed). See
200/// module docs for the conservative v1.6 stance.
201pub fn rule_already_fired_for_window(
202    rule: &PolicyRule,
203    subject_history: &[ActionForPolicyEval],
204    pending_actions: &[PendingActionForPolicyEval],
205) -> bool {
206    let any_subject_fire = subject_history.iter().any(|a| {
207        a.triggered_by_policy_rule.as_deref() == Some(rule.name.as_str()) && a.revoked_at.is_none()
208    });
209    let any_pending_fire = pending_actions
210        .iter()
211        .any(|p| p.triggered_by_policy_rule == rule.name && p.resolution.is_none());
212    any_subject_fire || any_pending_fire
213}
214
215/// Whether the subject has a non-revoked `Takedown` action in
216/// history. Takedown is terminal: no further policy rules fire
217/// against a takendown subject. Computed inline rather than
218/// derived from [`StrikeState`] (which has no takendown flag in
219/// v1.4/v1.5/v1.6) — the history scan is bounded by the same
220/// O(n) the rest of the evaluator already pays.
221fn subject_is_takendown(history: &[ActionForPolicyEval]) -> bool {
222    history
223        .iter()
224        .any(|a| matches!(a.action_type, ActionType::Takedown) && a.revoked_at.is_none())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::policy::automation::{PolicyAutomationPolicy, PolicyMode, PolicyRule};
231    use std::collections::BTreeMap;
232    use std::time::{Duration, UNIX_EPOCH};
233
234    // ---------- fixture builders ----------
235
236    fn t0() -> SystemTime {
237        UNIX_EPOCH + Duration::from_secs(2_000_000_000)
238    }
239
240    /// Build a StrikeState with just the `current_count` field
241    /// the evaluator reads. Other fields default-initialized;
242    /// the evaluator doesn't consult them, so values don't
243    /// matter.
244    fn state(count: u32) -> StrikeState {
245        StrikeState {
246            current_count: count,
247            raw_total: count,
248            revoked_count: 0,
249            decayed_count: 0,
250            active_suspension: None,
251            good_standing: count == 0,
252        }
253    }
254
255    fn rule(name: &str, threshold: i64, action_type: ActionType, mode: PolicyMode) -> PolicyRule {
256        PolicyRule {
257            name: name.to_string(),
258            threshold_strikes: threshold,
259            action_type,
260            mode,
261            duration: if matches!(action_type, ActionType::TempSuspension) {
262                Some(Duration::from_secs(86_400))
263            } else {
264                None
265            },
266            reason_codes: vec!["policy-threshold".to_string()],
267        }
268    }
269
270    fn policy_with(rules: Vec<PolicyRule>) -> PolicyAutomationPolicy {
271        let mut map = BTreeMap::new();
272        for r in rules {
273            map.insert(r.name.clone(), r);
274        }
275        PolicyAutomationPolicy {
276            enabled: true,
277            rules: map,
278        }
279    }
280
281    fn unrevoked_action(action_type: ActionType, rule_name: Option<&str>) -> ActionForPolicyEval {
282        ActionForPolicyEval {
283            effective_at: t0(),
284            action_type,
285            revoked_at: None,
286            triggered_by_policy_rule: rule_name.map(str::to_string),
287        }
288    }
289
290    fn revoked_action(action_type: ActionType, rule_name: Option<&str>) -> ActionForPolicyEval {
291        ActionForPolicyEval {
292            effective_at: t0(),
293            action_type,
294            revoked_at: Some(t0() + Duration::from_secs(60)),
295            triggered_by_policy_rule: rule_name.map(str::to_string),
296        }
297    }
298
299    fn pending(
300        rule_name: &str,
301        resolution: Option<PendingResolution>,
302    ) -> PendingActionForPolicyEval {
303        PendingActionForPolicyEval {
304            triggered_by_policy_rule: rule_name.to_string(),
305            resolution,
306        }
307    }
308
309    // ============================================================
310    // rule_matches_crossing
311    // ============================================================
312
313    #[test]
314    fn crossing_below_to_above_matches() {
315        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
316        assert!(rule_matches_crossing(&r, &state(4), &state(5)));
317    }
318
319    #[test]
320    fn crossing_below_to_well_above_matches() {
321        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
322        assert!(rule_matches_crossing(&r, &state(2), &state(7)));
323    }
324
325    #[test]
326    fn crossing_above_to_above_does_not_match() {
327        // Pre-action count was already at-or-above threshold.
328        // The threshold was crossed by some earlier action.
329        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
330        assert!(!rule_matches_crossing(&r, &state(7), &state(9)));
331    }
332
333    #[test]
334    fn crossing_below_to_below_does_not_match() {
335        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
336        assert!(!rule_matches_crossing(&r, &state(2), &state(4)));
337    }
338
339    #[test]
340    fn crossing_above_to_below_does_not_match() {
341        // Decay or revocation drove the count down. Not a rule-
342        // firing event; rules fire on the *upward* crossing.
343        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
344        assert!(!rule_matches_crossing(&r, &state(7), &state(3)));
345    }
346
347    #[test]
348    fn crossing_pre_at_threshold_does_not_match() {
349        // Pre-action count exactly at threshold: not strictly
350        // below, so no crossing. Documented edge case — the
351        // threshold itself is not a crossing event; the first
352        // action that pushes the count strictly above threshold
353        // is.
354        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
355        assert!(!rule_matches_crossing(&r, &state(5), &state(6)));
356    }
357
358    #[test]
359    fn crossing_pre_just_below_post_exactly_at_threshold_matches() {
360        // Post-action count exactly at threshold counts as
361        // crossing (threshold is the rule's `>=` boundary).
362        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
363        assert!(rule_matches_crossing(&r, &state(4), &state(5)));
364    }
365
366    // ============================================================
367    // rule_already_fired_for_window
368    // ============================================================
369
370    #[test]
371    fn never_fired_window_open() {
372        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
373        assert!(!rule_already_fired_for_window(&r, &[], &[]));
374    }
375
376    #[test]
377    fn unrevoked_subject_firing_window_closed() {
378        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
379        let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
380        assert!(rule_already_fired_for_window(&r, &history, &[]));
381    }
382
383    #[test]
384    fn revoked_subject_firing_window_open() {
385        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
386        let history = vec![revoked_action(ActionType::Warning, Some("warn_5"))];
387        assert!(!rule_already_fired_for_window(&r, &history, &[]));
388    }
389
390    #[test]
391    fn unresolved_pending_window_closed() {
392        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
393        let pendings = vec![pending("warn_5", None)];
394        assert!(rule_already_fired_for_window(&r, &[], &pendings));
395    }
396
397    #[test]
398    fn dismissed_pending_window_open() {
399        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
400        let pendings = vec![pending("warn_5", Some(PendingResolution::Dismissed))];
401        assert!(!rule_already_fired_for_window(&r, &[], &pendings));
402    }
403
404    #[test]
405    fn confirmed_pending_alone_window_open_subject_actions_carries_state() {
406        // A confirmed pending is "done" from the pending side; the
407        // resulting subject_actions row carries
408        // triggered_by_policy_rule. With NO corresponding
409        // subject_actions row in this test's history, the
410        // window is observed as "open" — but this state is
411        // unreachable in practice (#74 always inserts the
412        // subject_actions row on confirm). Pinned to document
413        // the conservative-double-counting-avoidance posture:
414        // confirmed pendings don't ALSO close the window via
415        // the pending check.
416        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
417        let pendings = vec![pending("warn_5", Some(PendingResolution::Confirmed))];
418        assert!(!rule_already_fired_for_window(&r, &[], &pendings));
419    }
420
421    #[test]
422    fn confirmed_pending_with_subject_action_window_closed() {
423        // Realistic post-confirm shape: pending has
424        // resolution=Confirmed, AND a subject_actions row exists
425        // with triggered_by_policy_rule. The subject-side check
426        // closes the window.
427        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
428        let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
429        let pendings = vec![pending("warn_5", Some(PendingResolution::Confirmed))];
430        assert!(rule_already_fired_for_window(&r, &history, &pendings));
431    }
432
433    #[test]
434    fn unrelated_rule_firings_dont_close_window() {
435        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
436        let history = vec![unrevoked_action(ActionType::Warning, Some("other_rule"))];
437        let pendings = vec![pending("yet_another_rule", None)];
438        assert!(!rule_already_fired_for_window(&r, &history, &pendings));
439    }
440
441    #[test]
442    fn moderator_action_with_no_rule_attribution_doesnt_close_window() {
443        // Moderator-recorded action (triggered_by_policy_rule =
444        // None) doesn't count as a rule firing.
445        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
446        let history = vec![unrevoked_action(ActionType::Warning, None)];
447        assert!(!rule_already_fired_for_window(&r, &history, &[]));
448    }
449
450    #[test]
451    fn either_subject_or_pending_firing_closes_window() {
452        let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
453        // Subject side has a firing; pending side empty.
454        let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
455        assert!(rule_already_fired_for_window(&r, &history, &[]));
456        // Pending side has a firing; subject side empty.
457        let pendings = vec![pending("warn_5", None)];
458        assert!(rule_already_fired_for_window(&r, &[], &pendings));
459    }
460
461    // ============================================================
462    // resolve_firing_rule
463    // ============================================================
464
465    #[test]
466    fn disabled_policy_returns_none() {
467        let mut policy = policy_with(vec![rule(
468            "warn_5",
469            5,
470            ActionType::Warning,
471            PolicyMode::Auto,
472        )]);
473        policy.enabled = false;
474        assert!(resolve_firing_rule(&state(0), &state(10), &[], &[], &policy).is_none());
475    }
476
477    #[test]
478    fn empty_policy_returns_none() {
479        let policy = policy_with(vec![]);
480        assert!(resolve_firing_rule(&state(0), &state(10), &[], &[], &policy).is_none());
481    }
482
483    #[test]
484    fn no_rules_match_crossing_returns_none() {
485        let policy = policy_with(vec![rule(
486            "warn_10",
487            10,
488            ActionType::Warning,
489            PolicyMode::Auto,
490        )]);
491        // Pre 0 → post 5; rule needs threshold 10. No match.
492        assert!(resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).is_none());
493    }
494
495    #[test]
496    fn single_matching_rule_returns_it() {
497        let policy = policy_with(vec![rule(
498            "warn_5",
499            5,
500            ActionType::Warning,
501            PolicyMode::Auto,
502        )]);
503        let fired =
504            resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
505        assert_eq!(fired.name, "warn_5");
506    }
507
508    #[test]
509    fn matching_rule_already_fired_returns_none() {
510        let policy = policy_with(vec![rule(
511            "warn_5",
512            5,
513            ActionType::Warning,
514            PolicyMode::Auto,
515        )]);
516        let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
517        assert!(
518            resolve_firing_rule(&state(0), &state(5), &history, &[], &policy).is_none(),
519            "already-fired rule must not re-fire while window closed"
520        );
521    }
522
523    #[test]
524    fn takedown_in_history_blocks_all_firings() {
525        let policy = policy_with(vec![rule(
526            "warn_5",
527            5,
528            ActionType::Warning,
529            PolicyMode::Auto,
530        )]);
531        let history = vec![unrevoked_action(ActionType::Takedown, None)];
532        assert!(
533            resolve_firing_rule(&state(0), &state(5), &history, &[], &policy).is_none(),
534            "takedown is terminal — no policy fires"
535        );
536    }
537
538    #[test]
539    fn revoked_takedown_does_not_block() {
540        // Defensive case: a revoked takedown shouldn't gate the
541        // engine. The decay calculator (#50) treats revoked
542        // suspensions/takedowns as "didn't happen" for state
543        // purposes; the evaluator should match that posture.
544        let policy = policy_with(vec![rule(
545            "warn_5",
546            5,
547            ActionType::Warning,
548            PolicyMode::Auto,
549        )]);
550        let history = vec![revoked_action(ActionType::Takedown, None)];
551        let fired = resolve_firing_rule(&state(0), &state(5), &history, &[], &policy);
552        assert!(
553            fired.is_some(),
554            "revoked takedown should not block policy evaluation"
555        );
556    }
557
558    #[test]
559    fn severity_order_takedown_wins_over_indef() {
560        let policy = policy_with(vec![
561            rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
562            rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Auto),
563            rule("takedown_5", 5, ActionType::Takedown, PolicyMode::Auto),
564        ]);
565        let fired =
566            resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
567        assert_eq!(fired.name, "takedown_5");
568    }
569
570    #[test]
571    fn severity_order_indef_beats_temp() {
572        let policy = policy_with(vec![
573            rule("temp_5", 5, ActionType::TempSuspension, PolicyMode::Auto),
574            rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Auto),
575        ]);
576        let fired =
577            resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
578        assert_eq!(fired.name, "indef_5");
579    }
580
581    #[test]
582    fn severity_order_temp_beats_warning() {
583        let policy = policy_with(vec![
584            rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
585            rule("temp_5", 5, ActionType::TempSuspension, PolicyMode::Auto),
586        ]);
587        let fired =
588            resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
589        assert_eq!(fired.name, "temp_5");
590    }
591
592    #[test]
593    fn tie_within_severity_higher_threshold_wins() {
594        // Two warning rules, both crossed by the same recordAction.
595        // Higher threshold = "you got further along the curve" =
596        // more severe outcome.
597        let policy = policy_with(vec![
598            rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
599            rule("warn_10", 10, ActionType::Warning, PolicyMode::Auto),
600        ]);
601        let fired =
602            resolve_firing_rule(&state(0), &state(15), &[], &[], &policy).expect("rule fires");
603        assert_eq!(fired.name, "warn_10");
604    }
605
606    #[test]
607    fn highest_severity_already_fired_falls_through_to_next() {
608        // Takedown rule already fired (revoked, so window open
609        // again — actually wait, an unrevoked takedown blocks
610        // everything via subject_is_takendown above).
611        // Test the realistic case: subject-side firing of
612        // takedown recorded, then revoked. Strike count back
613        // to crossing range. Now severity check picks the next
614        // rule in line.
615        let policy = policy_with(vec![
616            rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
617            rule("takedown_5", 5, ActionType::Takedown, PolicyMode::Auto),
618        ]);
619        // Takedown rule fired previously, then was revoked.
620        // The action is gone from "blocking everything" and the
621        // window is open. But warn_5 hasn't fired.
622        // Actually: revoked takedown means subject_is_takendown
623        // returns false; the takedown rule's window is open
624        // (revoked firing); both rules are eligible.
625        // Severity order picks takedown again. Test the case
626        // where takedown's window is closed (unrevoked previous
627        // firing) but takedown isn't blocking — that's
628        // unreachable (unrevoked takedown DOES block). So this
629        // test pins the simpler severity-order-with-eligible-
630        // alternates case.
631        let history = vec![
632            // Pretend we have warn_5 already fired and revoked,
633            // and takedown_5 is eligible. resolve picks
634            // takedown_5 (higher severity, eligible).
635            revoked_action(ActionType::Warning, Some("warn_5")),
636        ];
637        let fired =
638            resolve_firing_rule(&state(0), &state(5), &history, &[], &policy).expect("rule fires");
639        assert_eq!(fired.name, "takedown_5");
640    }
641
642    #[test]
643    fn all_matching_rules_already_fired_returns_none() {
644        let policy = policy_with(vec![
645            rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
646            rule("warn_10", 10, ActionType::Warning, PolicyMode::Auto),
647        ]);
648        let history = vec![
649            unrevoked_action(ActionType::Warning, Some("warn_5")),
650            unrevoked_action(ActionType::Warning, Some("warn_10")),
651        ];
652        assert!(
653            resolve_firing_rule(&state(0), &state(15), &history, &[], &policy).is_none(),
654            "all matching rules already fired → no firing"
655        );
656    }
657
658    #[test]
659    fn higher_severity_already_fired_falls_through_to_lower() {
660        // Multiple rules match crossing: takedown (higher sev,
661        // already fired and revoked... no, unrevoked takedown
662        // blocks. Use indef + warning instead). Indef already
663        // fired, warning hasn't. Warning fires.
664        let policy = policy_with(vec![
665            rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
666            rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Auto),
667        ]);
668        let history = vec![unrevoked_action(
669            ActionType::IndefSuspension,
670            Some("indef_5"),
671        )];
672        let fired = resolve_firing_rule(&state(0), &state(5), &history, &[], &policy)
673            .expect("warning rule fires");
674        assert_eq!(fired.name, "warn_5");
675    }
676
677    #[test]
678    fn pending_blocks_higher_severity_falls_through() {
679        // Pending exists for indef rule; warning rule fires
680        // instead (lower severity is the next eligible).
681        let policy = policy_with(vec![
682            rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
683            rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Flag),
684        ]);
685        let pendings = vec![pending("indef_5", None)];
686        let fired = resolve_firing_rule(&state(0), &state(5), &[], &pendings, &policy)
687            .expect("warning rule fires");
688        assert_eq!(fired.name, "warn_5");
689    }
690
691    #[test]
692    fn flag_mode_rule_returned_just_like_auto() {
693        // The evaluator doesn't distinguish auto vs flag at the
694        // resolve step — that's the recorder's concern. Pin
695        // that flag rules return the same way.
696        let policy = policy_with(vec![rule(
697            "indef_5",
698            5,
699            ActionType::IndefSuspension,
700            PolicyMode::Flag,
701        )]);
702        let fired =
703            resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
704        assert_eq!(fired.mode, PolicyMode::Flag);
705    }
706
707    // ============================================================
708    // determinism — same inputs always produce same output
709    // ============================================================
710
711    #[test]
712    fn outputs_deterministic_for_same_inputs() {
713        let policy = policy_with(vec![
714            rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
715            rule("warn_10", 10, ActionType::Warning, PolicyMode::Auto),
716            rule(
717                "indef_15",
718                15,
719                ActionType::IndefSuspension,
720                PolicyMode::Auto,
721            ),
722        ]);
723        let s_before = state(0);
724        let s_after = state(20);
725        let a = resolve_firing_rule(&s_before, &s_after, &[], &[], &policy);
726        let b = resolve_firing_rule(&s_before, &s_after, &[], &[], &policy);
727        assert_eq!(a.map(|r| r.name.as_str()), b.map(|r| r.name.as_str()));
728    }
729}