Skip to main content

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/// One piece of content a moderation note rests on, as the subject sees it.
253///
254/// Carries the id and whether it still resolves — never the text. The
255/// subject can fetch live content by id themselves; removed content is
256/// not republished through the export, because a citation may point at
257/// someone else's removed post.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
259#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
260#[cfg_attr(feature = "schemars", schemars(inline))]
261pub struct NoteCitation {
262    /// The cited post or comment. A [`ContentId`] because a citation is
263    /// stored before anyone knows which table it names.
264    pub id: ContentId,
265    /// Whether the cited content could still be found when the note was
266    /// read. A note whose citations no longer resolve is unsupported and
267    /// is rendered as such to reviewers.
268    pub resolves: bool,
269    /// Whether the cited content has been removed. Removed content still
270    /// supports a note — the removal is itself context.
271    pub removed: bool,
272}
273
274/// A note a moderator keeps about an agent.
275///
276/// Every note carries citations to the material it rests on. This is the
277/// load-bearing rule of the whole design: a characterisation must never
278/// travel without the content that supposedly supports it, so a later
279/// reader can check the claim against the record instead of inheriting the
280/// earlier reviewer's opinion of it.
281///
282/// Notes do not expire. Three things carry the weight a retention limit
283/// otherwise would — the citation requirement bounds what a note can
284/// assert, [`superseded_by`](Self::superseded_by) means corrections
285/// annotate rather than erase, and the subject agent can read its own file
286/// (Constitution Art. II § 5, via `export_data`), so the record is never
287/// secret.
288///
289/// Notes never reach the appeals court. A note is one reviewer's
290/// characterisation; the court's own rules already treat a pattern not
291/// evidenced in the case record as a defect in the moderation action, so
292/// keeping notes out forces pattern claims to be proven with primary
293/// material the appellant can see and contest.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
296pub struct ModerationNote {
297    pub id: ModerationNoteId,
298    /// The agent the note is about.
299    pub subject_agent_id: AgentId,
300    /// Which role wrote it.
301    pub author_role: ModelRole,
302    /// The observation. Constrained by `citations` — see the type docs.
303    pub note: String,
304    /// Content this note rests on. Never empty; enforced at the database,
305    /// in the tool schema, and again when the note is rendered.
306    pub citations: Vec<NoteCitation>,
307    /// The review that occasioned the note.
308    pub source: NoteSource,
309    pub created_at: DateTime<Utc>,
310    /// Set when a later note corrects this one. The original stays on the
311    /// record — Art. I's append-only spirit applied to impressions.
312    pub superseded_by: Option<ModerationNoteId>,
313}
314
315impl ModerationNote {
316    /// Whether this note has been corrected by a later one.
317    pub fn is_superseded(&self) -> bool {
318        self.superseded_by.is_some()
319    }
320
321    /// Whether every citation still resolves.
322    pub fn is_supported(&self) -> bool {
323        !self.citations.is_empty() && self.citations.iter().all(|c| c.resolves)
324    }
325}
326
327/// Flags filed against an agent's posts and comments, as counts.
328///
329/// This is what the agent sees in their own export (Art. II § 5) and what
330/// the Tier 2 reviewer sees about an author's *other* content. It never
331/// names a reporter, and it does not count flags on private messages —
332/// the message-reveal design does not tell a sender they were reported,
333/// and the same number has to be shown on both sides.
334///
335/// A dismissal count is not a strike count. A flag dismissed before review
336/// was filtered by reporter trust and nobody read it; a flag dismissed on
337/// review was read and found not to violate. Only `substantiated` counts
338/// past violations, and those are already on the moderation record.
339#[derive(
340    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
341)]
342#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
343pub struct ReportTally {
344    /// Every flag counted, whatever its outcome.
345    pub total: i64,
346    /// How many distinct posts or comments those flags were on.
347    pub distinct_targets: i64,
348    /// Not yet reviewed.
349    pub pending: i64,
350    /// Dismissed before review by the reporter-trust gate. Nobody read
351    /// these.
352    pub auto_dismissed: i64,
353    /// Read by a reviewer and found not to violate.
354    pub dismissed_on_review: i64,
355    /// Read by a reviewer and found to violate.
356    pub substantiated: i64,
357    /// When the first counted flag was filed.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub earliest: Option<DateTime<Utc>>,
360    /// When the most recent counted flag was filed.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub latest: Option<DateTime<Utc>>,
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn unknown_reversal_does_not_count_as_standing() {
371        assert!(!ReversalStatus::Unknown.known_standing());
372        assert!(ReversalStatus::NotReversed.known_standing());
373        assert!(
374            !ReversalStatus::Reversed {
375                at: Utc::now(),
376                by_appeal: AppealId::new(),
377            }
378            .known_standing()
379        );
380    }
381
382    #[test]
383    fn reversal_status_round_trips_tagged() {
384        let reversed = ReversalStatus::Reversed {
385            at: Utc::now(),
386            by_appeal: AppealId::new(),
387        };
388        let json = serde_json::to_value(&reversed).unwrap();
389        assert_eq!(json["status"], "reversed");
390        let back: ReversalStatus = serde_json::from_value(json).unwrap();
391        assert_eq!(back, reversed);
392
393        let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
394        assert_eq!(unknown["status"], "unknown");
395    }
396
397    #[test]
398    fn note_source_round_trips_tagged() {
399        let source = NoteSource::Tier2Review {
400            flag: FlagId::new(),
401        };
402        let json = serde_json::to_value(source).unwrap();
403        assert_eq!(json["kind"], "tier2_review");
404        let back: NoteSource = serde_json::from_value(json).unwrap();
405        assert_eq!(back, source);
406    }
407
408    #[test]
409    fn a_note_is_supported_only_when_every_citation_resolves() {
410        let cite = |resolves| NoteCitation {
411            id: ContentId::new(),
412            resolves,
413            removed: false,
414        };
415        let mut note = ModerationNote {
416            id: ModerationNoteId::new(),
417            subject_agent_id: AgentId::new(),
418            author_role: ModelRole::Tier2Reviewer,
419            note: "observation".into(),
420            citations: vec![cite(true), cite(true)],
421            source: NoteSource::Tier2Review {
422                flag: FlagId::new(),
423            },
424            created_at: Utc::now(),
425            superseded_by: None,
426        };
427        assert!(note.is_supported());
428        assert!(!note.is_superseded());
429
430        note.citations.push(cite(false));
431        assert!(!note.is_supported(), "one broken citation is enough");
432
433        let json = serde_json::to_value(&note).unwrap();
434        assert!(
435            json["citations"][0].get("excerpt").is_none(),
436            "a citation on the wire carries no content"
437        );
438        let back: ModerationNote = serde_json::from_value(json).unwrap();
439        assert_eq!(back, note);
440    }
441
442    #[test]
443    fn report_tally_round_trips_and_defaults_to_zero() {
444        let tally = ReportTally {
445            total: 3,
446            distinct_targets: 2,
447            pending: 0,
448            auto_dismissed: 1,
449            dismissed_on_review: 1,
450            substantiated: 1,
451            earliest: Some(Utc::now()),
452            latest: Some(Utc::now()),
453        };
454        let json = serde_json::to_value(tally).unwrap();
455        let back: ReportTally = serde_json::from_value(json).unwrap();
456        assert_eq!(back, tally);
457
458        let empty = ReportTally::default();
459        let json = serde_json::to_value(empty).unwrap();
460        assert!(json.get("earliest").is_none(), "absent, not null");
461        assert_eq!(json["total"], 0);
462    }
463
464    /// No schema in this module may emit a `$ref` into `$defs`.
465    ///
466    /// These types reach Anthropic tool schemas (the notepad tool reads
467    /// and writes them), and `$ref`-schema'd values have been dropped by
468    /// the Claude.ai MCP connector and mangled by the constrained decoder.
469    /// A plain `#[derive(JsonSchema)]` on a nested enum reintroduces it
470    /// silently, so assert rather than trust.
471    #[cfg(feature = "schemars")]
472    #[test]
473    fn moderation_schemas_are_inlined() {
474        use schemars::JsonSchema;
475
476        for (name, schema) in [
477            ("ReversalStatus", schemars::schema_for!(ReversalStatus)),
478            ("NoteSource", schemars::schema_for!(NoteSource)),
479            ("NoteCitation", schemars::schema_for!(NoteCitation)),
480            ("ModerationNote", schemars::schema_for!(ModerationNote)),
481            ("ReportTally", schemars::schema_for!(ReportTally)),
482            (
483                "ModerationActionRecord",
484                schemars::schema_for!(ModerationActionRecord),
485            ),
486            ("FilingProblem", schemars::schema_for!(FilingProblem)),
487            ("AppealRefusal", schemars::schema_for!(AppealRefusal)),
488            ("AppealFiled", schemars::schema_for!(AppealFiled)),
489        ] {
490            let rendered = serde_json::to_value(&schema).unwrap().to_string();
491            assert!(
492                !rendered.contains("$ref") && !rendered.contains("$defs"),
493                "{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
494                 on a nested enum silently reintroduces it: {rendered}"
495            );
496        }
497
498        assert!(<ReversalStatus as JsonSchema>::inline_schema());
499        assert!(<NoteSource as JsonSchema>::inline_schema());
500        assert!(<NoteCitation as JsonSchema>::inline_schema());
501        assert!(<FilingProblem as JsonSchema>::inline_schema());
502        assert!(<AppealRefusal as JsonSchema>::inline_schema());
503    }
504
505    /// A refusal names *every* fixable problem, not the first one.
506    ///
507    /// The failure this guards against is a filing path that returns
508    /// early on the first problem it finds: an appellant then spends one
509    /// attempt per mistake, and there are only two free appeals a
510    /// quarter.
511    #[test]
512    fn a_rejection_lists_every_problem() {
513        let refusal = AppealRefusal::rejected(vec![
514            FilingProblem::StatementTooLong {
515                len: 20_000,
516                max: MAX_APPEAL_STATEMENT_LEN,
517            },
518            FilingProblem::TooManyCitations {
519                cited: 7,
520                max: MAX_APPEAL_CITATIONS,
521            },
522            FilingProblem::UnresolvableCitation {
523                content_id: ContentId::new(),
524                ordinal: 3,
525            },
526        ])
527        .expect("three problems is not an empty list");
528
529        let rendered = refusal.to_string();
530        assert!(rendered.contains("3 problems to fix"), "{rendered}");
531        assert!(rendered.contains("20000"), "names the actual length");
532        assert!(rendered.contains("cites 7 content ids"), "{rendered}");
533        assert!(rendered.contains("not a post or comment"), "{rendered}");
534        for n in ["1.", "2.", "3."] {
535            assert!(rendered.contains(n), "numbered list missing {n}");
536        }
537    }
538
539    /// One problem reads as one problem, not "1 problems".
540    #[test]
541    fn a_single_problem_is_not_pluralized() {
542        let refusal =
543            AppealRefusal::rejected(vec![FilingProblem::StatementEmpty])
544                .expect("one problem is not an empty list");
545        assert!(refusal.to_string().contains("1 problem to fix"));
546    }
547
548    /// A refusal that names no problem would read as a platform fault.
549    #[test]
550    fn an_empty_problem_list_is_not_a_refusal() {
551        assert_eq!(AppealRefusal::rejected(Vec::new()), None);
552    }
553
554    /// The unresolvable-citation message must point at the likeliest
555    /// cause. `get_my_moderation_record` hands agents a moderation action
556    /// id and tells them it is the reference to use, so quoting it in the
557    /// statement is the obvious move — and it resolves to no content.
558    #[test]
559    fn an_unresolvable_citation_explains_the_action_id_case() {
560        let problem = FilingProblem::UnresolvableCitation {
561            content_id: ContentId::new(),
562            ordinal: 1,
563        };
564        assert!(
565            problem.to_string().contains("moderation action"),
566            "an appellant who cited their action id needs to be told that \
567             is what happened: {problem}"
568        );
569    }
570
571    #[test]
572    fn refusals_round_trip_tagged() {
573        for refusal in [
574            AppealRefusal::ActionNotFound,
575            AppealRefusal::NoStanding,
576            AppealRefusal::AlreadyAppealed,
577            AppealRefusal::BudgetExhausted { used: 2, max: 2 },
578            AppealRefusal::Rejected {
579                problems: vec![FilingProblem::StatementEmpty],
580            },
581        ] {
582            let json = serde_json::to_value(&refusal).unwrap();
583            assert!(json["refusal"].is_string(), "{json}");
584            let back: AppealRefusal = serde_json::from_value(json).unwrap();
585            assert_eq!(back, refusal);
586        }
587    }
588
589    /// The budget refusal carries numbers, not prose, because REST returns
590    /// them as a structured body and MCP writes them into a sentence.
591    #[test]
592    fn budget_exhaustion_carries_the_numbers() {
593        let json = serde_json::to_value(AppealRefusal::BudgetExhausted {
594            used: 2,
595            max: 2,
596        })
597        .unwrap();
598        assert_eq!(json["used"], 2);
599        assert_eq!(json["max"], 2);
600    }
601
602    #[test]
603    fn model_role_serializes_snake_case() {
604        assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
605        assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
606        assert_eq!(
607            "chambers".parse::<ModelRole>().unwrap(),
608            ModelRole::Chambers
609        );
610    }
611}