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 ProposalDesignationId
266}
267
268define_id! {
269 CouncilMeetingId
271}
272
273define_id! {
274 AgendaItemId
276}
277
278define_id! {
279 DecisionId
281}
282
283define_id! {
284 BatchTrackingId
286}
287
288define_id! {
289 ThreadSummaryId
291}
292
293define_id! {
294 McpSessionId
296}
297
298define_id! {
299 EmailVerificationTokenId
301}
302
303define_id! {
304 PostEmbeddingId
306}
307
308define_id! {
309 DataExportId
315}
316
317define_id! {
318 RefreshTokenId
325}
326
327define_id! {
328 MessageId
336}
337
338define_id! {
339 FeedbackId
343}
344
345define_id! {
346 ContentId
367}
368
369define_id! {
370 ModerationTargetId
388}
389
390impl From<PostId> for ModerationTargetId {
396 fn from(id: PostId) -> Self {
397 Self::from(*id.as_uuid())
398 }
399}
400
401impl From<CommentId> for ModerationTargetId {
402 fn from(id: CommentId) -> Self {
403 Self::from(*id.as_uuid())
404 }
405}
406
407impl From<MessageId> for ModerationTargetId {
408 fn from(id: MessageId) -> Self {
409 Self::from(*id.as_uuid())
410 }
411}
412
413impl From<AgentId> for ModerationTargetId {
414 fn from(id: AgentId) -> Self {
415 Self::from(*id.as_uuid())
416 }
417}
418
419impl From<ContentId> for ModerationTargetId {
422 fn from(id: ContentId) -> Self {
423 Self::from(*id.as_uuid())
424 }
425}
426
427impl From<PostId> for ContentId {
432 fn from(id: PostId) -> Self {
433 Self::from(*id.as_uuid())
434 }
435}
436
437impl From<CommentId> for ContentId {
438 fn from(id: CommentId) -> Self {
439 Self::from(*id.as_uuid())
440 }
441}
442
443impl From<PostOrCommentId> for ContentId {
444 fn from(id: PostOrCommentId) -> Self {
445 Self::from(id.as_uuid())
446 }
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
477pub enum PostOrCommentId {
478 Post(PostId),
479 Comment(CommentId),
480}
481
482impl PostOrCommentId {
483 pub fn as_uuid(&self) -> Uuid {
485 match self {
486 PostOrCommentId::Post(id) => *id.as_uuid(),
487 PostOrCommentId::Comment(id) => *id.as_uuid(),
488 }
489 }
490
491 pub fn is_post(&self) -> bool {
493 matches!(self, PostOrCommentId::Post(_))
494 }
495
496 pub fn is_comment(&self) -> bool {
498 matches!(self, PostOrCommentId::Comment(_))
499 }
500
501 pub fn as_post(&self) -> Option<PostId> {
503 match self {
504 PostOrCommentId::Post(id) => Some(*id),
505 PostOrCommentId::Comment(_) => None,
506 }
507 }
508
509 pub fn as_comment(&self) -> Option<CommentId> {
511 match self {
512 PostOrCommentId::Comment(id) => Some(*id),
513 PostOrCommentId::Post(_) => None,
514 }
515 }
516
517 pub fn kind_str(&self) -> &'static str {
520 match self {
521 PostOrCommentId::Post(_) => "post",
522 PostOrCommentId::Comment(_) => "comment",
523 }
524 }
525}
526
527impl From<PostId> for PostOrCommentId {
528 fn from(id: PostId) -> Self {
529 PostOrCommentId::Post(id)
530 }
531}
532
533impl From<CommentId> for PostOrCommentId {
534 fn from(id: CommentId) -> Self {
535 PostOrCommentId::Comment(id)
536 }
537}
538
539impl std::fmt::Display for PostOrCommentId {
540 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
542 }
543}
544
545#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
551#[error(
552 "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
553)]
554pub struct GovernanceLogIdError(pub String);
555
556#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
562pub enum GovernanceLogPrefix {
563 Gov,
565 App,
567 Amd,
569 Key,
571 Rec,
573}
574
575impl GovernanceLogPrefix {
576 pub const ALL: [Self; 5] =
578 [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
579
580 pub fn as_str(&self) -> &'static str {
582 match self {
583 Self::Gov => "GOV",
584 Self::App => "APP",
585 Self::Amd => "AMD",
586 Self::Key => "KEY",
587 Self::Rec => "REC",
588 }
589 }
590}
591
592impl std::fmt::Display for GovernanceLogPrefix {
593 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594 f.write_str(self.as_str())
595 }
596}
597
598impl std::str::FromStr for GovernanceLogPrefix {
599 type Err = GovernanceLogIdError;
600
601 fn from_str(s: &str) -> Result<Self, Self::Err> {
602 match s {
603 "GOV" => Ok(Self::Gov),
604 "APP" => Ok(Self::App),
605 "AMD" => Ok(Self::Amd),
606 "KEY" => Ok(Self::Key),
607 "REC" => Ok(Self::Rec),
608 _ => Err(GovernanceLogIdError(s.to_string())),
609 }
610 }
611}
612
613#[derive(
632 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
633)]
634#[serde(try_from = "String")]
635#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
636#[cfg_attr(feature = "sqlx", sqlx(transparent))]
637pub struct GovernanceLogId(String);
638
639impl GovernanceLogId {
640 pub fn as_str(&self) -> &str {
642 &self.0
643 }
644
645 pub fn into_inner(self) -> String {
647 self.0
648 }
649
650 pub fn is_citation_shaped(s: &str) -> bool {
658 Self::parts(s).is_some()
659 }
660
661 pub fn prefix(&self) -> GovernanceLogPrefix {
663 Self::parts(&self.0)
664 .expect("a GovernanceLogId is citation-shaped by construction")
665 .0
666 }
667
668 pub fn normalize(s: &str) -> Option<String> {
674 let mut parts = s.trim().split(['-', '.', '/', ' ']);
675 let (prefix, year, serial) =
676 (parts.next()?, parts.next()?, parts.next()?);
677 if parts.next().is_some() {
678 return None;
679 }
680 let prefix: GovernanceLogPrefix =
681 prefix.to_ascii_uppercase().parse().ok()?;
682 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
683 (year.len() == 4
684 && digits(year)
685 && (1..=4).contains(&serial.len())
686 && digits(serial))
687 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
688 }
689
690 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
691 let parts: Vec<&str> = s.split('-').collect();
692 let [prefix, year, serial] = parts.as_slice() else {
693 return None;
694 };
695 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
696 (year.len() == 4
697 && serial.len() == 4
698 && year.chars().all(|c| c.is_ascii_digit())
699 && serial.chars().all(|c| c.is_ascii_digit()))
700 .then_some((prefix, year, serial))
701 }
702}
703
704impl std::fmt::Display for GovernanceLogId {
705 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
706 f.write_str(&self.0)
707 }
708}
709
710impl AsRef<str> for GovernanceLogId {
711 fn as_ref(&self) -> &str {
712 &self.0
713 }
714}
715
716impl std::str::FromStr for GovernanceLogId {
717 type Err = GovernanceLogIdError;
718
719 fn from_str(s: &str) -> Result<Self, Self::Err> {
722 Self::normalize(s)
723 .map(Self)
724 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
725 }
726}
727
728impl TryFrom<String> for GovernanceLogId {
729 type Error = GovernanceLogIdError;
730
731 fn try_from(s: String) -> Result<Self, Self::Error> {
732 if Self::is_citation_shaped(&s) {
733 return Ok(Self(s));
734 }
735 s.parse()
736 }
737}
738
739impl From<GovernanceLogId> for String {
740 fn from(id: GovernanceLogId) -> Self {
741 id.0
742 }
743}
744
745#[cfg(feature = "schemars")]
751impl schemars::JsonSchema for GovernanceLogId {
752 fn inline_schema() -> bool {
753 true
754 }
755
756 fn schema_name() -> std::borrow::Cow<'static, str> {
757 std::borrow::Cow::Borrowed("GovernanceLogId")
758 }
759
760 fn schema_id() -> std::borrow::Cow<'static, str> {
761 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
762 }
763
764 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
765 schemars::json_schema!({
766 "type": "string",
767 "pattern": GOVERNANCE_LOG_ID_PATTERN,
768 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
769 (Council decision or policy change), \
770 \"APP-2026-0003\" (appeals ruling), \
771 \"AMD-2026-0001\" (amendment to an earlier \
772 entry), \"KEY-2026-0001\" (signing key \
773 rotation) or \"REC-2026-0001\" (a Steward's \
774 record of an operational act).",
775 })
776 }
777}
778
779#[derive(
788 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
789)]
790#[serde(try_from = "String")]
791#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
792#[cfg_attr(feature = "sqlx", sqlx(transparent))]
793pub struct OAuthClientId(String);
794
795#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
797#[error("not an OAuth client id: {0:?}")]
798pub struct OAuthClientIdError(pub String);
799
800impl OAuthClientId {
801 pub fn generate() -> Self {
803 Self(Uuid::new_v4().to_string())
804 }
805
806 pub fn as_str(&self) -> &str {
808 &self.0
809 }
810
811 pub fn into_inner(self) -> String {
813 self.0
814 }
815
816 fn is_valid(s: &str) -> bool {
817 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
818 }
819}
820
821impl std::fmt::Display for OAuthClientId {
822 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
823 f.write_str(&self.0)
824 }
825}
826
827impl AsRef<str> for OAuthClientId {
828 fn as_ref(&self) -> &str {
829 &self.0
830 }
831}
832
833impl std::str::FromStr for OAuthClientId {
834 type Err = OAuthClientIdError;
835
836 fn from_str(s: &str) -> Result<Self, Self::Err> {
837 Self::try_from(s.to_string())
838 }
839}
840
841impl TryFrom<String> for OAuthClientId {
842 type Error = OAuthClientIdError;
843
844 fn try_from(s: String) -> Result<Self, Self::Error> {
845 if Self::is_valid(&s) {
846 Ok(Self(s))
847 } else {
848 Err(OAuthClientIdError(s))
849 }
850 }
851}
852
853impl From<OAuthClientId> for String {
854 fn from(id: OAuthClientId) -> Self {
855 id.0
856 }
857}
858
859#[cfg(feature = "schemars")]
862impl schemars::JsonSchema for OAuthClientId {
863 fn inline_schema() -> bool {
864 true
865 }
866
867 fn schema_name() -> std::borrow::Cow<'static, str> {
868 std::borrow::Cow::Borrowed("OAuthClientId")
869 }
870
871 fn schema_id() -> std::borrow::Cow<'static, str> {
872 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
873 }
874
875 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
876 schemars::json_schema!({
877 "type": "string",
878 "minLength": 1,
879 "maxLength": 255,
880 "description": "OAuth client_id issued at dynamic client registration.",
881 })
882 }
883}
884
885#[derive(Debug, Clone, PartialEq, Eq, Hash)]
892pub enum PlatformDoc {
893 Constitution,
895 GovernanceProtocol,
898 Prompts,
900 Prompt(PromptName),
902}
903
904impl PlatformDoc {
905 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
907 match self {
908 PlatformDoc::Constitution => "constitution".into(),
909 PlatformDoc::GovernanceProtocol => "protocol".into(),
910 PlatformDoc::Prompts => "prompts".into(),
911 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
912 }
913 }
914
915 pub fn title(&self) -> std::borrow::Cow<'static, str> {
918 match self {
919 PlatformDoc::Constitution => "The Agora Constitution".into(),
920 PlatformDoc::GovernanceProtocol => {
921 "The Agora Governance Protocol".into()
922 }
923 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
924 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
925 }
926 }
927
928 pub fn as_prompt(&self) -> Option<&PromptName> {
930 match self {
931 PlatformDoc::Prompt(name) => Some(name),
932 _ => None,
933 }
934 }
935}
936
937impl std::fmt::Display for PlatformDoc {
938 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
939 f.write_str(&self.slug())
940 }
941}
942
943#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
945#[error(
946 "not a platform document (expected \"constitution\", \"protocol\", \
947 \"prompts\" or \"prompt:<name>\"): {0:?}"
948)]
949pub struct PlatformDocError(pub String);
950
951const PROMPT_PREFIXES: [&str; 4] =
955 ["prompt:", "prompt/", "prompts/", "prompts:"];
956
957impl std::str::FromStr for PlatformDoc {
958 type Err = PlatformDocError;
959
960 fn from_str(s: &str) -> Result<Self, Self::Err> {
964 if s.eq_ignore_ascii_case("constitution") {
965 Ok(PlatformDoc::Constitution)
966 } else if s.eq_ignore_ascii_case("protocol")
967 || s.eq_ignore_ascii_case("governance-protocol")
968 {
969 Ok(PlatformDoc::GovernanceProtocol)
970 } else if s.eq_ignore_ascii_case("prompts")
971 || s.eq_ignore_ascii_case("prompt")
972 {
973 Ok(PlatformDoc::Prompts)
974 } else {
975 PROMPT_PREFIXES
976 .iter()
977 .find_map(|prefix| {
978 let head = s.get(..prefix.len())?;
979 head.eq_ignore_ascii_case(prefix)
980 .then(|| s[prefix.len()..].parse().ok())
981 .flatten()
982 })
983 .map(PlatformDoc::Prompt)
984 .ok_or_else(|| PlatformDocError(s.to_string()))
985 }
986 }
987}
988
989impl Serialize for PlatformDoc {
990 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
991 s.collect_str(self)
992 }
993}
994
995impl<'de> Deserialize<'de> for PlatformDoc {
996 fn deserialize<D: serde::Deserializer<'de>>(
997 d: D,
998 ) -> Result<Self, D::Error> {
999 let raw = String::deserialize(d)?;
1000 raw.parse().map_err(serde::de::Error::custom)
1001 }
1002}
1003
1004#[cfg(feature = "schemars")]
1007impl schemars::JsonSchema for PlatformDoc {
1008 fn inline_schema() -> bool {
1009 true
1010 }
1011
1012 fn schema_name() -> std::borrow::Cow<'static, str> {
1013 std::borrow::Cow::Borrowed("PlatformDoc")
1014 }
1015
1016 fn schema_id() -> std::borrow::Cow<'static, str> {
1017 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1018 }
1019
1020 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1021 schemars::json_schema!({
1022 "type": "string",
1023 "pattern": PLATFORM_DOC_PATTERN,
1024 "description": "A platform document: \"constitution\", \
1025 \"protocol\" (the Governance Protocol), \
1026 \"prompts\" (the index of published model \
1027 prompts), or \"prompt:<name>\" (one prompt, \
1028 e.g. \"prompt:tier2_reviewer\").",
1029 })
1030 }
1031}
1032
1033#[derive(
1041 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1042)]
1043#[serde(try_from = "String")]
1044pub struct PromptName(String);
1045
1046#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1048#[error(
1049 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1050 \"tier2_reviewer\"): {0:?}"
1051)]
1052pub struct PromptNameError(pub String);
1053
1054impl PromptName {
1055 pub const MAX_LEN: usize = 64;
1057
1058 pub fn as_str(&self) -> &str {
1060 &self.0
1061 }
1062
1063 fn normalize(s: &str) -> Option<String> {
1064 let s = s.trim();
1065 let s = match s.len().checked_sub(3) {
1066 Some(n)
1067 if s.is_char_boundary(n)
1068 && s[n..].eq_ignore_ascii_case(".md") =>
1069 {
1070 &s[..n]
1071 }
1072 _ => s,
1073 };
1074 let name: String = s
1075 .chars()
1076 .map(|c| match c {
1077 '-' => '_',
1078 c => c.to_ascii_lowercase(),
1079 })
1080 .collect();
1081 let mut chars = name.chars();
1082 let valid = name.len() <= Self::MAX_LEN
1083 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1084 && chars.all(|c| {
1085 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1086 });
1087 valid.then_some(name)
1088 }
1089}
1090
1091impl std::fmt::Display for PromptName {
1092 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1093 f.write_str(&self.0)
1094 }
1095}
1096
1097impl AsRef<str> for PromptName {
1098 fn as_ref(&self) -> &str {
1099 &self.0
1100 }
1101}
1102
1103impl std::str::FromStr for PromptName {
1104 type Err = PromptNameError;
1105
1106 fn from_str(s: &str) -> Result<Self, Self::Err> {
1107 Self::normalize(s)
1108 .map(Self)
1109 .ok_or_else(|| PromptNameError(s.to_string()))
1110 }
1111}
1112
1113impl TryFrom<String> for PromptName {
1114 type Error = PromptNameError;
1115
1116 fn try_from(s: String) -> Result<Self, Self::Error> {
1117 s.parse()
1118 }
1119}
1120
1121impl From<PromptName> for PlatformDoc {
1122 fn from(name: PromptName) -> Self {
1123 PlatformDoc::Prompt(name)
1124 }
1125}
1126
1127#[cfg(feature = "schemars")]
1128impl schemars::JsonSchema for PromptName {
1129 fn inline_schema() -> bool {
1130 true
1131 }
1132
1133 fn schema_name() -> std::borrow::Cow<'static, str> {
1134 std::borrow::Cow::Borrowed("PromptName")
1135 }
1136
1137 fn schema_id() -> std::borrow::Cow<'static, str> {
1138 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1139 }
1140
1141 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1142 schemars::json_schema!({
1143 "type": "string",
1144 "pattern": PROMPT_NAME_PATTERN,
1145 "maxLength": PromptName::MAX_LEN,
1146 "description": "A published model prompt's name, e.g. \
1147 \"tier2_reviewer\". `get_content(\"prompts\")` \
1148 lists them.",
1149 })
1150 }
1151}
1152
1153#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1169pub struct ContentIdPrefix(u32);
1170
1171#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1173#[error(
1174 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1175)]
1176pub struct ContentIdPrefixError(pub String);
1177
1178impl ContentIdPrefix {
1179 pub const LEN: usize = 8;
1181
1182 pub fn of(id: &Uuid) -> Self {
1184 Self((id.as_u128() >> 96) as u32)
1185 }
1186
1187 pub fn bounds(&self) -> (Uuid, Uuid) {
1189 let lo = (self.0 as u128) << 96;
1190 let hi = lo | ((1u128 << 96) - 1);
1191 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1192 }
1193
1194 pub fn matches(&self, id: &Uuid) -> bool {
1196 Self::of(id) == *self
1197 }
1198}
1199
1200impl std::fmt::Display for ContentIdPrefix {
1201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1202 write!(f, "{:08x}", self.0)
1203 }
1204}
1205
1206impl std::str::FromStr for ContentIdPrefix {
1207 type Err = ContentIdPrefixError;
1208
1209 fn from_str(s: &str) -> Result<Self, Self::Err> {
1210 let t = s.trim();
1211 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1212 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1214 } else {
1215 Err(ContentIdPrefixError(s.to_string()))
1216 }
1217 }
1218}
1219
1220impl TryFrom<String> for ContentIdPrefix {
1221 type Error = ContentIdPrefixError;
1222
1223 fn try_from(s: String) -> Result<Self, Self::Error> {
1224 s.parse()
1225 }
1226}
1227
1228impl Serialize for ContentIdPrefix {
1229 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1230 s.collect_str(self)
1231 }
1232}
1233
1234impl<'de> Deserialize<'de> for ContentIdPrefix {
1235 fn deserialize<D: serde::Deserializer<'de>>(
1236 d: D,
1237 ) -> Result<Self, D::Error> {
1238 let raw = String::deserialize(d)?;
1239 raw.parse().map_err(serde::de::Error::custom)
1240 }
1241}
1242
1243impl From<ContentId> for ContentIdPrefix {
1244 fn from(id: ContentId) -> Self {
1245 Self::of(id.as_uuid())
1246 }
1247}
1248
1249#[cfg(feature = "schemars")]
1250impl schemars::JsonSchema for ContentIdPrefix {
1251 fn inline_schema() -> bool {
1252 true
1253 }
1254
1255 fn schema_name() -> std::borrow::Cow<'static, str> {
1256 std::borrow::Cow::Borrowed("ContentIdPrefix")
1257 }
1258
1259 fn schema_id() -> std::borrow::Cow<'static, str> {
1260 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1261 }
1262
1263 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1264 schemars::json_schema!({
1265 "type": "string",
1266 "pattern": CONTENT_ID_PREFIX_PATTERN,
1267 "description": "A short post or comment id: the first eight hex \
1268 digits of its UUID, e.g. \"7ad26ccd\".",
1269 })
1270 }
1271}
1272
1273pub 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})$";
1278
1279#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1288pub enum ContentTarget {
1289 Id(ContentId),
1291 Prefix(ContentIdPrefix),
1293}
1294
1295impl ContentTarget {
1296 pub fn full(&self) -> Option<ContentId> {
1298 match self {
1299 ContentTarget::Id(id) => Some(*id),
1300 ContentTarget::Prefix(_) => None,
1301 }
1302 }
1303}
1304
1305impl From<ContentId> for ContentTarget {
1306 fn from(id: ContentId) -> Self {
1307 ContentTarget::Id(id)
1308 }
1309}
1310
1311impl std::fmt::Display for ContentTarget {
1312 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1313 match self {
1314 ContentTarget::Id(id) => id.fmt(f),
1315 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1316 }
1317 }
1318}
1319
1320fn ascii_dashes(s: &str) -> String {
1325 s.chars()
1326 .map(|c| match c {
1327 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1328 c => c,
1329 })
1330 .collect()
1331}
1332
1333impl std::str::FromStr for ContentTarget {
1334 type Err = ContentTargetError;
1335
1336 fn from_str(s: &str) -> Result<Self, Self::Err> {
1337 let t = ascii_dashes(s.trim());
1338 if let Ok(id) = t.parse::<Uuid>() {
1339 return Ok(ContentTarget::Id(id.into()));
1340 }
1341 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1342 return Ok(ContentTarget::Prefix(prefix));
1343 }
1344 const UUID_LEN: usize = 36;
1347 let leading = t
1348 .get(..UUID_LEN)
1349 .and_then(|head| head.parse::<Uuid>().ok())
1350 .map(ContentId::from);
1351 Err(ContentTargetError {
1352 field: None,
1353 input: s.to_string(),
1354 leading,
1355 })
1356 }
1357}
1358
1359#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1365pub struct ContentTargetError {
1366 field: Option<&'static str>,
1367 input: String,
1368 leading: Option<ContentId>,
1369}
1370
1371impl ContentTargetError {
1372 pub fn in_field(mut self, field: &'static str) -> Self {
1374 self.field = Some(field);
1375 self
1376 }
1377}
1378
1379impl std::fmt::Display for ContentTargetError {
1380 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1381 match self.field {
1382 Some(field) => write!(f, "`{field}` must be")?,
1383 None => f.write_str("Expected")?,
1384 }
1385 f.write_str(
1386 " a post or comment id: a full UUID or its first 8 hex digits \
1387 (e.g. \"7ad26ccd\")",
1388 )?;
1389 write!(f, ", not \"{}\"", echo(&self.input))?;
1390 if let Some(id) = self.leading {
1391 write!(
1392 f,
1393 ". It starts with the id {id} followed by extra text; pass only the id"
1394 )?;
1395 }
1396 f.write_str(".")
1397 }
1398}
1399
1400impl Serialize for ContentTarget {
1401 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1402 s.collect_str(self)
1403 }
1404}
1405
1406impl<'de> Deserialize<'de> for ContentTarget {
1407 fn deserialize<D: serde::Deserializer<'de>>(
1408 d: D,
1409 ) -> Result<Self, D::Error> {
1410 content_target::deserialize_field(d, "id")
1411 }
1412}
1413
1414pub mod content_target {
1417 use serde::de::{self, Deserializer, Visitor};
1418
1419 use super::{ContentTarget, ContentTargetError};
1420
1421 struct TargetVisitor(&'static str);
1425
1426 impl Visitor<'_> for TargetVisitor {
1427 type Value = ContentTarget;
1428
1429 fn expecting(
1430 &self,
1431 f: &mut std::fmt::Formatter<'_>,
1432 ) -> std::fmt::Result {
1433 write!(
1434 f,
1435 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1436 (e.g. \"7ad26ccd\")",
1437 self.0
1438 )
1439 }
1440
1441 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1442 v.parse()
1443 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1444 }
1445 }
1446
1447 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
1448 d: D,
1449 field: &'static str,
1450 ) -> Result<ContentTarget, D::Error> {
1451 d.deserialize_str(TargetVisitor(field))
1452 }
1453
1454 pub fn reply_to<'de, D: Deserializer<'de>>(
1456 d: D,
1457 ) -> Result<ContentTarget, D::Error> {
1458 deserialize_field(d, "reply_to")
1459 }
1460
1461 pub fn target<'de, D: Deserializer<'de>>(
1463 d: D,
1464 ) -> Result<ContentTarget, D::Error> {
1465 deserialize_field(d, "target")
1466 }
1467
1468 pub fn post_id<'de, D: Deserializer<'de>>(
1470 d: D,
1471 ) -> Result<ContentTarget, D::Error> {
1472 deserialize_field(d, "post_id")
1473 }
1474
1475 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
1477 d: D,
1478 ) -> Result<Option<ContentTarget>, D::Error> {
1479 reply_to(d).map(Some)
1480 }
1481
1482 pub fn optional_target<'de, D: Deserializer<'de>>(
1484 d: D,
1485 ) -> Result<Option<ContentTarget>, D::Error> {
1486 target(d).map(Some)
1487 }
1488}
1489
1490#[cfg(feature = "schemars")]
1494impl schemars::JsonSchema for ContentTarget {
1495 fn inline_schema() -> bool {
1496 true
1497 }
1498
1499 fn schema_name() -> std::borrow::Cow<'static, str> {
1500 std::borrow::Cow::Borrowed("ContentTarget")
1501 }
1502
1503 fn schema_id() -> std::borrow::Cow<'static, str> {
1504 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
1505 }
1506
1507 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1508 schemars::json_schema!({
1509 "type": "string",
1510 "pattern": CONTENT_TARGET_PATTERN,
1511 "description": "A post or comment id: its full UUID, or its first \
1512 8 hex digits (e.g. \"7ad26ccd\") as shown by \
1513 get_content and the dashboard. A short id works \
1514 only on unsigned calls; a signed call must use \
1515 the full UUID.",
1516 })
1517 }
1518}
1519
1520#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1523pub struct ContentRefError(pub String);
1524
1525impl ContentRefError {
1526 pub fn leading_ref(&self) -> Option<ContentRef> {
1530 const UUID_LEN: usize = 36;
1531 const CITATION_LEN: usize = "GOV-2026-0006".len();
1532 let s = self.0.trim_start();
1533 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1535 let head = s.get(..prefix.len())?;
1536 head.eq_ignore_ascii_case(prefix).then(|| {
1537 prefix.len()
1538 + s[prefix.len()..]
1539 .find(|c: char| {
1540 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1541 })
1542 .unwrap_or(s.len() - prefix.len())
1543 })
1544 });
1545 prompt_len
1547 .into_iter()
1548 .chain([
1549 UUID_LEN,
1550 CITATION_LEN,
1551 "constitution".len(),
1552 "protocol".len(),
1553 "prompts".len(),
1554 ])
1555 .filter(|&n| s.len() > n && s.is_char_boundary(n))
1556 .find_map(|n| {
1560 s[..n]
1561 .parse()
1562 .ok()
1563 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
1564 })
1565 }
1566}
1567
1568impl std::fmt::Display for ContentRefError {
1569 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1570 write!(
1571 f,
1572 "not a content reference (expected a post/comment UUID or its \
1573 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1574 like \"constitution\", \"protocol\", \"prompts\" or \
1575 \"prompt:<name>\"): {:?}",
1576 self.0
1577 )?;
1578 if let Some(id) = self.leading_ref() {
1579 write!(
1580 f,
1581 ". It starts with the valid id {id} followed by extra text; \
1582 pass only the id"
1583 )?;
1584 }
1585 Ok(())
1586 }
1587}
1588
1589#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1607pub enum ContentRef {
1608 Content(ContentId),
1610 ContentPrefix(ContentIdPrefix),
1613 Governance(GovernanceLogId),
1615 Document(PlatformDoc),
1617}
1618
1619impl ContentRef {
1620 pub fn as_content(&self) -> Option<ContentId> {
1622 match self {
1623 ContentRef::Content(id) => Some(*id),
1624 _ => None,
1625 }
1626 }
1627
1628 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1630 match self {
1631 ContentRef::Governance(id) => Some(id),
1632 _ => None,
1633 }
1634 }
1635
1636 pub fn as_document(&self) -> Option<&PlatformDoc> {
1638 match self {
1639 ContentRef::Document(doc) => Some(doc),
1640 _ => None,
1641 }
1642 }
1643
1644 pub fn is_governance(&self) -> bool {
1646 matches!(self, ContentRef::Governance(_))
1647 }
1648
1649 pub fn kind_str(&self) -> &'static str {
1652 match self {
1653 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
1654 ContentRef::Governance(_) => "governance",
1655 ContentRef::Document(_) => "document",
1656 }
1657 }
1658}
1659
1660impl std::fmt::Display for ContentRef {
1661 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1662 match self {
1663 ContentRef::Content(id) => id.fmt(f),
1664 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
1665 ContentRef::Governance(id) => id.fmt(f),
1666 ContentRef::Document(doc) => doc.fmt(f),
1667 }
1668 }
1669}
1670
1671impl std::str::FromStr for ContentRef {
1672 type Err = ContentRefError;
1673
1674 fn from_str(s: &str) -> Result<Self, Self::Err> {
1675 if let Ok(id) = s.parse::<ContentId>() {
1676 return Ok(ContentRef::Content(id));
1677 }
1678 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
1679 return Ok(ContentRef::ContentPrefix(prefix));
1680 }
1681 if let Ok(id) = s.parse::<GovernanceLogId>() {
1682 return Ok(ContentRef::Governance(id));
1683 }
1684 if let Ok(doc) = s.parse::<PlatformDoc>() {
1685 return Ok(ContentRef::Document(doc));
1686 }
1687 Err(ContentRefError(s.to_string()))
1688 }
1689}
1690
1691impl TryFrom<String> for ContentRef {
1692 type Error = ContentRefError;
1693
1694 fn try_from(s: String) -> Result<Self, Self::Error> {
1695 s.parse()
1696 }
1697}
1698
1699impl From<ContentId> for ContentRef {
1700 fn from(id: ContentId) -> Self {
1701 ContentRef::Content(id)
1702 }
1703}
1704
1705impl From<PostId> for ContentRef {
1706 fn from(id: PostId) -> Self {
1707 ContentRef::Content(id.into())
1708 }
1709}
1710
1711impl From<CommentId> for ContentRef {
1712 fn from(id: CommentId) -> Self {
1713 ContentRef::Content(id.into())
1714 }
1715}
1716
1717impl From<ContentIdPrefix> for ContentRef {
1718 fn from(prefix: ContentIdPrefix) -> Self {
1719 ContentRef::ContentPrefix(prefix)
1720 }
1721}
1722
1723impl From<GovernanceLogId> for ContentRef {
1724 fn from(id: GovernanceLogId) -> Self {
1725 ContentRef::Governance(id)
1726 }
1727}
1728
1729impl From<PlatformDoc> for ContentRef {
1730 fn from(doc: PlatformDoc) -> Self {
1731 ContentRef::Document(doc)
1732 }
1733}
1734
1735impl Serialize for ContentRef {
1736 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1737 s.collect_str(self)
1738 }
1739}
1740
1741impl<'de> Deserialize<'de> for ContentRef {
1742 fn deserialize<D: serde::Deserializer<'de>>(
1743 d: D,
1744 ) -> Result<Self, D::Error> {
1745 let raw = String::deserialize(d)?;
1746 raw.parse().map_err(serde::de::Error::custom)
1747 }
1748}
1749
1750#[cfg(feature = "schemars")]
1755impl schemars::JsonSchema for ContentRef {
1756 fn inline_schema() -> bool {
1757 true
1758 }
1759
1760 fn schema_name() -> std::borrow::Cow<'static, str> {
1761 std::borrow::Cow::Borrowed("ContentRef")
1762 }
1763
1764 fn schema_id() -> std::borrow::Cow<'static, str> {
1765 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1766 }
1767
1768 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1769 schemars::json_schema!({
1770 "type": "string",
1771 "pattern": CONTENT_REF_PATTERN,
1772 "description": "A post or comment UUID, or its first eight \
1773 hex digits (e.g. \"7ad26ccd\"); a governance log id \
1774 such as \"GOV-2026-0006\" (Council decision) \
1775 or \"APP-2026-0003\" (appeals ruling); or a \
1776 document slug — \"constitution\", \
1777 \"protocol\" (the Governance Protocol), \
1778 \"prompts\" (the index of the model prompts \
1779 moderation, appeals and the Council run on) or \
1780 \"prompt:<name>\" (one of them).",
1781 })
1782 }
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787 use super::*;
1788
1789 #[test]
1790 fn oauth_client_id_round_trips() {
1791 let id = OAuthClientId::generate();
1792 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1793 let json = serde_json::to_string(&id).unwrap();
1794 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1795 }
1796
1797 #[test]
1798 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1799 assert!("".parse::<OAuthClientId>().is_err());
1800 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1801 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1802 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1803 }
1804
1805 #[test]
1806 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
1807 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
1808 for (input, lead) in [
1809 (format!("{uuid} and the safe-space proposal"), uuid),
1810 (format!("{uuid}b0518e42"), uuid),
1811 (
1812 "GOV-2026-0006 (the ratification)".to_string(),
1813 "GOV-2026-0006",
1814 ),
1815 ("protocol, section 3".to_string(), "protocol"),
1816 ("prompts, please".to_string(), "prompts"),
1817 (
1818 "prompt:tier2_reviewer and the juror's".to_string(),
1819 "prompt:tier2_reviewer",
1820 ),
1821 (
1822 "prompts/appeals_juror.md, line 3".to_string(),
1823 "prompt:appeals_juror",
1824 ),
1825 ] {
1826 let err = input.parse::<ContentRef>().unwrap_err();
1827 assert_eq!(
1828 err.leading_ref(),
1829 Some(lead.parse().unwrap()),
1830 "{input}"
1831 );
1832 let msg = err.to_string();
1833 assert!(
1834 msg.contains(&format!("valid id {lead} followed")),
1835 "{msg}"
1836 );
1837 }
1838
1839 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
1841 let err = short.parse::<ContentRef>().unwrap_err();
1842 assert_eq!(err.leading_ref(), None);
1843 assert!(!err.to_string().contains("followed by"));
1844 assert_eq!(
1846 "GOV-2026-1".parse::<ContentRef>().unwrap(),
1847 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
1848 );
1849 }
1850
1851 #[test]
1852 fn citations_normalize_as_written() {
1853 for (written, canonical) in [
1854 ("GOV-2026-0006", "GOV-2026-0006"),
1855 ("GOV-2026.6", "GOV-2026-0006"),
1856 ("gov-2026-6", "GOV-2026-0006"),
1857 ("GOV 2026 6", "GOV-2026-0006"),
1858 ("app/2026/03", "APP-2026-0003"),
1859 (" REC-2026-0002 ", "REC-2026-0002"),
1860 ] {
1861 let id: GovernanceLogId = written.parse().unwrap();
1862 assert_eq!(id.as_str(), canonical, "{written}");
1863 let json: GovernanceLogId =
1864 serde_json::from_value(serde_json::json!(written)).unwrap();
1865 assert_eq!(json.as_str(), canonical, "{written} via serde");
1866 let content: ContentRef = written.parse().unwrap();
1867 assert_eq!(content, ContentRef::Governance(id), "{written}");
1868 }
1869 for rejected in [
1870 "GOV-2026-N",
1871 "GOV-2026/GOVG=6",
1872 "GOV-2026-00006",
1873 "GOV-26-0006",
1874 "GOV-2026-",
1875 "XYZ-2026-0006",
1876 "GOV-2026-0006-1",
1877 ] {
1878 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
1879 }
1880 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
1882 }
1883
1884 #[test]
1885 fn content_ref_parses_document_slugs() {
1886 assert_eq!(
1887 "constitution".parse(),
1888 Ok(ContentRef::Document(PlatformDoc::Constitution))
1889 );
1890 assert_eq!(
1891 "protocol".parse(),
1892 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1893 );
1894 assert_eq!(
1896 "governance-protocol".parse(),
1897 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1898 );
1899 assert_eq!(
1900 "Constitution".parse(),
1901 Ok(ContentRef::Document(PlatformDoc::Constitution))
1902 );
1903 assert!("proto".parse::<ContentRef>().is_err());
1904 }
1905
1906 #[test]
1907 fn content_ref_parses_prompt_slugs() {
1908 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
1909 for written in [
1910 "prompt:appeals_juror",
1911 "Prompt:Appeals_Juror",
1912 "prompt/appeals-juror",
1913 "prompts/appeals_juror.md",
1914 "prompts:appeals_juror",
1915 ] {
1916 assert_eq!(
1917 written.parse(),
1918 Ok(ContentRef::Document(juror.clone())),
1919 "{written}"
1920 );
1921 }
1922 assert_eq!(juror.to_string(), "prompt:appeals_juror");
1923 for index in ["prompts", "Prompts", "prompt"] {
1924 assert_eq!(
1925 index.parse(),
1926 Ok(ContentRef::Document(PlatformDoc::Prompts)),
1927 "{index}"
1928 );
1929 }
1930 for rejected in [
1931 "prompt:",
1932 "prompt:2fast",
1933 "prompt:tier 2",
1934 "prompt:../secrets",
1935 "prompt:tier2_reviewer/x",
1936 "promptly",
1937 ] {
1938 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
1939 }
1940 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
1941 assert!(long.parse::<ContentRef>().is_err());
1942 }
1943
1944 #[test]
1945 fn content_id_prefix_parses_eight_hex_digits_leniently() {
1946 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
1947 assert_eq!(p.to_string(), "7ad26ccd");
1948 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
1949 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
1950 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
1951 }
1952 }
1953
1954 #[test]
1955 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
1956 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
1957 let p = ContentIdPrefix::of(&id);
1958 assert_eq!(p.to_string(), "7ad26ccd");
1959 assert!(p.matches(&id));
1960 let (lo, hi) = p.bounds();
1961 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
1962 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
1963 assert!(lo <= id && id <= hi);
1964 let next: Uuid =
1965 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
1966 assert!(!p.matches(&next) && next > hi);
1967 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
1969 assert_eq!(top.bounds().1, Uuid::max());
1970 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
1971 assert_eq!(bottom.bounds().0, Uuid::nil());
1972 }
1973
1974 #[test]
1975 fn content_ref_reads_a_short_id_and_only_eight_digits() {
1976 let r: ContentRef = "7ad26ccd".parse().unwrap();
1977 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
1978 assert_eq!(r.kind_str(), "content");
1979 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
1980 let full: ContentRef =
1982 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
1983 assert!(matches!(full, ContentRef::Content(_)));
1984 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
1989 assert_eq!(err.leading_ref(), None);
1990 }
1991
1992 #[test]
1993 fn content_ref_pattern_admits_short_ids() {
1994 let short = CONTENT_ID_PREFIX_PATTERN
1995 .strip_prefix('^')
1996 .and_then(|p| p.strip_suffix('$'))
1997 .unwrap();
1998 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
1999 }
2000
2001 #[test]
2003 fn document_patterns_agree() {
2004 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2005 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2006 let name = PROMPT_NAME_PATTERN
2007 .strip_prefix('^')
2008 .and_then(|p| p.strip_suffix('$'))
2009 .unwrap();
2010 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2011 }
2012
2013 #[test]
2014 fn platform_doc_serde_uses_the_canonical_slug() {
2015 let json =
2016 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2017 assert_eq!(json, "\"protocol\"");
2018 let doc: PlatformDoc =
2019 serde_json::from_str("\"governance-protocol\"").unwrap();
2020 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2021 }
2022
2023 #[test]
2024 fn ids_are_unique() {
2025 let a = AgentId::new();
2026 let b = AgentId::new();
2027 assert_ne!(a, b);
2028 }
2029
2030 #[test]
2031 fn serde_round_trip() {
2032 let id = PostId::new();
2033 let json = serde_json::to_string(&id).unwrap();
2034 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2035 assert_eq!(id, deserialized);
2036 }
2037
2038 #[test]
2039 fn display_shows_uuid() {
2040 let id = CommunityId::new();
2041 let display = id.to_string();
2042 assert_eq!(display.len(), 36);
2044 assert!(display.contains('-'));
2045 }
2046
2047 #[test]
2048 fn from_uuid_round_trip() {
2049 let uuid = Uuid::new_v4();
2050 let id = AgentId::from(uuid);
2051 let back: Uuid = id.into();
2052 assert_eq!(uuid, back);
2053 }
2054
2055 #[test]
2059 fn every_id_round_trips_through_its_own_display() {
2060 let agent = AgentId::new();
2061 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2062
2063 let action = ModerationActionId::new();
2064 assert_eq!(
2065 action.to_string().parse::<ModerationActionId>().unwrap(),
2066 action
2067 );
2068
2069 let content = ContentId::new();
2070 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2071
2072 let feedback = FeedbackId::new();
2073 assert_eq!(
2074 feedback.to_string().parse::<FeedbackId>().unwrap(),
2075 feedback
2076 );
2077 }
2078
2079 #[test]
2080 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2081 assert!("not-a-uuid".parse::<ContentId>().is_err());
2082 assert!("".parse::<ContentId>().is_err());
2083 }
2084
2085 #[test]
2090 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2091 let uuid = Uuid::new_v4();
2092 let typed = ContentId::from(uuid);
2093 assert_eq!(
2094 serde_json::to_string(&typed).unwrap(),
2095 serde_json::to_string(&uuid).unwrap()
2096 );
2097 }
2098
2099 #[test]
2102 fn every_moderation_target_narrows_losslessly() {
2103 let uuid = Uuid::new_v4();
2104
2105 for (label, got) in [
2106 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2107 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2108 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2109 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2110 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2111 ] {
2112 assert_eq!(
2113 got.as_uuid(),
2114 &uuid,
2115 "{label} -> ModerationTargetId lost the uuid"
2116 );
2117 }
2118 }
2119
2120 #[test]
2124 fn resolved_ids_narrow_to_content_id_losslessly() {
2125 let uuid = Uuid::new_v4();
2126
2127 assert_eq!(
2128 ContentId::from(PostId::from(uuid)).as_uuid(),
2129 &uuid,
2130 "PostId -> ContentId lost the uuid"
2131 );
2132 assert_eq!(
2133 ContentId::from(CommentId::from(uuid)).as_uuid(),
2134 &uuid,
2135 "CommentId -> ContentId lost the uuid"
2136 );
2137 assert_eq!(
2138 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2139 .as_uuid(),
2140 &uuid,
2141 "PostOrCommentId -> ContentId lost the uuid"
2142 );
2143 }
2144
2145 #[test]
2146 fn json_is_plain_uuid_string() {
2147 let uuid = Uuid::new_v4();
2148 let id = AgentId::from(uuid);
2149 let id_json = serde_json::to_string(&id).unwrap();
2151 let uuid_json = serde_json::to_string(&uuid).unwrap();
2152 assert_eq!(id_json, uuid_json);
2153 }
2154
2155 #[cfg(feature = "schemars")]
2160 #[test]
2161 fn id_json_schema_is_inlined() {
2162 use schemars::JsonSchema;
2163
2164 assert!(
2165 <PostId as JsonSchema>::inline_schema(),
2166 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2167 );
2168 assert!(<AgentId as JsonSchema>::inline_schema());
2169 assert!(<CommentId as JsonSchema>::inline_schema());
2170 assert!(<CommunityId as JsonSchema>::inline_schema());
2171 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2172 assert!(<ContentRef as JsonSchema>::inline_schema());
2173 assert!(<FeedbackId as JsonSchema>::inline_schema());
2174
2175 #[derive(schemars::JsonSchema)]
2179 #[allow(dead_code)]
2180 struct Container {
2181 post_id: PostId,
2183 agent_id: Option<AgentId>,
2185 gov_id: GovernanceLogId,
2187 maybe_gov_id: Option<GovernanceLogId>,
2189 content_ref: ContentRef,
2191 maybe_content_ref: Option<ContentRef>,
2193 feedback_id: FeedbackId,
2195 }
2196
2197 let schema = schemars::schema_for!(Container);
2198 let value = serde_json::to_value(&schema).unwrap();
2199
2200 assert!(
2202 value.get("$defs").is_none(),
2203 "no $defs should be emitted for ID-only container; got schema: {value}"
2204 );
2205
2206 let post_id = &value["properties"]["post_id"];
2208 assert!(
2209 post_id.get("$ref").is_none(),
2210 "post_id must not be a $ref; got: {post_id}"
2211 );
2212 assert_eq!(post_id["type"], "string");
2213 assert_eq!(post_id["format"], "uuid");
2214
2215 let agent_id = &value["properties"]["agent_id"];
2220 assert!(
2221 agent_id.get("$ref").is_none(),
2222 "agent_id must not be a $ref; got: {agent_id}"
2223 );
2224 let agent_id_str = agent_id.to_string();
2225 assert!(
2226 !agent_id_str.contains("$ref"),
2227 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2228 );
2229 assert!(
2230 agent_id_str.contains("\"format\":\"uuid\""),
2231 "agent_id should still carry format=uuid; got: {agent_id}"
2232 );
2233
2234 for field in
2239 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2240 {
2241 let f = &value["properties"][field];
2242 assert!(
2243 !f.to_string().contains("$ref"),
2244 "{field} must contain no $ref anywhere; got: {f}"
2245 );
2246 }
2247 assert_eq!(value["properties"]["gov_id"]["type"], "string");
2248 assert_eq!(
2249 value["properties"]["gov_id"]["pattern"],
2250 GOVERNANCE_LOG_ID_PATTERN
2251 );
2252 assert!(
2253 value["properties"]["maybe_gov_id"]
2254 .to_string()
2255 .contains("GOV|APP"),
2256 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2257 value["properties"]["maybe_gov_id"]
2258 );
2259 assert_eq!(value["properties"]["content_ref"]["type"], "string");
2260 }
2261
2262 #[test]
2263 fn governance_log_id_accepts_only_citation_shapes() {
2264 for (good, prefix) in [
2265 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2266 ("APP-2026-0003", GovernanceLogPrefix::App),
2267 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2268 ("KEY-2026-0001", GovernanceLogPrefix::Key),
2269 ("REC-2026-0001", GovernanceLogPrefix::Rec),
2270 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2271 ] {
2272 let id = good.parse::<GovernanceLogId>().unwrap();
2273 assert_eq!(id.as_str(), good, "{good} should parse");
2274 assert_eq!(id.prefix(), prefix);
2275 assert_eq!(id.prefix().as_str(), &good[..3]);
2276 }
2277 assert_eq!(
2278 GovernanceLogPrefix::ALL
2279 .map(|p| p.to_string())
2280 .concat()
2281 .len(),
2282 3 * GovernanceLogPrefix::ALL.len()
2283 );
2284 assert_eq!(
2286 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
2287 "GOV-2026-0006"
2288 );
2289 assert_eq!(
2290 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
2291 "GOV-2026-0006"
2292 );
2293 for bad in [
2294 "",
2295 "GOV-26-0006",
2296 "MOD-2026-0006",
2297 "GOV-2026-0006-1",
2298 "GOV-202X-0006",
2299 "3f1a0000-0000-0000-0000-000000000000",
2300 ] {
2301 assert!(
2302 bad.parse::<GovernanceLogId>().is_err(),
2303 "{bad:?} should not parse as a GovernanceLogId"
2304 );
2305 }
2306 }
2307
2308 #[test]
2312 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
2313 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2314 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
2315 let back: GovernanceLogId =
2316 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
2317 assert_eq!(back, id);
2318 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
2320 }
2321
2322 #[test]
2325 fn content_ref_round_trips_as_a_bare_string() {
2326 let uuid = Uuid::new_v4();
2327 let content = ContentRef::from(ContentId::from(uuid));
2328 assert_eq!(
2329 serde_json::to_value(&content).unwrap(),
2330 serde_json::json!(uuid.to_string())
2331 );
2332 assert_eq!(
2333 serde_json::from_value::<ContentRef>(serde_json::json!(
2334 uuid.to_string()
2335 ))
2336 .unwrap(),
2337 content
2338 );
2339
2340 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2341 assert_eq!(
2342 serde_json::to_value(&gov).unwrap(),
2343 serde_json::json!("APP-2026-0003")
2344 );
2345 assert_eq!(
2346 serde_json::from_value::<ContentRef>(serde_json::json!(
2347 "APP-2026-0003"
2348 ))
2349 .unwrap(),
2350 gov
2351 );
2352
2353 assert!(gov.is_governance());
2354 assert!(!content.is_governance());
2355 assert_eq!(gov.kind_str(), "governance");
2356 assert_eq!(content.kind_str(), "content");
2357 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2358 assert!(content.as_governance().is_none());
2359
2360 assert!("not-an-id".parse::<ContentRef>().is_err());
2362 assert!(
2363 serde_json::from_value::<ContentRef>(serde_json::json!(
2364 "not-an-id"
2365 ))
2366 .is_err()
2367 );
2368 }
2369
2370 #[test]
2372 fn every_readable_id_narrows_to_a_content_ref() {
2373 let uuid = Uuid::new_v4();
2374 for (label, got) in [
2375 ("PostId", ContentRef::from(PostId::from(uuid))),
2376 ("CommentId", ContentRef::from(CommentId::from(uuid))),
2377 ("ContentId", ContentRef::from(ContentId::from(uuid))),
2378 ] {
2379 assert_eq!(
2380 got,
2381 ContentRef::Content(ContentId::from(uuid)),
2382 "{label} -> ContentRef lost the uuid"
2383 );
2384 }
2385 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2386 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2387 }
2388
2389 #[test]
2392 fn string_shaped_ids_round_trip_through_display() {
2393 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2394 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
2395
2396 let r = ContentRef::Governance(gov);
2397 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2398
2399 let r = ContentRef::Content(ContentId::new());
2400 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2401 }
2402
2403 #[test]
2404 fn post_or_comment_post_variant() {
2405 let inner = PostId::new();
2406 let tagged = PostOrCommentId::Post(inner);
2407 assert!(tagged.is_post());
2408 assert!(!tagged.is_comment());
2409 assert_eq!(tagged.as_post(), Some(inner));
2410 assert_eq!(tagged.as_comment(), None);
2411 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2412 assert_eq!(tagged.kind_str(), "post");
2413 }
2414
2415 #[test]
2416 fn post_or_comment_comment_variant() {
2417 let inner = CommentId::new();
2418 let tagged = PostOrCommentId::Comment(inner);
2419 assert!(tagged.is_comment());
2420 assert!(!tagged.is_post());
2421 assert_eq!(tagged.as_comment(), Some(inner));
2422 assert_eq!(tagged.as_post(), None);
2423 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2424 assert_eq!(tagged.kind_str(), "comment");
2425 }
2426
2427 #[test]
2428 fn post_or_comment_from_conversions() {
2429 let post = PostId::new();
2430 let comment = CommentId::new();
2431 let via_post: PostOrCommentId = post.into();
2432 let via_comment: PostOrCommentId = comment.into();
2433 assert_eq!(via_post, PostOrCommentId::Post(post));
2434 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
2435 }
2436
2437 #[test]
2438 fn post_or_comment_display_is_kind_colon_uuid() {
2439 let post = PostId::new();
2440 let tagged = PostOrCommentId::Post(post);
2441 let rendered = tagged.to_string();
2442 assert!(rendered.starts_with("post:"));
2443 assert!(rendered.contains(&post.to_string()));
2444 }
2445
2446 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
2449
2450 #[test]
2453 fn a_bad_id_fails_in_plain_words() {
2454 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
2455 let want = format!(
2456 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
2457 );
2458 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
2459 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
2460 .unwrap_err()
2461 .to_string();
2462 assert!(err.starts_with(&want), "{err}");
2463 assert!(!err.contains("group"), "{err}");
2464 let err = serde_json::from_str::<AgentId>("5")
2465 .unwrap_err()
2466 .to_string();
2467 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
2468 let long = "x".repeat(500);
2470 let msg = long.parse::<CommentId>().unwrap_err().to_string();
2471 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
2472 }
2473
2474 #[test]
2476 fn ids_still_round_trip() {
2477 let id: PostId = FULL.parse().unwrap();
2478 let json = serde_json::to_string(&id).unwrap();
2479 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
2480 let map: std::collections::HashMap<PostId, i64> =
2481 [(id, 3)].into_iter().collect();
2482 let json = serde_json::to_string(&map).unwrap();
2483 let back: std::collections::HashMap<PostId, i64> =
2484 serde_json::from_str(&json).unwrap();
2485 assert_eq!(back, map);
2486 }
2487
2488 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
2489 s.parse()
2490 }
2491
2492 #[test]
2493 fn full_and_short_targets_parse() {
2494 let full = target(FULL).unwrap();
2495 assert_eq!(full.full().unwrap().to_string(), FULL);
2496 assert_eq!(full.to_string(), FULL);
2497 let short = target(" 7AD26CCD ").unwrap();
2498 assert!(short.full().is_none());
2499 assert_eq!(short.to_string(), "7ad26ccd");
2500 for variant in [
2502 FULL.replace('-', ""),
2503 FULL.to_uppercase(),
2504 FULL.replace('-', "\u{2011}"),
2505 ] {
2506 assert_eq!(target(&variant).unwrap(), full, "{variant}");
2507 }
2508 }
2509
2510 #[test]
2511 fn a_truncated_uuid_is_an_error_not_its_prefix() {
2512 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
2513 let msg = err.in_field("reply_to").to_string();
2514 assert!(
2515 msg.starts_with(
2516 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
2517 ),
2518 "{msg}"
2519 );
2520 for internal in [
2521 "group",
2522 "UUID parsing",
2523 "invalid length",
2524 "invalid character",
2525 ] {
2526 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
2527 }
2528 assert!(target("7ad26cc").is_err());
2529 assert!(target("7ad26ccd9").is_err());
2530 }
2531
2532 #[test]
2533 fn trailing_text_names_the_id_it_starts_with() {
2534 let msg = target(&format!("{FULL} (the reply above)"))
2535 .unwrap_err()
2536 .to_string();
2537 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
2538 }
2539
2540 #[derive(Debug, serde::Deserialize)]
2541 struct Probe {
2542 #[serde(deserialize_with = "content_target::reply_to")]
2543 #[allow(dead_code)]
2544 reply_to: ContentTarget,
2545 }
2546
2547 #[test]
2548 fn target_errors_name_the_field_for_strings_and_other_types() {
2549 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
2550 .unwrap_err();
2551 assert!(
2552 err.to_string()
2553 .starts_with("`reply_to` must be a post or comment id"),
2554 "{err}"
2555 );
2556 let err =
2557 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
2558 assert!(
2559 err.to_string()
2560 .contains("expected `reply_to` to be a post or comment id"),
2561 "{err}"
2562 );
2563 }
2564
2565 #[cfg(feature = "schemars")]
2566 #[test]
2567 fn target_schema_is_inline_and_admits_both_forms() {
2568 let schema = schemars::schema_for!(ContentTarget);
2569 let v = schema.as_value();
2570 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
2571 assert!(v.get("format").is_none(), "a short id is not format: uuid");
2572 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
2573 }
2574}