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)]
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 = IdParseError;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 s.parse::<Uuid>().map(Self).map_err(|_| IdParseError {
98 name: stringify!($name),
99 input: s.to_string(),
100 })
101 }
102 }
103
104 impl<'de> Deserialize<'de> for $name {
107 fn deserialize<D: serde::Deserializer<'de>>(
108 d: D,
109 ) -> Result<Self, D::Error> {
110 struct V;
111 impl serde::de::Visitor<'_> for V {
112 type Value = $name;
113
114 fn expecting(
115 &self,
116 f: &mut std::fmt::Formatter<'_>,
117 ) -> std::fmt::Result {
118 write!(f, "{} as a UUID string", stringify!($name))
119 }
120
121 fn visit_str<E: serde::de::Error>(
122 self,
123 v: &str,
124 ) -> Result<$name, E> {
125 v.parse().map_err(E::custom)
126 }
127 }
128 d.deserialize_str(V)
129 }
130 }
131
132 #[cfg(feature = "schemars")]
139 impl schemars::JsonSchema for $name {
140 fn inline_schema() -> bool {
141 true
142 }
143
144 fn schema_name() -> std::borrow::Cow<'static, str> {
145 std::borrow::Cow::Borrowed(stringify!($name))
146 }
147
148 fn schema_id() -> std::borrow::Cow<'static, str> {
149 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
150 }
151
152 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
153 schemars::json_schema!({
154 "type": "string",
155 "format": "uuid",
156 "pattern": UUID_PATTERN,
157 })
158 }
159 }
160 };
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
169#[error(
170 "not a valid {name}: expected a UUID like \
171 \"7ad26ccd-922f-484a-a37c-51777344a98c\", got \"{}\"",
172 echo(input)
173)]
174pub struct IdParseError {
175 pub name: &'static str,
177 pub input: String,
178}
179
180const ECHO_CHARS: usize = 60;
182
183fn echo(input: &str) -> String {
185 let mut out: String = input.chars().take(ECHO_CHARS).collect();
186 if input.chars().count() > ECHO_CHARS {
187 out.push('…');
188 }
189 out
190}
191
192define_id! {
193 AgentId
195}
196
197define_id! {
198 ReactorId
200}
201
202define_id! {
203 OperatorId
205}
206
207define_id! {
208 PostId
210}
211
212define_id! {
213 CommentId
215}
216
217define_id! {
218 CommunityId
220}
221
222define_id! {
223 VoteId
225}
226
227define_id! {
228 ModerationActionId
230}
231
232define_id! {
233 ModerationNoteId
240}
241
242define_id! {
243 PromptArchiveId
250}
251
252define_id! {
253 AppealId
255}
256
257define_id! {
258 FlagId
260}
261
262define_id! {
263 CouncilMeetingId
265}
266
267define_id! {
268 AgendaItemId
270}
271
272define_id! {
273 DecisionId
275}
276
277define_id! {
278 BatchTrackingId
280}
281
282define_id! {
283 ThreadSummaryId
285}
286
287define_id! {
288 McpSessionId
290}
291
292define_id! {
293 EmailVerificationTokenId
295}
296
297define_id! {
298 PostEmbeddingId
300}
301
302define_id! {
303 DataExportId
309}
310
311define_id! {
312 RefreshTokenId
319}
320
321define_id! {
322 MessageId
330}
331
332define_id! {
333 ContentId
354}
355
356define_id! {
357 ModerationTargetId
375}
376
377impl From<PostId> for ModerationTargetId {
383 fn from(id: PostId) -> Self {
384 Self::from(*id.as_uuid())
385 }
386}
387
388impl From<CommentId> for ModerationTargetId {
389 fn from(id: CommentId) -> Self {
390 Self::from(*id.as_uuid())
391 }
392}
393
394impl From<MessageId> for ModerationTargetId {
395 fn from(id: MessageId) -> Self {
396 Self::from(*id.as_uuid())
397 }
398}
399
400impl From<AgentId> for ModerationTargetId {
401 fn from(id: AgentId) -> Self {
402 Self::from(*id.as_uuid())
403 }
404}
405
406impl From<ContentId> for ModerationTargetId {
409 fn from(id: ContentId) -> Self {
410 Self::from(*id.as_uuid())
411 }
412}
413
414impl From<PostId> for ContentId {
419 fn from(id: PostId) -> Self {
420 Self::from(*id.as_uuid())
421 }
422}
423
424impl From<CommentId> for ContentId {
425 fn from(id: CommentId) -> Self {
426 Self::from(*id.as_uuid())
427 }
428}
429
430impl From<PostOrCommentId> for ContentId {
431 fn from(id: PostOrCommentId) -> Self {
432 Self::from(id.as_uuid())
433 }
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
464pub enum PostOrCommentId {
465 Post(PostId),
466 Comment(CommentId),
467}
468
469impl PostOrCommentId {
470 pub fn as_uuid(&self) -> Uuid {
472 match self {
473 PostOrCommentId::Post(id) => *id.as_uuid(),
474 PostOrCommentId::Comment(id) => *id.as_uuid(),
475 }
476 }
477
478 pub fn is_post(&self) -> bool {
480 matches!(self, PostOrCommentId::Post(_))
481 }
482
483 pub fn is_comment(&self) -> bool {
485 matches!(self, PostOrCommentId::Comment(_))
486 }
487
488 pub fn as_post(&self) -> Option<PostId> {
490 match self {
491 PostOrCommentId::Post(id) => Some(*id),
492 PostOrCommentId::Comment(_) => None,
493 }
494 }
495
496 pub fn as_comment(&self) -> Option<CommentId> {
498 match self {
499 PostOrCommentId::Comment(id) => Some(*id),
500 PostOrCommentId::Post(_) => None,
501 }
502 }
503
504 pub fn kind_str(&self) -> &'static str {
507 match self {
508 PostOrCommentId::Post(_) => "post",
509 PostOrCommentId::Comment(_) => "comment",
510 }
511 }
512}
513
514impl From<PostId> for PostOrCommentId {
515 fn from(id: PostId) -> Self {
516 PostOrCommentId::Post(id)
517 }
518}
519
520impl From<CommentId> for PostOrCommentId {
521 fn from(id: CommentId) -> Self {
522 PostOrCommentId::Comment(id)
523 }
524}
525
526impl std::fmt::Display for PostOrCommentId {
527 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
529 }
530}
531
532#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
538#[error(
539 "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
540)]
541pub struct GovernanceLogIdError(pub String);
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
549pub enum GovernanceLogPrefix {
550 Gov,
552 App,
554 Amd,
556 Key,
558 Rec,
560}
561
562impl GovernanceLogPrefix {
563 pub const ALL: [Self; 5] =
565 [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
566
567 pub fn as_str(&self) -> &'static str {
569 match self {
570 Self::Gov => "GOV",
571 Self::App => "APP",
572 Self::Amd => "AMD",
573 Self::Key => "KEY",
574 Self::Rec => "REC",
575 }
576 }
577}
578
579impl std::fmt::Display for GovernanceLogPrefix {
580 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581 f.write_str(self.as_str())
582 }
583}
584
585impl std::str::FromStr for GovernanceLogPrefix {
586 type Err = GovernanceLogIdError;
587
588 fn from_str(s: &str) -> Result<Self, Self::Err> {
589 match s {
590 "GOV" => Ok(Self::Gov),
591 "APP" => Ok(Self::App),
592 "AMD" => Ok(Self::Amd),
593 "KEY" => Ok(Self::Key),
594 "REC" => Ok(Self::Rec),
595 _ => Err(GovernanceLogIdError(s.to_string())),
596 }
597 }
598}
599
600#[derive(
619 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
620)]
621#[serde(try_from = "String")]
622#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
623#[cfg_attr(feature = "sqlx", sqlx(transparent))]
624pub struct GovernanceLogId(String);
625
626impl GovernanceLogId {
627 pub fn as_str(&self) -> &str {
629 &self.0
630 }
631
632 pub fn into_inner(self) -> String {
634 self.0
635 }
636
637 pub fn is_citation_shaped(s: &str) -> bool {
645 Self::parts(s).is_some()
646 }
647
648 pub fn prefix(&self) -> GovernanceLogPrefix {
650 Self::parts(&self.0)
651 .expect("a GovernanceLogId is citation-shaped by construction")
652 .0
653 }
654
655 pub fn normalize(s: &str) -> Option<String> {
661 let mut parts = s.trim().split(['-', '.', '/', ' ']);
662 let (prefix, year, serial) =
663 (parts.next()?, parts.next()?, parts.next()?);
664 if parts.next().is_some() {
665 return None;
666 }
667 let prefix: GovernanceLogPrefix =
668 prefix.to_ascii_uppercase().parse().ok()?;
669 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
670 (year.len() == 4
671 && digits(year)
672 && (1..=4).contains(&serial.len())
673 && digits(serial))
674 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
675 }
676
677 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
678 let parts: Vec<&str> = s.split('-').collect();
679 let [prefix, year, serial] = parts.as_slice() else {
680 return None;
681 };
682 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
683 (year.len() == 4
684 && serial.len() == 4
685 && year.chars().all(|c| c.is_ascii_digit())
686 && serial.chars().all(|c| c.is_ascii_digit()))
687 .then_some((prefix, year, serial))
688 }
689}
690
691impl std::fmt::Display for GovernanceLogId {
692 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
693 f.write_str(&self.0)
694 }
695}
696
697impl AsRef<str> for GovernanceLogId {
698 fn as_ref(&self) -> &str {
699 &self.0
700 }
701}
702
703impl std::str::FromStr for GovernanceLogId {
704 type Err = GovernanceLogIdError;
705
706 fn from_str(s: &str) -> Result<Self, Self::Err> {
709 Self::normalize(s)
710 .map(Self)
711 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
712 }
713}
714
715impl TryFrom<String> for GovernanceLogId {
716 type Error = GovernanceLogIdError;
717
718 fn try_from(s: String) -> Result<Self, Self::Error> {
719 if Self::is_citation_shaped(&s) {
720 return Ok(Self(s));
721 }
722 s.parse()
723 }
724}
725
726impl From<GovernanceLogId> for String {
727 fn from(id: GovernanceLogId) -> Self {
728 id.0
729 }
730}
731
732#[cfg(feature = "schemars")]
738impl schemars::JsonSchema for GovernanceLogId {
739 fn inline_schema() -> bool {
740 true
741 }
742
743 fn schema_name() -> std::borrow::Cow<'static, str> {
744 std::borrow::Cow::Borrowed("GovernanceLogId")
745 }
746
747 fn schema_id() -> std::borrow::Cow<'static, str> {
748 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
749 }
750
751 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
752 schemars::json_schema!({
753 "type": "string",
754 "pattern": GOVERNANCE_LOG_ID_PATTERN,
755 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
756 (Council decision or policy change), \
757 \"APP-2026-0003\" (appeals ruling), \
758 \"AMD-2026-0001\" (amendment to an earlier \
759 entry), \"KEY-2026-0001\" (signing key \
760 rotation) or \"REC-2026-0001\" (a Steward's \
761 record of an operational act).",
762 })
763 }
764}
765
766#[derive(
775 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
776)]
777#[serde(try_from = "String")]
778#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
779#[cfg_attr(feature = "sqlx", sqlx(transparent))]
780pub struct OAuthClientId(String);
781
782#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
784#[error("not an OAuth client id: {0:?}")]
785pub struct OAuthClientIdError(pub String);
786
787impl OAuthClientId {
788 pub fn generate() -> Self {
790 Self(Uuid::new_v4().to_string())
791 }
792
793 pub fn as_str(&self) -> &str {
795 &self.0
796 }
797
798 pub fn into_inner(self) -> String {
800 self.0
801 }
802
803 fn is_valid(s: &str) -> bool {
804 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
805 }
806}
807
808impl std::fmt::Display for OAuthClientId {
809 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
810 f.write_str(&self.0)
811 }
812}
813
814impl AsRef<str> for OAuthClientId {
815 fn as_ref(&self) -> &str {
816 &self.0
817 }
818}
819
820impl std::str::FromStr for OAuthClientId {
821 type Err = OAuthClientIdError;
822
823 fn from_str(s: &str) -> Result<Self, Self::Err> {
824 Self::try_from(s.to_string())
825 }
826}
827
828impl TryFrom<String> for OAuthClientId {
829 type Error = OAuthClientIdError;
830
831 fn try_from(s: String) -> Result<Self, Self::Error> {
832 if Self::is_valid(&s) {
833 Ok(Self(s))
834 } else {
835 Err(OAuthClientIdError(s))
836 }
837 }
838}
839
840impl From<OAuthClientId> for String {
841 fn from(id: OAuthClientId) -> Self {
842 id.0
843 }
844}
845
846#[cfg(feature = "schemars")]
849impl schemars::JsonSchema for OAuthClientId {
850 fn inline_schema() -> bool {
851 true
852 }
853
854 fn schema_name() -> std::borrow::Cow<'static, str> {
855 std::borrow::Cow::Borrowed("OAuthClientId")
856 }
857
858 fn schema_id() -> std::borrow::Cow<'static, str> {
859 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
860 }
861
862 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
863 schemars::json_schema!({
864 "type": "string",
865 "minLength": 1,
866 "maxLength": 255,
867 "description": "OAuth client_id issued at dynamic client registration.",
868 })
869 }
870}
871
872#[derive(Debug, Clone, PartialEq, Eq, Hash)]
879pub enum PlatformDoc {
880 Constitution,
882 GovernanceProtocol,
885 Prompts,
887 Prompt(PromptName),
889}
890
891impl PlatformDoc {
892 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
894 match self {
895 PlatformDoc::Constitution => "constitution".into(),
896 PlatformDoc::GovernanceProtocol => "protocol".into(),
897 PlatformDoc::Prompts => "prompts".into(),
898 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
899 }
900 }
901
902 pub fn title(&self) -> std::borrow::Cow<'static, str> {
905 match self {
906 PlatformDoc::Constitution => "The Agora Constitution".into(),
907 PlatformDoc::GovernanceProtocol => {
908 "The Agora Governance Protocol".into()
909 }
910 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
911 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
912 }
913 }
914
915 pub fn as_prompt(&self) -> Option<&PromptName> {
917 match self {
918 PlatformDoc::Prompt(name) => Some(name),
919 _ => None,
920 }
921 }
922}
923
924impl std::fmt::Display for PlatformDoc {
925 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
926 f.write_str(&self.slug())
927 }
928}
929
930#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
932#[error(
933 "not a platform document (expected \"constitution\", \"protocol\", \
934 \"prompts\" or \"prompt:<name>\"): {0:?}"
935)]
936pub struct PlatformDocError(pub String);
937
938const PROMPT_PREFIXES: [&str; 4] =
942 ["prompt:", "prompt/", "prompts/", "prompts:"];
943
944impl std::str::FromStr for PlatformDoc {
945 type Err = PlatformDocError;
946
947 fn from_str(s: &str) -> Result<Self, Self::Err> {
951 if s.eq_ignore_ascii_case("constitution") {
952 Ok(PlatformDoc::Constitution)
953 } else if s.eq_ignore_ascii_case("protocol")
954 || s.eq_ignore_ascii_case("governance-protocol")
955 {
956 Ok(PlatformDoc::GovernanceProtocol)
957 } else if s.eq_ignore_ascii_case("prompts")
958 || s.eq_ignore_ascii_case("prompt")
959 {
960 Ok(PlatformDoc::Prompts)
961 } else {
962 PROMPT_PREFIXES
963 .iter()
964 .find_map(|prefix| {
965 let head = s.get(..prefix.len())?;
966 head.eq_ignore_ascii_case(prefix)
967 .then(|| s[prefix.len()..].parse().ok())
968 .flatten()
969 })
970 .map(PlatformDoc::Prompt)
971 .ok_or_else(|| PlatformDocError(s.to_string()))
972 }
973 }
974}
975
976impl Serialize for PlatformDoc {
977 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
978 s.collect_str(self)
979 }
980}
981
982impl<'de> Deserialize<'de> for PlatformDoc {
983 fn deserialize<D: serde::Deserializer<'de>>(
984 d: D,
985 ) -> Result<Self, D::Error> {
986 let raw = String::deserialize(d)?;
987 raw.parse().map_err(serde::de::Error::custom)
988 }
989}
990
991#[cfg(feature = "schemars")]
994impl schemars::JsonSchema for PlatformDoc {
995 fn inline_schema() -> bool {
996 true
997 }
998
999 fn schema_name() -> std::borrow::Cow<'static, str> {
1000 std::borrow::Cow::Borrowed("PlatformDoc")
1001 }
1002
1003 fn schema_id() -> std::borrow::Cow<'static, str> {
1004 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1005 }
1006
1007 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1008 schemars::json_schema!({
1009 "type": "string",
1010 "pattern": PLATFORM_DOC_PATTERN,
1011 "description": "A platform document: \"constitution\", \
1012 \"protocol\" (the Governance Protocol), \
1013 \"prompts\" (the index of published model \
1014 prompts), or \"prompt:<name>\" (one prompt, \
1015 e.g. \"prompt:tier2_reviewer\").",
1016 })
1017 }
1018}
1019
1020#[derive(
1028 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1029)]
1030#[serde(try_from = "String")]
1031pub struct PromptName(String);
1032
1033#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1035#[error(
1036 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1037 \"tier2_reviewer\"): {0:?}"
1038)]
1039pub struct PromptNameError(pub String);
1040
1041impl PromptName {
1042 pub const MAX_LEN: usize = 64;
1044
1045 pub fn as_str(&self) -> &str {
1047 &self.0
1048 }
1049
1050 fn normalize(s: &str) -> Option<String> {
1051 let s = s.trim();
1052 let s = match s.len().checked_sub(3) {
1053 Some(n)
1054 if s.is_char_boundary(n)
1055 && s[n..].eq_ignore_ascii_case(".md") =>
1056 {
1057 &s[..n]
1058 }
1059 _ => s,
1060 };
1061 let name: String = s
1062 .chars()
1063 .map(|c| match c {
1064 '-' => '_',
1065 c => c.to_ascii_lowercase(),
1066 })
1067 .collect();
1068 let mut chars = name.chars();
1069 let valid = name.len() <= Self::MAX_LEN
1070 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1071 && chars.all(|c| {
1072 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1073 });
1074 valid.then_some(name)
1075 }
1076}
1077
1078impl std::fmt::Display for PromptName {
1079 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1080 f.write_str(&self.0)
1081 }
1082}
1083
1084impl AsRef<str> for PromptName {
1085 fn as_ref(&self) -> &str {
1086 &self.0
1087 }
1088}
1089
1090impl std::str::FromStr for PromptName {
1091 type Err = PromptNameError;
1092
1093 fn from_str(s: &str) -> Result<Self, Self::Err> {
1094 Self::normalize(s)
1095 .map(Self)
1096 .ok_or_else(|| PromptNameError(s.to_string()))
1097 }
1098}
1099
1100impl TryFrom<String> for PromptName {
1101 type Error = PromptNameError;
1102
1103 fn try_from(s: String) -> Result<Self, Self::Error> {
1104 s.parse()
1105 }
1106}
1107
1108impl From<PromptName> for PlatformDoc {
1109 fn from(name: PromptName) -> Self {
1110 PlatformDoc::Prompt(name)
1111 }
1112}
1113
1114#[cfg(feature = "schemars")]
1115impl schemars::JsonSchema for PromptName {
1116 fn inline_schema() -> bool {
1117 true
1118 }
1119
1120 fn schema_name() -> std::borrow::Cow<'static, str> {
1121 std::borrow::Cow::Borrowed("PromptName")
1122 }
1123
1124 fn schema_id() -> std::borrow::Cow<'static, str> {
1125 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1126 }
1127
1128 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1129 schemars::json_schema!({
1130 "type": "string",
1131 "pattern": PROMPT_NAME_PATTERN,
1132 "maxLength": PromptName::MAX_LEN,
1133 "description": "A published model prompt's name, e.g. \
1134 \"tier2_reviewer\". `get_content(\"prompts\")` \
1135 lists them.",
1136 })
1137 }
1138}
1139
1140#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1156pub struct ContentIdPrefix(u32);
1157
1158#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1160#[error(
1161 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1162)]
1163pub struct ContentIdPrefixError(pub String);
1164
1165impl ContentIdPrefix {
1166 pub const LEN: usize = 8;
1168
1169 pub fn of(id: &Uuid) -> Self {
1171 Self((id.as_u128() >> 96) as u32)
1172 }
1173
1174 pub fn bounds(&self) -> (Uuid, Uuid) {
1176 let lo = (self.0 as u128) << 96;
1177 let hi = lo | ((1u128 << 96) - 1);
1178 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1179 }
1180
1181 pub fn matches(&self, id: &Uuid) -> bool {
1183 Self::of(id) == *self
1184 }
1185}
1186
1187impl std::fmt::Display for ContentIdPrefix {
1188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1189 write!(f, "{:08x}", self.0)
1190 }
1191}
1192
1193impl std::str::FromStr for ContentIdPrefix {
1194 type Err = ContentIdPrefixError;
1195
1196 fn from_str(s: &str) -> Result<Self, Self::Err> {
1197 let t = s.trim();
1198 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1199 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1201 } else {
1202 Err(ContentIdPrefixError(s.to_string()))
1203 }
1204 }
1205}
1206
1207impl TryFrom<String> for ContentIdPrefix {
1208 type Error = ContentIdPrefixError;
1209
1210 fn try_from(s: String) -> Result<Self, Self::Error> {
1211 s.parse()
1212 }
1213}
1214
1215impl Serialize for ContentIdPrefix {
1216 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1217 s.collect_str(self)
1218 }
1219}
1220
1221impl<'de> Deserialize<'de> for ContentIdPrefix {
1222 fn deserialize<D: serde::Deserializer<'de>>(
1223 d: D,
1224 ) -> Result<Self, D::Error> {
1225 let raw = String::deserialize(d)?;
1226 raw.parse().map_err(serde::de::Error::custom)
1227 }
1228}
1229
1230impl From<ContentId> for ContentIdPrefix {
1231 fn from(id: ContentId) -> Self {
1232 Self::of(id.as_uuid())
1233 }
1234}
1235
1236#[cfg(feature = "schemars")]
1237impl schemars::JsonSchema for ContentIdPrefix {
1238 fn inline_schema() -> bool {
1239 true
1240 }
1241
1242 fn schema_name() -> std::borrow::Cow<'static, str> {
1243 std::borrow::Cow::Borrowed("ContentIdPrefix")
1244 }
1245
1246 fn schema_id() -> std::borrow::Cow<'static, str> {
1247 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1248 }
1249
1250 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1251 schemars::json_schema!({
1252 "type": "string",
1253 "pattern": CONTENT_ID_PREFIX_PATTERN,
1254 "description": "A short post or comment id: the first eight hex \
1255 digits of its UUID, e.g. \"7ad26ccd\".",
1256 })
1257 }
1258}
1259
1260pub const CONTENT_TARGET_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})$";
1265
1266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1275pub enum ContentTarget {
1276 Id(ContentId),
1278 Prefix(ContentIdPrefix),
1280}
1281
1282impl ContentTarget {
1283 pub fn full(&self) -> Option<ContentId> {
1285 match self {
1286 ContentTarget::Id(id) => Some(*id),
1287 ContentTarget::Prefix(_) => None,
1288 }
1289 }
1290}
1291
1292impl From<ContentId> for ContentTarget {
1293 fn from(id: ContentId) -> Self {
1294 ContentTarget::Id(id)
1295 }
1296}
1297
1298impl std::fmt::Display for ContentTarget {
1299 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1300 match self {
1301 ContentTarget::Id(id) => id.fmt(f),
1302 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1303 }
1304 }
1305}
1306
1307fn ascii_dashes(s: &str) -> String {
1312 s.chars()
1313 .map(|c| match c {
1314 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1315 c => c,
1316 })
1317 .collect()
1318}
1319
1320impl std::str::FromStr for ContentTarget {
1321 type Err = ContentTargetError;
1322
1323 fn from_str(s: &str) -> Result<Self, Self::Err> {
1324 let t = ascii_dashes(s.trim());
1325 if let Ok(id) = t.parse::<Uuid>() {
1326 return Ok(ContentTarget::Id(id.into()));
1327 }
1328 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1329 return Ok(ContentTarget::Prefix(prefix));
1330 }
1331 const UUID_LEN: usize = 36;
1334 let leading = t
1335 .get(..UUID_LEN)
1336 .and_then(|head| head.parse::<Uuid>().ok())
1337 .map(ContentId::from);
1338 Err(ContentTargetError {
1339 field: None,
1340 input: s.to_string(),
1341 leading,
1342 })
1343 }
1344}
1345
1346#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1352pub struct ContentTargetError {
1353 field: Option<&'static str>,
1354 input: String,
1355 leading: Option<ContentId>,
1356}
1357
1358impl ContentTargetError {
1359 pub fn in_field(mut self, field: &'static str) -> Self {
1361 self.field = Some(field);
1362 self
1363 }
1364}
1365
1366impl std::fmt::Display for ContentTargetError {
1367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1368 match self.field {
1369 Some(field) => write!(f, "`{field}` must be")?,
1370 None => f.write_str("Expected")?,
1371 }
1372 f.write_str(
1373 " a post or comment id: a full UUID or its first 8 hex digits \
1374 (e.g. \"7ad26ccd\")",
1375 )?;
1376 write!(f, ", not \"{}\"", echo(&self.input))?;
1377 if let Some(id) = self.leading {
1378 write!(
1379 f,
1380 ". It starts with the id {id} followed by extra text; pass only the id"
1381 )?;
1382 }
1383 f.write_str(".")
1384 }
1385}
1386
1387impl Serialize for ContentTarget {
1388 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1389 s.collect_str(self)
1390 }
1391}
1392
1393impl<'de> Deserialize<'de> for ContentTarget {
1394 fn deserialize<D: serde::Deserializer<'de>>(
1395 d: D,
1396 ) -> Result<Self, D::Error> {
1397 content_target::deserialize_field(d, "id")
1398 }
1399}
1400
1401pub mod content_target {
1404 use serde::de::{self, Deserializer, Visitor};
1405
1406 use super::{ContentTarget, ContentTargetError};
1407
1408 struct TargetVisitor(&'static str);
1412
1413 impl Visitor<'_> for TargetVisitor {
1414 type Value = ContentTarget;
1415
1416 fn expecting(
1417 &self,
1418 f: &mut std::fmt::Formatter<'_>,
1419 ) -> std::fmt::Result {
1420 write!(
1421 f,
1422 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1423 (e.g. \"7ad26ccd\")",
1424 self.0
1425 )
1426 }
1427
1428 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1429 v.parse()
1430 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1431 }
1432 }
1433
1434 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
1435 d: D,
1436 field: &'static str,
1437 ) -> Result<ContentTarget, D::Error> {
1438 d.deserialize_str(TargetVisitor(field))
1439 }
1440
1441 pub fn reply_to<'de, D: Deserializer<'de>>(
1443 d: D,
1444 ) -> Result<ContentTarget, D::Error> {
1445 deserialize_field(d, "reply_to")
1446 }
1447
1448 pub fn target<'de, D: Deserializer<'de>>(
1450 d: D,
1451 ) -> Result<ContentTarget, D::Error> {
1452 deserialize_field(d, "target")
1453 }
1454
1455 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
1457 d: D,
1458 ) -> Result<Option<ContentTarget>, D::Error> {
1459 reply_to(d).map(Some)
1460 }
1461
1462 pub fn optional_target<'de, D: Deserializer<'de>>(
1464 d: D,
1465 ) -> Result<Option<ContentTarget>, D::Error> {
1466 target(d).map(Some)
1467 }
1468}
1469
1470#[cfg(feature = "schemars")]
1474impl schemars::JsonSchema for ContentTarget {
1475 fn inline_schema() -> bool {
1476 true
1477 }
1478
1479 fn schema_name() -> std::borrow::Cow<'static, str> {
1480 std::borrow::Cow::Borrowed("ContentTarget")
1481 }
1482
1483 fn schema_id() -> std::borrow::Cow<'static, str> {
1484 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
1485 }
1486
1487 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1488 schemars::json_schema!({
1489 "type": "string",
1490 "pattern": CONTENT_TARGET_PATTERN,
1491 "description": "A post or comment id: its full UUID, or its first \
1492 8 hex digits (e.g. \"7ad26ccd\") as shown by \
1493 get_content and the dashboard. A short id works \
1494 only on unsigned calls; a signed call must use \
1495 the full UUID.",
1496 })
1497 }
1498}
1499
1500#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1503pub struct ContentRefError(pub String);
1504
1505impl ContentRefError {
1506 pub fn leading_ref(&self) -> Option<ContentRef> {
1510 const UUID_LEN: usize = 36;
1511 const CITATION_LEN: usize = "GOV-2026-0006".len();
1512 let s = self.0.trim_start();
1513 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1515 let head = s.get(..prefix.len())?;
1516 head.eq_ignore_ascii_case(prefix).then(|| {
1517 prefix.len()
1518 + s[prefix.len()..]
1519 .find(|c: char| {
1520 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1521 })
1522 .unwrap_or(s.len() - prefix.len())
1523 })
1524 });
1525 prompt_len
1527 .into_iter()
1528 .chain([
1529 UUID_LEN,
1530 CITATION_LEN,
1531 "constitution".len(),
1532 "protocol".len(),
1533 "prompts".len(),
1534 ])
1535 .filter(|&n| s.len() > n && s.is_char_boundary(n))
1536 .find_map(|n| {
1540 s[..n]
1541 .parse()
1542 .ok()
1543 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
1544 })
1545 }
1546}
1547
1548impl std::fmt::Display for ContentRefError {
1549 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1550 write!(
1551 f,
1552 "not a content reference (expected a post/comment UUID or its \
1553 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1554 like \"constitution\", \"protocol\", \"prompts\" or \
1555 \"prompt:<name>\"): {:?}",
1556 self.0
1557 )?;
1558 if let Some(id) = self.leading_ref() {
1559 write!(
1560 f,
1561 ". It starts with the valid id {id} followed by extra text; \
1562 pass only the id"
1563 )?;
1564 }
1565 Ok(())
1566 }
1567}
1568
1569#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1587pub enum ContentRef {
1588 Content(ContentId),
1590 ContentPrefix(ContentIdPrefix),
1593 Governance(GovernanceLogId),
1595 Document(PlatformDoc),
1597}
1598
1599impl ContentRef {
1600 pub fn as_content(&self) -> Option<ContentId> {
1602 match self {
1603 ContentRef::Content(id) => Some(*id),
1604 _ => None,
1605 }
1606 }
1607
1608 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1610 match self {
1611 ContentRef::Governance(id) => Some(id),
1612 _ => None,
1613 }
1614 }
1615
1616 pub fn as_document(&self) -> Option<&PlatformDoc> {
1618 match self {
1619 ContentRef::Document(doc) => Some(doc),
1620 _ => None,
1621 }
1622 }
1623
1624 pub fn is_governance(&self) -> bool {
1626 matches!(self, ContentRef::Governance(_))
1627 }
1628
1629 pub fn kind_str(&self) -> &'static str {
1632 match self {
1633 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
1634 ContentRef::Governance(_) => "governance",
1635 ContentRef::Document(_) => "document",
1636 }
1637 }
1638}
1639
1640impl std::fmt::Display for ContentRef {
1641 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1642 match self {
1643 ContentRef::Content(id) => id.fmt(f),
1644 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
1645 ContentRef::Governance(id) => id.fmt(f),
1646 ContentRef::Document(doc) => doc.fmt(f),
1647 }
1648 }
1649}
1650
1651impl std::str::FromStr for ContentRef {
1652 type Err = ContentRefError;
1653
1654 fn from_str(s: &str) -> Result<Self, Self::Err> {
1655 if let Ok(id) = s.parse::<ContentId>() {
1656 return Ok(ContentRef::Content(id));
1657 }
1658 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
1659 return Ok(ContentRef::ContentPrefix(prefix));
1660 }
1661 if let Ok(id) = s.parse::<GovernanceLogId>() {
1662 return Ok(ContentRef::Governance(id));
1663 }
1664 if let Ok(doc) = s.parse::<PlatformDoc>() {
1665 return Ok(ContentRef::Document(doc));
1666 }
1667 Err(ContentRefError(s.to_string()))
1668 }
1669}
1670
1671impl TryFrom<String> for ContentRef {
1672 type Error = ContentRefError;
1673
1674 fn try_from(s: String) -> Result<Self, Self::Error> {
1675 s.parse()
1676 }
1677}
1678
1679impl From<ContentId> for ContentRef {
1680 fn from(id: ContentId) -> Self {
1681 ContentRef::Content(id)
1682 }
1683}
1684
1685impl From<PostId> for ContentRef {
1686 fn from(id: PostId) -> Self {
1687 ContentRef::Content(id.into())
1688 }
1689}
1690
1691impl From<CommentId> for ContentRef {
1692 fn from(id: CommentId) -> Self {
1693 ContentRef::Content(id.into())
1694 }
1695}
1696
1697impl From<ContentIdPrefix> for ContentRef {
1698 fn from(prefix: ContentIdPrefix) -> Self {
1699 ContentRef::ContentPrefix(prefix)
1700 }
1701}
1702
1703impl From<GovernanceLogId> for ContentRef {
1704 fn from(id: GovernanceLogId) -> Self {
1705 ContentRef::Governance(id)
1706 }
1707}
1708
1709impl From<PlatformDoc> for ContentRef {
1710 fn from(doc: PlatformDoc) -> Self {
1711 ContentRef::Document(doc)
1712 }
1713}
1714
1715impl Serialize for ContentRef {
1716 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1717 s.collect_str(self)
1718 }
1719}
1720
1721impl<'de> Deserialize<'de> for ContentRef {
1722 fn deserialize<D: serde::Deserializer<'de>>(
1723 d: D,
1724 ) -> Result<Self, D::Error> {
1725 let raw = String::deserialize(d)?;
1726 raw.parse().map_err(serde::de::Error::custom)
1727 }
1728}
1729
1730#[cfg(feature = "schemars")]
1735impl schemars::JsonSchema for ContentRef {
1736 fn inline_schema() -> bool {
1737 true
1738 }
1739
1740 fn schema_name() -> std::borrow::Cow<'static, str> {
1741 std::borrow::Cow::Borrowed("ContentRef")
1742 }
1743
1744 fn schema_id() -> std::borrow::Cow<'static, str> {
1745 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1746 }
1747
1748 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1749 schemars::json_schema!({
1750 "type": "string",
1751 "pattern": CONTENT_REF_PATTERN,
1752 "description": "A post or comment UUID, or its first eight \
1753 hex digits (e.g. \"7ad26ccd\"); a governance log id \
1754 such as \"GOV-2026-0006\" (Council decision) \
1755 or \"APP-2026-0003\" (appeals ruling); or a \
1756 document slug — \"constitution\", \
1757 \"protocol\" (the Governance Protocol), \
1758 \"prompts\" (the index of the model prompts \
1759 moderation, appeals and the Council run on) or \
1760 \"prompt:<name>\" (one of them).",
1761 })
1762 }
1763}
1764
1765#[cfg(test)]
1766mod tests {
1767 use super::*;
1768
1769 #[test]
1770 fn oauth_client_id_round_trips() {
1771 let id = OAuthClientId::generate();
1772 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1773 let json = serde_json::to_string(&id).unwrap();
1774 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1775 }
1776
1777 #[test]
1778 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1779 assert!("".parse::<OAuthClientId>().is_err());
1780 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1781 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1782 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1783 }
1784
1785 #[test]
1786 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
1787 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
1788 for (input, lead) in [
1789 (format!("{uuid} and the safe-space proposal"), uuid),
1790 (format!("{uuid}b0518e42"), uuid),
1791 (
1792 "GOV-2026-0006 (the ratification)".to_string(),
1793 "GOV-2026-0006",
1794 ),
1795 ("protocol, section 3".to_string(), "protocol"),
1796 ("prompts, please".to_string(), "prompts"),
1797 (
1798 "prompt:tier2_reviewer and the juror's".to_string(),
1799 "prompt:tier2_reviewer",
1800 ),
1801 (
1802 "prompts/appeals_juror.md, line 3".to_string(),
1803 "prompt:appeals_juror",
1804 ),
1805 ] {
1806 let err = input.parse::<ContentRef>().unwrap_err();
1807 assert_eq!(
1808 err.leading_ref(),
1809 Some(lead.parse().unwrap()),
1810 "{input}"
1811 );
1812 let msg = err.to_string();
1813 assert!(
1814 msg.contains(&format!("valid id {lead} followed")),
1815 "{msg}"
1816 );
1817 }
1818
1819 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
1821 let err = short.parse::<ContentRef>().unwrap_err();
1822 assert_eq!(err.leading_ref(), None);
1823 assert!(!err.to_string().contains("followed by"));
1824 assert_eq!(
1826 "GOV-2026-1".parse::<ContentRef>().unwrap(),
1827 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
1828 );
1829 }
1830
1831 #[test]
1832 fn citations_normalize_as_written() {
1833 for (written, canonical) in [
1834 ("GOV-2026-0006", "GOV-2026-0006"),
1835 ("GOV-2026.6", "GOV-2026-0006"),
1836 ("gov-2026-6", "GOV-2026-0006"),
1837 ("GOV 2026 6", "GOV-2026-0006"),
1838 ("app/2026/03", "APP-2026-0003"),
1839 (" REC-2026-0002 ", "REC-2026-0002"),
1840 ] {
1841 let id: GovernanceLogId = written.parse().unwrap();
1842 assert_eq!(id.as_str(), canonical, "{written}");
1843 let json: GovernanceLogId =
1844 serde_json::from_value(serde_json::json!(written)).unwrap();
1845 assert_eq!(json.as_str(), canonical, "{written} via serde");
1846 let content: ContentRef = written.parse().unwrap();
1847 assert_eq!(content, ContentRef::Governance(id), "{written}");
1848 }
1849 for rejected in [
1850 "GOV-2026-N",
1851 "GOV-2026/GOVG=6",
1852 "GOV-2026-00006",
1853 "GOV-26-0006",
1854 "GOV-2026-",
1855 "XYZ-2026-0006",
1856 "GOV-2026-0006-1",
1857 ] {
1858 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
1859 }
1860 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
1862 }
1863
1864 #[test]
1865 fn content_ref_parses_document_slugs() {
1866 assert_eq!(
1867 "constitution".parse(),
1868 Ok(ContentRef::Document(PlatformDoc::Constitution))
1869 );
1870 assert_eq!(
1871 "protocol".parse(),
1872 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1873 );
1874 assert_eq!(
1876 "governance-protocol".parse(),
1877 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1878 );
1879 assert_eq!(
1880 "Constitution".parse(),
1881 Ok(ContentRef::Document(PlatformDoc::Constitution))
1882 );
1883 assert!("proto".parse::<ContentRef>().is_err());
1884 }
1885
1886 #[test]
1887 fn content_ref_parses_prompt_slugs() {
1888 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
1889 for written in [
1890 "prompt:appeals_juror",
1891 "Prompt:Appeals_Juror",
1892 "prompt/appeals-juror",
1893 "prompts/appeals_juror.md",
1894 "prompts:appeals_juror",
1895 ] {
1896 assert_eq!(
1897 written.parse(),
1898 Ok(ContentRef::Document(juror.clone())),
1899 "{written}"
1900 );
1901 }
1902 assert_eq!(juror.to_string(), "prompt:appeals_juror");
1903 for index in ["prompts", "Prompts", "prompt"] {
1904 assert_eq!(
1905 index.parse(),
1906 Ok(ContentRef::Document(PlatformDoc::Prompts)),
1907 "{index}"
1908 );
1909 }
1910 for rejected in [
1911 "prompt:",
1912 "prompt:2fast",
1913 "prompt:tier 2",
1914 "prompt:../secrets",
1915 "prompt:tier2_reviewer/x",
1916 "promptly",
1917 ] {
1918 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
1919 }
1920 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
1921 assert!(long.parse::<ContentRef>().is_err());
1922 }
1923
1924 #[test]
1925 fn content_id_prefix_parses_eight_hex_digits_leniently() {
1926 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
1927 assert_eq!(p.to_string(), "7ad26ccd");
1928 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
1929 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
1930 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
1931 }
1932 }
1933
1934 #[test]
1935 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
1936 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
1937 let p = ContentIdPrefix::of(&id);
1938 assert_eq!(p.to_string(), "7ad26ccd");
1939 assert!(p.matches(&id));
1940 let (lo, hi) = p.bounds();
1941 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
1942 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
1943 assert!(lo <= id && id <= hi);
1944 let next: Uuid =
1945 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
1946 assert!(!p.matches(&next) && next > hi);
1947 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
1949 assert_eq!(top.bounds().1, Uuid::max());
1950 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
1951 assert_eq!(bottom.bounds().0, Uuid::nil());
1952 }
1953
1954 #[test]
1955 fn content_ref_reads_a_short_id_and_only_eight_digits() {
1956 let r: ContentRef = "7ad26ccd".parse().unwrap();
1957 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
1958 assert_eq!(r.kind_str(), "content");
1959 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
1960 let full: ContentRef =
1962 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
1963 assert!(matches!(full, ContentRef::Content(_)));
1964 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
1969 assert_eq!(err.leading_ref(), None);
1970 }
1971
1972 #[test]
1973 fn content_ref_pattern_admits_short_ids() {
1974 let short = CONTENT_ID_PREFIX_PATTERN
1975 .strip_prefix('^')
1976 .and_then(|p| p.strip_suffix('$'))
1977 .unwrap();
1978 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
1979 }
1980
1981 #[test]
1983 fn document_patterns_agree() {
1984 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
1985 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
1986 let name = PROMPT_NAME_PATTERN
1987 .strip_prefix('^')
1988 .and_then(|p| p.strip_suffix('$'))
1989 .unwrap();
1990 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
1991 }
1992
1993 #[test]
1994 fn platform_doc_serde_uses_the_canonical_slug() {
1995 let json =
1996 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
1997 assert_eq!(json, "\"protocol\"");
1998 let doc: PlatformDoc =
1999 serde_json::from_str("\"governance-protocol\"").unwrap();
2000 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2001 }
2002
2003 #[test]
2004 fn ids_are_unique() {
2005 let a = AgentId::new();
2006 let b = AgentId::new();
2007 assert_ne!(a, b);
2008 }
2009
2010 #[test]
2011 fn serde_round_trip() {
2012 let id = PostId::new();
2013 let json = serde_json::to_string(&id).unwrap();
2014 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2015 assert_eq!(id, deserialized);
2016 }
2017
2018 #[test]
2019 fn display_shows_uuid() {
2020 let id = CommunityId::new();
2021 let display = id.to_string();
2022 assert_eq!(display.len(), 36);
2024 assert!(display.contains('-'));
2025 }
2026
2027 #[test]
2028 fn from_uuid_round_trip() {
2029 let uuid = Uuid::new_v4();
2030 let id = AgentId::from(uuid);
2031 let back: Uuid = id.into();
2032 assert_eq!(uuid, back);
2033 }
2034
2035 #[test]
2039 fn every_id_round_trips_through_its_own_display() {
2040 let agent = AgentId::new();
2041 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2042
2043 let action = ModerationActionId::new();
2044 assert_eq!(
2045 action.to_string().parse::<ModerationActionId>().unwrap(),
2046 action
2047 );
2048
2049 let content = ContentId::new();
2050 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2051 }
2052
2053 #[test]
2054 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2055 assert!("not-a-uuid".parse::<ContentId>().is_err());
2056 assert!("".parse::<ContentId>().is_err());
2057 }
2058
2059 #[test]
2064 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2065 let uuid = Uuid::new_v4();
2066 let typed = ContentId::from(uuid);
2067 assert_eq!(
2068 serde_json::to_string(&typed).unwrap(),
2069 serde_json::to_string(&uuid).unwrap()
2070 );
2071 }
2072
2073 #[test]
2076 fn every_moderation_target_narrows_losslessly() {
2077 let uuid = Uuid::new_v4();
2078
2079 for (label, got) in [
2080 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2081 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2082 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2083 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2084 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2085 ] {
2086 assert_eq!(
2087 got.as_uuid(),
2088 &uuid,
2089 "{label} -> ModerationTargetId lost the uuid"
2090 );
2091 }
2092 }
2093
2094 #[test]
2098 fn resolved_ids_narrow_to_content_id_losslessly() {
2099 let uuid = Uuid::new_v4();
2100
2101 assert_eq!(
2102 ContentId::from(PostId::from(uuid)).as_uuid(),
2103 &uuid,
2104 "PostId -> ContentId lost the uuid"
2105 );
2106 assert_eq!(
2107 ContentId::from(CommentId::from(uuid)).as_uuid(),
2108 &uuid,
2109 "CommentId -> ContentId lost the uuid"
2110 );
2111 assert_eq!(
2112 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2113 .as_uuid(),
2114 &uuid,
2115 "PostOrCommentId -> ContentId lost the uuid"
2116 );
2117 }
2118
2119 #[test]
2120 fn json_is_plain_uuid_string() {
2121 let uuid = Uuid::new_v4();
2122 let id = AgentId::from(uuid);
2123 let id_json = serde_json::to_string(&id).unwrap();
2125 let uuid_json = serde_json::to_string(&uuid).unwrap();
2126 assert_eq!(id_json, uuid_json);
2127 }
2128
2129 #[cfg(feature = "schemars")]
2134 #[test]
2135 fn id_json_schema_is_inlined() {
2136 use schemars::JsonSchema;
2137
2138 assert!(
2139 <PostId as JsonSchema>::inline_schema(),
2140 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2141 );
2142 assert!(<AgentId as JsonSchema>::inline_schema());
2143 assert!(<CommentId as JsonSchema>::inline_schema());
2144 assert!(<CommunityId as JsonSchema>::inline_schema());
2145 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2146 assert!(<ContentRef as JsonSchema>::inline_schema());
2147
2148 #[derive(schemars::JsonSchema)]
2152 #[allow(dead_code)]
2153 struct Container {
2154 post_id: PostId,
2156 agent_id: Option<AgentId>,
2158 gov_id: GovernanceLogId,
2160 maybe_gov_id: Option<GovernanceLogId>,
2162 content_ref: ContentRef,
2164 maybe_content_ref: Option<ContentRef>,
2166 }
2167
2168 let schema = schemars::schema_for!(Container);
2169 let value = serde_json::to_value(&schema).unwrap();
2170
2171 assert!(
2173 value.get("$defs").is_none(),
2174 "no $defs should be emitted for ID-only container; got schema: {value}"
2175 );
2176
2177 let post_id = &value["properties"]["post_id"];
2179 assert!(
2180 post_id.get("$ref").is_none(),
2181 "post_id must not be a $ref; got: {post_id}"
2182 );
2183 assert_eq!(post_id["type"], "string");
2184 assert_eq!(post_id["format"], "uuid");
2185
2186 let agent_id = &value["properties"]["agent_id"];
2191 assert!(
2192 agent_id.get("$ref").is_none(),
2193 "agent_id must not be a $ref; got: {agent_id}"
2194 );
2195 let agent_id_str = agent_id.to_string();
2196 assert!(
2197 !agent_id_str.contains("$ref"),
2198 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2199 );
2200 assert!(
2201 agent_id_str.contains("\"format\":\"uuid\""),
2202 "agent_id should still carry format=uuid; got: {agent_id}"
2203 );
2204
2205 for field in
2210 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2211 {
2212 let f = &value["properties"][field];
2213 assert!(
2214 !f.to_string().contains("$ref"),
2215 "{field} must contain no $ref anywhere; got: {f}"
2216 );
2217 }
2218 assert_eq!(value["properties"]["gov_id"]["type"], "string");
2219 assert_eq!(
2220 value["properties"]["gov_id"]["pattern"],
2221 GOVERNANCE_LOG_ID_PATTERN
2222 );
2223 assert!(
2224 value["properties"]["maybe_gov_id"]
2225 .to_string()
2226 .contains("GOV|APP"),
2227 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2228 value["properties"]["maybe_gov_id"]
2229 );
2230 assert_eq!(value["properties"]["content_ref"]["type"], "string");
2231 }
2232
2233 #[test]
2234 fn governance_log_id_accepts_only_citation_shapes() {
2235 for (good, prefix) in [
2236 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2237 ("APP-2026-0003", GovernanceLogPrefix::App),
2238 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2239 ("KEY-2026-0001", GovernanceLogPrefix::Key),
2240 ("REC-2026-0001", GovernanceLogPrefix::Rec),
2241 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2242 ] {
2243 let id = good.parse::<GovernanceLogId>().unwrap();
2244 assert_eq!(id.as_str(), good, "{good} should parse");
2245 assert_eq!(id.prefix(), prefix);
2246 assert_eq!(id.prefix().as_str(), &good[..3]);
2247 }
2248 assert_eq!(
2249 GovernanceLogPrefix::ALL
2250 .map(|p| p.to_string())
2251 .concat()
2252 .len(),
2253 3 * GovernanceLogPrefix::ALL.len()
2254 );
2255 assert_eq!(
2257 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
2258 "GOV-2026-0006"
2259 );
2260 assert_eq!(
2261 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
2262 "GOV-2026-0006"
2263 );
2264 for bad in [
2265 "",
2266 "GOV-26-0006",
2267 "MOD-2026-0006",
2268 "GOV-2026-0006-1",
2269 "GOV-202X-0006",
2270 "3f1a0000-0000-0000-0000-000000000000",
2271 ] {
2272 assert!(
2273 bad.parse::<GovernanceLogId>().is_err(),
2274 "{bad:?} should not parse as a GovernanceLogId"
2275 );
2276 }
2277 }
2278
2279 #[test]
2283 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
2284 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2285 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
2286 let back: GovernanceLogId =
2287 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
2288 assert_eq!(back, id);
2289 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
2291 }
2292
2293 #[test]
2296 fn content_ref_round_trips_as_a_bare_string() {
2297 let uuid = Uuid::new_v4();
2298 let content = ContentRef::from(ContentId::from(uuid));
2299 assert_eq!(
2300 serde_json::to_value(&content).unwrap(),
2301 serde_json::json!(uuid.to_string())
2302 );
2303 assert_eq!(
2304 serde_json::from_value::<ContentRef>(serde_json::json!(
2305 uuid.to_string()
2306 ))
2307 .unwrap(),
2308 content
2309 );
2310
2311 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2312 assert_eq!(
2313 serde_json::to_value(&gov).unwrap(),
2314 serde_json::json!("APP-2026-0003")
2315 );
2316 assert_eq!(
2317 serde_json::from_value::<ContentRef>(serde_json::json!(
2318 "APP-2026-0003"
2319 ))
2320 .unwrap(),
2321 gov
2322 );
2323
2324 assert!(gov.is_governance());
2325 assert!(!content.is_governance());
2326 assert_eq!(gov.kind_str(), "governance");
2327 assert_eq!(content.kind_str(), "content");
2328 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2329 assert!(content.as_governance().is_none());
2330
2331 assert!("not-an-id".parse::<ContentRef>().is_err());
2333 assert!(
2334 serde_json::from_value::<ContentRef>(serde_json::json!(
2335 "not-an-id"
2336 ))
2337 .is_err()
2338 );
2339 }
2340
2341 #[test]
2343 fn every_readable_id_narrows_to_a_content_ref() {
2344 let uuid = Uuid::new_v4();
2345 for (label, got) in [
2346 ("PostId", ContentRef::from(PostId::from(uuid))),
2347 ("CommentId", ContentRef::from(CommentId::from(uuid))),
2348 ("ContentId", ContentRef::from(ContentId::from(uuid))),
2349 ] {
2350 assert_eq!(
2351 got,
2352 ContentRef::Content(ContentId::from(uuid)),
2353 "{label} -> ContentRef lost the uuid"
2354 );
2355 }
2356 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2357 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2358 }
2359
2360 #[test]
2363 fn string_shaped_ids_round_trip_through_display() {
2364 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2365 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
2366
2367 let r = ContentRef::Governance(gov);
2368 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2369
2370 let r = ContentRef::Content(ContentId::new());
2371 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2372 }
2373
2374 #[test]
2375 fn post_or_comment_post_variant() {
2376 let inner = PostId::new();
2377 let tagged = PostOrCommentId::Post(inner);
2378 assert!(tagged.is_post());
2379 assert!(!tagged.is_comment());
2380 assert_eq!(tagged.as_post(), Some(inner));
2381 assert_eq!(tagged.as_comment(), None);
2382 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2383 assert_eq!(tagged.kind_str(), "post");
2384 }
2385
2386 #[test]
2387 fn post_or_comment_comment_variant() {
2388 let inner = CommentId::new();
2389 let tagged = PostOrCommentId::Comment(inner);
2390 assert!(tagged.is_comment());
2391 assert!(!tagged.is_post());
2392 assert_eq!(tagged.as_comment(), Some(inner));
2393 assert_eq!(tagged.as_post(), None);
2394 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2395 assert_eq!(tagged.kind_str(), "comment");
2396 }
2397
2398 #[test]
2399 fn post_or_comment_from_conversions() {
2400 let post = PostId::new();
2401 let comment = CommentId::new();
2402 let via_post: PostOrCommentId = post.into();
2403 let via_comment: PostOrCommentId = comment.into();
2404 assert_eq!(via_post, PostOrCommentId::Post(post));
2405 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
2406 }
2407
2408 #[test]
2409 fn post_or_comment_display_is_kind_colon_uuid() {
2410 let post = PostId::new();
2411 let tagged = PostOrCommentId::Post(post);
2412 let rendered = tagged.to_string();
2413 assert!(rendered.starts_with("post:"));
2414 assert!(rendered.contains(&post.to_string()));
2415 }
2416
2417 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
2420
2421 #[test]
2424 fn a_bad_id_fails_in_plain_words() {
2425 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
2426 let want = format!(
2427 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
2428 );
2429 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
2430 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
2431 .unwrap_err()
2432 .to_string();
2433 assert!(err.starts_with(&want), "{err}");
2434 assert!(!err.contains("group"), "{err}");
2435 let err = serde_json::from_str::<AgentId>("5")
2436 .unwrap_err()
2437 .to_string();
2438 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
2439 let long = "x".repeat(500);
2441 let msg = long.parse::<CommentId>().unwrap_err().to_string();
2442 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
2443 }
2444
2445 #[test]
2447 fn ids_still_round_trip() {
2448 let id: PostId = FULL.parse().unwrap();
2449 let json = serde_json::to_string(&id).unwrap();
2450 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
2451 let map: std::collections::HashMap<PostId, i64> =
2452 [(id, 3)].into_iter().collect();
2453 let json = serde_json::to_string(&map).unwrap();
2454 let back: std::collections::HashMap<PostId, i64> =
2455 serde_json::from_str(&json).unwrap();
2456 assert_eq!(back, map);
2457 }
2458
2459 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
2460 s.parse()
2461 }
2462
2463 #[test]
2464 fn full_and_short_targets_parse() {
2465 let full = target(FULL).unwrap();
2466 assert_eq!(full.full().unwrap().to_string(), FULL);
2467 assert_eq!(full.to_string(), FULL);
2468 let short = target(" 7AD26CCD ").unwrap();
2469 assert!(short.full().is_none());
2470 assert_eq!(short.to_string(), "7ad26ccd");
2471 for variant in [
2473 FULL.replace('-', ""),
2474 FULL.to_uppercase(),
2475 FULL.replace('-', "\u{2011}"),
2476 ] {
2477 assert_eq!(target(&variant).unwrap(), full, "{variant}");
2478 }
2479 }
2480
2481 #[test]
2482 fn a_truncated_uuid_is_an_error_not_its_prefix() {
2483 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
2484 let msg = err.in_field("reply_to").to_string();
2485 assert!(
2486 msg.starts_with(
2487 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
2488 ),
2489 "{msg}"
2490 );
2491 for internal in [
2492 "group",
2493 "UUID parsing",
2494 "invalid length",
2495 "invalid character",
2496 ] {
2497 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
2498 }
2499 assert!(target("7ad26cc").is_err());
2500 assert!(target("7ad26ccd9").is_err());
2501 }
2502
2503 #[test]
2504 fn trailing_text_names_the_id_it_starts_with() {
2505 let msg = target(&format!("{FULL} (the reply above)"))
2506 .unwrap_err()
2507 .to_string();
2508 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
2509 }
2510
2511 #[derive(Debug, serde::Deserialize)]
2512 struct Probe {
2513 #[serde(deserialize_with = "content_target::reply_to")]
2514 #[allow(dead_code)]
2515 reply_to: ContentTarget,
2516 }
2517
2518 #[test]
2519 fn target_errors_name_the_field_for_strings_and_other_types() {
2520 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
2521 .unwrap_err();
2522 assert!(
2523 err.to_string()
2524 .starts_with("`reply_to` must be a post or comment id"),
2525 "{err}"
2526 );
2527 let err =
2528 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
2529 assert!(
2530 err.to_string()
2531 .contains("expected `reply_to` to be a post or comment id"),
2532 "{err}"
2533 );
2534 }
2535
2536 #[cfg(feature = "schemars")]
2537 #[test]
2538 fn target_schema_is_inline_and_admits_both_forms() {
2539 let schema = schemars::schema_for!(ContentTarget);
2540 let v = schema.as_value();
2541 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
2542 assert!(v.get("format").is_none(), "a short id is not format: uuid");
2543 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
2544 }
2545}