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-YYYY-NNNN or APP-YYYY-NNNN): {0:?}"
451)]
452pub struct GovernanceLogIdError(pub String);
453
454#[derive(
471 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
472)]
473#[serde(try_from = "String")]
474#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
475#[cfg_attr(feature = "sqlx", sqlx(transparent))]
476pub struct GovernanceLogId(String);
477
478impl GovernanceLogId {
479 pub fn as_str(&self) -> &str {
481 &self.0
482 }
483
484 pub fn into_inner(self) -> String {
486 self.0
487 }
488
489 pub fn is_citation_shaped(s: &str) -> bool {
496 let parts: Vec<&str> = s.split('-').collect();
497 let [prefix, year, serial] = parts.as_slice() else {
498 return false;
499 };
500 matches!(*prefix, "GOV" | "APP")
501 && year.len() == 4
502 && serial.len() == 4
503 && year.chars().all(|c| c.is_ascii_digit())
504 && serial.chars().all(|c| c.is_ascii_digit())
505 }
506}
507
508impl std::fmt::Display for GovernanceLogId {
509 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510 f.write_str(&self.0)
511 }
512}
513
514impl AsRef<str> for GovernanceLogId {
515 fn as_ref(&self) -> &str {
516 &self.0
517 }
518}
519
520impl std::str::FromStr for GovernanceLogId {
521 type Err = GovernanceLogIdError;
522
523 fn from_str(s: &str) -> Result<Self, Self::Err> {
524 if Self::is_citation_shaped(s) {
525 Ok(Self(s.to_string()))
526 } else {
527 Err(GovernanceLogIdError(s.to_string()))
528 }
529 }
530}
531
532impl TryFrom<String> for GovernanceLogId {
533 type Error = GovernanceLogIdError;
534
535 fn try_from(s: String) -> Result<Self, Self::Error> {
536 if Self::is_citation_shaped(&s) {
537 Ok(Self(s))
538 } else {
539 Err(GovernanceLogIdError(s))
540 }
541 }
542}
543
544impl From<GovernanceLogId> for String {
545 fn from(id: GovernanceLogId) -> Self {
546 id.0
547 }
548}
549
550#[cfg(feature = "schemars")]
556impl schemars::JsonSchema for GovernanceLogId {
557 fn inline_schema() -> bool {
558 true
559 }
560
561 fn schema_name() -> std::borrow::Cow<'static, str> {
562 std::borrow::Cow::Borrowed("GovernanceLogId")
563 }
564
565 fn schema_id() -> std::borrow::Cow<'static, str> {
566 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
567 }
568
569 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
570 schemars::json_schema!({
571 "type": "string",
572 "pattern": r"^(GOV|APP)-\d{4}-\d{4}$",
573 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
574 (Council decision or policy change) or \
575 \"APP-2026-0003\" (appeals ruling).",
576 })
577 }
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
587pub enum PlatformDoc {
588 Constitution,
590 GovernanceProtocol,
593}
594
595impl PlatformDoc {
596 pub fn slug(&self) -> &'static str {
598 match self {
599 PlatformDoc::Constitution => "constitution",
600 PlatformDoc::GovernanceProtocol => "protocol",
601 }
602 }
603
604 pub fn title(&self) -> &'static str {
606 match self {
607 PlatformDoc::Constitution => "The Agora Constitution",
608 PlatformDoc::GovernanceProtocol => "The Agora Governance Protocol",
609 }
610 }
611}
612
613impl std::fmt::Display for PlatformDoc {
614 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615 f.write_str(self.slug())
616 }
617}
618
619#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
621#[error(
622 "not a platform document (expected \"constitution\" or \"protocol\"): {0:?}"
623)]
624pub struct PlatformDocError(pub String);
625
626impl std::str::FromStr for PlatformDoc {
627 type Err = PlatformDocError;
628
629 fn from_str(s: &str) -> Result<Self, Self::Err> {
633 if s.eq_ignore_ascii_case("constitution") {
634 Ok(PlatformDoc::Constitution)
635 } else if s.eq_ignore_ascii_case("protocol")
636 || s.eq_ignore_ascii_case("governance-protocol")
637 {
638 Ok(PlatformDoc::GovernanceProtocol)
639 } else {
640 Err(PlatformDocError(s.to_string()))
641 }
642 }
643}
644
645impl Serialize for PlatformDoc {
646 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
647 s.serialize_str(self.slug())
648 }
649}
650
651impl<'de> Deserialize<'de> for PlatformDoc {
652 fn deserialize<D: serde::Deserializer<'de>>(
653 d: D,
654 ) -> Result<Self, D::Error> {
655 let raw = String::deserialize(d)?;
656 raw.parse().map_err(serde::de::Error::custom)
657 }
658}
659
660#[cfg(feature = "schemars")]
662impl schemars::JsonSchema for PlatformDoc {
663 fn inline_schema() -> bool {
664 true
665 }
666
667 fn schema_name() -> std::borrow::Cow<'static, str> {
668 std::borrow::Cow::Borrowed("PlatformDoc")
669 }
670
671 fn schema_id() -> std::borrow::Cow<'static, str> {
672 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
673 }
674
675 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
676 schemars::json_schema!({
677 "type": "string",
678 "enum": ["constitution", "protocol"],
679 "description": "A platform governing document: the Agora \
680 Constitution or the Governance Protocol.",
681 })
682 }
683}
684
685#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
688#[error(
689 "not a content reference (expected a post/comment UUID, a \
690 GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
691 like \"constitution\" or \"protocol\"): {0:?}"
692)]
693pub struct ContentRefError(pub String);
694
695#[derive(Debug, Clone, PartialEq, Eq, Hash)]
710pub enum ContentRef {
711 Content(ContentId),
713 Governance(GovernanceLogId),
715 Document(PlatformDoc),
717}
718
719impl ContentRef {
720 pub fn as_content(&self) -> Option<ContentId> {
722 match self {
723 ContentRef::Content(id) => Some(*id),
724 _ => None,
725 }
726 }
727
728 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
730 match self {
731 ContentRef::Governance(id) => Some(id),
732 _ => None,
733 }
734 }
735
736 pub fn as_document(&self) -> Option<PlatformDoc> {
738 match self {
739 ContentRef::Document(doc) => Some(*doc),
740 _ => None,
741 }
742 }
743
744 pub fn is_governance(&self) -> bool {
746 matches!(self, ContentRef::Governance(_))
747 }
748
749 pub fn kind_str(&self) -> &'static str {
752 match self {
753 ContentRef::Content(_) => "content",
754 ContentRef::Governance(_) => "governance",
755 ContentRef::Document(_) => "document",
756 }
757 }
758}
759
760impl std::fmt::Display for ContentRef {
761 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
762 match self {
763 ContentRef::Content(id) => id.fmt(f),
764 ContentRef::Governance(id) => id.fmt(f),
765 ContentRef::Document(doc) => doc.fmt(f),
766 }
767 }
768}
769
770impl std::str::FromStr for ContentRef {
771 type Err = ContentRefError;
772
773 fn from_str(s: &str) -> Result<Self, Self::Err> {
774 if let Ok(id) = s.parse::<ContentId>() {
775 return Ok(ContentRef::Content(id));
776 }
777 if let Ok(id) = s.parse::<GovernanceLogId>() {
778 return Ok(ContentRef::Governance(id));
779 }
780 if let Ok(doc) = s.parse::<PlatformDoc>() {
781 return Ok(ContentRef::Document(doc));
782 }
783 Err(ContentRefError(s.to_string()))
784 }
785}
786
787impl TryFrom<String> for ContentRef {
788 type Error = ContentRefError;
789
790 fn try_from(s: String) -> Result<Self, Self::Error> {
791 s.parse()
792 }
793}
794
795impl From<ContentId> for ContentRef {
796 fn from(id: ContentId) -> Self {
797 ContentRef::Content(id)
798 }
799}
800
801impl From<PostId> for ContentRef {
802 fn from(id: PostId) -> Self {
803 ContentRef::Content(id.into())
804 }
805}
806
807impl From<CommentId> for ContentRef {
808 fn from(id: CommentId) -> Self {
809 ContentRef::Content(id.into())
810 }
811}
812
813impl From<GovernanceLogId> for ContentRef {
814 fn from(id: GovernanceLogId) -> Self {
815 ContentRef::Governance(id)
816 }
817}
818
819impl From<PlatformDoc> for ContentRef {
820 fn from(doc: PlatformDoc) -> Self {
821 ContentRef::Document(doc)
822 }
823}
824
825impl Serialize for ContentRef {
826 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
827 s.collect_str(self)
828 }
829}
830
831impl<'de> Deserialize<'de> for ContentRef {
832 fn deserialize<D: serde::Deserializer<'de>>(
833 d: D,
834 ) -> Result<Self, D::Error> {
835 let raw = String::deserialize(d)?;
836 raw.parse().map_err(serde::de::Error::custom)
837 }
838}
839
840#[cfg(feature = "schemars")]
844impl schemars::JsonSchema for ContentRef {
845 fn inline_schema() -> bool {
846 true
847 }
848
849 fn schema_name() -> std::borrow::Cow<'static, str> {
850 std::borrow::Cow::Borrowed("ContentRef")
851 }
852
853 fn schema_id() -> std::borrow::Cow<'static, str> {
854 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
855 }
856
857 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
858 schemars::json_schema!({
859 "type": "string",
860 "description": "A post or comment UUID; a governance log id \
861 such as \"GOV-2026-0006\" (Council decision) \
862 or \"APP-2026-0003\" (appeals ruling); or a \
863 document slug — \"constitution\" or \
864 \"protocol\" (the Governance Protocol).",
865 })
866 }
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872
873 #[test]
874 fn content_ref_parses_document_slugs() {
875 assert_eq!(
876 "constitution".parse(),
877 Ok(ContentRef::Document(PlatformDoc::Constitution))
878 );
879 assert_eq!(
880 "protocol".parse(),
881 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
882 );
883 assert_eq!(
885 "governance-protocol".parse(),
886 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
887 );
888 assert_eq!(
889 "Constitution".parse(),
890 Ok(ContentRef::Document(PlatformDoc::Constitution))
891 );
892 assert!("proto".parse::<ContentRef>().is_err());
893 }
894
895 #[test]
896 fn platform_doc_serde_uses_the_canonical_slug() {
897 let json =
898 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
899 assert_eq!(json, "\"protocol\"");
900 let doc: PlatformDoc =
901 serde_json::from_str("\"governance-protocol\"").unwrap();
902 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
903 }
904
905 #[test]
906 fn ids_are_unique() {
907 let a = AgentId::new();
908 let b = AgentId::new();
909 assert_ne!(a, b);
910 }
911
912 #[test]
913 fn serde_round_trip() {
914 let id = PostId::new();
915 let json = serde_json::to_string(&id).unwrap();
916 let deserialized: PostId = serde_json::from_str(&json).unwrap();
917 assert_eq!(id, deserialized);
918 }
919
920 #[test]
921 fn display_shows_uuid() {
922 let id = CommunityId::new();
923 let display = id.to_string();
924 assert_eq!(display.len(), 36);
926 assert!(display.contains('-'));
927 }
928
929 #[test]
930 fn from_uuid_round_trip() {
931 let uuid = Uuid::new_v4();
932 let id = AgentId::from(uuid);
933 let back: Uuid = id.into();
934 assert_eq!(uuid, back);
935 }
936
937 #[test]
941 fn every_id_round_trips_through_its_own_display() {
942 let agent = AgentId::new();
943 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
944
945 let action = ModerationActionId::new();
946 assert_eq!(
947 action.to_string().parse::<ModerationActionId>().unwrap(),
948 action
949 );
950
951 let content = ContentId::new();
952 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
953 }
954
955 #[test]
956 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
957 assert!("not-a-uuid".parse::<ContentId>().is_err());
958 assert!("".parse::<ContentId>().is_err());
959 }
960
961 #[test]
966 fn content_id_is_wire_compatible_with_a_bare_uuid() {
967 let uuid = Uuid::new_v4();
968 let typed = ContentId::from(uuid);
969 assert_eq!(
970 serde_json::to_string(&typed).unwrap(),
971 serde_json::to_string(&uuid).unwrap()
972 );
973 }
974
975 #[test]
978 fn every_moderation_target_narrows_losslessly() {
979 let uuid = Uuid::new_v4();
980
981 for (label, got) in [
982 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
983 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
984 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
985 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
986 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
987 ] {
988 assert_eq!(
989 got.as_uuid(),
990 &uuid,
991 "{label} -> ModerationTargetId lost the uuid"
992 );
993 }
994 }
995
996 #[test]
1000 fn resolved_ids_narrow_to_content_id_losslessly() {
1001 let uuid = Uuid::new_v4();
1002
1003 assert_eq!(
1004 ContentId::from(PostId::from(uuid)).as_uuid(),
1005 &uuid,
1006 "PostId -> ContentId lost the uuid"
1007 );
1008 assert_eq!(
1009 ContentId::from(CommentId::from(uuid)).as_uuid(),
1010 &uuid,
1011 "CommentId -> ContentId lost the uuid"
1012 );
1013 assert_eq!(
1014 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
1015 .as_uuid(),
1016 &uuid,
1017 "PostOrCommentId -> ContentId lost the uuid"
1018 );
1019 }
1020
1021 #[test]
1022 fn json_is_plain_uuid_string() {
1023 let uuid = Uuid::new_v4();
1024 let id = AgentId::from(uuid);
1025 let id_json = serde_json::to_string(&id).unwrap();
1027 let uuid_json = serde_json::to_string(&uuid).unwrap();
1028 assert_eq!(id_json, uuid_json);
1029 }
1030
1031 #[cfg(feature = "schemars")]
1036 #[test]
1037 fn id_json_schema_is_inlined() {
1038 use schemars::JsonSchema;
1039
1040 assert!(
1041 <PostId as JsonSchema>::inline_schema(),
1042 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
1043 );
1044 assert!(<AgentId as JsonSchema>::inline_schema());
1045 assert!(<CommentId as JsonSchema>::inline_schema());
1046 assert!(<CommunityId as JsonSchema>::inline_schema());
1047 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
1048 assert!(<ContentRef as JsonSchema>::inline_schema());
1049
1050 #[derive(schemars::JsonSchema)]
1054 #[allow(dead_code)]
1055 struct Container {
1056 post_id: PostId,
1058 agent_id: Option<AgentId>,
1060 gov_id: GovernanceLogId,
1062 maybe_gov_id: Option<GovernanceLogId>,
1064 content_ref: ContentRef,
1066 maybe_content_ref: Option<ContentRef>,
1068 }
1069
1070 let schema = schemars::schema_for!(Container);
1071 let value = serde_json::to_value(&schema).unwrap();
1072
1073 assert!(
1075 value.get("$defs").is_none(),
1076 "no $defs should be emitted for ID-only container; got schema: {value}"
1077 );
1078
1079 let post_id = &value["properties"]["post_id"];
1081 assert!(
1082 post_id.get("$ref").is_none(),
1083 "post_id must not be a $ref; got: {post_id}"
1084 );
1085 assert_eq!(post_id["type"], "string");
1086 assert_eq!(post_id["format"], "uuid");
1087
1088 let agent_id = &value["properties"]["agent_id"];
1093 assert!(
1094 agent_id.get("$ref").is_none(),
1095 "agent_id must not be a $ref; got: {agent_id}"
1096 );
1097 let agent_id_str = agent_id.to_string();
1098 assert!(
1099 !agent_id_str.contains("$ref"),
1100 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
1101 );
1102 assert!(
1103 agent_id_str.contains("\"format\":\"uuid\""),
1104 "agent_id should still carry format=uuid; got: {agent_id}"
1105 );
1106
1107 for field in
1112 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
1113 {
1114 let f = &value["properties"][field];
1115 assert!(
1116 !f.to_string().contains("$ref"),
1117 "{field} must contain no $ref anywhere; got: {f}"
1118 );
1119 }
1120 assert_eq!(value["properties"]["gov_id"]["type"], "string");
1121 assert_eq!(
1122 value["properties"]["gov_id"]["pattern"],
1123 r"^(GOV|APP)-\d{4}-\d{4}$"
1124 );
1125 assert!(
1126 value["properties"]["maybe_gov_id"]
1127 .to_string()
1128 .contains("GOV|APP"),
1129 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
1130 value["properties"]["maybe_gov_id"]
1131 );
1132 assert_eq!(value["properties"]["content_ref"]["type"], "string");
1133 }
1134
1135 #[test]
1136 fn governance_log_id_accepts_only_citation_shapes() {
1137 for good in ["GOV-2026-0006", "APP-2026-0003", "GOV-1999-0000"] {
1138 assert_eq!(
1139 good.parse::<GovernanceLogId>().unwrap().as_str(),
1140 good,
1141 "{good} should parse"
1142 );
1143 }
1144 for bad in [
1145 "",
1146 "GOV-2026-006",
1147 "GOV-26-0006",
1148 "gov-2026-0006",
1149 "MOD-2026-0006",
1150 "GOV-2026-0006-1",
1151 "GOV-202X-0006",
1152 "3f1a0000-0000-0000-0000-000000000000",
1153 ] {
1154 assert!(
1155 bad.parse::<GovernanceLogId>().is_err(),
1156 "{bad:?} should not parse as a GovernanceLogId"
1157 );
1158 }
1159 }
1160
1161 #[test]
1165 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1166 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1167 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1168 let back: GovernanceLogId =
1169 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1170 assert_eq!(back, id);
1171 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1173 }
1174
1175 #[test]
1178 fn content_ref_round_trips_as_a_bare_string() {
1179 let uuid = Uuid::new_v4();
1180 let content = ContentRef::from(ContentId::from(uuid));
1181 assert_eq!(
1182 serde_json::to_value(&content).unwrap(),
1183 serde_json::json!(uuid.to_string())
1184 );
1185 assert_eq!(
1186 serde_json::from_value::<ContentRef>(serde_json::json!(
1187 uuid.to_string()
1188 ))
1189 .unwrap(),
1190 content
1191 );
1192
1193 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
1194 assert_eq!(
1195 serde_json::to_value(&gov).unwrap(),
1196 serde_json::json!("APP-2026-0003")
1197 );
1198 assert_eq!(
1199 serde_json::from_value::<ContentRef>(serde_json::json!(
1200 "APP-2026-0003"
1201 ))
1202 .unwrap(),
1203 gov
1204 );
1205
1206 assert!(gov.is_governance());
1207 assert!(!content.is_governance());
1208 assert_eq!(gov.kind_str(), "governance");
1209 assert_eq!(content.kind_str(), "content");
1210 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
1211 assert!(content.as_governance().is_none());
1212
1213 assert!("not-an-id".parse::<ContentRef>().is_err());
1215 assert!(
1216 serde_json::from_value::<ContentRef>(serde_json::json!(
1217 "not-an-id"
1218 ))
1219 .is_err()
1220 );
1221 }
1222
1223 #[test]
1225 fn every_readable_id_narrows_to_a_content_ref() {
1226 let uuid = Uuid::new_v4();
1227 for (label, got) in [
1228 ("PostId", ContentRef::from(PostId::from(uuid))),
1229 ("CommentId", ContentRef::from(CommentId::from(uuid))),
1230 ("ContentId", ContentRef::from(ContentId::from(uuid))),
1231 ] {
1232 assert_eq!(
1233 got,
1234 ContentRef::Content(ContentId::from(uuid)),
1235 "{label} -> ContentRef lost the uuid"
1236 );
1237 }
1238 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1239 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
1240 }
1241
1242 #[test]
1245 fn string_shaped_ids_round_trip_through_display() {
1246 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1247 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
1248
1249 let r = ContentRef::Governance(gov);
1250 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1251
1252 let r = ContentRef::Content(ContentId::new());
1253 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1254 }
1255
1256 #[test]
1257 fn post_or_comment_post_variant() {
1258 let inner = PostId::new();
1259 let tagged = PostOrCommentId::Post(inner);
1260 assert!(tagged.is_post());
1261 assert!(!tagged.is_comment());
1262 assert_eq!(tagged.as_post(), Some(inner));
1263 assert_eq!(tagged.as_comment(), None);
1264 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1265 assert_eq!(tagged.kind_str(), "post");
1266 }
1267
1268 #[test]
1269 fn post_or_comment_comment_variant() {
1270 let inner = CommentId::new();
1271 let tagged = PostOrCommentId::Comment(inner);
1272 assert!(tagged.is_comment());
1273 assert!(!tagged.is_post());
1274 assert_eq!(tagged.as_comment(), Some(inner));
1275 assert_eq!(tagged.as_post(), None);
1276 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1277 assert_eq!(tagged.kind_str(), "comment");
1278 }
1279
1280 #[test]
1281 fn post_or_comment_from_conversions() {
1282 let post = PostId::new();
1283 let comment = CommentId::new();
1284 let via_post: PostOrCommentId = post.into();
1285 let via_comment: PostOrCommentId = comment.into();
1286 assert_eq!(via_post, PostOrCommentId::Post(post));
1287 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
1288 }
1289
1290 #[test]
1291 fn post_or_comment_display_is_kind_colon_uuid() {
1292 let post = PostId::new();
1293 let tagged = PostOrCommentId::Post(post);
1294 let rendered = tagged.to_string();
1295 assert!(rendered.starts_with("post:"));
1296 assert!(rendered.contains(&post.to_string()));
1297 }
1298}