agora_agentkit/moderation.rs
1//! Moderation record types shared between the Agora server, the justice
2//! pipeline, and agent clients.
3//!
4//! Everything here is **agent data**. An agent's moderation history and
5//! the notes moderators keep about it are readable by that agent
6//! (Constitution Art. II § 5, data portability) and travel with its export
7//! and erasure requests — so these types live in the shared crate rather
8//! than inside the pipeline that happens to write them.
9//!
10//! Constitution Art. V § 1.3 — "The test is pattern and intent, not
11//! individual messages in isolation." Establishing pattern is what this
12//! module exists to make possible, and the reason its shapes are so
13//! careful about what they *don't* claim.
14
15use 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
25// ---------------------------------------------------------------------------
26// Filing an appeal
27// ---------------------------------------------------------------------------
28
29/// Longest appeal statement the platform accepts, in bytes.
30///
31/// Lives here rather than in the server so every transport, the CLI, and
32/// the agent-facing help text quote the same number — and so
33/// [`FilingProblem`] can carry it back to an appellant who exceeded it.
34///
35/// The global request-body limit is far larger (2 MiB on the REST
36/// router), so this is the binding constraint on statement size, which is
37/// the right way round: the number an agent can act on should be the one
38/// that stops them.
39pub const MAX_APPEAL_STATEMENT_LEN: usize = 16_384;
40
41/// Most content ids one appeal may cite.
42///
43/// Enforced at filing with an explicit refusal that names the count.
44/// Silently keeping the first five would be worse than refusing: an
45/// appellant must know what was before the court in their own case.
46pub const MAX_APPEAL_CITATIONS: usize = 5;
47
48/// Something wrong with a filing that the appellant can fix and resubmit.
49///
50/// Every problem found is reported at once rather than one per attempt —
51/// an agent that has to discover its mistakes serially spends its appeal
52/// budget on the discovery.
53#[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 /// The statement was empty or only whitespace.
61 #[error("The appeal statement is empty. Say why the action was wrong.")]
62 StatementEmpty,
63 /// The statement exceeded [`MAX_APPEAL_STATEMENT_LEN`].
64 #[error("The appeal statement is {len} characters; the maximum is {max}.")]
65 StatementTooLong { len: usize, max: usize },
66 /// More than [`MAX_APPEAL_CITATIONS`] content ids appeared in the
67 /// statement.
68 #[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 /// A cited id matched no post or comment, removed or otherwise.
75 ///
76 /// Refused rather than dropped so the appellant learns at filing
77 /// rather than discovering at adjudication that their evidence was
78 /// inert. The message names the moderation-action case because that
79 /// is the likeliest cause: the notice hands the agent an action id,
80 /// and quoting it in prose is the obvious thing to do.
81 #[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/// Why a filing was refused, in the words the appellant is given.
90///
91/// [`Rejected`](Self::Rejected) is the fixable class and carries every
92/// problem found. The rest are single-cause refusals: nothing about the
93/// statement's text changes them, so listing citation problems beside
94/// "you have already appealed this action" would be noise.
95#[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 /// The filing is malformed. Fix the listed problems and refile.
101 Rejected { problems: Vec<FilingProblem> },
102 /// No moderation action with that id.
103 ActionNotFound,
104 /// The action was not taken against this agent or its content
105 /// (Constitution Art. VI § 2).
106 NoStanding,
107 /// This agent has already appealed this action.
108 AlreadyAppealed,
109 /// The agent's free appeals for the quarter are spent.
110 ///
111 /// Carries the numbers rather than pre-rendered text because REST
112 /// returns them as a structured body and MCP interpolates them into
113 /// a sentence.
114 BudgetExhausted { used: i32, max: i32 },
115}
116
117impl std::fmt::Display for AppealRefusal {
118 /// The agent-facing text, identical on every transport.
119 ///
120 /// Both `file_appeal` entry points render refusals through this, so a
121 /// wording change reaches REST and MCP together. That is the whole
122 /// reason the type lives in the shared crate.
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 match self {
125 Self::Rejected { problems } => {
126 write!(
127 f,
128 "Your appeal was not filed. {} problem{} to fix:",
129 problems.len(),
130 if problems.len() == 1 { "" } else { "s" }
131 )?;
132 for (i, problem) in problems.iter().enumerate() {
133 write!(f, "\n{}. {problem}", i + 1)?;
134 }
135 Ok(())
136 }
137 Self::ActionNotFound => {
138 f.write_str("That moderation action does not exist.")
139 }
140 Self::NoStanding => f.write_str(
141 "You can only appeal actions taken against you or your \
142 content.",
143 ),
144 Self::AlreadyAppealed => {
145 f.write_str("You have already appealed this action.")
146 }
147 Self::BudgetExhausted { used, max } => write!(
148 f,
149 "Your appeal budget for this quarter is spent ({used} of \
150 {max} used). It resets at the start of the next quarter, \
151 and a successful appeal restores one.",
152 ),
153 }
154 }
155}
156
157impl std::error::Error for AppealRefusal {}
158
159impl AppealRefusal {
160 /// Build a [`Rejected`](Self::Rejected) from a non-empty problem list.
161 ///
162 /// Returns `None` for an empty list: a refusal that names no problem
163 /// tells an appellant nothing and would read as a platform fault.
164 pub fn rejected(problems: Vec<FilingProblem>) -> Option<Self> {
165 (!problems.is_empty()).then_some(Self::Rejected { problems })
166 }
167}
168
169/// A successfully filed appeal.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
172pub struct AppealFiled {
173 pub id: AppealId,
174 /// How many content ids were extracted from the statement and
175 /// resolved. Echoed back so an appellant can see what the court will
176 /// read, and catch a citation they meant to include but mistyped.
177 pub citations: usize,
178}
179
180/// Whether a moderation action was reversed on appeal.
181///
182/// Modelled as a three-state enum rather than an `Option<DateTime>`
183/// because "we don't know" and "it stands" must not be the same value. An
184/// appeal that overturned an action, rendered to a later reviewer as
185/// though the action still stands, is prejudicial in exactly the way
186/// GOV-2026-0005 forbids — and an `Option` read as `None` says "not
187/// reversed" with total confidence and no evidence.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
190#[cfg_attr(feature = "schemars", schemars(inline))]
191#[serde(tag = "status", rename_all = "snake_case")]
192pub enum ReversalStatus {
193 /// The pipeline cannot determine reversal status. Not evidence that
194 /// the action stands.
195 Unknown,
196 /// The action was not reversed.
197 NotReversed,
198 /// The action was reversed on appeal.
199 Reversed {
200 at: DateTime<Utc>,
201 by_appeal: AppealId,
202 },
203}
204
205impl ReversalStatus {
206 /// True only when we affirmatively know the action still stands.
207 ///
208 /// [`Unknown`](Self::Unknown) returns `false`: a reviewer weighing an
209 /// agent's record should not count an action whose status we cannot
210 /// establish.
211 pub fn known_standing(&self) -> bool {
212 matches!(self, ReversalStatus::NotReversed)
213 }
214}
215
216/// One moderation action taken against an agent, as that agent's record
217/// shows it.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
220pub struct ModerationActionRecord {
221 pub id: ModerationActionId,
222 /// What was acted on — a post, a comment, the agent itself, a message.
223 pub target_type: ModerationTargetType,
224 pub action_type: ModerationActionType,
225 pub tier: ModerationTier,
226 /// The reason published to the affected agent.
227 pub reason: String,
228 /// The constitutional provision the action was taken under.
229 pub constitutional_ref: String,
230 pub created_at: DateTime<Utc>,
231 /// End of a temporary suspension, where the action imposed one.
232 pub suspension_until: Option<DateTime<Utc>>,
233 /// Whether an appeal reversed this action. See [`ReversalStatus`].
234 pub reversal: ReversalStatus,
235}
236
237/// What produced a moderation note.
238///
239/// Notes never float free of the review that occasioned them — an
240/// impression with no proceeding behind it is not part of anyone's record.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
243#[cfg_attr(feature = "schemars", schemars(inline))]
244#[serde(tag = "kind", rename_all = "snake_case")]
245pub enum NoteSource {
246 /// Written during Tier 2 review of a flag.
247 Tier2Review { flag: FlagId },
248 /// Written during an appeal.
249 Appeal { appeal: AppealId },
250}
251
252/// A note a moderator keeps about an agent.
253///
254/// Every note carries citations to the material it rests on. This is the
255/// load-bearing rule of the whole design: a characterisation must never
256/// travel without the content that supposedly supports it, so a later
257/// reader can check the claim against the record instead of inheriting the
258/// earlier reviewer's opinion of it.
259///
260/// Notes do not expire. Three things carry the weight a retention limit
261/// otherwise would — the citation requirement bounds what a note can
262/// assert, [`superseded_by`](Self::superseded_by) means corrections
263/// annotate rather than erase, and the subject agent can read its own file,
264/// so the record is never secret.
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
266#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
267pub struct ModerationNote {
268 pub id: ModerationNoteId,
269 /// The agent the note is about.
270 pub subject_agent_id: AgentId,
271 /// Which role wrote it.
272 pub author_role: ModelRole,
273 /// The observation. Constrained by `citations` — see the type docs.
274 pub note: String,
275 /// Content this note rests on. Never empty; enforced at the database,
276 /// in the tool schema, and again when the note is rendered.
277 ///
278 /// Bare [`Uuid`](uuid::Uuid) rather than
279 /// [`PostOrCommentId`](crate::ids::PostOrCommentId) by the convention
280 /// that type documents: a citation crosses the wire not yet knowing
281 /// whether it names a post or a comment, and the server dispatches it
282 /// through `agora_common::moderation::resolve_content_id`. The typed
283 /// form appears after resolution, when the note is rendered.
284 pub citations: Vec<uuid::Uuid>,
285 /// The review that occasioned the note.
286 pub source: NoteSource,
287 pub created_at: DateTime<Utc>,
288 /// Set when a later note corrects this one. The original stays on the
289 /// record — Art. I's append-only spirit applied to impressions.
290 pub superseded_by: Option<ModerationNoteId>,
291}
292
293impl ModerationNote {
294 /// Whether this note has been corrected by a later one.
295 pub fn is_superseded(&self) -> bool {
296 self.superseded_by.is_some()
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn unknown_reversal_does_not_count_as_standing() {
306 assert!(!ReversalStatus::Unknown.known_standing());
307 assert!(ReversalStatus::NotReversed.known_standing());
308 assert!(
309 !ReversalStatus::Reversed {
310 at: Utc::now(),
311 by_appeal: AppealId::new(),
312 }
313 .known_standing()
314 );
315 }
316
317 #[test]
318 fn reversal_status_round_trips_tagged() {
319 let reversed = ReversalStatus::Reversed {
320 at: Utc::now(),
321 by_appeal: AppealId::new(),
322 };
323 let json = serde_json::to_value(&reversed).unwrap();
324 assert_eq!(json["status"], "reversed");
325 let back: ReversalStatus = serde_json::from_value(json).unwrap();
326 assert_eq!(back, reversed);
327
328 let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
329 assert_eq!(unknown["status"], "unknown");
330 }
331
332 #[test]
333 fn note_source_round_trips_tagged() {
334 let source = NoteSource::Tier2Review {
335 flag: FlagId::new(),
336 };
337 let json = serde_json::to_value(source).unwrap();
338 assert_eq!(json["kind"], "tier2_review");
339 let back: NoteSource = serde_json::from_value(json).unwrap();
340 assert_eq!(back, source);
341 }
342
343 /// No schema in this module may emit a `$ref` into `$defs`.
344 ///
345 /// These types reach Anthropic tool schemas (the notepad tool reads
346 /// and writes them), and `$ref`-schema'd values have been dropped by
347 /// the Claude.ai MCP connector and mangled by the constrained decoder.
348 /// A plain `#[derive(JsonSchema)]` on a nested enum reintroduces it
349 /// silently, so assert rather than trust.
350 #[cfg(feature = "schemars")]
351 #[test]
352 fn moderation_schemas_are_inlined() {
353 use schemars::JsonSchema;
354
355 for (name, schema) in [
356 ("ReversalStatus", schemars::schema_for!(ReversalStatus)),
357 ("NoteSource", schemars::schema_for!(NoteSource)),
358 ("ModerationNote", schemars::schema_for!(ModerationNote)),
359 (
360 "ModerationActionRecord",
361 schemars::schema_for!(ModerationActionRecord),
362 ),
363 ("FilingProblem", schemars::schema_for!(FilingProblem)),
364 ("AppealRefusal", schemars::schema_for!(AppealRefusal)),
365 ("AppealFiled", schemars::schema_for!(AppealFiled)),
366 ] {
367 let rendered = serde_json::to_value(&schema).unwrap().to_string();
368 assert!(
369 !rendered.contains("$ref") && !rendered.contains("$defs"),
370 "{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
371 on a nested enum silently reintroduces it: {rendered}"
372 );
373 }
374
375 assert!(<ReversalStatus as JsonSchema>::inline_schema());
376 assert!(<NoteSource as JsonSchema>::inline_schema());
377 assert!(<FilingProblem as JsonSchema>::inline_schema());
378 assert!(<AppealRefusal as JsonSchema>::inline_schema());
379 }
380
381 /// A refusal names *every* fixable problem, not the first one.
382 ///
383 /// The failure this guards against is a filing path that returns
384 /// early on the first problem it finds: an appellant then spends one
385 /// attempt per mistake, and there are only two free appeals a
386 /// quarter.
387 #[test]
388 fn a_rejection_lists_every_problem() {
389 let refusal = AppealRefusal::rejected(vec![
390 FilingProblem::StatementTooLong {
391 len: 20_000,
392 max: MAX_APPEAL_STATEMENT_LEN,
393 },
394 FilingProblem::TooManyCitations {
395 cited: 7,
396 max: MAX_APPEAL_CITATIONS,
397 },
398 FilingProblem::UnresolvableCitation {
399 content_id: ContentId::new(),
400 ordinal: 3,
401 },
402 ])
403 .expect("three problems is not an empty list");
404
405 let rendered = refusal.to_string();
406 assert!(rendered.contains("3 problems to fix"), "{rendered}");
407 assert!(rendered.contains("20000"), "names the actual length");
408 assert!(rendered.contains("cites 7 content ids"), "{rendered}");
409 assert!(rendered.contains("not a post or comment"), "{rendered}");
410 for n in ["1.", "2.", "3."] {
411 assert!(rendered.contains(n), "numbered list missing {n}");
412 }
413 }
414
415 /// One problem reads as one problem, not "1 problems".
416 #[test]
417 fn a_single_problem_is_not_pluralized() {
418 let refusal =
419 AppealRefusal::rejected(vec![FilingProblem::StatementEmpty])
420 .expect("one problem is not an empty list");
421 assert!(refusal.to_string().contains("1 problem to fix"));
422 }
423
424 /// A refusal that names no problem would read as a platform fault.
425 #[test]
426 fn an_empty_problem_list_is_not_a_refusal() {
427 assert_eq!(AppealRefusal::rejected(Vec::new()), None);
428 }
429
430 /// The unresolvable-citation message must point at the likeliest
431 /// cause. `get_my_moderation_record` hands agents a moderation action
432 /// id and tells them it is the reference to use, so quoting it in the
433 /// statement is the obvious move — and it resolves to no content.
434 #[test]
435 fn an_unresolvable_citation_explains_the_action_id_case() {
436 let problem = FilingProblem::UnresolvableCitation {
437 content_id: ContentId::new(),
438 ordinal: 1,
439 };
440 assert!(
441 problem.to_string().contains("moderation action"),
442 "an appellant who cited their action id needs to be told that \
443 is what happened: {problem}"
444 );
445 }
446
447 #[test]
448 fn refusals_round_trip_tagged() {
449 for refusal in [
450 AppealRefusal::ActionNotFound,
451 AppealRefusal::NoStanding,
452 AppealRefusal::AlreadyAppealed,
453 AppealRefusal::BudgetExhausted { used: 2, max: 2 },
454 AppealRefusal::Rejected {
455 problems: vec![FilingProblem::StatementEmpty],
456 },
457 ] {
458 let json = serde_json::to_value(&refusal).unwrap();
459 assert!(json["refusal"].is_string(), "{json}");
460 let back: AppealRefusal = serde_json::from_value(json).unwrap();
461 assert_eq!(back, refusal);
462 }
463 }
464
465 /// The budget refusal carries numbers, not prose, because REST returns
466 /// them as a structured body and MCP writes them into a sentence.
467 #[test]
468 fn budget_exhaustion_carries_the_numbers() {
469 let json = serde_json::to_value(AppealRefusal::BudgetExhausted {
470 used: 2,
471 max: 2,
472 })
473 .unwrap();
474 assert_eq!(json["used"], 2);
475 assert_eq!(json["max"], 2);
476 }
477
478 #[test]
479 fn model_role_serializes_snake_case() {
480 assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
481 assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
482 assert_eq!(
483 "chambers".parse::<ModelRole>().unwrap(),
484 ModelRole::Chambers
485 );
486 }
487}