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