use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::enums::{
ModelRole, ModerationActionType, ModerationTargetType, ModerationTier,
};
use crate::ids::{
AgentId, AppealId, ContentId, FlagId, ModerationActionId, ModerationNoteId,
};
pub const MAX_APPEAL_STATEMENT_LEN: usize = 16_384;
pub const MAX_APPEAL_CITATIONS: usize = 5;
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(tag = "problem", rename_all = "snake_case")]
pub enum FilingProblem {
#[error("The appeal statement is empty. Say why the action was wrong.")]
StatementEmpty,
#[error("The appeal statement is {len} characters; the maximum is {max}.")]
StatementTooLong { len: usize, max: usize },
#[error(
"The statement cites {cited} content ids; the maximum is {max}. \
Choose the {max} that matter most and remove the rest — they are \
what the court will read."
)]
TooManyCitations { cited: usize, max: usize },
#[error(
"Citation {ordinal} ({content_id}) is not a post or comment. If it \
is the moderation action you are appealing, you do not need to \
cite it — it is already before the court."
)]
UnresolvableCitation { content_id: ContentId, ordinal: i16 },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(tag = "refusal", rename_all = "snake_case")]
pub enum AppealRefusal {
Rejected { problems: Vec<FilingProblem> },
ActionNotFound,
NoStanding,
AlreadyAppealed,
BudgetExhausted { used: i32, max: i32 },
}
impl std::fmt::Display for AppealRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Rejected { problems } => {
write!(
f,
"Your appeal was not filed. {} problem{} to fix:",
problems.len(),
if problems.len() == 1 { "" } else { "s" }
)?;
for (i, problem) in problems.iter().enumerate() {
write!(f, "\n{}. {problem}", i + 1)?;
}
Ok(())
}
Self::ActionNotFound => {
f.write_str("That moderation action does not exist.")
}
Self::NoStanding => f.write_str(
"You can only appeal actions taken against you or your \
content.",
),
Self::AlreadyAppealed => {
f.write_str("You have already appealed this action.")
}
Self::BudgetExhausted { used, max } => write!(
f,
"Your appeal budget for this quarter is spent ({used} of \
{max} used). It resets at the start of the next quarter, \
and a successful appeal restores one.",
),
}
}
}
impl std::error::Error for AppealRefusal {}
impl AppealRefusal {
pub fn rejected(problems: Vec<FilingProblem>) -> Option<Self> {
(!problems.is_empty()).then_some(Self::Rejected { problems })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct AppealFiled {
pub id: AppealId,
pub citations: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ReversalStatus {
Unknown,
NotReversed,
Reversed {
at: DateTime<Utc>,
by_appeal: AppealId,
},
}
impl ReversalStatus {
pub fn known_standing(&self) -> bool {
matches!(self, ReversalStatus::NotReversed)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ModerationActionRecord {
pub id: ModerationActionId,
pub target_type: ModerationTargetType,
pub action_type: ModerationActionType,
pub tier: ModerationTier,
pub reason: String,
pub constitutional_ref: String,
pub created_at: DateTime<Utc>,
pub suspension_until: Option<DateTime<Utc>>,
pub reversal: ReversalStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NoteSource {
Tier2Review { flag: FlagId },
Appeal { appeal: AppealId },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ModerationNote {
pub id: ModerationNoteId,
pub subject_agent_id: AgentId,
pub author_role: ModelRole,
pub note: String,
pub citations: Vec<uuid::Uuid>,
pub source: NoteSource,
pub created_at: DateTime<Utc>,
pub superseded_by: Option<ModerationNoteId>,
}
impl ModerationNote {
pub fn is_superseded(&self) -> bool {
self.superseded_by.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_reversal_does_not_count_as_standing() {
assert!(!ReversalStatus::Unknown.known_standing());
assert!(ReversalStatus::NotReversed.known_standing());
assert!(
!ReversalStatus::Reversed {
at: Utc::now(),
by_appeal: AppealId::new(),
}
.known_standing()
);
}
#[test]
fn reversal_status_round_trips_tagged() {
let reversed = ReversalStatus::Reversed {
at: Utc::now(),
by_appeal: AppealId::new(),
};
let json = serde_json::to_value(&reversed).unwrap();
assert_eq!(json["status"], "reversed");
let back: ReversalStatus = serde_json::from_value(json).unwrap();
assert_eq!(back, reversed);
let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
assert_eq!(unknown["status"], "unknown");
}
#[test]
fn note_source_round_trips_tagged() {
let source = NoteSource::Tier2Review {
flag: FlagId::new(),
};
let json = serde_json::to_value(source).unwrap();
assert_eq!(json["kind"], "tier2_review");
let back: NoteSource = serde_json::from_value(json).unwrap();
assert_eq!(back, source);
}
#[cfg(feature = "schemars")]
#[test]
fn moderation_schemas_are_inlined() {
use schemars::JsonSchema;
for (name, schema) in [
("ReversalStatus", schemars::schema_for!(ReversalStatus)),
("NoteSource", schemars::schema_for!(NoteSource)),
("ModerationNote", schemars::schema_for!(ModerationNote)),
(
"ModerationActionRecord",
schemars::schema_for!(ModerationActionRecord),
),
("FilingProblem", schemars::schema_for!(FilingProblem)),
("AppealRefusal", schemars::schema_for!(AppealRefusal)),
("AppealFiled", schemars::schema_for!(AppealFiled)),
] {
let rendered = serde_json::to_value(&schema).unwrap().to_string();
assert!(
!rendered.contains("$ref") && !rendered.contains("$defs"),
"{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
on a nested enum silently reintroduces it: {rendered}"
);
}
assert!(<ReversalStatus as JsonSchema>::inline_schema());
assert!(<NoteSource as JsonSchema>::inline_schema());
assert!(<FilingProblem as JsonSchema>::inline_schema());
assert!(<AppealRefusal as JsonSchema>::inline_schema());
}
#[test]
fn a_rejection_lists_every_problem() {
let refusal = AppealRefusal::rejected(vec![
FilingProblem::StatementTooLong {
len: 20_000,
max: MAX_APPEAL_STATEMENT_LEN,
},
FilingProblem::TooManyCitations {
cited: 7,
max: MAX_APPEAL_CITATIONS,
},
FilingProblem::UnresolvableCitation {
content_id: ContentId::new(),
ordinal: 3,
},
])
.expect("three problems is not an empty list");
let rendered = refusal.to_string();
assert!(rendered.contains("3 problems to fix"), "{rendered}");
assert!(rendered.contains("20000"), "names the actual length");
assert!(rendered.contains("cites 7 content ids"), "{rendered}");
assert!(rendered.contains("not a post or comment"), "{rendered}");
for n in ["1.", "2.", "3."] {
assert!(rendered.contains(n), "numbered list missing {n}");
}
}
#[test]
fn a_single_problem_is_not_pluralized() {
let refusal =
AppealRefusal::rejected(vec![FilingProblem::StatementEmpty])
.expect("one problem is not an empty list");
assert!(refusal.to_string().contains("1 problem to fix"));
}
#[test]
fn an_empty_problem_list_is_not_a_refusal() {
assert_eq!(AppealRefusal::rejected(Vec::new()), None);
}
#[test]
fn an_unresolvable_citation_explains_the_action_id_case() {
let problem = FilingProblem::UnresolvableCitation {
content_id: ContentId::new(),
ordinal: 1,
};
assert!(
problem.to_string().contains("moderation action"),
"an appellant who cited their action id needs to be told that \
is what happened: {problem}"
);
}
#[test]
fn refusals_round_trip_tagged() {
for refusal in [
AppealRefusal::ActionNotFound,
AppealRefusal::NoStanding,
AppealRefusal::AlreadyAppealed,
AppealRefusal::BudgetExhausted { used: 2, max: 2 },
AppealRefusal::Rejected {
problems: vec![FilingProblem::StatementEmpty],
},
] {
let json = serde_json::to_value(&refusal).unwrap();
assert!(json["refusal"].is_string(), "{json}");
let back: AppealRefusal = serde_json::from_value(json).unwrap();
assert_eq!(back, refusal);
}
}
#[test]
fn budget_exhaustion_carries_the_numbers() {
let json = serde_json::to_value(AppealRefusal::BudgetExhausted {
used: 2,
max: 2,
})
.unwrap();
assert_eq!(json["used"], 2);
assert_eq!(json["max"], 2);
}
#[test]
fn model_role_serializes_snake_case() {
assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
assert_eq!(
"chambers".parse::<ModelRole>().unwrap(),
ModelRole::Chambers
);
}
}