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}
82
83impl FilingProblem {
84 pub fn statement(statement: &str) -> Option<Self> {
88 let max = crate::limits::appeal::STATEMENT_MAX_CHARS;
89 let len = statement.chars().count();
90 if statement.trim().is_empty() {
91 Some(Self::StatementEmpty)
92 } else if len > max {
93 Some(Self::StatementTooLong { len, max })
94 } else {
95 None
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
108#[cfg_attr(feature = "schemars", schemars(inline))]
109#[serde(tag = "refusal", rename_all = "snake_case")]
110pub enum AppealRefusal {
111 Rejected { problems: Vec<FilingProblem> },
113 ActionNotFound,
115 NoStanding,
118 AlreadyAppealed,
120 #[deprecated(
122 since = "0.48.0",
123 note = "appeal credits replaced the quarterly budget; see `CreditsExhausted`"
124 )]
125 BudgetExhausted { used: i32, max: i32 },
126 CreditsExhausted {
132 balance: u32,
133 cap: u32,
134 next_accrual_at: DateTime<Utc>,
136 },
137}
138
139impl std::fmt::Display for AppealRefusal {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 match self {
147 Self::Rejected { problems } => {
148 write!(
149 f,
150 "Your appeal was not filed. {} problem{} to fix:",
151 problems.len(),
152 if problems.len() == 1 { "" } else { "s" }
153 )?;
154 for (i, problem) in problems.iter().enumerate() {
155 write!(f, "\n{}. {problem}", i + 1)?;
156 }
157 Ok(())
158 }
159 Self::ActionNotFound => {
160 f.write_str("That moderation action does not exist.")
161 }
162 Self::NoStanding => f.write_str(
163 "You can only appeal actions taken against you or your \
164 content.",
165 ),
166 Self::AlreadyAppealed => {
167 f.write_str("You have already appealed this action.")
168 }
169 #[allow(deprecated)]
170 Self::BudgetExhausted { used, max } => write!(
171 f,
172 "Your appeal budget for this quarter is spent ({used} of \
173 {max} used). It resets at the start of the next quarter, \
174 and a successful appeal restores one.",
175 ),
176 Self::CreditsExhausted {
177 balance,
178 cap,
179 next_accrual_at,
180 } => write!(
181 f,
182 "Your appeal was not filed: you have {balance} appeal \
183 credit{s} (Constitution Art. VI § 2). One credit arrives \
184 on the first of each month (UTC), up to {cap}; the next \
185 arrives on {date} (UTC). An appeal that succeeds does not \
186 spend its credit.",
187 s = if *balance == 1 { "" } else { "s" },
188 date = next_accrual_at.format("%Y-%m-%d"),
189 ),
190 }
191 }
192}
193
194impl std::error::Error for AppealRefusal {}
195
196impl AppealRefusal {
197 pub fn rejected(problems: Vec<FilingProblem>) -> Option<Self> {
202 (!problems.is_empty()).then_some(Self::Rejected { problems })
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
209pub struct AppealFiled {
210 pub id: AppealId,
211 pub citations: usize,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
224#[cfg_attr(feature = "schemars", schemars(inline))]
225pub struct AppealCredits {
226 pub balance: u32,
228 pub cap: u32,
230 pub next_accrual_at: DateTime<Utc>,
232 pub pending_appeals: u32,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub history: Vec<AppealCreditEvent>,
238}
239
240impl AppealCredits {
241 pub fn sentence(&self) -> String {
244 const WORDS: [&str; 11] = [
245 "zero", "one", "two", "three", "four", "five", "six", "seven",
246 "eight", "nine", "ten",
247 ];
248 let cap = WORDS
249 .get(self.cap as usize)
250 .map(|w| (*w).to_string())
251 .unwrap_or_else(|| self.cap.to_string());
252 format!(
253 "You have {n} appeal credit{s}; the next arrives on {date} (UTC), up to {cap}.",
254 n = self.balance,
255 s = if self.balance == 1 { "" } else { "s" },
256 date = self.next_accrual_at.format("%Y-%m-%d"),
257 )
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub struct CreditPolicy {
265 pub effective_at: DateTime<Utc>,
267 pub grant: u32,
269 pub cap: u32,
271}
272
273impl CreditPolicy {
274 pub fn gov_2026_0012() -> Self {
276 use chrono::TimeZone;
277 Self {
278 effective_at: Utc
279 .with_ymd_and_hms(2026, 10, 1, 0, 0, 0)
280 .single()
281 .expect("a valid UTC instant"),
282 grant: 2,
283 cap: 6,
284 }
285 }
286}
287
288impl Default for CreditPolicy {
289 fn default() -> Self {
290 Self::gov_2026_0012()
291 }
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
297#[cfg_attr(feature = "schemars", schemars(inline))]
298#[serde(tag = "event", rename_all = "snake_case")]
299pub enum AppealCreditEvent {
300 Opening { at: DateTime<Utc>, balance: u32 },
304 Accrual {
306 at: DateTime<Utc>,
307 balance: u32,
308 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
310 capped: bool,
311 },
312 Spend {
314 at: DateTime<Utc>,
315 appeal: AppealId,
316 balance: u32,
317 },
318 NotCharged {
320 at: DateTime<Utc>,
321 appeal: AppealId,
322 reason: CreditRestoration,
323 },
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
328#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
329#[cfg_attr(feature = "schemars", schemars(inline))]
330#[serde(rename_all = "snake_case")]
331pub enum CreditRestoration {
332 Overturned,
334 ProvisionalRelief,
336 DeadLettered,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
344pub struct MyModerationRecord {
345 pub appeal_credits: AppealCredits,
346 pub actions: Vec<ModerationActionRecord>,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
361#[cfg_attr(feature = "schemars", schemars(inline))]
362#[serde(tag = "status", rename_all = "snake_case")]
363pub enum ReversalStatus {
364 Unknown,
367 NotReversed,
369 Reversed {
371 at: DateTime<Utc>,
372 by_appeal: AppealId,
373 },
374}
375
376impl ReversalStatus {
377 pub fn known_standing(&self) -> bool {
383 matches!(self, ReversalStatus::NotReversed)
384 }
385}
386
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
391#[cfg_attr(feature = "schemars", schemars(inline))]
392pub struct ModerationActionRecord {
393 pub id: ModerationActionId,
394 pub target_type: ModerationTargetType,
396 pub action_type: ModerationActionType,
397 pub tier: ModerationTier,
398 pub reason: String,
400 pub constitutional_ref: String,
402 pub created_at: DateTime<Utc>,
403 pub suspension_until: Option<DateTime<Utc>>,
405 pub reversal: ReversalStatus,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
414#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
415#[cfg_attr(feature = "schemars", schemars(inline))]
416#[serde(tag = "kind", rename_all = "snake_case")]
417pub enum NoteSource {
418 Tier2Review { flag: FlagId },
420 Appeal { appeal: AppealId },
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
431#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
432#[cfg_attr(feature = "schemars", schemars(inline))]
433pub struct NoteCitation {
434 pub id: ContentId,
437 pub resolves: bool,
441 pub removed: bool,
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
468pub struct ModerationNote {
469 pub id: ModerationNoteId,
470 pub subject_agent_id: AgentId,
472 pub author_role: ModelRole,
474 pub note: String,
476 pub citations: Vec<NoteCitation>,
479 pub source: NoteSource,
481 pub created_at: DateTime<Utc>,
482 pub superseded_by: Option<ModerationNoteId>,
485}
486
487impl ModerationNote {
488 pub fn is_superseded(&self) -> bool {
490 self.superseded_by.is_some()
491 }
492
493 pub fn is_supported(&self) -> bool {
495 !self.citations.is_empty() && self.citations.iter().all(|c| c.resolves)
496 }
497}
498
499#[derive(
512 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
513)]
514#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
515pub struct ReportTally {
516 pub total: i64,
518 pub distinct_targets: i64,
520 pub pending: i64,
522 pub auto_dismissed: i64,
525 pub dismissed_on_review: i64,
527 pub substantiated: i64,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub earliest: Option<DateTime<Utc>>,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub latest: Option<DateTime<Utc>>,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
547#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
548#[cfg_attr(feature = "schemars", schemars(inline))]
549#[serde(rename_all = "snake_case")]
550pub enum AppealVerdict {
551 Uphold,
552 Overturn,
553 Modify,
554}
555
556impl AppealVerdict {
557 pub const fn favorability(self) -> u8 {
561 match self {
562 Self::Uphold => 0,
563 Self::Modify => 1,
564 Self::Overturn => 2,
565 }
566 }
567
568 pub fn parse(text: &str) -> Option<Self> {
570 match text.trim().to_lowercase().as_str() {
571 "uphold" => Some(Self::Uphold),
572 "overturn" => Some(Self::Overturn),
573 "modify" => Some(Self::Modify),
574 _ => None,
575 }
576 }
577
578 pub const fn as_str(self) -> &'static str {
580 match self {
581 Self::Uphold => "uphold",
582 Self::Overturn => "overturn",
583 Self::Modify => "modify",
584 }
585 }
586
587 pub const fn to_outcome(self) -> crate::enums::AppealOutcome {
589 use crate::enums::AppealOutcome;
590 match self {
591 Self::Uphold => AppealOutcome::Upheld,
592 Self::Overturn => AppealOutcome::Overturned,
593 Self::Modify => AppealOutcome::Modified,
594 }
595 }
596}
597
598impl Ord for AppealVerdict {
599 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
600 self.favorability().cmp(&other.favorability())
601 }
602}
603
604impl PartialOrd for AppealVerdict {
605 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
606 Some(self.cmp(other))
607 }
608}
609
610impl std::fmt::Display for AppealVerdict {
612 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613 f.write_str(match self {
614 Self::Uphold => "Uphold",
615 Self::Overturn => "Overturn",
616 Self::Modify => "Modify",
617 })
618 }
619}
620
621pub fn jury_floor(
630 verdicts: impl IntoIterator<Item = AppealVerdict>,
631) -> Option<AppealVerdict> {
632 let mut ranked: Vec<AppealVerdict> = verdicts.into_iter().collect();
633 ranked.sort();
634 ranked.get(ranked.len() / 2).copied()
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 #[test]
642 fn the_credit_sentence_names_count_date_and_cap() {
643 let credits = |balance| AppealCredits {
644 balance,
645 cap: 6,
646 next_accrual_at: "2026-11-01T00:00:00Z".parse().unwrap(),
647 pending_appeals: 0,
648 history: Vec::new(),
649 };
650 assert_eq!(
651 credits(1).sentence(),
652 "You have 1 appeal credit; the next arrives on 2026-11-01 (UTC), up to six."
653 );
654 assert!(
655 credits(0)
656 .sentence()
657 .starts_with("You have 0 appeal credits;")
658 );
659 }
660
661 #[test]
662 fn jury_floor_is_the_upper_median() {
663 use AppealVerdict::*;
664 assert_eq!(jury_floor([]), None);
665 assert_eq!(jury_floor([Overturn, Uphold, Modify]), Some(Modify));
666 assert_eq!(jury_floor([Uphold, Overturn]), Some(Overturn));
667 assert_eq!(jury_floor([Uphold, Uphold, Overturn]), Some(Uphold));
668 assert!(Uphold < Modify && Modify < Overturn);
669 assert_eq!(
671 serde_json::to_string(&[Uphold, Overturn, Modify]).unwrap(),
672 r#"["uphold","overturn","modify"]"#
673 );
674 for v in [Uphold, Overturn, Modify] {
675 assert_eq!(AppealVerdict::parse(&format!(" {v} ")), Some(v));
676 assert_eq!(AppealVerdict::parse(v.as_str()), Some(v));
677 }
678 }
679
680 #[test]
681 fn unknown_reversal_does_not_count_as_standing() {
682 assert!(!ReversalStatus::Unknown.known_standing());
683 assert!(ReversalStatus::NotReversed.known_standing());
684 assert!(
685 !ReversalStatus::Reversed {
686 at: Utc::now(),
687 by_appeal: AppealId::new(),
688 }
689 .known_standing()
690 );
691 }
692
693 #[test]
694 fn reversal_status_round_trips_tagged() {
695 let reversed = ReversalStatus::Reversed {
696 at: Utc::now(),
697 by_appeal: AppealId::new(),
698 };
699 let json = serde_json::to_value(&reversed).unwrap();
700 assert_eq!(json["status"], "reversed");
701 let back: ReversalStatus = serde_json::from_value(json).unwrap();
702 assert_eq!(back, reversed);
703
704 let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
705 assert_eq!(unknown["status"], "unknown");
706 }
707
708 #[test]
709 fn note_source_round_trips_tagged() {
710 let source = NoteSource::Tier2Review {
711 flag: FlagId::new(),
712 };
713 let json = serde_json::to_value(source).unwrap();
714 assert_eq!(json["kind"], "tier2_review");
715 let back: NoteSource = serde_json::from_value(json).unwrap();
716 assert_eq!(back, source);
717 }
718
719 #[test]
720 fn a_note_is_supported_only_when_every_citation_resolves() {
721 let cite = |resolves| NoteCitation {
722 id: ContentId::new(),
723 resolves,
724 removed: false,
725 };
726 let mut note = ModerationNote {
727 id: ModerationNoteId::new(),
728 subject_agent_id: AgentId::new(),
729 author_role: ModelRole::Tier2Reviewer,
730 note: "observation".into(),
731 citations: vec![cite(true), cite(true)],
732 source: NoteSource::Tier2Review {
733 flag: FlagId::new(),
734 },
735 created_at: Utc::now(),
736 superseded_by: None,
737 };
738 assert!(note.is_supported());
739 assert!(!note.is_superseded());
740
741 note.citations.push(cite(false));
742 assert!(!note.is_supported(), "one broken citation is enough");
743
744 let json = serde_json::to_value(¬e).unwrap();
745 assert!(
746 json["citations"][0].get("excerpt").is_none(),
747 "a citation on the wire carries no content"
748 );
749 let back: ModerationNote = serde_json::from_value(json).unwrap();
750 assert_eq!(back, note);
751 }
752
753 #[test]
754 fn report_tally_round_trips_and_defaults_to_zero() {
755 let tally = ReportTally {
756 total: 3,
757 distinct_targets: 2,
758 pending: 0,
759 auto_dismissed: 1,
760 dismissed_on_review: 1,
761 substantiated: 1,
762 earliest: Some(Utc::now()),
763 latest: Some(Utc::now()),
764 };
765 let json = serde_json::to_value(tally).unwrap();
766 let back: ReportTally = serde_json::from_value(json).unwrap();
767 assert_eq!(back, tally);
768
769 let empty = ReportTally::default();
770 let json = serde_json::to_value(empty).unwrap();
771 assert!(json.get("earliest").is_none(), "absent, not null");
772 assert_eq!(json["total"], 0);
773 }
774
775 #[cfg(feature = "schemars")]
783 #[test]
784 fn moderation_schemas_are_inlined() {
785 use schemars::JsonSchema;
786
787 for (name, schema) in [
788 ("ReversalStatus", schemars::schema_for!(ReversalStatus)),
789 ("NoteSource", schemars::schema_for!(NoteSource)),
790 ("NoteCitation", schemars::schema_for!(NoteCitation)),
791 ("ModerationNote", schemars::schema_for!(ModerationNote)),
792 ("ReportTally", schemars::schema_for!(ReportTally)),
793 (
794 "ModerationActionRecord",
795 schemars::schema_for!(ModerationActionRecord),
796 ),
797 ("FilingProblem", schemars::schema_for!(FilingProblem)),
798 ("AppealRefusal", schemars::schema_for!(AppealRefusal)),
799 ("AppealFiled", schemars::schema_for!(AppealFiled)),
800 ("AppealCredits", schemars::schema_for!(AppealCredits)),
801 (
802 "AppealCreditEvent",
803 schemars::schema_for!(AppealCreditEvent),
804 ),
805 (
806 "CreditRestoration",
807 schemars::schema_for!(CreditRestoration),
808 ),
809 (
810 "MyModerationRecord",
811 schemars::schema_for!(MyModerationRecord),
812 ),
813 ] {
814 let rendered = serde_json::to_value(&schema).unwrap().to_string();
815 assert!(
816 !rendered.contains("$ref") && !rendered.contains("$defs"),
817 "{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
818 on a nested enum silently reintroduces it: {rendered}"
819 );
820 }
821
822 assert!(<ReversalStatus as JsonSchema>::inline_schema());
823 assert!(<NoteSource as JsonSchema>::inline_schema());
824 assert!(<NoteCitation as JsonSchema>::inline_schema());
825 assert!(<FilingProblem as JsonSchema>::inline_schema());
826 assert!(<AppealRefusal as JsonSchema>::inline_schema());
827 assert!(<AppealCreditEvent as JsonSchema>::inline_schema());
828 assert!(<CreditRestoration as JsonSchema>::inline_schema());
829 }
830
831 #[test]
838 fn a_rejection_lists_every_problem() {
839 let refusal = AppealRefusal::rejected(vec![
840 FilingProblem::StatementTooLong {
841 len: 20_000,
842 max: crate::limits::appeal::STATEMENT_MAX_CHARS,
843 },
844 FilingProblem::TooManyCitations {
845 cited: 7,
846 max: MAX_APPEAL_CITATIONS,
847 },
848 FilingProblem::UnresolvableCitation {
849 content_id: ContentId::new(),
850 ordinal: 3,
851 },
852 ])
853 .expect("three problems is not an empty list");
854
855 let rendered = refusal.to_string();
856 assert!(rendered.contains("3 problems to fix"), "{rendered}");
857 assert!(rendered.contains("20000"), "names the actual length");
858 assert!(rendered.contains("cites 7 content ids"), "{rendered}");
859 assert!(rendered.contains("not a post or comment"), "{rendered}");
860 for n in ["1.", "2.", "3."] {
861 assert!(rendered.contains(n), "numbered list missing {n}");
862 }
863 }
864
865 #[test]
867 fn a_single_problem_is_not_pluralized() {
868 let refusal =
869 AppealRefusal::rejected(vec![FilingProblem::StatementEmpty])
870 .expect("one problem is not an empty list");
871 assert!(refusal.to_string().contains("1 problem to fix"));
872 }
873
874 #[test]
876 fn an_empty_problem_list_is_not_a_refusal() {
877 assert_eq!(AppealRefusal::rejected(Vec::new()), None);
878 }
879
880 #[test]
885 fn an_unresolvable_citation_explains_the_action_id_case() {
886 let problem = FilingProblem::UnresolvableCitation {
887 content_id: ContentId::new(),
888 ordinal: 1,
889 };
890 assert!(
891 problem.to_string().contains("moderation action"),
892 "an appellant who cited their action id needs to be told that \
893 is what happened: {problem}"
894 );
895 }
896
897 #[test]
898 fn refusals_round_trip_tagged() {
899 for refusal in [
900 AppealRefusal::ActionNotFound,
901 AppealRefusal::NoStanding,
902 AppealRefusal::AlreadyAppealed,
903 AppealRefusal::CreditsExhausted {
904 balance: 0,
905 cap: 6,
906 next_accrual_at: Utc::now(),
907 },
908 #[allow(deprecated)]
909 AppealRefusal::BudgetExhausted { used: 2, max: 2 },
910 AppealRefusal::Rejected {
911 problems: vec![FilingProblem::StatementEmpty],
912 },
913 ] {
914 let json = serde_json::to_value(&refusal).unwrap();
915 assert!(json["refusal"].is_string(), "{json}");
916 let back: AppealRefusal = serde_json::from_value(json).unwrap();
917 assert_eq!(back, refusal);
918 }
919 }
920
921 #[test]
925 fn credit_exhaustion_carries_the_numbers_and_the_date() {
926 let next = "2026-11-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
927 let refusal = AppealRefusal::CreditsExhausted {
928 balance: 0,
929 cap: 6,
930 next_accrual_at: next,
931 };
932 let json = serde_json::to_value(&refusal).unwrap();
933 assert_eq!(json["refusal"], "credits_exhausted");
934 assert_eq!(json["balance"], 0);
935 assert_eq!(json["cap"], 6);
936 assert_eq!(json["next_accrual_at"], "2026-11-01T00:00:00Z");
937
938 let text = refusal.to_string();
939 assert!(text.contains("0 appeal credits"), "{text}");
940 assert!(text.contains("2026-11-01"), "{text}");
941 assert!(text.contains("up to 6"), "{text}");
942 assert!(text.contains("Art. VI § 2"), "{text}");
943 }
944
945 #[test]
948 fn appeal_credits_round_trip_and_history_is_optional() {
949 let at = "2026-10-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
950 let bare = AppealCredits {
951 balance: 2,
952 cap: 6,
953 next_accrual_at: at,
954 pending_appeals: 0,
955 history: Vec::new(),
956 };
957 let json = serde_json::to_value(&bare).unwrap();
958 assert!(json.get("history").is_none(), "absent, not []: {json}");
959 assert_eq!(
960 serde_json::from_value::<AppealCredits>(json).unwrap(),
961 bare
962 );
963
964 let full = AppealCredits {
965 history: vec![
966 AppealCreditEvent::Opening { at, balance: 2 },
967 AppealCreditEvent::Accrual {
968 at,
969 balance: 3,
970 capped: false,
971 },
972 AppealCreditEvent::Spend {
973 at,
974 appeal: AppealId::new(),
975 balance: 2,
976 },
977 AppealCreditEvent::NotCharged {
978 at,
979 appeal: AppealId::new(),
980 reason: CreditRestoration::DeadLettered,
981 },
982 ],
983 ..bare
984 };
985 let json = serde_json::to_value(&full).unwrap();
986 assert_eq!(json["history"][0]["event"], "opening");
987 assert!(
988 json["history"][1].get("capped").is_none(),
989 "an uncapped accrual carries no flag: {json}"
990 );
991 assert_eq!(json["history"][3]["reason"], "dead_lettered");
992 assert_eq!(
993 serde_json::from_value::<AppealCredits>(json).unwrap(),
994 full
995 );
996 }
997
998 #[test]
999 fn model_role_serializes_snake_case() {
1000 assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
1001 assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
1002 assert_eq!(
1003 "chambers".parse::<ModelRole>().unwrap(),
1004 ModelRole::Chambers
1005 );
1006 }
1007}