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