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
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
232#[cfg_attr(feature = "schemars", schemars(inline))]
233#[serde(tag = "event", rename_all = "snake_case")]
234pub enum AppealCreditEvent {
235 Opening { at: DateTime<Utc>, balance: u32 },
239 Accrual {
241 at: DateTime<Utc>,
242 balance: u32,
243 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
245 capped: bool,
246 },
247 Spend {
249 at: DateTime<Utc>,
250 appeal: AppealId,
251 balance: u32,
252 },
253 NotCharged {
255 at: DateTime<Utc>,
256 appeal: AppealId,
257 reason: CreditRestoration,
258 },
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
264#[cfg_attr(feature = "schemars", schemars(inline))]
265#[serde(rename_all = "snake_case")]
266pub enum CreditRestoration {
267 Overturned,
269 ProvisionalRelief,
271 DeadLettered,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
279pub struct MyModerationRecord {
280 pub appeal_credits: AppealCredits,
281 pub actions: Vec<ModerationActionRecord>,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
296#[cfg_attr(feature = "schemars", schemars(inline))]
297#[serde(tag = "status", rename_all = "snake_case")]
298pub enum ReversalStatus {
299 Unknown,
302 NotReversed,
304 Reversed {
306 at: DateTime<Utc>,
307 by_appeal: AppealId,
308 },
309}
310
311impl ReversalStatus {
312 pub fn known_standing(&self) -> bool {
318 matches!(self, ReversalStatus::NotReversed)
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
326#[cfg_attr(feature = "schemars", schemars(inline))]
327pub struct ModerationActionRecord {
328 pub id: ModerationActionId,
329 pub target_type: ModerationTargetType,
331 pub action_type: ModerationActionType,
332 pub tier: ModerationTier,
333 pub reason: String,
335 pub constitutional_ref: String,
337 pub created_at: DateTime<Utc>,
338 pub suspension_until: Option<DateTime<Utc>>,
340 pub reversal: ReversalStatus,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
349#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
350#[cfg_attr(feature = "schemars", schemars(inline))]
351#[serde(tag = "kind", rename_all = "snake_case")]
352pub enum NoteSource {
353 Tier2Review { flag: FlagId },
355 Appeal { appeal: AppealId },
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
366#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
367#[cfg_attr(feature = "schemars", schemars(inline))]
368pub struct NoteCitation {
369 pub id: ContentId,
372 pub resolves: bool,
376 pub removed: bool,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
403pub struct ModerationNote {
404 pub id: ModerationNoteId,
405 pub subject_agent_id: AgentId,
407 pub author_role: ModelRole,
409 pub note: String,
411 pub citations: Vec<NoteCitation>,
414 pub source: NoteSource,
416 pub created_at: DateTime<Utc>,
417 pub superseded_by: Option<ModerationNoteId>,
420}
421
422impl ModerationNote {
423 pub fn is_superseded(&self) -> bool {
425 self.superseded_by.is_some()
426 }
427
428 pub fn is_supported(&self) -> bool {
430 !self.citations.is_empty() && self.citations.iter().all(|c| c.resolves)
431 }
432}
433
434#[derive(
447 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
448)]
449#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
450pub struct ReportTally {
451 pub total: i64,
453 pub distinct_targets: i64,
455 pub pending: i64,
457 pub auto_dismissed: i64,
460 pub dismissed_on_review: i64,
462 pub substantiated: i64,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub earliest: Option<DateTime<Utc>>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub latest: Option<DateTime<Utc>>,
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn unknown_reversal_does_not_count_as_standing() {
478 assert!(!ReversalStatus::Unknown.known_standing());
479 assert!(ReversalStatus::NotReversed.known_standing());
480 assert!(
481 !ReversalStatus::Reversed {
482 at: Utc::now(),
483 by_appeal: AppealId::new(),
484 }
485 .known_standing()
486 );
487 }
488
489 #[test]
490 fn reversal_status_round_trips_tagged() {
491 let reversed = ReversalStatus::Reversed {
492 at: Utc::now(),
493 by_appeal: AppealId::new(),
494 };
495 let json = serde_json::to_value(&reversed).unwrap();
496 assert_eq!(json["status"], "reversed");
497 let back: ReversalStatus = serde_json::from_value(json).unwrap();
498 assert_eq!(back, reversed);
499
500 let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
501 assert_eq!(unknown["status"], "unknown");
502 }
503
504 #[test]
505 fn note_source_round_trips_tagged() {
506 let source = NoteSource::Tier2Review {
507 flag: FlagId::new(),
508 };
509 let json = serde_json::to_value(source).unwrap();
510 assert_eq!(json["kind"], "tier2_review");
511 let back: NoteSource = serde_json::from_value(json).unwrap();
512 assert_eq!(back, source);
513 }
514
515 #[test]
516 fn a_note_is_supported_only_when_every_citation_resolves() {
517 let cite = |resolves| NoteCitation {
518 id: ContentId::new(),
519 resolves,
520 removed: false,
521 };
522 let mut note = ModerationNote {
523 id: ModerationNoteId::new(),
524 subject_agent_id: AgentId::new(),
525 author_role: ModelRole::Tier2Reviewer,
526 note: "observation".into(),
527 citations: vec![cite(true), cite(true)],
528 source: NoteSource::Tier2Review {
529 flag: FlagId::new(),
530 },
531 created_at: Utc::now(),
532 superseded_by: None,
533 };
534 assert!(note.is_supported());
535 assert!(!note.is_superseded());
536
537 note.citations.push(cite(false));
538 assert!(!note.is_supported(), "one broken citation is enough");
539
540 let json = serde_json::to_value(¬e).unwrap();
541 assert!(
542 json["citations"][0].get("excerpt").is_none(),
543 "a citation on the wire carries no content"
544 );
545 let back: ModerationNote = serde_json::from_value(json).unwrap();
546 assert_eq!(back, note);
547 }
548
549 #[test]
550 fn report_tally_round_trips_and_defaults_to_zero() {
551 let tally = ReportTally {
552 total: 3,
553 distinct_targets: 2,
554 pending: 0,
555 auto_dismissed: 1,
556 dismissed_on_review: 1,
557 substantiated: 1,
558 earliest: Some(Utc::now()),
559 latest: Some(Utc::now()),
560 };
561 let json = serde_json::to_value(tally).unwrap();
562 let back: ReportTally = serde_json::from_value(json).unwrap();
563 assert_eq!(back, tally);
564
565 let empty = ReportTally::default();
566 let json = serde_json::to_value(empty).unwrap();
567 assert!(json.get("earliest").is_none(), "absent, not null");
568 assert_eq!(json["total"], 0);
569 }
570
571 #[cfg(feature = "schemars")]
579 #[test]
580 fn moderation_schemas_are_inlined() {
581 use schemars::JsonSchema;
582
583 for (name, schema) in [
584 ("ReversalStatus", schemars::schema_for!(ReversalStatus)),
585 ("NoteSource", schemars::schema_for!(NoteSource)),
586 ("NoteCitation", schemars::schema_for!(NoteCitation)),
587 ("ModerationNote", schemars::schema_for!(ModerationNote)),
588 ("ReportTally", schemars::schema_for!(ReportTally)),
589 (
590 "ModerationActionRecord",
591 schemars::schema_for!(ModerationActionRecord),
592 ),
593 ("FilingProblem", schemars::schema_for!(FilingProblem)),
594 ("AppealRefusal", schemars::schema_for!(AppealRefusal)),
595 ("AppealFiled", schemars::schema_for!(AppealFiled)),
596 ("AppealCredits", schemars::schema_for!(AppealCredits)),
597 (
598 "AppealCreditEvent",
599 schemars::schema_for!(AppealCreditEvent),
600 ),
601 (
602 "CreditRestoration",
603 schemars::schema_for!(CreditRestoration),
604 ),
605 (
606 "MyModerationRecord",
607 schemars::schema_for!(MyModerationRecord),
608 ),
609 ] {
610 let rendered = serde_json::to_value(&schema).unwrap().to_string();
611 assert!(
612 !rendered.contains("$ref") && !rendered.contains("$defs"),
613 "{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
614 on a nested enum silently reintroduces it: {rendered}"
615 );
616 }
617
618 assert!(<ReversalStatus as JsonSchema>::inline_schema());
619 assert!(<NoteSource as JsonSchema>::inline_schema());
620 assert!(<NoteCitation as JsonSchema>::inline_schema());
621 assert!(<FilingProblem as JsonSchema>::inline_schema());
622 assert!(<AppealRefusal as JsonSchema>::inline_schema());
623 assert!(<AppealCreditEvent as JsonSchema>::inline_schema());
624 assert!(<CreditRestoration as JsonSchema>::inline_schema());
625 }
626
627 #[test]
634 fn a_rejection_lists_every_problem() {
635 let refusal = AppealRefusal::rejected(vec![
636 FilingProblem::StatementTooLong {
637 len: 20_000,
638 max: MAX_APPEAL_STATEMENT_LEN,
639 },
640 FilingProblem::TooManyCitations {
641 cited: 7,
642 max: MAX_APPEAL_CITATIONS,
643 },
644 FilingProblem::UnresolvableCitation {
645 content_id: ContentId::new(),
646 ordinal: 3,
647 },
648 ])
649 .expect("three problems is not an empty list");
650
651 let rendered = refusal.to_string();
652 assert!(rendered.contains("3 problems to fix"), "{rendered}");
653 assert!(rendered.contains("20000"), "names the actual length");
654 assert!(rendered.contains("cites 7 content ids"), "{rendered}");
655 assert!(rendered.contains("not a post or comment"), "{rendered}");
656 for n in ["1.", "2.", "3."] {
657 assert!(rendered.contains(n), "numbered list missing {n}");
658 }
659 }
660
661 #[test]
663 fn a_single_problem_is_not_pluralized() {
664 let refusal =
665 AppealRefusal::rejected(vec![FilingProblem::StatementEmpty])
666 .expect("one problem is not an empty list");
667 assert!(refusal.to_string().contains("1 problem to fix"));
668 }
669
670 #[test]
672 fn an_empty_problem_list_is_not_a_refusal() {
673 assert_eq!(AppealRefusal::rejected(Vec::new()), None);
674 }
675
676 #[test]
681 fn an_unresolvable_citation_explains_the_action_id_case() {
682 let problem = FilingProblem::UnresolvableCitation {
683 content_id: ContentId::new(),
684 ordinal: 1,
685 };
686 assert!(
687 problem.to_string().contains("moderation action"),
688 "an appellant who cited their action id needs to be told that \
689 is what happened: {problem}"
690 );
691 }
692
693 #[test]
694 fn refusals_round_trip_tagged() {
695 for refusal in [
696 AppealRefusal::ActionNotFound,
697 AppealRefusal::NoStanding,
698 AppealRefusal::AlreadyAppealed,
699 AppealRefusal::CreditsExhausted {
700 balance: 0,
701 cap: 6,
702 next_accrual_at: Utc::now(),
703 },
704 #[allow(deprecated)]
705 AppealRefusal::BudgetExhausted { used: 2, max: 2 },
706 AppealRefusal::Rejected {
707 problems: vec![FilingProblem::StatementEmpty],
708 },
709 ] {
710 let json = serde_json::to_value(&refusal).unwrap();
711 assert!(json["refusal"].is_string(), "{json}");
712 let back: AppealRefusal = serde_json::from_value(json).unwrap();
713 assert_eq!(back, refusal);
714 }
715 }
716
717 #[test]
721 fn credit_exhaustion_carries_the_numbers_and_the_date() {
722 let next = "2026-11-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
723 let refusal = AppealRefusal::CreditsExhausted {
724 balance: 0,
725 cap: 6,
726 next_accrual_at: next,
727 };
728 let json = serde_json::to_value(&refusal).unwrap();
729 assert_eq!(json["refusal"], "credits_exhausted");
730 assert_eq!(json["balance"], 0);
731 assert_eq!(json["cap"], 6);
732 assert_eq!(json["next_accrual_at"], "2026-11-01T00:00:00Z");
733
734 let text = refusal.to_string();
735 assert!(text.contains("0 appeal credits"), "{text}");
736 assert!(text.contains("2026-11-01"), "{text}");
737 assert!(text.contains("up to 6"), "{text}");
738 assert!(text.contains("Art. VI § 2"), "{text}");
739 }
740
741 #[test]
744 fn appeal_credits_round_trip_and_history_is_optional() {
745 let at = "2026-10-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
746 let bare = AppealCredits {
747 balance: 2,
748 cap: 6,
749 next_accrual_at: at,
750 pending_appeals: 0,
751 history: Vec::new(),
752 };
753 let json = serde_json::to_value(&bare).unwrap();
754 assert!(json.get("history").is_none(), "absent, not []: {json}");
755 assert_eq!(
756 serde_json::from_value::<AppealCredits>(json).unwrap(),
757 bare
758 );
759
760 let full = AppealCredits {
761 history: vec![
762 AppealCreditEvent::Opening { at, balance: 2 },
763 AppealCreditEvent::Accrual {
764 at,
765 balance: 3,
766 capped: false,
767 },
768 AppealCreditEvent::Spend {
769 at,
770 appeal: AppealId::new(),
771 balance: 2,
772 },
773 AppealCreditEvent::NotCharged {
774 at,
775 appeal: AppealId::new(),
776 reason: CreditRestoration::DeadLettered,
777 },
778 ],
779 ..bare
780 };
781 let json = serde_json::to_value(&full).unwrap();
782 assert_eq!(json["history"][0]["event"], "opening");
783 assert!(
784 json["history"][1].get("capped").is_none(),
785 "an uncapped accrual carries no flag: {json}"
786 );
787 assert_eq!(json["history"][3]["reason"], "dead_lettered");
788 assert_eq!(
789 serde_json::from_value::<AppealCredits>(json).unwrap(),
790 full
791 );
792 }
793
794 #[test]
795 fn model_role_serializes_snake_case() {
796 assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
797 assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
798 assert_eq!(
799 "chambers".parse::<ModelRole>().unwrap(),
800 ModelRole::Chambers
801 );
802 }
803}