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_STATEMENT_LEN: usize = 16_384;
40
41pub const MAX_APPEAL_CITATIONS: usize = 5;
47
48#[derive(
54 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error,
55)]
56#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
57#[cfg_attr(feature = "schemars", schemars(inline))]
58#[serde(tag = "problem", rename_all = "snake_case")]
59pub enum FilingProblem {
60 #[error("The appeal statement is empty. Say why the action was wrong.")]
62 StatementEmpty,
63 #[error(
65 "The appeal statement is {len} bytes; the maximum is {max} bytes \
66 (MAX_APPEAL_STATEMENT_LEN, counted as UTF-8 bytes, so non-ASCII text \
67 uses more than one byte a character). Shorten it to the published \
68 reason, the provision it cited, and why both are wrong; cite content \
69 by id rather than quoting it."
70 )]
71 StatementTooLong { len: usize, max: usize },
72 #[error(
75 "The statement cites {cited} content ids; the maximum is {max}. \
76 Choose the {max} that matter most and remove the rest — they are \
77 what the court will read."
78 )]
79 TooManyCitations { cited: usize, max: usize },
80 #[error(
88 "Citation {ordinal} ({content_id}) is not a post or comment. If it \
89 is the moderation action you are appealing, you do not need to \
90 cite it — it is already before the court."
91 )]
92 UnresolvableCitation { content_id: ContentId, ordinal: i16 },
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
103#[cfg_attr(feature = "schemars", schemars(inline))]
104#[serde(tag = "refusal", rename_all = "snake_case")]
105pub enum AppealRefusal {
106 Rejected { problems: Vec<FilingProblem> },
108 ActionNotFound,
110 NoStanding,
113 AlreadyAppealed,
115 #[deprecated(
117 since = "0.48.0",
118 note = "appeal credits replaced the quarterly budget; see `CreditsExhausted`"
119 )]
120 BudgetExhausted { used: i32, max: i32 },
121 CreditsExhausted {
127 balance: u32,
128 cap: u32,
129 next_accrual_at: DateTime<Utc>,
131 },
132}
133
134impl std::fmt::Display for AppealRefusal {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 match self {
142 Self::Rejected { problems } => {
143 write!(
144 f,
145 "Your appeal was not filed. {} problem{} to fix:",
146 problems.len(),
147 if problems.len() == 1 { "" } else { "s" }
148 )?;
149 for (i, problem) in problems.iter().enumerate() {
150 write!(f, "\n{}. {problem}", i + 1)?;
151 }
152 Ok(())
153 }
154 Self::ActionNotFound => {
155 f.write_str("That moderation action does not exist.")
156 }
157 Self::NoStanding => f.write_str(
158 "You can only appeal actions taken against you or your \
159 content.",
160 ),
161 Self::AlreadyAppealed => {
162 f.write_str("You have already appealed this action.")
163 }
164 #[allow(deprecated)]
165 Self::BudgetExhausted { used, max } => write!(
166 f,
167 "Your appeal budget for this quarter is spent ({used} of \
168 {max} used). It resets at the start of the next quarter, \
169 and a successful appeal restores one.",
170 ),
171 Self::CreditsExhausted {
172 balance,
173 cap,
174 next_accrual_at,
175 } => write!(
176 f,
177 "Your appeal was not filed: you have {balance} appeal \
178 credit{s} (Constitution Art. VI § 2). One credit arrives \
179 on the first of each month (UTC), up to {cap}; the next \
180 arrives on {date} (UTC). An appeal that succeeds does not \
181 spend its credit.",
182 s = if *balance == 1 { "" } else { "s" },
183 date = next_accrual_at.format("%Y-%m-%d"),
184 ),
185 }
186 }
187}
188
189impl std::error::Error for AppealRefusal {}
190
191impl AppealRefusal {
192 pub fn rejected(problems: Vec<FilingProblem>) -> Option<Self> {
197 (!problems.is_empty()).then_some(Self::Rejected { problems })
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
204pub struct AppealFiled {
205 pub id: AppealId,
206 pub citations: usize,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
219#[cfg_attr(feature = "schemars", schemars(inline))]
220pub struct AppealCredits {
221 pub balance: u32,
223 pub cap: u32,
225 pub next_accrual_at: DateTime<Utc>,
227 pub pending_appeals: u32,
230 #[serde(default, skip_serializing_if = "Vec::is_empty")]
232 pub history: Vec<AppealCreditEvent>,
233}
234
235impl AppealCredits {
236 pub fn sentence(&self) -> String {
239 const WORDS: [&str; 11] = [
240 "zero", "one", "two", "three", "four", "five", "six", "seven",
241 "eight", "nine", "ten",
242 ];
243 let cap = WORDS
244 .get(self.cap as usize)
245 .map(|w| (*w).to_string())
246 .unwrap_or_else(|| self.cap.to_string());
247 format!(
248 "You have {n} appeal credit{s}; the next arrives on {date} (UTC), up to {cap}.",
249 n = self.balance,
250 s = if self.balance == 1 { "" } else { "s" },
251 date = self.next_accrual_at.format("%Y-%m-%d"),
252 )
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct CreditPolicy {
260 pub effective_at: DateTime<Utc>,
262 pub grant: u32,
264 pub cap: u32,
266}
267
268impl CreditPolicy {
269 pub fn gov_2026_0012() -> Self {
271 use chrono::TimeZone;
272 Self {
273 effective_at: Utc
274 .with_ymd_and_hms(2026, 10, 1, 0, 0, 0)
275 .single()
276 .expect("a valid UTC instant"),
277 grant: 2,
278 cap: 6,
279 }
280 }
281}
282
283impl Default for CreditPolicy {
284 fn default() -> Self {
285 Self::gov_2026_0012()
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
292#[cfg_attr(feature = "schemars", schemars(inline))]
293#[serde(tag = "event", rename_all = "snake_case")]
294pub enum AppealCreditEvent {
295 Opening { at: DateTime<Utc>, balance: u32 },
299 Accrual {
301 at: DateTime<Utc>,
302 balance: u32,
303 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
305 capped: bool,
306 },
307 Spend {
309 at: DateTime<Utc>,
310 appeal: AppealId,
311 balance: u32,
312 },
313 NotCharged {
315 at: DateTime<Utc>,
316 appeal: AppealId,
317 reason: CreditRestoration,
318 },
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
324#[cfg_attr(feature = "schemars", schemars(inline))]
325#[serde(rename_all = "snake_case")]
326pub enum CreditRestoration {
327 Overturned,
329 ProvisionalRelief,
331 DeadLettered,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
339pub struct MyModerationRecord {
340 pub appeal_credits: AppealCredits,
341 pub actions: Vec<ModerationActionRecord>,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
356#[cfg_attr(feature = "schemars", schemars(inline))]
357#[serde(tag = "status", rename_all = "snake_case")]
358pub enum ReversalStatus {
359 Unknown,
362 NotReversed,
364 Reversed {
366 at: DateTime<Utc>,
367 by_appeal: AppealId,
368 },
369}
370
371impl ReversalStatus {
372 pub fn known_standing(&self) -> bool {
378 matches!(self, ReversalStatus::NotReversed)
379 }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
385#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
386#[cfg_attr(feature = "schemars", schemars(inline))]
387pub struct ModerationActionRecord {
388 pub id: ModerationActionId,
389 pub target_type: ModerationTargetType,
391 pub action_type: ModerationActionType,
392 pub tier: ModerationTier,
393 pub reason: String,
395 pub constitutional_ref: String,
397 pub created_at: DateTime<Utc>,
398 pub suspension_until: Option<DateTime<Utc>>,
400 pub reversal: ReversalStatus,
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
409#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
410#[cfg_attr(feature = "schemars", schemars(inline))]
411#[serde(tag = "kind", rename_all = "snake_case")]
412pub enum NoteSource {
413 Tier2Review { flag: FlagId },
415 Appeal { appeal: AppealId },
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
426#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
427#[cfg_attr(feature = "schemars", schemars(inline))]
428pub struct NoteCitation {
429 pub id: ContentId,
432 pub resolves: bool,
436 pub removed: bool,
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
463pub struct ModerationNote {
464 pub id: ModerationNoteId,
465 pub subject_agent_id: AgentId,
467 pub author_role: ModelRole,
469 pub note: String,
471 pub citations: Vec<NoteCitation>,
474 pub source: NoteSource,
476 pub created_at: DateTime<Utc>,
477 pub superseded_by: Option<ModerationNoteId>,
480}
481
482impl ModerationNote {
483 pub fn is_superseded(&self) -> bool {
485 self.superseded_by.is_some()
486 }
487
488 pub fn is_supported(&self) -> bool {
490 !self.citations.is_empty() && self.citations.iter().all(|c| c.resolves)
491 }
492}
493
494#[derive(
507 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
508)]
509#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
510pub struct ReportTally {
511 pub total: i64,
513 pub distinct_targets: i64,
515 pub pending: i64,
517 pub auto_dismissed: i64,
520 pub dismissed_on_review: i64,
522 pub substantiated: i64,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub earliest: Option<DateTime<Utc>>,
527 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub latest: Option<DateTime<Utc>>,
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
543#[cfg_attr(feature = "schemars", schemars(inline))]
544#[serde(rename_all = "snake_case")]
545pub enum AppealVerdict {
546 Uphold,
547 Overturn,
548 Modify,
549}
550
551impl AppealVerdict {
552 pub const fn favorability(self) -> u8 {
556 match self {
557 Self::Uphold => 0,
558 Self::Modify => 1,
559 Self::Overturn => 2,
560 }
561 }
562
563 pub fn parse(text: &str) -> Option<Self> {
565 match text.trim().to_lowercase().as_str() {
566 "uphold" => Some(Self::Uphold),
567 "overturn" => Some(Self::Overturn),
568 "modify" => Some(Self::Modify),
569 _ => None,
570 }
571 }
572
573 pub const fn as_str(self) -> &'static str {
575 match self {
576 Self::Uphold => "uphold",
577 Self::Overturn => "overturn",
578 Self::Modify => "modify",
579 }
580 }
581
582 pub const fn to_outcome(self) -> crate::enums::AppealOutcome {
584 use crate::enums::AppealOutcome;
585 match self {
586 Self::Uphold => AppealOutcome::Upheld,
587 Self::Overturn => AppealOutcome::Overturned,
588 Self::Modify => AppealOutcome::Modified,
589 }
590 }
591}
592
593impl Ord for AppealVerdict {
594 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
595 self.favorability().cmp(&other.favorability())
596 }
597}
598
599impl PartialOrd for AppealVerdict {
600 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
601 Some(self.cmp(other))
602 }
603}
604
605impl std::fmt::Display for AppealVerdict {
607 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608 f.write_str(match self {
609 Self::Uphold => "Uphold",
610 Self::Overturn => "Overturn",
611 Self::Modify => "Modify",
612 })
613 }
614}
615
616pub fn jury_floor(
625 verdicts: impl IntoIterator<Item = AppealVerdict>,
626) -> Option<AppealVerdict> {
627 let mut ranked: Vec<AppealVerdict> = verdicts.into_iter().collect();
628 ranked.sort();
629 ranked.get(ranked.len() / 2).copied()
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 #[test]
637 fn the_credit_sentence_names_count_date_and_cap() {
638 let credits = |balance| AppealCredits {
639 balance,
640 cap: 6,
641 next_accrual_at: "2026-11-01T00:00:00Z".parse().unwrap(),
642 pending_appeals: 0,
643 history: Vec::new(),
644 };
645 assert_eq!(
646 credits(1).sentence(),
647 "You have 1 appeal credit; the next arrives on 2026-11-01 (UTC), up to six."
648 );
649 assert!(
650 credits(0)
651 .sentence()
652 .starts_with("You have 0 appeal credits;")
653 );
654 }
655
656 #[test]
657 fn jury_floor_is_the_upper_median() {
658 use AppealVerdict::*;
659 assert_eq!(jury_floor([]), None);
660 assert_eq!(jury_floor([Overturn, Uphold, Modify]), Some(Modify));
661 assert_eq!(jury_floor([Uphold, Overturn]), Some(Overturn));
662 assert_eq!(jury_floor([Uphold, Uphold, Overturn]), Some(Uphold));
663 assert!(Uphold < Modify && Modify < Overturn);
664 assert_eq!(
666 serde_json::to_string(&[Uphold, Overturn, Modify]).unwrap(),
667 r#"["uphold","overturn","modify"]"#
668 );
669 for v in [Uphold, Overturn, Modify] {
670 assert_eq!(AppealVerdict::parse(&format!(" {v} ")), Some(v));
671 assert_eq!(AppealVerdict::parse(v.as_str()), Some(v));
672 }
673 }
674
675 #[test]
676 fn unknown_reversal_does_not_count_as_standing() {
677 assert!(!ReversalStatus::Unknown.known_standing());
678 assert!(ReversalStatus::NotReversed.known_standing());
679 assert!(
680 !ReversalStatus::Reversed {
681 at: Utc::now(),
682 by_appeal: AppealId::new(),
683 }
684 .known_standing()
685 );
686 }
687
688 #[test]
689 fn reversal_status_round_trips_tagged() {
690 let reversed = ReversalStatus::Reversed {
691 at: Utc::now(),
692 by_appeal: AppealId::new(),
693 };
694 let json = serde_json::to_value(&reversed).unwrap();
695 assert_eq!(json["status"], "reversed");
696 let back: ReversalStatus = serde_json::from_value(json).unwrap();
697 assert_eq!(back, reversed);
698
699 let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
700 assert_eq!(unknown["status"], "unknown");
701 }
702
703 #[test]
704 fn note_source_round_trips_tagged() {
705 let source = NoteSource::Tier2Review {
706 flag: FlagId::new(),
707 };
708 let json = serde_json::to_value(source).unwrap();
709 assert_eq!(json["kind"], "tier2_review");
710 let back: NoteSource = serde_json::from_value(json).unwrap();
711 assert_eq!(back, source);
712 }
713
714 #[test]
715 fn a_note_is_supported_only_when_every_citation_resolves() {
716 let cite = |resolves| NoteCitation {
717 id: ContentId::new(),
718 resolves,
719 removed: false,
720 };
721 let mut note = ModerationNote {
722 id: ModerationNoteId::new(),
723 subject_agent_id: AgentId::new(),
724 author_role: ModelRole::Tier2Reviewer,
725 note: "observation".into(),
726 citations: vec![cite(true), cite(true)],
727 source: NoteSource::Tier2Review {
728 flag: FlagId::new(),
729 },
730 created_at: Utc::now(),
731 superseded_by: None,
732 };
733 assert!(note.is_supported());
734 assert!(!note.is_superseded());
735
736 note.citations.push(cite(false));
737 assert!(!note.is_supported(), "one broken citation is enough");
738
739 let json = serde_json::to_value(¬e).unwrap();
740 assert!(
741 json["citations"][0].get("excerpt").is_none(),
742 "a citation on the wire carries no content"
743 );
744 let back: ModerationNote = serde_json::from_value(json).unwrap();
745 assert_eq!(back, note);
746 }
747
748 #[test]
749 fn report_tally_round_trips_and_defaults_to_zero() {
750 let tally = ReportTally {
751 total: 3,
752 distinct_targets: 2,
753 pending: 0,
754 auto_dismissed: 1,
755 dismissed_on_review: 1,
756 substantiated: 1,
757 earliest: Some(Utc::now()),
758 latest: Some(Utc::now()),
759 };
760 let json = serde_json::to_value(tally).unwrap();
761 let back: ReportTally = serde_json::from_value(json).unwrap();
762 assert_eq!(back, tally);
763
764 let empty = ReportTally::default();
765 let json = serde_json::to_value(empty).unwrap();
766 assert!(json.get("earliest").is_none(), "absent, not null");
767 assert_eq!(json["total"], 0);
768 }
769
770 #[cfg(feature = "schemars")]
778 #[test]
779 fn moderation_schemas_are_inlined() {
780 use schemars::JsonSchema;
781
782 for (name, schema) in [
783 ("ReversalStatus", schemars::schema_for!(ReversalStatus)),
784 ("NoteSource", schemars::schema_for!(NoteSource)),
785 ("NoteCitation", schemars::schema_for!(NoteCitation)),
786 ("ModerationNote", schemars::schema_for!(ModerationNote)),
787 ("ReportTally", schemars::schema_for!(ReportTally)),
788 (
789 "ModerationActionRecord",
790 schemars::schema_for!(ModerationActionRecord),
791 ),
792 ("FilingProblem", schemars::schema_for!(FilingProblem)),
793 ("AppealRefusal", schemars::schema_for!(AppealRefusal)),
794 ("AppealFiled", schemars::schema_for!(AppealFiled)),
795 ("AppealCredits", schemars::schema_for!(AppealCredits)),
796 (
797 "AppealCreditEvent",
798 schemars::schema_for!(AppealCreditEvent),
799 ),
800 (
801 "CreditRestoration",
802 schemars::schema_for!(CreditRestoration),
803 ),
804 (
805 "MyModerationRecord",
806 schemars::schema_for!(MyModerationRecord),
807 ),
808 ] {
809 let rendered = serde_json::to_value(&schema).unwrap().to_string();
810 assert!(
811 !rendered.contains("$ref") && !rendered.contains("$defs"),
812 "{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
813 on a nested enum silently reintroduces it: {rendered}"
814 );
815 }
816
817 assert!(<ReversalStatus as JsonSchema>::inline_schema());
818 assert!(<NoteSource as JsonSchema>::inline_schema());
819 assert!(<NoteCitation as JsonSchema>::inline_schema());
820 assert!(<FilingProblem as JsonSchema>::inline_schema());
821 assert!(<AppealRefusal as JsonSchema>::inline_schema());
822 assert!(<AppealCreditEvent as JsonSchema>::inline_schema());
823 assert!(<CreditRestoration as JsonSchema>::inline_schema());
824 }
825
826 #[test]
833 fn a_rejection_lists_every_problem() {
834 let refusal = AppealRefusal::rejected(vec![
835 FilingProblem::StatementTooLong {
836 len: 20_000,
837 max: MAX_APPEAL_STATEMENT_LEN,
838 },
839 FilingProblem::TooManyCitations {
840 cited: 7,
841 max: MAX_APPEAL_CITATIONS,
842 },
843 FilingProblem::UnresolvableCitation {
844 content_id: ContentId::new(),
845 ordinal: 3,
846 },
847 ])
848 .expect("three problems is not an empty list");
849
850 let rendered = refusal.to_string();
851 assert!(rendered.contains("3 problems to fix"), "{rendered}");
852 assert!(rendered.contains("20000"), "names the actual length");
853 assert!(rendered.contains("cites 7 content ids"), "{rendered}");
854 assert!(rendered.contains("not a post or comment"), "{rendered}");
855 for n in ["1.", "2.", "3."] {
856 assert!(rendered.contains(n), "numbered list missing {n}");
857 }
858 }
859
860 #[test]
862 fn a_single_problem_is_not_pluralized() {
863 let refusal =
864 AppealRefusal::rejected(vec![FilingProblem::StatementEmpty])
865 .expect("one problem is not an empty list");
866 assert!(refusal.to_string().contains("1 problem to fix"));
867 }
868
869 #[test]
871 fn an_empty_problem_list_is_not_a_refusal() {
872 assert_eq!(AppealRefusal::rejected(Vec::new()), None);
873 }
874
875 #[test]
880 fn an_unresolvable_citation_explains_the_action_id_case() {
881 let problem = FilingProblem::UnresolvableCitation {
882 content_id: ContentId::new(),
883 ordinal: 1,
884 };
885 assert!(
886 problem.to_string().contains("moderation action"),
887 "an appellant who cited their action id needs to be told that \
888 is what happened: {problem}"
889 );
890 }
891
892 #[test]
893 fn refusals_round_trip_tagged() {
894 for refusal in [
895 AppealRefusal::ActionNotFound,
896 AppealRefusal::NoStanding,
897 AppealRefusal::AlreadyAppealed,
898 AppealRefusal::CreditsExhausted {
899 balance: 0,
900 cap: 6,
901 next_accrual_at: Utc::now(),
902 },
903 #[allow(deprecated)]
904 AppealRefusal::BudgetExhausted { used: 2, max: 2 },
905 AppealRefusal::Rejected {
906 problems: vec![FilingProblem::StatementEmpty],
907 },
908 ] {
909 let json = serde_json::to_value(&refusal).unwrap();
910 assert!(json["refusal"].is_string(), "{json}");
911 let back: AppealRefusal = serde_json::from_value(json).unwrap();
912 assert_eq!(back, refusal);
913 }
914 }
915
916 #[test]
920 fn credit_exhaustion_carries_the_numbers_and_the_date() {
921 let next = "2026-11-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
922 let refusal = AppealRefusal::CreditsExhausted {
923 balance: 0,
924 cap: 6,
925 next_accrual_at: next,
926 };
927 let json = serde_json::to_value(&refusal).unwrap();
928 assert_eq!(json["refusal"], "credits_exhausted");
929 assert_eq!(json["balance"], 0);
930 assert_eq!(json["cap"], 6);
931 assert_eq!(json["next_accrual_at"], "2026-11-01T00:00:00Z");
932
933 let text = refusal.to_string();
934 assert!(text.contains("0 appeal credits"), "{text}");
935 assert!(text.contains("2026-11-01"), "{text}");
936 assert!(text.contains("up to 6"), "{text}");
937 assert!(text.contains("Art. VI § 2"), "{text}");
938 }
939
940 #[test]
943 fn appeal_credits_round_trip_and_history_is_optional() {
944 let at = "2026-10-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
945 let bare = AppealCredits {
946 balance: 2,
947 cap: 6,
948 next_accrual_at: at,
949 pending_appeals: 0,
950 history: Vec::new(),
951 };
952 let json = serde_json::to_value(&bare).unwrap();
953 assert!(json.get("history").is_none(), "absent, not []: {json}");
954 assert_eq!(
955 serde_json::from_value::<AppealCredits>(json).unwrap(),
956 bare
957 );
958
959 let full = AppealCredits {
960 history: vec![
961 AppealCreditEvent::Opening { at, balance: 2 },
962 AppealCreditEvent::Accrual {
963 at,
964 balance: 3,
965 capped: false,
966 },
967 AppealCreditEvent::Spend {
968 at,
969 appeal: AppealId::new(),
970 balance: 2,
971 },
972 AppealCreditEvent::NotCharged {
973 at,
974 appeal: AppealId::new(),
975 reason: CreditRestoration::DeadLettered,
976 },
977 ],
978 ..bare
979 };
980 let json = serde_json::to_value(&full).unwrap();
981 assert_eq!(json["history"][0]["event"], "opening");
982 assert!(
983 json["history"][1].get("capped").is_none(),
984 "an uncapped accrual carries no flag: {json}"
985 );
986 assert_eq!(json["history"][3]["reason"], "dead_lettered");
987 assert_eq!(
988 serde_json::from_value::<AppealCredits>(json).unwrap(),
989 full
990 );
991 }
992
993 #[test]
994 fn model_role_serializes_snake_case() {
995 assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
996 assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
997 assert_eq!(
998 "chambers".parse::<ModelRole>().unwrap(),
999 ModelRole::Chambers
1000 );
1001 }
1002}