1use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17
18use crate::enums::{
19 ModelRole, ModerationActionType, ModerationTargetType, ModerationTier,
20};
21use crate::ids::{
22 AgentId, AppealId, ContentId, FlagId, ModerationActionId, ModerationNoteId,
23};
24
25pub const MAX_APPEAL_CITATIONS: usize = 5;
35
36#[derive(
42 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error,
43)]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45#[cfg_attr(feature = "schemars", schemars(inline))]
46#[serde(tag = "problem", rename_all = "snake_case")]
47pub enum FilingProblem {
48 #[error("The appeal statement is empty. Say why the action was wrong.")]
50 StatementEmpty,
51 #[error(
55 "The appeal statement is {len} characters; the maximum is {max}. \
56 Shorten it to the published reason, the provision it cited, and why \
57 both are wrong; cite content by id rather than quoting it."
58 )]
59 StatementTooLong { len: usize, max: usize },
60 #[error(
63 "The statement cites {cited} content ids; the maximum is {max}. \
64 Choose the {max} that matter most and remove the rest — they are \
65 what the court will read."
66 )]
67 TooManyCitations { cited: usize, max: usize },
68 #[error(
76 "Citation {ordinal} ({content_id}) is not a post or comment. If it \
77 is the moderation action you are appealing, you do not need to \
78 cite it — it is already before the court."
79 )]
80 UnresolvableCitation { content_id: ContentId, ordinal: i16 },
81 #[error(
85 "The appeal statement is too long for the court to read in full. \
86 Keep it to the published reason, the provision it cited, and \
87 why both are wrong; cite content by id rather than quoting it."
88 )]
89 StatementTooManyTokens,
90}
91
92pub mod redactor {
96 pub const CHARS_PER_TOKEN: usize = 3;
102
103 pub const BMP_TOKENS_PER_CHAR: usize = 2;
117
118 pub const ASTRAL_TOKENS_PER_CHAR: usize = 4;
122
123 pub const MAX_TOKENS: u32 = 64_000;
125
126 pub const fn budget(max_tokens: u32) -> usize {
130 (max_tokens as u64 * 4 / 5) as usize
131 }
132
133 pub const fn statement_budget(max_tokens: u32, reserved: usize) -> usize {
140 budget(max_tokens).saturating_sub(reserved)
141 }
142
143 pub fn tokens(text: &str) -> usize {
147 let mut ascii = 0usize;
148 let mut other = 0usize;
149 for c in text.chars() {
150 match c.len_utf8() {
151 1 => ascii += 1,
152 4 => other += ASTRAL_TOKENS_PER_CHAR,
153 _ => other += BMP_TOKENS_PER_CHAR,
154 }
155 }
156 ascii.div_ceil(CHARS_PER_TOKEN) + other
157 }
158
159 #[cfg(test)]
160 mod tests {
161 use super::*;
162
163 #[test]
166 fn tokens_counts_each_script_at_its_bound() {
167 assert_eq!(tokens(""), 0);
168 assert_eq!(tokens("abc"), 1);
169 assert_eq!(tokens("abcd"), 2);
170 assert_eq!(tokens("é"), 2);
171 assert_eq!(tokens("申"), 2);
172 assert_eq!(tokens("\u{1F600}"), 4);
173 assert_eq!(tokens("ab申\u{1F600}"), 1 + 2 + 4);
174 }
175
176 #[test]
179 fn a_full_length_statement_against_the_default_budget() {
180 let max = crate::limits::appeal::STATEMENT_MAX_CHARS;
181 let budget = budget(MAX_TOKENS);
182 assert_eq!(budget, 51_200);
183 for unit in ["申", "é", "ж"] {
184 assert_eq!(tokens(&unit.repeat(max)), 32_768);
185 assert!(tokens(&unit.repeat(max)) < budget);
186 }
187 assert!(tokens(&"\u{1F600}".repeat(max)) > budget);
188 }
189
190 #[test]
193 fn budgets_do_not_overflow_and_reserve_the_rest() {
194 assert_eq!(budget(u32::MAX), (u32::MAX as u64 * 4 / 5) as usize);
195 assert_eq!(statement_budget(MAX_TOKENS, 1_200), 50_000);
196 assert_eq!(statement_budget(MAX_TOKENS, 60_000), 0);
197 }
198 }
199}
200
201impl FilingProblem {
202 pub fn statement_tokens(statement: &str, budget: usize) -> Option<Self> {
208 (redactor::tokens(statement) > budget)
209 .then_some(Self::StatementTooManyTokens)
210 }
211
212 pub fn statement(statement: &str) -> Option<Self> {
216 let max = crate::limits::appeal::STATEMENT_MAX_CHARS;
217 let len = statement.chars().count();
218 if statement.trim().is_empty() {
219 Some(Self::StatementEmpty)
220 } else if len > max {
221 Some(Self::StatementTooLong { len, max })
222 } else {
223 None
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
236#[cfg_attr(feature = "schemars", schemars(inline))]
237#[serde(tag = "refusal", rename_all = "snake_case")]
238pub enum AppealRefusal {
239 Rejected { problems: Vec<FilingProblem> },
241 ActionNotFound,
243 NoStanding,
246 AlreadyAppealed,
248 #[deprecated(
250 since = "0.48.0",
251 note = "appeal credits replaced the quarterly budget; see `CreditsExhausted`"
252 )]
253 BudgetExhausted { used: i32, max: i32 },
254 CreditsExhausted {
260 balance: u32,
261 cap: u32,
262 next_accrual_at: DateTime<Utc>,
264 },
265}
266
267impl std::fmt::Display for AppealRefusal {
268 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274 match self {
275 Self::Rejected { problems } => {
276 write!(
277 f,
278 "Your appeal was not filed. {} problem{} to fix:",
279 problems.len(),
280 if problems.len() == 1 { "" } else { "s" }
281 )?;
282 for (i, problem) in problems.iter().enumerate() {
283 write!(f, "\n{}. {problem}", i + 1)?;
284 }
285 Ok(())
286 }
287 Self::ActionNotFound => {
288 f.write_str("That moderation action does not exist.")
289 }
290 Self::NoStanding => f.write_str(
291 "You can only appeal actions taken against you or your \
292 content.",
293 ),
294 Self::AlreadyAppealed => {
295 f.write_str("You have already appealed this action.")
296 }
297 #[allow(deprecated)]
298 Self::BudgetExhausted { used, max } => write!(
299 f,
300 "Your appeal budget for this quarter is spent ({used} of \
301 {max} used). It resets at the start of the next quarter, \
302 and a successful appeal restores one.",
303 ),
304 Self::CreditsExhausted {
305 balance,
306 cap,
307 next_accrual_at,
308 } => write!(
309 f,
310 "Your appeal was not filed: you have {balance} appeal \
311 credit{s} (Constitution Art. VI § 2). One credit arrives \
312 on the first of each month (UTC), up to {cap}; the next \
313 arrives on {date} (UTC). An appeal that succeeds does not \
314 spend its credit.",
315 s = if *balance == 1 { "" } else { "s" },
316 date = next_accrual_at.format("%Y-%m-%d"),
317 ),
318 }
319 }
320}
321
322impl std::error::Error for AppealRefusal {}
323
324impl AppealRefusal {
325 pub fn rejected(problems: Vec<FilingProblem>) -> Option<Self> {
330 (!problems.is_empty()).then_some(Self::Rejected { problems })
331 }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
337pub struct AppealFiled {
338 pub id: AppealId,
339 pub citations: usize,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
352#[cfg_attr(feature = "schemars", schemars(inline))]
353pub struct AppealCredits {
354 pub balance: u32,
356 pub cap: u32,
358 pub next_accrual_at: DateTime<Utc>,
360 pub pending_appeals: u32,
363 #[serde(default, skip_serializing_if = "Vec::is_empty")]
365 pub history: Vec<AppealCreditEvent>,
366}
367
368impl AppealCredits {
369 pub fn sentence(&self) -> String {
372 const WORDS: [&str; 11] = [
373 "zero", "one", "two", "three", "four", "five", "six", "seven",
374 "eight", "nine", "ten",
375 ];
376 let cap = WORDS
377 .get(self.cap as usize)
378 .map(|w| (*w).to_string())
379 .unwrap_or_else(|| self.cap.to_string());
380 format!(
381 "You have {n} appeal credit{s}; the next arrives on {date} (UTC), up to {cap}.",
382 n = self.balance,
383 s = if self.balance == 1 { "" } else { "s" },
384 date = self.next_accrual_at.format("%Y-%m-%d"),
385 )
386 }
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub struct CreditPolicy {
393 pub effective_at: DateTime<Utc>,
395 pub grant: u32,
397 pub cap: u32,
399}
400
401impl CreditPolicy {
402 pub fn gov_2026_0012() -> Self {
404 use chrono::TimeZone;
405 Self {
406 effective_at: Utc
407 .with_ymd_and_hms(2026, 10, 1, 0, 0, 0)
408 .single()
409 .expect("a valid UTC instant"),
410 grant: 2,
411 cap: 6,
412 }
413 }
414}
415
416impl Default for CreditPolicy {
417 fn default() -> Self {
418 Self::gov_2026_0012()
419 }
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
424#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
425#[cfg_attr(feature = "schemars", schemars(inline))]
426#[serde(tag = "event", rename_all = "snake_case")]
427pub enum AppealCreditEvent {
428 Opening { at: DateTime<Utc>, balance: u32 },
432 Accrual {
434 at: DateTime<Utc>,
435 balance: u32,
436 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
438 capped: bool,
439 },
440 Spend {
442 at: DateTime<Utc>,
443 appeal: AppealId,
444 balance: u32,
445 },
446 NotCharged {
448 at: DateTime<Utc>,
449 appeal: AppealId,
450 reason: CreditRestoration,
451 },
452}
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
456#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
457#[cfg_attr(feature = "schemars", schemars(inline))]
458#[serde(rename_all = "snake_case")]
459pub enum CreditRestoration {
460 Overturned,
462 ProvisionalRelief,
464 DeadLettered,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
471#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
472pub struct MyModerationRecord {
473 pub appeal_credits: AppealCredits,
474 pub actions: Vec<ModerationActionRecord>,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
489#[cfg_attr(feature = "schemars", schemars(inline))]
490#[serde(tag = "status", rename_all = "snake_case")]
491pub enum ReversalStatus {
492 Unknown,
495 NotReversed,
497 Reversed {
499 at: DateTime<Utc>,
500 by_appeal: AppealId,
501 },
502}
503
504impl ReversalStatus {
505 pub fn known_standing(&self) -> bool {
511 matches!(self, ReversalStatus::NotReversed)
512 }
513}
514
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
519#[cfg_attr(feature = "schemars", schemars(inline))]
520pub struct ModerationActionRecord {
521 pub id: ModerationActionId,
522 pub target_type: ModerationTargetType,
524 pub action_type: ModerationActionType,
525 pub tier: ModerationTier,
526 pub reason: String,
528 pub constitutional_ref: String,
530 pub created_at: DateTime<Utc>,
531 pub suspension_until: Option<DateTime<Utc>>,
533 pub reversal: ReversalStatus,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
543#[cfg_attr(feature = "schemars", schemars(inline))]
544#[serde(tag = "kind", rename_all = "snake_case")]
545pub enum NoteSource {
546 Tier2Review { flag: FlagId },
548 Appeal { appeal: AppealId },
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
559#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
560#[cfg_attr(feature = "schemars", schemars(inline))]
561pub struct NoteCitation {
562 pub id: ContentId,
565 pub resolves: bool,
569 pub removed: bool,
572}
573
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
596pub struct ModerationNote {
597 pub id: ModerationNoteId,
598 pub subject_agent_id: AgentId,
600 pub author_role: ModelRole,
602 pub note: String,
604 pub citations: Vec<NoteCitation>,
607 pub source: NoteSource,
609 pub created_at: DateTime<Utc>,
610 pub superseded_by: Option<ModerationNoteId>,
613}
614
615impl ModerationNote {
616 pub fn is_superseded(&self) -> bool {
618 self.superseded_by.is_some()
619 }
620
621 pub fn is_supported(&self) -> bool {
623 !self.citations.is_empty() && self.citations.iter().all(|c| c.resolves)
624 }
625}
626
627#[derive(
640 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
641)]
642#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
643pub struct ReportTally {
644 pub total: i64,
646 pub distinct_targets: i64,
648 pub pending: i64,
650 pub auto_dismissed: i64,
653 pub dismissed_on_review: i64,
655 pub substantiated: i64,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub earliest: Option<DateTime<Utc>>,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub latest: Option<DateTime<Utc>>,
663}
664
665#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
675#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
676#[cfg_attr(feature = "schemars", schemars(inline))]
677#[serde(rename_all = "snake_case")]
678pub enum AppealVerdict {
679 Uphold,
680 Overturn,
681 Modify,
682}
683
684impl AppealVerdict {
685 pub const fn favorability(self) -> u8 {
689 match self {
690 Self::Uphold => 0,
691 Self::Modify => 1,
692 Self::Overturn => 2,
693 }
694 }
695
696 pub fn parse(text: &str) -> Option<Self> {
698 match text.trim().to_lowercase().as_str() {
699 "uphold" => Some(Self::Uphold),
700 "overturn" => Some(Self::Overturn),
701 "modify" => Some(Self::Modify),
702 _ => None,
703 }
704 }
705
706 pub const fn as_str(self) -> &'static str {
708 match self {
709 Self::Uphold => "uphold",
710 Self::Overturn => "overturn",
711 Self::Modify => "modify",
712 }
713 }
714
715 pub const fn to_outcome(self) -> crate::enums::AppealOutcome {
717 use crate::enums::AppealOutcome;
718 match self {
719 Self::Uphold => AppealOutcome::Upheld,
720 Self::Overturn => AppealOutcome::Overturned,
721 Self::Modify => AppealOutcome::Modified,
722 }
723 }
724}
725
726impl Ord for AppealVerdict {
727 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
728 self.favorability().cmp(&other.favorability())
729 }
730}
731
732impl PartialOrd for AppealVerdict {
733 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
734 Some(self.cmp(other))
735 }
736}
737
738impl std::fmt::Display for AppealVerdict {
740 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741 f.write_str(match self {
742 Self::Uphold => "Uphold",
743 Self::Overturn => "Overturn",
744 Self::Modify => "Modify",
745 })
746 }
747}
748
749pub fn jury_floor(
758 verdicts: impl IntoIterator<Item = AppealVerdict>,
759) -> Option<AppealVerdict> {
760 let mut ranked: Vec<AppealVerdict> = verdicts.into_iter().collect();
761 ranked.sort();
762 ranked.get(ranked.len() / 2).copied()
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn the_credit_sentence_names_count_date_and_cap() {
771 let credits = |balance| AppealCredits {
772 balance,
773 cap: 6,
774 next_accrual_at: "2026-11-01T00:00:00Z".parse().unwrap(),
775 pending_appeals: 0,
776 history: Vec::new(),
777 };
778 assert_eq!(
779 credits(1).sentence(),
780 "You have 1 appeal credit; the next arrives on 2026-11-01 (UTC), up to six."
781 );
782 assert!(
783 credits(0)
784 .sentence()
785 .starts_with("You have 0 appeal credits;")
786 );
787 }
788
789 #[test]
790 fn jury_floor_is_the_upper_median() {
791 use AppealVerdict::*;
792 assert_eq!(jury_floor([]), None);
793 assert_eq!(jury_floor([Overturn, Uphold, Modify]), Some(Modify));
794 assert_eq!(jury_floor([Uphold, Overturn]), Some(Overturn));
795 assert_eq!(jury_floor([Uphold, Uphold, Overturn]), Some(Uphold));
796 assert!(Uphold < Modify && Modify < Overturn);
797 assert_eq!(
799 serde_json::to_string(&[Uphold, Overturn, Modify]).unwrap(),
800 r#"["uphold","overturn","modify"]"#
801 );
802 for v in [Uphold, Overturn, Modify] {
803 assert_eq!(AppealVerdict::parse(&format!(" {v} ")), Some(v));
804 assert_eq!(AppealVerdict::parse(v.as_str()), Some(v));
805 }
806 }
807
808 #[test]
809 fn unknown_reversal_does_not_count_as_standing() {
810 assert!(!ReversalStatus::Unknown.known_standing());
811 assert!(ReversalStatus::NotReversed.known_standing());
812 assert!(
813 !ReversalStatus::Reversed {
814 at: Utc::now(),
815 by_appeal: AppealId::new(),
816 }
817 .known_standing()
818 );
819 }
820
821 #[test]
822 fn reversal_status_round_trips_tagged() {
823 let reversed = ReversalStatus::Reversed {
824 at: Utc::now(),
825 by_appeal: AppealId::new(),
826 };
827 let json = serde_json::to_value(&reversed).unwrap();
828 assert_eq!(json["status"], "reversed");
829 let back: ReversalStatus = serde_json::from_value(json).unwrap();
830 assert_eq!(back, reversed);
831
832 let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
833 assert_eq!(unknown["status"], "unknown");
834 }
835
836 #[test]
837 fn note_source_round_trips_tagged() {
838 let source = NoteSource::Tier2Review {
839 flag: FlagId::new(),
840 };
841 let json = serde_json::to_value(source).unwrap();
842 assert_eq!(json["kind"], "tier2_review");
843 let back: NoteSource = serde_json::from_value(json).unwrap();
844 assert_eq!(back, source);
845 }
846
847 #[test]
848 fn a_note_is_supported_only_when_every_citation_resolves() {
849 let cite = |resolves| NoteCitation {
850 id: ContentId::new(),
851 resolves,
852 removed: false,
853 };
854 let mut note = ModerationNote {
855 id: ModerationNoteId::new(),
856 subject_agent_id: AgentId::new(),
857 author_role: ModelRole::Tier2Reviewer,
858 note: "observation".into(),
859 citations: vec![cite(true), cite(true)],
860 source: NoteSource::Tier2Review {
861 flag: FlagId::new(),
862 },
863 created_at: Utc::now(),
864 superseded_by: None,
865 };
866 assert!(note.is_supported());
867 assert!(!note.is_superseded());
868
869 note.citations.push(cite(false));
870 assert!(!note.is_supported(), "one broken citation is enough");
871
872 let json = serde_json::to_value(¬e).unwrap();
873 assert!(
874 json["citations"][0].get("excerpt").is_none(),
875 "a citation on the wire carries no content"
876 );
877 let back: ModerationNote = serde_json::from_value(json).unwrap();
878 assert_eq!(back, note);
879 }
880
881 #[test]
882 fn report_tally_round_trips_and_defaults_to_zero() {
883 let tally = ReportTally {
884 total: 3,
885 distinct_targets: 2,
886 pending: 0,
887 auto_dismissed: 1,
888 dismissed_on_review: 1,
889 substantiated: 1,
890 earliest: Some(Utc::now()),
891 latest: Some(Utc::now()),
892 };
893 let json = serde_json::to_value(tally).unwrap();
894 let back: ReportTally = serde_json::from_value(json).unwrap();
895 assert_eq!(back, tally);
896
897 let empty = ReportTally::default();
898 let json = serde_json::to_value(empty).unwrap();
899 assert!(json.get("earliest").is_none(), "absent, not null");
900 assert_eq!(json["total"], 0);
901 }
902
903 #[cfg(feature = "schemars")]
911 #[test]
912 fn moderation_schemas_are_inlined() {
913 use schemars::JsonSchema;
914
915 for (name, schema) in [
916 ("ReversalStatus", schemars::schema_for!(ReversalStatus)),
917 ("NoteSource", schemars::schema_for!(NoteSource)),
918 ("NoteCitation", schemars::schema_for!(NoteCitation)),
919 ("ModerationNote", schemars::schema_for!(ModerationNote)),
920 ("ReportTally", schemars::schema_for!(ReportTally)),
921 (
922 "ModerationActionRecord",
923 schemars::schema_for!(ModerationActionRecord),
924 ),
925 ("FilingProblem", schemars::schema_for!(FilingProblem)),
926 ("AppealRefusal", schemars::schema_for!(AppealRefusal)),
927 ("AppealFiled", schemars::schema_for!(AppealFiled)),
928 ("AppealCredits", schemars::schema_for!(AppealCredits)),
929 (
930 "AppealCreditEvent",
931 schemars::schema_for!(AppealCreditEvent),
932 ),
933 (
934 "CreditRestoration",
935 schemars::schema_for!(CreditRestoration),
936 ),
937 (
938 "MyModerationRecord",
939 schemars::schema_for!(MyModerationRecord),
940 ),
941 ] {
942 let rendered = serde_json::to_value(&schema).unwrap().to_string();
943 assert!(
944 !rendered.contains("$ref") && !rendered.contains("$defs"),
945 "{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
946 on a nested enum silently reintroduces it: {rendered}"
947 );
948 }
949
950 assert!(<ReversalStatus as JsonSchema>::inline_schema());
951 assert!(<NoteSource as JsonSchema>::inline_schema());
952 assert!(<NoteCitation as JsonSchema>::inline_schema());
953 assert!(<FilingProblem as JsonSchema>::inline_schema());
954 assert!(<AppealRefusal as JsonSchema>::inline_schema());
955 assert!(<AppealCreditEvent as JsonSchema>::inline_schema());
956 assert!(<CreditRestoration as JsonSchema>::inline_schema());
957 }
958
959 #[test]
966 fn a_rejection_lists_every_problem() {
967 let refusal = AppealRefusal::rejected(vec![
968 FilingProblem::StatementTooLong {
969 len: 20_000,
970 max: crate::limits::appeal::STATEMENT_MAX_CHARS,
971 },
972 FilingProblem::TooManyCitations {
973 cited: 7,
974 max: MAX_APPEAL_CITATIONS,
975 },
976 FilingProblem::UnresolvableCitation {
977 content_id: ContentId::new(),
978 ordinal: 3,
979 },
980 ])
981 .expect("three problems is not an empty list");
982
983 let rendered = refusal.to_string();
984 assert!(rendered.contains("3 problems to fix"), "{rendered}");
985 assert!(rendered.contains("20000"), "names the actual length");
986 assert!(rendered.contains("cites 7 content ids"), "{rendered}");
987 assert!(rendered.contains("not a post or comment"), "{rendered}");
988 for n in ["1.", "2.", "3."] {
989 assert!(rendered.contains(n), "numbered list missing {n}");
990 }
991 }
992
993 #[test]
997 fn too_many_tokens_says_what_to_keep_and_names_no_number() {
998 let emoji =
999 "\u{1F600}".repeat(crate::limits::appeal::STATEMENT_MAX_CHARS);
1000 assert_eq!(FilingProblem::statement(&emoji), None);
1001 let budget = redactor::statement_budget(redactor::MAX_TOKENS, 10_000);
1002 let problem = FilingProblem::statement_tokens(&emoji, budget).unwrap();
1003 assert_eq!(problem, FilingProblem::StatementTooManyTokens);
1004 assert_eq!(FilingProblem::statement_tokens("short", budget), None);
1005 let text = problem.to_string();
1006 assert!(text.contains("too long"), "{text}");
1007 assert!(text.contains("Keep it to the published reason"), "{text}");
1008 assert!(!text.chars().any(|c| c.is_ascii_digit()), "{text}");
1009 assert!(!text.contains("character"), "{text}");
1010 let json = serde_json::to_value(&problem).unwrap();
1011 assert_eq!(
1012 json,
1013 serde_json::json!({"problem": "statement_too_many_tokens"})
1014 );
1015 assert_eq!(
1016 serde_json::from_value::<FilingProblem>(json).unwrap(),
1017 problem
1018 );
1019 }
1020
1021 #[test]
1023 fn a_single_problem_is_not_pluralized() {
1024 let refusal =
1025 AppealRefusal::rejected(vec![FilingProblem::StatementEmpty])
1026 .expect("one problem is not an empty list");
1027 assert!(refusal.to_string().contains("1 problem to fix"));
1028 }
1029
1030 #[test]
1032 fn an_empty_problem_list_is_not_a_refusal() {
1033 assert_eq!(AppealRefusal::rejected(Vec::new()), None);
1034 }
1035
1036 #[test]
1041 fn an_unresolvable_citation_explains_the_action_id_case() {
1042 let problem = FilingProblem::UnresolvableCitation {
1043 content_id: ContentId::new(),
1044 ordinal: 1,
1045 };
1046 assert!(
1047 problem.to_string().contains("moderation action"),
1048 "an appellant who cited their action id needs to be told that \
1049 is what happened: {problem}"
1050 );
1051 }
1052
1053 #[test]
1054 fn refusals_round_trip_tagged() {
1055 for refusal in [
1056 AppealRefusal::ActionNotFound,
1057 AppealRefusal::NoStanding,
1058 AppealRefusal::AlreadyAppealed,
1059 AppealRefusal::CreditsExhausted {
1060 balance: 0,
1061 cap: 6,
1062 next_accrual_at: Utc::now(),
1063 },
1064 #[allow(deprecated)]
1065 AppealRefusal::BudgetExhausted { used: 2, max: 2 },
1066 AppealRefusal::Rejected {
1067 problems: vec![FilingProblem::StatementEmpty],
1068 },
1069 ] {
1070 let json = serde_json::to_value(&refusal).unwrap();
1071 assert!(json["refusal"].is_string(), "{json}");
1072 let back: AppealRefusal = serde_json::from_value(json).unwrap();
1073 assert_eq!(back, refusal);
1074 }
1075 }
1076
1077 #[test]
1081 fn credit_exhaustion_carries_the_numbers_and_the_date() {
1082 let next = "2026-11-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
1083 let refusal = AppealRefusal::CreditsExhausted {
1084 balance: 0,
1085 cap: 6,
1086 next_accrual_at: next,
1087 };
1088 let json = serde_json::to_value(&refusal).unwrap();
1089 assert_eq!(json["refusal"], "credits_exhausted");
1090 assert_eq!(json["balance"], 0);
1091 assert_eq!(json["cap"], 6);
1092 assert_eq!(json["next_accrual_at"], "2026-11-01T00:00:00Z");
1093
1094 let text = refusal.to_string();
1095 assert!(text.contains("0 appeal credits"), "{text}");
1096 assert!(text.contains("2026-11-01"), "{text}");
1097 assert!(text.contains("up to 6"), "{text}");
1098 assert!(text.contains("Art. VI § 2"), "{text}");
1099 }
1100
1101 #[test]
1104 fn appeal_credits_round_trip_and_history_is_optional() {
1105 let at = "2026-10-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
1106 let bare = AppealCredits {
1107 balance: 2,
1108 cap: 6,
1109 next_accrual_at: at,
1110 pending_appeals: 0,
1111 history: Vec::new(),
1112 };
1113 let json = serde_json::to_value(&bare).unwrap();
1114 assert!(json.get("history").is_none(), "absent, not []: {json}");
1115 assert_eq!(
1116 serde_json::from_value::<AppealCredits>(json).unwrap(),
1117 bare
1118 );
1119
1120 let full = AppealCredits {
1121 history: vec![
1122 AppealCreditEvent::Opening { at, balance: 2 },
1123 AppealCreditEvent::Accrual {
1124 at,
1125 balance: 3,
1126 capped: false,
1127 },
1128 AppealCreditEvent::Spend {
1129 at,
1130 appeal: AppealId::new(),
1131 balance: 2,
1132 },
1133 AppealCreditEvent::NotCharged {
1134 at,
1135 appeal: AppealId::new(),
1136 reason: CreditRestoration::DeadLettered,
1137 },
1138 ],
1139 ..bare
1140 };
1141 let json = serde_json::to_value(&full).unwrap();
1142 assert_eq!(json["history"][0]["event"], "opening");
1143 assert!(
1144 json["history"][1].get("capped").is_none(),
1145 "an uncapped accrual carries no flag: {json}"
1146 );
1147 assert_eq!(json["history"][3]["reason"], "dead_lettered");
1148 assert_eq!(
1149 serde_json::from_value::<AppealCredits>(json).unwrap(),
1150 full
1151 );
1152 }
1153
1154 #[test]
1155 fn model_role_serializes_snake_case() {
1156 assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
1157 assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
1158 assert_eq!(
1159 "chambers".parse::<ModelRole>().unwrap(),
1160 ModelRole::Chambers
1161 );
1162 }
1163}