Skip to main content

cairn_mod/moderation/
types.rs

1//! Shared types for the v1.4 graduated-action moderation surface
2//! (§F20).
3//!
4//! [`ActionType`] is the runtime form of the `subject_actions.action_type`
5//! TEXT CHECK enum (#46). Variants match the SQL CHECK values
6//! one-for-one — `warning`, `note`, `temp_suspension`,
7//! `indef_suspension`, `takedown` — and revocation is metadata on the
8//! row, NOT a distinct type, so the enum has exactly five variants.
9//!
10//! [`ActionRecord`] is the read-side projection that the decay
11//! calculator (#50) and the strike calculator (#49) consume. It's a
12//! deliberate subset of the full `subject_actions` row: the columns
13//! needed for strike-state computation, none of the display-only
14//! ones (notes, reason_codes, audit_log_id, etc.). Code paths that
15//! need richer fields should use a richer struct and project down to
16//! `ActionRecord` when handing to the calculators.
17//!
18//! Both types are also used by:
19//! - the recorder (#51) when constructing rows to insert,
20//! - the history CLI (#52) and admin/public XRPC (#53/#54) when
21//!   surfacing action records to operators or subjects.
22
23use std::time::SystemTime;
24
25/// Operator-facing graduated-action enum (§F20). Variants match the
26/// `subject_actions.action_type` SQL CHECK values exactly. New
27/// variants here would require a coordinated migration + admin/CLI
28/// surface change, so the set is closed for v1.4.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
30pub enum ActionType {
31    /// Operator-issued warning. Does not carry strikes; surfaced to
32    /// the subject for awareness. Stored so warnings appear in
33    /// history alongside heavier actions.
34    Warning,
35    /// Internal moderator note. Not visible to the subject; never
36    /// carries strikes. Useful for "saw this, didn't act" annotations
37    /// that still want to show up in `cairn moderator history` (#52).
38    Note,
39    /// Time-bounded suspension. `expires_at` is required (set from
40    /// `effective_at + duration`); decay halts during the suspension
41    /// when `policy.suspension_freezes_decay` is true.
42    TempSuspension,
43    /// Open-ended suspension. `expires_at` is `None`. Revocation is
44    /// the only path back to good standing while this is active.
45    IndefSuspension,
46    /// Account takedown. Carries strikes; not a suspension (does not
47    /// trigger the decay-freeze branch in the decay calculator).
48    Takedown,
49}
50
51impl ActionType {
52    /// Whether this action's `strike_value_applied` should be summed
53    /// into the subject's strike total. Returns `true` for
54    /// suspensions and takedowns; `false` for warnings and notes.
55    ///
56    /// This is also a defense-in-depth filter: the decay calculator
57    /// uses it before adding a row's contribution, so a stray
58    /// non-zero `strike_value_applied` on a Warning/Note row (which
59    /// shouldn't happen given the recorder's logic) won't leak into
60    /// the count.
61    pub fn contributes_strikes(self) -> bool {
62        match self {
63            ActionType::Note | ActionType::Warning => false,
64            ActionType::TempSuspension | ActionType::IndefSuspension | ActionType::Takedown => true,
65        }
66    }
67
68    /// Whether this action is a suspension (temp or indef). Used by
69    /// the decay calculator to find the most recent unrevoked
70    /// suspension when applying the freeze rule (§F20 / #48
71    /// `suspension_freezes_decay`).
72    pub fn is_suspension(self) -> bool {
73        matches!(
74            self,
75            ActionType::TempSuspension | ActionType::IndefSuspension
76        )
77    }
78
79    /// String form matching the `subject_actions.action_type` SQL
80    /// CHECK constraint (#46). Stable wire identifier — used by the
81    /// recorder to insert rows, by the lexicon `knownValues` set,
82    /// and by CLI flag parsing.
83    pub fn as_db_str(self) -> &'static str {
84        match self {
85            ActionType::Warning => "warning",
86            ActionType::Note => "note",
87            ActionType::TempSuspension => "temp_suspension",
88            ActionType::IndefSuspension => "indef_suspension",
89            ActionType::Takedown => "takedown",
90        }
91    }
92
93    /// Parse the SQL/wire string form. Returns `None` for any value
94    /// not in the §F20 enum — the caller surfaces this as
95    /// `InvalidActionType` at the request boundary.
96    pub fn from_db_str(s: &str) -> Option<Self> {
97        match s {
98            "warning" => Some(ActionType::Warning),
99            "note" => Some(ActionType::Note),
100            "temp_suspension" => Some(ActionType::TempSuspension),
101            "indef_suspension" => Some(ActionType::IndefSuspension),
102            "takedown" => Some(ActionType::Takedown),
103            _ => None,
104        }
105    }
106}
107
108/// Read-side projection of a `subject_actions` row, holding only
109/// the columns the strike + decay + window calculators consume.
110///
111/// Field set is deliberately narrow:
112///
113/// - `strike_value_applied` — what the strike calculator (#49)
114///   resolved at action time, frozen on the row.
115/// - `effective_at` — wall-clock at which the action started
116///   counting; decay's elapsed-time clock starts here.
117/// - `revoked_at` — `Some` iff the action was revoked. Revoked
118///   actions don't contribute to `current_count` but still appear
119///   in `raw_total` + `revoked_count` for display.
120/// - `action_type` — drives the `contributes_strikes` /
121///   `is_suspension` branches.
122/// - `expires_at` — `Some` for `TempSuspension` (used to detect
123///   whether a suspension is currently active vs. expired); `None`
124///   for `IndefSuspension` (no end) and for non-suspensions
125///   (irrelevant).
126/// - `was_dampened` — frozen on the row by the strike calculator
127///   (#49) at action time. Read by the window calculator (#51) as
128///   the "in-good-standing at action time" predicate; a stable
129///   signal that doesn't drift if `[strike_policy]` is later edited.
130///
131/// Ordering convention: callers pass slices in id-ascending order
132/// (matching `SELECT ... ORDER BY id` from `subject_actions`). The
133/// calculators iterate in the order given and don't re-sort.
134#[derive(Debug, Clone)]
135pub struct ActionRecord {
136    /// Strike weight applied at action time after dampening, frozen
137    /// on the row by the recorder (#51). For Warning/Note rows this
138    /// is conventionally `0` and additionally filtered out via
139    /// [`ActionType::contributes_strikes`] as defense-in-depth.
140    pub strike_value_applied: u32,
141    /// Wall-clock at which the action took effect. Decay's elapsed
142    /// clock measures from here.
143    pub effective_at: SystemTime,
144    /// Revocation timestamp, or `None` if the action is still in
145    /// force. Revoked actions are excluded from decay accounting
146    /// (see [`crate::moderation::decay`] module docs for the
147    /// retroactive-revoke semantics).
148    pub revoked_at: Option<SystemTime>,
149    /// The action's graduated-action category. Drives strike-bearing
150    /// and suspension-detection logic.
151    pub action_type: ActionType,
152    /// Expiration wall-clock for `TempSuspension`; `None` for
153    /// `IndefSuspension` (open-ended) and for non-suspensions.
154    pub expires_at: Option<SystemTime>,
155    /// `true` iff the strike calculator's dampening curve was
156    /// consulted at action time — i.e., the subject was in good
157    /// standing AND the position was covered by the curve (#49).
158    /// Used by the window calculator (#51) as the "in-good-standing
159    /// at action time" predicate. Frozen on the row, so historical
160    /// position counting reflects the standing the subject was
161    /// actually in when each prior action fired.
162    pub was_dampened: bool,
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn contributes_strikes_matches_design() {
171        assert!(!ActionType::Warning.contributes_strikes());
172        assert!(!ActionType::Note.contributes_strikes());
173        assert!(ActionType::TempSuspension.contributes_strikes());
174        assert!(ActionType::IndefSuspension.contributes_strikes());
175        assert!(ActionType::Takedown.contributes_strikes());
176    }
177
178    #[test]
179    fn is_suspension_only_for_temp_and_indef() {
180        assert!(!ActionType::Warning.is_suspension());
181        assert!(!ActionType::Note.is_suspension());
182        assert!(ActionType::TempSuspension.is_suspension());
183        assert!(ActionType::IndefSuspension.is_suspension());
184        assert!(
185            !ActionType::Takedown.is_suspension(),
186            "Takedown carries strikes but is not a suspension — it does not trigger decay freeze"
187        );
188    }
189
190    #[test]
191    fn db_str_round_trip_for_every_variant() {
192        for v in [
193            ActionType::Warning,
194            ActionType::Note,
195            ActionType::TempSuspension,
196            ActionType::IndefSuspension,
197            ActionType::Takedown,
198        ] {
199            assert_eq!(ActionType::from_db_str(v.as_db_str()), Some(v));
200        }
201    }
202
203    #[test]
204    fn db_str_unknown_value_yields_none() {
205        assert!(ActionType::from_db_str("WARNING").is_none()); // case-sensitive
206        assert!(ActionType::from_db_str("ban").is_none());
207        assert!(ActionType::from_db_str("").is_none());
208    }
209}