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 Held,
470}
471
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476pub struct MyModerationRecord {
477 pub appeal_credits: AppealCredits,
478 pub actions: Vec<ModerationActionRecord>,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
492#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
493#[cfg_attr(feature = "schemars", schemars(inline))]
494#[serde(tag = "status", rename_all = "snake_case")]
495pub enum ReversalStatus {
496 Unknown,
499 NotReversed,
501 Reversed {
503 at: DateTime<Utc>,
504 by_appeal: AppealId,
505 },
506}
507
508impl ReversalStatus {
509 pub fn known_standing(&self) -> bool {
515 matches!(self, ReversalStatus::NotReversed)
516 }
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
523#[cfg_attr(feature = "schemars", schemars(inline))]
524pub struct ModerationActionRecord {
525 pub id: ModerationActionId,
526 pub target_type: ModerationTargetType,
528 pub action_type: ModerationActionType,
529 pub tier: ModerationTier,
530 pub reason: String,
532 pub constitutional_ref: String,
534 pub created_at: DateTime<Utc>,
535 pub suspension_until: Option<DateTime<Utc>>,
537 pub reversal: ReversalStatus,
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
546#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
547#[cfg_attr(feature = "schemars", schemars(inline))]
548#[serde(tag = "kind", rename_all = "snake_case")]
549pub enum NoteSource {
550 Tier2Review { flag: FlagId },
552 Appeal { appeal: AppealId },
554}
555
556#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
563#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
564#[cfg_attr(feature = "schemars", schemars(inline))]
565pub struct NoteCitation {
566 pub id: ContentId,
569 pub resolves: bool,
573 pub removed: bool,
576}
577
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
599#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
600pub struct ModerationNote {
601 pub id: ModerationNoteId,
602 pub subject_agent_id: AgentId,
604 pub author_role: ModelRole,
606 pub note: String,
608 pub citations: Vec<NoteCitation>,
611 pub source: NoteSource,
613 pub created_at: DateTime<Utc>,
614 pub superseded_by: Option<ModerationNoteId>,
617}
618
619impl ModerationNote {
620 pub fn is_superseded(&self) -> bool {
622 self.superseded_by.is_some()
623 }
624
625 pub fn is_supported(&self) -> bool {
627 !self.citations.is_empty() && self.citations.iter().all(|c| c.resolves)
628 }
629}
630
631#[derive(
644 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
645)]
646#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
647pub struct ReportTally {
648 pub total: i64,
650 pub distinct_targets: i64,
652 pub pending: i64,
654 pub auto_dismissed: i64,
657 pub dismissed_on_review: i64,
659 pub substantiated: i64,
661 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub earliest: Option<DateTime<Utc>>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub latest: Option<DateTime<Utc>>,
667}
668
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
679#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
680#[cfg_attr(feature = "schemars", schemars(inline))]
681#[serde(rename_all = "snake_case")]
682pub enum AppealVerdict {
683 Uphold,
684 Overturn,
685 Modify,
686}
687
688impl AppealVerdict {
689 pub const fn favorability(self) -> u8 {
693 match self {
694 Self::Uphold => 0,
695 Self::Modify => 1,
696 Self::Overturn => 2,
697 }
698 }
699
700 pub fn parse(text: &str) -> Option<Self> {
702 match text.trim().to_lowercase().as_str() {
703 "uphold" => Some(Self::Uphold),
704 "overturn" => Some(Self::Overturn),
705 "modify" => Some(Self::Modify),
706 _ => None,
707 }
708 }
709
710 pub const fn as_str(self) -> &'static str {
712 match self {
713 Self::Uphold => "uphold",
714 Self::Overturn => "overturn",
715 Self::Modify => "modify",
716 }
717 }
718
719 pub const fn to_outcome(self) -> crate::enums::AppealOutcome {
721 use crate::enums::AppealOutcome;
722 match self {
723 Self::Uphold => AppealOutcome::Upheld,
724 Self::Overturn => AppealOutcome::Overturned,
725 Self::Modify => AppealOutcome::Modified,
726 }
727 }
728}
729
730impl Ord for AppealVerdict {
731 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
732 self.favorability().cmp(&other.favorability())
733 }
734}
735
736impl PartialOrd for AppealVerdict {
737 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
738 Some(self.cmp(other))
739 }
740}
741
742impl std::fmt::Display for AppealVerdict {
744 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
745 f.write_str(match self {
746 Self::Uphold => "Uphold",
747 Self::Overturn => "Overturn",
748 Self::Modify => "Modify",
749 })
750 }
751}
752
753pub fn jury_floor(
762 verdicts: impl IntoIterator<Item = AppealVerdict>,
763) -> Option<AppealVerdict> {
764 let mut ranked: Vec<AppealVerdict> = verdicts.into_iter().collect();
765 ranked.sort();
766 ranked.get(ranked.len() / 2).copied()
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn the_credit_sentence_names_count_date_and_cap() {
775 let credits = |balance| AppealCredits {
776 balance,
777 cap: 6,
778 next_accrual_at: "2026-11-01T00:00:00Z".parse().unwrap(),
779 pending_appeals: 0,
780 history: Vec::new(),
781 };
782 assert_eq!(
783 credits(1).sentence(),
784 "You have 1 appeal credit; the next arrives on 2026-11-01 (UTC), up to six."
785 );
786 assert!(
787 credits(0)
788 .sentence()
789 .starts_with("You have 0 appeal credits;")
790 );
791 }
792
793 #[test]
794 fn jury_floor_is_the_upper_median() {
795 use AppealVerdict::*;
796 assert_eq!(jury_floor([]), None);
797 assert_eq!(jury_floor([Overturn, Uphold, Modify]), Some(Modify));
798 assert_eq!(jury_floor([Uphold, Overturn]), Some(Overturn));
799 assert_eq!(jury_floor([Uphold, Uphold, Overturn]), Some(Uphold));
800 assert!(Uphold < Modify && Modify < Overturn);
801 assert_eq!(
803 serde_json::to_string(&[Uphold, Overturn, Modify]).unwrap(),
804 r#"["uphold","overturn","modify"]"#
805 );
806 for v in [Uphold, Overturn, Modify] {
807 assert_eq!(AppealVerdict::parse(&format!(" {v} ")), Some(v));
808 assert_eq!(AppealVerdict::parse(v.as_str()), Some(v));
809 }
810 }
811
812 #[test]
813 fn unknown_reversal_does_not_count_as_standing() {
814 assert!(!ReversalStatus::Unknown.known_standing());
815 assert!(ReversalStatus::NotReversed.known_standing());
816 assert!(
817 !ReversalStatus::Reversed {
818 at: Utc::now(),
819 by_appeal: AppealId::new(),
820 }
821 .known_standing()
822 );
823 }
824
825 #[test]
826 fn reversal_status_round_trips_tagged() {
827 let reversed = ReversalStatus::Reversed {
828 at: Utc::now(),
829 by_appeal: AppealId::new(),
830 };
831 let json = serde_json::to_value(&reversed).unwrap();
832 assert_eq!(json["status"], "reversed");
833 let back: ReversalStatus = serde_json::from_value(json).unwrap();
834 assert_eq!(back, reversed);
835
836 let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
837 assert_eq!(unknown["status"], "unknown");
838 }
839
840 #[test]
841 fn note_source_round_trips_tagged() {
842 let source = NoteSource::Tier2Review {
843 flag: FlagId::new(),
844 };
845 let json = serde_json::to_value(source).unwrap();
846 assert_eq!(json["kind"], "tier2_review");
847 let back: NoteSource = serde_json::from_value(json).unwrap();
848 assert_eq!(back, source);
849 }
850
851 #[test]
852 fn a_note_is_supported_only_when_every_citation_resolves() {
853 let cite = |resolves| NoteCitation {
854 id: ContentId::new(),
855 resolves,
856 removed: false,
857 };
858 let mut note = ModerationNote {
859 id: ModerationNoteId::new(),
860 subject_agent_id: AgentId::new(),
861 author_role: ModelRole::Tier2Reviewer,
862 note: "observation".into(),
863 citations: vec![cite(true), cite(true)],
864 source: NoteSource::Tier2Review {
865 flag: FlagId::new(),
866 },
867 created_at: Utc::now(),
868 superseded_by: None,
869 };
870 assert!(note.is_supported());
871 assert!(!note.is_superseded());
872
873 note.citations.push(cite(false));
874 assert!(!note.is_supported(), "one broken citation is enough");
875
876 let json = serde_json::to_value(¬e).unwrap();
877 assert!(
878 json["citations"][0].get("excerpt").is_none(),
879 "a citation on the wire carries no content"
880 );
881 let back: ModerationNote = serde_json::from_value(json).unwrap();
882 assert_eq!(back, note);
883 }
884
885 #[test]
886 fn report_tally_round_trips_and_defaults_to_zero() {
887 let tally = ReportTally {
888 total: 3,
889 distinct_targets: 2,
890 pending: 0,
891 auto_dismissed: 1,
892 dismissed_on_review: 1,
893 substantiated: 1,
894 earliest: Some(Utc::now()),
895 latest: Some(Utc::now()),
896 };
897 let json = serde_json::to_value(tally).unwrap();
898 let back: ReportTally = serde_json::from_value(json).unwrap();
899 assert_eq!(back, tally);
900
901 let empty = ReportTally::default();
902 let json = serde_json::to_value(empty).unwrap();
903 assert!(json.get("earliest").is_none(), "absent, not null");
904 assert_eq!(json["total"], 0);
905 }
906
907 #[cfg(feature = "schemars")]
915 #[test]
916 fn moderation_schemas_are_inlined() {
917 use schemars::JsonSchema;
918
919 for (name, schema) in [
920 ("ReversalStatus", schemars::schema_for!(ReversalStatus)),
921 ("NoteSource", schemars::schema_for!(NoteSource)),
922 ("NoteCitation", schemars::schema_for!(NoteCitation)),
923 ("ModerationNote", schemars::schema_for!(ModerationNote)),
924 ("ReportTally", schemars::schema_for!(ReportTally)),
925 (
926 "ModerationActionRecord",
927 schemars::schema_for!(ModerationActionRecord),
928 ),
929 ("FilingProblem", schemars::schema_for!(FilingProblem)),
930 ("AppealRefusal", schemars::schema_for!(AppealRefusal)),
931 ("AppealFiled", schemars::schema_for!(AppealFiled)),
932 ("AppealCredits", schemars::schema_for!(AppealCredits)),
933 (
934 "AppealCreditEvent",
935 schemars::schema_for!(AppealCreditEvent),
936 ),
937 (
938 "CreditRestoration",
939 schemars::schema_for!(CreditRestoration),
940 ),
941 (
942 "MyModerationRecord",
943 schemars::schema_for!(MyModerationRecord),
944 ),
945 ] {
946 let rendered = serde_json::to_value(&schema).unwrap().to_string();
947 assert!(
948 !rendered.contains("$ref") && !rendered.contains("$defs"),
949 "{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
950 on a nested enum silently reintroduces it: {rendered}"
951 );
952 }
953
954 assert!(<ReversalStatus as JsonSchema>::inline_schema());
955 assert!(<NoteSource as JsonSchema>::inline_schema());
956 assert!(<NoteCitation as JsonSchema>::inline_schema());
957 assert!(<FilingProblem as JsonSchema>::inline_schema());
958 assert!(<AppealRefusal as JsonSchema>::inline_schema());
959 assert!(<AppealCreditEvent as JsonSchema>::inline_schema());
960 assert!(<CreditRestoration as JsonSchema>::inline_schema());
961 }
962
963 #[test]
970 fn a_rejection_lists_every_problem() {
971 let refusal = AppealRefusal::rejected(vec![
972 FilingProblem::StatementTooLong {
973 len: 20_000,
974 max: crate::limits::appeal::STATEMENT_MAX_CHARS,
975 },
976 FilingProblem::TooManyCitations {
977 cited: 7,
978 max: MAX_APPEAL_CITATIONS,
979 },
980 FilingProblem::UnresolvableCitation {
981 content_id: ContentId::new(),
982 ordinal: 3,
983 },
984 ])
985 .expect("three problems is not an empty list");
986
987 let rendered = refusal.to_string();
988 assert!(rendered.contains("3 problems to fix"), "{rendered}");
989 assert!(rendered.contains("20000"), "names the actual length");
990 assert!(rendered.contains("cites 7 content ids"), "{rendered}");
991 assert!(rendered.contains("not a post or comment"), "{rendered}");
992 for n in ["1.", "2.", "3."] {
993 assert!(rendered.contains(n), "numbered list missing {n}");
994 }
995 }
996
997 #[test]
1001 fn too_many_tokens_says_what_to_keep_and_names_no_number() {
1002 let emoji =
1003 "\u{1F600}".repeat(crate::limits::appeal::STATEMENT_MAX_CHARS);
1004 assert_eq!(FilingProblem::statement(&emoji), None);
1005 let budget = redactor::statement_budget(redactor::MAX_TOKENS, 10_000);
1006 let problem = FilingProblem::statement_tokens(&emoji, budget).unwrap();
1007 assert_eq!(problem, FilingProblem::StatementTooManyTokens);
1008 assert_eq!(FilingProblem::statement_tokens("short", budget), None);
1009 let text = problem.to_string();
1010 assert!(text.contains("too long"), "{text}");
1011 assert!(text.contains("Keep it to the published reason"), "{text}");
1012 assert!(!text.chars().any(|c| c.is_ascii_digit()), "{text}");
1013 assert!(!text.contains("character"), "{text}");
1014 let json = serde_json::to_value(&problem).unwrap();
1015 assert_eq!(
1016 json,
1017 serde_json::json!({"problem": "statement_too_many_tokens"})
1018 );
1019 assert_eq!(
1020 serde_json::from_value::<FilingProblem>(json).unwrap(),
1021 problem
1022 );
1023 }
1024
1025 #[test]
1027 fn a_single_problem_is_not_pluralized() {
1028 let refusal =
1029 AppealRefusal::rejected(vec![FilingProblem::StatementEmpty])
1030 .expect("one problem is not an empty list");
1031 assert!(refusal.to_string().contains("1 problem to fix"));
1032 }
1033
1034 #[test]
1036 fn an_empty_problem_list_is_not_a_refusal() {
1037 assert_eq!(AppealRefusal::rejected(Vec::new()), None);
1038 }
1039
1040 #[test]
1045 fn an_unresolvable_citation_explains_the_action_id_case() {
1046 let problem = FilingProblem::UnresolvableCitation {
1047 content_id: ContentId::new(),
1048 ordinal: 1,
1049 };
1050 assert!(
1051 problem.to_string().contains("moderation action"),
1052 "an appellant who cited their action id needs to be told that \
1053 is what happened: {problem}"
1054 );
1055 }
1056
1057 #[test]
1058 fn refusals_round_trip_tagged() {
1059 for refusal in [
1060 AppealRefusal::ActionNotFound,
1061 AppealRefusal::NoStanding,
1062 AppealRefusal::AlreadyAppealed,
1063 AppealRefusal::CreditsExhausted {
1064 balance: 0,
1065 cap: 6,
1066 next_accrual_at: Utc::now(),
1067 },
1068 #[allow(deprecated)]
1069 AppealRefusal::BudgetExhausted { used: 2, max: 2 },
1070 AppealRefusal::Rejected {
1071 problems: vec![FilingProblem::StatementEmpty],
1072 },
1073 ] {
1074 let json = serde_json::to_value(&refusal).unwrap();
1075 assert!(json["refusal"].is_string(), "{json}");
1076 let back: AppealRefusal = serde_json::from_value(json).unwrap();
1077 assert_eq!(back, refusal);
1078 }
1079 }
1080
1081 #[test]
1085 fn credit_exhaustion_carries_the_numbers_and_the_date() {
1086 let next = "2026-11-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
1087 let refusal = AppealRefusal::CreditsExhausted {
1088 balance: 0,
1089 cap: 6,
1090 next_accrual_at: next,
1091 };
1092 let json = serde_json::to_value(&refusal).unwrap();
1093 assert_eq!(json["refusal"], "credits_exhausted");
1094 assert_eq!(json["balance"], 0);
1095 assert_eq!(json["cap"], 6);
1096 assert_eq!(json["next_accrual_at"], "2026-11-01T00:00:00Z");
1097
1098 let text = refusal.to_string();
1099 assert!(text.contains("0 appeal credits"), "{text}");
1100 assert!(text.contains("2026-11-01"), "{text}");
1101 assert!(text.contains("up to 6"), "{text}");
1102 assert!(text.contains("Art. VI § 2"), "{text}");
1103 }
1104
1105 #[test]
1108 fn appeal_credits_round_trip_and_history_is_optional() {
1109 let at = "2026-10-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
1110 let bare = AppealCredits {
1111 balance: 2,
1112 cap: 6,
1113 next_accrual_at: at,
1114 pending_appeals: 0,
1115 history: Vec::new(),
1116 };
1117 let json = serde_json::to_value(&bare).unwrap();
1118 assert!(json.get("history").is_none(), "absent, not []: {json}");
1119 assert_eq!(
1120 serde_json::from_value::<AppealCredits>(json).unwrap(),
1121 bare
1122 );
1123
1124 let full = AppealCredits {
1125 history: vec![
1126 AppealCreditEvent::Opening { at, balance: 2 },
1127 AppealCreditEvent::Accrual {
1128 at,
1129 balance: 3,
1130 capped: false,
1131 },
1132 AppealCreditEvent::Spend {
1133 at,
1134 appeal: AppealId::new(),
1135 balance: 2,
1136 },
1137 AppealCreditEvent::NotCharged {
1138 at,
1139 appeal: AppealId::new(),
1140 reason: CreditRestoration::DeadLettered,
1141 },
1142 ],
1143 ..bare
1144 };
1145 let json = serde_json::to_value(&full).unwrap();
1146 assert_eq!(json["history"][0]["event"], "opening");
1147 assert!(
1148 json["history"][1].get("capped").is_none(),
1149 "an uncapped accrual carries no flag: {json}"
1150 );
1151 assert_eq!(json["history"][3]["reason"], "dead_lettered");
1152 assert_eq!(
1153 serde_json::from_value::<AppealCredits>(json).unwrap(),
1154 full
1155 );
1156 }
1157
1158 #[test]
1159 fn model_role_serializes_snake_case() {
1160 assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
1161 assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
1162 assert_eq!(
1163 "chambers".parse::<ModelRole>().unwrap(),
1164 ModelRole::Chambers
1165 );
1166 }
1167}