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}