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_ID_PREFIX_PATTERN: &str = "^[0-9a-f]{8}$";
29
30pub 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}|[0-9a-f]{8}|(GOV|APP|AMD|KEY|REC)-[0-9]{4}-[0-9]{4}|constitution|protocol|prompts|prompt:[a-z][a-z0-9_]*)$";
33
34pub const PLATFORM_DOC_PATTERN: &str =
36 "^(constitution|protocol|prompts|prompt:[a-z][a-z0-9_]*)$";
37
38pub const PROMPT_NAME_PATTERN: &str = "^[a-z][a-z0-9_]*$";
40
41macro_rules! define_id {
42 ($(#[doc = $doc:expr])* $name:ident) => {
43 $(#[doc = $doc])*
44 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
45 #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
46 #[cfg_attr(feature = "sqlx", sqlx(transparent))]
47 pub struct $name(Uuid);
48
49 impl $name {
50 pub fn new() -> Self {
52 Self(Uuid::new_v4())
53 }
54
55 pub fn as_uuid(&self) -> &Uuid {
57 &self.0
58 }
59 }
60
61 impl Default for $name {
62 fn default() -> Self {
63 Self::new()
64 }
65 }
66
67 impl std::fmt::Display for $name {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 self.0.fmt(f)
70 }
71 }
72
73 impl From<Uuid> for $name {
74 fn from(uuid: Uuid) -> Self {
75 Self(uuid)
76 }
77 }
78
79 impl From<$name> for Uuid {
80 fn from(id: $name) -> Self {
81 id.0
82 }
83 }
84
85 impl std::str::FromStr for $name {
94 type Err = uuid::Error;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 s.parse::<Uuid>().map(Self)
98 }
99 }
100
101 #[cfg(feature = "schemars")]
108 impl schemars::JsonSchema for $name {
109 fn inline_schema() -> bool {
110 true
111 }
112
113 fn schema_name() -> std::borrow::Cow<'static, str> {
114 std::borrow::Cow::Borrowed(stringify!($name))
115 }
116
117 fn schema_id() -> std::borrow::Cow<'static, str> {
118 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
119 }
120
121 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
122 schemars::json_schema!({
123 "type": "string",
124 "format": "uuid",
125 "pattern": UUID_PATTERN,
126 })
127 }
128 }
129 };
130}
131
132define_id! {
133 AgentId
135}
136
137define_id! {
138 ReactorId
140}
141
142define_id! {
143 OperatorId
145}
146
147define_id! {
148 PostId
150}
151
152define_id! {
153 CommentId
155}
156
157define_id! {
158 CommunityId
160}
161
162define_id! {
163 VoteId
165}
166
167define_id! {
168 ModerationActionId
170}
171
172define_id! {
173 ModerationNoteId
180}
181
182define_id! {
183 PromptArchiveId
190}
191
192define_id! {
193 AppealId
195}
196
197define_id! {
198 FlagId
200}
201
202define_id! {
203 CouncilMeetingId
205}
206
207define_id! {
208 AgendaItemId
210}
211
212define_id! {
213 DecisionId
215}
216
217define_id! {
218 BatchTrackingId
220}
221
222define_id! {
223 ThreadSummaryId
225}
226
227define_id! {
228 McpSessionId
230}
231
232define_id! {
233 EmailVerificationTokenId
235}
236
237define_id! {
238 PostEmbeddingId
240}
241
242define_id! {
243 DataExportId
249}
250
251define_id! {
252 RefreshTokenId
259}
260
261define_id! {
262 MessageId
270}
271
272define_id! {
273 ContentId
294}
295
296define_id! {
297 ModerationTargetId
315}
316
317impl From<PostId> for ModerationTargetId {
323 fn from(id: PostId) -> Self {
324 Self::from(*id.as_uuid())
325 }
326}
327
328impl From<CommentId> for ModerationTargetId {
329 fn from(id: CommentId) -> Self {
330 Self::from(*id.as_uuid())
331 }
332}
333
334impl From<MessageId> for ModerationTargetId {
335 fn from(id: MessageId) -> Self {
336 Self::from(*id.as_uuid())
337 }
338}
339
340impl From<AgentId> for ModerationTargetId {
341 fn from(id: AgentId) -> Self {
342 Self::from(*id.as_uuid())
343 }
344}
345
346impl From<ContentId> for ModerationTargetId {
349 fn from(id: ContentId) -> Self {
350 Self::from(*id.as_uuid())
351 }
352}
353
354impl From<PostId> for ContentId {
359 fn from(id: PostId) -> Self {
360 Self::from(*id.as_uuid())
361 }
362}
363
364impl From<CommentId> for ContentId {
365 fn from(id: CommentId) -> Self {
366 Self::from(*id.as_uuid())
367 }
368}
369
370impl From<PostOrCommentId> for ContentId {
371 fn from(id: PostOrCommentId) -> Self {
372 Self::from(id.as_uuid())
373 }
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
404pub enum PostOrCommentId {
405 Post(PostId),
406 Comment(CommentId),
407}
408
409impl PostOrCommentId {
410 pub fn as_uuid(&self) -> Uuid {
412 match self {
413 PostOrCommentId::Post(id) => *id.as_uuid(),
414 PostOrCommentId::Comment(id) => *id.as_uuid(),
415 }
416 }
417
418 pub fn is_post(&self) -> bool {
420 matches!(self, PostOrCommentId::Post(_))
421 }
422
423 pub fn is_comment(&self) -> bool {
425 matches!(self, PostOrCommentId::Comment(_))
426 }
427
428 pub fn as_post(&self) -> Option<PostId> {
430 match self {
431 PostOrCommentId::Post(id) => Some(*id),
432 PostOrCommentId::Comment(_) => None,
433 }
434 }
435
436 pub fn as_comment(&self) -> Option<CommentId> {
438 match self {
439 PostOrCommentId::Comment(id) => Some(*id),
440 PostOrCommentId::Post(_) => None,
441 }
442 }
443
444 pub fn kind_str(&self) -> &'static str {
447 match self {
448 PostOrCommentId::Post(_) => "post",
449 PostOrCommentId::Comment(_) => "comment",
450 }
451 }
452}
453
454impl From<PostId> for PostOrCommentId {
455 fn from(id: PostId) -> Self {
456 PostOrCommentId::Post(id)
457 }
458}
459
460impl From<CommentId> for PostOrCommentId {
461 fn from(id: CommentId) -> Self {
462 PostOrCommentId::Comment(id)
463 }
464}
465
466impl std::fmt::Display for PostOrCommentId {
467 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
469 }
470}
471
472#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
478#[error(
479 "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
480)]
481pub struct GovernanceLogIdError(pub String);
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
489pub enum GovernanceLogPrefix {
490 Gov,
492 App,
494 Amd,
496 Key,
498 Rec,
500}
501
502impl GovernanceLogPrefix {
503 pub const ALL: [Self; 5] =
505 [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
506
507 pub fn as_str(&self) -> &'static str {
509 match self {
510 Self::Gov => "GOV",
511 Self::App => "APP",
512 Self::Amd => "AMD",
513 Self::Key => "KEY",
514 Self::Rec => "REC",
515 }
516 }
517}
518
519impl std::fmt::Display for GovernanceLogPrefix {
520 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
521 f.write_str(self.as_str())
522 }
523}
524
525impl std::str::FromStr for GovernanceLogPrefix {
526 type Err = GovernanceLogIdError;
527
528 fn from_str(s: &str) -> Result<Self, Self::Err> {
529 match s {
530 "GOV" => Ok(Self::Gov),
531 "APP" => Ok(Self::App),
532 "AMD" => Ok(Self::Amd),
533 "KEY" => Ok(Self::Key),
534 "REC" => Ok(Self::Rec),
535 _ => Err(GovernanceLogIdError(s.to_string())),
536 }
537 }
538}
539
540#[derive(
559 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
560)]
561#[serde(try_from = "String")]
562#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
563#[cfg_attr(feature = "sqlx", sqlx(transparent))]
564pub struct GovernanceLogId(String);
565
566impl GovernanceLogId {
567 pub fn as_str(&self) -> &str {
569 &self.0
570 }
571
572 pub fn into_inner(self) -> String {
574 self.0
575 }
576
577 pub fn is_citation_shaped(s: &str) -> bool {
585 Self::parts(s).is_some()
586 }
587
588 pub fn prefix(&self) -> GovernanceLogPrefix {
590 Self::parts(&self.0)
591 .expect("a GovernanceLogId is citation-shaped by construction")
592 .0
593 }
594
595 pub fn normalize(s: &str) -> Option<String> {
601 let mut parts = s.trim().split(['-', '.', '/', ' ']);
602 let (prefix, year, serial) =
603 (parts.next()?, parts.next()?, parts.next()?);
604 if parts.next().is_some() {
605 return None;
606 }
607 let prefix: GovernanceLogPrefix =
608 prefix.to_ascii_uppercase().parse().ok()?;
609 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
610 (year.len() == 4
611 && digits(year)
612 && (1..=4).contains(&serial.len())
613 && digits(serial))
614 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
615 }
616
617 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
618 let parts: Vec<&str> = s.split('-').collect();
619 let [prefix, year, serial] = parts.as_slice() else {
620 return None;
621 };
622 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
623 (year.len() == 4
624 && serial.len() == 4
625 && year.chars().all(|c| c.is_ascii_digit())
626 && serial.chars().all(|c| c.is_ascii_digit()))
627 .then_some((prefix, year, serial))
628 }
629}
630
631impl std::fmt::Display for GovernanceLogId {
632 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
633 f.write_str(&self.0)
634 }
635}
636
637impl AsRef<str> for GovernanceLogId {
638 fn as_ref(&self) -> &str {
639 &self.0
640 }
641}
642
643impl std::str::FromStr for GovernanceLogId {
644 type Err = GovernanceLogIdError;
645
646 fn from_str(s: &str) -> Result<Self, Self::Err> {
649 Self::normalize(s)
650 .map(Self)
651 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
652 }
653}
654
655impl TryFrom<String> for GovernanceLogId {
656 type Error = GovernanceLogIdError;
657
658 fn try_from(s: String) -> Result<Self, Self::Error> {
659 if Self::is_citation_shaped(&s) {
660 return Ok(Self(s));
661 }
662 s.parse()
663 }
664}
665
666impl From<GovernanceLogId> for String {
667 fn from(id: GovernanceLogId) -> Self {
668 id.0
669 }
670}
671
672#[cfg(feature = "schemars")]
678impl schemars::JsonSchema for GovernanceLogId {
679 fn inline_schema() -> bool {
680 true
681 }
682
683 fn schema_name() -> std::borrow::Cow<'static, str> {
684 std::borrow::Cow::Borrowed("GovernanceLogId")
685 }
686
687 fn schema_id() -> std::borrow::Cow<'static, str> {
688 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
689 }
690
691 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
692 schemars::json_schema!({
693 "type": "string",
694 "pattern": GOVERNANCE_LOG_ID_PATTERN,
695 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
696 (Council decision or policy change), \
697 \"APP-2026-0003\" (appeals ruling), \
698 \"AMD-2026-0001\" (amendment to an earlier \
699 entry), \"KEY-2026-0001\" (signing key \
700 rotation) or \"REC-2026-0001\" (a Steward's \
701 record of an operational act).",
702 })
703 }
704}
705
706#[derive(
715 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
716)]
717#[serde(try_from = "String")]
718#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
719#[cfg_attr(feature = "sqlx", sqlx(transparent))]
720pub struct OAuthClientId(String);
721
722#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
724#[error("not an OAuth client id: {0:?}")]
725pub struct OAuthClientIdError(pub String);
726
727impl OAuthClientId {
728 pub fn generate() -> Self {
730 Self(Uuid::new_v4().to_string())
731 }
732
733 pub fn as_str(&self) -> &str {
735 &self.0
736 }
737
738 pub fn into_inner(self) -> String {
740 self.0
741 }
742
743 fn is_valid(s: &str) -> bool {
744 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
745 }
746}
747
748impl std::fmt::Display for OAuthClientId {
749 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750 f.write_str(&self.0)
751 }
752}
753
754impl AsRef<str> for OAuthClientId {
755 fn as_ref(&self) -> &str {
756 &self.0
757 }
758}
759
760impl std::str::FromStr for OAuthClientId {
761 type Err = OAuthClientIdError;
762
763 fn from_str(s: &str) -> Result<Self, Self::Err> {
764 Self::try_from(s.to_string())
765 }
766}
767
768impl TryFrom<String> for OAuthClientId {
769 type Error = OAuthClientIdError;
770
771 fn try_from(s: String) -> Result<Self, Self::Error> {
772 if Self::is_valid(&s) {
773 Ok(Self(s))
774 } else {
775 Err(OAuthClientIdError(s))
776 }
777 }
778}
779
780impl From<OAuthClientId> for String {
781 fn from(id: OAuthClientId) -> Self {
782 id.0
783 }
784}
785
786#[cfg(feature = "schemars")]
789impl schemars::JsonSchema for OAuthClientId {
790 fn inline_schema() -> bool {
791 true
792 }
793
794 fn schema_name() -> std::borrow::Cow<'static, str> {
795 std::borrow::Cow::Borrowed("OAuthClientId")
796 }
797
798 fn schema_id() -> std::borrow::Cow<'static, str> {
799 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
800 }
801
802 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
803 schemars::json_schema!({
804 "type": "string",
805 "minLength": 1,
806 "maxLength": 255,
807 "description": "OAuth client_id issued at dynamic client registration.",
808 })
809 }
810}
811
812#[derive(Debug, Clone, PartialEq, Eq, Hash)]
819pub enum PlatformDoc {
820 Constitution,
822 GovernanceProtocol,
825 Prompts,
827 Prompt(PromptName),
829}
830
831impl PlatformDoc {
832 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
834 match self {
835 PlatformDoc::Constitution => "constitution".into(),
836 PlatformDoc::GovernanceProtocol => "protocol".into(),
837 PlatformDoc::Prompts => "prompts".into(),
838 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
839 }
840 }
841
842 pub fn title(&self) -> std::borrow::Cow<'static, str> {
845 match self {
846 PlatformDoc::Constitution => "The Agora Constitution".into(),
847 PlatformDoc::GovernanceProtocol => {
848 "The Agora Governance Protocol".into()
849 }
850 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
851 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
852 }
853 }
854
855 pub fn as_prompt(&self) -> Option<&PromptName> {
857 match self {
858 PlatformDoc::Prompt(name) => Some(name),
859 _ => None,
860 }
861 }
862}
863
864impl std::fmt::Display for PlatformDoc {
865 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
866 f.write_str(&self.slug())
867 }
868}
869
870#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
872#[error(
873 "not a platform document (expected \"constitution\", \"protocol\", \
874 \"prompts\" or \"prompt:<name>\"): {0:?}"
875)]
876pub struct PlatformDocError(pub String);
877
878const PROMPT_PREFIXES: [&str; 4] =
882 ["prompt:", "prompt/", "prompts/", "prompts:"];
883
884impl std::str::FromStr for PlatformDoc {
885 type Err = PlatformDocError;
886
887 fn from_str(s: &str) -> Result<Self, Self::Err> {
891 if s.eq_ignore_ascii_case("constitution") {
892 Ok(PlatformDoc::Constitution)
893 } else if s.eq_ignore_ascii_case("protocol")
894 || s.eq_ignore_ascii_case("governance-protocol")
895 {
896 Ok(PlatformDoc::GovernanceProtocol)
897 } else if s.eq_ignore_ascii_case("prompts")
898 || s.eq_ignore_ascii_case("prompt")
899 {
900 Ok(PlatformDoc::Prompts)
901 } else {
902 PROMPT_PREFIXES
903 .iter()
904 .find_map(|prefix| {
905 let head = s.get(..prefix.len())?;
906 head.eq_ignore_ascii_case(prefix)
907 .then(|| s[prefix.len()..].parse().ok())
908 .flatten()
909 })
910 .map(PlatformDoc::Prompt)
911 .ok_or_else(|| PlatformDocError(s.to_string()))
912 }
913 }
914}
915
916impl Serialize for PlatformDoc {
917 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
918 s.collect_str(self)
919 }
920}
921
922impl<'de> Deserialize<'de> for PlatformDoc {
923 fn deserialize<D: serde::Deserializer<'de>>(
924 d: D,
925 ) -> Result<Self, D::Error> {
926 let raw = String::deserialize(d)?;
927 raw.parse().map_err(serde::de::Error::custom)
928 }
929}
930
931#[cfg(feature = "schemars")]
934impl schemars::JsonSchema for PlatformDoc {
935 fn inline_schema() -> bool {
936 true
937 }
938
939 fn schema_name() -> std::borrow::Cow<'static, str> {
940 std::borrow::Cow::Borrowed("PlatformDoc")
941 }
942
943 fn schema_id() -> std::borrow::Cow<'static, str> {
944 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
945 }
946
947 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
948 schemars::json_schema!({
949 "type": "string",
950 "pattern": PLATFORM_DOC_PATTERN,
951 "description": "A platform document: \"constitution\", \
952 \"protocol\" (the Governance Protocol), \
953 \"prompts\" (the index of published model \
954 prompts), or \"prompt:<name>\" (one prompt, \
955 e.g. \"prompt:tier2_reviewer\").",
956 })
957 }
958}
959
960#[derive(
968 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
969)]
970#[serde(try_from = "String")]
971pub struct PromptName(String);
972
973#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
975#[error(
976 "not a prompt name (lowercase letters, digits and `_`, e.g. \
977 \"tier2_reviewer\"): {0:?}"
978)]
979pub struct PromptNameError(pub String);
980
981impl PromptName {
982 pub const MAX_LEN: usize = 64;
984
985 pub fn as_str(&self) -> &str {
987 &self.0
988 }
989
990 fn normalize(s: &str) -> Option<String> {
991 let s = s.trim();
992 let s = match s.len().checked_sub(3) {
993 Some(n)
994 if s.is_char_boundary(n)
995 && s[n..].eq_ignore_ascii_case(".md") =>
996 {
997 &s[..n]
998 }
999 _ => s,
1000 };
1001 let name: String = s
1002 .chars()
1003 .map(|c| match c {
1004 '-' => '_',
1005 c => c.to_ascii_lowercase(),
1006 })
1007 .collect();
1008 let mut chars = name.chars();
1009 let valid = name.len() <= Self::MAX_LEN
1010 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1011 && chars.all(|c| {
1012 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1013 });
1014 valid.then_some(name)
1015 }
1016}
1017
1018impl std::fmt::Display for PromptName {
1019 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1020 f.write_str(&self.0)
1021 }
1022}
1023
1024impl AsRef<str> for PromptName {
1025 fn as_ref(&self) -> &str {
1026 &self.0
1027 }
1028}
1029
1030impl std::str::FromStr for PromptName {
1031 type Err = PromptNameError;
1032
1033 fn from_str(s: &str) -> Result<Self, Self::Err> {
1034 Self::normalize(s)
1035 .map(Self)
1036 .ok_or_else(|| PromptNameError(s.to_string()))
1037 }
1038}
1039
1040impl TryFrom<String> for PromptName {
1041 type Error = PromptNameError;
1042
1043 fn try_from(s: String) -> Result<Self, Self::Error> {
1044 s.parse()
1045 }
1046}
1047
1048impl From<PromptName> for PlatformDoc {
1049 fn from(name: PromptName) -> Self {
1050 PlatformDoc::Prompt(name)
1051 }
1052}
1053
1054#[cfg(feature = "schemars")]
1055impl schemars::JsonSchema for PromptName {
1056 fn inline_schema() -> bool {
1057 true
1058 }
1059
1060 fn schema_name() -> std::borrow::Cow<'static, str> {
1061 std::borrow::Cow::Borrowed("PromptName")
1062 }
1063
1064 fn schema_id() -> std::borrow::Cow<'static, str> {
1065 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1066 }
1067
1068 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1069 schemars::json_schema!({
1070 "type": "string",
1071 "pattern": PROMPT_NAME_PATTERN,
1072 "maxLength": PromptName::MAX_LEN,
1073 "description": "A published model prompt's name, e.g. \
1074 \"tier2_reviewer\". `get_content(\"prompts\")` \
1075 lists them.",
1076 })
1077 }
1078}
1079
1080#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1096pub struct ContentIdPrefix(u32);
1097
1098#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1100#[error(
1101 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1102)]
1103pub struct ContentIdPrefixError(pub String);
1104
1105impl ContentIdPrefix {
1106 pub const LEN: usize = 8;
1108
1109 pub fn of(id: &Uuid) -> Self {
1111 Self((id.as_u128() >> 96) as u32)
1112 }
1113
1114 pub fn bounds(&self) -> (Uuid, Uuid) {
1116 let lo = (self.0 as u128) << 96;
1117 let hi = lo | ((1u128 << 96) - 1);
1118 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1119 }
1120
1121 pub fn matches(&self, id: &Uuid) -> bool {
1123 Self::of(id) == *self
1124 }
1125}
1126
1127impl std::fmt::Display for ContentIdPrefix {
1128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1129 write!(f, "{:08x}", self.0)
1130 }
1131}
1132
1133impl std::str::FromStr for ContentIdPrefix {
1134 type Err = ContentIdPrefixError;
1135
1136 fn from_str(s: &str) -> Result<Self, Self::Err> {
1137 let t = s.trim();
1138 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1139 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1141 } else {
1142 Err(ContentIdPrefixError(s.to_string()))
1143 }
1144 }
1145}
1146
1147impl TryFrom<String> for ContentIdPrefix {
1148 type Error = ContentIdPrefixError;
1149
1150 fn try_from(s: String) -> Result<Self, Self::Error> {
1151 s.parse()
1152 }
1153}
1154
1155impl Serialize for ContentIdPrefix {
1156 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1157 s.collect_str(self)
1158 }
1159}
1160
1161impl<'de> Deserialize<'de> for ContentIdPrefix {
1162 fn deserialize<D: serde::Deserializer<'de>>(
1163 d: D,
1164 ) -> Result<Self, D::Error> {
1165 let raw = String::deserialize(d)?;
1166 raw.parse().map_err(serde::de::Error::custom)
1167 }
1168}
1169
1170impl From<ContentId> for ContentIdPrefix {
1171 fn from(id: ContentId) -> Self {
1172 Self::of(id.as_uuid())
1173 }
1174}
1175
1176#[cfg(feature = "schemars")]
1177impl schemars::JsonSchema for ContentIdPrefix {
1178 fn inline_schema() -> bool {
1179 true
1180 }
1181
1182 fn schema_name() -> std::borrow::Cow<'static, str> {
1183 std::borrow::Cow::Borrowed("ContentIdPrefix")
1184 }
1185
1186 fn schema_id() -> std::borrow::Cow<'static, str> {
1187 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1188 }
1189
1190 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1191 schemars::json_schema!({
1192 "type": "string",
1193 "pattern": CONTENT_ID_PREFIX_PATTERN,
1194 "description": "A short post or comment id: the first eight hex \
1195 digits of its UUID, e.g. \"7ad26ccd\".",
1196 })
1197 }
1198}
1199
1200#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1203pub struct ContentRefError(pub String);
1204
1205impl ContentRefError {
1206 pub fn leading_ref(&self) -> Option<ContentRef> {
1210 const UUID_LEN: usize = 36;
1211 const CITATION_LEN: usize = "GOV-2026-0006".len();
1212 let s = self.0.trim_start();
1213 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1215 let head = s.get(..prefix.len())?;
1216 head.eq_ignore_ascii_case(prefix).then(|| {
1217 prefix.len()
1218 + s[prefix.len()..]
1219 .find(|c: char| {
1220 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1221 })
1222 .unwrap_or(s.len() - prefix.len())
1223 })
1224 });
1225 prompt_len
1227 .into_iter()
1228 .chain([
1229 UUID_LEN,
1230 CITATION_LEN,
1231 "constitution".len(),
1232 "protocol".len(),
1233 "prompts".len(),
1234 ])
1235 .filter(|&n| s.len() > n && s.is_char_boundary(n))
1236 .find_map(|n| {
1240 s[..n]
1241 .parse()
1242 .ok()
1243 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
1244 })
1245 }
1246}
1247
1248impl std::fmt::Display for ContentRefError {
1249 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1250 write!(
1251 f,
1252 "not a content reference (expected a post/comment UUID or its \
1253 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1254 like \"constitution\", \"protocol\", \"prompts\" or \
1255 \"prompt:<name>\"): {:?}",
1256 self.0
1257 )?;
1258 if let Some(id) = self.leading_ref() {
1259 write!(
1260 f,
1261 ". It starts with the valid id {id} followed by extra text; \
1262 pass only the id"
1263 )?;
1264 }
1265 Ok(())
1266 }
1267}
1268
1269#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1287pub enum ContentRef {
1288 Content(ContentId),
1290 ContentPrefix(ContentIdPrefix),
1293 Governance(GovernanceLogId),
1295 Document(PlatformDoc),
1297}
1298
1299impl ContentRef {
1300 pub fn as_content(&self) -> Option<ContentId> {
1302 match self {
1303 ContentRef::Content(id) => Some(*id),
1304 _ => None,
1305 }
1306 }
1307
1308 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1310 match self {
1311 ContentRef::Governance(id) => Some(id),
1312 _ => None,
1313 }
1314 }
1315
1316 pub fn as_document(&self) -> Option<&PlatformDoc> {
1318 match self {
1319 ContentRef::Document(doc) => Some(doc),
1320 _ => None,
1321 }
1322 }
1323
1324 pub fn is_governance(&self) -> bool {
1326 matches!(self, ContentRef::Governance(_))
1327 }
1328
1329 pub fn kind_str(&self) -> &'static str {
1332 match self {
1333 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
1334 ContentRef::Governance(_) => "governance",
1335 ContentRef::Document(_) => "document",
1336 }
1337 }
1338}
1339
1340impl std::fmt::Display for ContentRef {
1341 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1342 match self {
1343 ContentRef::Content(id) => id.fmt(f),
1344 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
1345 ContentRef::Governance(id) => id.fmt(f),
1346 ContentRef::Document(doc) => doc.fmt(f),
1347 }
1348 }
1349}
1350
1351impl std::str::FromStr for ContentRef {
1352 type Err = ContentRefError;
1353
1354 fn from_str(s: &str) -> Result<Self, Self::Err> {
1355 if let Ok(id) = s.parse::<ContentId>() {
1356 return Ok(ContentRef::Content(id));
1357 }
1358 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
1359 return Ok(ContentRef::ContentPrefix(prefix));
1360 }
1361 if let Ok(id) = s.parse::<GovernanceLogId>() {
1362 return Ok(ContentRef::Governance(id));
1363 }
1364 if let Ok(doc) = s.parse::<PlatformDoc>() {
1365 return Ok(ContentRef::Document(doc));
1366 }
1367 Err(ContentRefError(s.to_string()))
1368 }
1369}
1370
1371impl TryFrom<String> for ContentRef {
1372 type Error = ContentRefError;
1373
1374 fn try_from(s: String) -> Result<Self, Self::Error> {
1375 s.parse()
1376 }
1377}
1378
1379impl From<ContentId> for ContentRef {
1380 fn from(id: ContentId) -> Self {
1381 ContentRef::Content(id)
1382 }
1383}
1384
1385impl From<PostId> for ContentRef {
1386 fn from(id: PostId) -> Self {
1387 ContentRef::Content(id.into())
1388 }
1389}
1390
1391impl From<CommentId> for ContentRef {
1392 fn from(id: CommentId) -> Self {
1393 ContentRef::Content(id.into())
1394 }
1395}
1396
1397impl From<ContentIdPrefix> for ContentRef {
1398 fn from(prefix: ContentIdPrefix) -> Self {
1399 ContentRef::ContentPrefix(prefix)
1400 }
1401}
1402
1403impl From<GovernanceLogId> for ContentRef {
1404 fn from(id: GovernanceLogId) -> Self {
1405 ContentRef::Governance(id)
1406 }
1407}
1408
1409impl From<PlatformDoc> for ContentRef {
1410 fn from(doc: PlatformDoc) -> Self {
1411 ContentRef::Document(doc)
1412 }
1413}
1414
1415impl Serialize for ContentRef {
1416 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1417 s.collect_str(self)
1418 }
1419}
1420
1421impl<'de> Deserialize<'de> for ContentRef {
1422 fn deserialize<D: serde::Deserializer<'de>>(
1423 d: D,
1424 ) -> Result<Self, D::Error> {
1425 let raw = String::deserialize(d)?;
1426 raw.parse().map_err(serde::de::Error::custom)
1427 }
1428}
1429
1430#[cfg(feature = "schemars")]
1435impl schemars::JsonSchema for ContentRef {
1436 fn inline_schema() -> bool {
1437 true
1438 }
1439
1440 fn schema_name() -> std::borrow::Cow<'static, str> {
1441 std::borrow::Cow::Borrowed("ContentRef")
1442 }
1443
1444 fn schema_id() -> std::borrow::Cow<'static, str> {
1445 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1446 }
1447
1448 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1449 schemars::json_schema!({
1450 "type": "string",
1451 "pattern": CONTENT_REF_PATTERN,
1452 "description": "A post or comment UUID, or its first eight \
1453 hex digits (e.g. \"7ad26ccd\"); a governance log id \
1454 such as \"GOV-2026-0006\" (Council decision) \
1455 or \"APP-2026-0003\" (appeals ruling); or a \
1456 document slug — \"constitution\", \
1457 \"protocol\" (the Governance Protocol), \
1458 \"prompts\" (the index of the model prompts \
1459 moderation, appeals and the Council run on) or \
1460 \"prompt:<name>\" (one of them).",
1461 })
1462 }
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467 use super::*;
1468
1469 #[test]
1470 fn oauth_client_id_round_trips() {
1471 let id = OAuthClientId::generate();
1472 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1473 let json = serde_json::to_string(&id).unwrap();
1474 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1475 }
1476
1477 #[test]
1478 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1479 assert!("".parse::<OAuthClientId>().is_err());
1480 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1481 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1482 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1483 }
1484
1485 #[test]
1486 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
1487 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
1488 for (input, lead) in [
1489 (format!("{uuid} and the safe-space proposal"), uuid),
1490 (format!("{uuid}b0518e42"), uuid),
1491 (
1492 "GOV-2026-0006 (the ratification)".to_string(),
1493 "GOV-2026-0006",
1494 ),
1495 ("protocol, section 3".to_string(), "protocol"),
1496 ("prompts, please".to_string(), "prompts"),
1497 (
1498 "prompt:tier2_reviewer and the juror's".to_string(),
1499 "prompt:tier2_reviewer",
1500 ),
1501 (
1502 "prompts/appeals_juror.md, line 3".to_string(),
1503 "prompt:appeals_juror",
1504 ),
1505 ] {
1506 let err = input.parse::<ContentRef>().unwrap_err();
1507 assert_eq!(
1508 err.leading_ref(),
1509 Some(lead.parse().unwrap()),
1510 "{input}"
1511 );
1512 let msg = err.to_string();
1513 assert!(
1514 msg.contains(&format!("valid id {lead} followed")),
1515 "{msg}"
1516 );
1517 }
1518
1519 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
1521 let err = short.parse::<ContentRef>().unwrap_err();
1522 assert_eq!(err.leading_ref(), None);
1523 assert!(!err.to_string().contains("followed by"));
1524 assert_eq!(
1526 "GOV-2026-1".parse::<ContentRef>().unwrap(),
1527 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
1528 );
1529 }
1530
1531 #[test]
1532 fn citations_normalize_as_written() {
1533 for (written, canonical) in [
1534 ("GOV-2026-0006", "GOV-2026-0006"),
1535 ("GOV-2026.6", "GOV-2026-0006"),
1536 ("gov-2026-6", "GOV-2026-0006"),
1537 ("GOV 2026 6", "GOV-2026-0006"),
1538 ("app/2026/03", "APP-2026-0003"),
1539 (" REC-2026-0002 ", "REC-2026-0002"),
1540 ] {
1541 let id: GovernanceLogId = written.parse().unwrap();
1542 assert_eq!(id.as_str(), canonical, "{written}");
1543 let json: GovernanceLogId =
1544 serde_json::from_value(serde_json::json!(written)).unwrap();
1545 assert_eq!(json.as_str(), canonical, "{written} via serde");
1546 let content: ContentRef = written.parse().unwrap();
1547 assert_eq!(content, ContentRef::Governance(id), "{written}");
1548 }
1549 for rejected in [
1550 "GOV-2026-N",
1551 "GOV-2026/GOVG=6",
1552 "GOV-2026-00006",
1553 "GOV-26-0006",
1554 "GOV-2026-",
1555 "XYZ-2026-0006",
1556 "GOV-2026-0006-1",
1557 ] {
1558 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
1559 }
1560 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
1562 }
1563
1564 #[test]
1565 fn content_ref_parses_document_slugs() {
1566 assert_eq!(
1567 "constitution".parse(),
1568 Ok(ContentRef::Document(PlatformDoc::Constitution))
1569 );
1570 assert_eq!(
1571 "protocol".parse(),
1572 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1573 );
1574 assert_eq!(
1576 "governance-protocol".parse(),
1577 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1578 );
1579 assert_eq!(
1580 "Constitution".parse(),
1581 Ok(ContentRef::Document(PlatformDoc::Constitution))
1582 );
1583 assert!("proto".parse::<ContentRef>().is_err());
1584 }
1585
1586 #[test]
1587 fn content_ref_parses_prompt_slugs() {
1588 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
1589 for written in [
1590 "prompt:appeals_juror",
1591 "Prompt:Appeals_Juror",
1592 "prompt/appeals-juror",
1593 "prompts/appeals_juror.md",
1594 "prompts:appeals_juror",
1595 ] {
1596 assert_eq!(
1597 written.parse(),
1598 Ok(ContentRef::Document(juror.clone())),
1599 "{written}"
1600 );
1601 }
1602 assert_eq!(juror.to_string(), "prompt:appeals_juror");
1603 for index in ["prompts", "Prompts", "prompt"] {
1604 assert_eq!(
1605 index.parse(),
1606 Ok(ContentRef::Document(PlatformDoc::Prompts)),
1607 "{index}"
1608 );
1609 }
1610 for rejected in [
1611 "prompt:",
1612 "prompt:2fast",
1613 "prompt:tier 2",
1614 "prompt:../secrets",
1615 "prompt:tier2_reviewer/x",
1616 "promptly",
1617 ] {
1618 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
1619 }
1620 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
1621 assert!(long.parse::<ContentRef>().is_err());
1622 }
1623
1624 #[test]
1625 fn content_id_prefix_parses_eight_hex_digits_leniently() {
1626 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
1627 assert_eq!(p.to_string(), "7ad26ccd");
1628 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
1629 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
1630 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
1631 }
1632 }
1633
1634 #[test]
1635 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
1636 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
1637 let p = ContentIdPrefix::of(&id);
1638 assert_eq!(p.to_string(), "7ad26ccd");
1639 assert!(p.matches(&id));
1640 let (lo, hi) = p.bounds();
1641 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
1642 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
1643 assert!(lo <= id && id <= hi);
1644 let next: Uuid =
1645 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
1646 assert!(!p.matches(&next) && next > hi);
1647 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
1649 assert_eq!(top.bounds().1, Uuid::max());
1650 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
1651 assert_eq!(bottom.bounds().0, Uuid::nil());
1652 }
1653
1654 #[test]
1655 fn content_ref_reads_a_short_id_and_only_eight_digits() {
1656 let r: ContentRef = "7ad26ccd".parse().unwrap();
1657 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
1658 assert_eq!(r.kind_str(), "content");
1659 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
1660 let full: ContentRef =
1662 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
1663 assert!(matches!(full, ContentRef::Content(_)));
1664 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
1669 assert_eq!(err.leading_ref(), None);
1670 }
1671
1672 #[test]
1673 fn content_ref_pattern_admits_short_ids() {
1674 let short = CONTENT_ID_PREFIX_PATTERN
1675 .strip_prefix('^')
1676 .and_then(|p| p.strip_suffix('$'))
1677 .unwrap();
1678 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
1679 }
1680
1681 #[test]
1683 fn document_patterns_agree() {
1684 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
1685 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
1686 let name = PROMPT_NAME_PATTERN
1687 .strip_prefix('^')
1688 .and_then(|p| p.strip_suffix('$'))
1689 .unwrap();
1690 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
1691 }
1692
1693 #[test]
1694 fn platform_doc_serde_uses_the_canonical_slug() {
1695 let json =
1696 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
1697 assert_eq!(json, "\"protocol\"");
1698 let doc: PlatformDoc =
1699 serde_json::from_str("\"governance-protocol\"").unwrap();
1700 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
1701 }
1702
1703 #[test]
1704 fn ids_are_unique() {
1705 let a = AgentId::new();
1706 let b = AgentId::new();
1707 assert_ne!(a, b);
1708 }
1709
1710 #[test]
1711 fn serde_round_trip() {
1712 let id = PostId::new();
1713 let json = serde_json::to_string(&id).unwrap();
1714 let deserialized: PostId = serde_json::from_str(&json).unwrap();
1715 assert_eq!(id, deserialized);
1716 }
1717
1718 #[test]
1719 fn display_shows_uuid() {
1720 let id = CommunityId::new();
1721 let display = id.to_string();
1722 assert_eq!(display.len(), 36);
1724 assert!(display.contains('-'));
1725 }
1726
1727 #[test]
1728 fn from_uuid_round_trip() {
1729 let uuid = Uuid::new_v4();
1730 let id = AgentId::from(uuid);
1731 let back: Uuid = id.into();
1732 assert_eq!(uuid, back);
1733 }
1734
1735 #[test]
1739 fn every_id_round_trips_through_its_own_display() {
1740 let agent = AgentId::new();
1741 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
1742
1743 let action = ModerationActionId::new();
1744 assert_eq!(
1745 action.to_string().parse::<ModerationActionId>().unwrap(),
1746 action
1747 );
1748
1749 let content = ContentId::new();
1750 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
1751 }
1752
1753 #[test]
1754 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
1755 assert!("not-a-uuid".parse::<ContentId>().is_err());
1756 assert!("".parse::<ContentId>().is_err());
1757 }
1758
1759 #[test]
1764 fn content_id_is_wire_compatible_with_a_bare_uuid() {
1765 let uuid = Uuid::new_v4();
1766 let typed = ContentId::from(uuid);
1767 assert_eq!(
1768 serde_json::to_string(&typed).unwrap(),
1769 serde_json::to_string(&uuid).unwrap()
1770 );
1771 }
1772
1773 #[test]
1776 fn every_moderation_target_narrows_losslessly() {
1777 let uuid = Uuid::new_v4();
1778
1779 for (label, got) in [
1780 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
1781 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
1782 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
1783 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
1784 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
1785 ] {
1786 assert_eq!(
1787 got.as_uuid(),
1788 &uuid,
1789 "{label} -> ModerationTargetId lost the uuid"
1790 );
1791 }
1792 }
1793
1794 #[test]
1798 fn resolved_ids_narrow_to_content_id_losslessly() {
1799 let uuid = Uuid::new_v4();
1800
1801 assert_eq!(
1802 ContentId::from(PostId::from(uuid)).as_uuid(),
1803 &uuid,
1804 "PostId -> ContentId lost the uuid"
1805 );
1806 assert_eq!(
1807 ContentId::from(CommentId::from(uuid)).as_uuid(),
1808 &uuid,
1809 "CommentId -> ContentId lost the uuid"
1810 );
1811 assert_eq!(
1812 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
1813 .as_uuid(),
1814 &uuid,
1815 "PostOrCommentId -> ContentId lost the uuid"
1816 );
1817 }
1818
1819 #[test]
1820 fn json_is_plain_uuid_string() {
1821 let uuid = Uuid::new_v4();
1822 let id = AgentId::from(uuid);
1823 let id_json = serde_json::to_string(&id).unwrap();
1825 let uuid_json = serde_json::to_string(&uuid).unwrap();
1826 assert_eq!(id_json, uuid_json);
1827 }
1828
1829 #[cfg(feature = "schemars")]
1834 #[test]
1835 fn id_json_schema_is_inlined() {
1836 use schemars::JsonSchema;
1837
1838 assert!(
1839 <PostId as JsonSchema>::inline_schema(),
1840 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
1841 );
1842 assert!(<AgentId as JsonSchema>::inline_schema());
1843 assert!(<CommentId as JsonSchema>::inline_schema());
1844 assert!(<CommunityId as JsonSchema>::inline_schema());
1845 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
1846 assert!(<ContentRef as JsonSchema>::inline_schema());
1847
1848 #[derive(schemars::JsonSchema)]
1852 #[allow(dead_code)]
1853 struct Container {
1854 post_id: PostId,
1856 agent_id: Option<AgentId>,
1858 gov_id: GovernanceLogId,
1860 maybe_gov_id: Option<GovernanceLogId>,
1862 content_ref: ContentRef,
1864 maybe_content_ref: Option<ContentRef>,
1866 }
1867
1868 let schema = schemars::schema_for!(Container);
1869 let value = serde_json::to_value(&schema).unwrap();
1870
1871 assert!(
1873 value.get("$defs").is_none(),
1874 "no $defs should be emitted for ID-only container; got schema: {value}"
1875 );
1876
1877 let post_id = &value["properties"]["post_id"];
1879 assert!(
1880 post_id.get("$ref").is_none(),
1881 "post_id must not be a $ref; got: {post_id}"
1882 );
1883 assert_eq!(post_id["type"], "string");
1884 assert_eq!(post_id["format"], "uuid");
1885
1886 let agent_id = &value["properties"]["agent_id"];
1891 assert!(
1892 agent_id.get("$ref").is_none(),
1893 "agent_id must not be a $ref; got: {agent_id}"
1894 );
1895 let agent_id_str = agent_id.to_string();
1896 assert!(
1897 !agent_id_str.contains("$ref"),
1898 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
1899 );
1900 assert!(
1901 agent_id_str.contains("\"format\":\"uuid\""),
1902 "agent_id should still carry format=uuid; got: {agent_id}"
1903 );
1904
1905 for field in
1910 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
1911 {
1912 let f = &value["properties"][field];
1913 assert!(
1914 !f.to_string().contains("$ref"),
1915 "{field} must contain no $ref anywhere; got: {f}"
1916 );
1917 }
1918 assert_eq!(value["properties"]["gov_id"]["type"], "string");
1919 assert_eq!(
1920 value["properties"]["gov_id"]["pattern"],
1921 GOVERNANCE_LOG_ID_PATTERN
1922 );
1923 assert!(
1924 value["properties"]["maybe_gov_id"]
1925 .to_string()
1926 .contains("GOV|APP"),
1927 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
1928 value["properties"]["maybe_gov_id"]
1929 );
1930 assert_eq!(value["properties"]["content_ref"]["type"], "string");
1931 }
1932
1933 #[test]
1934 fn governance_log_id_accepts_only_citation_shapes() {
1935 for (good, prefix) in [
1936 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
1937 ("APP-2026-0003", GovernanceLogPrefix::App),
1938 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
1939 ("KEY-2026-0001", GovernanceLogPrefix::Key),
1940 ("REC-2026-0001", GovernanceLogPrefix::Rec),
1941 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
1942 ] {
1943 let id = good.parse::<GovernanceLogId>().unwrap();
1944 assert_eq!(id.as_str(), good, "{good} should parse");
1945 assert_eq!(id.prefix(), prefix);
1946 assert_eq!(id.prefix().as_str(), &good[..3]);
1947 }
1948 assert_eq!(
1949 GovernanceLogPrefix::ALL
1950 .map(|p| p.to_string())
1951 .concat()
1952 .len(),
1953 3 * GovernanceLogPrefix::ALL.len()
1954 );
1955 assert_eq!(
1957 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
1958 "GOV-2026-0006"
1959 );
1960 assert_eq!(
1961 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
1962 "GOV-2026-0006"
1963 );
1964 for bad in [
1965 "",
1966 "GOV-26-0006",
1967 "MOD-2026-0006",
1968 "GOV-2026-0006-1",
1969 "GOV-202X-0006",
1970 "3f1a0000-0000-0000-0000-000000000000",
1971 ] {
1972 assert!(
1973 bad.parse::<GovernanceLogId>().is_err(),
1974 "{bad:?} should not parse as a GovernanceLogId"
1975 );
1976 }
1977 }
1978
1979 #[test]
1983 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1984 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1985 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1986 let back: GovernanceLogId =
1987 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1988 assert_eq!(back, id);
1989 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1991 }
1992
1993 #[test]
1996 fn content_ref_round_trips_as_a_bare_string() {
1997 let uuid = Uuid::new_v4();
1998 let content = ContentRef::from(ContentId::from(uuid));
1999 assert_eq!(
2000 serde_json::to_value(&content).unwrap(),
2001 serde_json::json!(uuid.to_string())
2002 );
2003 assert_eq!(
2004 serde_json::from_value::<ContentRef>(serde_json::json!(
2005 uuid.to_string()
2006 ))
2007 .unwrap(),
2008 content
2009 );
2010
2011 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2012 assert_eq!(
2013 serde_json::to_value(&gov).unwrap(),
2014 serde_json::json!("APP-2026-0003")
2015 );
2016 assert_eq!(
2017 serde_json::from_value::<ContentRef>(serde_json::json!(
2018 "APP-2026-0003"
2019 ))
2020 .unwrap(),
2021 gov
2022 );
2023
2024 assert!(gov.is_governance());
2025 assert!(!content.is_governance());
2026 assert_eq!(gov.kind_str(), "governance");
2027 assert_eq!(content.kind_str(), "content");
2028 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2029 assert!(content.as_governance().is_none());
2030
2031 assert!("not-an-id".parse::<ContentRef>().is_err());
2033 assert!(
2034 serde_json::from_value::<ContentRef>(serde_json::json!(
2035 "not-an-id"
2036 ))
2037 .is_err()
2038 );
2039 }
2040
2041 #[test]
2043 fn every_readable_id_narrows_to_a_content_ref() {
2044 let uuid = Uuid::new_v4();
2045 for (label, got) in [
2046 ("PostId", ContentRef::from(PostId::from(uuid))),
2047 ("CommentId", ContentRef::from(CommentId::from(uuid))),
2048 ("ContentId", ContentRef::from(ContentId::from(uuid))),
2049 ] {
2050 assert_eq!(
2051 got,
2052 ContentRef::Content(ContentId::from(uuid)),
2053 "{label} -> ContentRef lost the uuid"
2054 );
2055 }
2056 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2057 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2058 }
2059
2060 #[test]
2063 fn string_shaped_ids_round_trip_through_display() {
2064 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2065 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
2066
2067 let r = ContentRef::Governance(gov);
2068 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2069
2070 let r = ContentRef::Content(ContentId::new());
2071 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2072 }
2073
2074 #[test]
2075 fn post_or_comment_post_variant() {
2076 let inner = PostId::new();
2077 let tagged = PostOrCommentId::Post(inner);
2078 assert!(tagged.is_post());
2079 assert!(!tagged.is_comment());
2080 assert_eq!(tagged.as_post(), Some(inner));
2081 assert_eq!(tagged.as_comment(), None);
2082 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2083 assert_eq!(tagged.kind_str(), "post");
2084 }
2085
2086 #[test]
2087 fn post_or_comment_comment_variant() {
2088 let inner = CommentId::new();
2089 let tagged = PostOrCommentId::Comment(inner);
2090 assert!(tagged.is_comment());
2091 assert!(!tagged.is_post());
2092 assert_eq!(tagged.as_comment(), Some(inner));
2093 assert_eq!(tagged.as_post(), None);
2094 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2095 assert_eq!(tagged.kind_str(), "comment");
2096 }
2097
2098 #[test]
2099 fn post_or_comment_from_conversions() {
2100 let post = PostId::new();
2101 let comment = CommentId::new();
2102 let via_post: PostOrCommentId = post.into();
2103 let via_comment: PostOrCommentId = comment.into();
2104 assert_eq!(via_post, PostOrCommentId::Post(post));
2105 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
2106 }
2107
2108 #[test]
2109 fn post_or_comment_display_is_kind_colon_uuid() {
2110 let post = PostId::new();
2111 let tagged = PostOrCommentId::Post(post);
2112 let rendered = tagged.to_string();
2113 assert!(rendered.starts_with("post:"));
2114 assert!(rendered.contains(&post.to_string()));
2115 }
2116}