use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
use crate::error::{Error, Result};
use crate::moderation::reasons::ReasonVocabulary;
use crate::moderation::types::ActionType;
pub const SYNTHETIC_POLICY_ACTOR_DID: &str = "did:internal:policy";
pub const DEFAULT_POLICY_REASON_CODE: &str = "policy-threshold";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyMode {
Auto,
Flag,
}
impl PolicyMode {
pub fn as_str(self) -> &'static str {
match self {
PolicyMode::Auto => "auto",
PolicyMode::Flag => "flag",
}
}
fn from_str(s: &str) -> Option<Self> {
match s {
"auto" => Some(PolicyMode::Auto),
"flag" => Some(PolicyMode::Flag),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyRule {
pub name: String,
pub threshold_strikes: i64,
pub action_type: ActionType,
pub mode: PolicyMode,
pub duration: Option<Duration>,
pub reason_codes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyAutomationPolicy {
pub enabled: bool,
pub rules: BTreeMap<String, PolicyRule>,
}
impl PolicyAutomationPolicy {
pub fn from_config(cfg: &crate::config::Config) -> Result<Self> {
let Some(toml) = cfg.policy_automation.as_ref() else {
return Ok(Self::defaults());
};
Self::validated_from_toml(toml)
}
pub fn defaults() -> Self {
Self {
enabled: true,
rules: BTreeMap::new(),
}
}
pub fn validate_reason_codes_against(&self, vocab: &ReasonVocabulary) -> Result<()> {
if !self.enabled {
return Ok(());
}
for rule in self.rules.values() {
for code in &rule.reason_codes {
if vocab.lookup(code).is_none() {
return Err(Error::Signing(format!(
"config: [policy_automation.rules.{}] reason_codes references \
'{}' which is not declared in [moderation_reasons]",
rule.name, code
)));
}
}
}
Ok(())
}
pub fn rules_in_severity_order(&self) -> Vec<&PolicyRule> {
let mut out: Vec<&PolicyRule> = self.rules.values().collect();
out.sort_by(|a, b| {
let rank_a = severity_rank(a.action_type);
let rank_b = severity_rank(b.action_type);
rank_b
.cmp(&rank_a)
.then_with(|| b.threshold_strikes.cmp(&a.threshold_strikes))
.then_with(|| a.name.cmp(&b.name))
});
out
}
fn validated_from_toml(toml: &crate::config::PolicyAutomationPolicyToml) -> Result<Self> {
let mut rules: BTreeMap<String, PolicyRule> = BTreeMap::new();
let mut seen_pairs: BTreeSet<(i64, ActionType)> = BTreeSet::new();
for (raw_name, rule_toml) in &toml.rules {
validate_rule_name(raw_name)?;
if rule_toml.threshold_strikes <= 0 {
return Err(Error::Signing(format!(
"config: [policy_automation.rules.{}] threshold_strikes must be > 0 (got {})",
raw_name, rule_toml.threshold_strikes
)));
}
let action_type = ActionType::from_db_str(&rule_toml.action_type).ok_or_else(|| {
Error::Signing(format!(
"config: [policy_automation.rules.{}] action_type '{}' is not a valid \
action_type (expected one of warning / note / temp_suspension / \
indef_suspension / takedown)",
raw_name, rule_toml.action_type
))
})?;
let mode = PolicyMode::from_str(&rule_toml.mode).ok_or_else(|| {
Error::Signing(format!(
"config: [policy_automation.rules.{}] mode '{}' is not valid \
(expected 'auto' or 'flag')",
raw_name, rule_toml.mode
))
})?;
let duration = match (action_type, rule_toml.duration.as_deref()) {
(ActionType::TempSuspension, Some(s)) => {
let secs = crate::writer::parse_iso8601_duration(s).map_err(|e| {
Error::Signing(format!(
"config: [policy_automation.rules.{}] duration '{}' is invalid: {}",
raw_name, s, e
))
})?;
Some(Duration::from_secs(secs))
}
(ActionType::TempSuspension, None) => {
return Err(Error::Signing(format!(
"config: [policy_automation.rules.{}] action_type 'temp_suspension' \
requires a duration",
raw_name
)));
}
(_, Some(_)) => {
return Err(Error::Signing(format!(
"config: [policy_automation.rules.{}] duration is only valid for \
action_type 'temp_suspension' (got action_type '{}')",
raw_name, rule_toml.action_type
)));
}
(_, None) => None,
};
let reason_codes = match &rule_toml.reason_codes {
Some(codes) if codes.is_empty() => {
return Err(Error::Signing(format!(
"config: [policy_automation.rules.{}] reason_codes must be non-empty \
when specified (omit the key entirely to use the default \
[\"{}\"])",
raw_name, DEFAULT_POLICY_REASON_CODE
)));
}
Some(codes) => codes.clone(),
None => vec![DEFAULT_POLICY_REASON_CODE.to_string()],
};
if !seen_pairs.insert((rule_toml.threshold_strikes, action_type)) {
return Err(Error::Signing(format!(
"config: [policy_automation.rules.{}] duplicates the \
(threshold_strikes={}, action_type='{}') pair already declared by another \
rule — each pair must be unique to a single rule for unambiguous \
evaluation",
raw_name, rule_toml.threshold_strikes, rule_toml.action_type
)));
}
rules.insert(
raw_name.clone(),
PolicyRule {
name: raw_name.clone(),
threshold_strikes: rule_toml.threshold_strikes,
action_type,
mode,
duration,
reason_codes,
},
);
}
Ok(Self {
enabled: toml.enabled,
rules,
})
}
}
fn severity_rank(t: ActionType) -> u8 {
match t {
ActionType::Takedown => 4,
ActionType::IndefSuspension => 3,
ActionType::TempSuspension => 2,
ActionType::Warning => 1,
ActionType::Note => 0,
}
}
fn validate_rule_name(s: &str) -> Result<()> {
if s.is_empty() || s.len() > 64 {
return Err(Error::Signing(format!(
"config: [policy_automation.rules.{s}] rule name must be 1-64 chars (got {} chars)",
s.len()
)));
}
let mut chars = s.chars();
let first = chars.next().expect("non-empty checked above");
if !first.is_ascii_lowercase() {
return Err(Error::Signing(format!(
"config: [policy_automation.rules.{s}] rule name must start with a lowercase ASCII \
letter (got '{first}')"
)));
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' {
return Err(Error::Signing(format!(
"config: [policy_automation.rules.{s}] rule name contains invalid char '{c}' \
(allowed: a-z, 0-9, underscore)"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn config_with_policy(value: serde_json::Value) -> Config {
let mut v = serde_json::json!({
"service_did": "did:web:labeler.example",
"service_endpoint": "https://labeler.example",
"db_path": "/var/lib/cairn/cairn.db",
"signing_key_path": "/etc/cairn/signing-key.hex",
});
if !value.is_null() {
v["policy_automation"] = value;
}
serde_json::from_value(v).expect("config deserializes")
}
fn config_with_policy_and_reasons(
policy: serde_json::Value,
reasons: serde_json::Value,
) -> Config {
let mut v = serde_json::json!({
"service_did": "did:web:labeler.example",
"service_endpoint": "https://labeler.example",
"db_path": "/var/lib/cairn/cairn.db",
"signing_key_path": "/etc/cairn/signing-key.hex",
});
if !policy.is_null() {
v["policy_automation"] = policy;
}
if !reasons.is_null() {
v["moderation_reasons"] = reasons;
}
serde_json::from_value(v).expect("config deserializes")
}
#[test]
fn defaults_engine_on_no_rules() {
let p = PolicyAutomationPolicy::defaults();
assert!(p.enabled);
assert!(p.rules.is_empty());
}
#[test]
fn from_config_no_block_returns_defaults() {
let cfg = config_with_policy(serde_json::Value::Null);
let p = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
assert_eq!(p, PolicyAutomationPolicy::defaults());
}
#[test]
fn synthetic_did_constant_is_did_internal_policy() {
assert_eq!(SYNTHETIC_POLICY_ACTOR_DID, "did:internal:policy");
}
#[test]
fn default_reason_code_is_policy_dash_threshold() {
assert_eq!(DEFAULT_POLICY_REASON_CODE, "policy-threshold");
}
#[test]
fn enabled_false_explicit_loads_disabled() {
let cfg = config_with_policy(serde_json::json!({ "enabled": false }));
let p = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
assert!(!p.enabled);
}
#[test]
fn enabled_defaults_to_true_when_only_rules_specified() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
}
}
}));
let p = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
assert!(p.enabled);
}
#[test]
fn single_warning_rule_loads_with_default_reason_code() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
}
}
}));
let p = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
let rule = p.rules.get("warn_at_5").expect("rule present");
assert_eq!(rule.name, "warn_at_5");
assert_eq!(rule.threshold_strikes, 5);
assert_eq!(rule.action_type, ActionType::Warning);
assert_eq!(rule.mode, PolicyMode::Auto);
assert!(rule.duration.is_none());
assert_eq!(rule.reason_codes, vec!["policy-threshold".to_string()]);
}
#[test]
fn temp_suspension_rule_with_duration_parses() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"temp_at_10": {
"threshold_strikes": 10,
"action_type": "temp_suspension",
"mode": "auto",
"duration": "P3D",
"reason_codes": ["policy-threshold", "spam"],
}
}
}));
let p = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
let rule = p.rules.get("temp_at_10").expect("rule present");
assert_eq!(rule.duration, Some(Duration::from_secs(3 * 86_400)));
assert_eq!(
rule.reason_codes,
vec!["policy-threshold".to_string(), "spam".to_string()]
);
}
#[test]
fn flag_mode_loads() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"indef_at_25": {
"threshold_strikes": 25,
"action_type": "indef_suspension",
"mode": "flag",
}
}
}));
let p = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
let rule = p.rules.get("indef_at_25").expect("rule present");
assert_eq!(rule.mode, PolicyMode::Flag);
}
#[test]
fn rule_name_uppercase_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"Warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("must start with a lowercase"));
}
#[test]
fn rule_name_with_hyphen_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn-at-5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("invalid char"));
}
#[test]
fn rule_name_starts_with_digit_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"5warn": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("must start with a lowercase"));
}
#[test]
fn rule_name_too_long_rejected() {
let long = "a".repeat(65);
let cfg = config_with_policy(serde_json::json!({
"rules": {
long.as_str(): {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("1-64 chars"));
}
#[test]
fn threshold_zero_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_0": {
"threshold_strikes": 0,
"action_type": "warning",
"mode": "auto",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("threshold_strikes must be > 0"));
}
#[test]
fn threshold_negative_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_neg": {
"threshold_strikes": -5,
"action_type": "warning",
"mode": "auto",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("threshold_strikes must be > 0"));
}
#[test]
fn unknown_action_type_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"bogus_at_5": {
"threshold_strikes": 5,
"action_type": "exile",
"mode": "auto",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("not a valid action_type"));
}
#[test]
fn unknown_mode_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "maybe",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("not valid"));
}
#[test]
fn duration_on_non_temp_suspension_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
"duration": "P7D",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("only valid for action_type 'temp_suspension'"));
}
#[test]
fn missing_duration_on_temp_suspension_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"temp_at_10": {
"threshold_strikes": 10,
"action_type": "temp_suspension",
"mode": "auto",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("requires a duration"));
}
#[test]
fn duration_year_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"temp_at_10": {
"threshold_strikes": 10,
"action_type": "temp_suspension",
"mode": "auto",
"duration": "P1Y",
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("duration"), "got: {msg}");
}
#[test]
fn empty_explicit_reason_codes_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
"reason_codes": [],
}
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("must be non-empty"));
}
#[test]
fn duplicate_threshold_action_type_pair_rejected() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_a": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
},
"warn_b": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "flag",
},
}
}));
let err = PolicyAutomationPolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("duplicates the"));
}
#[test]
fn same_threshold_different_action_type_allowed() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
},
"temp_at_5": {
"threshold_strikes": 5,
"action_type": "temp_suspension",
"mode": "auto",
"duration": "P1D",
},
}
}));
let p = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
assert_eq!(p.rules.len(), 2);
}
#[test]
fn severity_order_takedown_first() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
},
"takedown_at_40": {
"threshold_strikes": 40,
"action_type": "takedown",
"mode": "auto",
},
"indef_at_25": {
"threshold_strikes": 25,
"action_type": "indef_suspension",
"mode": "flag",
},
"temp_at_10": {
"threshold_strikes": 10,
"action_type": "temp_suspension",
"mode": "auto",
"duration": "P3D",
},
}
}));
let p = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
let ordered: Vec<&str> = p
.rules_in_severity_order()
.iter()
.map(|r| r.name.as_str())
.collect();
assert_eq!(
ordered,
vec!["takedown_at_40", "indef_at_25", "temp_at_10", "warn_at_5"]
);
}
#[test]
fn severity_order_ties_resolve_by_higher_threshold() {
let cfg = config_with_policy(serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
},
"warn_at_10": {
"threshold_strikes": 10,
"action_type": "warning",
"mode": "auto",
},
}
}));
let p = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
let ordered: Vec<&str> = p
.rules_in_severity_order()
.iter()
.map(|r| r.name.as_str())
.collect();
assert_eq!(ordered, vec!["warn_at_10", "warn_at_5"]);
}
#[test]
fn cross_validation_unknown_reason_code_rejected() {
let cfg = config_with_policy_and_reasons(
serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
"reason_codes": ["spam", "made_up_code"],
}
}
}),
serde_json::json!({
"spam": { "base_weight": 2, "description": "spam" },
}),
);
let policy = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
let vocab = ReasonVocabulary::from_config(&cfg).expect("vocab");
let err = policy.validate_reason_codes_against(&vocab).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("made_up_code"), "got: {msg}");
assert!(msg.contains("warn_at_5"), "got: {msg}");
}
#[test]
fn cross_validation_known_reason_codes_pass() {
let cfg = config_with_policy_and_reasons(
serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
"reason_codes": ["spam", "hate-speech"],
}
}
}),
serde_json::json!({
"spam": { "base_weight": 2, "description": "spam" },
"hate-speech": { "base_weight": 4, "description": "hate" },
}),
);
let policy = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
let vocab = ReasonVocabulary::from_config(&cfg).expect("vocab");
policy
.validate_reason_codes_against(&vocab)
.expect("known codes pass");
}
#[test]
fn cross_validation_default_reason_code_must_be_in_vocabulary() {
let cfg = config_with_policy_and_reasons(
serde_json::json!({
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
}
}
}),
serde_json::json!({
"spam": { "base_weight": 2, "description": "spam" },
}),
);
let policy = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
let vocab = ReasonVocabulary::from_config(&cfg).expect("vocab");
let err = policy.validate_reason_codes_against(&vocab).unwrap_err();
assert!(format!("{err}").contains("policy-threshold"));
}
#[test]
fn cross_validation_skipped_when_engine_disabled() {
let cfg = config_with_policy_and_reasons(
serde_json::json!({
"enabled": false,
"rules": {
"warn_at_5": {
"threshold_strikes": 5,
"action_type": "warning",
"mode": "auto",
"reason_codes": ["future_reason_not_yet_declared"],
}
}
}),
serde_json::json!({
"spam": { "base_weight": 2, "description": "spam" },
}),
);
let policy = PolicyAutomationPolicy::from_config(&cfg).expect("from_config");
let vocab = ReasonVocabulary::from_config(&cfg).expect("vocab");
policy
.validate_reason_codes_against(&vocab)
.expect("disabled engine skips cross-check");
}
#[test]
fn policy_mode_as_str_round_trip() {
for m in [PolicyMode::Auto, PolicyMode::Flag] {
assert_eq!(PolicyMode::from_str(m.as_str()), Some(m));
}
}
}