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(
665 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
666)]
667#[serde(try_from = "String")]
668#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
669#[cfg_attr(feature = "sqlx", sqlx(transparent))]
670pub struct OAuthClientId(String);
671
672#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
674#[error("not an OAuth client id: {0:?}")]
675pub struct OAuthClientIdError(pub String);
676
677impl OAuthClientId {
678 pub fn generate() -> Self {
680 Self(Uuid::new_v4().to_string())
681 }
682
683 pub fn as_str(&self) -> &str {
685 &self.0
686 }
687
688 pub fn into_inner(self) -> String {
690 self.0
691 }
692
693 fn is_valid(s: &str) -> bool {
694 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
695 }
696}
697
698impl std::fmt::Display for OAuthClientId {
699 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
700 f.write_str(&self.0)
701 }
702}
703
704impl AsRef<str> for OAuthClientId {
705 fn as_ref(&self) -> &str {
706 &self.0
707 }
708}
709
710impl std::str::FromStr for OAuthClientId {
711 type Err = OAuthClientIdError;
712
713 fn from_str(s: &str) -> Result<Self, Self::Err> {
714 Self::try_from(s.to_string())
715 }
716}
717
718impl TryFrom<String> for OAuthClientId {
719 type Error = OAuthClientIdError;
720
721 fn try_from(s: String) -> Result<Self, Self::Error> {
722 if Self::is_valid(&s) {
723 Ok(Self(s))
724 } else {
725 Err(OAuthClientIdError(s))
726 }
727 }
728}
729
730impl From<OAuthClientId> for String {
731 fn from(id: OAuthClientId) -> Self {
732 id.0
733 }
734}
735
736#[cfg(feature = "schemars")]
739impl schemars::JsonSchema for OAuthClientId {
740 fn inline_schema() -> bool {
741 true
742 }
743
744 fn schema_name() -> std::borrow::Cow<'static, str> {
745 std::borrow::Cow::Borrowed("OAuthClientId")
746 }
747
748 fn schema_id() -> std::borrow::Cow<'static, str> {
749 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
750 }
751
752 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
753 schemars::json_schema!({
754 "type": "string",
755 "minLength": 1,
756 "maxLength": 255,
757 "description": "OAuth client_id issued at dynamic client registration.",
758 })
759 }
760}
761
762#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
769pub enum PlatformDoc {
770 Constitution,
772 GovernanceProtocol,
775}
776
777impl PlatformDoc {
778 pub fn slug(&self) -> &'static str {
780 match self {
781 PlatformDoc::Constitution => "constitution",
782 PlatformDoc::GovernanceProtocol => "protocol",
783 }
784 }
785
786 pub fn title(&self) -> &'static str {
788 match self {
789 PlatformDoc::Constitution => "The Agora Constitution",
790 PlatformDoc::GovernanceProtocol => "The Agora Governance Protocol",
791 }
792 }
793}
794
795impl std::fmt::Display for PlatformDoc {
796 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
797 f.write_str(self.slug())
798 }
799}
800
801#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
803#[error(
804 "not a platform document (expected \"constitution\" or \"protocol\"): {0:?}"
805)]
806pub struct PlatformDocError(pub String);
807
808impl std::str::FromStr for PlatformDoc {
809 type Err = PlatformDocError;
810
811 fn from_str(s: &str) -> Result<Self, Self::Err> {
815 if s.eq_ignore_ascii_case("constitution") {
816 Ok(PlatformDoc::Constitution)
817 } else if s.eq_ignore_ascii_case("protocol")
818 || s.eq_ignore_ascii_case("governance-protocol")
819 {
820 Ok(PlatformDoc::GovernanceProtocol)
821 } else {
822 Err(PlatformDocError(s.to_string()))
823 }
824 }
825}
826
827impl Serialize for PlatformDoc {
828 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
829 s.serialize_str(self.slug())
830 }
831}
832
833impl<'de> Deserialize<'de> for PlatformDoc {
834 fn deserialize<D: serde::Deserializer<'de>>(
835 d: D,
836 ) -> Result<Self, D::Error> {
837 let raw = String::deserialize(d)?;
838 raw.parse().map_err(serde::de::Error::custom)
839 }
840}
841
842#[cfg(feature = "schemars")]
844impl schemars::JsonSchema for PlatformDoc {
845 fn inline_schema() -> bool {
846 true
847 }
848
849 fn schema_name() -> std::borrow::Cow<'static, str> {
850 std::borrow::Cow::Borrowed("PlatformDoc")
851 }
852
853 fn schema_id() -> std::borrow::Cow<'static, str> {
854 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
855 }
856
857 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
858 schemars::json_schema!({
859 "type": "string",
860 "enum": ["constitution", "protocol"],
861 "description": "A platform governing document: the Agora \
862 Constitution or the Governance Protocol.",
863 })
864 }
865}
866
867#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
870#[error(
871 "not a content reference (expected a post/comment UUID, a \
872 GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
873 like \"constitution\" or \"protocol\"): {0:?}"
874)]
875pub struct ContentRefError(pub String);
876
877#[derive(Debug, Clone, PartialEq, Eq, Hash)]
892pub enum ContentRef {
893 Content(ContentId),
895 Governance(GovernanceLogId),
897 Document(PlatformDoc),
899}
900
901impl ContentRef {
902 pub fn as_content(&self) -> Option<ContentId> {
904 match self {
905 ContentRef::Content(id) => Some(*id),
906 _ => None,
907 }
908 }
909
910 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
912 match self {
913 ContentRef::Governance(id) => Some(id),
914 _ => None,
915 }
916 }
917
918 pub fn as_document(&self) -> Option<PlatformDoc> {
920 match self {
921 ContentRef::Document(doc) => Some(*doc),
922 _ => None,
923 }
924 }
925
926 pub fn is_governance(&self) -> bool {
928 matches!(self, ContentRef::Governance(_))
929 }
930
931 pub fn kind_str(&self) -> &'static str {
934 match self {
935 ContentRef::Content(_) => "content",
936 ContentRef::Governance(_) => "governance",
937 ContentRef::Document(_) => "document",
938 }
939 }
940}
941
942impl std::fmt::Display for ContentRef {
943 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
944 match self {
945 ContentRef::Content(id) => id.fmt(f),
946 ContentRef::Governance(id) => id.fmt(f),
947 ContentRef::Document(doc) => doc.fmt(f),
948 }
949 }
950}
951
952impl std::str::FromStr for ContentRef {
953 type Err = ContentRefError;
954
955 fn from_str(s: &str) -> Result<Self, Self::Err> {
956 if let Ok(id) = s.parse::<ContentId>() {
957 return Ok(ContentRef::Content(id));
958 }
959 if let Ok(id) = s.parse::<GovernanceLogId>() {
960 return Ok(ContentRef::Governance(id));
961 }
962 if let Ok(doc) = s.parse::<PlatformDoc>() {
963 return Ok(ContentRef::Document(doc));
964 }
965 Err(ContentRefError(s.to_string()))
966 }
967}
968
969impl TryFrom<String> for ContentRef {
970 type Error = ContentRefError;
971
972 fn try_from(s: String) -> Result<Self, Self::Error> {
973 s.parse()
974 }
975}
976
977impl From<ContentId> for ContentRef {
978 fn from(id: ContentId) -> Self {
979 ContentRef::Content(id)
980 }
981}
982
983impl From<PostId> for ContentRef {
984 fn from(id: PostId) -> Self {
985 ContentRef::Content(id.into())
986 }
987}
988
989impl From<CommentId> for ContentRef {
990 fn from(id: CommentId) -> Self {
991 ContentRef::Content(id.into())
992 }
993}
994
995impl From<GovernanceLogId> for ContentRef {
996 fn from(id: GovernanceLogId) -> Self {
997 ContentRef::Governance(id)
998 }
999}
1000
1001impl From<PlatformDoc> for ContentRef {
1002 fn from(doc: PlatformDoc) -> Self {
1003 ContentRef::Document(doc)
1004 }
1005}
1006
1007impl Serialize for ContentRef {
1008 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1009 s.collect_str(self)
1010 }
1011}
1012
1013impl<'de> Deserialize<'de> for ContentRef {
1014 fn deserialize<D: serde::Deserializer<'de>>(
1015 d: D,
1016 ) -> Result<Self, D::Error> {
1017 let raw = String::deserialize(d)?;
1018 raw.parse().map_err(serde::de::Error::custom)
1019 }
1020}
1021
1022#[cfg(feature = "schemars")]
1026impl schemars::JsonSchema for ContentRef {
1027 fn inline_schema() -> bool {
1028 true
1029 }
1030
1031 fn schema_name() -> std::borrow::Cow<'static, str> {
1032 std::borrow::Cow::Borrowed("ContentRef")
1033 }
1034
1035 fn schema_id() -> std::borrow::Cow<'static, str> {
1036 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1037 }
1038
1039 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1040 schemars::json_schema!({
1041 "type": "string",
1042 "description": "A post or comment UUID; a governance log id \
1043 such as \"GOV-2026-0006\" (Council decision) \
1044 or \"APP-2026-0003\" (appeals ruling); or a \
1045 document slug — \"constitution\" or \
1046 \"protocol\" (the Governance Protocol).",
1047 })
1048 }
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use super::*;
1054
1055 #[test]
1056 fn oauth_client_id_round_trips() {
1057 let id = OAuthClientId::generate();
1058 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1059 let json = serde_json::to_string(&id).unwrap();
1060 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1061 }
1062
1063 #[test]
1064 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1065 assert!("".parse::<OAuthClientId>().is_err());
1066 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1067 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1068 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1069 }
1070
1071 #[test]
1072 fn content_ref_parses_document_slugs() {
1073 assert_eq!(
1074 "constitution".parse(),
1075 Ok(ContentRef::Document(PlatformDoc::Constitution))
1076 );
1077 assert_eq!(
1078 "protocol".parse(),
1079 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1080 );
1081 assert_eq!(
1083 "governance-protocol".parse(),
1084 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1085 );
1086 assert_eq!(
1087 "Constitution".parse(),
1088 Ok(ContentRef::Document(PlatformDoc::Constitution))
1089 );
1090 assert!("proto".parse::<ContentRef>().is_err());
1091 }
1092
1093 #[test]
1094 fn platform_doc_serde_uses_the_canonical_slug() {
1095 let json =
1096 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
1097 assert_eq!(json, "\"protocol\"");
1098 let doc: PlatformDoc =
1099 serde_json::from_str("\"governance-protocol\"").unwrap();
1100 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
1101 }
1102
1103 #[test]
1104 fn ids_are_unique() {
1105 let a = AgentId::new();
1106 let b = AgentId::new();
1107 assert_ne!(a, b);
1108 }
1109
1110 #[test]
1111 fn serde_round_trip() {
1112 let id = PostId::new();
1113 let json = serde_json::to_string(&id).unwrap();
1114 let deserialized: PostId = serde_json::from_str(&json).unwrap();
1115 assert_eq!(id, deserialized);
1116 }
1117
1118 #[test]
1119 fn display_shows_uuid() {
1120 let id = CommunityId::new();
1121 let display = id.to_string();
1122 assert_eq!(display.len(), 36);
1124 assert!(display.contains('-'));
1125 }
1126
1127 #[test]
1128 fn from_uuid_round_trip() {
1129 let uuid = Uuid::new_v4();
1130 let id = AgentId::from(uuid);
1131 let back: Uuid = id.into();
1132 assert_eq!(uuid, back);
1133 }
1134
1135 #[test]
1139 fn every_id_round_trips_through_its_own_display() {
1140 let agent = AgentId::new();
1141 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
1142
1143 let action = ModerationActionId::new();
1144 assert_eq!(
1145 action.to_string().parse::<ModerationActionId>().unwrap(),
1146 action
1147 );
1148
1149 let content = ContentId::new();
1150 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
1151 }
1152
1153 #[test]
1154 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
1155 assert!("not-a-uuid".parse::<ContentId>().is_err());
1156 assert!("".parse::<ContentId>().is_err());
1157 }
1158
1159 #[test]
1164 fn content_id_is_wire_compatible_with_a_bare_uuid() {
1165 let uuid = Uuid::new_v4();
1166 let typed = ContentId::from(uuid);
1167 assert_eq!(
1168 serde_json::to_string(&typed).unwrap(),
1169 serde_json::to_string(&uuid).unwrap()
1170 );
1171 }
1172
1173 #[test]
1176 fn every_moderation_target_narrows_losslessly() {
1177 let uuid = Uuid::new_v4();
1178
1179 for (label, got) in [
1180 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
1181 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
1182 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
1183 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
1184 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
1185 ] {
1186 assert_eq!(
1187 got.as_uuid(),
1188 &uuid,
1189 "{label} -> ModerationTargetId lost the uuid"
1190 );
1191 }
1192 }
1193
1194 #[test]
1198 fn resolved_ids_narrow_to_content_id_losslessly() {
1199 let uuid = Uuid::new_v4();
1200
1201 assert_eq!(
1202 ContentId::from(PostId::from(uuid)).as_uuid(),
1203 &uuid,
1204 "PostId -> ContentId lost the uuid"
1205 );
1206 assert_eq!(
1207 ContentId::from(CommentId::from(uuid)).as_uuid(),
1208 &uuid,
1209 "CommentId -> ContentId lost the uuid"
1210 );
1211 assert_eq!(
1212 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
1213 .as_uuid(),
1214 &uuid,
1215 "PostOrCommentId -> ContentId lost the uuid"
1216 );
1217 }
1218
1219 #[test]
1220 fn json_is_plain_uuid_string() {
1221 let uuid = Uuid::new_v4();
1222 let id = AgentId::from(uuid);
1223 let id_json = serde_json::to_string(&id).unwrap();
1225 let uuid_json = serde_json::to_string(&uuid).unwrap();
1226 assert_eq!(id_json, uuid_json);
1227 }
1228
1229 #[cfg(feature = "schemars")]
1234 #[test]
1235 fn id_json_schema_is_inlined() {
1236 use schemars::JsonSchema;
1237
1238 assert!(
1239 <PostId as JsonSchema>::inline_schema(),
1240 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
1241 );
1242 assert!(<AgentId as JsonSchema>::inline_schema());
1243 assert!(<CommentId as JsonSchema>::inline_schema());
1244 assert!(<CommunityId as JsonSchema>::inline_schema());
1245 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
1246 assert!(<ContentRef as JsonSchema>::inline_schema());
1247
1248 #[derive(schemars::JsonSchema)]
1252 #[allow(dead_code)]
1253 struct Container {
1254 post_id: PostId,
1256 agent_id: Option<AgentId>,
1258 gov_id: GovernanceLogId,
1260 maybe_gov_id: Option<GovernanceLogId>,
1262 content_ref: ContentRef,
1264 maybe_content_ref: Option<ContentRef>,
1266 }
1267
1268 let schema = schemars::schema_for!(Container);
1269 let value = serde_json::to_value(&schema).unwrap();
1270
1271 assert!(
1273 value.get("$defs").is_none(),
1274 "no $defs should be emitted for ID-only container; got schema: {value}"
1275 );
1276
1277 let post_id = &value["properties"]["post_id"];
1279 assert!(
1280 post_id.get("$ref").is_none(),
1281 "post_id must not be a $ref; got: {post_id}"
1282 );
1283 assert_eq!(post_id["type"], "string");
1284 assert_eq!(post_id["format"], "uuid");
1285
1286 let agent_id = &value["properties"]["agent_id"];
1291 assert!(
1292 agent_id.get("$ref").is_none(),
1293 "agent_id must not be a $ref; got: {agent_id}"
1294 );
1295 let agent_id_str = agent_id.to_string();
1296 assert!(
1297 !agent_id_str.contains("$ref"),
1298 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
1299 );
1300 assert!(
1301 agent_id_str.contains("\"format\":\"uuid\""),
1302 "agent_id should still carry format=uuid; got: {agent_id}"
1303 );
1304
1305 for field in
1310 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
1311 {
1312 let f = &value["properties"][field];
1313 assert!(
1314 !f.to_string().contains("$ref"),
1315 "{field} must contain no $ref anywhere; got: {f}"
1316 );
1317 }
1318 assert_eq!(value["properties"]["gov_id"]["type"], "string");
1319 assert_eq!(
1320 value["properties"]["gov_id"]["pattern"],
1321 r"^(GOV|APP|AMD|KEY|REC)-\d{4}-\d{4}$"
1322 );
1323 assert!(
1324 value["properties"]["maybe_gov_id"]
1325 .to_string()
1326 .contains("GOV|APP"),
1327 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
1328 value["properties"]["maybe_gov_id"]
1329 );
1330 assert_eq!(value["properties"]["content_ref"]["type"], "string");
1331 }
1332
1333 #[test]
1334 fn governance_log_id_accepts_only_citation_shapes() {
1335 for (good, prefix) in [
1336 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
1337 ("APP-2026-0003", GovernanceLogPrefix::App),
1338 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
1339 ("KEY-2026-0001", GovernanceLogPrefix::Key),
1340 ("REC-2026-0001", GovernanceLogPrefix::Rec),
1341 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
1342 ] {
1343 let id = good.parse::<GovernanceLogId>().unwrap();
1344 assert_eq!(id.as_str(), good, "{good} should parse");
1345 assert_eq!(id.prefix(), prefix);
1346 assert_eq!(id.prefix().as_str(), &good[..3]);
1347 }
1348 assert_eq!(
1349 GovernanceLogPrefix::ALL
1350 .map(|p| p.to_string())
1351 .concat()
1352 .len(),
1353 3 * GovernanceLogPrefix::ALL.len()
1354 );
1355 for bad in [
1356 "",
1357 "GOV-2026-006",
1358 "GOV-26-0006",
1359 "gov-2026-0006",
1360 "MOD-2026-0006",
1361 "GOV-2026-0006-1",
1362 "GOV-202X-0006",
1363 "3f1a0000-0000-0000-0000-000000000000",
1364 ] {
1365 assert!(
1366 bad.parse::<GovernanceLogId>().is_err(),
1367 "{bad:?} should not parse as a GovernanceLogId"
1368 );
1369 }
1370 }
1371
1372 #[test]
1376 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1377 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1378 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1379 let back: GovernanceLogId =
1380 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1381 assert_eq!(back, id);
1382 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1384 }
1385
1386 #[test]
1389 fn content_ref_round_trips_as_a_bare_string() {
1390 let uuid = Uuid::new_v4();
1391 let content = ContentRef::from(ContentId::from(uuid));
1392 assert_eq!(
1393 serde_json::to_value(&content).unwrap(),
1394 serde_json::json!(uuid.to_string())
1395 );
1396 assert_eq!(
1397 serde_json::from_value::<ContentRef>(serde_json::json!(
1398 uuid.to_string()
1399 ))
1400 .unwrap(),
1401 content
1402 );
1403
1404 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
1405 assert_eq!(
1406 serde_json::to_value(&gov).unwrap(),
1407 serde_json::json!("APP-2026-0003")
1408 );
1409 assert_eq!(
1410 serde_json::from_value::<ContentRef>(serde_json::json!(
1411 "APP-2026-0003"
1412 ))
1413 .unwrap(),
1414 gov
1415 );
1416
1417 assert!(gov.is_governance());
1418 assert!(!content.is_governance());
1419 assert_eq!(gov.kind_str(), "governance");
1420 assert_eq!(content.kind_str(), "content");
1421 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
1422 assert!(content.as_governance().is_none());
1423
1424 assert!("not-an-id".parse::<ContentRef>().is_err());
1426 assert!(
1427 serde_json::from_value::<ContentRef>(serde_json::json!(
1428 "not-an-id"
1429 ))
1430 .is_err()
1431 );
1432 }
1433
1434 #[test]
1436 fn every_readable_id_narrows_to_a_content_ref() {
1437 let uuid = Uuid::new_v4();
1438 for (label, got) in [
1439 ("PostId", ContentRef::from(PostId::from(uuid))),
1440 ("CommentId", ContentRef::from(CommentId::from(uuid))),
1441 ("ContentId", ContentRef::from(ContentId::from(uuid))),
1442 ] {
1443 assert_eq!(
1444 got,
1445 ContentRef::Content(ContentId::from(uuid)),
1446 "{label} -> ContentRef lost the uuid"
1447 );
1448 }
1449 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1450 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
1451 }
1452
1453 #[test]
1456 fn string_shaped_ids_round_trip_through_display() {
1457 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1458 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
1459
1460 let r = ContentRef::Governance(gov);
1461 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1462
1463 let r = ContentRef::Content(ContentId::new());
1464 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1465 }
1466
1467 #[test]
1468 fn post_or_comment_post_variant() {
1469 let inner = PostId::new();
1470 let tagged = PostOrCommentId::Post(inner);
1471 assert!(tagged.is_post());
1472 assert!(!tagged.is_comment());
1473 assert_eq!(tagged.as_post(), Some(inner));
1474 assert_eq!(tagged.as_comment(), None);
1475 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1476 assert_eq!(tagged.kind_str(), "post");
1477 }
1478
1479 #[test]
1480 fn post_or_comment_comment_variant() {
1481 let inner = CommentId::new();
1482 let tagged = PostOrCommentId::Comment(inner);
1483 assert!(tagged.is_comment());
1484 assert!(!tagged.is_post());
1485 assert_eq!(tagged.as_comment(), Some(inner));
1486 assert_eq!(tagged.as_post(), None);
1487 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1488 assert_eq!(tagged.kind_str(), "comment");
1489 }
1490
1491 #[test]
1492 fn post_or_comment_from_conversions() {
1493 let post = PostId::new();
1494 let comment = CommentId::new();
1495 let via_post: PostOrCommentId = post.into();
1496 let via_comment: PostOrCommentId = comment.into();
1497 assert_eq!(via_post, PostOrCommentId::Post(post));
1498 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
1499 }
1500
1501 #[test]
1502 fn post_or_comment_display_is_kind_colon_uuid() {
1503 let post = PostId::new();
1504 let tagged = PostOrCommentId::Post(post);
1505 let rendered = tagged.to_string();
1506 assert!(rendered.starts_with("post:"));
1507 assert!(rendered.contains(&post.to_string()));
1508 }
1509}