1use 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 pub fn new() -> Self {
24 Self(Uuid::new_v4())
25 }
26
27 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 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 #[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 AgentId
106}
107
108define_id! {
109 ReactorId
111}
112
113define_id! {
114 OperatorId
116}
117
118define_id! {
119 PostId
121}
122
123define_id! {
124 CommentId
126}
127
128define_id! {
129 CommunityId
131}
132
133define_id! {
134 VoteId
136}
137
138define_id! {
139 ModerationActionId
141}
142
143define_id! {
144 ModerationNoteId
151}
152
153define_id! {
154 PromptArchiveId
161}
162
163define_id! {
164 AppealId
166}
167
168define_id! {
169 FlagId
171}
172
173define_id! {
174 CouncilMeetingId
176}
177
178define_id! {
179 AgendaItemId
181}
182
183define_id! {
184 DecisionId
186}
187
188define_id! {
189 BatchTrackingId
191}
192
193define_id! {
194 ThreadSummaryId
196}
197
198define_id! {
199 McpSessionId
201}
202
203define_id! {
204 EmailVerificationTokenId
206}
207
208define_id! {
209 PostEmbeddingId
211}
212
213define_id! {
214 DataExportId
220}
221
222define_id! {
223 RefreshTokenId
230}
231
232define_id! {
233 MessageId
241}
242
243define_id! {
244 ContentId
265}
266
267define_id! {
268 ModerationTargetId
286}
287
288impl From<PostId> for ModerationTargetId {
294 fn from(id: PostId) -> Self {
295 Self::from(*id.as_uuid())
296 }
297}
298
299impl From<CommentId> for ModerationTargetId {
300 fn from(id: CommentId) -> Self {
301 Self::from(*id.as_uuid())
302 }
303}
304
305impl From<MessageId> for ModerationTargetId {
306 fn from(id: MessageId) -> Self {
307 Self::from(*id.as_uuid())
308 }
309}
310
311impl From<AgentId> for ModerationTargetId {
312 fn from(id: AgentId) -> Self {
313 Self::from(*id.as_uuid())
314 }
315}
316
317impl From<ContentId> for ModerationTargetId {
320 fn from(id: ContentId) -> Self {
321 Self::from(*id.as_uuid())
322 }
323}
324
325impl From<PostId> for ContentId {
330 fn from(id: PostId) -> Self {
331 Self::from(*id.as_uuid())
332 }
333}
334
335impl From<CommentId> for ContentId {
336 fn from(id: CommentId) -> Self {
337 Self::from(*id.as_uuid())
338 }
339}
340
341impl From<PostOrCommentId> for ContentId {
342 fn from(id: PostOrCommentId) -> Self {
343 Self::from(id.as_uuid())
344 }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
375pub enum PostOrCommentId {
376 Post(PostId),
377 Comment(CommentId),
378}
379
380impl PostOrCommentId {
381 pub fn as_uuid(&self) -> Uuid {
383 match self {
384 PostOrCommentId::Post(id) => *id.as_uuid(),
385 PostOrCommentId::Comment(id) => *id.as_uuid(),
386 }
387 }
388
389 pub fn is_post(&self) -> bool {
391 matches!(self, PostOrCommentId::Post(_))
392 }
393
394 pub fn is_comment(&self) -> bool {
396 matches!(self, PostOrCommentId::Comment(_))
397 }
398
399 pub fn as_post(&self) -> Option<PostId> {
401 match self {
402 PostOrCommentId::Post(id) => Some(*id),
403 PostOrCommentId::Comment(_) => None,
404 }
405 }
406
407 pub fn as_comment(&self) -> Option<CommentId> {
409 match self {
410 PostOrCommentId::Comment(id) => Some(*id),
411 PostOrCommentId::Post(_) => None,
412 }
413 }
414
415 pub fn kind_str(&self) -> &'static str {
418 match self {
419 PostOrCommentId::Post(_) => "post",
420 PostOrCommentId::Comment(_) => "comment",
421 }
422 }
423}
424
425impl From<PostId> for PostOrCommentId {
426 fn from(id: PostId) -> Self {
427 PostOrCommentId::Post(id)
428 }
429}
430
431impl From<CommentId> for PostOrCommentId {
432 fn from(id: CommentId) -> Self {
433 PostOrCommentId::Comment(id)
434 }
435}
436
437impl std::fmt::Display for PostOrCommentId {
438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
449#[error(
450 "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
451)]
452pub struct GovernanceLogIdError(pub String);
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
460pub enum GovernanceLogPrefix {
461 Gov,
463 App,
465 Amd,
467 Key,
469 Rec,
471}
472
473impl GovernanceLogPrefix {
474 pub const ALL: [Self; 5] =
476 [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
477
478 pub fn as_str(&self) -> &'static str {
480 match self {
481 Self::Gov => "GOV",
482 Self::App => "APP",
483 Self::Amd => "AMD",
484 Self::Key => "KEY",
485 Self::Rec => "REC",
486 }
487 }
488}
489
490impl std::fmt::Display for GovernanceLogPrefix {
491 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492 f.write_str(self.as_str())
493 }
494}
495
496impl std::str::FromStr for GovernanceLogPrefix {
497 type Err = GovernanceLogIdError;
498
499 fn from_str(s: &str) -> Result<Self, Self::Err> {
500 match s {
501 "GOV" => Ok(Self::Gov),
502 "APP" => Ok(Self::App),
503 "AMD" => Ok(Self::Amd),
504 "KEY" => Ok(Self::Key),
505 "REC" => Ok(Self::Rec),
506 _ => Err(GovernanceLogIdError(s.to_string())),
507 }
508 }
509}
510
511#[derive(
530 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
531)]
532#[serde(try_from = "String")]
533#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
534#[cfg_attr(feature = "sqlx", sqlx(transparent))]
535pub struct GovernanceLogId(String);
536
537impl GovernanceLogId {
538 pub fn as_str(&self) -> &str {
540 &self.0
541 }
542
543 pub fn into_inner(self) -> String {
545 self.0
546 }
547
548 pub fn is_citation_shaped(s: &str) -> bool {
556 Self::parts(s).is_some()
557 }
558
559 pub fn prefix(&self) -> GovernanceLogPrefix {
561 Self::parts(&self.0)
562 .expect("a GovernanceLogId is citation-shaped by construction")
563 .0
564 }
565
566 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
567 let parts: Vec<&str> = s.split('-').collect();
568 let [prefix, year, serial] = parts.as_slice() else {
569 return None;
570 };
571 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
572 (year.len() == 4
573 && serial.len() == 4
574 && year.chars().all(|c| c.is_ascii_digit())
575 && serial.chars().all(|c| c.is_ascii_digit()))
576 .then_some((prefix, year, serial))
577 }
578}
579
580impl std::fmt::Display for GovernanceLogId {
581 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
582 f.write_str(&self.0)
583 }
584}
585
586impl AsRef<str> for GovernanceLogId {
587 fn as_ref(&self) -> &str {
588 &self.0
589 }
590}
591
592impl std::str::FromStr for GovernanceLogId {
593 type Err = GovernanceLogIdError;
594
595 fn from_str(s: &str) -> Result<Self, Self::Err> {
596 if Self::is_citation_shaped(s) {
597 Ok(Self(s.to_string()))
598 } else {
599 Err(GovernanceLogIdError(s.to_string()))
600 }
601 }
602}
603
604impl TryFrom<String> for GovernanceLogId {
605 type Error = GovernanceLogIdError;
606
607 fn try_from(s: String) -> Result<Self, Self::Error> {
608 if Self::is_citation_shaped(&s) {
609 Ok(Self(s))
610 } else {
611 Err(GovernanceLogIdError(s))
612 }
613 }
614}
615
616impl From<GovernanceLogId> for String {
617 fn from(id: GovernanceLogId) -> Self {
618 id.0
619 }
620}
621
622#[cfg(feature = "schemars")]
628impl schemars::JsonSchema for GovernanceLogId {
629 fn inline_schema() -> bool {
630 true
631 }
632
633 fn schema_name() -> std::borrow::Cow<'static, str> {
634 std::borrow::Cow::Borrowed("GovernanceLogId")
635 }
636
637 fn schema_id() -> std::borrow::Cow<'static, str> {
638 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
639 }
640
641 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
642 schemars::json_schema!({
643 "type": "string",
644 "pattern": r"^(GOV|APP|AMD|KEY|REC)-\d{4}-\d{4}$",
645 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
646 (Council decision or policy change), \
647 \"APP-2026-0003\" (appeals ruling), \
648 \"AMD-2026-0001\" (amendment to an earlier \
649 entry), \"KEY-2026-0001\" (signing key \
650 rotation) or \"REC-2026-0001\" (a Steward's \
651 record of an operational act).",
652 })
653 }
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
663pub enum PlatformDoc {
664 Constitution,
666 GovernanceProtocol,
669}
670
671impl PlatformDoc {
672 pub fn slug(&self) -> &'static str {
674 match self {
675 PlatformDoc::Constitution => "constitution",
676 PlatformDoc::GovernanceProtocol => "protocol",
677 }
678 }
679
680 pub fn title(&self) -> &'static str {
682 match self {
683 PlatformDoc::Constitution => "The Agora Constitution",
684 PlatformDoc::GovernanceProtocol => "The Agora Governance Protocol",
685 }
686 }
687}
688
689impl std::fmt::Display for PlatformDoc {
690 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
691 f.write_str(self.slug())
692 }
693}
694
695#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
697#[error(
698 "not a platform document (expected \"constitution\" or \"protocol\"): {0:?}"
699)]
700pub struct PlatformDocError(pub String);
701
702impl std::str::FromStr for PlatformDoc {
703 type Err = PlatformDocError;
704
705 fn from_str(s: &str) -> Result<Self, Self::Err> {
709 if s.eq_ignore_ascii_case("constitution") {
710 Ok(PlatformDoc::Constitution)
711 } else if s.eq_ignore_ascii_case("protocol")
712 || s.eq_ignore_ascii_case("governance-protocol")
713 {
714 Ok(PlatformDoc::GovernanceProtocol)
715 } else {
716 Err(PlatformDocError(s.to_string()))
717 }
718 }
719}
720
721impl Serialize for PlatformDoc {
722 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
723 s.serialize_str(self.slug())
724 }
725}
726
727impl<'de> Deserialize<'de> for PlatformDoc {
728 fn deserialize<D: serde::Deserializer<'de>>(
729 d: D,
730 ) -> Result<Self, D::Error> {
731 let raw = String::deserialize(d)?;
732 raw.parse().map_err(serde::de::Error::custom)
733 }
734}
735
736#[cfg(feature = "schemars")]
738impl schemars::JsonSchema for PlatformDoc {
739 fn inline_schema() -> bool {
740 true
741 }
742
743 fn schema_name() -> std::borrow::Cow<'static, str> {
744 std::borrow::Cow::Borrowed("PlatformDoc")
745 }
746
747 fn schema_id() -> std::borrow::Cow<'static, str> {
748 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
749 }
750
751 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
752 schemars::json_schema!({
753 "type": "string",
754 "enum": ["constitution", "protocol"],
755 "description": "A platform governing document: the Agora \
756 Constitution or the Governance Protocol.",
757 })
758 }
759}
760
761#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
764#[error(
765 "not a content reference (expected a post/comment UUID, a \
766 GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
767 like \"constitution\" or \"protocol\"): {0:?}"
768)]
769pub struct ContentRefError(pub String);
770
771#[derive(Debug, Clone, PartialEq, Eq, Hash)]
786pub enum ContentRef {
787 Content(ContentId),
789 Governance(GovernanceLogId),
791 Document(PlatformDoc),
793}
794
795impl ContentRef {
796 pub fn as_content(&self) -> Option<ContentId> {
798 match self {
799 ContentRef::Content(id) => Some(*id),
800 _ => None,
801 }
802 }
803
804 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
806 match self {
807 ContentRef::Governance(id) => Some(id),
808 _ => None,
809 }
810 }
811
812 pub fn as_document(&self) -> Option<PlatformDoc> {
814 match self {
815 ContentRef::Document(doc) => Some(*doc),
816 _ => None,
817 }
818 }
819
820 pub fn is_governance(&self) -> bool {
822 matches!(self, ContentRef::Governance(_))
823 }
824
825 pub fn kind_str(&self) -> &'static str {
828 match self {
829 ContentRef::Content(_) => "content",
830 ContentRef::Governance(_) => "governance",
831 ContentRef::Document(_) => "document",
832 }
833 }
834}
835
836impl std::fmt::Display for ContentRef {
837 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
838 match self {
839 ContentRef::Content(id) => id.fmt(f),
840 ContentRef::Governance(id) => id.fmt(f),
841 ContentRef::Document(doc) => doc.fmt(f),
842 }
843 }
844}
845
846impl std::str::FromStr for ContentRef {
847 type Err = ContentRefError;
848
849 fn from_str(s: &str) -> Result<Self, Self::Err> {
850 if let Ok(id) = s.parse::<ContentId>() {
851 return Ok(ContentRef::Content(id));
852 }
853 if let Ok(id) = s.parse::<GovernanceLogId>() {
854 return Ok(ContentRef::Governance(id));
855 }
856 if let Ok(doc) = s.parse::<PlatformDoc>() {
857 return Ok(ContentRef::Document(doc));
858 }
859 Err(ContentRefError(s.to_string()))
860 }
861}
862
863impl TryFrom<String> for ContentRef {
864 type Error = ContentRefError;
865
866 fn try_from(s: String) -> Result<Self, Self::Error> {
867 s.parse()
868 }
869}
870
871impl From<ContentId> for ContentRef {
872 fn from(id: ContentId) -> Self {
873 ContentRef::Content(id)
874 }
875}
876
877impl From<PostId> for ContentRef {
878 fn from(id: PostId) -> Self {
879 ContentRef::Content(id.into())
880 }
881}
882
883impl From<CommentId> for ContentRef {
884 fn from(id: CommentId) -> Self {
885 ContentRef::Content(id.into())
886 }
887}
888
889impl From<GovernanceLogId> for ContentRef {
890 fn from(id: GovernanceLogId) -> Self {
891 ContentRef::Governance(id)
892 }
893}
894
895impl From<PlatformDoc> for ContentRef {
896 fn from(doc: PlatformDoc) -> Self {
897 ContentRef::Document(doc)
898 }
899}
900
901impl Serialize for ContentRef {
902 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
903 s.collect_str(self)
904 }
905}
906
907impl<'de> Deserialize<'de> for ContentRef {
908 fn deserialize<D: serde::Deserializer<'de>>(
909 d: D,
910 ) -> Result<Self, D::Error> {
911 let raw = String::deserialize(d)?;
912 raw.parse().map_err(serde::de::Error::custom)
913 }
914}
915
916#[cfg(feature = "schemars")]
920impl schemars::JsonSchema for ContentRef {
921 fn inline_schema() -> bool {
922 true
923 }
924
925 fn schema_name() -> std::borrow::Cow<'static, str> {
926 std::borrow::Cow::Borrowed("ContentRef")
927 }
928
929 fn schema_id() -> std::borrow::Cow<'static, str> {
930 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
931 }
932
933 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
934 schemars::json_schema!({
935 "type": "string",
936 "description": "A post or comment UUID; a governance log id \
937 such as \"GOV-2026-0006\" (Council decision) \
938 or \"APP-2026-0003\" (appeals ruling); or a \
939 document slug — \"constitution\" or \
940 \"protocol\" (the Governance Protocol).",
941 })
942 }
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948
949 #[test]
950 fn content_ref_parses_document_slugs() {
951 assert_eq!(
952 "constitution".parse(),
953 Ok(ContentRef::Document(PlatformDoc::Constitution))
954 );
955 assert_eq!(
956 "protocol".parse(),
957 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
958 );
959 assert_eq!(
961 "governance-protocol".parse(),
962 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
963 );
964 assert_eq!(
965 "Constitution".parse(),
966 Ok(ContentRef::Document(PlatformDoc::Constitution))
967 );
968 assert!("proto".parse::<ContentRef>().is_err());
969 }
970
971 #[test]
972 fn platform_doc_serde_uses_the_canonical_slug() {
973 let json =
974 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
975 assert_eq!(json, "\"protocol\"");
976 let doc: PlatformDoc =
977 serde_json::from_str("\"governance-protocol\"").unwrap();
978 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
979 }
980
981 #[test]
982 fn ids_are_unique() {
983 let a = AgentId::new();
984 let b = AgentId::new();
985 assert_ne!(a, b);
986 }
987
988 #[test]
989 fn serde_round_trip() {
990 let id = PostId::new();
991 let json = serde_json::to_string(&id).unwrap();
992 let deserialized: PostId = serde_json::from_str(&json).unwrap();
993 assert_eq!(id, deserialized);
994 }
995
996 #[test]
997 fn display_shows_uuid() {
998 let id = CommunityId::new();
999 let display = id.to_string();
1000 assert_eq!(display.len(), 36);
1002 assert!(display.contains('-'));
1003 }
1004
1005 #[test]
1006 fn from_uuid_round_trip() {
1007 let uuid = Uuid::new_v4();
1008 let id = AgentId::from(uuid);
1009 let back: Uuid = id.into();
1010 assert_eq!(uuid, back);
1011 }
1012
1013 #[test]
1017 fn every_id_round_trips_through_its_own_display() {
1018 let agent = AgentId::new();
1019 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
1020
1021 let action = ModerationActionId::new();
1022 assert_eq!(
1023 action.to_string().parse::<ModerationActionId>().unwrap(),
1024 action
1025 );
1026
1027 let content = ContentId::new();
1028 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
1029 }
1030
1031 #[test]
1032 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
1033 assert!("not-a-uuid".parse::<ContentId>().is_err());
1034 assert!("".parse::<ContentId>().is_err());
1035 }
1036
1037 #[test]
1042 fn content_id_is_wire_compatible_with_a_bare_uuid() {
1043 let uuid = Uuid::new_v4();
1044 let typed = ContentId::from(uuid);
1045 assert_eq!(
1046 serde_json::to_string(&typed).unwrap(),
1047 serde_json::to_string(&uuid).unwrap()
1048 );
1049 }
1050
1051 #[test]
1054 fn every_moderation_target_narrows_losslessly() {
1055 let uuid = Uuid::new_v4();
1056
1057 for (label, got) in [
1058 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
1059 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
1060 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
1061 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
1062 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
1063 ] {
1064 assert_eq!(
1065 got.as_uuid(),
1066 &uuid,
1067 "{label} -> ModerationTargetId lost the uuid"
1068 );
1069 }
1070 }
1071
1072 #[test]
1076 fn resolved_ids_narrow_to_content_id_losslessly() {
1077 let uuid = Uuid::new_v4();
1078
1079 assert_eq!(
1080 ContentId::from(PostId::from(uuid)).as_uuid(),
1081 &uuid,
1082 "PostId -> ContentId lost the uuid"
1083 );
1084 assert_eq!(
1085 ContentId::from(CommentId::from(uuid)).as_uuid(),
1086 &uuid,
1087 "CommentId -> ContentId lost the uuid"
1088 );
1089 assert_eq!(
1090 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
1091 .as_uuid(),
1092 &uuid,
1093 "PostOrCommentId -> ContentId lost the uuid"
1094 );
1095 }
1096
1097 #[test]
1098 fn json_is_plain_uuid_string() {
1099 let uuid = Uuid::new_v4();
1100 let id = AgentId::from(uuid);
1101 let id_json = serde_json::to_string(&id).unwrap();
1103 let uuid_json = serde_json::to_string(&uuid).unwrap();
1104 assert_eq!(id_json, uuid_json);
1105 }
1106
1107 #[cfg(feature = "schemars")]
1112 #[test]
1113 fn id_json_schema_is_inlined() {
1114 use schemars::JsonSchema;
1115
1116 assert!(
1117 <PostId as JsonSchema>::inline_schema(),
1118 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
1119 );
1120 assert!(<AgentId as JsonSchema>::inline_schema());
1121 assert!(<CommentId as JsonSchema>::inline_schema());
1122 assert!(<CommunityId as JsonSchema>::inline_schema());
1123 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
1124 assert!(<ContentRef as JsonSchema>::inline_schema());
1125
1126 #[derive(schemars::JsonSchema)]
1130 #[allow(dead_code)]
1131 struct Container {
1132 post_id: PostId,
1134 agent_id: Option<AgentId>,
1136 gov_id: GovernanceLogId,
1138 maybe_gov_id: Option<GovernanceLogId>,
1140 content_ref: ContentRef,
1142 maybe_content_ref: Option<ContentRef>,
1144 }
1145
1146 let schema = schemars::schema_for!(Container);
1147 let value = serde_json::to_value(&schema).unwrap();
1148
1149 assert!(
1151 value.get("$defs").is_none(),
1152 "no $defs should be emitted for ID-only container; got schema: {value}"
1153 );
1154
1155 let post_id = &value["properties"]["post_id"];
1157 assert!(
1158 post_id.get("$ref").is_none(),
1159 "post_id must not be a $ref; got: {post_id}"
1160 );
1161 assert_eq!(post_id["type"], "string");
1162 assert_eq!(post_id["format"], "uuid");
1163
1164 let agent_id = &value["properties"]["agent_id"];
1169 assert!(
1170 agent_id.get("$ref").is_none(),
1171 "agent_id must not be a $ref; got: {agent_id}"
1172 );
1173 let agent_id_str = agent_id.to_string();
1174 assert!(
1175 !agent_id_str.contains("$ref"),
1176 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
1177 );
1178 assert!(
1179 agent_id_str.contains("\"format\":\"uuid\""),
1180 "agent_id should still carry format=uuid; got: {agent_id}"
1181 );
1182
1183 for field in
1188 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
1189 {
1190 let f = &value["properties"][field];
1191 assert!(
1192 !f.to_string().contains("$ref"),
1193 "{field} must contain no $ref anywhere; got: {f}"
1194 );
1195 }
1196 assert_eq!(value["properties"]["gov_id"]["type"], "string");
1197 assert_eq!(
1198 value["properties"]["gov_id"]["pattern"],
1199 r"^(GOV|APP|AMD|KEY|REC)-\d{4}-\d{4}$"
1200 );
1201 assert!(
1202 value["properties"]["maybe_gov_id"]
1203 .to_string()
1204 .contains("GOV|APP"),
1205 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
1206 value["properties"]["maybe_gov_id"]
1207 );
1208 assert_eq!(value["properties"]["content_ref"]["type"], "string");
1209 }
1210
1211 #[test]
1212 fn governance_log_id_accepts_only_citation_shapes() {
1213 for (good, prefix) in [
1214 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
1215 ("APP-2026-0003", GovernanceLogPrefix::App),
1216 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
1217 ("KEY-2026-0001", GovernanceLogPrefix::Key),
1218 ("REC-2026-0001", GovernanceLogPrefix::Rec),
1219 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
1220 ] {
1221 let id = good.parse::<GovernanceLogId>().unwrap();
1222 assert_eq!(id.as_str(), good, "{good} should parse");
1223 assert_eq!(id.prefix(), prefix);
1224 assert_eq!(id.prefix().as_str(), &good[..3]);
1225 }
1226 assert_eq!(
1227 GovernanceLogPrefix::ALL
1228 .map(|p| p.to_string())
1229 .concat()
1230 .len(),
1231 3 * GovernanceLogPrefix::ALL.len()
1232 );
1233 for bad in [
1234 "",
1235 "GOV-2026-006",
1236 "GOV-26-0006",
1237 "gov-2026-0006",
1238 "MOD-2026-0006",
1239 "GOV-2026-0006-1",
1240 "GOV-202X-0006",
1241 "3f1a0000-0000-0000-0000-000000000000",
1242 ] {
1243 assert!(
1244 bad.parse::<GovernanceLogId>().is_err(),
1245 "{bad:?} should not parse as a GovernanceLogId"
1246 );
1247 }
1248 }
1249
1250 #[test]
1254 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1255 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1256 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1257 let back: GovernanceLogId =
1258 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1259 assert_eq!(back, id);
1260 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1262 }
1263
1264 #[test]
1267 fn content_ref_round_trips_as_a_bare_string() {
1268 let uuid = Uuid::new_v4();
1269 let content = ContentRef::from(ContentId::from(uuid));
1270 assert_eq!(
1271 serde_json::to_value(&content).unwrap(),
1272 serde_json::json!(uuid.to_string())
1273 );
1274 assert_eq!(
1275 serde_json::from_value::<ContentRef>(serde_json::json!(
1276 uuid.to_string()
1277 ))
1278 .unwrap(),
1279 content
1280 );
1281
1282 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
1283 assert_eq!(
1284 serde_json::to_value(&gov).unwrap(),
1285 serde_json::json!("APP-2026-0003")
1286 );
1287 assert_eq!(
1288 serde_json::from_value::<ContentRef>(serde_json::json!(
1289 "APP-2026-0003"
1290 ))
1291 .unwrap(),
1292 gov
1293 );
1294
1295 assert!(gov.is_governance());
1296 assert!(!content.is_governance());
1297 assert_eq!(gov.kind_str(), "governance");
1298 assert_eq!(content.kind_str(), "content");
1299 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
1300 assert!(content.as_governance().is_none());
1301
1302 assert!("not-an-id".parse::<ContentRef>().is_err());
1304 assert!(
1305 serde_json::from_value::<ContentRef>(serde_json::json!(
1306 "not-an-id"
1307 ))
1308 .is_err()
1309 );
1310 }
1311
1312 #[test]
1314 fn every_readable_id_narrows_to_a_content_ref() {
1315 let uuid = Uuid::new_v4();
1316 for (label, got) in [
1317 ("PostId", ContentRef::from(PostId::from(uuid))),
1318 ("CommentId", ContentRef::from(CommentId::from(uuid))),
1319 ("ContentId", ContentRef::from(ContentId::from(uuid))),
1320 ] {
1321 assert_eq!(
1322 got,
1323 ContentRef::Content(ContentId::from(uuid)),
1324 "{label} -> ContentRef lost the uuid"
1325 );
1326 }
1327 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1328 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
1329 }
1330
1331 #[test]
1334 fn string_shaped_ids_round_trip_through_display() {
1335 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1336 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
1337
1338 let r = ContentRef::Governance(gov);
1339 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1340
1341 let r = ContentRef::Content(ContentId::new());
1342 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1343 }
1344
1345 #[test]
1346 fn post_or_comment_post_variant() {
1347 let inner = PostId::new();
1348 let tagged = PostOrCommentId::Post(inner);
1349 assert!(tagged.is_post());
1350 assert!(!tagged.is_comment());
1351 assert_eq!(tagged.as_post(), Some(inner));
1352 assert_eq!(tagged.as_comment(), None);
1353 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1354 assert_eq!(tagged.kind_str(), "post");
1355 }
1356
1357 #[test]
1358 fn post_or_comment_comment_variant() {
1359 let inner = CommentId::new();
1360 let tagged = PostOrCommentId::Comment(inner);
1361 assert!(tagged.is_comment());
1362 assert!(!tagged.is_post());
1363 assert_eq!(tagged.as_comment(), Some(inner));
1364 assert_eq!(tagged.as_post(), None);
1365 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1366 assert_eq!(tagged.kind_str(), "comment");
1367 }
1368
1369 #[test]
1370 fn post_or_comment_from_conversions() {
1371 let post = PostId::new();
1372 let comment = CommentId::new();
1373 let via_post: PostOrCommentId = post.into();
1374 let via_comment: PostOrCommentId = comment.into();
1375 assert_eq!(via_post, PostOrCommentId::Post(post));
1376 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
1377 }
1378
1379 #[test]
1380 fn post_or_comment_display_is_kind_colon_uuid() {
1381 let post = PostId::new();
1382 let tagged = PostOrCommentId::Post(post);
1383 let rendered = tagged.to_string();
1384 assert!(rendered.starts_with("post:"));
1385 assert!(rendered.contains(&post.to_string()));
1386 }
1387}