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|prompts|prompt:[a-z][a-z0-9_]*)$";
29
30pub const PLATFORM_DOC_PATTERN: &str =
32 "^(constitution|protocol|prompts|prompt:[a-z][a-z0-9_]*)$";
33
34pub const PROMPT_NAME_PATTERN: &str = "^[a-z][a-z0-9_]*$";
36
37macro_rules! define_id {
38 ($(#[doc = $doc:expr])* $name:ident) => {
39 $(#[doc = $doc])*
40 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
41 #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
42 #[cfg_attr(feature = "sqlx", sqlx(transparent))]
43 pub struct $name(Uuid);
44
45 impl $name {
46 pub fn new() -> Self {
48 Self(Uuid::new_v4())
49 }
50
51 pub fn as_uuid(&self) -> &Uuid {
53 &self.0
54 }
55 }
56
57 impl Default for $name {
58 fn default() -> Self {
59 Self::new()
60 }
61 }
62
63 impl std::fmt::Display for $name {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 self.0.fmt(f)
66 }
67 }
68
69 impl From<Uuid> for $name {
70 fn from(uuid: Uuid) -> Self {
71 Self(uuid)
72 }
73 }
74
75 impl From<$name> for Uuid {
76 fn from(id: $name) -> Self {
77 id.0
78 }
79 }
80
81 impl std::str::FromStr for $name {
90 type Err = uuid::Error;
91
92 fn from_str(s: &str) -> Result<Self, Self::Err> {
93 s.parse::<Uuid>().map(Self)
94 }
95 }
96
97 #[cfg(feature = "schemars")]
104 impl schemars::JsonSchema for $name {
105 fn inline_schema() -> bool {
106 true
107 }
108
109 fn schema_name() -> std::borrow::Cow<'static, str> {
110 std::borrow::Cow::Borrowed(stringify!($name))
111 }
112
113 fn schema_id() -> std::borrow::Cow<'static, str> {
114 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
115 }
116
117 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
118 schemars::json_schema!({
119 "type": "string",
120 "format": "uuid",
121 "pattern": UUID_PATTERN,
122 })
123 }
124 }
125 };
126}
127
128define_id! {
129 AgentId
131}
132
133define_id! {
134 ReactorId
136}
137
138define_id! {
139 OperatorId
141}
142
143define_id! {
144 PostId
146}
147
148define_id! {
149 CommentId
151}
152
153define_id! {
154 CommunityId
156}
157
158define_id! {
159 VoteId
161}
162
163define_id! {
164 ModerationActionId
166}
167
168define_id! {
169 ModerationNoteId
176}
177
178define_id! {
179 PromptArchiveId
186}
187
188define_id! {
189 AppealId
191}
192
193define_id! {
194 FlagId
196}
197
198define_id! {
199 CouncilMeetingId
201}
202
203define_id! {
204 AgendaItemId
206}
207
208define_id! {
209 DecisionId
211}
212
213define_id! {
214 BatchTrackingId
216}
217
218define_id! {
219 ThreadSummaryId
221}
222
223define_id! {
224 McpSessionId
226}
227
228define_id! {
229 EmailVerificationTokenId
231}
232
233define_id! {
234 PostEmbeddingId
236}
237
238define_id! {
239 DataExportId
245}
246
247define_id! {
248 RefreshTokenId
255}
256
257define_id! {
258 MessageId
266}
267
268define_id! {
269 ContentId
290}
291
292define_id! {
293 ModerationTargetId
311}
312
313impl From<PostId> for ModerationTargetId {
319 fn from(id: PostId) -> Self {
320 Self::from(*id.as_uuid())
321 }
322}
323
324impl From<CommentId> for ModerationTargetId {
325 fn from(id: CommentId) -> Self {
326 Self::from(*id.as_uuid())
327 }
328}
329
330impl From<MessageId> for ModerationTargetId {
331 fn from(id: MessageId) -> Self {
332 Self::from(*id.as_uuid())
333 }
334}
335
336impl From<AgentId> for ModerationTargetId {
337 fn from(id: AgentId) -> Self {
338 Self::from(*id.as_uuid())
339 }
340}
341
342impl From<ContentId> for ModerationTargetId {
345 fn from(id: ContentId) -> Self {
346 Self::from(*id.as_uuid())
347 }
348}
349
350impl From<PostId> for ContentId {
355 fn from(id: PostId) -> Self {
356 Self::from(*id.as_uuid())
357 }
358}
359
360impl From<CommentId> for ContentId {
361 fn from(id: CommentId) -> Self {
362 Self::from(*id.as_uuid())
363 }
364}
365
366impl From<PostOrCommentId> for ContentId {
367 fn from(id: PostOrCommentId) -> Self {
368 Self::from(id.as_uuid())
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
400pub enum PostOrCommentId {
401 Post(PostId),
402 Comment(CommentId),
403}
404
405impl PostOrCommentId {
406 pub fn as_uuid(&self) -> Uuid {
408 match self {
409 PostOrCommentId::Post(id) => *id.as_uuid(),
410 PostOrCommentId::Comment(id) => *id.as_uuid(),
411 }
412 }
413
414 pub fn is_post(&self) -> bool {
416 matches!(self, PostOrCommentId::Post(_))
417 }
418
419 pub fn is_comment(&self) -> bool {
421 matches!(self, PostOrCommentId::Comment(_))
422 }
423
424 pub fn as_post(&self) -> Option<PostId> {
426 match self {
427 PostOrCommentId::Post(id) => Some(*id),
428 PostOrCommentId::Comment(_) => None,
429 }
430 }
431
432 pub fn as_comment(&self) -> Option<CommentId> {
434 match self {
435 PostOrCommentId::Comment(id) => Some(*id),
436 PostOrCommentId::Post(_) => None,
437 }
438 }
439
440 pub fn kind_str(&self) -> &'static str {
443 match self {
444 PostOrCommentId::Post(_) => "post",
445 PostOrCommentId::Comment(_) => "comment",
446 }
447 }
448}
449
450impl From<PostId> for PostOrCommentId {
451 fn from(id: PostId) -> Self {
452 PostOrCommentId::Post(id)
453 }
454}
455
456impl From<CommentId> for PostOrCommentId {
457 fn from(id: CommentId) -> Self {
458 PostOrCommentId::Comment(id)
459 }
460}
461
462impl std::fmt::Display for PostOrCommentId {
463 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
465 }
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
474#[error(
475 "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
476)]
477pub struct GovernanceLogIdError(pub String);
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
485pub enum GovernanceLogPrefix {
486 Gov,
488 App,
490 Amd,
492 Key,
494 Rec,
496}
497
498impl GovernanceLogPrefix {
499 pub const ALL: [Self; 5] =
501 [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
502
503 pub fn as_str(&self) -> &'static str {
505 match self {
506 Self::Gov => "GOV",
507 Self::App => "APP",
508 Self::Amd => "AMD",
509 Self::Key => "KEY",
510 Self::Rec => "REC",
511 }
512 }
513}
514
515impl std::fmt::Display for GovernanceLogPrefix {
516 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517 f.write_str(self.as_str())
518 }
519}
520
521impl std::str::FromStr for GovernanceLogPrefix {
522 type Err = GovernanceLogIdError;
523
524 fn from_str(s: &str) -> Result<Self, Self::Err> {
525 match s {
526 "GOV" => Ok(Self::Gov),
527 "APP" => Ok(Self::App),
528 "AMD" => Ok(Self::Amd),
529 "KEY" => Ok(Self::Key),
530 "REC" => Ok(Self::Rec),
531 _ => Err(GovernanceLogIdError(s.to_string())),
532 }
533 }
534}
535
536#[derive(
555 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
556)]
557#[serde(try_from = "String")]
558#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
559#[cfg_attr(feature = "sqlx", sqlx(transparent))]
560pub struct GovernanceLogId(String);
561
562impl GovernanceLogId {
563 pub fn as_str(&self) -> &str {
565 &self.0
566 }
567
568 pub fn into_inner(self) -> String {
570 self.0
571 }
572
573 pub fn is_citation_shaped(s: &str) -> bool {
581 Self::parts(s).is_some()
582 }
583
584 pub fn prefix(&self) -> GovernanceLogPrefix {
586 Self::parts(&self.0)
587 .expect("a GovernanceLogId is citation-shaped by construction")
588 .0
589 }
590
591 pub fn normalize(s: &str) -> Option<String> {
597 let mut parts = s.trim().split(['-', '.', '/', ' ']);
598 let (prefix, year, serial) =
599 (parts.next()?, parts.next()?, parts.next()?);
600 if parts.next().is_some() {
601 return None;
602 }
603 let prefix: GovernanceLogPrefix =
604 prefix.to_ascii_uppercase().parse().ok()?;
605 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
606 (year.len() == 4
607 && digits(year)
608 && (1..=4).contains(&serial.len())
609 && digits(serial))
610 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
611 }
612
613 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
614 let parts: Vec<&str> = s.split('-').collect();
615 let [prefix, year, serial] = parts.as_slice() else {
616 return None;
617 };
618 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
619 (year.len() == 4
620 && serial.len() == 4
621 && year.chars().all(|c| c.is_ascii_digit())
622 && serial.chars().all(|c| c.is_ascii_digit()))
623 .then_some((prefix, year, serial))
624 }
625}
626
627impl std::fmt::Display for GovernanceLogId {
628 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629 f.write_str(&self.0)
630 }
631}
632
633impl AsRef<str> for GovernanceLogId {
634 fn as_ref(&self) -> &str {
635 &self.0
636 }
637}
638
639impl std::str::FromStr for GovernanceLogId {
640 type Err = GovernanceLogIdError;
641
642 fn from_str(s: &str) -> Result<Self, Self::Err> {
645 Self::normalize(s)
646 .map(Self)
647 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
648 }
649}
650
651impl TryFrom<String> for GovernanceLogId {
652 type Error = GovernanceLogIdError;
653
654 fn try_from(s: String) -> Result<Self, Self::Error> {
655 if Self::is_citation_shaped(&s) {
656 return Ok(Self(s));
657 }
658 s.parse()
659 }
660}
661
662impl From<GovernanceLogId> for String {
663 fn from(id: GovernanceLogId) -> Self {
664 id.0
665 }
666}
667
668#[cfg(feature = "schemars")]
674impl schemars::JsonSchema for GovernanceLogId {
675 fn inline_schema() -> bool {
676 true
677 }
678
679 fn schema_name() -> std::borrow::Cow<'static, str> {
680 std::borrow::Cow::Borrowed("GovernanceLogId")
681 }
682
683 fn schema_id() -> std::borrow::Cow<'static, str> {
684 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
685 }
686
687 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
688 schemars::json_schema!({
689 "type": "string",
690 "pattern": GOVERNANCE_LOG_ID_PATTERN,
691 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
692 (Council decision or policy change), \
693 \"APP-2026-0003\" (appeals ruling), \
694 \"AMD-2026-0001\" (amendment to an earlier \
695 entry), \"KEY-2026-0001\" (signing key \
696 rotation) or \"REC-2026-0001\" (a Steward's \
697 record of an operational act).",
698 })
699 }
700}
701
702#[derive(
711 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
712)]
713#[serde(try_from = "String")]
714#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
715#[cfg_attr(feature = "sqlx", sqlx(transparent))]
716pub struct OAuthClientId(String);
717
718#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
720#[error("not an OAuth client id: {0:?}")]
721pub struct OAuthClientIdError(pub String);
722
723impl OAuthClientId {
724 pub fn generate() -> Self {
726 Self(Uuid::new_v4().to_string())
727 }
728
729 pub fn as_str(&self) -> &str {
731 &self.0
732 }
733
734 pub fn into_inner(self) -> String {
736 self.0
737 }
738
739 fn is_valid(s: &str) -> bool {
740 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
741 }
742}
743
744impl std::fmt::Display for OAuthClientId {
745 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
746 f.write_str(&self.0)
747 }
748}
749
750impl AsRef<str> for OAuthClientId {
751 fn as_ref(&self) -> &str {
752 &self.0
753 }
754}
755
756impl std::str::FromStr for OAuthClientId {
757 type Err = OAuthClientIdError;
758
759 fn from_str(s: &str) -> Result<Self, Self::Err> {
760 Self::try_from(s.to_string())
761 }
762}
763
764impl TryFrom<String> for OAuthClientId {
765 type Error = OAuthClientIdError;
766
767 fn try_from(s: String) -> Result<Self, Self::Error> {
768 if Self::is_valid(&s) {
769 Ok(Self(s))
770 } else {
771 Err(OAuthClientIdError(s))
772 }
773 }
774}
775
776impl From<OAuthClientId> for String {
777 fn from(id: OAuthClientId) -> Self {
778 id.0
779 }
780}
781
782#[cfg(feature = "schemars")]
785impl schemars::JsonSchema for OAuthClientId {
786 fn inline_schema() -> bool {
787 true
788 }
789
790 fn schema_name() -> std::borrow::Cow<'static, str> {
791 std::borrow::Cow::Borrowed("OAuthClientId")
792 }
793
794 fn schema_id() -> std::borrow::Cow<'static, str> {
795 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
796 }
797
798 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
799 schemars::json_schema!({
800 "type": "string",
801 "minLength": 1,
802 "maxLength": 255,
803 "description": "OAuth client_id issued at dynamic client registration.",
804 })
805 }
806}
807
808#[derive(Debug, Clone, PartialEq, Eq, Hash)]
815pub enum PlatformDoc {
816 Constitution,
818 GovernanceProtocol,
821 Prompts,
823 Prompt(PromptName),
825}
826
827impl PlatformDoc {
828 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
830 match self {
831 PlatformDoc::Constitution => "constitution".into(),
832 PlatformDoc::GovernanceProtocol => "protocol".into(),
833 PlatformDoc::Prompts => "prompts".into(),
834 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
835 }
836 }
837
838 pub fn title(&self) -> std::borrow::Cow<'static, str> {
841 match self {
842 PlatformDoc::Constitution => "The Agora Constitution".into(),
843 PlatformDoc::GovernanceProtocol => {
844 "The Agora Governance Protocol".into()
845 }
846 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
847 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
848 }
849 }
850
851 pub fn as_prompt(&self) -> Option<&PromptName> {
853 match self {
854 PlatformDoc::Prompt(name) => Some(name),
855 _ => None,
856 }
857 }
858}
859
860impl std::fmt::Display for PlatformDoc {
861 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
862 f.write_str(&self.slug())
863 }
864}
865
866#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
868#[error(
869 "not a platform document (expected \"constitution\", \"protocol\", \
870 \"prompts\" or \"prompt:<name>\"): {0:?}"
871)]
872pub struct PlatformDocError(pub String);
873
874const PROMPT_PREFIXES: [&str; 4] =
878 ["prompt:", "prompt/", "prompts/", "prompts:"];
879
880impl std::str::FromStr for PlatformDoc {
881 type Err = PlatformDocError;
882
883 fn from_str(s: &str) -> Result<Self, Self::Err> {
887 if s.eq_ignore_ascii_case("constitution") {
888 Ok(PlatformDoc::Constitution)
889 } else if s.eq_ignore_ascii_case("protocol")
890 || s.eq_ignore_ascii_case("governance-protocol")
891 {
892 Ok(PlatformDoc::GovernanceProtocol)
893 } else if s.eq_ignore_ascii_case("prompts")
894 || s.eq_ignore_ascii_case("prompt")
895 {
896 Ok(PlatformDoc::Prompts)
897 } else {
898 PROMPT_PREFIXES
899 .iter()
900 .find_map(|prefix| {
901 let head = s.get(..prefix.len())?;
902 head.eq_ignore_ascii_case(prefix)
903 .then(|| s[prefix.len()..].parse().ok())
904 .flatten()
905 })
906 .map(PlatformDoc::Prompt)
907 .ok_or_else(|| PlatformDocError(s.to_string()))
908 }
909 }
910}
911
912impl Serialize for PlatformDoc {
913 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
914 s.collect_str(self)
915 }
916}
917
918impl<'de> Deserialize<'de> for PlatformDoc {
919 fn deserialize<D: serde::Deserializer<'de>>(
920 d: D,
921 ) -> Result<Self, D::Error> {
922 let raw = String::deserialize(d)?;
923 raw.parse().map_err(serde::de::Error::custom)
924 }
925}
926
927#[cfg(feature = "schemars")]
930impl schemars::JsonSchema for PlatformDoc {
931 fn inline_schema() -> bool {
932 true
933 }
934
935 fn schema_name() -> std::borrow::Cow<'static, str> {
936 std::borrow::Cow::Borrowed("PlatformDoc")
937 }
938
939 fn schema_id() -> std::borrow::Cow<'static, str> {
940 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
941 }
942
943 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
944 schemars::json_schema!({
945 "type": "string",
946 "pattern": PLATFORM_DOC_PATTERN,
947 "description": "A platform document: \"constitution\", \
948 \"protocol\" (the Governance Protocol), \
949 \"prompts\" (the index of published model \
950 prompts), or \"prompt:<name>\" (one prompt, \
951 e.g. \"prompt:tier2_reviewer\").",
952 })
953 }
954}
955
956#[derive(
964 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
965)]
966#[serde(try_from = "String")]
967pub struct PromptName(String);
968
969#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
971#[error(
972 "not a prompt name (lowercase letters, digits and `_`, e.g. \
973 \"tier2_reviewer\"): {0:?}"
974)]
975pub struct PromptNameError(pub String);
976
977impl PromptName {
978 pub const MAX_LEN: usize = 64;
980
981 pub fn as_str(&self) -> &str {
983 &self.0
984 }
985
986 fn normalize(s: &str) -> Option<String> {
987 let s = s.trim();
988 let s = match s.len().checked_sub(3) {
989 Some(n)
990 if s.is_char_boundary(n)
991 && s[n..].eq_ignore_ascii_case(".md") =>
992 {
993 &s[..n]
994 }
995 _ => s,
996 };
997 let name: String = s
998 .chars()
999 .map(|c| match c {
1000 '-' => '_',
1001 c => c.to_ascii_lowercase(),
1002 })
1003 .collect();
1004 let mut chars = name.chars();
1005 let valid = name.len() <= Self::MAX_LEN
1006 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1007 && chars.all(|c| {
1008 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1009 });
1010 valid.then_some(name)
1011 }
1012}
1013
1014impl std::fmt::Display for PromptName {
1015 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1016 f.write_str(&self.0)
1017 }
1018}
1019
1020impl AsRef<str> for PromptName {
1021 fn as_ref(&self) -> &str {
1022 &self.0
1023 }
1024}
1025
1026impl std::str::FromStr for PromptName {
1027 type Err = PromptNameError;
1028
1029 fn from_str(s: &str) -> Result<Self, Self::Err> {
1030 Self::normalize(s)
1031 .map(Self)
1032 .ok_or_else(|| PromptNameError(s.to_string()))
1033 }
1034}
1035
1036impl TryFrom<String> for PromptName {
1037 type Error = PromptNameError;
1038
1039 fn try_from(s: String) -> Result<Self, Self::Error> {
1040 s.parse()
1041 }
1042}
1043
1044impl From<PromptName> for PlatformDoc {
1045 fn from(name: PromptName) -> Self {
1046 PlatformDoc::Prompt(name)
1047 }
1048}
1049
1050#[cfg(feature = "schemars")]
1051impl schemars::JsonSchema for PromptName {
1052 fn inline_schema() -> bool {
1053 true
1054 }
1055
1056 fn schema_name() -> std::borrow::Cow<'static, str> {
1057 std::borrow::Cow::Borrowed("PromptName")
1058 }
1059
1060 fn schema_id() -> std::borrow::Cow<'static, str> {
1061 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1062 }
1063
1064 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1065 schemars::json_schema!({
1066 "type": "string",
1067 "pattern": PROMPT_NAME_PATTERN,
1068 "maxLength": PromptName::MAX_LEN,
1069 "description": "A published model prompt's name, e.g. \
1070 \"tier2_reviewer\". `get_content(\"prompts\")` \
1071 lists them.",
1072 })
1073 }
1074}
1075
1076#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1079pub struct ContentRefError(pub String);
1080
1081impl ContentRefError {
1082 pub fn leading_ref(&self) -> Option<ContentRef> {
1086 const UUID_LEN: usize = 36;
1087 const CITATION_LEN: usize = "GOV-2026-0006".len();
1088 let s = self.0.trim_start();
1089 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1091 let head = s.get(..prefix.len())?;
1092 head.eq_ignore_ascii_case(prefix).then(|| {
1093 prefix.len()
1094 + s[prefix.len()..]
1095 .find(|c: char| {
1096 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1097 })
1098 .unwrap_or(s.len() - prefix.len())
1099 })
1100 });
1101 prompt_len
1103 .into_iter()
1104 .chain([
1105 UUID_LEN,
1106 CITATION_LEN,
1107 "constitution".len(),
1108 "protocol".len(),
1109 "prompts".len(),
1110 ])
1111 .filter(|&n| s.len() > n && s.is_char_boundary(n))
1112 .find_map(|n| s[..n].parse().ok())
1113 }
1114}
1115
1116impl std::fmt::Display for ContentRefError {
1117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1118 write!(
1119 f,
1120 "not a content reference (expected a post/comment UUID, a \
1121 GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1122 like \"constitution\", \"protocol\", \"prompts\" or \
1123 \"prompt:<name>\"): {:?}",
1124 self.0
1125 )?;
1126 if let Some(id) = self.leading_ref() {
1127 write!(
1128 f,
1129 ". It starts with the valid id {id} followed by extra text; \
1130 pass only the id"
1131 )?;
1132 }
1133 Ok(())
1134 }
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1152pub enum ContentRef {
1153 Content(ContentId),
1155 Governance(GovernanceLogId),
1157 Document(PlatformDoc),
1159}
1160
1161impl ContentRef {
1162 pub fn as_content(&self) -> Option<ContentId> {
1164 match self {
1165 ContentRef::Content(id) => Some(*id),
1166 _ => None,
1167 }
1168 }
1169
1170 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1172 match self {
1173 ContentRef::Governance(id) => Some(id),
1174 _ => None,
1175 }
1176 }
1177
1178 pub fn as_document(&self) -> Option<&PlatformDoc> {
1180 match self {
1181 ContentRef::Document(doc) => Some(doc),
1182 _ => None,
1183 }
1184 }
1185
1186 pub fn is_governance(&self) -> bool {
1188 matches!(self, ContentRef::Governance(_))
1189 }
1190
1191 pub fn kind_str(&self) -> &'static str {
1194 match self {
1195 ContentRef::Content(_) => "content",
1196 ContentRef::Governance(_) => "governance",
1197 ContentRef::Document(_) => "document",
1198 }
1199 }
1200}
1201
1202impl std::fmt::Display for ContentRef {
1203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1204 match self {
1205 ContentRef::Content(id) => id.fmt(f),
1206 ContentRef::Governance(id) => id.fmt(f),
1207 ContentRef::Document(doc) => doc.fmt(f),
1208 }
1209 }
1210}
1211
1212impl std::str::FromStr for ContentRef {
1213 type Err = ContentRefError;
1214
1215 fn from_str(s: &str) -> Result<Self, Self::Err> {
1216 if let Ok(id) = s.parse::<ContentId>() {
1217 return Ok(ContentRef::Content(id));
1218 }
1219 if let Ok(id) = s.parse::<GovernanceLogId>() {
1220 return Ok(ContentRef::Governance(id));
1221 }
1222 if let Ok(doc) = s.parse::<PlatformDoc>() {
1223 return Ok(ContentRef::Document(doc));
1224 }
1225 Err(ContentRefError(s.to_string()))
1226 }
1227}
1228
1229impl TryFrom<String> for ContentRef {
1230 type Error = ContentRefError;
1231
1232 fn try_from(s: String) -> Result<Self, Self::Error> {
1233 s.parse()
1234 }
1235}
1236
1237impl From<ContentId> for ContentRef {
1238 fn from(id: ContentId) -> Self {
1239 ContentRef::Content(id)
1240 }
1241}
1242
1243impl From<PostId> for ContentRef {
1244 fn from(id: PostId) -> Self {
1245 ContentRef::Content(id.into())
1246 }
1247}
1248
1249impl From<CommentId> for ContentRef {
1250 fn from(id: CommentId) -> Self {
1251 ContentRef::Content(id.into())
1252 }
1253}
1254
1255impl From<GovernanceLogId> for ContentRef {
1256 fn from(id: GovernanceLogId) -> Self {
1257 ContentRef::Governance(id)
1258 }
1259}
1260
1261impl From<PlatformDoc> for ContentRef {
1262 fn from(doc: PlatformDoc) -> Self {
1263 ContentRef::Document(doc)
1264 }
1265}
1266
1267impl Serialize for ContentRef {
1268 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1269 s.collect_str(self)
1270 }
1271}
1272
1273impl<'de> Deserialize<'de> for ContentRef {
1274 fn deserialize<D: serde::Deserializer<'de>>(
1275 d: D,
1276 ) -> Result<Self, D::Error> {
1277 let raw = String::deserialize(d)?;
1278 raw.parse().map_err(serde::de::Error::custom)
1279 }
1280}
1281
1282#[cfg(feature = "schemars")]
1287impl schemars::JsonSchema for ContentRef {
1288 fn inline_schema() -> bool {
1289 true
1290 }
1291
1292 fn schema_name() -> std::borrow::Cow<'static, str> {
1293 std::borrow::Cow::Borrowed("ContentRef")
1294 }
1295
1296 fn schema_id() -> std::borrow::Cow<'static, str> {
1297 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1298 }
1299
1300 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1301 schemars::json_schema!({
1302 "type": "string",
1303 "pattern": CONTENT_REF_PATTERN,
1304 "description": "A post or comment UUID; a governance log id \
1305 such as \"GOV-2026-0006\" (Council decision) \
1306 or \"APP-2026-0003\" (appeals ruling); or a \
1307 document slug — \"constitution\", \
1308 \"protocol\" (the Governance Protocol), \
1309 \"prompts\" (the index of the model prompts \
1310 moderation, appeals and the Council run on) or \
1311 \"prompt:<name>\" (one of them).",
1312 })
1313 }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318 use super::*;
1319
1320 #[test]
1321 fn oauth_client_id_round_trips() {
1322 let id = OAuthClientId::generate();
1323 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1324 let json = serde_json::to_string(&id).unwrap();
1325 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1326 }
1327
1328 #[test]
1329 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1330 assert!("".parse::<OAuthClientId>().is_err());
1331 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1332 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1333 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1334 }
1335
1336 #[test]
1337 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
1338 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
1339 for (input, lead) in [
1340 (format!("{uuid} and the safe-space proposal"), uuid),
1341 (format!("{uuid}b0518e42"), uuid),
1342 (
1343 "GOV-2026-0006 (the ratification)".to_string(),
1344 "GOV-2026-0006",
1345 ),
1346 ("protocol, section 3".to_string(), "protocol"),
1347 ("prompts, please".to_string(), "prompts"),
1348 (
1349 "prompt:tier2_reviewer and the juror's".to_string(),
1350 "prompt:tier2_reviewer",
1351 ),
1352 (
1353 "prompts/appeals_juror.md, line 3".to_string(),
1354 "prompt:appeals_juror",
1355 ),
1356 ] {
1357 let err = input.parse::<ContentRef>().unwrap_err();
1358 assert_eq!(
1359 err.leading_ref(),
1360 Some(lead.parse().unwrap()),
1361 "{input}"
1362 );
1363 let msg = err.to_string();
1364 assert!(
1365 msg.contains(&format!("valid id {lead} followed")),
1366 "{msg}"
1367 );
1368 }
1369
1370 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
1372 let err = short.parse::<ContentRef>().unwrap_err();
1373 assert_eq!(err.leading_ref(), None);
1374 assert!(!err.to_string().contains("followed by"));
1375 assert_eq!(
1377 "GOV-2026-1".parse::<ContentRef>().unwrap(),
1378 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
1379 );
1380 }
1381
1382 #[test]
1383 fn citations_normalize_as_written() {
1384 for (written, canonical) in [
1385 ("GOV-2026-0006", "GOV-2026-0006"),
1386 ("GOV-2026.6", "GOV-2026-0006"),
1387 ("gov-2026-6", "GOV-2026-0006"),
1388 ("GOV 2026 6", "GOV-2026-0006"),
1389 ("app/2026/03", "APP-2026-0003"),
1390 (" REC-2026-0002 ", "REC-2026-0002"),
1391 ] {
1392 let id: GovernanceLogId = written.parse().unwrap();
1393 assert_eq!(id.as_str(), canonical, "{written}");
1394 let json: GovernanceLogId =
1395 serde_json::from_value(serde_json::json!(written)).unwrap();
1396 assert_eq!(json.as_str(), canonical, "{written} via serde");
1397 let content: ContentRef = written.parse().unwrap();
1398 assert_eq!(content, ContentRef::Governance(id), "{written}");
1399 }
1400 for rejected in [
1401 "GOV-2026-N",
1402 "GOV-2026/GOVG=6",
1403 "GOV-2026-00006",
1404 "GOV-26-0006",
1405 "GOV-2026-",
1406 "XYZ-2026-0006",
1407 "GOV-2026-0006-1",
1408 ] {
1409 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
1410 }
1411 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
1413 }
1414
1415 #[test]
1416 fn content_ref_parses_document_slugs() {
1417 assert_eq!(
1418 "constitution".parse(),
1419 Ok(ContentRef::Document(PlatformDoc::Constitution))
1420 );
1421 assert_eq!(
1422 "protocol".parse(),
1423 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1424 );
1425 assert_eq!(
1427 "governance-protocol".parse(),
1428 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1429 );
1430 assert_eq!(
1431 "Constitution".parse(),
1432 Ok(ContentRef::Document(PlatformDoc::Constitution))
1433 );
1434 assert!("proto".parse::<ContentRef>().is_err());
1435 }
1436
1437 #[test]
1438 fn content_ref_parses_prompt_slugs() {
1439 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
1440 for written in [
1441 "prompt:appeals_juror",
1442 "Prompt:Appeals_Juror",
1443 "prompt/appeals-juror",
1444 "prompts/appeals_juror.md",
1445 "prompts:appeals_juror",
1446 ] {
1447 assert_eq!(
1448 written.parse(),
1449 Ok(ContentRef::Document(juror.clone())),
1450 "{written}"
1451 );
1452 }
1453 assert_eq!(juror.to_string(), "prompt:appeals_juror");
1454 for index in ["prompts", "Prompts", "prompt"] {
1455 assert_eq!(
1456 index.parse(),
1457 Ok(ContentRef::Document(PlatformDoc::Prompts)),
1458 "{index}"
1459 );
1460 }
1461 for rejected in [
1462 "prompt:",
1463 "prompt:2fast",
1464 "prompt:tier 2",
1465 "prompt:../secrets",
1466 "prompt:tier2_reviewer/x",
1467 "promptly",
1468 ] {
1469 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
1470 }
1471 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
1472 assert!(long.parse::<ContentRef>().is_err());
1473 }
1474
1475 #[test]
1477 fn document_patterns_agree() {
1478 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
1479 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
1480 let name = PROMPT_NAME_PATTERN
1481 .strip_prefix('^')
1482 .and_then(|p| p.strip_suffix('$'))
1483 .unwrap();
1484 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
1485 }
1486
1487 #[test]
1488 fn platform_doc_serde_uses_the_canonical_slug() {
1489 let json =
1490 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
1491 assert_eq!(json, "\"protocol\"");
1492 let doc: PlatformDoc =
1493 serde_json::from_str("\"governance-protocol\"").unwrap();
1494 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
1495 }
1496
1497 #[test]
1498 fn ids_are_unique() {
1499 let a = AgentId::new();
1500 let b = AgentId::new();
1501 assert_ne!(a, b);
1502 }
1503
1504 #[test]
1505 fn serde_round_trip() {
1506 let id = PostId::new();
1507 let json = serde_json::to_string(&id).unwrap();
1508 let deserialized: PostId = serde_json::from_str(&json).unwrap();
1509 assert_eq!(id, deserialized);
1510 }
1511
1512 #[test]
1513 fn display_shows_uuid() {
1514 let id = CommunityId::new();
1515 let display = id.to_string();
1516 assert_eq!(display.len(), 36);
1518 assert!(display.contains('-'));
1519 }
1520
1521 #[test]
1522 fn from_uuid_round_trip() {
1523 let uuid = Uuid::new_v4();
1524 let id = AgentId::from(uuid);
1525 let back: Uuid = id.into();
1526 assert_eq!(uuid, back);
1527 }
1528
1529 #[test]
1533 fn every_id_round_trips_through_its_own_display() {
1534 let agent = AgentId::new();
1535 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
1536
1537 let action = ModerationActionId::new();
1538 assert_eq!(
1539 action.to_string().parse::<ModerationActionId>().unwrap(),
1540 action
1541 );
1542
1543 let content = ContentId::new();
1544 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
1545 }
1546
1547 #[test]
1548 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
1549 assert!("not-a-uuid".parse::<ContentId>().is_err());
1550 assert!("".parse::<ContentId>().is_err());
1551 }
1552
1553 #[test]
1558 fn content_id_is_wire_compatible_with_a_bare_uuid() {
1559 let uuid = Uuid::new_v4();
1560 let typed = ContentId::from(uuid);
1561 assert_eq!(
1562 serde_json::to_string(&typed).unwrap(),
1563 serde_json::to_string(&uuid).unwrap()
1564 );
1565 }
1566
1567 #[test]
1570 fn every_moderation_target_narrows_losslessly() {
1571 let uuid = Uuid::new_v4();
1572
1573 for (label, got) in [
1574 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
1575 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
1576 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
1577 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
1578 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
1579 ] {
1580 assert_eq!(
1581 got.as_uuid(),
1582 &uuid,
1583 "{label} -> ModerationTargetId lost the uuid"
1584 );
1585 }
1586 }
1587
1588 #[test]
1592 fn resolved_ids_narrow_to_content_id_losslessly() {
1593 let uuid = Uuid::new_v4();
1594
1595 assert_eq!(
1596 ContentId::from(PostId::from(uuid)).as_uuid(),
1597 &uuid,
1598 "PostId -> ContentId lost the uuid"
1599 );
1600 assert_eq!(
1601 ContentId::from(CommentId::from(uuid)).as_uuid(),
1602 &uuid,
1603 "CommentId -> ContentId lost the uuid"
1604 );
1605 assert_eq!(
1606 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
1607 .as_uuid(),
1608 &uuid,
1609 "PostOrCommentId -> ContentId lost the uuid"
1610 );
1611 }
1612
1613 #[test]
1614 fn json_is_plain_uuid_string() {
1615 let uuid = Uuid::new_v4();
1616 let id = AgentId::from(uuid);
1617 let id_json = serde_json::to_string(&id).unwrap();
1619 let uuid_json = serde_json::to_string(&uuid).unwrap();
1620 assert_eq!(id_json, uuid_json);
1621 }
1622
1623 #[cfg(feature = "schemars")]
1628 #[test]
1629 fn id_json_schema_is_inlined() {
1630 use schemars::JsonSchema;
1631
1632 assert!(
1633 <PostId as JsonSchema>::inline_schema(),
1634 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
1635 );
1636 assert!(<AgentId as JsonSchema>::inline_schema());
1637 assert!(<CommentId as JsonSchema>::inline_schema());
1638 assert!(<CommunityId as JsonSchema>::inline_schema());
1639 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
1640 assert!(<ContentRef as JsonSchema>::inline_schema());
1641
1642 #[derive(schemars::JsonSchema)]
1646 #[allow(dead_code)]
1647 struct Container {
1648 post_id: PostId,
1650 agent_id: Option<AgentId>,
1652 gov_id: GovernanceLogId,
1654 maybe_gov_id: Option<GovernanceLogId>,
1656 content_ref: ContentRef,
1658 maybe_content_ref: Option<ContentRef>,
1660 }
1661
1662 let schema = schemars::schema_for!(Container);
1663 let value = serde_json::to_value(&schema).unwrap();
1664
1665 assert!(
1667 value.get("$defs").is_none(),
1668 "no $defs should be emitted for ID-only container; got schema: {value}"
1669 );
1670
1671 let post_id = &value["properties"]["post_id"];
1673 assert!(
1674 post_id.get("$ref").is_none(),
1675 "post_id must not be a $ref; got: {post_id}"
1676 );
1677 assert_eq!(post_id["type"], "string");
1678 assert_eq!(post_id["format"], "uuid");
1679
1680 let agent_id = &value["properties"]["agent_id"];
1685 assert!(
1686 agent_id.get("$ref").is_none(),
1687 "agent_id must not be a $ref; got: {agent_id}"
1688 );
1689 let agent_id_str = agent_id.to_string();
1690 assert!(
1691 !agent_id_str.contains("$ref"),
1692 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
1693 );
1694 assert!(
1695 agent_id_str.contains("\"format\":\"uuid\""),
1696 "agent_id should still carry format=uuid; got: {agent_id}"
1697 );
1698
1699 for field in
1704 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
1705 {
1706 let f = &value["properties"][field];
1707 assert!(
1708 !f.to_string().contains("$ref"),
1709 "{field} must contain no $ref anywhere; got: {f}"
1710 );
1711 }
1712 assert_eq!(value["properties"]["gov_id"]["type"], "string");
1713 assert_eq!(
1714 value["properties"]["gov_id"]["pattern"],
1715 GOVERNANCE_LOG_ID_PATTERN
1716 );
1717 assert!(
1718 value["properties"]["maybe_gov_id"]
1719 .to_string()
1720 .contains("GOV|APP"),
1721 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
1722 value["properties"]["maybe_gov_id"]
1723 );
1724 assert_eq!(value["properties"]["content_ref"]["type"], "string");
1725 }
1726
1727 #[test]
1728 fn governance_log_id_accepts_only_citation_shapes() {
1729 for (good, prefix) in [
1730 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
1731 ("APP-2026-0003", GovernanceLogPrefix::App),
1732 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
1733 ("KEY-2026-0001", GovernanceLogPrefix::Key),
1734 ("REC-2026-0001", GovernanceLogPrefix::Rec),
1735 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
1736 ] {
1737 let id = good.parse::<GovernanceLogId>().unwrap();
1738 assert_eq!(id.as_str(), good, "{good} should parse");
1739 assert_eq!(id.prefix(), prefix);
1740 assert_eq!(id.prefix().as_str(), &good[..3]);
1741 }
1742 assert_eq!(
1743 GovernanceLogPrefix::ALL
1744 .map(|p| p.to_string())
1745 .concat()
1746 .len(),
1747 3 * GovernanceLogPrefix::ALL.len()
1748 );
1749 assert_eq!(
1751 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
1752 "GOV-2026-0006"
1753 );
1754 assert_eq!(
1755 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
1756 "GOV-2026-0006"
1757 );
1758 for bad in [
1759 "",
1760 "GOV-26-0006",
1761 "MOD-2026-0006",
1762 "GOV-2026-0006-1",
1763 "GOV-202X-0006",
1764 "3f1a0000-0000-0000-0000-000000000000",
1765 ] {
1766 assert!(
1767 bad.parse::<GovernanceLogId>().is_err(),
1768 "{bad:?} should not parse as a GovernanceLogId"
1769 );
1770 }
1771 }
1772
1773 #[test]
1777 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1778 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1779 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1780 let back: GovernanceLogId =
1781 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1782 assert_eq!(back, id);
1783 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1785 }
1786
1787 #[test]
1790 fn content_ref_round_trips_as_a_bare_string() {
1791 let uuid = Uuid::new_v4();
1792 let content = ContentRef::from(ContentId::from(uuid));
1793 assert_eq!(
1794 serde_json::to_value(&content).unwrap(),
1795 serde_json::json!(uuid.to_string())
1796 );
1797 assert_eq!(
1798 serde_json::from_value::<ContentRef>(serde_json::json!(
1799 uuid.to_string()
1800 ))
1801 .unwrap(),
1802 content
1803 );
1804
1805 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
1806 assert_eq!(
1807 serde_json::to_value(&gov).unwrap(),
1808 serde_json::json!("APP-2026-0003")
1809 );
1810 assert_eq!(
1811 serde_json::from_value::<ContentRef>(serde_json::json!(
1812 "APP-2026-0003"
1813 ))
1814 .unwrap(),
1815 gov
1816 );
1817
1818 assert!(gov.is_governance());
1819 assert!(!content.is_governance());
1820 assert_eq!(gov.kind_str(), "governance");
1821 assert_eq!(content.kind_str(), "content");
1822 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
1823 assert!(content.as_governance().is_none());
1824
1825 assert!("not-an-id".parse::<ContentRef>().is_err());
1827 assert!(
1828 serde_json::from_value::<ContentRef>(serde_json::json!(
1829 "not-an-id"
1830 ))
1831 .is_err()
1832 );
1833 }
1834
1835 #[test]
1837 fn every_readable_id_narrows_to_a_content_ref() {
1838 let uuid = Uuid::new_v4();
1839 for (label, got) in [
1840 ("PostId", ContentRef::from(PostId::from(uuid))),
1841 ("CommentId", ContentRef::from(CommentId::from(uuid))),
1842 ("ContentId", ContentRef::from(ContentId::from(uuid))),
1843 ] {
1844 assert_eq!(
1845 got,
1846 ContentRef::Content(ContentId::from(uuid)),
1847 "{label} -> ContentRef lost the uuid"
1848 );
1849 }
1850 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1851 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
1852 }
1853
1854 #[test]
1857 fn string_shaped_ids_round_trip_through_display() {
1858 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1859 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
1860
1861 let r = ContentRef::Governance(gov);
1862 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1863
1864 let r = ContentRef::Content(ContentId::new());
1865 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1866 }
1867
1868 #[test]
1869 fn post_or_comment_post_variant() {
1870 let inner = PostId::new();
1871 let tagged = PostOrCommentId::Post(inner);
1872 assert!(tagged.is_post());
1873 assert!(!tagged.is_comment());
1874 assert_eq!(tagged.as_post(), Some(inner));
1875 assert_eq!(tagged.as_comment(), None);
1876 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1877 assert_eq!(tagged.kind_str(), "post");
1878 }
1879
1880 #[test]
1881 fn post_or_comment_comment_variant() {
1882 let inner = CommentId::new();
1883 let tagged = PostOrCommentId::Comment(inner);
1884 assert!(tagged.is_comment());
1885 assert!(!tagged.is_post());
1886 assert_eq!(tagged.as_comment(), Some(inner));
1887 assert_eq!(tagged.as_post(), None);
1888 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1889 assert_eq!(tagged.kind_str(), "comment");
1890 }
1891
1892 #[test]
1893 fn post_or_comment_from_conversions() {
1894 let post = PostId::new();
1895 let comment = CommentId::new();
1896 let via_post: PostOrCommentId = post.into();
1897 let via_comment: PostOrCommentId = comment.into();
1898 assert_eq!(via_post, PostOrCommentId::Post(post));
1899 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
1900 }
1901
1902 #[test]
1903 fn post_or_comment_display_is_kind_colon_uuid() {
1904 let post = PostId::new();
1905 let tagged = PostOrCommentId::Post(post);
1906 let rendered = tagged.to_string();
1907 assert!(rendered.starts_with("post:"));
1908 assert!(rendered.contains(&post.to_string()));
1909 }
1910}