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