use std::time::SystemTime;
use crate::moderation::decay::StrikeState;
use crate::moderation::types::ActionType;
use super::automation::{PolicyAutomationPolicy, PolicyRule};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionForPolicyEval {
pub effective_at: SystemTime,
pub action_type: ActionType,
pub revoked_at: Option<SystemTime>,
pub triggered_by_policy_rule: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingActionForPolicyEval {
pub triggered_by_policy_rule: String,
pub resolution: Option<PendingResolution>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingResolution {
Confirmed,
Dismissed,
}
pub fn resolve_firing_rule<'a>(
state_before: &StrikeState,
state_after: &StrikeState,
subject_history: &[ActionForPolicyEval],
pending_actions: &[PendingActionForPolicyEval],
policy: &'a PolicyAutomationPolicy,
) -> Option<&'a PolicyRule> {
if !policy.enabled {
return None;
}
if subject_is_takendown(subject_history) {
return None;
}
for rule in policy.rules_in_severity_order() {
if !rule_matches_crossing(rule, state_before, state_after) {
continue;
}
if rule_already_fired_for_window(rule, subject_history, pending_actions) {
continue;
}
return Some(rule);
}
None
}
pub fn rule_matches_crossing(
rule: &PolicyRule,
state_before: &StrikeState,
state_after: &StrikeState,
) -> bool {
let before = i64::from(state_before.current_count);
let after = i64::from(state_after.current_count);
before < rule.threshold_strikes && after >= rule.threshold_strikes
}
pub fn rule_already_fired_for_window(
rule: &PolicyRule,
subject_history: &[ActionForPolicyEval],
pending_actions: &[PendingActionForPolicyEval],
) -> bool {
let any_subject_fire = subject_history.iter().any(|a| {
a.triggered_by_policy_rule.as_deref() == Some(rule.name.as_str()) && a.revoked_at.is_none()
});
let any_pending_fire = pending_actions
.iter()
.any(|p| p.triggered_by_policy_rule == rule.name && p.resolution.is_none());
any_subject_fire || any_pending_fire
}
fn subject_is_takendown(history: &[ActionForPolicyEval]) -> bool {
history
.iter()
.any(|a| matches!(a.action_type, ActionType::Takedown) && a.revoked_at.is_none())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::automation::{PolicyAutomationPolicy, PolicyMode, PolicyRule};
use std::collections::BTreeMap;
use std::time::{Duration, UNIX_EPOCH};
fn t0() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(2_000_000_000)
}
fn state(count: u32) -> StrikeState {
StrikeState {
current_count: count,
raw_total: count,
revoked_count: 0,
decayed_count: 0,
active_suspension: None,
good_standing: count == 0,
}
}
fn rule(name: &str, threshold: i64, action_type: ActionType, mode: PolicyMode) -> PolicyRule {
PolicyRule {
name: name.to_string(),
threshold_strikes: threshold,
action_type,
mode,
duration: if matches!(action_type, ActionType::TempSuspension) {
Some(Duration::from_secs(86_400))
} else {
None
},
reason_codes: vec!["policy-threshold".to_string()],
}
}
fn policy_with(rules: Vec<PolicyRule>) -> PolicyAutomationPolicy {
let mut map = BTreeMap::new();
for r in rules {
map.insert(r.name.clone(), r);
}
PolicyAutomationPolicy {
enabled: true,
rules: map,
}
}
fn unrevoked_action(action_type: ActionType, rule_name: Option<&str>) -> ActionForPolicyEval {
ActionForPolicyEval {
effective_at: t0(),
action_type,
revoked_at: None,
triggered_by_policy_rule: rule_name.map(str::to_string),
}
}
fn revoked_action(action_type: ActionType, rule_name: Option<&str>) -> ActionForPolicyEval {
ActionForPolicyEval {
effective_at: t0(),
action_type,
revoked_at: Some(t0() + Duration::from_secs(60)),
triggered_by_policy_rule: rule_name.map(str::to_string),
}
}
fn pending(
rule_name: &str,
resolution: Option<PendingResolution>,
) -> PendingActionForPolicyEval {
PendingActionForPolicyEval {
triggered_by_policy_rule: rule_name.to_string(),
resolution,
}
}
#[test]
fn crossing_below_to_above_matches() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
assert!(rule_matches_crossing(&r, &state(4), &state(5)));
}
#[test]
fn crossing_below_to_well_above_matches() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
assert!(rule_matches_crossing(&r, &state(2), &state(7)));
}
#[test]
fn crossing_above_to_above_does_not_match() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
assert!(!rule_matches_crossing(&r, &state(7), &state(9)));
}
#[test]
fn crossing_below_to_below_does_not_match() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
assert!(!rule_matches_crossing(&r, &state(2), &state(4)));
}
#[test]
fn crossing_above_to_below_does_not_match() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
assert!(!rule_matches_crossing(&r, &state(7), &state(3)));
}
#[test]
fn crossing_pre_at_threshold_does_not_match() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
assert!(!rule_matches_crossing(&r, &state(5), &state(6)));
}
#[test]
fn crossing_pre_just_below_post_exactly_at_threshold_matches() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
assert!(rule_matches_crossing(&r, &state(4), &state(5)));
}
#[test]
fn never_fired_window_open() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
assert!(!rule_already_fired_for_window(&r, &[], &[]));
}
#[test]
fn unrevoked_subject_firing_window_closed() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
assert!(rule_already_fired_for_window(&r, &history, &[]));
}
#[test]
fn revoked_subject_firing_window_open() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
let history = vec![revoked_action(ActionType::Warning, Some("warn_5"))];
assert!(!rule_already_fired_for_window(&r, &history, &[]));
}
#[test]
fn unresolved_pending_window_closed() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
let pendings = vec![pending("warn_5", None)];
assert!(rule_already_fired_for_window(&r, &[], &pendings));
}
#[test]
fn dismissed_pending_window_open() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
let pendings = vec![pending("warn_5", Some(PendingResolution::Dismissed))];
assert!(!rule_already_fired_for_window(&r, &[], &pendings));
}
#[test]
fn confirmed_pending_alone_window_open_subject_actions_carries_state() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
let pendings = vec![pending("warn_5", Some(PendingResolution::Confirmed))];
assert!(!rule_already_fired_for_window(&r, &[], &pendings));
}
#[test]
fn confirmed_pending_with_subject_action_window_closed() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
let pendings = vec![pending("warn_5", Some(PendingResolution::Confirmed))];
assert!(rule_already_fired_for_window(&r, &history, &pendings));
}
#[test]
fn unrelated_rule_firings_dont_close_window() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
let history = vec![unrevoked_action(ActionType::Warning, Some("other_rule"))];
let pendings = vec![pending("yet_another_rule", None)];
assert!(!rule_already_fired_for_window(&r, &history, &pendings));
}
#[test]
fn moderator_action_with_no_rule_attribution_doesnt_close_window() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
let history = vec![unrevoked_action(ActionType::Warning, None)];
assert!(!rule_already_fired_for_window(&r, &history, &[]));
}
#[test]
fn either_subject_or_pending_firing_closes_window() {
let r = rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto);
let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
assert!(rule_already_fired_for_window(&r, &history, &[]));
let pendings = vec![pending("warn_5", None)];
assert!(rule_already_fired_for_window(&r, &[], &pendings));
}
#[test]
fn disabled_policy_returns_none() {
let mut policy = policy_with(vec![rule(
"warn_5",
5,
ActionType::Warning,
PolicyMode::Auto,
)]);
policy.enabled = false;
assert!(resolve_firing_rule(&state(0), &state(10), &[], &[], &policy).is_none());
}
#[test]
fn empty_policy_returns_none() {
let policy = policy_with(vec![]);
assert!(resolve_firing_rule(&state(0), &state(10), &[], &[], &policy).is_none());
}
#[test]
fn no_rules_match_crossing_returns_none() {
let policy = policy_with(vec![rule(
"warn_10",
10,
ActionType::Warning,
PolicyMode::Auto,
)]);
assert!(resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).is_none());
}
#[test]
fn single_matching_rule_returns_it() {
let policy = policy_with(vec![rule(
"warn_5",
5,
ActionType::Warning,
PolicyMode::Auto,
)]);
let fired =
resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
assert_eq!(fired.name, "warn_5");
}
#[test]
fn matching_rule_already_fired_returns_none() {
let policy = policy_with(vec![rule(
"warn_5",
5,
ActionType::Warning,
PolicyMode::Auto,
)]);
let history = vec![unrevoked_action(ActionType::Warning, Some("warn_5"))];
assert!(
resolve_firing_rule(&state(0), &state(5), &history, &[], &policy).is_none(),
"already-fired rule must not re-fire while window closed"
);
}
#[test]
fn takedown_in_history_blocks_all_firings() {
let policy = policy_with(vec![rule(
"warn_5",
5,
ActionType::Warning,
PolicyMode::Auto,
)]);
let history = vec![unrevoked_action(ActionType::Takedown, None)];
assert!(
resolve_firing_rule(&state(0), &state(5), &history, &[], &policy).is_none(),
"takedown is terminal — no policy fires"
);
}
#[test]
fn revoked_takedown_does_not_block() {
let policy = policy_with(vec![rule(
"warn_5",
5,
ActionType::Warning,
PolicyMode::Auto,
)]);
let history = vec![revoked_action(ActionType::Takedown, None)];
let fired = resolve_firing_rule(&state(0), &state(5), &history, &[], &policy);
assert!(
fired.is_some(),
"revoked takedown should not block policy evaluation"
);
}
#[test]
fn severity_order_takedown_wins_over_indef() {
let policy = policy_with(vec![
rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Auto),
rule("takedown_5", 5, ActionType::Takedown, PolicyMode::Auto),
]);
let fired =
resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
assert_eq!(fired.name, "takedown_5");
}
#[test]
fn severity_order_indef_beats_temp() {
let policy = policy_with(vec![
rule("temp_5", 5, ActionType::TempSuspension, PolicyMode::Auto),
rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Auto),
]);
let fired =
resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
assert_eq!(fired.name, "indef_5");
}
#[test]
fn severity_order_temp_beats_warning() {
let policy = policy_with(vec![
rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
rule("temp_5", 5, ActionType::TempSuspension, PolicyMode::Auto),
]);
let fired =
resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
assert_eq!(fired.name, "temp_5");
}
#[test]
fn tie_within_severity_higher_threshold_wins() {
let policy = policy_with(vec![
rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
rule("warn_10", 10, ActionType::Warning, PolicyMode::Auto),
]);
let fired =
resolve_firing_rule(&state(0), &state(15), &[], &[], &policy).expect("rule fires");
assert_eq!(fired.name, "warn_10");
}
#[test]
fn highest_severity_already_fired_falls_through_to_next() {
let policy = policy_with(vec![
rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
rule("takedown_5", 5, ActionType::Takedown, PolicyMode::Auto),
]);
let history = vec![
revoked_action(ActionType::Warning, Some("warn_5")),
];
let fired =
resolve_firing_rule(&state(0), &state(5), &history, &[], &policy).expect("rule fires");
assert_eq!(fired.name, "takedown_5");
}
#[test]
fn all_matching_rules_already_fired_returns_none() {
let policy = policy_with(vec![
rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
rule("warn_10", 10, ActionType::Warning, PolicyMode::Auto),
]);
let history = vec![
unrevoked_action(ActionType::Warning, Some("warn_5")),
unrevoked_action(ActionType::Warning, Some("warn_10")),
];
assert!(
resolve_firing_rule(&state(0), &state(15), &history, &[], &policy).is_none(),
"all matching rules already fired → no firing"
);
}
#[test]
fn higher_severity_already_fired_falls_through_to_lower() {
let policy = policy_with(vec![
rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Auto),
]);
let history = vec![unrevoked_action(
ActionType::IndefSuspension,
Some("indef_5"),
)];
let fired = resolve_firing_rule(&state(0), &state(5), &history, &[], &policy)
.expect("warning rule fires");
assert_eq!(fired.name, "warn_5");
}
#[test]
fn pending_blocks_higher_severity_falls_through() {
let policy = policy_with(vec![
rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
rule("indef_5", 5, ActionType::IndefSuspension, PolicyMode::Flag),
]);
let pendings = vec![pending("indef_5", None)];
let fired = resolve_firing_rule(&state(0), &state(5), &[], &pendings, &policy)
.expect("warning rule fires");
assert_eq!(fired.name, "warn_5");
}
#[test]
fn flag_mode_rule_returned_just_like_auto() {
let policy = policy_with(vec![rule(
"indef_5",
5,
ActionType::IndefSuspension,
PolicyMode::Flag,
)]);
let fired =
resolve_firing_rule(&state(0), &state(5), &[], &[], &policy).expect("rule fires");
assert_eq!(fired.mode, PolicyMode::Flag);
}
#[test]
fn outputs_deterministic_for_same_inputs() {
let policy = policy_with(vec![
rule("warn_5", 5, ActionType::Warning, PolicyMode::Auto),
rule("warn_10", 10, ActionType::Warning, PolicyMode::Auto),
rule(
"indef_15",
15,
ActionType::IndefSuspension,
PolicyMode::Auto,
),
]);
let s_before = state(0);
let s_after = state(20);
let a = resolve_firing_rule(&s_before, &s_after, &[], &[], &policy);
let b = resolve_firing_rule(&s_before, &s_after, &[], &[], &policy);
assert_eq!(a.map(|r| r.name.as_str()), b.map(|r| r.name.as_str()));
}
}