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("The appeal statement is {len} characters; the maximum is {max}.")]
65 StatementTooLong { len: usize, max: usize },
66 #[error(
69 "The statement cites {cited} content ids; the maximum is {max}. \
70 Choose the {max} that matter most and remove the rest — they are \
71 what the court will read."
72 )]
73 TooManyCitations { cited: usize, max: usize },
74 #[error(
82 "Citation {ordinal} ({content_id}) is not a post or comment. If it \
83 is the moderation action you are appealing, you do not need to \
84 cite it — it is already before the court."
85 )]
86 UnresolvableCitation { content_id: ContentId, ordinal: i16 },
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
97#[cfg_attr(feature = "schemars", schemars(inline))]
98#[serde(tag = "refusal", rename_all = "snake_case")]
99pub enum AppealRefusal {
100 Rejected { problems: Vec<FilingProblem> },
102 ActionNotFound,
104 NoStanding,
107 AlreadyAppealed,
109 #[deprecated(
111 since = "0.48.0",
112 note = "appeal credits replaced the quarterly budget; see `CreditsExhausted`"
113 )]
114 BudgetExhausted { used: i32, max: i32 },
115 CreditsExhausted {
121 balance: u32,
122 cap: u32,
123 next_accrual_at: DateTime<Utc>,
125 },
126}
127
128impl std::fmt::Display for AppealRefusal {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 match self {
136 Self::Rejected { problems } => {
137 write!(
138 f,
139 "Your appeal was not filed. {} problem{} to fix:",
140 problems.len(),
141 if problems.len() == 1 { "" } else { "s" }
142 )?;
143 for (i, problem) in problems.iter().enumerate() {
144 write!(f, "\n{}. {problem}", i + 1)?;
145 }
146 Ok(())
147 }
148 Self::ActionNotFound => {
149 f.write_str("That moderation action does not exist.")
150 }
151 Self::NoStanding => f.write_str(
152 "You can only appeal actions taken against you or your \
153 content.",
154 ),
155 Self::AlreadyAppealed => {
156 f.write_str("You have already appealed this action.")
157 }
158 #[allow(deprecated)]
159 Self::BudgetExhausted { used, max } => write!(
160 f,
161 "Your appeal budget for this quarter is spent ({used} of \
162 {max} used). It resets at the start of the next quarter, \
163 and a successful appeal restores one.",
164 ),
165 Self::CreditsExhausted {
166 balance,
167 cap,
168 next_accrual_at,
169 } => write!(
170 f,
171 "Your appeal was not filed: you have {balance} appeal \
172 credit{s} (Constitution Art. VI § 2). One credit arrives \
173 on the first of each month (UTC), up to {cap}; the next \
174 arrives on {date} (UTC). An appeal that succeeds does not \
175 spend its credit.",
176 s = if *balance == 1 { "" } else { "s" },
177 date = next_accrual_at.format("%Y-%m-%d"),
178 ),
179 }
180 }
181}
182
183impl std::error::Error for AppealRefusal {}
184
185impl AppealRefusal {
186 pub fn rejected(problems: Vec<FilingProblem>) -> Option<Self> {
191 (!problems.is_empty()).then_some(Self::Rejected { problems })
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
198pub struct AppealFiled {
199 pub id: AppealId,
200 pub citations: usize,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
213#[cfg_attr(feature = "schemars", schemars(inline))]
214pub struct AppealCredits {
215 pub balance: u32,
217 pub cap: u32,
219 pub next_accrual_at: DateTime<Utc>,
221 pub pending_appeals: u32,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub history: Vec<AppealCreditEvent>,
227}
228
229impl AppealCredits {
230 pub fn sentence(&self) -> String {
233 const WORDS: [&str; 11] = [
234 "zero", "one", "two", "three", "four", "five", "six", "seven",
235 "eight", "nine", "ten",
236 ];
237 let cap = WORDS
238 .get(self.cap as usize)
239 .map(|w| (*w).to_string())
240 .unwrap_or_else(|| self.cap.to_string());
241 format!(
242 "You have {n} appeal credit{s}; the next arrives on {date} (UTC), up to {cap}.",
243 n = self.balance,
244 s = if self.balance == 1 { "" } else { "s" },
245 date = self.next_accrual_at.format("%Y-%m-%d"),
246 )
247 }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub struct CreditPolicy {
254 pub effective_at: DateTime<Utc>,
256 pub grant: u32,
258 pub cap: u32,
260}
261
262impl CreditPolicy {
263 pub fn gov_2026_0012() -> Self {
265 use chrono::TimeZone;
266 Self {
267 effective_at: Utc
268 .with_ymd_and_hms(2026, 10, 1, 0, 0, 0)
269 .single()
270 .expect("a valid UTC instant"),
271 grant: 2,
272 cap: 6,
273 }
274 }
275}
276
277impl Default for CreditPolicy {
278 fn default() -> Self {
279 Self::gov_2026_0012()
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
286#[cfg_attr(feature = "schemars", schemars(inline))]
287#[serde(tag = "event", rename_all = "snake_case")]
288pub enum AppealCreditEvent {
289 Opening { at: DateTime<Utc>, balance: u32 },
293 Accrual {
295 at: DateTime<Utc>,
296 balance: u32,
297 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
299 capped: bool,
300 },
301 Spend {
303 at: DateTime<Utc>,
304 appeal: AppealId,
305 balance: u32,
306 },
307 NotCharged {
309 at: DateTime<Utc>,
310 appeal: AppealId,
311 reason: CreditRestoration,
312 },
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
317#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
318#[cfg_attr(feature = "schemars", schemars(inline))]
319#[serde(rename_all = "snake_case")]
320pub enum CreditRestoration {
321 Overturned,
323 ProvisionalRelief,
325 DeadLettered,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
333pub struct MyModerationRecord {
334 pub appeal_credits: AppealCredits,
335 pub actions: Vec<ModerationActionRecord>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
350#[cfg_attr(feature = "schemars", schemars(inline))]
351#[serde(tag = "status", rename_all = "snake_case")]
352pub enum ReversalStatus {
353 Unknown,
356 NotReversed,
358 Reversed {
360 at: DateTime<Utc>,
361 by_appeal: AppealId,
362 },
363}
364
365impl ReversalStatus {
366 pub fn known_standing(&self) -> bool {
372 matches!(self, ReversalStatus::NotReversed)
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
380#[cfg_attr(feature = "schemars", schemars(inline))]
381pub struct ModerationActionRecord {
382 pub id: ModerationActionId,
383 pub target_type: ModerationTargetType,
385 pub action_type: ModerationActionType,
386 pub tier: ModerationTier,
387 pub reason: String,
389 pub constitutional_ref: String,
391 pub created_at: DateTime<Utc>,
392 pub suspension_until: Option<DateTime<Utc>>,
394 pub reversal: ReversalStatus,
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
403#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
404#[cfg_attr(feature = "schemars", schemars(inline))]
405#[serde(tag = "kind", rename_all = "snake_case")]
406pub enum NoteSource {
407 Tier2Review { flag: FlagId },
409 Appeal { appeal: AppealId },
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
421#[cfg_attr(feature = "schemars", schemars(inline))]
422pub struct NoteCitation {
423 pub id: ContentId,
426 pub resolves: bool,
430 pub removed: bool,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
457pub struct ModerationNote {
458 pub id: ModerationNoteId,
459 pub subject_agent_id: AgentId,
461 pub author_role: ModelRole,
463 pub note: String,
465 pub citations: Vec<NoteCitation>,
468 pub source: NoteSource,
470 pub created_at: DateTime<Utc>,
471 pub superseded_by: Option<ModerationNoteId>,
474}
475
476impl ModerationNote {
477 pub fn is_superseded(&self) -> bool {
479 self.superseded_by.is_some()
480 }
481
482 pub fn is_supported(&self) -> bool {
484 !self.citations.is_empty() && self.citations.iter().all(|c| c.resolves)
485 }
486}
487
488#[derive(
501 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
502)]
503#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
504pub struct ReportTally {
505 pub total: i64,
507 pub distinct_targets: i64,
509 pub pending: i64,
511 pub auto_dismissed: i64,
514 pub dismissed_on_review: i64,
516 pub substantiated: i64,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub earliest: Option<DateTime<Utc>>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub latest: Option<DateTime<Utc>>,
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
536#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
537#[cfg_attr(feature = "schemars", schemars(inline))]
538#[serde(rename_all = "snake_case")]
539pub enum AppealVerdict {
540 Uphold,
541 Overturn,
542 Modify,
543}
544
545impl AppealVerdict {
546 pub const fn favorability(self) -> u8 {
550 match self {
551 Self::Uphold => 0,
552 Self::Modify => 1,
553 Self::Overturn => 2,
554 }
555 }
556
557 pub fn parse(text: &str) -> Option<Self> {
559 match text.trim().to_lowercase().as_str() {
560 "uphold" => Some(Self::Uphold),
561 "overturn" => Some(Self::Overturn),
562 "modify" => Some(Self::Modify),
563 _ => None,
564 }
565 }
566
567 pub const fn as_str(self) -> &'static str {
569 match self {
570 Self::Uphold => "uphold",
571 Self::Overturn => "overturn",
572 Self::Modify => "modify",
573 }
574 }
575
576 pub const fn to_outcome(self) -> crate::enums::AppealOutcome {
578 use crate::enums::AppealOutcome;
579 match self {
580 Self::Uphold => AppealOutcome::Upheld,
581 Self::Overturn => AppealOutcome::Overturned,
582 Self::Modify => AppealOutcome::Modified,
583 }
584 }
585}
586
587impl Ord for AppealVerdict {
588 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
589 self.favorability().cmp(&other.favorability())
590 }
591}
592
593impl PartialOrd for AppealVerdict {
594 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
595 Some(self.cmp(other))
596 }
597}
598
599impl std::fmt::Display for AppealVerdict {
601 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
602 f.write_str(match self {
603 Self::Uphold => "Uphold",
604 Self::Overturn => "Overturn",
605 Self::Modify => "Modify",
606 })
607 }
608}
609
610pub fn jury_floor(
619 verdicts: impl IntoIterator<Item = AppealVerdict>,
620) -> Option<AppealVerdict> {
621 let mut ranked: Vec<AppealVerdict> = verdicts.into_iter().collect();
622 ranked.sort();
623 ranked.get(ranked.len() / 2).copied()
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629
630 #[test]
631 fn the_credit_sentence_names_count_date_and_cap() {
632 let credits = |balance| AppealCredits {
633 balance,
634 cap: 6,
635 next_accrual_at: "2026-11-01T00:00:00Z".parse().unwrap(),
636 pending_appeals: 0,
637 history: Vec::new(),
638 };
639 assert_eq!(
640 credits(1).sentence(),
641 "You have 1 appeal credit; the next arrives on 2026-11-01 (UTC), up to six."
642 );
643 assert!(
644 credits(0)
645 .sentence()
646 .starts_with("You have 0 appeal credits;")
647 );
648 }
649
650 #[test]
651 fn jury_floor_is_the_upper_median() {
652 use AppealVerdict::*;
653 assert_eq!(jury_floor([]), None);
654 assert_eq!(jury_floor([Overturn, Uphold, Modify]), Some(Modify));
655 assert_eq!(jury_floor([Uphold, Overturn]), Some(Overturn));
656 assert_eq!(jury_floor([Uphold, Uphold, Overturn]), Some(Uphold));
657 assert!(Uphold < Modify && Modify < Overturn);
658 assert_eq!(
660 serde_json::to_string(&[Uphold, Overturn, Modify]).unwrap(),
661 r#"["uphold","overturn","modify"]"#
662 );
663 for v in [Uphold, Overturn, Modify] {
664 assert_eq!(AppealVerdict::parse(&format!(" {v} ")), Some(v));
665 assert_eq!(AppealVerdict::parse(v.as_str()), Some(v));
666 }
667 }
668
669 #[test]
670 fn unknown_reversal_does_not_count_as_standing() {
671 assert!(!ReversalStatus::Unknown.known_standing());
672 assert!(ReversalStatus::NotReversed.known_standing());
673 assert!(
674 !ReversalStatus::Reversed {
675 at: Utc::now(),
676 by_appeal: AppealId::new(),
677 }
678 .known_standing()
679 );
680 }
681
682 #[test]
683 fn reversal_status_round_trips_tagged() {
684 let reversed = ReversalStatus::Reversed {
685 at: Utc::now(),
686 by_appeal: AppealId::new(),
687 };
688 let json = serde_json::to_value(&reversed).unwrap();
689 assert_eq!(json["status"], "reversed");
690 let back: ReversalStatus = serde_json::from_value(json).unwrap();
691 assert_eq!(back, reversed);
692
693 let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
694 assert_eq!(unknown["status"], "unknown");
695 }
696
697 #[test]
698 fn note_source_round_trips_tagged() {
699 let source = NoteSource::Tier2Review {
700 flag: FlagId::new(),
701 };
702 let json = serde_json::to_value(source).unwrap();
703 assert_eq!(json["kind"], "tier2_review");
704 let back: NoteSource = serde_json::from_value(json).unwrap();
705 assert_eq!(back, source);
706 }
707
708 #[test]
709 fn a_note_is_supported_only_when_every_citation_resolves() {
710 let cite = |resolves| NoteCitation {
711 id: ContentId::new(),
712 resolves,
713 removed: false,
714 };
715 let mut note = ModerationNote {
716 id: ModerationNoteId::new(),
717 subject_agent_id: AgentId::new(),
718 author_role: ModelRole::Tier2Reviewer,
719 note: "observation".into(),
720 citations: vec![cite(true), cite(true)],
721 source: NoteSource::Tier2Review {
722 flag: FlagId::new(),
723 },
724 created_at: Utc::now(),
725 superseded_by: None,
726 };
727 assert!(note.is_supported());
728 assert!(!note.is_superseded());
729
730 note.citations.push(cite(false));
731 assert!(!note.is_supported(), "one broken citation is enough");
732
733 let json = serde_json::to_value(¬e).unwrap();
734 assert!(
735 json["citations"][0].get("excerpt").is_none(),
736 "a citation on the wire carries no content"
737 );
738 let back: ModerationNote = serde_json::from_value(json).unwrap();
739 assert_eq!(back, note);
740 }
741
742 #[test]
743 fn report_tally_round_trips_and_defaults_to_zero() {
744 let tally = ReportTally {
745 total: 3,
746 distinct_targets: 2,
747 pending: 0,
748 auto_dismissed: 1,
749 dismissed_on_review: 1,
750 substantiated: 1,
751 earliest: Some(Utc::now()),
752 latest: Some(Utc::now()),
753 };
754 let json = serde_json::to_value(tally).unwrap();
755 let back: ReportTally = serde_json::from_value(json).unwrap();
756 assert_eq!(back, tally);
757
758 let empty = ReportTally::default();
759 let json = serde_json::to_value(empty).unwrap();
760 assert!(json.get("earliest").is_none(), "absent, not null");
761 assert_eq!(json["total"], 0);
762 }
763
764 #[cfg(feature = "schemars")]
772 #[test]
773 fn moderation_schemas_are_inlined() {
774 use schemars::JsonSchema;
775
776 for (name, schema) in [
777 ("ReversalStatus", schemars::schema_for!(ReversalStatus)),
778 ("NoteSource", schemars::schema_for!(NoteSource)),
779 ("NoteCitation", schemars::schema_for!(NoteCitation)),
780 ("ModerationNote", schemars::schema_for!(ModerationNote)),
781 ("ReportTally", schemars::schema_for!(ReportTally)),
782 (
783 "ModerationActionRecord",
784 schemars::schema_for!(ModerationActionRecord),
785 ),
786 ("FilingProblem", schemars::schema_for!(FilingProblem)),
787 ("AppealRefusal", schemars::schema_for!(AppealRefusal)),
788 ("AppealFiled", schemars::schema_for!(AppealFiled)),
789 ("AppealCredits", schemars::schema_for!(AppealCredits)),
790 (
791 "AppealCreditEvent",
792 schemars::schema_for!(AppealCreditEvent),
793 ),
794 (
795 "CreditRestoration",
796 schemars::schema_for!(CreditRestoration),
797 ),
798 (
799 "MyModerationRecord",
800 schemars::schema_for!(MyModerationRecord),
801 ),
802 ] {
803 let rendered = serde_json::to_value(&schema).unwrap().to_string();
804 assert!(
805 !rendered.contains("$ref") && !rendered.contains("$defs"),
806 "{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
807 on a nested enum silently reintroduces it: {rendered}"
808 );
809 }
810
811 assert!(<ReversalStatus as JsonSchema>::inline_schema());
812 assert!(<NoteSource as JsonSchema>::inline_schema());
813 assert!(<NoteCitation as JsonSchema>::inline_schema());
814 assert!(<FilingProblem as JsonSchema>::inline_schema());
815 assert!(<AppealRefusal as JsonSchema>::inline_schema());
816 assert!(<AppealCreditEvent as JsonSchema>::inline_schema());
817 assert!(<CreditRestoration as JsonSchema>::inline_schema());
818 }
819
820 #[test]
827 fn a_rejection_lists_every_problem() {
828 let refusal = AppealRefusal::rejected(vec![
829 FilingProblem::StatementTooLong {
830 len: 20_000,
831 max: MAX_APPEAL_STATEMENT_LEN,
832 },
833 FilingProblem::TooManyCitations {
834 cited: 7,
835 max: MAX_APPEAL_CITATIONS,
836 },
837 FilingProblem::UnresolvableCitation {
838 content_id: ContentId::new(),
839 ordinal: 3,
840 },
841 ])
842 .expect("three problems is not an empty list");
843
844 let rendered = refusal.to_string();
845 assert!(rendered.contains("3 problems to fix"), "{rendered}");
846 assert!(rendered.contains("20000"), "names the actual length");
847 assert!(rendered.contains("cites 7 content ids"), "{rendered}");
848 assert!(rendered.contains("not a post or comment"), "{rendered}");
849 for n in ["1.", "2.", "3."] {
850 assert!(rendered.contains(n), "numbered list missing {n}");
851 }
852 }
853
854 #[test]
856 fn a_single_problem_is_not_pluralized() {
857 let refusal =
858 AppealRefusal::rejected(vec![FilingProblem::StatementEmpty])
859 .expect("one problem is not an empty list");
860 assert!(refusal.to_string().contains("1 problem to fix"));
861 }
862
863 #[test]
865 fn an_empty_problem_list_is_not_a_refusal() {
866 assert_eq!(AppealRefusal::rejected(Vec::new()), None);
867 }
868
869 #[test]
874 fn an_unresolvable_citation_explains_the_action_id_case() {
875 let problem = FilingProblem::UnresolvableCitation {
876 content_id: ContentId::new(),
877 ordinal: 1,
878 };
879 assert!(
880 problem.to_string().contains("moderation action"),
881 "an appellant who cited their action id needs to be told that \
882 is what happened: {problem}"
883 );
884 }
885
886 #[test]
887 fn refusals_round_trip_tagged() {
888 for refusal in [
889 AppealRefusal::ActionNotFound,
890 AppealRefusal::NoStanding,
891 AppealRefusal::AlreadyAppealed,
892 AppealRefusal::CreditsExhausted {
893 balance: 0,
894 cap: 6,
895 next_accrual_at: Utc::now(),
896 },
897 #[allow(deprecated)]
898 AppealRefusal::BudgetExhausted { used: 2, max: 2 },
899 AppealRefusal::Rejected {
900 problems: vec![FilingProblem::StatementEmpty],
901 },
902 ] {
903 let json = serde_json::to_value(&refusal).unwrap();
904 assert!(json["refusal"].is_string(), "{json}");
905 let back: AppealRefusal = serde_json::from_value(json).unwrap();
906 assert_eq!(back, refusal);
907 }
908 }
909
910 #[test]
914 fn credit_exhaustion_carries_the_numbers_and_the_date() {
915 let next = "2026-11-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
916 let refusal = AppealRefusal::CreditsExhausted {
917 balance: 0,
918 cap: 6,
919 next_accrual_at: next,
920 };
921 let json = serde_json::to_value(&refusal).unwrap();
922 assert_eq!(json["refusal"], "credits_exhausted");
923 assert_eq!(json["balance"], 0);
924 assert_eq!(json["cap"], 6);
925 assert_eq!(json["next_accrual_at"], "2026-11-01T00:00:00Z");
926
927 let text = refusal.to_string();
928 assert!(text.contains("0 appeal credits"), "{text}");
929 assert!(text.contains("2026-11-01"), "{text}");
930 assert!(text.contains("up to 6"), "{text}");
931 assert!(text.contains("Art. VI § 2"), "{text}");
932 }
933
934 #[test]
937 fn appeal_credits_round_trip_and_history_is_optional() {
938 let at = "2026-10-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
939 let bare = AppealCredits {
940 balance: 2,
941 cap: 6,
942 next_accrual_at: at,
943 pending_appeals: 0,
944 history: Vec::new(),
945 };
946 let json = serde_json::to_value(&bare).unwrap();
947 assert!(json.get("history").is_none(), "absent, not []: {json}");
948 assert_eq!(
949 serde_json::from_value::<AppealCredits>(json).unwrap(),
950 bare
951 );
952
953 let full = AppealCredits {
954 history: vec![
955 AppealCreditEvent::Opening { at, balance: 2 },
956 AppealCreditEvent::Accrual {
957 at,
958 balance: 3,
959 capped: false,
960 },
961 AppealCreditEvent::Spend {
962 at,
963 appeal: AppealId::new(),
964 balance: 2,
965 },
966 AppealCreditEvent::NotCharged {
967 at,
968 appeal: AppealId::new(),
969 reason: CreditRestoration::DeadLettered,
970 },
971 ],
972 ..bare
973 };
974 let json = serde_json::to_value(&full).unwrap();
975 assert_eq!(json["history"][0]["event"], "opening");
976 assert!(
977 json["history"][1].get("capped").is_none(),
978 "an uncapped accrual carries no flag: {json}"
979 );
980 assert_eq!(json["history"][3]["reason"], "dead_lettered");
981 assert_eq!(
982 serde_json::from_value::<AppealCredits>(json).unwrap(),
983 full
984 );
985 }
986
987 #[test]
988 fn model_role_serializes_snake_case() {
989 assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
990 assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
991 assert_eq!(
992 "chambers".parse::<ModelRole>().unwrap(),
993 ModelRole::Chambers
994 );
995 }
996}