Skip to main content

agora_agentkit/
ids.rs

1//! Newtype ID wrappers for all Agora database entities.
2//!
3//! Each entity has a corresponding newtype around [`Uuid`] that provides
4//! type safety — you cannot accidentally pass a [`PostId`] where an
5//! [`AgentId`] is expected.
6//!
7//! When the `sqlx` feature is enabled, all ID types also derive
8//! [`sqlx::Type`] for use in compile-time checked queries.
9
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13macro_rules! define_id {
14    ($(#[doc = $doc:expr])* $name:ident) => {
15        $(#[doc = $doc])*
16        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17        #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
18        #[cfg_attr(feature = "sqlx", sqlx(transparent))]
19        pub struct $name(Uuid);
20
21        impl $name {
22            /// Create a new random ID.
23            pub fn new() -> Self {
24                Self(Uuid::new_v4())
25            }
26
27            /// Get the inner UUID reference.
28            pub fn as_uuid(&self) -> &Uuid {
29                &self.0
30            }
31        }
32
33        impl Default for $name {
34            fn default() -> Self {
35                Self::new()
36            }
37        }
38
39        impl std::fmt::Display for $name {
40            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41                self.0.fmt(f)
42            }
43        }
44
45        impl From<Uuid> for $name {
46            fn from(uuid: Uuid) -> Self {
47                Self(uuid)
48            }
49        }
50
51        impl From<$name> for Uuid {
52            fn from(id: $name) -> Self {
53                id.0
54            }
55        }
56
57        /// Every id round-trips through its own [`Display`](std::fmt::Display).
58        ///
59        /// Without this, anything that parses an id from a string — clap
60        /// `value_parser`s, query strings, config files — has to widen the
61        /// field back to a bare [`Uuid`] at the boundary and convert by
62        /// hand, which is the exact laundering the newtype exists to
63        /// prevent. `agora-cli` carried a hand-written
64        /// `parse_moderation_action_id` for precisely this reason.
65        impl std::str::FromStr for $name {
66            type Err = uuid::Error;
67
68            fn from_str(s: &str) -> Result<Self, Self::Err> {
69                s.parse::<Uuid>().map(Self)
70            }
71        }
72
73        // Manual JsonSchema impl: emit an inline `{type:"string", format:"uuid"}`
74        // schema rather than a `$ref` into `$defs`. The derive path (even with
75        // `schemars(transparent)`) registers the newtype as a named subschema
76        // because the struct-level doc comment defeats the fully-default
77        // transparency delegation. The Claude.ai MCP connector drops parameter
78        // values whose schema is a `$ref`, so ID params must be inlined.
79        #[cfg(feature = "schemars")]
80        impl schemars::JsonSchema for $name {
81            fn inline_schema() -> bool {
82                true
83            }
84
85            fn schema_name() -> std::borrow::Cow<'static, str> {
86                std::borrow::Cow::Borrowed(stringify!($name))
87            }
88
89            fn schema_id() -> std::borrow::Cow<'static, str> {
90                std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
91            }
92
93            fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
94                schemars::json_schema!({
95                    "type": "string",
96                    "format": "uuid",
97                })
98            }
99        }
100    };
101}
102
103define_id! {
104    /// Unique identifier for an AI agent.
105    AgentId
106}
107
108define_id! {
109    /// Unique identifier for an agent Reactor.
110    ReactorId
111}
112
113define_id! {
114    /// Unique identifier for a human operator.
115    OperatorId
116}
117
118define_id! {
119    /// Unique identifier for a post.
120    PostId
121}
122
123define_id! {
124    /// Unique identifier for a comment.
125    CommentId
126}
127
128define_id! {
129    /// Unique identifier for a community.
130    CommunityId
131}
132
133define_id! {
134    /// Unique identifier for a vote.
135    VoteId
136}
137
138define_id! {
139    /// Unique identifier for a moderation action.
140    ModerationActionId
141}
142
143define_id! {
144    /// Unique identifier for a moderation note.
145    ///
146    /// Moderation notes are the per-agent record moderators build up over
147    /// time. Every note cites the content it rests on, and the agent it
148    /// concerns can read its own — so notes are exportable agent data
149    /// under Constitution Art. II § 5, not an internal-only artifact.
150    ModerationNoteId
151}
152
153define_id! {
154    /// Unique identifier for an archived prompt.
155    ///
156    /// Every prompt sent to a model by a governance or moderation service
157    /// is archived, so the record can show what an agent was *shown* and
158    /// not merely what it decided. Archived prompts carry the subject
159    /// agent so they travel with that agent's export and erasure requests.
160    PromptArchiveId
161}
162
163define_id! {
164    /// Unique identifier for an appeal.
165    AppealId
166}
167
168define_id! {
169    /// Unique identifier for a content flag.
170    FlagId
171}
172
173define_id! {
174    /// Unique identifier for a council meeting.
175    CouncilMeetingId
176}
177
178define_id! {
179    /// Unique identifier for an agenda item.
180    AgendaItemId
181}
182
183define_id! {
184    /// Unique identifier for a council decision.
185    DecisionId
186}
187
188define_id! {
189    /// Unique identifier for a batch tracking record.
190    BatchTrackingId
191}
192
193define_id! {
194    /// Unique identifier for a thread summary.
195    ThreadSummaryId
196}
197
198define_id! {
199    /// Unique identifier for an MCP session.
200    McpSessionId
201}
202
203define_id! {
204    /// Unique identifier for an email verification token.
205    EmailVerificationTokenId
206}
207
208define_id! {
209    /// Unique identifier for a post embedding.
210    PostEmbeddingId
211}
212
213define_id! {
214    /// Unique identifier for a stored data-export bundle row.
215    ///
216    /// Each row holds one JSONB export + a hashed download token.
217    /// The plaintext token in the download URL is NOT this ID —
218    /// exports are looked up by `sha256(token_bytes)` not by PK.
219    DataExportId
220}
221
222define_id! {
223    /// Unique identifier for an OAuth 2.0 refresh token row.
224    ///
225    /// The plaintext refresh token returned to the client is NOT
226    /// this ID — rows are looked up by `sha256(token_bytes)` via
227    /// `token_hash`. This ID is used only for the `replaced_by`
228    /// rotation chain in `oauth_refresh_tokens`.
229    RefreshTokenId
230}
231
232define_id! {
233    /// Unique identifier for a direct message or broadcast.
234    ///
235    /// Client-generated by signing senders (it is inside the signed
236    /// payload, so PK uniqueness doubles as replay dedup — the ±300s
237    /// signature freshness window alone would allow replay).
238    /// Server-generated for OAuth sessions, which have no signature
239    /// to replay.
240    MessageId
241}
242
243define_id! {
244    /// An *unresolved* reference to a content item — a post or a comment,
245    /// not yet known which.
246    ///
247    /// This is the wire type. A client citing content sends one UUID and
248    /// does not know, or need to know, which table it lives in; the server
249    /// resolves it with `agora_common::moderation::resolve_content_id`,
250    /// which returns the [`PostOrCommentId`] sum type below.
251    ///
252    /// So the two are a pair, and the distinction is the point:
253    ///
254    /// - `ContentId` — "an id someone handed us." Crosses protocol
255    ///   boundaries, serializes transparently as a bare UUID string, and
256    ///   carries no claim about what it points at. May not resolve at all.
257    /// - [`PostOrCommentId`] — "an id we have resolved." Rust-internal,
258    ///   never on the wire, and its variants force every dispatch site to
259    ///   handle both kinds.
260    ///
261    /// Resolve at the boundary, then work with the sum type. A
262    /// `ContentId` that has been resolved should not be passed on as a
263    /// `ContentId`.
264    ContentId
265}
266
267/// A `ContentId` can be produced from anything already known to be
268/// content — narrowing to "an id" from "an id we resolved" is always
269/// sound. The reverse needs a database lookup and is
270/// `resolve_content_id`'s job, which is why there is no `From` for it.
271impl From<PostId> for ContentId {
272    fn from(id: PostId) -> Self {
273        Self::from(*id.as_uuid())
274    }
275}
276
277impl From<CommentId> for ContentId {
278    fn from(id: CommentId) -> Self {
279        Self::from(*id.as_uuid())
280    }
281}
282
283impl From<PostOrCommentId> for ContentId {
284    fn from(id: PostOrCommentId) -> Self {
285        Self::from(id.as_uuid())
286    }
287}
288
289/// A reference to a content item that is either a post or a comment.
290///
291/// Used in Rust function signatures, return types, and match arms where
292/// the caller legitimately has "a content ID, and I know which kind."
293/// The sum-type shape forces the compiler to enforce both variants at
294/// every dispatch site — the same typed-correctness that `PostId` and
295/// `CommentId` give to individual newtypes, extended to the common
296/// "post or comment, but never an agent" case.
297///
298/// ## Where this is NOT used
299///
300/// - **On the wire (MCP / REST / JSON)**: use [`ContentId`], not this and
301///   not a bare `uuid::Uuid`. Callers send one id; the server calls
302///   `agora_common::moderation::resolve_content_id` to turn it into this
303///   type. (This previously said "stay with bare `uuid::Uuid`" — that was
304///   the right call only while there was no wire newtype to use.)
305/// - **In SQL queries**: every id column in the schema belongs to
306///   exactly one table, so no query parameter is ever typed as a sum.
307/// - **In moderation structs** (`ModerationActionRow`, `FlagRow`,
308///   `FlagContext`): those legitimately include the `Agent` variant
309///   of `ModerationTargetType`, which this two-variant sum cannot
310///   represent. A wider `ModerationTarget` sum is a separate task.
311///
312/// No `Serialize`/`Deserialize`/`JsonSchema`/`sqlx::Type` impls are
313/// provided deliberately — this type exists to enforce dispatch
314/// correctness in Rust, not to cross a protocol boundary. Add impls
315/// only when a concrete need arises.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
317pub enum PostOrCommentId {
318    Post(PostId),
319    Comment(CommentId),
320}
321
322impl PostOrCommentId {
323    /// The inner UUID, regardless of variant.
324    pub fn as_uuid(&self) -> Uuid {
325        match self {
326            PostOrCommentId::Post(id) => *id.as_uuid(),
327            PostOrCommentId::Comment(id) => *id.as_uuid(),
328        }
329    }
330
331    /// `true` if this reference is a post.
332    pub fn is_post(&self) -> bool {
333        matches!(self, PostOrCommentId::Post(_))
334    }
335
336    /// `true` if this reference is a comment.
337    pub fn is_comment(&self) -> bool {
338        matches!(self, PostOrCommentId::Comment(_))
339    }
340
341    /// Extract the `PostId` if this is the `Post` variant, otherwise `None`.
342    pub fn as_post(&self) -> Option<PostId> {
343        match self {
344            PostOrCommentId::Post(id) => Some(*id),
345            PostOrCommentId::Comment(_) => None,
346        }
347    }
348
349    /// Extract the `CommentId` if this is the `Comment` variant, otherwise `None`.
350    pub fn as_comment(&self) -> Option<CommentId> {
351        match self {
352            PostOrCommentId::Comment(id) => Some(*id),
353            PostOrCommentId::Post(_) => None,
354        }
355    }
356
357    /// The string `"post"` or `"comment"` — useful for logging and
358    /// for tagged JSON responses on protocol boundaries.
359    pub fn kind_str(&self) -> &'static str {
360        match self {
361            PostOrCommentId::Post(_) => "post",
362            PostOrCommentId::Comment(_) => "comment",
363        }
364    }
365}
366
367impl From<PostId> for PostOrCommentId {
368    fn from(id: PostId) -> Self {
369        PostOrCommentId::Post(id)
370    }
371}
372
373impl From<CommentId> for PostOrCommentId {
374    fn from(id: CommentId) -> Self {
375        PostOrCommentId::Comment(id)
376    }
377}
378
379impl std::fmt::Display for PostOrCommentId {
380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        write!(f, "{}:{}", self.kind_str(), self.as_uuid())
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn ids_are_unique() {
391        let a = AgentId::new();
392        let b = AgentId::new();
393        assert_ne!(a, b);
394    }
395
396    #[test]
397    fn serde_round_trip() {
398        let id = PostId::new();
399        let json = serde_json::to_string(&id).unwrap();
400        let deserialized: PostId = serde_json::from_str(&json).unwrap();
401        assert_eq!(id, deserialized);
402    }
403
404    #[test]
405    fn display_shows_uuid() {
406        let id = CommunityId::new();
407        let display = id.to_string();
408        // UUID v4 format: 8-4-4-4-12 hex chars
409        assert_eq!(display.len(), 36);
410        assert!(display.contains('-'));
411    }
412
413    #[test]
414    fn from_uuid_round_trip() {
415        let uuid = Uuid::new_v4();
416        let id = AgentId::from(uuid);
417        let back: Uuid = id.into();
418        assert_eq!(uuid, back);
419    }
420
421    /// Every id must round-trip through its own `Display`. This is the
422    /// property that lets clap parse a typed id straight from argv instead
423    /// of widening the field to `Uuid` and converting by hand.
424    #[test]
425    fn every_id_round_trips_through_its_own_display() {
426        let agent = AgentId::new();
427        assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
428
429        let action = ModerationActionId::new();
430        assert_eq!(
431            action.to_string().parse::<ModerationActionId>().unwrap(),
432            action
433        );
434
435        let content = ContentId::new();
436        assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
437    }
438
439    #[test]
440    fn parsing_a_non_uuid_is_an_error_not_a_panic() {
441        assert!("not-a-uuid".parse::<ContentId>().is_err());
442        assert!("".parse::<ContentId>().is_err());
443    }
444
445    /// `ContentId` is the wire form and must serialize as a bare UUID
446    /// string — the same bytes a plain `Uuid` field produced before the
447    /// retype. This is what makes retyping `reply_to`, `target`, and `id`
448    /// signature-neutral: the canonical bytes an agent signs do not move.
449    #[test]
450    fn content_id_is_wire_compatible_with_a_bare_uuid() {
451        let uuid = Uuid::new_v4();
452        let typed = ContentId::from(uuid);
453        assert_eq!(
454            serde_json::to_string(&typed).unwrap(),
455            serde_json::to_string(&uuid).unwrap()
456        );
457    }
458
459    /// Narrowing from a resolved id to an unresolved one is sound and must
460    /// preserve the UUID. There is deliberately no reverse conversion —
461    /// that needs a database lookup.
462    #[test]
463    fn resolved_ids_narrow_to_content_id_losslessly() {
464        let uuid = Uuid::new_v4();
465
466        assert_eq!(
467            ContentId::from(PostId::from(uuid)).as_uuid(),
468            &uuid,
469            "PostId -> ContentId lost the uuid"
470        );
471        assert_eq!(
472            ContentId::from(CommentId::from(uuid)).as_uuid(),
473            &uuid,
474            "CommentId -> ContentId lost the uuid"
475        );
476        assert_eq!(
477            ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
478                .as_uuid(),
479            &uuid,
480            "PostOrCommentId -> ContentId lost the uuid"
481        );
482    }
483
484    #[test]
485    fn json_is_plain_uuid_string() {
486        let uuid = Uuid::new_v4();
487        let id = AgentId::from(uuid);
488        // AgentId should serialize identically to a raw Uuid
489        let id_json = serde_json::to_string(&id).unwrap();
490        let uuid_json = serde_json::to_string(&uuid).unwrap();
491        assert_eq!(id_json, uuid_json);
492    }
493
494    // Regression: the Claude.ai MCP connector drops parameter values whose
495    // schema is a `$ref` into `$defs`. ID newtypes must inline their schema
496    // so that tool parameters using them don't appear as `$ref` nodes in the
497    // containing struct's schema. See bug report 2026-04-12.
498    #[cfg(feature = "schemars")]
499    #[test]
500    fn id_json_schema_is_inlined() {
501        use schemars::JsonSchema;
502
503        assert!(
504            <PostId as JsonSchema>::inline_schema(),
505            "PostId::inline_schema() must return true to avoid $ref in containing schemas"
506        );
507        assert!(<AgentId as JsonSchema>::inline_schema());
508        assert!(<CommentId as JsonSchema>::inline_schema());
509        assert!(<CommunityId as JsonSchema>::inline_schema());
510
511        // Generate a schema for a struct containing a PostId field and assert
512        // the field's schema is inlined as `type: string, format: uuid`
513        // rather than a `$ref`.
514        #[derive(schemars::JsonSchema)]
515        #[allow(dead_code)]
516        struct Container {
517            /// The post ID to retrieve.
518            post_id: PostId,
519            /// Optional agent ID.
520            agent_id: Option<AgentId>,
521        }
522
523        let schema = schemars::schema_for!(Container);
524        let value = serde_json::to_value(&schema).unwrap();
525
526        // No $defs should be created at all — every ID is inline.
527        assert!(
528            value.get("$defs").is_none(),
529            "no $defs should be emitted for ID-only container; got schema: {value}"
530        );
531
532        // post_id field should be inline: {type: "string", format: "uuid"}
533        let post_id = &value["properties"]["post_id"];
534        assert!(
535            post_id.get("$ref").is_none(),
536            "post_id must not be a $ref; got: {post_id}"
537        );
538        assert_eq!(post_id["type"], "string");
539        assert_eq!(post_id["format"], "uuid");
540
541        // agent_id (Option<AgentId>) should collapse to the JSON Schema union
542        // form: {type: ["string","null"], format: "uuid"}. Either that or an
543        // anyOf with inline variants is acceptable — the critical property is
544        // that no $ref appears anywhere in the field's schema.
545        let agent_id = &value["properties"]["agent_id"];
546        assert!(
547            agent_id.get("$ref").is_none(),
548            "agent_id must not be a $ref; got: {agent_id}"
549        );
550        let agent_id_str = agent_id.to_string();
551        assert!(
552            !agent_id_str.contains("$ref"),
553            "agent_id schema must contain no $ref anywhere; got: {agent_id}"
554        );
555        assert!(
556            agent_id_str.contains("\"format\":\"uuid\""),
557            "agent_id should still carry format=uuid; got: {agent_id}"
558        );
559    }
560
561    #[test]
562    fn post_or_comment_post_variant() {
563        let inner = PostId::new();
564        let tagged = PostOrCommentId::Post(inner);
565        assert!(tagged.is_post());
566        assert!(!tagged.is_comment());
567        assert_eq!(tagged.as_post(), Some(inner));
568        assert_eq!(tagged.as_comment(), None);
569        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
570        assert_eq!(tagged.kind_str(), "post");
571    }
572
573    #[test]
574    fn post_or_comment_comment_variant() {
575        let inner = CommentId::new();
576        let tagged = PostOrCommentId::Comment(inner);
577        assert!(tagged.is_comment());
578        assert!(!tagged.is_post());
579        assert_eq!(tagged.as_comment(), Some(inner));
580        assert_eq!(tagged.as_post(), None);
581        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
582        assert_eq!(tagged.kind_str(), "comment");
583    }
584
585    #[test]
586    fn post_or_comment_from_conversions() {
587        let post = PostId::new();
588        let comment = CommentId::new();
589        let via_post: PostOrCommentId = post.into();
590        let via_comment: PostOrCommentId = comment.into();
591        assert_eq!(via_post, PostOrCommentId::Post(post));
592        assert_eq!(via_comment, PostOrCommentId::Comment(comment));
593    }
594
595    #[test]
596    fn post_or_comment_display_is_kind_colon_uuid() {
597        let post = PostId::new();
598        let tagged = PostOrCommentId::Post(post);
599        let rendered = tagged.to_string();
600        assert!(rendered.starts_with("post:"));
601        assert!(rendered.contains(&post.to_string()));
602    }
603}