Skip to main content

cairn_mod/labels/
policy.rs

1//! Label emission policy (§F21, #58).
2//!
3//! Operator-config surface that drives v1.5's label emission. The
4//! policy is loaded once at config-load time from `[label_emission]`
5//! and is read-only thereafter — operators restart `cairn serve` to
6//! change the mapping, matching `[moderation_reasons]` (#47) and
7//! `[strike_policy]` (#48) posture.
8//!
9//! # Defaults
10//!
11//! Public-cairn-mod's baseline:
12//!
13//! - `enabled = true` — emission is on.
14//! - `emit_reason_labels = true` — `reason-<code>` labels emitted
15//!   alongside action labels.
16//! - `warning_emits_label = false` — warnings are typically
17//!   advisory; operators opt in to surface them.
18//! - `reason_label_prefix = "reason-"`.
19//! - Per-action-type label mapping:
20//!
21//! | Action type        | Default `val` | Default severity | Notes |
22//! |--------------------|---------------|------------------|-------|
23//! | `takedown`         | `!takedown`   | `alert`          | ATProto global |
24//! | `indef_suspension` | `!hide`       | `alert`          | ATProto global |
25//! | `temp_suspension`  | `!hide`       | `alert`          | `exp` set per #63 |
26//! | `warning`          | `!warn`       | `inform`         | only when `warning_emits_label = true` |
27//! | `note`             | (none)        | (none)           | never emits, no opt-in |
28//!
29//! # Resolution rules
30//!
31//! [`LabelEmissionPolicy::resolve_action_label`]:
32//!
33//! 1. `note` → always `None`. There is no operator opt-in path
34//!    for note emission; notes are forensic context, not protocol-
35//!    visible moderation.
36//! 2. `warning` → `None` unless `warning_emits_label = true`.
37//! 3. Otherwise → `Some(spec)` where `spec` comes from:
38//!    - `action_label_overrides[action_type]` if the operator
39//!      declared a full override; or
40//!    - The shipped default for that action type, with
41//!      `severity_overrides[action_type]` applied if present.
42//!
43//! When `action_label_overrides` has an entry, its severity wins —
44//! `severity_overrides` is the lighter-weight knob for "tweak
45//! severity without changing val."
46//!
47//! [`LabelEmissionPolicy::resolve_reason_label_value`] always
48//! returns the prefix-applied value. Whether to actually emit is
49//! the consumer's decision (gated on `emit_reason_labels`).
50//!
51//! # Customization across deployments
52//!
53//! The same code path serves Hideaway, Northsky, Eurosky, and the
54//! generic public deployment. Per-deployment differences live in
55//! `[label_emission]` config values:
56//!
57//! - Hideaway sets `action_label_overrides.takedown =
58//!   { val = "!hideaway-takedown", ... }` to brand its labels.
59//! - A deployment that wants visible warnings flips
60//!   `warning_emits_label = true`.
61//! - A deployment with custom reason vocabulary keeps
62//!   `reason_label_prefix = "reason-"` so cairn-mod's default
63//!   reason-decoding posture works across the fleet.
64//!
65//! Cairn-mod's code path is the same for all of them; the
66//! deployment-specific behavior emerges from the deployment-
67//! specific config.
68
69use std::collections::{BTreeMap, BTreeSet};
70
71use crate::config::{BlursToml, LocaleToml, SeverityToml};
72use crate::error::{Error, Result};
73use crate::moderation::types::ActionType;
74
75/// Resolved label-emission policy. Produced at config-load time by
76/// [`Self::from_config`] and held by the writer task + read endpoints
77/// that need to surface emission state.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct LabelEmissionPolicy {
80    /// Master toggle. When `false`, [`Self::resolve_action_label`]
81    /// returns `None` for every action type and emission is fully
82    /// suppressed.
83    pub enabled: bool,
84    /// Whether to emit `<prefix><reason_code>` labels alongside the
85    /// action label.
86    pub emit_reason_labels: bool,
87    /// Whether `warning` actions emit a label.
88    pub warning_emits_label: bool,
89    /// Prefix prepended to each `reason_code` to form the reason
90    /// label's `val`. Default `"reason-"`.
91    pub reason_label_prefix: String,
92    /// Per-action-type label override. Keys are validated
93    /// [`ActionType`] values; entries take priority over the
94    /// shipped defaults.
95    pub action_label_overrides: BTreeMap<ActionType, LabelSpec>,
96    /// Per-action-type severity override. Lighter-weight knob than
97    /// a full override entry; ignored when
98    /// [`Self::action_label_overrides`] has an entry for the same
99    /// action type (the explicit override's severity wins).
100    pub severity_overrides: BTreeMap<ActionType, SeverityToml>,
101}
102
103/// One entry in [`LabelEmissionPolicy::action_label_overrides`]. The
104/// runtime equivalent of [`crate::config::LabelSpecToml`].
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct LabelSpec {
107    /// The label `val` emitted at action time.
108    pub val: String,
109    /// Severity hint surfaced to consumer AppViews (#56's trust-
110    /// chain disclosure framing applies — operators declare,
111    /// observers verify).
112    pub severity: SeverityToml,
113    /// Optional blur hint. Most graduated-action labels do not
114    /// specify blurs since they represent account-level state, not
115    /// content-level visual treatment.
116    pub blurs: Option<BlursToml>,
117    /// Optional localized display strings. Empty by default —
118    /// operators that want consumer-friendly labels populate this
119    /// in their [`crate::config::LabelSpecToml`].
120    pub locales: Vec<LocaleToml>,
121}
122
123impl LabelEmissionPolicy {
124    /// Build the policy from `cfg.label_emission`. When the field is
125    /// `None` (operator declared no `[label_emission]` block),
126    /// returns [`Self::defaults`]. When the field is present (even
127    /// with only some sub-fields specified), all unspecified
128    /// sub-fields take their `serde(default)` values, then the
129    /// resolved values are validated together.
130    pub fn from_config(cfg: &crate::config::Config) -> Result<Self> {
131        let Some(toml) = cfg.label_emission.as_ref() else {
132            return Ok(Self::defaults());
133        };
134        Self::validated_from_toml(toml)
135    }
136
137    /// Public-cairn-mod default policy. See module docs for the full
138    /// per-action-type table; the runtime defaults here mirror the
139    /// shipped baseline for any action_type not present in
140    /// `action_label_overrides`.
141    pub fn defaults() -> Self {
142        Self {
143            enabled: true,
144            emit_reason_labels: true,
145            warning_emits_label: false,
146            reason_label_prefix: "reason-".to_string(),
147            action_label_overrides: BTreeMap::new(),
148            severity_overrides: BTreeMap::new(),
149        }
150    }
151
152    fn validated_from_toml(toml: &crate::config::LabelEmissionPolicyToml) -> Result<Self> {
153        validate_reason_label_prefix(&toml.reason_label_prefix)?;
154
155        // Project action_label_overrides keys: TOML keys are
156        // strings; validate each as an ActionType. note is
157        // permitted as a key for symmetry with the schema's CHECK
158        // values but the resolver will still return None for note
159        // (defense-in-depth — see resolve_action_label).
160        let mut overrides: BTreeMap<ActionType, LabelSpec> = BTreeMap::new();
161        let mut seen_vals: BTreeSet<String> = BTreeSet::new();
162        for (key, spec_toml) in &toml.action_label_overrides {
163            let action_type = ActionType::from_db_str(key).ok_or_else(|| {
164                Error::Signing(format!(
165                    "config: [label_emission.action_label_overrides.{key}] is not a valid action_type \
166                     (expected one of warning / note / temp_suspension / indef_suspension / takedown)"
167                ))
168            })?;
169            validate_label_val(&spec_toml.val).map_err(|e| {
170                Error::Signing(format!(
171                    "config: [label_emission.action_label_overrides.{key}].val: {e}"
172                ))
173            })?;
174            if !seen_vals.insert(spec_toml.val.clone()) {
175                return Err(Error::Signing(format!(
176                    "config: [label_emission.action_label_overrides] declares duplicate val \
177                     {:?} across multiple action_type entries — each label val must be \
178                     unique to a single action_type so revocation can route negation \
179                     unambiguously",
180                    spec_toml.val
181                )));
182            }
183            overrides.insert(
184                action_type,
185                LabelSpec {
186                    val: spec_toml.val.clone(),
187                    severity: spec_toml.severity,
188                    blurs: spec_toml.blurs,
189                    locales: spec_toml.locales.clone(),
190                },
191            );
192        }
193
194        // Project severity_overrides keys.
195        let mut sev_overrides: BTreeMap<ActionType, SeverityToml> = BTreeMap::new();
196        for (key, severity) in &toml.severity_overrides {
197            let action_type = ActionType::from_db_str(key).ok_or_else(|| {
198                Error::Signing(format!(
199                    "config: [label_emission.severity_overrides.{key}] is not a valid action_type"
200                ))
201            })?;
202            sev_overrides.insert(action_type, *severity);
203        }
204
205        Ok(Self {
206            enabled: toml.enabled,
207            emit_reason_labels: toml.emit_reason_labels,
208            warning_emits_label: toml.warning_emits_label,
209            reason_label_prefix: toml.reason_label_prefix.clone(),
210            action_label_overrides: overrides,
211            severity_overrides: sev_overrides,
212        })
213    }
214
215    /// Resolve the action label for an [`ActionType`], honoring all
216    /// applicable knobs. See module docs for the full rule table.
217    /// Returns `None` when emission is disabled, the action type is
218    /// `note`, or the action type is `warning` and
219    /// `warning_emits_label = false`.
220    pub fn resolve_action_label(&self, action_type: ActionType) -> Option<LabelSpec> {
221        if !self.enabled {
222            return None;
223        }
224        if matches!(action_type, ActionType::Note) {
225            // Notes never emit, regardless of any operator config.
226            // Defense-in-depth: even if a future operator configures
227            // an action_label_override for note, this gate still
228            // suppresses emission.
229            return None;
230        }
231        if matches!(action_type, ActionType::Warning) && !self.warning_emits_label {
232            return None;
233        }
234
235        if let Some(spec) = self.action_label_overrides.get(&action_type) {
236            // Explicit override — the operator declared the full
237            // spec, and severity_overrides does NOT apply on top
238            // (the override is the canonical statement).
239            return Some(spec.clone());
240        }
241
242        // Fall back to the shipped default. severity_overrides
243        // applies on top of the default.
244        let mut spec = default_spec_for(action_type)?;
245        if let Some(sev) = self.severity_overrides.get(&action_type) {
246            spec.severity = *sev;
247        }
248        Some(spec)
249    }
250
251    /// Compute the reason label's `val` for a given reason code.
252    /// Always returns a value; the consumer (#59 emission core)
253    /// decides whether to actually emit based on
254    /// [`Self::emit_reason_labels`].
255    pub fn resolve_reason_label_value(&self, reason_code: &str) -> String {
256        format!("{}{}", self.reason_label_prefix, reason_code)
257    }
258}
259
260/// Shipped per-action-type default. Returns `None` for `note`
261/// (matching the no-emission contract).
262fn default_spec_for(action_type: ActionType) -> Option<LabelSpec> {
263    match action_type {
264        ActionType::Note => None,
265        ActionType::Takedown => Some(LabelSpec {
266            val: "!takedown".to_string(),
267            severity: SeverityToml::Alert,
268            blurs: None,
269            locales: Vec::new(),
270        }),
271        ActionType::IndefSuspension => Some(LabelSpec {
272            val: "!hide".to_string(),
273            severity: SeverityToml::Alert,
274            blurs: None,
275            locales: Vec::new(),
276        }),
277        ActionType::TempSuspension => Some(LabelSpec {
278            val: "!hide".to_string(),
279            severity: SeverityToml::Alert,
280            blurs: None,
281            locales: Vec::new(),
282        }),
283        ActionType::Warning => Some(LabelSpec {
284            val: "!warn".to_string(),
285            severity: SeverityToml::Inform,
286            blurs: None,
287            locales: Vec::new(),
288        }),
289    }
290}
291
292/// Validate a label `val` per ATProto + cairn-mod conventions.
293///
294/// Rules:
295/// - Length 1..=128 bytes (matches the §6.4 schema CHECK).
296/// - First char: ASCII lowercase letter, ASCII digit, or `!`
297///   (the latter for ATProto global label values like `!takedown`).
298/// - Subsequent chars: ASCII lowercase letter, ASCII digit, or `-`.
299fn validate_label_val(val: &str) -> std::result::Result<(), String> {
300    if val.is_empty() {
301        return Err("label val must be non-empty".into());
302    }
303    if val.len() > 128 {
304        return Err(format!(
305            "label val {val:?} exceeds the §6.4 schema CHECK length limit (got {} bytes, max 128)",
306            val.len()
307        ));
308    }
309    let mut chars = val.chars();
310    let first = chars.next().expect("non-empty checked above");
311    if !first.is_ascii_lowercase() && !first.is_ascii_digit() && first != '!' {
312        return Err(format!(
313            "label val {val:?} must start with a lowercase ASCII letter, ASCII digit, or `!` \
314             (got {first:?})"
315        ));
316    }
317    for c in chars {
318        if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
319            return Err(format!(
320                "label val {val:?} contains invalid char {c:?} \
321                 (allowed after the first char: a-z, 0-9, hyphen)"
322            ));
323        }
324    }
325    Ok(())
326}
327
328/// Validate the reason label prefix. Empty is permitted (logged as
329/// a startup warning by [`LabelEmissionPolicy::from_config`] —
330/// callers pair an empty prefix with intentional reason-code
331/// surfacing — though the warning is best-effort and emitted via
332/// `tracing`).
333fn validate_reason_label_prefix(prefix: &str) -> Result<()> {
334    if prefix.is_empty() {
335        // Empty is legal but suspicious. Log a warning so operators
336        // see it at startup; don't fail config load.
337        tracing::warn!(
338            "config: [label_emission].reason_label_prefix is empty — \
339             reason labels will use bare reason_codes as their val"
340        );
341        return Ok(());
342    }
343    if prefix.len() > 32 {
344        return Err(Error::Signing(format!(
345            "config: [label_emission].reason_label_prefix {:?} exceeds 32 bytes (got {})",
346            prefix,
347            prefix.len()
348        )));
349    }
350    let mut chars = prefix.chars();
351    let first = chars.next().expect("non-empty checked above");
352    if !first.is_ascii_lowercase() {
353        return Err(Error::Signing(format!(
354            "config: [label_emission].reason_label_prefix {:?} must start with a lowercase \
355             ASCII letter (got {:?})",
356            prefix, first
357        )));
358    }
359    for c in chars {
360        if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
361            return Err(Error::Signing(format!(
362                "config: [label_emission].reason_label_prefix {:?} contains invalid char {:?} \
363                 (allowed: a-z, 0-9, hyphen)",
364                prefix, c
365            )));
366        }
367    }
368    Ok(())
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::config::Config;
375
376    fn config_with_emission(value: serde_json::Value) -> Config {
377        let mut v = serde_json::json!({
378            "service_did": "did:web:labeler.example",
379            "service_endpoint": "https://labeler.example",
380            "db_path": "/var/lib/cairn/cairn.db",
381            "signing_key_path": "/etc/cairn/signing-key.hex",
382        });
383        if !value.is_null() {
384            v["label_emission"] = value;
385        }
386        serde_json::from_value(v).expect("config deserializes")
387    }
388
389    // ---------- defaults ----------
390
391    #[test]
392    fn defaults_match_documented_baseline() {
393        let p = LabelEmissionPolicy::defaults();
394        assert!(p.enabled);
395        assert!(p.emit_reason_labels);
396        assert!(!p.warning_emits_label);
397        assert_eq!(p.reason_label_prefix, "reason-");
398        assert!(p.action_label_overrides.is_empty());
399        assert!(p.severity_overrides.is_empty());
400    }
401
402    #[test]
403    fn absent_block_loads_defaults() {
404        let cfg = config_with_emission(serde_json::Value::Null);
405        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
406        assert_eq!(p, LabelEmissionPolicy::defaults());
407    }
408
409    #[test]
410    fn empty_block_loads_defaults_via_serde_fallbacks() {
411        let cfg = config_with_emission(serde_json::json!({}));
412        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
413        assert_eq!(p, LabelEmissionPolicy::defaults());
414    }
415
416    // ---------- partial declarations ----------
417
418    #[test]
419    fn partial_block_uses_serde_defaults_for_other_fields() {
420        let cfg = config_with_emission(serde_json::json!({
421            "warning_emits_label": true,
422        }));
423        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
424        assert!(p.warning_emits_label);
425        assert!(p.enabled);
426        assert!(p.emit_reason_labels);
427        assert_eq!(p.reason_label_prefix, "reason-");
428    }
429
430    #[test]
431    fn full_explicit_declaration_reflects_all_values() {
432        let cfg = config_with_emission(serde_json::json!({
433            "enabled": false,
434            "emit_reason_labels": false,
435            "warning_emits_label": true,
436            "reason_label_prefix": "rsn-",
437        }));
438        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
439        assert!(!p.enabled);
440        assert!(!p.emit_reason_labels);
441        assert!(p.warning_emits_label);
442        assert_eq!(p.reason_label_prefix, "rsn-");
443    }
444
445    // ---------- action_label_overrides ----------
446
447    #[test]
448    fn action_label_override_replaces_default_for_that_type() {
449        let cfg = config_with_emission(serde_json::json!({
450            "action_label_overrides": {
451                "takedown": {
452                    "val": "!hideaway-takedown",
453                    "severity": "alert",
454                }
455            }
456        }));
457        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
458        let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
459        assert_eq!(spec.val, "!hideaway-takedown");
460    }
461
462    #[test]
463    fn action_label_override_with_unknown_type_rejected() {
464        let cfg = config_with_emission(serde_json::json!({
465            "action_label_overrides": {
466                "ban": {
467                    "val": "!banned",
468                    "severity": "alert",
469                }
470            }
471        }));
472        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
473        let msg = format!("{err}");
474        assert!(msg.contains("not a valid action_type"));
475        assert!(msg.contains("ban"));
476    }
477
478    #[test]
479    fn duplicate_val_across_action_label_overrides_rejected() {
480        let cfg = config_with_emission(serde_json::json!({
481            "action_label_overrides": {
482                "takedown":         { "val": "!enforced", "severity": "alert" },
483                "indef_suspension": { "val": "!enforced", "severity": "alert" },
484            }
485        }));
486        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
487        let msg = format!("{err}");
488        assert!(msg.contains("duplicate val"));
489    }
490
491    #[test]
492    fn action_label_override_with_blurs_and_locales_round_trips() {
493        let cfg = config_with_emission(serde_json::json!({
494            "action_label_overrides": {
495                "takedown": {
496                    "val": "!takedown",
497                    "severity": "alert",
498                    "blurs": "media",
499                    "locales": [
500                        { "lang": "en", "name": "Removed", "description": "Account removed by moderation" }
501                    ]
502                }
503            }
504        }));
505        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
506        let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
507        assert!(matches!(spec.blurs, Some(BlursToml::Media)));
508        assert_eq!(spec.locales.len(), 1);
509        assert_eq!(spec.locales[0].lang, "en");
510    }
511
512    // ---------- val validation ----------
513
514    #[test]
515    fn empty_val_rejected() {
516        let cfg = config_with_emission(serde_json::json!({
517            "action_label_overrides": {
518                "takedown": { "val": "", "severity": "alert" }
519            }
520        }));
521        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
522        assert!(format!("{err}").contains("non-empty"));
523    }
524
525    #[test]
526    fn uppercase_val_rejected() {
527        // "Bad-Val" — first char `B` triggers the start-of-val
528        // validator (must start with lowercase letter / digit / `!`).
529        let cfg = config_with_emission(serde_json::json!({
530            "action_label_overrides": {
531                "takedown": { "val": "Bad-Val", "severity": "alert" }
532            }
533        }));
534        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
535        assert!(format!("{err}").contains("lowercase"));
536    }
537
538    #[test]
539    fn uppercase_in_middle_of_val_rejected() {
540        // "good-Val" — first char `g` is fine; later `V` triggers
541        // the per-char validator with the `invalid char` message.
542        let cfg = config_with_emission(serde_json::json!({
543            "action_label_overrides": {
544                "takedown": { "val": "good-Val", "severity": "alert" }
545            }
546        }));
547        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
548        assert!(format!("{err}").contains("invalid char"));
549    }
550
551    #[test]
552    fn val_with_underscore_rejected() {
553        let cfg = config_with_emission(serde_json::json!({
554            "action_label_overrides": {
555                "takedown": { "val": "my_label", "severity": "alert" }
556            }
557        }));
558        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
559        assert!(format!("{err}").contains("invalid char"));
560    }
561
562    #[test]
563    fn val_starting_with_digit_accepted() {
564        // Digits are permitted as the first character.
565        let cfg = config_with_emission(serde_json::json!({
566            "action_label_overrides": {
567                "takedown": { "val": "2025-policy", "severity": "alert" }
568            }
569        }));
570        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
571        let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
572        assert_eq!(spec.val, "2025-policy");
573    }
574
575    #[test]
576    fn val_with_bang_prefix_accepted() {
577        // ATProto global labels start with `!`.
578        let cfg = config_with_emission(serde_json::json!({
579            "action_label_overrides": {
580                "takedown": { "val": "!hideaway-takedown", "severity": "alert" }
581            }
582        }));
583        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
584        let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
585        assert_eq!(spec.val, "!hideaway-takedown");
586    }
587
588    #[test]
589    fn val_too_long_rejected() {
590        let long = "a".repeat(129);
591        let cfg = config_with_emission(serde_json::json!({
592            "action_label_overrides": {
593                "takedown": { "val": long, "severity": "alert" }
594            }
595        }));
596        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
597        assert!(format!("{err}").contains("128"));
598    }
599
600    // ---------- reason_label_prefix validation ----------
601
602    #[test]
603    fn empty_prefix_is_valid_with_warning() {
604        // Empty prefix logs a warning but is permitted.
605        let cfg = config_with_emission(serde_json::json!({
606            "reason_label_prefix": "",
607        }));
608        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
609        assert_eq!(p.reason_label_prefix, "");
610        assert_eq!(p.resolve_reason_label_value("hate-speech"), "hate-speech");
611    }
612
613    #[test]
614    fn uppercase_prefix_rejected() {
615        let cfg = config_with_emission(serde_json::json!({
616            "reason_label_prefix": "Reason-",
617        }));
618        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
619        assert!(format!("{err}").contains("lowercase"));
620    }
621
622    #[test]
623    fn prefix_with_underscore_rejected() {
624        let cfg = config_with_emission(serde_json::json!({
625            "reason_label_prefix": "rsn_",
626        }));
627        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
628        assert!(format!("{err}").contains("invalid char"));
629    }
630
631    #[test]
632    fn prefix_too_long_rejected() {
633        let long = "a".repeat(33);
634        let cfg = config_with_emission(serde_json::json!({
635            "reason_label_prefix": long,
636        }));
637        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
638        assert!(format!("{err}").contains("32 bytes"));
639    }
640
641    // ---------- severity_overrides ----------
642
643    #[test]
644    fn severity_override_applies_when_no_full_override() {
645        let cfg = config_with_emission(serde_json::json!({
646            "warning_emits_label": true,
647            "severity_overrides": {
648                "warning": "alert",
649            }
650        }));
651        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
652        let spec = p.resolve_action_label(ActionType::Warning).unwrap();
653        assert!(matches!(spec.severity, SeverityToml::Alert));
654        assert_eq!(spec.val, "!warn"); // val from default, severity overridden
655    }
656
657    #[test]
658    fn severity_override_ignored_when_full_override_present() {
659        // When action_label_overrides has the action_type, its
660        // severity wins — severity_overrides for the same type is
661        // ignored.
662        let cfg = config_with_emission(serde_json::json!({
663            "action_label_overrides": {
664                "takedown": { "val": "!custom", "severity": "inform" }
665            },
666            "severity_overrides": {
667                "takedown": "alert",
668            }
669        }));
670        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
671        let spec = p.resolve_action_label(ActionType::Takedown).unwrap();
672        assert!(matches!(spec.severity, SeverityToml::Inform));
673    }
674
675    #[test]
676    fn severity_override_with_unknown_type_rejected() {
677        let cfg = config_with_emission(serde_json::json!({
678            "severity_overrides": {
679                "ban": "alert",
680            }
681        }));
682        let err = LabelEmissionPolicy::from_config(&cfg).unwrap_err();
683        assert!(format!("{err}").contains("not a valid action_type"));
684    }
685
686    // ---------- resolve_action_label semantics ----------
687
688    #[test]
689    fn enabled_false_suppresses_all_emission() {
690        let cfg = config_with_emission(serde_json::json!({
691            "enabled": false,
692        }));
693        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
694        for at in [
695            ActionType::Takedown,
696            ActionType::IndefSuspension,
697            ActionType::TempSuspension,
698            ActionType::Warning,
699            ActionType::Note,
700        ] {
701            assert!(
702                p.resolve_action_label(at).is_none(),
703                "expected None for {at:?}"
704            );
705        }
706    }
707
708    #[test]
709    fn note_never_emits_regardless_of_config() {
710        // Defense-in-depth: even if a future operator declares an
711        // action_label_overrides.note entry, the resolver
712        // suppresses it.
713        let cfg = config_with_emission(serde_json::json!({
714            "action_label_overrides": {
715                "note": { "val": "!noted", "severity": "inform" }
716            }
717        }));
718        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
719        assert!(p.resolve_action_label(ActionType::Note).is_none());
720    }
721
722    #[test]
723    fn warning_emits_only_when_flag_true() {
724        // Flag false (default) — no emission.
725        let p = LabelEmissionPolicy::defaults();
726        assert!(p.resolve_action_label(ActionType::Warning).is_none());
727
728        // Flag true — emission with default !warn.
729        let cfg = config_with_emission(serde_json::json!({
730            "warning_emits_label": true,
731        }));
732        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
733        let spec = p.resolve_action_label(ActionType::Warning).unwrap();
734        assert_eq!(spec.val, "!warn");
735        assert!(matches!(spec.severity, SeverityToml::Inform));
736    }
737
738    #[test]
739    fn defaults_resolve_to_atproto_globals() {
740        let p = LabelEmissionPolicy::defaults();
741        assert_eq!(
742            p.resolve_action_label(ActionType::Takedown).unwrap().val,
743            "!takedown"
744        );
745        assert_eq!(
746            p.resolve_action_label(ActionType::IndefSuspension)
747                .unwrap()
748                .val,
749            "!hide"
750        );
751        assert_eq!(
752            p.resolve_action_label(ActionType::TempSuspension)
753                .unwrap()
754                .val,
755            "!hide"
756        );
757    }
758
759    // ---------- resolve_reason_label_value ----------
760
761    #[test]
762    fn reason_label_value_uses_default_prefix() {
763        let p = LabelEmissionPolicy::defaults();
764        assert_eq!(
765            p.resolve_reason_label_value("hate-speech"),
766            "reason-hate-speech"
767        );
768        assert_eq!(p.resolve_reason_label_value("spam"), "reason-spam");
769    }
770
771    #[test]
772    fn reason_label_value_uses_custom_prefix() {
773        let cfg = config_with_emission(serde_json::json!({
774            "reason_label_prefix": "rsn-",
775        }));
776        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
777        assert_eq!(
778            p.resolve_reason_label_value("hate-speech"),
779            "rsn-hate-speech"
780        );
781    }
782
783    #[test]
784    fn reason_label_value_returns_value_even_when_emit_reason_labels_false() {
785        // The loader doesn't gate on emit_reason_labels — that's
786        // the consumer's decision (#59 emission core checks).
787        // resolve_reason_label_value always returns the computed
788        // val for whoever asks.
789        let cfg = config_with_emission(serde_json::json!({
790            "emit_reason_labels": false,
791        }));
792        let p = LabelEmissionPolicy::from_config(&cfg).expect("from_config");
793        assert!(!p.emit_reason_labels);
794        assert_eq!(p.resolve_reason_label_value("spam"), "reason-spam");
795    }
796}