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}