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, 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
370impl Default for Shields {
371    fn default() -> Self {
372        Shields { trusted: TrustedBar::default() }
373    }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377pub struct Policy {
378    pub format: u32,
379    /// Must-understand keys: an engine that does not know one marks the policy
380    /// INERT rather than silently convicting differently under an identical
381    /// policy_hash.
382    #[serde(default, skip_serializing_if = "Vec::is_empty")]
383    pub requires: Vec<String>,
384    pub name: String,
385    /// Community-declared shortcodes: resolution is community-scoped, never
386    /// viewer-scoped, or two mods compute different skeletons.
387    #[serde(default, skip_serializing_if = "Vec::is_empty")]
388    pub emoji_codes: Vec<String>,
389    #[serde(default)]
390    pub shields: Shields,
391    #[serde(default)]
392    pub window: Window,
393    #[serde(default)]
394    pub exempt: Exempt,
395    pub rules: Vec<Rule>,
396}
397
398pub const FORMAT: u32 = 1;
399
400/// Keys this engine understands in `requires`.
401const KNOWN_REQUIRED: &[&str] = &["emoji_codes", "shields", "window", "exempt", "repeat_within", "armed_by_any"];
402
403impl Policy {
404    /// Validate against the frozen caps and semantics. Returns the FIRST
405    /// failure in the §13.3 code order, as an `InertReason` — the same shape the
406    /// report carries, since an invalid policy evaluates nothing.
407    pub fn validate(&self) -> Result<(), InertReason> {
408        if self.format != FORMAT {
409            return Err(InertReason::UnknownFormat { found: self.format });
410        }
411        for key in &self.requires {
412            if !KNOWN_REQUIRED.contains(&key.as_str()) {
413                return Err(InertReason::UnknownRequiredKey { key: key.clone() });
414            }
415        }
416        self.check().map_err(|c| InertReason::ValidationFailed { code: c.to_string() })
417    }
418
419    fn check(&self) -> Result<(), &'static str> {
420        if self.rules.len() > caps::MAX_RULES_PER_POLICY || self.emoji_codes.len() > caps::MAX_EMOJI_CODES {
421            return Err(code::CAP_EXCEEDED);
422        }
423        if self.window.hours == 0
424            || self.window.hours > caps::WINDOW_MAX_HOURS
425            || self.window.max_messages == 0
426            || self.window.max_messages > caps::WINDOW_MAX_MESSAGES
427        {
428            return Err(code::WINDOW_OUT_OF_RANGE);
429        }
430        let mut seen: Vec<&str> = Vec::with_capacity(self.rules.len());
431        for r in &self.rules {
432            if !r.id.is_ascii() {
433                return Err(code::RULE_ID_NOT_ASCII);
434            }
435            if r.id.is_empty() || r.id.len() > caps::MAX_RULE_ID_LEN {
436                return Err(code::CAP_EXCEEDED);
437            }
438            if seen.contains(&r.id.as_str()) {
439                return Err(code::RULE_ID_DUPLICATE);
440            }
441            seen.push(&r.id);
442            self.check_rule(r)?;
443        }
444        // armed_by resolves within this policy, one level only.
445        for r in &self.rules {
446            let Some(a) = &r.armed_by else { continue };
447            // Every alternative is held to the same bar as the primary: a named
448            // rule that does not exist, or one that is itself armed, is a chain
449            // this engine refuses to resolve.
450            for id in std::iter::once(&a.rule).chain(a.also.iter()) {
451                let Some(target) = self.rules.iter().find(|t| &t.id == id) else {
452                    return Err(code::ARMED_BY_UNKNOWN_RULE);
453                };
454                if target.armed_by.is_some() {
455                    return Err(code::ARMED_BY_NESTED);
456                }
457            }
458            if a.scope == ArmScope::Subject && a.min_subjects.is_some() {
459                return Err(code::ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE);
460            }
461        }
462        Ok(())
463    }
464
465    fn check_rule(&self, r: &Rule) -> Result<(), &'static str> {
466        let m = &r.matcher;
467        if m.patterns().len() > caps::MAX_PATTERNS_PER_RULE
468            || m.patterns().iter().any(|p| p.chars().count() > caps::MAX_PATTERN_LEN)
469        {
470            return Err(code::CAP_EXCEEDED);
471        }
472        if let Match::Cohort { min, short_factor, thin_ratio, .. } = m {
473            if *min == 0 || *short_factor == 0 {
474                return Err(code::MISSING_REQUIRED_PARAMETER);
475            }
476            if thin_ratio.is_some_and(|t| t.denom == 0) {
477                return Err(code::MISSING_REQUIRED_PARAMETER);
478            }
479        }
480        if let Match::JoinBurst { gap_secs, min } = m {
481            if *gap_secs == 0 || *min == 0 {
482                return Err(code::MISSING_REQUIRED_PARAMETER);
483            }
484        }
485        if let Match::Rate { per_secs } = m {
486            if *per_secs == 0 {
487                return Err(code::MISSING_REQUIRED_PARAMETER);
488            }
489        }
490
491        // Tiers or direct form, never both; and each must be admissible.
492        let has_direct = r.severity.is_some() || r.weight.is_some();
493        let has_tiers = r.tiers.as_ref().is_some_and(|t| !t.per_message.is_empty() || !t.per_window.is_empty());
494        match (has_tiers, has_direct) {
495            (true, true) => return Err(code::TIERS_AND_DIRECT_FORM),
496            (false, false) => return Err(code::MISSING_REQUIRED_PARAMETER),
497            (false, true) => {
498                if !m.admits(Scope::Whole) {
499                    return Err(code::DIRECT_FORM_NOT_ADMISSIBLE);
500                }
501                let w = r.weight.ok_or(code::MISSING_REQUIRED_PARAMETER)?;
502                if !(caps::WEIGHT_MIN..=caps::WEIGHT_MAX).contains(&w) {
503                    return Err(code::WEIGHT_OUT_OF_RANGE);
504                }
505                if r.severity.is_none() {
506                    return Err(code::MISSING_REQUIRED_PARAMETER);
507                }
508                if r.pierces_trusted && r.severity != Some(Severity::Severe) {
509                    return Err(code::PIERCES_BELOW_SEVERE);
510                }
511            }
512            (true, false) => {
513                let t = r.tiers.as_ref().expect("has_tiers");
514                for (scope, rungs) in [(Scope::PerMessage, &t.per_message), (Scope::PerWindow, &t.per_window)] {
515                    if rungs.is_empty() {
516                        continue;
517                    }
518                    if !m.admits(scope) {
519                        return Err(code::SCOPE_NOT_ADMISSIBLE);
520                    }
521                    let mut prev = 0u32;
522                    for rung in rungs {
523                        if rung.hits <= prev {
524                            return Err(code::RUNG_ORDER_NOT_ASCENDING);
525                        }
526                        prev = rung.hits;
527                        if !(caps::WEIGHT_MIN..=caps::WEIGHT_MAX).contains(&rung.weight) {
528                            return Err(code::WEIGHT_OUT_OF_RANGE);
529                        }
530                        if rung.pierces_trusted && rung.severity != Severity::Severe {
531                            return Err(code::PIERCES_BELOW_SEVERE);
532                        }
533                    }
534                }
535                // Families exist for one-shot aggravators; allowing a tiered
536                // rule into a fold would contradict "both scope convictions
537                // enter the combinator".
538                if r.family.is_some() {
539                    return Err(code::FAMILY_ON_TIERED_RULE);
540                }
541            }
542        }
543
544        // Built-in family tags are immutable: the fold is what stops correlated
545        // proxies over-convicting newcomers.
546        if let Some(builtin) = m.builtin_family() {
547            if r.family.as_deref().is_some_and(|f| f != builtin) {
548                return Err(code::FAMILY_REASSIGNED_BUILTIN);
549            }
550        }
551
552        // `skeleton` deletes separators, so word boundaries and domain
553        // exemptions have nothing to anchor on.
554        if let Some(n) = m.normalize() {
555            if n.strips_separators() {
556                if matches!(m, Match::Regex { boundary_word: true, .. }) {
557                    return Err(code::BOUNDARY_ON_STRIPPING_NORMALIZER);
558                }
559                if matches!(m, Match::Keyword { patterns, .. } if patterns.iter().any(|p| !is_double_wildcard(p))) {
560                    return Err(code::BOUNDARY_ON_STRIPPING_NORMALIZER);
561                }
562                let domain_exempt = r
563                    .exempt
564                    .patterns
565                    .iter()
566                    .chain(self.exempt.patterns.iter())
567                    .any(|p| p.kind == Some(ExemptKind::Domain));
568                if domain_exempt {
569                    return Err(code::DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER);
570                }
571            }
572        }
573
574        if r.enforcement == Enforcement::Refuse {
575            if m.basis() != Basis::Deterministic {
576                return Err(code::REFUSE_ON_HEURISTIC);
577            }
578            // Breadth is capped STATICALLY: a corpus dry-run would be
579            // viewer-local and time-varying, so the same bytes would validate
580            // for the author and fail for a mod who joined yesterday.
581            match m {
582                Match::Keyword { patterns, .. } | Match::Regex { patterns, .. } => {
583                    if patterns.iter().any(|p| min_branch_literals(p) < caps::REFUSE_MIN_LITERAL_CHARS) {
584                        return Err(code::REFUSE_TOO_BROAD);
585                    }
586                }
587                // Rule types whose rung 1 is satisfiable by an arbitrary message
588                // may not refuse unless they narrow it. An allowlist-shaped
589                // exemption IS a filter (the Strict Link Blocker's shape).
590                Match::Link { patterns } => {
591                    let narrowed = !patterns.is_empty()
592                        || r.exempt.patterns.iter().chain(self.exempt.patterns.iter()).any(|p| !p.values.is_empty());
593                    if !narrowed {
594                        return Err(code::REFUSE_ON_UNNARROWED_RULE);
595                    }
596                }
597                Match::Rate { .. } | Match::Repeat { .. } | Match::Mentions {} => {
598                    let rung1_needs_more_than_one = r
599                        .tiers
600                        .as_ref()
601                        .and_then(|t| t.per_window.first().or(t.per_message.first()))
602                        .is_some_and(|g| g.hits > 1);
603                    if !rung1_needs_more_than_one {
604                        return Err(code::REFUSE_ON_UNNARROWED_RULE);
605                    }
606                }
607                _ => {}
608            }
609        }
610        Ok(())
611    }
612}
613
614/// `*word*` — the only keyword form `skeleton` can match, since it deletes the
615/// separators a token anchor needs.
616fn is_double_wildcard(p: &str) -> bool {
617    let unescaped_star = |s: &str, at_start: bool| {
618        if at_start {
619            s.starts_with('*')
620        } else {
621            s.ends_with('*') && !s.ends_with("\\*")
622        }
623    };
624    unescaped_star(p, true) && unescaped_star(p, false) && p.chars().count() > 2
625}
626
627/// Literal characters required by the THINNEST alternation branch. A
628/// whole-pattern count passes `abcd|.*`, which mutes a community.
629fn min_branch_literals(pattern: &str) -> usize {
630    pattern
631        .split('|')
632        .map(|branch| {
633            let mut n = 0usize;
634            let mut chars = branch.chars().peekable();
635            while let Some(c) = chars.next() {
636                match c {
637                    '\\' => {
638                        // An escaped literal counts once; a shorthand class does not.
639                        if let Some(next) = chars.next() {
640                            if !next.is_ascii_alphanumeric() {
641                                n += 1;
642                            }
643                        }
644                    }
645                    '[' => {
646                        for c2 in chars.by_ref() {
647                            if c2 == ']' {
648                                break;
649                            }
650                        }
651                    }
652                    '.' | '*' | '+' | '?' | '(' | ')' | '{' | '}' | '^' | '$' => {}
653                    _ => n += 1,
654                }
655            }
656            n
657        })
658        .min()
659        .unwrap_or(0)
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    fn base(rules: Vec<Rule>) -> Policy {
667        Policy {
668            format: FORMAT,
669            requires: vec![],
670            name: "p".into(),
671            emoji_codes: vec![],
672            shields: Shields::default(),
673            window: Window::default(),
674            exempt: Exempt::default(),
675            rules,
676        }
677    }
678
679    fn keyword_rule() -> Rule {
680        Rule {
681            id: "swears".into(),
682            matcher: Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Fold },
683            tiers: Some(Tiers {
684                per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
685                per_window: vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: true }],
686            }),
687            severity: None,
688            weight: None,
689            pierces_trusted: false,
690            family: None,
691            armed_by: None,
692            exempt: Exempt::default(),
693            enforcement: Enforcement::Advisory,
694        }
695    }
696
697    fn cohort_rule() -> Rule {
698        Rule {
699            id: "cohort".into(),
700            matcher: Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
701            tiers: None,
702            severity: Some(Severity::Severe),
703            weight: Some(85),
704            pierces_trusted: false,
705            family: None,
706            armed_by: None,
707            exempt: Exempt::default(),
708            enforcement: Enforcement::Advisory,
709        }
710    }
711
712    fn err_code(p: &Policy) -> String {
713        match p.validate() {
714            Err(InertReason::ValidationFailed { code }) => code,
715            other => panic!("expected a validation failure, got {other:?}"),
716        }
717    }
718
719    #[test]
720    fn a_well_formed_policy_validates() {
721        assert!(base(vec![keyword_rule(), cohort_rule()]).validate().is_ok());
722    }
723
724    #[test]
725    fn format_and_required_keys_make_a_policy_inert_not_merely_invalid() {
726        let mut p = base(vec![keyword_rule()]);
727        p.format = 2;
728        assert!(matches!(p.validate(), Err(InertReason::UnknownFormat { found: 2 })));
729
730        let mut p = base(vec![keyword_rule()]);
731        p.requires = vec!["quarantine".into()];
732        assert!(matches!(p.validate(), Err(InertReason::UnknownRequiredKey { key }) if key == "quarantine"));
733    }
734
735    #[test]
736    fn weights_and_rungs_are_policed() {
737        let mut p = base(vec![keyword_rule()]);
738        p.rules[0].tiers.as_mut().unwrap().per_message[0].weight = 100;
739        assert_eq!(err_code(&p), code::WEIGHT_OUT_OF_RANGE, "100 is reserved");
740
741        let mut p = base(vec![keyword_rule()]);
742        p.rules[0].tiers.as_mut().unwrap().per_window =
743            vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: false },
744                 Rung { hits: 3, severity: Severity::Severe, weight: 80, pierces_trusted: false }];
745        assert_eq!(err_code(&p), code::RUNG_ORDER_NOT_ASCENDING, "'highest rung reached' needs an order");
746
747        let mut p = base(vec![keyword_rule()]);
748        p.rules[0].tiers.as_mut().unwrap().per_message[0].pierces_trusted = true;
749        assert_eq!(err_code(&p), code::PIERCES_BELOW_SEVERE, "only Severe may pierce");
750    }
751
752    #[test]
753    fn scope_and_form_admissibility() {
754        // repeat is window-level: per_message would double-count one fact.
755        let mut r = keyword_rule();
756        r.matcher = Match::Repeat { normalize: Normalize::Fold, within_secs: None };
757        let p = base(vec![r]);
758        assert_eq!(err_code(&p), code::SCOPE_NOT_ADMISSIBLE);
759
760        // A keyword rule cannot become single-rung by declaring weights directly.
761        let mut r = keyword_rule();
762        r.tiers = None;
763        r.severity = Some(Severity::Major);
764        r.weight = Some(40);
765        let p = base(vec![r]);
766        assert_eq!(err_code(&p), code::DIRECT_FORM_NOT_ADMISSIBLE);
767
768        // Both forms at once is ambiguous.
769        let mut r = keyword_rule();
770        r.severity = Some(Severity::Major);
771        let p = base(vec![r]);
772        assert_eq!(err_code(&p), code::TIERS_AND_DIRECT_FORM);
773    }
774
775    #[test]
776    fn families_are_immutable_on_builtins_and_banned_on_ladders() {
777        let mut r = keyword_rule();
778        r.family = Some("mine".into());
779        assert_eq!(err_code(&base(vec![r])), code::FAMILY_ON_TIERED_RULE);
780
781        let tenure = Rule {
782            id: "tenure".into(),
783            matcher: Match::TenureLt { secs: 86_400 },
784            tiers: None,
785            severity: Some(Severity::Notice),
786            weight: Some(20),
787            pierces_trusted: false,
788            family: Some("not-fresh-account".into()),
789            armed_by: None,
790            exempt: Exempt::default(),
791            enforcement: Enforcement::Advisory,
792        };
793        assert_eq!(err_code(&base(vec![tenure])), code::FAMILY_REASSIGNED_BUILTIN);
794    }
795
796    #[test]
797    fn refuse_is_deterministic_only_and_narrow() {
798        // A heuristic rule may never refuse a send.
799        let mut r = cohort_rule();
800        r.enforcement = Enforcement::Refuse;
801        assert_eq!(err_code(&base(vec![r])), code::REFUSE_ON_HEURISTIC);
802
803        // Per-branch literal counting: the whole-pattern count passes `abcd|.*`,
804        // which mutes a community.
805        let mut r = keyword_rule();
806        r.matcher = Match::Regex { patterns: vec!["abcd|.*".into()], normalize: Normalize::Fold, boundary_word: false };
807        r.enforcement = Enforcement::Refuse;
808        assert_eq!(err_code(&base(vec![r])), code::REFUSE_TOO_BROAD);
809
810        // A bare link rule bans every link; an allowlist narrows it and passes.
811        let mut r = keyword_rule();
812        r.matcher = Match::Link { patterns: vec![] };
813        r.enforcement = Enforcement::Refuse;
814        assert_eq!(err_code(&base(vec![r.clone()])), code::REFUSE_ON_UNNARROWED_RULE);
815        r.exempt = Exempt {
816            patterns: vec![ExemptPatterns { kind: Some(ExemptKind::Domain), values: vec!["vectorapp.io".into()] }],
817            ..Default::default()
818        };
819        assert!(base(vec![r]).validate().is_ok(), "an allowlist IS a filter");
820    }
821
822    #[test]
823    fn skeleton_refuses_anchors_it_cannot_honour() {
824        let mut r = keyword_rule();
825        r.matcher = Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Skeleton };
826        assert_eq!(err_code(&base(vec![r.clone()])), code::BOUNDARY_ON_STRIPPING_NORMALIZER, "bare pattern needs separators");
827
828        r.matcher = Match::Keyword { patterns: vec!["*darn*".into()], normalize: Normalize::Skeleton };
829        assert!(base(vec![r.clone()]).validate().is_ok(), "*word* is the only skeleton-safe form");
830
831        r.exempt = Exempt {
832            patterns: vec![ExemptPatterns { kind: Some(ExemptKind::Domain), values: vec!["a.io".into()] }],
833            ..Default::default()
834        };
835        assert_eq!(err_code(&base(vec![r])), code::DOMAIN_EXEMPT_ON_STRIPPING_NORMALIZER);
836    }
837
838    #[test]
839    fn armed_by_resolves_one_level_within_the_policy() {
840        let mut burst = Rule {
841            id: "burst".into(),
842            matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
843            tiers: None,
844            severity: Some(Severity::Major),
845            weight: Some(40),
846            pierces_trusted: false,
847            family: None,
848            armed_by: Some(ArmedBy { rule: "nope".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] }),
849            exempt: Exempt::default(),
850            enforcement: Enforcement::Advisory,
851        };
852        assert_eq!(err_code(&base(vec![cohort_rule(), burst.clone()])), code::ARMED_BY_UNKNOWN_RULE);
853
854        burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Subject, min_subjects: Some(3), also: vec![] });
855        assert_eq!(
856            err_code(&base(vec![cohort_rule(), burst.clone()])),
857            code::ARMED_BY_MIN_SUBJECTS_WITH_SUBJECT_SCOPE,
858            "a subject-scoped arm has no count to threshold"
859        );
860
861        burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
862        assert!(base(vec![cohort_rule(), burst]).validate().is_ok());
863    }
864
865    #[test]
866    fn caps_and_ids_are_enforced() {
867        let mut p = base(vec![keyword_rule(), keyword_rule()]);
868        assert_eq!(err_code(&p), code::RULE_ID_DUPLICATE);
869
870        p = base(vec![keyword_rule()]);
871        p.window.hours = caps::WINDOW_MAX_HOURS + 1;
872        assert_eq!(err_code(&p), code::WINDOW_OUT_OF_RANGE);
873
874        p = base(vec![keyword_rule()]);
875        p.rules[0].id = "sw€ars".into();
876        assert_eq!(err_code(&p), code::RULE_ID_NOT_ASCII, "rule_id enters every id preimage");
877    }
878
879    #[test]
880    fn min_branch_literals_counts_the_thinnest_branch() {
881        assert_eq!(min_branch_literals("scam"), 4);
882        assert_eq!(min_branch_literals("abcd|.*"), 0, "the escape hatch branch decides");
883        assert_eq!(min_branch_literals("[a-z]+word"), 4, "classes contribute nothing");
884        assert_eq!(min_branch_literals(r"\*sale"), 5, "an escaped literal counts");
885    }
886}