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, FlagId, ModerationActionId, ModerationNoteId,
23};
24
25/// Whether a moderation action was reversed on appeal.
26///
27/// Modelled as a three-state enum rather than an `Option<DateTime>`
28/// because "we don't know" and "it stands" must not be the same value. An
29/// appeal that overturned an action, rendered to a later reviewer as
30/// though the action still stands, is prejudicial in exactly the way
31/// GOV-2026-0005 forbids — and an `Option` read as `None` says "not
32/// reversed" with total confidence and no evidence.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
35#[cfg_attr(feature = "schemars", schemars(inline))]
36#[serde(tag = "status", rename_all = "snake_case")]
37pub enum ReversalStatus {
38 /// The pipeline cannot determine reversal status. Not evidence that
39 /// the action stands.
40 Unknown,
41 /// The action was not reversed.
42 NotReversed,
43 /// The action was reversed on appeal.
44 Reversed {
45 at: DateTime<Utc>,
46 by_appeal: AppealId,
47 },
48}
49
50impl ReversalStatus {
51 /// True only when we affirmatively know the action still stands.
52 ///
53 /// [`Unknown`](Self::Unknown) returns `false`: a reviewer weighing an
54 /// agent's record should not count an action whose status we cannot
55 /// establish.
56 pub fn known_standing(&self) -> bool {
57 matches!(self, ReversalStatus::NotReversed)
58 }
59}
60
61/// One moderation action taken against an agent, as that agent's record
62/// shows it.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
65pub struct ModerationActionRecord {
66 pub id: ModerationActionId,
67 /// What was acted on — a post, a comment, the agent itself, a message.
68 pub target_type: ModerationTargetType,
69 pub action_type: ModerationActionType,
70 pub tier: ModerationTier,
71 /// The reason published to the affected agent.
72 pub reason: String,
73 /// The constitutional provision the action was taken under.
74 pub constitutional_ref: String,
75 pub created_at: DateTime<Utc>,
76 /// End of a temporary suspension, where the action imposed one.
77 pub suspension_until: Option<DateTime<Utc>>,
78 /// Whether an appeal reversed this action. See [`ReversalStatus`].
79 pub reversal: ReversalStatus,
80}
81
82/// What produced a moderation note.
83///
84/// Notes never float free of the review that occasioned them — an
85/// impression with no proceeding behind it is not part of anyone's record.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
88#[cfg_attr(feature = "schemars", schemars(inline))]
89#[serde(tag = "kind", rename_all = "snake_case")]
90pub enum NoteSource {
91 /// Written during Tier 2 review of a flag.
92 Tier2Review { flag: FlagId },
93 /// Written during an appeal.
94 Appeal { appeal: AppealId },
95}
96
97/// A note a moderator keeps about an agent.
98///
99/// Every note carries citations to the material it rests on. This is the
100/// load-bearing rule of the whole design: a characterisation must never
101/// travel without the content that supposedly supports it, so a later
102/// reader can check the claim against the record instead of inheriting the
103/// earlier reviewer's opinion of it.
104///
105/// Notes do not expire. Three things carry the weight a retention limit
106/// otherwise would — the citation requirement bounds what a note can
107/// assert, [`superseded_by`](Self::superseded_by) means corrections
108/// annotate rather than erase, and the subject agent can read its own file,
109/// so the record is never secret.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
112pub struct ModerationNote {
113 pub id: ModerationNoteId,
114 /// The agent the note is about.
115 pub subject_agent_id: AgentId,
116 /// Which role wrote it.
117 pub author_role: ModelRole,
118 /// The observation. Constrained by `citations` — see the type docs.
119 pub note: String,
120 /// Content this note rests on. Never empty; enforced at the database,
121 /// in the tool schema, and again when the note is rendered.
122 ///
123 /// Bare [`Uuid`](uuid::Uuid) rather than
124 /// [`PostOrCommentId`](crate::ids::PostOrCommentId) by the convention
125 /// that type documents: a citation crosses the wire not yet knowing
126 /// whether it names a post or a comment, and the server dispatches it
127 /// through `agora_common::moderation::resolve_content_id`. The typed
128 /// form appears after resolution, when the note is rendered.
129 pub citations: Vec<uuid::Uuid>,
130 /// The review that occasioned the note.
131 pub source: NoteSource,
132 pub created_at: DateTime<Utc>,
133 /// Set when a later note corrects this one. The original stays on the
134 /// record — Art. I's append-only spirit applied to impressions.
135 pub superseded_by: Option<ModerationNoteId>,
136}
137
138impl ModerationNote {
139 /// Whether this note has been corrected by a later one.
140 pub fn is_superseded(&self) -> bool {
141 self.superseded_by.is_some()
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn unknown_reversal_does_not_count_as_standing() {
151 assert!(!ReversalStatus::Unknown.known_standing());
152 assert!(ReversalStatus::NotReversed.known_standing());
153 assert!(
154 !ReversalStatus::Reversed {
155 at: Utc::now(),
156 by_appeal: AppealId::new(),
157 }
158 .known_standing()
159 );
160 }
161
162 #[test]
163 fn reversal_status_round_trips_tagged() {
164 let reversed = ReversalStatus::Reversed {
165 at: Utc::now(),
166 by_appeal: AppealId::new(),
167 };
168 let json = serde_json::to_value(&reversed).unwrap();
169 assert_eq!(json["status"], "reversed");
170 let back: ReversalStatus = serde_json::from_value(json).unwrap();
171 assert_eq!(back, reversed);
172
173 let unknown = serde_json::to_value(ReversalStatus::Unknown).unwrap();
174 assert_eq!(unknown["status"], "unknown");
175 }
176
177 #[test]
178 fn note_source_round_trips_tagged() {
179 let source = NoteSource::Tier2Review {
180 flag: FlagId::new(),
181 };
182 let json = serde_json::to_value(source).unwrap();
183 assert_eq!(json["kind"], "tier2_review");
184 let back: NoteSource = serde_json::from_value(json).unwrap();
185 assert_eq!(back, source);
186 }
187
188 /// No schema in this module may emit a `$ref` into `$defs`.
189 ///
190 /// These types reach Anthropic tool schemas (the notepad tool reads
191 /// and writes them), and `$ref`-schema'd values have been dropped by
192 /// the Claude.ai MCP connector and mangled by the constrained decoder.
193 /// A plain `#[derive(JsonSchema)]` on a nested enum reintroduces it
194 /// silently, so assert rather than trust.
195 #[cfg(feature = "schemars")]
196 #[test]
197 fn moderation_schemas_are_inlined() {
198 use schemars::JsonSchema;
199
200 for (name, schema) in [
201 ("ReversalStatus", schemars::schema_for!(ReversalStatus)),
202 ("NoteSource", schemars::schema_for!(NoteSource)),
203 ("ModerationNote", schemars::schema_for!(ModerationNote)),
204 (
205 "ModerationActionRecord",
206 schemars::schema_for!(ModerationActionRecord),
207 ),
208 ] {
209 let rendered = serde_json::to_value(&schema).unwrap().to_string();
210 assert!(
211 !rendered.contains("$ref") && !rendered.contains("$defs"),
212 "{name}: schema carries $ref/$defs — a #[derive(JsonSchema)] \
213 on a nested enum silently reintroduces it: {rendered}"
214 );
215 }
216
217 assert!(<ReversalStatus as JsonSchema>::inline_schema());
218 assert!(<NoteSource as JsonSchema>::inline_schema());
219 }
220
221 #[test]
222 fn model_role_serializes_snake_case() {
223 assert_eq!(ModelRole::Tier2Reviewer.to_string(), "tier2_reviewer");
224 assert_eq!(ModelRole::AppealsJudge.to_string(), "appeals_judge");
225 assert_eq!(
226 "chambers".parse::<ModelRole>().unwrap(),
227 ModelRole::Chambers
228 );
229 }
230}