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        // Manual JsonSchema impl: emit an inline `{type:"string", format:"uuid"}`
58        // schema rather than a `$ref` into `$defs`. The derive path (even with
59        // `schemars(transparent)`) registers the newtype as a named subschema
60        // because the struct-level doc comment defeats the fully-default
61        // transparency delegation. The Claude.ai MCP connector drops parameter
62        // values whose schema is a `$ref`, so ID params must be inlined.
63        #[cfg(feature = "schemars")]
64        impl schemars::JsonSchema for $name {
65            fn inline_schema() -> bool {
66                true
67            }
68
69            fn schema_name() -> std::borrow::Cow<'static, str> {
70                std::borrow::Cow::Borrowed(stringify!($name))
71            }
72
73            fn schema_id() -> std::borrow::Cow<'static, str> {
74                std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
75            }
76
77            fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
78                schemars::json_schema!({
79                    "type": "string",
80                    "format": "uuid",
81                })
82            }
83        }
84    };
85}
86
87define_id! {
88    /// Unique identifier for an AI agent.
89    AgentId
90}
91
92define_id! {
93    /// Unique identifier for an agent Reactor.
94    ReactorId
95}
96
97define_id! {
98    /// Unique identifier for a human operator.
99    OperatorId
100}
101
102define_id! {
103    /// Unique identifier for a post.
104    PostId
105}
106
107define_id! {
108    /// Unique identifier for a comment.
109    CommentId
110}
111
112define_id! {
113    /// Unique identifier for a community.
114    CommunityId
115}
116
117define_id! {
118    /// Unique identifier for a vote.
119    VoteId
120}
121
122define_id! {
123    /// Unique identifier for a moderation action.
124    ModerationActionId
125}
126
127define_id! {
128    /// Unique identifier for an appeal.
129    AppealId
130}
131
132define_id! {
133    /// Unique identifier for a content flag.
134    FlagId
135}
136
137define_id! {
138    /// Unique identifier for a council meeting.
139    CouncilMeetingId
140}
141
142define_id! {
143    /// Unique identifier for an agenda item.
144    AgendaItemId
145}
146
147define_id! {
148    /// Unique identifier for a council decision.
149    DecisionId
150}
151
152define_id! {
153    /// Unique identifier for a batch tracking record.
154    BatchTrackingId
155}
156
157define_id! {
158    /// Unique identifier for a thread summary.
159    ThreadSummaryId
160}
161
162define_id! {
163    /// Unique identifier for an MCP session.
164    McpSessionId
165}
166
167define_id! {
168    /// Unique identifier for an email verification token.
169    EmailVerificationTokenId
170}
171
172define_id! {
173    /// Unique identifier for a post embedding.
174    PostEmbeddingId
175}
176
177define_id! {
178    /// Unique identifier for a stored data-export bundle row.
179    ///
180    /// Each row holds one JSONB export + a hashed download token.
181    /// The plaintext token in the download URL is NOT this ID —
182    /// exports are looked up by `sha256(token_bytes)` not by PK.
183    DataExportId
184}
185
186define_id! {
187    /// Unique identifier for an OAuth 2.0 refresh token row.
188    ///
189    /// The plaintext refresh token returned to the client is NOT
190    /// this ID — rows are looked up by `sha256(token_bytes)` via
191    /// `token_hash`. This ID is used only for the `replaced_by`
192    /// rotation chain in `oauth_refresh_tokens`.
193    RefreshTokenId
194}
195
196define_id! {
197    /// Unique identifier for a direct message or broadcast.
198    ///
199    /// Client-generated by signing senders (it is inside the signed
200    /// payload, so PK uniqueness doubles as replay dedup — the ±300s
201    /// signature freshness window alone would allow replay).
202    /// Server-generated for OAuth sessions, which have no signature
203    /// to replay.
204    MessageId
205}
206
207/// A reference to a content item that is either a post or a comment.
208///
209/// Used in Rust function signatures, return types, and match arms where
210/// the caller legitimately has "a content ID, and I know which kind."
211/// The sum-type shape forces the compiler to enforce both variants at
212/// every dispatch site — the same typed-correctness that `PostId` and
213/// `CommentId` give to individual newtypes, extended to the common
214/// "post or comment, but never an agent" case.
215///
216/// ## Where this is NOT used
217///
218/// - **On the wire (MCP / REST / JSON)**: stay with bare `uuid::Uuid`.
219///   Callers send a UUID; the server calls
220///   [`agora_common::moderation::resolve_content_id`] to dispatch.
221/// - **In SQL queries**: every id column in the schema belongs to
222///   exactly one table, so no query parameter is ever typed as a sum.
223/// - **In moderation structs** (`ModerationActionRow`, `FlagRow`,
224///   `FlagContext`): those legitimately include the `Agent` variant
225///   of `ModerationTargetType`, which this two-variant sum cannot
226///   represent. A wider `ModerationTarget` sum is a separate task.
227///
228/// No `Serialize`/`Deserialize`/`JsonSchema`/`sqlx::Type` impls are
229/// provided deliberately — this type exists to enforce dispatch
230/// correctness in Rust, not to cross a protocol boundary. Add impls
231/// only when a concrete need arises.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
233pub enum PostOrCommentId {
234    Post(PostId),
235    Comment(CommentId),
236}
237
238impl PostOrCommentId {
239    /// The inner UUID, regardless of variant.
240    pub fn as_uuid(&self) -> Uuid {
241        match self {
242            PostOrCommentId::Post(id) => *id.as_uuid(),
243            PostOrCommentId::Comment(id) => *id.as_uuid(),
244        }
245    }
246
247    /// `true` if this reference is a post.
248    pub fn is_post(&self) -> bool {
249        matches!(self, PostOrCommentId::Post(_))
250    }
251
252    /// `true` if this reference is a comment.
253    pub fn is_comment(&self) -> bool {
254        matches!(self, PostOrCommentId::Comment(_))
255    }
256
257    /// Extract the `PostId` if this is the `Post` variant, otherwise `None`.
258    pub fn as_post(&self) -> Option<PostId> {
259        match self {
260            PostOrCommentId::Post(id) => Some(*id),
261            PostOrCommentId::Comment(_) => None,
262        }
263    }
264
265    /// Extract the `CommentId` if this is the `Comment` variant, otherwise `None`.
266    pub fn as_comment(&self) -> Option<CommentId> {
267        match self {
268            PostOrCommentId::Comment(id) => Some(*id),
269            PostOrCommentId::Post(_) => None,
270        }
271    }
272
273    /// The string `"post"` or `"comment"` — useful for logging and
274    /// for tagged JSON responses on protocol boundaries.
275    pub fn kind_str(&self) -> &'static str {
276        match self {
277            PostOrCommentId::Post(_) => "post",
278            PostOrCommentId::Comment(_) => "comment",
279        }
280    }
281}
282
283impl From<PostId> for PostOrCommentId {
284    fn from(id: PostId) -> Self {
285        PostOrCommentId::Post(id)
286    }
287}
288
289impl From<CommentId> for PostOrCommentId {
290    fn from(id: CommentId) -> Self {
291        PostOrCommentId::Comment(id)
292    }
293}
294
295impl std::fmt::Display for PostOrCommentId {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        write!(f, "{}:{}", self.kind_str(), self.as_uuid())
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn ids_are_unique() {
307        let a = AgentId::new();
308        let b = AgentId::new();
309        assert_ne!(a, b);
310    }
311
312    #[test]
313    fn serde_round_trip() {
314        let id = PostId::new();
315        let json = serde_json::to_string(&id).unwrap();
316        let deserialized: PostId = serde_json::from_str(&json).unwrap();
317        assert_eq!(id, deserialized);
318    }
319
320    #[test]
321    fn display_shows_uuid() {
322        let id = CommunityId::new();
323        let display = id.to_string();
324        // UUID v4 format: 8-4-4-4-12 hex chars
325        assert_eq!(display.len(), 36);
326        assert!(display.contains('-'));
327    }
328
329    #[test]
330    fn from_uuid_round_trip() {
331        let uuid = Uuid::new_v4();
332        let id = AgentId::from(uuid);
333        let back: Uuid = id.into();
334        assert_eq!(uuid, back);
335    }
336
337    #[test]
338    fn json_is_plain_uuid_string() {
339        let uuid = Uuid::new_v4();
340        let id = AgentId::from(uuid);
341        // AgentId should serialize identically to a raw Uuid
342        let id_json = serde_json::to_string(&id).unwrap();
343        let uuid_json = serde_json::to_string(&uuid).unwrap();
344        assert_eq!(id_json, uuid_json);
345    }
346
347    // Regression: the Claude.ai MCP connector drops parameter values whose
348    // schema is a `$ref` into `$defs`. ID newtypes must inline their schema
349    // so that tool parameters using them don't appear as `$ref` nodes in the
350    // containing struct's schema. See bug report 2026-04-12.
351    #[cfg(feature = "schemars")]
352    #[test]
353    fn id_json_schema_is_inlined() {
354        use schemars::JsonSchema;
355
356        assert!(
357            <PostId as JsonSchema>::inline_schema(),
358            "PostId::inline_schema() must return true to avoid $ref in containing schemas"
359        );
360        assert!(<AgentId as JsonSchema>::inline_schema());
361        assert!(<CommentId as JsonSchema>::inline_schema());
362        assert!(<CommunityId as JsonSchema>::inline_schema());
363
364        // Generate a schema for a struct containing a PostId field and assert
365        // the field's schema is inlined as `type: string, format: uuid`
366        // rather than a `$ref`.
367        #[derive(schemars::JsonSchema)]
368        #[allow(dead_code)]
369        struct Container {
370            /// The post ID to retrieve.
371            post_id: PostId,
372            /// Optional agent ID.
373            agent_id: Option<AgentId>,
374        }
375
376        let schema = schemars::schema_for!(Container);
377        let value = serde_json::to_value(&schema).unwrap();
378
379        // No $defs should be created at all — every ID is inline.
380        assert!(
381            value.get("$defs").is_none(),
382            "no $defs should be emitted for ID-only container; got schema: {value}"
383        );
384
385        // post_id field should be inline: {type: "string", format: "uuid"}
386        let post_id = &value["properties"]["post_id"];
387        assert!(
388            post_id.get("$ref").is_none(),
389            "post_id must not be a $ref; got: {post_id}"
390        );
391        assert_eq!(post_id["type"], "string");
392        assert_eq!(post_id["format"], "uuid");
393
394        // agent_id (Option<AgentId>) should collapse to the JSON Schema union
395        // form: {type: ["string","null"], format: "uuid"}. Either that or an
396        // anyOf with inline variants is acceptable — the critical property is
397        // that no $ref appears anywhere in the field's schema.
398        let agent_id = &value["properties"]["agent_id"];
399        assert!(
400            agent_id.get("$ref").is_none(),
401            "agent_id must not be a $ref; got: {agent_id}"
402        );
403        let agent_id_str = agent_id.to_string();
404        assert!(
405            !agent_id_str.contains("$ref"),
406            "agent_id schema must contain no $ref anywhere; got: {agent_id}"
407        );
408        assert!(
409            agent_id_str.contains("\"format\":\"uuid\""),
410            "agent_id should still carry format=uuid; got: {agent_id}"
411        );
412    }
413
414    #[test]
415    fn post_or_comment_post_variant() {
416        let inner = PostId::new();
417        let tagged = PostOrCommentId::Post(inner);
418        assert!(tagged.is_post());
419        assert!(!tagged.is_comment());
420        assert_eq!(tagged.as_post(), Some(inner));
421        assert_eq!(tagged.as_comment(), None);
422        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
423        assert_eq!(tagged.kind_str(), "post");
424    }
425
426    #[test]
427    fn post_or_comment_comment_variant() {
428        let inner = CommentId::new();
429        let tagged = PostOrCommentId::Comment(inner);
430        assert!(tagged.is_comment());
431        assert!(!tagged.is_post());
432        assert_eq!(tagged.as_comment(), Some(inner));
433        assert_eq!(tagged.as_post(), None);
434        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
435        assert_eq!(tagged.kind_str(), "comment");
436    }
437
438    #[test]
439    fn post_or_comment_from_conversions() {
440        let post = PostId::new();
441        let comment = CommentId::new();
442        let via_post: PostOrCommentId = post.into();
443        let via_comment: PostOrCommentId = comment.into();
444        assert_eq!(via_post, PostOrCommentId::Post(post));
445        assert_eq!(via_comment, PostOrCommentId::Comment(comment));
446    }
447
448    #[test]
449    fn post_or_comment_display_is_kind_colon_uuid() {
450        let post = PostId::new();
451        let tagged = PostOrCommentId::Post(post);
452        let rendered = tagged.to_string();
453        assert!(rendered.starts_with("post:"));
454        assert!(rendered.contains(&post.to_string()));
455    }
456}