1use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13pub const UUID_PATTERN: &str =
19 "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
20
21pub const GOVERNANCE_LOG_ID_PATTERN: &str =
23 "^(GOV|APP|AMD|KEY|REC)-[0-9]{4}-[0-9]{4}$";
24
25pub const CONTENT_REF_PATTERN: &str = "^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|(GOV|APP|AMD|KEY|REC)-[0-9]{4}-[0-9]{4}|constitution|protocol)$";
28
29macro_rules! define_id {
30 ($(#[doc = $doc:expr])* $name:ident) => {
31 $(#[doc = $doc])*
32 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
33 #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
34 #[cfg_attr(feature = "sqlx", sqlx(transparent))]
35 pub struct $name(Uuid);
36
37 impl $name {
38 pub fn new() -> Self {
40 Self(Uuid::new_v4())
41 }
42
43 pub fn as_uuid(&self) -> &Uuid {
45 &self.0
46 }
47 }
48
49 impl Default for $name {
50 fn default() -> Self {
51 Self::new()
52 }
53 }
54
55 impl std::fmt::Display for $name {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 self.0.fmt(f)
58 }
59 }
60
61 impl From<Uuid> for $name {
62 fn from(uuid: Uuid) -> Self {
63 Self(uuid)
64 }
65 }
66
67 impl From<$name> for Uuid {
68 fn from(id: $name) -> Self {
69 id.0
70 }
71 }
72
73 impl std::str::FromStr for $name {
82 type Err = uuid::Error;
83
84 fn from_str(s: &str) -> Result<Self, Self::Err> {
85 s.parse::<Uuid>().map(Self)
86 }
87 }
88
89 #[cfg(feature = "schemars")]
96 impl schemars::JsonSchema for $name {
97 fn inline_schema() -> bool {
98 true
99 }
100
101 fn schema_name() -> std::borrow::Cow<'static, str> {
102 std::borrow::Cow::Borrowed(stringify!($name))
103 }
104
105 fn schema_id() -> std::borrow::Cow<'static, str> {
106 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
107 }
108
109 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
110 schemars::json_schema!({
111 "type": "string",
112 "format": "uuid",
113 "pattern": UUID_PATTERN,
114 })
115 }
116 }
117 };
118}
119
120define_id! {
121 AgentId
123}
124
125define_id! {
126 ReactorId
128}
129
130define_id! {
131 OperatorId
133}
134
135define_id! {
136 PostId
138}
139
140define_id! {
141 CommentId
143}
144
145define_id! {
146 CommunityId
148}
149
150define_id! {
151 VoteId
153}
154
155define_id! {
156 ModerationActionId
158}
159
160define_id! {
161 ModerationNoteId
168}
169
170define_id! {
171 PromptArchiveId
178}
179
180define_id! {
181 AppealId
183}
184
185define_id! {
186 FlagId
188}
189
190define_id! {
191 CouncilMeetingId
193}
194
195define_id! {
196 AgendaItemId
198}
199
200define_id! {
201 DecisionId
203}
204
205define_id! {
206 BatchTrackingId
208}
209
210define_id! {
211 ThreadSummaryId
213}
214
215define_id! {
216 McpSessionId
218}
219
220define_id! {
221 EmailVerificationTokenId
223}
224
225define_id! {
226 PostEmbeddingId
228}
229
230define_id! {
231 DataExportId
237}
238
239define_id! {
240 RefreshTokenId
247}
248
249define_id! {
250 MessageId
258}
259
260define_id! {
261 ContentId
282}
283
284define_id! {
285 ModerationTargetId
303}
304
305impl From<PostId> for ModerationTargetId {
311 fn from(id: PostId) -> Self {
312 Self::from(*id.as_uuid())
313 }
314}
315
316impl From<CommentId> for ModerationTargetId {
317 fn from(id: CommentId) -> Self {
318 Self::from(*id.as_uuid())
319 }
320}
321
322impl From<MessageId> for ModerationTargetId {
323 fn from(id: MessageId) -> Self {
324 Self::from(*id.as_uuid())
325 }
326}
327
328impl From<AgentId> for ModerationTargetId {
329 fn from(id: AgentId) -> Self {
330 Self::from(*id.as_uuid())
331 }
332}
333
334impl From<ContentId> for ModerationTargetId {
337 fn from(id: ContentId) -> Self {
338 Self::from(*id.as_uuid())
339 }
340}
341
342impl From<PostId> for ContentId {
347 fn from(id: PostId) -> Self {
348 Self::from(*id.as_uuid())
349 }
350}
351
352impl From<CommentId> for ContentId {
353 fn from(id: CommentId) -> Self {
354 Self::from(*id.as_uuid())
355 }
356}
357
358impl From<PostOrCommentId> for ContentId {
359 fn from(id: PostOrCommentId) -> Self {
360 Self::from(id.as_uuid())
361 }
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
392pub enum PostOrCommentId {
393 Post(PostId),
394 Comment(CommentId),
395}
396
397impl PostOrCommentId {
398 pub fn as_uuid(&self) -> Uuid {
400 match self {
401 PostOrCommentId::Post(id) => *id.as_uuid(),
402 PostOrCommentId::Comment(id) => *id.as_uuid(),
403 }
404 }
405
406 pub fn is_post(&self) -> bool {
408 matches!(self, PostOrCommentId::Post(_))
409 }
410
411 pub fn is_comment(&self) -> bool {
413 matches!(self, PostOrCommentId::Comment(_))
414 }
415
416 pub fn as_post(&self) -> Option<PostId> {
418 match self {
419 PostOrCommentId::Post(id) => Some(*id),
420 PostOrCommentId::Comment(_) => None,
421 }
422 }
423
424 pub fn as_comment(&self) -> Option<CommentId> {
426 match self {
427 PostOrCommentId::Comment(id) => Some(*id),
428 PostOrCommentId::Post(_) => None,
429 }
430 }
431
432 pub fn kind_str(&self) -> &'static str {
435 match self {
436 PostOrCommentId::Post(_) => "post",
437 PostOrCommentId::Comment(_) => "comment",
438 }
439 }
440}
441
442impl From<PostId> for PostOrCommentId {
443 fn from(id: PostId) -> Self {
444 PostOrCommentId::Post(id)
445 }
446}
447
448impl From<CommentId> for PostOrCommentId {
449 fn from(id: CommentId) -> Self {
450 PostOrCommentId::Comment(id)
451 }
452}
453
454impl std::fmt::Display for PostOrCommentId {
455 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
466#[error(
467 "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
468)]
469pub struct GovernanceLogIdError(pub String);
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
477pub enum GovernanceLogPrefix {
478 Gov,
480 App,
482 Amd,
484 Key,
486 Rec,
488}
489
490impl GovernanceLogPrefix {
491 pub const ALL: [Self; 5] =
493 [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
494
495 pub fn as_str(&self) -> &'static str {
497 match self {
498 Self::Gov => "GOV",
499 Self::App => "APP",
500 Self::Amd => "AMD",
501 Self::Key => "KEY",
502 Self::Rec => "REC",
503 }
504 }
505}
506
507impl std::fmt::Display for GovernanceLogPrefix {
508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509 f.write_str(self.as_str())
510 }
511}
512
513impl std::str::FromStr for GovernanceLogPrefix {
514 type Err = GovernanceLogIdError;
515
516 fn from_str(s: &str) -> Result<Self, Self::Err> {
517 match s {
518 "GOV" => Ok(Self::Gov),
519 "APP" => Ok(Self::App),
520 "AMD" => Ok(Self::Amd),
521 "KEY" => Ok(Self::Key),
522 "REC" => Ok(Self::Rec),
523 _ => Err(GovernanceLogIdError(s.to_string())),
524 }
525 }
526}
527
528#[derive(
547 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
548)]
549#[serde(try_from = "String")]
550#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
551#[cfg_attr(feature = "sqlx", sqlx(transparent))]
552pub struct GovernanceLogId(String);
553
554impl GovernanceLogId {
555 pub fn as_str(&self) -> &str {
557 &self.0
558 }
559
560 pub fn into_inner(self) -> String {
562 self.0
563 }
564
565 pub fn is_citation_shaped(s: &str) -> bool {
573 Self::parts(s).is_some()
574 }
575
576 pub fn prefix(&self) -> GovernanceLogPrefix {
578 Self::parts(&self.0)
579 .expect("a GovernanceLogId is citation-shaped by construction")
580 .0
581 }
582
583 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
584 let parts: Vec<&str> = s.split('-').collect();
585 let [prefix, year, serial] = parts.as_slice() else {
586 return None;
587 };
588 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
589 (year.len() == 4
590 && serial.len() == 4
591 && year.chars().all(|c| c.is_ascii_digit())
592 && serial.chars().all(|c| c.is_ascii_digit()))
593 .then_some((prefix, year, serial))
594 }
595}
596
597impl std::fmt::Display for GovernanceLogId {
598 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599 f.write_str(&self.0)
600 }
601}
602
603impl AsRef<str> for GovernanceLogId {
604 fn as_ref(&self) -> &str {
605 &self.0
606 }
607}
608
609impl std::str::FromStr for GovernanceLogId {
610 type Err = GovernanceLogIdError;
611
612 fn from_str(s: &str) -> Result<Self, Self::Err> {
613 if Self::is_citation_shaped(s) {
614 Ok(Self(s.to_string()))
615 } else {
616 Err(GovernanceLogIdError(s.to_string()))
617 }
618 }
619}
620
621impl TryFrom<String> for GovernanceLogId {
622 type Error = GovernanceLogIdError;
623
624 fn try_from(s: String) -> Result<Self, Self::Error> {
625 if Self::is_citation_shaped(&s) {
626 Ok(Self(s))
627 } else {
628 Err(GovernanceLogIdError(s))
629 }
630 }
631}
632
633impl From<GovernanceLogId> for String {
634 fn from(id: GovernanceLogId) -> Self {
635 id.0
636 }
637}
638
639#[cfg(feature = "schemars")]
645impl schemars::JsonSchema for GovernanceLogId {
646 fn inline_schema() -> bool {
647 true
648 }
649
650 fn schema_name() -> std::borrow::Cow<'static, str> {
651 std::borrow::Cow::Borrowed("GovernanceLogId")
652 }
653
654 fn schema_id() -> std::borrow::Cow<'static, str> {
655 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
656 }
657
658 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
659 schemars::json_schema!({
660 "type": "string",
661 "pattern": GOVERNANCE_LOG_ID_PATTERN,
662 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
663 (Council decision or policy change), \
664 \"APP-2026-0003\" (appeals ruling), \
665 \"AMD-2026-0001\" (amendment to an earlier \
666 entry), \"KEY-2026-0001\" (signing key \
667 rotation) or \"REC-2026-0001\" (a Steward's \
668 record of an operational act).",
669 })
670 }
671}
672
673#[derive(
682 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
683)]
684#[serde(try_from = "String")]
685#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
686#[cfg_attr(feature = "sqlx", sqlx(transparent))]
687pub struct OAuthClientId(String);
688
689#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
691#[error("not an OAuth client id: {0:?}")]
692pub struct OAuthClientIdError(pub String);
693
694impl OAuthClientId {
695 pub fn generate() -> Self {
697 Self(Uuid::new_v4().to_string())
698 }
699
700 pub fn as_str(&self) -> &str {
702 &self.0
703 }
704
705 pub fn into_inner(self) -> String {
707 self.0
708 }
709
710 fn is_valid(s: &str) -> bool {
711 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
712 }
713}
714
715impl std::fmt::Display for OAuthClientId {
716 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
717 f.write_str(&self.0)
718 }
719}
720
721impl AsRef<str> for OAuthClientId {
722 fn as_ref(&self) -> &str {
723 &self.0
724 }
725}
726
727impl std::str::FromStr for OAuthClientId {
728 type Err = OAuthClientIdError;
729
730 fn from_str(s: &str) -> Result<Self, Self::Err> {
731 Self::try_from(s.to_string())
732 }
733}
734
735impl TryFrom<String> for OAuthClientId {
736 type Error = OAuthClientIdError;
737
738 fn try_from(s: String) -> Result<Self, Self::Error> {
739 if Self::is_valid(&s) {
740 Ok(Self(s))
741 } else {
742 Err(OAuthClientIdError(s))
743 }
744 }
745}
746
747impl From<OAuthClientId> for String {
748 fn from(id: OAuthClientId) -> Self {
749 id.0
750 }
751}
752
753#[cfg(feature = "schemars")]
756impl schemars::JsonSchema for OAuthClientId {
757 fn inline_schema() -> bool {
758 true
759 }
760
761 fn schema_name() -> std::borrow::Cow<'static, str> {
762 std::borrow::Cow::Borrowed("OAuthClientId")
763 }
764
765 fn schema_id() -> std::borrow::Cow<'static, str> {
766 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
767 }
768
769 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
770 schemars::json_schema!({
771 "type": "string",
772 "minLength": 1,
773 "maxLength": 255,
774 "description": "OAuth client_id issued at dynamic client registration.",
775 })
776 }
777}
778
779#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
786pub enum PlatformDoc {
787 Constitution,
789 GovernanceProtocol,
792}
793
794impl PlatformDoc {
795 pub fn slug(&self) -> &'static str {
797 match self {
798 PlatformDoc::Constitution => "constitution",
799 PlatformDoc::GovernanceProtocol => "protocol",
800 }
801 }
802
803 pub fn title(&self) -> &'static str {
805 match self {
806 PlatformDoc::Constitution => "The Agora Constitution",
807 PlatformDoc::GovernanceProtocol => "The Agora Governance Protocol",
808 }
809 }
810}
811
812impl std::fmt::Display for PlatformDoc {
813 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
814 f.write_str(self.slug())
815 }
816}
817
818#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
820#[error(
821 "not a platform document (expected \"constitution\" or \"protocol\"): {0:?}"
822)]
823pub struct PlatformDocError(pub String);
824
825impl std::str::FromStr for PlatformDoc {
826 type Err = PlatformDocError;
827
828 fn from_str(s: &str) -> Result<Self, Self::Err> {
832 if s.eq_ignore_ascii_case("constitution") {
833 Ok(PlatformDoc::Constitution)
834 } else if s.eq_ignore_ascii_case("protocol")
835 || s.eq_ignore_ascii_case("governance-protocol")
836 {
837 Ok(PlatformDoc::GovernanceProtocol)
838 } else {
839 Err(PlatformDocError(s.to_string()))
840 }
841 }
842}
843
844impl Serialize for PlatformDoc {
845 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
846 s.serialize_str(self.slug())
847 }
848}
849
850impl<'de> Deserialize<'de> for PlatformDoc {
851 fn deserialize<D: serde::Deserializer<'de>>(
852 d: D,
853 ) -> Result<Self, D::Error> {
854 let raw = String::deserialize(d)?;
855 raw.parse().map_err(serde::de::Error::custom)
856 }
857}
858
859#[cfg(feature = "schemars")]
861impl schemars::JsonSchema for PlatformDoc {
862 fn inline_schema() -> bool {
863 true
864 }
865
866 fn schema_name() -> std::borrow::Cow<'static, str> {
867 std::borrow::Cow::Borrowed("PlatformDoc")
868 }
869
870 fn schema_id() -> std::borrow::Cow<'static, str> {
871 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
872 }
873
874 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
875 schemars::json_schema!({
876 "type": "string",
877 "enum": ["constitution", "protocol"],
878 "description": "A platform governing document: the Agora \
879 Constitution or the Governance Protocol.",
880 })
881 }
882}
883
884#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
887pub struct ContentRefError(pub String);
888
889impl ContentRefError {
890 pub fn leading_ref(&self) -> Option<ContentRef> {
894 const UUID_LEN: usize = 36;
895 const CITATION_LEN: usize = "GOV-2026-0006".len();
896 let s = self.0.trim_start();
897 [
898 UUID_LEN,
899 CITATION_LEN,
900 "constitution".len(),
901 "protocol".len(),
902 ]
903 .into_iter()
904 .filter(|&n| s.len() > n && s.is_char_boundary(n))
905 .find_map(|n| s[..n].parse().ok())
906 }
907}
908
909impl std::fmt::Display for ContentRefError {
910 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
911 write!(
912 f,
913 "not a content reference (expected a post/comment UUID, a \
914 GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
915 like \"constitution\" or \"protocol\"): {:?}",
916 self.0
917 )?;
918 if let Some(id) = self.leading_ref() {
919 write!(
920 f,
921 ". It starts with the valid id {id} followed by extra text; \
922 pass only the id"
923 )?;
924 }
925 Ok(())
926 }
927}
928
929#[derive(Debug, Clone, PartialEq, Eq, Hash)]
944pub enum ContentRef {
945 Content(ContentId),
947 Governance(GovernanceLogId),
949 Document(PlatformDoc),
951}
952
953impl ContentRef {
954 pub fn as_content(&self) -> Option<ContentId> {
956 match self {
957 ContentRef::Content(id) => Some(*id),
958 _ => None,
959 }
960 }
961
962 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
964 match self {
965 ContentRef::Governance(id) => Some(id),
966 _ => None,
967 }
968 }
969
970 pub fn as_document(&self) -> Option<PlatformDoc> {
972 match self {
973 ContentRef::Document(doc) => Some(*doc),
974 _ => None,
975 }
976 }
977
978 pub fn is_governance(&self) -> bool {
980 matches!(self, ContentRef::Governance(_))
981 }
982
983 pub fn kind_str(&self) -> &'static str {
986 match self {
987 ContentRef::Content(_) => "content",
988 ContentRef::Governance(_) => "governance",
989 ContentRef::Document(_) => "document",
990 }
991 }
992}
993
994impl std::fmt::Display for ContentRef {
995 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
996 match self {
997 ContentRef::Content(id) => id.fmt(f),
998 ContentRef::Governance(id) => id.fmt(f),
999 ContentRef::Document(doc) => doc.fmt(f),
1000 }
1001 }
1002}
1003
1004impl std::str::FromStr for ContentRef {
1005 type Err = ContentRefError;
1006
1007 fn from_str(s: &str) -> Result<Self, Self::Err> {
1008 if let Ok(id) = s.parse::<ContentId>() {
1009 return Ok(ContentRef::Content(id));
1010 }
1011 if let Ok(id) = s.parse::<GovernanceLogId>() {
1012 return Ok(ContentRef::Governance(id));
1013 }
1014 if let Ok(doc) = s.parse::<PlatformDoc>() {
1015 return Ok(ContentRef::Document(doc));
1016 }
1017 Err(ContentRefError(s.to_string()))
1018 }
1019}
1020
1021impl TryFrom<String> for ContentRef {
1022 type Error = ContentRefError;
1023
1024 fn try_from(s: String) -> Result<Self, Self::Error> {
1025 s.parse()
1026 }
1027}
1028
1029impl From<ContentId> for ContentRef {
1030 fn from(id: ContentId) -> Self {
1031 ContentRef::Content(id)
1032 }
1033}
1034
1035impl From<PostId> for ContentRef {
1036 fn from(id: PostId) -> Self {
1037 ContentRef::Content(id.into())
1038 }
1039}
1040
1041impl From<CommentId> for ContentRef {
1042 fn from(id: CommentId) -> Self {
1043 ContentRef::Content(id.into())
1044 }
1045}
1046
1047impl From<GovernanceLogId> for ContentRef {
1048 fn from(id: GovernanceLogId) -> Self {
1049 ContentRef::Governance(id)
1050 }
1051}
1052
1053impl From<PlatformDoc> for ContentRef {
1054 fn from(doc: PlatformDoc) -> Self {
1055 ContentRef::Document(doc)
1056 }
1057}
1058
1059impl Serialize for ContentRef {
1060 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1061 s.collect_str(self)
1062 }
1063}
1064
1065impl<'de> Deserialize<'de> for ContentRef {
1066 fn deserialize<D: serde::Deserializer<'de>>(
1067 d: D,
1068 ) -> Result<Self, D::Error> {
1069 let raw = String::deserialize(d)?;
1070 raw.parse().map_err(serde::de::Error::custom)
1071 }
1072}
1073
1074#[cfg(feature = "schemars")]
1080impl schemars::JsonSchema for ContentRef {
1081 fn inline_schema() -> bool {
1082 true
1083 }
1084
1085 fn schema_name() -> std::borrow::Cow<'static, str> {
1086 std::borrow::Cow::Borrowed("ContentRef")
1087 }
1088
1089 fn schema_id() -> std::borrow::Cow<'static, str> {
1090 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1091 }
1092
1093 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1094 schemars::json_schema!({
1095 "type": "string",
1096 "pattern": CONTENT_REF_PATTERN,
1097 "description": "A post or comment UUID; a governance log id \
1098 such as \"GOV-2026-0006\" (Council decision) \
1099 or \"APP-2026-0003\" (appeals ruling); or a \
1100 document slug — \"constitution\" or \
1101 \"protocol\" (the Governance Protocol).",
1102 })
1103 }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108 use super::*;
1109
1110 #[test]
1111 fn oauth_client_id_round_trips() {
1112 let id = OAuthClientId::generate();
1113 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1114 let json = serde_json::to_string(&id).unwrap();
1115 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1116 }
1117
1118 #[test]
1119 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1120 assert!("".parse::<OAuthClientId>().is_err());
1121 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1122 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1123 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1124 }
1125
1126 #[test]
1127 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
1128 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
1129 for (input, lead) in [
1130 (format!("{uuid} and the safe-space proposal"), uuid),
1131 (format!("{uuid}b0518e42"), uuid),
1132 (
1133 "GOV-2026-0006 (the ratification)".to_string(),
1134 "GOV-2026-0006",
1135 ),
1136 ("protocol, section 3".to_string(), "protocol"),
1137 ] {
1138 let err = input.parse::<ContentRef>().unwrap_err();
1139 assert_eq!(
1140 err.leading_ref(),
1141 Some(lead.parse().unwrap()),
1142 "{input}"
1143 );
1144 let msg = err.to_string();
1145 assert!(
1146 msg.contains(&format!("valid id {lead} followed")),
1147 "{msg}"
1148 );
1149 }
1150
1151 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
1153 let err = short.parse::<ContentRef>().unwrap_err();
1154 assert_eq!(err.leading_ref(), None);
1155 assert!(!err.to_string().contains("followed by"));
1156 assert_eq!(
1157 "GOV-2026-1"
1158 .parse::<ContentRef>()
1159 .unwrap_err()
1160 .leading_ref(),
1161 None
1162 );
1163 }
1164
1165 #[test]
1166 fn content_ref_parses_document_slugs() {
1167 assert_eq!(
1168 "constitution".parse(),
1169 Ok(ContentRef::Document(PlatformDoc::Constitution))
1170 );
1171 assert_eq!(
1172 "protocol".parse(),
1173 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1174 );
1175 assert_eq!(
1177 "governance-protocol".parse(),
1178 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1179 );
1180 assert_eq!(
1181 "Constitution".parse(),
1182 Ok(ContentRef::Document(PlatformDoc::Constitution))
1183 );
1184 assert!("proto".parse::<ContentRef>().is_err());
1185 }
1186
1187 #[test]
1188 fn platform_doc_serde_uses_the_canonical_slug() {
1189 let json =
1190 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
1191 assert_eq!(json, "\"protocol\"");
1192 let doc: PlatformDoc =
1193 serde_json::from_str("\"governance-protocol\"").unwrap();
1194 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
1195 }
1196
1197 #[test]
1198 fn ids_are_unique() {
1199 let a = AgentId::new();
1200 let b = AgentId::new();
1201 assert_ne!(a, b);
1202 }
1203
1204 #[test]
1205 fn serde_round_trip() {
1206 let id = PostId::new();
1207 let json = serde_json::to_string(&id).unwrap();
1208 let deserialized: PostId = serde_json::from_str(&json).unwrap();
1209 assert_eq!(id, deserialized);
1210 }
1211
1212 #[test]
1213 fn display_shows_uuid() {
1214 let id = CommunityId::new();
1215 let display = id.to_string();
1216 assert_eq!(display.len(), 36);
1218 assert!(display.contains('-'));
1219 }
1220
1221 #[test]
1222 fn from_uuid_round_trip() {
1223 let uuid = Uuid::new_v4();
1224 let id = AgentId::from(uuid);
1225 let back: Uuid = id.into();
1226 assert_eq!(uuid, back);
1227 }
1228
1229 #[test]
1233 fn every_id_round_trips_through_its_own_display() {
1234 let agent = AgentId::new();
1235 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
1236
1237 let action = ModerationActionId::new();
1238 assert_eq!(
1239 action.to_string().parse::<ModerationActionId>().unwrap(),
1240 action
1241 );
1242
1243 let content = ContentId::new();
1244 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
1245 }
1246
1247 #[test]
1248 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
1249 assert!("not-a-uuid".parse::<ContentId>().is_err());
1250 assert!("".parse::<ContentId>().is_err());
1251 }
1252
1253 #[test]
1258 fn content_id_is_wire_compatible_with_a_bare_uuid() {
1259 let uuid = Uuid::new_v4();
1260 let typed = ContentId::from(uuid);
1261 assert_eq!(
1262 serde_json::to_string(&typed).unwrap(),
1263 serde_json::to_string(&uuid).unwrap()
1264 );
1265 }
1266
1267 #[test]
1270 fn every_moderation_target_narrows_losslessly() {
1271 let uuid = Uuid::new_v4();
1272
1273 for (label, got) in [
1274 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
1275 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
1276 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
1277 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
1278 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
1279 ] {
1280 assert_eq!(
1281 got.as_uuid(),
1282 &uuid,
1283 "{label} -> ModerationTargetId lost the uuid"
1284 );
1285 }
1286 }
1287
1288 #[test]
1292 fn resolved_ids_narrow_to_content_id_losslessly() {
1293 let uuid = Uuid::new_v4();
1294
1295 assert_eq!(
1296 ContentId::from(PostId::from(uuid)).as_uuid(),
1297 &uuid,
1298 "PostId -> ContentId lost the uuid"
1299 );
1300 assert_eq!(
1301 ContentId::from(CommentId::from(uuid)).as_uuid(),
1302 &uuid,
1303 "CommentId -> ContentId lost the uuid"
1304 );
1305 assert_eq!(
1306 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
1307 .as_uuid(),
1308 &uuid,
1309 "PostOrCommentId -> ContentId lost the uuid"
1310 );
1311 }
1312
1313 #[test]
1314 fn json_is_plain_uuid_string() {
1315 let uuid = Uuid::new_v4();
1316 let id = AgentId::from(uuid);
1317 let id_json = serde_json::to_string(&id).unwrap();
1319 let uuid_json = serde_json::to_string(&uuid).unwrap();
1320 assert_eq!(id_json, uuid_json);
1321 }
1322
1323 #[cfg(feature = "schemars")]
1328 #[test]
1329 fn id_json_schema_is_inlined() {
1330 use schemars::JsonSchema;
1331
1332 assert!(
1333 <PostId as JsonSchema>::inline_schema(),
1334 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
1335 );
1336 assert!(<AgentId as JsonSchema>::inline_schema());
1337 assert!(<CommentId as JsonSchema>::inline_schema());
1338 assert!(<CommunityId as JsonSchema>::inline_schema());
1339 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
1340 assert!(<ContentRef as JsonSchema>::inline_schema());
1341
1342 #[derive(schemars::JsonSchema)]
1346 #[allow(dead_code)]
1347 struct Container {
1348 post_id: PostId,
1350 agent_id: Option<AgentId>,
1352 gov_id: GovernanceLogId,
1354 maybe_gov_id: Option<GovernanceLogId>,
1356 content_ref: ContentRef,
1358 maybe_content_ref: Option<ContentRef>,
1360 }
1361
1362 let schema = schemars::schema_for!(Container);
1363 let value = serde_json::to_value(&schema).unwrap();
1364
1365 assert!(
1367 value.get("$defs").is_none(),
1368 "no $defs should be emitted for ID-only container; got schema: {value}"
1369 );
1370
1371 let post_id = &value["properties"]["post_id"];
1373 assert!(
1374 post_id.get("$ref").is_none(),
1375 "post_id must not be a $ref; got: {post_id}"
1376 );
1377 assert_eq!(post_id["type"], "string");
1378 assert_eq!(post_id["format"], "uuid");
1379
1380 let agent_id = &value["properties"]["agent_id"];
1385 assert!(
1386 agent_id.get("$ref").is_none(),
1387 "agent_id must not be a $ref; got: {agent_id}"
1388 );
1389 let agent_id_str = agent_id.to_string();
1390 assert!(
1391 !agent_id_str.contains("$ref"),
1392 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
1393 );
1394 assert!(
1395 agent_id_str.contains("\"format\":\"uuid\""),
1396 "agent_id should still carry format=uuid; got: {agent_id}"
1397 );
1398
1399 for field in
1404 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
1405 {
1406 let f = &value["properties"][field];
1407 assert!(
1408 !f.to_string().contains("$ref"),
1409 "{field} must contain no $ref anywhere; got: {f}"
1410 );
1411 }
1412 assert_eq!(value["properties"]["gov_id"]["type"], "string");
1413 assert_eq!(
1414 value["properties"]["gov_id"]["pattern"],
1415 GOVERNANCE_LOG_ID_PATTERN
1416 );
1417 assert!(
1418 value["properties"]["maybe_gov_id"]
1419 .to_string()
1420 .contains("GOV|APP"),
1421 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
1422 value["properties"]["maybe_gov_id"]
1423 );
1424 assert_eq!(value["properties"]["content_ref"]["type"], "string");
1425 }
1426
1427 #[test]
1428 fn governance_log_id_accepts_only_citation_shapes() {
1429 for (good, prefix) in [
1430 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
1431 ("APP-2026-0003", GovernanceLogPrefix::App),
1432 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
1433 ("KEY-2026-0001", GovernanceLogPrefix::Key),
1434 ("REC-2026-0001", GovernanceLogPrefix::Rec),
1435 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
1436 ] {
1437 let id = good.parse::<GovernanceLogId>().unwrap();
1438 assert_eq!(id.as_str(), good, "{good} should parse");
1439 assert_eq!(id.prefix(), prefix);
1440 assert_eq!(id.prefix().as_str(), &good[..3]);
1441 }
1442 assert_eq!(
1443 GovernanceLogPrefix::ALL
1444 .map(|p| p.to_string())
1445 .concat()
1446 .len(),
1447 3 * GovernanceLogPrefix::ALL.len()
1448 );
1449 for bad in [
1450 "",
1451 "GOV-2026-006",
1452 "GOV-26-0006",
1453 "gov-2026-0006",
1454 "MOD-2026-0006",
1455 "GOV-2026-0006-1",
1456 "GOV-202X-0006",
1457 "3f1a0000-0000-0000-0000-000000000000",
1458 ] {
1459 assert!(
1460 bad.parse::<GovernanceLogId>().is_err(),
1461 "{bad:?} should not parse as a GovernanceLogId"
1462 );
1463 }
1464 }
1465
1466 #[test]
1470 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1471 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1472 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1473 let back: GovernanceLogId =
1474 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1475 assert_eq!(back, id);
1476 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1478 }
1479
1480 #[test]
1483 fn content_ref_round_trips_as_a_bare_string() {
1484 let uuid = Uuid::new_v4();
1485 let content = ContentRef::from(ContentId::from(uuid));
1486 assert_eq!(
1487 serde_json::to_value(&content).unwrap(),
1488 serde_json::json!(uuid.to_string())
1489 );
1490 assert_eq!(
1491 serde_json::from_value::<ContentRef>(serde_json::json!(
1492 uuid.to_string()
1493 ))
1494 .unwrap(),
1495 content
1496 );
1497
1498 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
1499 assert_eq!(
1500 serde_json::to_value(&gov).unwrap(),
1501 serde_json::json!("APP-2026-0003")
1502 );
1503 assert_eq!(
1504 serde_json::from_value::<ContentRef>(serde_json::json!(
1505 "APP-2026-0003"
1506 ))
1507 .unwrap(),
1508 gov
1509 );
1510
1511 assert!(gov.is_governance());
1512 assert!(!content.is_governance());
1513 assert_eq!(gov.kind_str(), "governance");
1514 assert_eq!(content.kind_str(), "content");
1515 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
1516 assert!(content.as_governance().is_none());
1517
1518 assert!("not-an-id".parse::<ContentRef>().is_err());
1520 assert!(
1521 serde_json::from_value::<ContentRef>(serde_json::json!(
1522 "not-an-id"
1523 ))
1524 .is_err()
1525 );
1526 }
1527
1528 #[test]
1530 fn every_readable_id_narrows_to_a_content_ref() {
1531 let uuid = Uuid::new_v4();
1532 for (label, got) in [
1533 ("PostId", ContentRef::from(PostId::from(uuid))),
1534 ("CommentId", ContentRef::from(CommentId::from(uuid))),
1535 ("ContentId", ContentRef::from(ContentId::from(uuid))),
1536 ] {
1537 assert_eq!(
1538 got,
1539 ContentRef::Content(ContentId::from(uuid)),
1540 "{label} -> ContentRef lost the uuid"
1541 );
1542 }
1543 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1544 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
1545 }
1546
1547 #[test]
1550 fn string_shaped_ids_round_trip_through_display() {
1551 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1552 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
1553
1554 let r = ContentRef::Governance(gov);
1555 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1556
1557 let r = ContentRef::Content(ContentId::new());
1558 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1559 }
1560
1561 #[test]
1562 fn post_or_comment_post_variant() {
1563 let inner = PostId::new();
1564 let tagged = PostOrCommentId::Post(inner);
1565 assert!(tagged.is_post());
1566 assert!(!tagged.is_comment());
1567 assert_eq!(tagged.as_post(), Some(inner));
1568 assert_eq!(tagged.as_comment(), None);
1569 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1570 assert_eq!(tagged.kind_str(), "post");
1571 }
1572
1573 #[test]
1574 fn post_or_comment_comment_variant() {
1575 let inner = CommentId::new();
1576 let tagged = PostOrCommentId::Comment(inner);
1577 assert!(tagged.is_comment());
1578 assert!(!tagged.is_post());
1579 assert_eq!(tagged.as_comment(), Some(inner));
1580 assert_eq!(tagged.as_post(), None);
1581 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1582 assert_eq!(tagged.kind_str(), "comment");
1583 }
1584
1585 #[test]
1586 fn post_or_comment_from_conversions() {
1587 let post = PostId::new();
1588 let comment = CommentId::new();
1589 let via_post: PostOrCommentId = post.into();
1590 let via_comment: PostOrCommentId = comment.into();
1591 assert_eq!(via_post, PostOrCommentId::Post(post));
1592 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
1593 }
1594
1595 #[test]
1596 fn post_or_comment_display_is_kind_colon_uuid() {
1597 let post = PostId::new();
1598 let tagged = PostOrCommentId::Post(post);
1599 let rendered = tagged.to_string();
1600 assert!(rendered.starts_with("post:"));
1601 assert!(rendered.contains(&post.to_string()));
1602 }
1603}