Skip to main content

vector_core/community/policy/
document.rs

1//! The policy document: what a community declares, and what the validator
2//! refuses (§5, §6, §12).
3//!
4//! Policies are UNTRUSTED input — a community you join hands your CPU a
5//! document to evaluate — so every cap is enforced here at validation, never at
6//! evaluation. A rejected policy is INERT (it evaluated nothing), which is a
7//! reported state rather than a silent skip: an empty subject list must never
8//! read as "everyone is clean".
9
10use super::types::{caps, Basis, InertReason, Scope, Severity};
11use serde::{Deserialize, Serialize};
12
13/// The validator's closed code list. A policy failing several checks reports
14/// the FIRST code in this order, so a multiply-invalid policy still produces
15/// one deterministic set of bytes.
16pub mod code {
17    pub const UNKNOWN_RULE_TYPE: &str = "unknown_rule_type";
18    pub const UNKNOWN_NORMALIZE: &str = "unknown_normalize";
19    pub const RULE_ID_DUPLICATE: &str = "rule_id_duplicate";
20    pub const RULE_ID_NOT_ASCII: &str = "rule_id_not_ascii";
21    pub const CAP_EXCEEDED: &str = "cap_exceeded";
22    pub const WINDOW_OUT_OF_RANGE: &str = "window_out_of_range";
23    pub const WEIGHT_OUT_OF_RANGE: &str = "weight_out_of_range";
24    pub const TIERS_AND_DIRECT_FORM: &str = "tiers_and_direct_form";
25    pub const MISSING_REQUIRED_PARAMETER: &str = "missing_required_parameter";
26    pub const RUNG_ORDER_NOT_ASCENDING: &str = "rung_order_not_ascending";
27    pub const PIERCES_BELOW_SEVERE: &str = "pierces_below_severe";
28    pub const SCOPE_NOT_ADMISSIBLE: &str = "scope_not_admissible";
29    pub const DIRECT_FORM_NOT_ADMISSIBLE: &str = "direct_form_not_admissible";
30    pub const FAMILY_ON_TIERED_RULE: &str = "family_on_tiered_rule";
31    pub const FAMILY_REASSIGNED_BUILTIN: &str = "family_reassigned_builtin";
32    pub const BOUNDARY_ON_STRIPPING_NORMALIZER: &str = "boundary_on_stripping_normalizer";
33    pub const DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER: &str = "domain_exempt_on_stripping_normalizer";
34    pub const REFUSE_ON_HEURISTIC: &str = "refuse_on_heuristic";
35    pub const REFUSE_TOO_BROAD: &str = "refuse_too_broad";
36    pub const REFUSE_ON_UNNARROWED_RULE: &str = "refuse_on_unnarrowed_rule";
37    pub const ARMED_BY_UNKNOWN_RULE: &str = "armed_by_unknown_rule";
38    pub const ARMED_BY_NESTED: &str = "armed_by_nested";
39    pub const ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE: &str = "armed_by_min_subjects_with_subject_scope";
40    pub const CORROBORATOR_NOT_CONTENT_DERIVED: &str = "corroborator_not_content_derived";
41    pub const EID_EXPECTED: &str = "eid_expected";
42}
43
44/// Normalizer names (the frozen bundle, §7.4). `skeleton` deletes separators,
45/// which is why it refuses word boundaries and domain exemptions.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum Normalize {
49    None,
50    Fold,
51    Skeleton,
52    Confusables,
53}
54
55impl Normalize {
56    /// Does this normalizer delete the separators a token boundary needs?
57    pub fn strips_separators(self) -> bool {
58        matches!(self, Normalize::Skeleton)
59    }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum ExemptKind {
65    Literal,
66    Wildcard,
67    Domain,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
71pub struct ExemptPatterns {
72    pub kind: Option<ExemptKind>,
73    #[serde(default)]
74    pub values: Vec<String>,
75}
76
77/// Exemptions remove TARGETS, never corpus statistics: exempt content and
78/// members stay in every aggregate input (cohort sizes, thinness denominators,
79/// tenure, volume) and are barred only from being cited or convicted.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
81pub struct Exempt {
82    /// A LIST: policy-level and rule-level lists concatenate, each entry
83    /// matching under its own kind, so a mixed-kind union is well-defined.
84    #[serde(default)]
85    pub patterns: Vec<ExemptPatterns>,
86    #[serde(default)]
87    pub roles: Vec<String>,
88    #[serde(default)]
89    pub channels: Vec<String>,
90}
91
92/// One rung of an escalation ladder. Severity, weight and piercing are declared
93/// PER RUNG — the rule is a ladder and the engine reports which rung fired.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct Rung {
96    pub hits: u32,
97    pub severity: Severity,
98    pub weight: u32,
99    #[serde(default)]
100    pub pierces_trusted: bool,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
104pub struct Tiers {
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub per_message: Vec<Rung>,
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub per_window: Vec<Rung>,
109}
110
111/// A rule fires only after the named rule (same policy) convicted.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ArmedBy {
114    pub rule: String,
115    pub scope: ArmScope,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub min_subjects: Option<u32>,
118    /// Alternatives: the rule is armed if `rule` OR any of these fired.
119    ///
120    /// One detector is one point of failure. Without this an aggravator hangs off
121    /// a single rule, so a raid that rule cannot see silences every downstream
122    /// signal too. A policy that sets it MUST declare `requires: ["armed_by_any"]`
123    /// — an older engine would ignore the alternatives and convict less under an
124    /// identical policy_hash.
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub also: Vec<String>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum ArmScope {
132    Subject,
133    Community,
134}
135
136/// Phase-1 rule types. Each entry states its admissible scopes and basis; the
137/// validator rejects anything the spec does not define, so an undefined rule
138/// type can never evaluate as a silent no-op.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(tag = "type", rename_all = "snake_case")]
141pub enum Match {
142    /// Bare pattern = token-anchored (the Scunthorpe guard); `*word`, `word*`,
143    /// `*word*` relax an edge; `\*` is a literal asterisk. No `boundary` field:
144    /// the grammar makes it redundant.
145    Keyword { patterns: Vec<String>, normalize: Normalize },
146    Regex {
147        patterns: Vec<String>,
148        normalize: Normalize,
149        #[serde(default)]
150        boundary_word: bool,
151    },
152    /// Hits = distinct non-exempt registrable domains per message. Carries no
153    /// span (its hit is a domain, not a text range).
154    Link {
155        #[serde(default, skip_serializing_if = "Vec::is_empty")]
156        patterns: Vec<String>,
157    },
158    /// Window-only. Hits = occurrences of the most-repeated normalized text by
159    /// that author; ties by the normalized key ascending.
160    ///
161    /// `within_secs` bounds the count to the densest run inside that span, so
162    /// the rule measures a burst rather than a habit. Without it a daily "gm"
163    /// over a seven-day window counts as seven repeats, which is a regular, not
164    /// a spammer. A policy that sets it MUST declare `requires: ["repeat_within"]`
165    /// or an older engine ignores the field and convicts where a newer one
166    /// would not — the same policy_hash, two different verdicts.
167    Repeat {
168        normalize: Normalize,
169        #[serde(default, skip_serializing_if = "Option::is_none")]
170        within_secs: Option<u64>,
171    },
172    /// Window-only. Half-open sliding window; candidate starts are the author's
173    /// own message timestamps.
174    Rate { per_secs: u64 },
175    /// Hits = p-tags on the message. Inline `@name` is NOT a mention.
176    Mentions {},
177    /// Cross-account same-skeleton clustering. Whole-only, Heuristic.
178    Cohort { min: u32, quiet_max: u32, short_factor: u32, thin_ratio: Option<Ratio> },
179    /// Densest run of joins within `gap_secs`; earliest wins on ties.
180    JoinBurst { gap_secs: u64, min: u32 },
181    /// Member-plane aggravators: citation-less, immutably family-tagged.
182    TenureLt { secs: u64 },
183    MessagesLte { n: u32 },
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187pub struct Ratio {
188    pub num: u32,
189    pub denom: u32,
190}
191
192impl Match {
193    pub fn type_name(&self) -> &'static str {
194        match self {
195            Match::Keyword { .. } => "keyword",
196            Match::Regex { .. } => "regex",
197            Match::Link { .. } => "link",
198            Match::Repeat { .. } => "repeat",
199            Match::Rate { .. } => "rate",
200            Match::Mentions {} => "mentions",
201            Match::Cohort { .. } => "cohort",
202            Match::JoinBurst { .. } => "join_burst",
203            Match::TenureLt { .. } => "tenure_lt",
204            Match::MessagesLte { .. } => "messages_lte",
205        }
206    }
207
208    /// Deterministic = byte-provable and replayable by anyone. Only these feed
209    /// `proven`, and only these may `refuse`.
210    pub fn basis(&self) -> Basis {
211        match self {
212            Match::Cohort { .. } | Match::JoinBurst { .. } => Basis::Heuristic,
213            _ => Basis::Deterministic,
214        }
215    }
216
217    /// Admissible scopes. A window-level quantity must not also declare
218    /// per_message tiers, or both convictions enter the combinator and
219    /// double-count one fact.
220    pub fn admits(&self, scope: Scope) -> bool {
221        match self {
222            Match::Keyword { .. } | Match::Regex { .. } | Match::Link { .. } | Match::Mentions {} => {
223                matches!(scope, Scope::PerMessage | Scope::PerWindow)
224            }
225            Match::Repeat { .. } | Match::Rate { .. } => scope == Scope::PerWindow,
226            Match::Cohort { .. } | Match::JoinBurst { .. } | Match::TenureLt { .. } | Match::MessagesLte { .. } => {
227                scope == Scope::Whole
228            }
229        }
230    }
231
232    /// Can this rule judge ONE message on its own, with no history and no view
233    /// of anyone else? Word filters and link blocks can; anything that counts
234    /// across a window, or compares members to each other, cannot.
235    ///
236    /// The split is what lets an ordinary member run moderation at all: their
237    /// client checks its own drafts and the messages in front of it, while
238    /// everything requiring historical state — repetition, rates, cohorts,
239    /// tenure — belongs to an admin or a moderation bot, which has both the
240    /// authority to act on it and the data to compute it honestly.
241    pub fn is_stateless(&self) -> bool {
242        matches!(self, Match::Keyword { .. } | Match::Regex { .. } | Match::Link { .. } | Match::Mentions {})
243    }
244
245    /// Built-in immutable family tag — the fresh-account proxies fold together
246    /// so correlated signals never OR into an inflated score.
247    pub fn builtin_family(&self) -> Option<&'static str> {
248        match self {
249            Match::TenureLt { .. } | Match::MessagesLte { .. } => Some("fresh-account"),
250            _ => None,
251        }
252    }
253
254    /// Cites content (so a conviction needs at least one citable citation), or
255    /// is member-state (citation-less by nature).
256    pub fn is_content_derived(&self) -> bool {
257        !matches!(self, Match::TenureLt { .. } | Match::MessagesLte { .. })
258    }
259
260    fn patterns(&self) -> &[String] {
261        match self {
262            Match::Keyword { patterns, .. } | Match::Regex { patterns, .. } | Match::Link { patterns } => patterns,
263            _ => &[],
264        }
265    }
266
267    fn normalize(&self) -> Option<Normalize> {
268        match self {
269            Match::Keyword { normalize, .. } | Match::Regex { normalize, .. } | Match::Repeat { normalize, .. } => {
270                Some(*normalize)
271            }
272            _ => None,
273        }
274    }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
278#[serde(rename_all = "snake_case")]
279pub enum Enforcement {
280    #[default]
281    Advisory,
282    Warn,
283    /// Only Deterministic rules may refuse — a heuristic maybe-conviction
284    /// refusing speech is indefensible. Cooperative, never security: an
285    /// adversary ships a non-enforcing client and is caught at receive.
286    Refuse,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct Rule {
291    pub id: String,
292    #[serde(rename = "match")]
293    pub matcher: Match,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub tiers: Option<Tiers>,
296    /// Direct single-rung form (scope `Whole`), for rules that admit it.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub severity: Option<Severity>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub weight: Option<u32>,
301    #[serde(default)]
302    pub pierces_trusted: bool,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub family: Option<String>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub armed_by: Option<ArmedBy>,
307    #[serde(default)]
308    pub exempt: Exempt,
309    #[serde(default)]
310    pub enforcement: Enforcement,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314pub struct Window {
315    pub hours: u64,
316    pub max_messages: usize,
317}
318
319impl Default for Window {
320    fn default() -> Self {
321        Window { hours: 24, max_messages: 500 }
322    }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
326pub struct Shields {
327    pub trusted: TrustedBar,
328}
329
330/// Standing, earned three ways. Thresholds live in the POLICY (a locally
331/// tunable shield would make two mods convict differently under identical
332/// hashes), and every input is a shared fact — tenure from the guestbook, roles
333/// from the stamped roster, activity over the DECLARED window — so two clients
334/// compute the same standing.
335///
336/// A member is Trusted if ANY path clears:
337///  * **role** — the community granted them one. A cosmetic role is not
338///    immunity (that is `Protected`, and it keys on moderation permissions),
339///    but it is a vouch, so it earns the gate a regular gets.
340///  * **veteran** — long tenure plus any activity at all. Someone who has been
341///    here for months and still talks is not a raid.
342///  * **active** — tenure AND volume AND variety together, all `>=`. Never
343///    volume alone: five varied lines from a script must not buy immunity,
344///    which is why every path carries a tenure floor a fresh account cannot
345///    clear.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347pub struct TrustedBar {
348    /// Tenure floor for the active path.
349    pub tenure_secs: u64,
350    pub messages: u64,
351    pub distinct: u64,
352    /// Tenure that earns trust on its own, given any activity.
353    pub veteran_secs: u64,
354    /// Holding any role earns trust.
355    pub roles_trust: bool,
356}
357
358impl Default for TrustedBar {
359    fn default() -> Self {
360        TrustedBar {
361            tenure_secs: 7 * 24 * 3600,
362            messages: 5,
363            distinct: 3,
364            veteran_secs: 30 * 24 * 3600,
365            roles_trust: true,
366        }
367    }
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct Policy {
372    pub format: u32,
373    /// Must-understand keys: an engine that does not know one marks the policy
374    /// INERT rather than silently convicting differently under an identical
375    /// policy_hash.
376    #[serde(default, skip_serializing_if = "Vec::is_empty")]
377    pub requires: Vec<String>,
378    pub name: String,
379    /// Community-declared shortcodes: resolution is community-scoped, never
380    /// viewer-scoped, or two mods compute different skeletons.
381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
382    pub emoji_codes: Vec<String>,
383    #[serde(default)]
384    pub shields: Shields,
385    #[serde(default)]
386    pub window: Window,
387    #[serde(default)]
388    pub exempt: Exempt,
389    pub rules: Vec<Rule>,
390}
391
392pub const FORMAT: u32 = 1;
393
394/// Keys this engine understands in `requires`.
395const KNOWN_REQUIRED: &[&str] = &["emoji_codes", "shields", "window", "exempt", "repeat_within", "armed_by_any"];
396
397impl Policy {
398    /// Validate against the frozen caps and semantics. Returns the FIRST
399    /// failure in the §13.3 code order, as an `InertReason` — the same shape the
400    /// report carries, since an invalid policy evaluates nothing.
401    pub fn validate(&self) -> Result<(), InertReason> {
402        if self.format != FORMAT {
403            return Err(InertReason::UnknownFormat { found: self.format });
404        }
405        for key in &self.requires {
406            if !KNOWN_REQUIRED.contains(&key.as_str()) {
407                return Err(InertReason::UnknownRequiredKey { key: key.clone() });
408            }
409        }
410        self.check().map_err(|c| InertReason::ValidationFailed { code: c.to_string() })
411    }
412
413    fn check(&self) -> Result<(), &'static str> {
414        if self.rules.len() > caps::MAX_RULES_PER_POLICY || self.emoji_codes.len() > caps::MAX_EMOJI_CODES {
415            return Err(code::CAP_EXCEEDED);
416        }
417        if self.window.hours == 0
418            || self.window.hours > caps::WINDOW_MAX_HOURS
419            || self.window.max_messages == 0
420            || self.window.max_messages > caps::WINDOW_MAX_MESSAGES
421        {
422            return Err(code::WINDOW_OUT_OF_RANGE);
423        }
424        let mut seen: Vec<&str> = Vec::with_capacity(self.rules.len());
425        for r in &self.rules {
426            if !r.id.is_ascii() {
427                return Err(code::RULE_ID_NOT_ASCII);
428            }
429            if r.id.is_empty() || r.id.len() > caps::MAX_RULE_ID_LEN {
430                return Err(code::CAP_EXCEEDED);
431            }
432            if seen.contains(&r.id.as_str()) {
433                return Err(code::RULE_ID_DUPLICATE);
434            }
435            seen.push(&r.id);
436            self.check_rule(r)?;
437        }
438        // armed_by resolves within this policy, one level only.
439        for r in &self.rules {
440            let Some(a) = &r.armed_by else { continue };
441            // Every alternative is held to the same bar as the primary: a named
442            // rule that does not exist, or one that is itself armed, is a chain
443            // this engine refuses to resolve.
444            for id in std::iter::once(&a.rule).chain(a.also.iter()) {
445                let Some(target) = self.rules.iter().find(|t| &t.id == id) else {
446                    return Err(code::ARMED_BY_UNKNOWN_RULE);
447                };
448                if target.armed_by.is_some() {
449                    return Err(code::ARMED_BY_NESTED);
450                }
451            }
452            if a.scope == ArmScope::Subject && a.min_subjects.is_some() {
453                return Err(code::ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE);
454            }
455        }
456        Ok(())
457    }
458
459    fn check_rule(&self, r: &Rule) -> Result<(), &'static str> {
460        let m = &r.matcher;
461        if m.patterns().len() > caps::MAX_PATTERNS_PER_RULE
462            || m.patterns().iter().any(|p| p.chars().count() > caps::MAX_PATTERN_LEN)
463        {
464            return Err(code::CAP_EXCEEDED);
465        }
466        if let Match::Cohort { min, short_factor, thin_ratio, .. } = m {
467            if *min == 0 || *short_factor == 0 {
468                return Err(code::MISSING_REQUIRED_PARAMETER);
469            }
470            if thin_ratio.is_some_and(|t| t.denom == 0) {
471                return Err(code::MISSING_REQUIRED_PARAMETER);
472            }
473        }
474        if let Match::JoinBurst { gap_secs, min } = m {
475            if *gap_secs == 0 || *min == 0 {
476                return Err(code::MISSING_REQUIRED_PARAMETER);
477            }
478        }
479        if let Match::Rate { per_secs } = m {
480            if *per_secs == 0 {
481                return Err(code::MISSING_REQUIRED_PARAMETER);
482            }
483        }
484
485        // Tiers or direct form, never both; and each must be admissible.
486        let has_direct = r.severity.is_some() || r.weight.is_some();
487        let has_tiers = r.tiers.as_ref().is_some_and(|t| !t.per_message.is_empty() || !t.per_window.is_empty());
488        match (has_tiers, has_direct) {
489            (true, true) => return Err(code::TIERS_AND_DIRECT_FORM),
490            (false, false) => return Err(code::MISSING_REQUIRED_PARAMETER),
491            (false, true) => {
492                if !m.admits(Scope::Whole) {
493                    return Err(code::DIRECT_FORM_NOT_ADMISSIBLE);
494                }
495                let w = r.weight.ok_or(code::MISSING_REQUIRED_PARAMETER)?;
496                if !(caps::WEIGHT_MIN..=caps::WEIGHT_MAX).contains(&w) {
497                    return Err(code::WEIGHT_OUT_OF_RANGE);
498                }
499                if r.severity.is_none() {
500                    return Err(code::MISSING_REQUIRED_PARAMETER);
501                }
502                if r.pierces_trusted && r.severity != Some(Severity::Severe) {
503                    return Err(code::PIERCES_BELOW_SEVERE);
504                }
505            }
506            (true, false) => {
507                let t = r.tiers.as_ref().expect("has_tiers");
508                for (scope, rungs) in [(Scope::PerMessage, &t.per_message), (Scope::PerWindow, &t.per_window)] {
509                    if rungs.is_empty() {
510                        continue;
511                    }
512                    if !m.admits(scope) {
513                        return Err(code::SCOPE_NOT_ADMISSIBLE);
514                    }
515                    let mut prev = 0u32;
516                    for rung in rungs {
517                        if rung.hits <= prev {
518                            return Err(code::RUNG_ORDER_NOT_ASCENDING);
519                        }
520                        prev = rung.hits;
521                        if !(caps::WEIGHT_MIN..=caps::WEIGHT_MAX).contains(&rung.weight) {
522                            return Err(code::WEIGHT_OUT_OF_RANGE);
523                        }
524                        if rung.pierces_trusted && rung.severity != Severity::Severe {
525                            return Err(code::PIERCES_BELOW_SEVERE);
526                        }
527                    }
528                }
529                // Families exist for one-shot aggravators; allowing a tiered
530                // rule into a fold would contradict "both scope convictions
531                // enter the combinator".
532                if r.family.is_some() {
533                    return Err(code::FAMILY_ON_TIERED_RULE);
534                }
535            }
536        }
537
538        // Built-in family tags are immutable: the fold is what stops correlated
539        // proxies over-convicting newcomers.
540        if let Some(builtin) = m.builtin_family() {
541            if r.family.as_deref().is_some_and(|f| f != builtin) {
542                return Err(code::FAMILY_REASSIGNED_BUILTIN);
543            }
544        }
545
546        // `skeleton` deletes separators, so word boundaries and domain
547        // exemptions have nothing to anchor on.
548        if let Some(n) = m.normalize() {
549            if n.strips_separators() {
550                if matches!(m, Match::Regex { boundary_word: true, .. }) {
551                    return Err(code::BOUNDARY_ON_STRIPPING_NORMALIZER);
552                }
553                if matches!(m, Match::Keyword { patterns, .. } if patterns.iter().any(|p| !is_double_wildcard(p))) {
554                    return Err(code::BOUNDARY_ON_STRIPPING_NORMALIZER);
555                }
556                let domain_exempt = r
557                    .exempt
558                    .patterns
559                    .iter()
560                    .chain(self.exempt.patterns.iter())
561                    .any(|p| p.kind == Some(ExemptKind::Domain));
562                if domain_exempt {
563                    return Err(code::DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER);
564                }
565            }
566        }
567
568        if r.enforcement == Enforcement::Refuse {
569            if m.basis() != Basis::Deterministic {
570                return Err(code::REFUSE_ON_HEURISTIC);
571            }
572            // Breadth is capped STATICALLY: a corpus dry-run would be
573            // viewer-local and time-varying, so the same bytes would validate
574            // for the author and fail for a mod who joined yesterday.
575            match m {
576                Match::Keyword { patterns, .. } | Match::Regex { patterns, .. }
577                    if patterns.iter().any(|p| min_branch_literals(p) < caps::REFUSE_MIN_LITERAL_CHARS) =>
578                {
579                    return Err(code::REFUSE_TOO_BROAD);
580                }
581                // Rule types whose rung 1 is satisfiable by an arbitrary message
582                // may not refuse unless they narrow it. An allowlist-shaped
583                // exemption IS a filter (the Strict Link Blocker's shape).
584                Match::Link { patterns } => {
585                    let narrowed = !patterns.is_empty()
586                        || r.exempt.patterns.iter().chain(self.exempt.patterns.iter()).any(|p| !p.values.is_empty());
587                    if !narrowed {
588                        return Err(code::REFUSE_ON_UNNARROWED_RULE);
589                    }
590                }
591                Match::Rate { .. } | Match::Repeat { .. } | Match::Mentions {} => {
592                    let rung1_needs_more_than_one = r
593                        .tiers
594                        .as_ref()
595                        .and_then(|t| t.per_window.first().or(t.per_message.first()))
596                        .is_some_and(|g| g.hits > 1);
597                    if !rung1_needs_more_than_one {
598                        return Err(code::REFUSE_ON_UNNARROWED_RULE);
599                    }
600                }
601                _ => {}
602            }
603        }
604        Ok(())
605    }
606}
607
608/// `*word*` — the only keyword form `skeleton` can match, since it deletes the
609/// separators a token anchor needs.
610fn is_double_wildcard(p: &str) -> bool {
611    let unescaped_star = |s: &str, at_start: bool| {
612        if at_start {
613            s.starts_with('*')
614        } else {
615            s.ends_with('*') && !s.ends_with("\\*")
616        }
617    };
618    unescaped_star(p, true) && unescaped_star(p, false) && p.chars().count() > 2
619}
620
621/// Literal characters required by the THINNEST alternation branch. A
622/// whole-pattern count passes `abcd|.*`, which mutes a community.
623fn min_branch_literals(pattern: &str) -> usize {
624    pattern
625        .split('|')
626        .map(|branch| {
627            let mut n = 0usize;
628            let mut chars = branch.chars().peekable();
629            while let Some(c) = chars.next() {
630                match c {
631                    '\\' => {
632                        // An escaped literal counts once; a shorthand class does not.
633                        if let Some(next) = chars.next() {
634                            if !next.is_ascii_alphanumeric() {
635                                n += 1;
636                            }
637                        }
638                    }
639                    '[' => {
640                        for c2 in chars.by_ref() {
641                            if c2 == ']' {
642                                break;
643                            }
644                        }
645                    }
646                    '.' | '*' | '+' | '?' | '(' | ')' | '{' | '}' | '^' | '$' => {}
647                    _ => n += 1,
648                }
649            }
650            n
651        })
652        .min()
653        .unwrap_or(0)
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    fn base(rules: Vec<Rule>) -> Policy {
661        Policy {
662            format: FORMAT,
663            requires: vec![],
664            name: "p".into(),
665            emoji_codes: vec![],
666            shields: Shields::default(),
667            window: Window::default(),
668            exempt: Exempt::default(),
669            rules,
670        }
671    }
672
673    fn keyword_rule() -> Rule {
674        Rule {
675            id: "swears".into(),
676            matcher: Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Fold },
677            tiers: Some(Tiers {
678                per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
679                per_window: vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: true }],
680            }),
681            severity: None,
682            weight: None,
683            pierces_trusted: false,
684            family: None,
685            armed_by: None,
686            exempt: Exempt::default(),
687            enforcement: Enforcement::Advisory,
688        }
689    }
690
691    fn cohort_rule() -> Rule {
692        Rule {
693            id: "cohort".into(),
694            matcher: Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
695            tiers: None,
696            severity: Some(Severity::Severe),
697            weight: Some(85),
698            pierces_trusted: false,
699            family: None,
700            armed_by: None,
701            exempt: Exempt::default(),
702            enforcement: Enforcement::Advisory,
703        }
704    }
705
706    fn err_code(p: &Policy) -> String {
707        match p.validate() {
708            Err(InertReason::ValidationFailed { code }) => code,
709            other => panic!("expected a validation failure, got {other:?}"),
710        }
711    }
712
713    #[test]
714    fn a_well_formed_policy_validates() {
715        assert!(base(vec![keyword_rule(), cohort_rule()]).validate().is_ok());
716    }
717
718    #[test]
719    fn format_and_required_keys_make_a_policy_inert_not_merely_invalid() {
720        let mut p = base(vec![keyword_rule()]);
721        p.format = 2;
722        assert!(matches!(p.validate(), Err(InertReason::UnknownFormat { found: 2 })));
723
724        let mut p = base(vec![keyword_rule()]);
725        p.requires = vec!["quarantine".into()];
726        assert!(matches!(p.validate(), Err(InertReason::UnknownRequiredKey { key }) if key == "quarantine"));
727    }
728
729    #[test]
730    fn weights_and_rungs_are_policed() {
731        let mut p = base(vec![keyword_rule()]);
732        p.rules[0].tiers.as_mut().unwrap().per_message[0].weight = 100;
733        assert_eq!(err_code(&p), code::WEIGHT_OUT_OF_RANGE, "100 is reserved");
734
735        let mut p = base(vec![keyword_rule()]);
736        p.rules[0].tiers.as_mut().unwrap().per_window =
737            vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: false },
738                 Rung { hits: 3, severity: Severity::Severe, weight: 80, pierces_trusted: false }];
739        assert_eq!(err_code(&p), code::RUNG_ORDER_NOT_ASCENDING, "'highest rung reached' needs an order");
740
741        let mut p = base(vec![keyword_rule()]);
742        p.rules[0].tiers.as_mut().unwrap().per_message[0].pierces_trusted = true;
743        assert_eq!(err_code(&p), code::PIERCES_BELOW_SEVERE, "only Severe may pierce");
744    }
745
746    #[test]
747    fn scope_and_form_admissibility() {
748        // repeat is window-level: per_message would double-count one fact.
749        let mut r = keyword_rule();
750        r.matcher = Match::Repeat { normalize: Normalize::Fold, within_secs: None };
751        let p = base(vec![r]);
752        assert_eq!(err_code(&p), code::SCOPE_NOT_ADMISSIBLE);
753
754        // A keyword rule cannot become single-rung by declaring weights directly.
755        let mut r = keyword_rule();
756        r.tiers = None;
757        r.severity = Some(Severity::Major);
758        r.weight = Some(40);
759        let p = base(vec![r]);
760        assert_eq!(err_code(&p), code::DIRECT_FORM_NOT_ADMISSIBLE);
761
762        // Both forms at once is ambiguous.
763        let mut r = keyword_rule();
764        r.severity = Some(Severity::Major);
765        let p = base(vec![r]);
766        assert_eq!(err_code(&p), code::TIERS_AND_DIRECT_FORM);
767    }
768
769    #[test]
770    fn families_are_immutable_on_builtins_and_banned_on_ladders() {
771        let mut r = keyword_rule();
772        r.family = Some("mine".into());
773        assert_eq!(err_code(&base(vec![r])), code::FAMILY_ON_TIERED_RULE);
774
775        let tenure = Rule {
776            id: "tenure".into(),
777            matcher: Match::TenureLt { secs: 86_400 },
778            tiers: None,
779            severity: Some(Severity::Notice),
780            weight: Some(20),
781            pierces_trusted: false,
782            family: Some("not-fresh-account".into()),
783            armed_by: None,
784            exempt: Exempt::default(),
785            enforcement: Enforcement::Advisory,
786        };
787        assert_eq!(err_code(&base(vec![tenure])), code::FAMILY_REASSIGNED_BUILTIN);
788    }
789
790    #[test]
791    fn refuse_is_deterministic_only_and_narrow() {
792        // A heuristic rule may never refuse a send.
793        let mut r = cohort_rule();
794        r.enforcement = Enforcement::Refuse;
795        assert_eq!(err_code(&base(vec![r])), code::REFUSE_ON_HEURISTIC);
796
797        // Per-branch literal counting: the whole-pattern count passes `abcd|.*`,
798        // which mutes a community.
799        let mut r = keyword_rule();
800        r.matcher = Match::Regex { patterns: vec!["abcd|.*".into()], normalize: Normalize::Fold, boundary_word: false };
801        r.enforcement = Enforcement::Refuse;
802        assert_eq!(err_code(&base(vec![r])), code::REFUSE_TOO_BROAD);
803
804        // A bare link rule bans every link; an allowlist narrows it and passes.
805        let mut r = keyword_rule();
806        r.matcher = Match::Link { patterns: vec![] };
807        r.enforcement = Enforcement::Refuse;
808        assert_eq!(err_code(&base(vec![r.clone()])), code::REFUSE_ON_UNNARROWED_RULE);
809        r.exempt = Exempt {
810            patterns: vec![ExemptPatterns { kind: Some(ExemptKind::Domain), values: vec!["vectorapp.io".into()] }],
811            ..Default::default()
812        };
813        assert!(base(vec![r]).validate().is_ok(), "an allowlist IS a filter");
814    }
815
816    #[test]
817    fn skeleton_refuses_anchors_it_cannot_honour() {
818        let mut r = keyword_rule();
819        r.matcher = Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Skeleton };
820        assert_eq!(err_code(&base(vec![r.clone()])), code::BOUNDARY_ON_STRIPPING_NORMALIZER, "bare pattern needs separators");
821
822        r.matcher = Match::Keyword { patterns: vec!["*darn*".into()], normalize: Normalize::Skeleton };
823        assert!(base(vec![r.clone()]).validate().is_ok(), "*word* is the only skeleton-safe form");
824
825        r.exempt = Exempt {
826            patterns: vec![ExemptPatterns { kind: Some(ExemptKind::Domain), values: vec!["a.io".into()] }],
827            ..Default::default()
828        };
829        assert_eq!(err_code(&base(vec![r])), code::DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER);
830    }
831
832    #[test]
833    fn armed_by_resolves_one_level_within_the_policy() {
834        let mut burst = Rule {
835            id: "burst".into(),
836            matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
837            tiers: None,
838            severity: Some(Severity::Major),
839            weight: Some(40),
840            pierces_trusted: false,
841            family: None,
842            armed_by: Some(ArmedBy { rule: "nope".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] }),
843            exempt: Exempt::default(),
844            enforcement: Enforcement::Advisory,
845        };
846        assert_eq!(err_code(&base(vec![cohort_rule(), burst.clone()])), code::ARMED_BY_UNKNOWN_RULE);
847
848        burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Subject, min_subjects: Some(3), also: vec![] });
849        assert_eq!(
850            err_code(&base(vec![cohort_rule(), burst.clone()])),
851            code::ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE,
852            "a subject-scoped arm has no count to threshold"
853        );
854
855        burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
856        assert!(base(vec![cohort_rule(), burst]).validate().is_ok());
857    }
858
859    #[test]
860    fn caps_and_ids_are_enforced() {
861        let mut p = base(vec![keyword_rule(), keyword_rule()]);
862        assert_eq!(err_code(&p), code::RULE_ID_DUPLICATE);
863
864        p = base(vec![keyword_rule()]);
865        p.window.hours = caps::WINDOW_MAX_HOURS + 1;
866        assert_eq!(err_code(&p), code::WINDOW_OUT_OF_RANGE);
867
868        p = base(vec![keyword_rule()]);
869        p.rules[0].id = "sw€ars".into();
870        assert_eq!(err_code(&p), code::RULE_ID_NOT_ASCII, "rule_id enters every id preimage");
871    }
872
873    #[test]
874    fn min_branch_literals_counts_the_thinnest_branch() {
875        assert_eq!(min_branch_literals("scam"), 4);
876        assert_eq!(min_branch_literals("abcd|.*"), 0, "the escape hatch branch decides");
877        assert_eq!(min_branch_literals("[a-z]+word"), 4, "classes contribute nothing");
878        assert_eq!(min_branch_literals(r"\*sale"), 5, "an escaped literal counts");
879    }
880}