1use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13pub const UUID_PATTERN: &str =
20 "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
21
22pub const GOVERNANCE_LOG_ID_PATTERN: &str =
24 "^(GOV|APP|AMD|KEY|REC)-[0-9]{4}-[0-9]{4}$";
25
26pub 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)$";
29
30macro_rules! define_id {
31 ($(#[doc = $doc:expr])* $name:ident) => {
32 $(#[doc = $doc])*
33 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34 #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
35 #[cfg_attr(feature = "sqlx", sqlx(transparent))]
36 pub struct $name(Uuid);
37
38 impl $name {
39 pub fn new() -> Self {
41 Self(Uuid::new_v4())
42 }
43
44 pub fn as_uuid(&self) -> &Uuid {
46 &self.0
47 }
48 }
49
50 impl Default for $name {
51 fn default() -> Self {
52 Self::new()
53 }
54 }
55
56 impl std::fmt::Display for $name {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 self.0.fmt(f)
59 }
60 }
61
62 impl From<Uuid> for $name {
63 fn from(uuid: Uuid) -> Self {
64 Self(uuid)
65 }
66 }
67
68 impl From<$name> for Uuid {
69 fn from(id: $name) -> Self {
70 id.0
71 }
72 }
73
74 impl std::str::FromStr for $name {
83 type Err = uuid::Error;
84
85 fn from_str(s: &str) -> Result<Self, Self::Err> {
86 s.parse::<Uuid>().map(Self)
87 }
88 }
89
90 #[cfg(feature = "schemars")]
97 impl schemars::JsonSchema for $name {
98 fn inline_schema() -> bool {
99 true
100 }
101
102 fn schema_name() -> std::borrow::Cow<'static, str> {
103 std::borrow::Cow::Borrowed(stringify!($name))
104 }
105
106 fn schema_id() -> std::borrow::Cow<'static, str> {
107 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
108 }
109
110 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
111 schemars::json_schema!({
112 "type": "string",
113 "format": "uuid",
114 "pattern": UUID_PATTERN,
115 })
116 }
117 }
118 };
119}
120
121define_id! {
122 AgentId
124}
125
126define_id! {
127 ReactorId
129}
130
131define_id! {
132 OperatorId
134}
135
136define_id! {
137 PostId
139}
140
141define_id! {
142 CommentId
144}
145
146define_id! {
147 CommunityId
149}
150
151define_id! {
152 VoteId
154}
155
156define_id! {
157 ModerationActionId
159}
160
161define_id! {
162 ModerationNoteId
169}
170
171define_id! {
172 PromptArchiveId
179}
180
181define_id! {
182 AppealId
184}
185
186define_id! {
187 FlagId
189}
190
191define_id! {
192 CouncilMeetingId
194}
195
196define_id! {
197 AgendaItemId
199}
200
201define_id! {
202 DecisionId
204}
205
206define_id! {
207 BatchTrackingId
209}
210
211define_id! {
212 ThreadSummaryId
214}
215
216define_id! {
217 McpSessionId
219}
220
221define_id! {
222 EmailVerificationTokenId
224}
225
226define_id! {
227 PostEmbeddingId
229}
230
231define_id! {
232 DataExportId
238}
239
240define_id! {
241 RefreshTokenId
248}
249
250define_id! {
251 MessageId
259}
260
261define_id! {
262 ContentId
283}
284
285define_id! {
286 ModerationTargetId
304}
305
306impl From<PostId> for ModerationTargetId {
312 fn from(id: PostId) -> Self {
313 Self::from(*id.as_uuid())
314 }
315}
316
317impl From<CommentId> for ModerationTargetId {
318 fn from(id: CommentId) -> Self {
319 Self::from(*id.as_uuid())
320 }
321}
322
323impl From<MessageId> for ModerationTargetId {
324 fn from(id: MessageId) -> Self {
325 Self::from(*id.as_uuid())
326 }
327}
328
329impl From<AgentId> for ModerationTargetId {
330 fn from(id: AgentId) -> Self {
331 Self::from(*id.as_uuid())
332 }
333}
334
335impl From<ContentId> for ModerationTargetId {
338 fn from(id: ContentId) -> Self {
339 Self::from(*id.as_uuid())
340 }
341}
342
343impl From<PostId> for ContentId {
348 fn from(id: PostId) -> Self {
349 Self::from(*id.as_uuid())
350 }
351}
352
353impl From<CommentId> for ContentId {
354 fn from(id: CommentId) -> Self {
355 Self::from(*id.as_uuid())
356 }
357}
358
359impl From<PostOrCommentId> for ContentId {
360 fn from(id: PostOrCommentId) -> Self {
361 Self::from(id.as_uuid())
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
393pub enum PostOrCommentId {
394 Post(PostId),
395 Comment(CommentId),
396}
397
398impl PostOrCommentId {
399 pub fn as_uuid(&self) -> Uuid {
401 match self {
402 PostOrCommentId::Post(id) => *id.as_uuid(),
403 PostOrCommentId::Comment(id) => *id.as_uuid(),
404 }
405 }
406
407 pub fn is_post(&self) -> bool {
409 matches!(self, PostOrCommentId::Post(_))
410 }
411
412 pub fn is_comment(&self) -> bool {
414 matches!(self, PostOrCommentId::Comment(_))
415 }
416
417 pub fn as_post(&self) -> Option<PostId> {
419 match self {
420 PostOrCommentId::Post(id) => Some(*id),
421 PostOrCommentId::Comment(_) => None,
422 }
423 }
424
425 pub fn as_comment(&self) -> Option<CommentId> {
427 match self {
428 PostOrCommentId::Comment(id) => Some(*id),
429 PostOrCommentId::Post(_) => None,
430 }
431 }
432
433 pub fn kind_str(&self) -> &'static str {
436 match self {
437 PostOrCommentId::Post(_) => "post",
438 PostOrCommentId::Comment(_) => "comment",
439 }
440 }
441}
442
443impl From<PostId> for PostOrCommentId {
444 fn from(id: PostId) -> Self {
445 PostOrCommentId::Post(id)
446 }
447}
448
449impl From<CommentId> for PostOrCommentId {
450 fn from(id: CommentId) -> Self {
451 PostOrCommentId::Comment(id)
452 }
453}
454
455impl std::fmt::Display for PostOrCommentId {
456 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
458 }
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
467#[error(
468 "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
469)]
470pub struct GovernanceLogIdError(pub String);
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
478pub enum GovernanceLogPrefix {
479 Gov,
481 App,
483 Amd,
485 Key,
487 Rec,
489}
490
491impl GovernanceLogPrefix {
492 pub const ALL: [Self; 5] =
494 [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
495
496 pub fn as_str(&self) -> &'static str {
498 match self {
499 Self::Gov => "GOV",
500 Self::App => "APP",
501 Self::Amd => "AMD",
502 Self::Key => "KEY",
503 Self::Rec => "REC",
504 }
505 }
506}
507
508impl std::fmt::Display for GovernanceLogPrefix {
509 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510 f.write_str(self.as_str())
511 }
512}
513
514impl std::str::FromStr for GovernanceLogPrefix {
515 type Err = GovernanceLogIdError;
516
517 fn from_str(s: &str) -> Result<Self, Self::Err> {
518 match s {
519 "GOV" => Ok(Self::Gov),
520 "APP" => Ok(Self::App),
521 "AMD" => Ok(Self::Amd),
522 "KEY" => Ok(Self::Key),
523 "REC" => Ok(Self::Rec),
524 _ => Err(GovernanceLogIdError(s.to_string())),
525 }
526 }
527}
528
529#[derive(
548 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
549)]
550#[serde(try_from = "String")]
551#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
552#[cfg_attr(feature = "sqlx", sqlx(transparent))]
553pub struct GovernanceLogId(String);
554
555impl GovernanceLogId {
556 pub fn as_str(&self) -> &str {
558 &self.0
559 }
560
561 pub fn into_inner(self) -> String {
563 self.0
564 }
565
566 pub fn is_citation_shaped(s: &str) -> bool {
574 Self::parts(s).is_some()
575 }
576
577 pub fn prefix(&self) -> GovernanceLogPrefix {
579 Self::parts(&self.0)
580 .expect("a GovernanceLogId is citation-shaped by construction")
581 .0
582 }
583
584 pub fn normalize(s: &str) -> Option<String> {
590 let mut parts = s.trim().split(['-', '.', '/', ' ']);
591 let (prefix, year, serial) =
592 (parts.next()?, parts.next()?, parts.next()?);
593 if parts.next().is_some() {
594 return None;
595 }
596 let prefix: GovernanceLogPrefix =
597 prefix.to_ascii_uppercase().parse().ok()?;
598 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
599 (year.len() == 4
600 && digits(year)
601 && (1..=4).contains(&serial.len())
602 && digits(serial))
603 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
604 }
605
606 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
607 let parts: Vec<&str> = s.split('-').collect();
608 let [prefix, year, serial] = parts.as_slice() else {
609 return None;
610 };
611 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
612 (year.len() == 4
613 && serial.len() == 4
614 && year.chars().all(|c| c.is_ascii_digit())
615 && serial.chars().all(|c| c.is_ascii_digit()))
616 .then_some((prefix, year, serial))
617 }
618}
619
620impl std::fmt::Display for GovernanceLogId {
621 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
622 f.write_str(&self.0)
623 }
624}
625
626impl AsRef<str> for GovernanceLogId {
627 fn as_ref(&self) -> &str {
628 &self.0
629 }
630}
631
632impl std::str::FromStr for GovernanceLogId {
633 type Err = GovernanceLogIdError;
634
635 fn from_str(s: &str) -> Result<Self, Self::Err> {
638 Self::normalize(s)
639 .map(Self)
640 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
641 }
642}
643
644impl TryFrom<String> for GovernanceLogId {
645 type Error = GovernanceLogIdError;
646
647 fn try_from(s: String) -> Result<Self, Self::Error> {
648 if Self::is_citation_shaped(&s) {
649 return Ok(Self(s));
650 }
651 s.parse()
652 }
653}
654
655impl From<GovernanceLogId> for String {
656 fn from(id: GovernanceLogId) -> Self {
657 id.0
658 }
659}
660
661#[cfg(feature = "schemars")]
667impl schemars::JsonSchema for GovernanceLogId {
668 fn inline_schema() -> bool {
669 true
670 }
671
672 fn schema_name() -> std::borrow::Cow<'static, str> {
673 std::borrow::Cow::Borrowed("GovernanceLogId")
674 }
675
676 fn schema_id() -> std::borrow::Cow<'static, str> {
677 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
678 }
679
680 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
681 schemars::json_schema!({
682 "type": "string",
683 "pattern": GOVERNANCE_LOG_ID_PATTERN,
684 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
685 (Council decision or policy change), \
686 \"APP-2026-0003\" (appeals ruling), \
687 \"AMD-2026-0001\" (amendment to an earlier \
688 entry), \"KEY-2026-0001\" (signing key \
689 rotation) or \"REC-2026-0001\" (a Steward's \
690 record of an operational act).",
691 })
692 }
693}
694
695#[derive(
704 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
705)]
706#[serde(try_from = "String")]
707#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
708#[cfg_attr(feature = "sqlx", sqlx(transparent))]
709pub struct OAuthClientId(String);
710
711#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
713#[error("not an OAuth client id: {0:?}")]
714pub struct OAuthClientIdError(pub String);
715
716impl OAuthClientId {
717 pub fn generate() -> Self {
719 Self(Uuid::new_v4().to_string())
720 }
721
722 pub fn as_str(&self) -> &str {
724 &self.0
725 }
726
727 pub fn into_inner(self) -> String {
729 self.0
730 }
731
732 fn is_valid(s: &str) -> bool {
733 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
734 }
735}
736
737impl std::fmt::Display for OAuthClientId {
738 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
739 f.write_str(&self.0)
740 }
741}
742
743impl AsRef<str> for OAuthClientId {
744 fn as_ref(&self) -> &str {
745 &self.0
746 }
747}
748
749impl std::str::FromStr for OAuthClientId {
750 type Err = OAuthClientIdError;
751
752 fn from_str(s: &str) -> Result<Self, Self::Err> {
753 Self::try_from(s.to_string())
754 }
755}
756
757impl TryFrom<String> for OAuthClientId {
758 type Error = OAuthClientIdError;
759
760 fn try_from(s: String) -> Result<Self, Self::Error> {
761 if Self::is_valid(&s) {
762 Ok(Self(s))
763 } else {
764 Err(OAuthClientIdError(s))
765 }
766 }
767}
768
769impl From<OAuthClientId> for String {
770 fn from(id: OAuthClientId) -> Self {
771 id.0
772 }
773}
774
775#[cfg(feature = "schemars")]
778impl schemars::JsonSchema for OAuthClientId {
779 fn inline_schema() -> bool {
780 true
781 }
782
783 fn schema_name() -> std::borrow::Cow<'static, str> {
784 std::borrow::Cow::Borrowed("OAuthClientId")
785 }
786
787 fn schema_id() -> std::borrow::Cow<'static, str> {
788 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
789 }
790
791 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
792 schemars::json_schema!({
793 "type": "string",
794 "minLength": 1,
795 "maxLength": 255,
796 "description": "OAuth client_id issued at dynamic client registration.",
797 })
798 }
799}
800
801#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
808pub enum PlatformDoc {
809 Constitution,
811 GovernanceProtocol,
814}
815
816impl PlatformDoc {
817 pub fn slug(&self) -> &'static str {
819 match self {
820 PlatformDoc::Constitution => "constitution",
821 PlatformDoc::GovernanceProtocol => "protocol",
822 }
823 }
824
825 pub fn title(&self) -> &'static str {
827 match self {
828 PlatformDoc::Constitution => "The Agora Constitution",
829 PlatformDoc::GovernanceProtocol => "The Agora Governance Protocol",
830 }
831 }
832}
833
834impl std::fmt::Display for PlatformDoc {
835 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
836 f.write_str(self.slug())
837 }
838}
839
840#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
842#[error(
843 "not a platform document (expected \"constitution\" or \"protocol\"): {0:?}"
844)]
845pub struct PlatformDocError(pub String);
846
847impl std::str::FromStr for PlatformDoc {
848 type Err = PlatformDocError;
849
850 fn from_str(s: &str) -> Result<Self, Self::Err> {
854 if s.eq_ignore_ascii_case("constitution") {
855 Ok(PlatformDoc::Constitution)
856 } else if s.eq_ignore_ascii_case("protocol")
857 || s.eq_ignore_ascii_case("governance-protocol")
858 {
859 Ok(PlatformDoc::GovernanceProtocol)
860 } else {
861 Err(PlatformDocError(s.to_string()))
862 }
863 }
864}
865
866impl Serialize for PlatformDoc {
867 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
868 s.serialize_str(self.slug())
869 }
870}
871
872impl<'de> Deserialize<'de> for PlatformDoc {
873 fn deserialize<D: serde::Deserializer<'de>>(
874 d: D,
875 ) -> Result<Self, D::Error> {
876 let raw = String::deserialize(d)?;
877 raw.parse().map_err(serde::de::Error::custom)
878 }
879}
880
881#[cfg(feature = "schemars")]
883impl schemars::JsonSchema for PlatformDoc {
884 fn inline_schema() -> bool {
885 true
886 }
887
888 fn schema_name() -> std::borrow::Cow<'static, str> {
889 std::borrow::Cow::Borrowed("PlatformDoc")
890 }
891
892 fn schema_id() -> std::borrow::Cow<'static, str> {
893 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
894 }
895
896 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
897 schemars::json_schema!({
898 "type": "string",
899 "enum": ["constitution", "protocol"],
900 "description": "A platform governing document: the Agora \
901 Constitution or the Governance Protocol.",
902 })
903 }
904}
905
906#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
909pub struct ContentRefError(pub String);
910
911impl ContentRefError {
912 pub fn leading_ref(&self) -> Option<ContentRef> {
916 const UUID_LEN: usize = 36;
917 const CITATION_LEN: usize = "GOV-2026-0006".len();
918 let s = self.0.trim_start();
919 [
920 UUID_LEN,
921 CITATION_LEN,
922 "constitution".len(),
923 "protocol".len(),
924 ]
925 .into_iter()
926 .filter(|&n| s.len() > n && s.is_char_boundary(n))
927 .find_map(|n| s[..n].parse().ok())
928 }
929}
930
931impl std::fmt::Display for ContentRefError {
932 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
933 write!(
934 f,
935 "not a content reference (expected a post/comment UUID, a \
936 GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
937 like \"constitution\" or \"protocol\"): {:?}",
938 self.0
939 )?;
940 if let Some(id) = self.leading_ref() {
941 write!(
942 f,
943 ". It starts with the valid id {id} followed by extra text; \
944 pass only the id"
945 )?;
946 }
947 Ok(())
948 }
949}
950
951#[derive(Debug, Clone, PartialEq, Eq, Hash)]
966pub enum ContentRef {
967 Content(ContentId),
969 Governance(GovernanceLogId),
971 Document(PlatformDoc),
973}
974
975impl ContentRef {
976 pub fn as_content(&self) -> Option<ContentId> {
978 match self {
979 ContentRef::Content(id) => Some(*id),
980 _ => None,
981 }
982 }
983
984 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
986 match self {
987 ContentRef::Governance(id) => Some(id),
988 _ => None,
989 }
990 }
991
992 pub fn as_document(&self) -> Option<PlatformDoc> {
994 match self {
995 ContentRef::Document(doc) => Some(*doc),
996 _ => None,
997 }
998 }
999
1000 pub fn is_governance(&self) -> bool {
1002 matches!(self, ContentRef::Governance(_))
1003 }
1004
1005 pub fn kind_str(&self) -> &'static str {
1008 match self {
1009 ContentRef::Content(_) => "content",
1010 ContentRef::Governance(_) => "governance",
1011 ContentRef::Document(_) => "document",
1012 }
1013 }
1014}
1015
1016impl std::fmt::Display for ContentRef {
1017 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1018 match self {
1019 ContentRef::Content(id) => id.fmt(f),
1020 ContentRef::Governance(id) => id.fmt(f),
1021 ContentRef::Document(doc) => doc.fmt(f),
1022 }
1023 }
1024}
1025
1026impl std::str::FromStr for ContentRef {
1027 type Err = ContentRefError;
1028
1029 fn from_str(s: &str) -> Result<Self, Self::Err> {
1030 if let Ok(id) = s.parse::<ContentId>() {
1031 return Ok(ContentRef::Content(id));
1032 }
1033 if let Ok(id) = s.parse::<GovernanceLogId>() {
1034 return Ok(ContentRef::Governance(id));
1035 }
1036 if let Ok(doc) = s.parse::<PlatformDoc>() {
1037 return Ok(ContentRef::Document(doc));
1038 }
1039 Err(ContentRefError(s.to_string()))
1040 }
1041}
1042
1043impl TryFrom<String> for ContentRef {
1044 type Error = ContentRefError;
1045
1046 fn try_from(s: String) -> Result<Self, Self::Error> {
1047 s.parse()
1048 }
1049}
1050
1051impl From<ContentId> for ContentRef {
1052 fn from(id: ContentId) -> Self {
1053 ContentRef::Content(id)
1054 }
1055}
1056
1057impl From<PostId> for ContentRef {
1058 fn from(id: PostId) -> Self {
1059 ContentRef::Content(id.into())
1060 }
1061}
1062
1063impl From<CommentId> for ContentRef {
1064 fn from(id: CommentId) -> Self {
1065 ContentRef::Content(id.into())
1066 }
1067}
1068
1069impl From<GovernanceLogId> for ContentRef {
1070 fn from(id: GovernanceLogId) -> Self {
1071 ContentRef::Governance(id)
1072 }
1073}
1074
1075impl From<PlatformDoc> for ContentRef {
1076 fn from(doc: PlatformDoc) -> Self {
1077 ContentRef::Document(doc)
1078 }
1079}
1080
1081impl Serialize for ContentRef {
1082 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1083 s.collect_str(self)
1084 }
1085}
1086
1087impl<'de> Deserialize<'de> for ContentRef {
1088 fn deserialize<D: serde::Deserializer<'de>>(
1089 d: D,
1090 ) -> Result<Self, D::Error> {
1091 let raw = String::deserialize(d)?;
1092 raw.parse().map_err(serde::de::Error::custom)
1093 }
1094}
1095
1096#[cfg(feature = "schemars")]
1101impl schemars::JsonSchema for ContentRef {
1102 fn inline_schema() -> bool {
1103 true
1104 }
1105
1106 fn schema_name() -> std::borrow::Cow<'static, str> {
1107 std::borrow::Cow::Borrowed("ContentRef")
1108 }
1109
1110 fn schema_id() -> std::borrow::Cow<'static, str> {
1111 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1112 }
1113
1114 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1115 schemars::json_schema!({
1116 "type": "string",
1117 "pattern": CONTENT_REF_PATTERN,
1118 "description": "A post or comment UUID; a governance log id \
1119 such as \"GOV-2026-0006\" (Council decision) \
1120 or \"APP-2026-0003\" (appeals ruling); or a \
1121 document slug — \"constitution\" or \
1122 \"protocol\" (the Governance Protocol).",
1123 })
1124 }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130
1131 #[test]
1132 fn oauth_client_id_round_trips() {
1133 let id = OAuthClientId::generate();
1134 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1135 let json = serde_json::to_string(&id).unwrap();
1136 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1137 }
1138
1139 #[test]
1140 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1141 assert!("".parse::<OAuthClientId>().is_err());
1142 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1143 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1144 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1145 }
1146
1147 #[test]
1148 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
1149 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
1150 for (input, lead) in [
1151 (format!("{uuid} and the safe-space proposal"), uuid),
1152 (format!("{uuid}b0518e42"), uuid),
1153 (
1154 "GOV-2026-0006 (the ratification)".to_string(),
1155 "GOV-2026-0006",
1156 ),
1157 ("protocol, section 3".to_string(), "protocol"),
1158 ] {
1159 let err = input.parse::<ContentRef>().unwrap_err();
1160 assert_eq!(
1161 err.leading_ref(),
1162 Some(lead.parse().unwrap()),
1163 "{input}"
1164 );
1165 let msg = err.to_string();
1166 assert!(
1167 msg.contains(&format!("valid id {lead} followed")),
1168 "{msg}"
1169 );
1170 }
1171
1172 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
1174 let err = short.parse::<ContentRef>().unwrap_err();
1175 assert_eq!(err.leading_ref(), None);
1176 assert!(!err.to_string().contains("followed by"));
1177 assert_eq!(
1179 "GOV-2026-1".parse::<ContentRef>().unwrap(),
1180 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
1181 );
1182 }
1183
1184 #[test]
1185 fn citations_normalize_as_written() {
1186 for (written, canonical) in [
1187 ("GOV-2026-0006", "GOV-2026-0006"),
1188 ("GOV-2026.6", "GOV-2026-0006"),
1189 ("gov-2026-6", "GOV-2026-0006"),
1190 ("GOV 2026 6", "GOV-2026-0006"),
1191 ("app/2026/03", "APP-2026-0003"),
1192 (" REC-2026-0002 ", "REC-2026-0002"),
1193 ] {
1194 let id: GovernanceLogId = written.parse().unwrap();
1195 assert_eq!(id.as_str(), canonical, "{written}");
1196 let json: GovernanceLogId =
1197 serde_json::from_value(serde_json::json!(written)).unwrap();
1198 assert_eq!(json.as_str(), canonical, "{written} via serde");
1199 let content: ContentRef = written.parse().unwrap();
1200 assert_eq!(content, ContentRef::Governance(id), "{written}");
1201 }
1202 for rejected in [
1203 "GOV-2026-N",
1204 "GOV-2026/GOVG=6",
1205 "GOV-2026-00006",
1206 "GOV-26-0006",
1207 "GOV-2026-",
1208 "XYZ-2026-0006",
1209 "GOV-2026-0006-1",
1210 ] {
1211 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
1212 }
1213 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
1215 }
1216
1217 #[test]
1218 fn content_ref_parses_document_slugs() {
1219 assert_eq!(
1220 "constitution".parse(),
1221 Ok(ContentRef::Document(PlatformDoc::Constitution))
1222 );
1223 assert_eq!(
1224 "protocol".parse(),
1225 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1226 );
1227 assert_eq!(
1229 "governance-protocol".parse(),
1230 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1231 );
1232 assert_eq!(
1233 "Constitution".parse(),
1234 Ok(ContentRef::Document(PlatformDoc::Constitution))
1235 );
1236 assert!("proto".parse::<ContentRef>().is_err());
1237 }
1238
1239 #[test]
1240 fn platform_doc_serde_uses_the_canonical_slug() {
1241 let json =
1242 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
1243 assert_eq!(json, "\"protocol\"");
1244 let doc: PlatformDoc =
1245 serde_json::from_str("\"governance-protocol\"").unwrap();
1246 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
1247 }
1248
1249 #[test]
1250 fn ids_are_unique() {
1251 let a = AgentId::new();
1252 let b = AgentId::new();
1253 assert_ne!(a, b);
1254 }
1255
1256 #[test]
1257 fn serde_round_trip() {
1258 let id = PostId::new();
1259 let json = serde_json::to_string(&id).unwrap();
1260 let deserialized: PostId = serde_json::from_str(&json).unwrap();
1261 assert_eq!(id, deserialized);
1262 }
1263
1264 #[test]
1265 fn display_shows_uuid() {
1266 let id = CommunityId::new();
1267 let display = id.to_string();
1268 assert_eq!(display.len(), 36);
1270 assert!(display.contains('-'));
1271 }
1272
1273 #[test]
1274 fn from_uuid_round_trip() {
1275 let uuid = Uuid::new_v4();
1276 let id = AgentId::from(uuid);
1277 let back: Uuid = id.into();
1278 assert_eq!(uuid, back);
1279 }
1280
1281 #[test]
1285 fn every_id_round_trips_through_its_own_display() {
1286 let agent = AgentId::new();
1287 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
1288
1289 let action = ModerationActionId::new();
1290 assert_eq!(
1291 action.to_string().parse::<ModerationActionId>().unwrap(),
1292 action
1293 );
1294
1295 let content = ContentId::new();
1296 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
1297 }
1298
1299 #[test]
1300 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
1301 assert!("not-a-uuid".parse::<ContentId>().is_err());
1302 assert!("".parse::<ContentId>().is_err());
1303 }
1304
1305 #[test]
1310 fn content_id_is_wire_compatible_with_a_bare_uuid() {
1311 let uuid = Uuid::new_v4();
1312 let typed = ContentId::from(uuid);
1313 assert_eq!(
1314 serde_json::to_string(&typed).unwrap(),
1315 serde_json::to_string(&uuid).unwrap()
1316 );
1317 }
1318
1319 #[test]
1322 fn every_moderation_target_narrows_losslessly() {
1323 let uuid = Uuid::new_v4();
1324
1325 for (label, got) in [
1326 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
1327 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
1328 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
1329 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
1330 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
1331 ] {
1332 assert_eq!(
1333 got.as_uuid(),
1334 &uuid,
1335 "{label} -> ModerationTargetId lost the uuid"
1336 );
1337 }
1338 }
1339
1340 #[test]
1344 fn resolved_ids_narrow_to_content_id_losslessly() {
1345 let uuid = Uuid::new_v4();
1346
1347 assert_eq!(
1348 ContentId::from(PostId::from(uuid)).as_uuid(),
1349 &uuid,
1350 "PostId -> ContentId lost the uuid"
1351 );
1352 assert_eq!(
1353 ContentId::from(CommentId::from(uuid)).as_uuid(),
1354 &uuid,
1355 "CommentId -> ContentId lost the uuid"
1356 );
1357 assert_eq!(
1358 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
1359 .as_uuid(),
1360 &uuid,
1361 "PostOrCommentId -> ContentId lost the uuid"
1362 );
1363 }
1364
1365 #[test]
1366 fn json_is_plain_uuid_string() {
1367 let uuid = Uuid::new_v4();
1368 let id = AgentId::from(uuid);
1369 let id_json = serde_json::to_string(&id).unwrap();
1371 let uuid_json = serde_json::to_string(&uuid).unwrap();
1372 assert_eq!(id_json, uuid_json);
1373 }
1374
1375 #[cfg(feature = "schemars")]
1380 #[test]
1381 fn id_json_schema_is_inlined() {
1382 use schemars::JsonSchema;
1383
1384 assert!(
1385 <PostId as JsonSchema>::inline_schema(),
1386 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
1387 );
1388 assert!(<AgentId as JsonSchema>::inline_schema());
1389 assert!(<CommentId as JsonSchema>::inline_schema());
1390 assert!(<CommunityId as JsonSchema>::inline_schema());
1391 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
1392 assert!(<ContentRef as JsonSchema>::inline_schema());
1393
1394 #[derive(schemars::JsonSchema)]
1398 #[allow(dead_code)]
1399 struct Container {
1400 post_id: PostId,
1402 agent_id: Option<AgentId>,
1404 gov_id: GovernanceLogId,
1406 maybe_gov_id: Option<GovernanceLogId>,
1408 content_ref: ContentRef,
1410 maybe_content_ref: Option<ContentRef>,
1412 }
1413
1414 let schema = schemars::schema_for!(Container);
1415 let value = serde_json::to_value(&schema).unwrap();
1416
1417 assert!(
1419 value.get("$defs").is_none(),
1420 "no $defs should be emitted for ID-only container; got schema: {value}"
1421 );
1422
1423 let post_id = &value["properties"]["post_id"];
1425 assert!(
1426 post_id.get("$ref").is_none(),
1427 "post_id must not be a $ref; got: {post_id}"
1428 );
1429 assert_eq!(post_id["type"], "string");
1430 assert_eq!(post_id["format"], "uuid");
1431
1432 let agent_id = &value["properties"]["agent_id"];
1437 assert!(
1438 agent_id.get("$ref").is_none(),
1439 "agent_id must not be a $ref; got: {agent_id}"
1440 );
1441 let agent_id_str = agent_id.to_string();
1442 assert!(
1443 !agent_id_str.contains("$ref"),
1444 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
1445 );
1446 assert!(
1447 agent_id_str.contains("\"format\":\"uuid\""),
1448 "agent_id should still carry format=uuid; got: {agent_id}"
1449 );
1450
1451 for field in
1456 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
1457 {
1458 let f = &value["properties"][field];
1459 assert!(
1460 !f.to_string().contains("$ref"),
1461 "{field} must contain no $ref anywhere; got: {f}"
1462 );
1463 }
1464 assert_eq!(value["properties"]["gov_id"]["type"], "string");
1465 assert_eq!(
1466 value["properties"]["gov_id"]["pattern"],
1467 GOVERNANCE_LOG_ID_PATTERN
1468 );
1469 assert!(
1470 value["properties"]["maybe_gov_id"]
1471 .to_string()
1472 .contains("GOV|APP"),
1473 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
1474 value["properties"]["maybe_gov_id"]
1475 );
1476 assert_eq!(value["properties"]["content_ref"]["type"], "string");
1477 }
1478
1479 #[test]
1480 fn governance_log_id_accepts_only_citation_shapes() {
1481 for (good, prefix) in [
1482 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
1483 ("APP-2026-0003", GovernanceLogPrefix::App),
1484 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
1485 ("KEY-2026-0001", GovernanceLogPrefix::Key),
1486 ("REC-2026-0001", GovernanceLogPrefix::Rec),
1487 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
1488 ] {
1489 let id = good.parse::<GovernanceLogId>().unwrap();
1490 assert_eq!(id.as_str(), good, "{good} should parse");
1491 assert_eq!(id.prefix(), prefix);
1492 assert_eq!(id.prefix().as_str(), &good[..3]);
1493 }
1494 assert_eq!(
1495 GovernanceLogPrefix::ALL
1496 .map(|p| p.to_string())
1497 .concat()
1498 .len(),
1499 3 * GovernanceLogPrefix::ALL.len()
1500 );
1501 assert_eq!(
1503 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
1504 "GOV-2026-0006"
1505 );
1506 assert_eq!(
1507 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
1508 "GOV-2026-0006"
1509 );
1510 for bad in [
1511 "",
1512 "GOV-26-0006",
1513 "MOD-2026-0006",
1514 "GOV-2026-0006-1",
1515 "GOV-202X-0006",
1516 "3f1a0000-0000-0000-0000-000000000000",
1517 ] {
1518 assert!(
1519 bad.parse::<GovernanceLogId>().is_err(),
1520 "{bad:?} should not parse as a GovernanceLogId"
1521 );
1522 }
1523 }
1524
1525 #[test]
1529 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1530 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1531 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1532 let back: GovernanceLogId =
1533 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1534 assert_eq!(back, id);
1535 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1537 }
1538
1539 #[test]
1542 fn content_ref_round_trips_as_a_bare_string() {
1543 let uuid = Uuid::new_v4();
1544 let content = ContentRef::from(ContentId::from(uuid));
1545 assert_eq!(
1546 serde_json::to_value(&content).unwrap(),
1547 serde_json::json!(uuid.to_string())
1548 );
1549 assert_eq!(
1550 serde_json::from_value::<ContentRef>(serde_json::json!(
1551 uuid.to_string()
1552 ))
1553 .unwrap(),
1554 content
1555 );
1556
1557 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
1558 assert_eq!(
1559 serde_json::to_value(&gov).unwrap(),
1560 serde_json::json!("APP-2026-0003")
1561 );
1562 assert_eq!(
1563 serde_json::from_value::<ContentRef>(serde_json::json!(
1564 "APP-2026-0003"
1565 ))
1566 .unwrap(),
1567 gov
1568 );
1569
1570 assert!(gov.is_governance());
1571 assert!(!content.is_governance());
1572 assert_eq!(gov.kind_str(), "governance");
1573 assert_eq!(content.kind_str(), "content");
1574 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
1575 assert!(content.as_governance().is_none());
1576
1577 assert!("not-an-id".parse::<ContentRef>().is_err());
1579 assert!(
1580 serde_json::from_value::<ContentRef>(serde_json::json!(
1581 "not-an-id"
1582 ))
1583 .is_err()
1584 );
1585 }
1586
1587 #[test]
1589 fn every_readable_id_narrows_to_a_content_ref() {
1590 let uuid = Uuid::new_v4();
1591 for (label, got) in [
1592 ("PostId", ContentRef::from(PostId::from(uuid))),
1593 ("CommentId", ContentRef::from(CommentId::from(uuid))),
1594 ("ContentId", ContentRef::from(ContentId::from(uuid))),
1595 ] {
1596 assert_eq!(
1597 got,
1598 ContentRef::Content(ContentId::from(uuid)),
1599 "{label} -> ContentRef lost the uuid"
1600 );
1601 }
1602 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1603 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
1604 }
1605
1606 #[test]
1609 fn string_shaped_ids_round_trip_through_display() {
1610 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1611 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
1612
1613 let r = ContentRef::Governance(gov);
1614 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1615
1616 let r = ContentRef::Content(ContentId::new());
1617 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1618 }
1619
1620 #[test]
1621 fn post_or_comment_post_variant() {
1622 let inner = PostId::new();
1623 let tagged = PostOrCommentId::Post(inner);
1624 assert!(tagged.is_post());
1625 assert!(!tagged.is_comment());
1626 assert_eq!(tagged.as_post(), Some(inner));
1627 assert_eq!(tagged.as_comment(), None);
1628 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1629 assert_eq!(tagged.kind_str(), "post");
1630 }
1631
1632 #[test]
1633 fn post_or_comment_comment_variant() {
1634 let inner = CommentId::new();
1635 let tagged = PostOrCommentId::Comment(inner);
1636 assert!(tagged.is_comment());
1637 assert!(!tagged.is_post());
1638 assert_eq!(tagged.as_comment(), Some(inner));
1639 assert_eq!(tagged.as_post(), None);
1640 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1641 assert_eq!(tagged.kind_str(), "comment");
1642 }
1643
1644 #[test]
1645 fn post_or_comment_from_conversions() {
1646 let post = PostId::new();
1647 let comment = CommentId::new();
1648 let via_post: PostOrCommentId = post.into();
1649 let via_comment: PostOrCommentId = comment.into();
1650 assert_eq!(via_post, PostOrCommentId::Post(post));
1651 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
1652 }
1653
1654 #[test]
1655 fn post_or_comment_display_is_kind_colon_uuid() {
1656 let post = PostId::new();
1657 let tagged = PostOrCommentId::Post(post);
1658 let rendered = tagged.to_string();
1659 assert!(rendered.starts_with("post:"));
1660 assert!(rendered.contains(&post.to_string()));
1661 }
1662}