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|OBL|DATA)-[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|OBL|DATA)-[0-9]{4}-[0-9]{4}|constitution|protocol|prompts|calendar|prompt:[a-z][a-z0-9_]*)$";
33
34pub const PLATFORM_DOC_PATTERN: &str =
36 "^(constitution|protocol|prompts|calendar|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 TrashEntryId
356}
357
358define_id! {
359 PollId
362}
363
364define_id! {
365 PollOptionId
369}
370
371define_id! {
372 BallotId
375}
376
377define_id! {
378 CalendarEventId
382}
383
384define_id! {
385 PromptArchiveResponseId
388}
389
390define_id! {
391 CleanupId
395}
396
397define_id! {
398 OperatorSessionId
401}
402
403define_id! {
404 OperatorPasswordResetId
407}
408
409define_id! {
410 OperatorPasskeyId
413}
414
415define_id! {
416 OperatorPendingLoginId
420}
421
422#[derive(Clone, PartialEq, Eq, Hash)]
426#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
427#[cfg_attr(feature = "sqlx", sqlx(transparent))]
428pub struct PasskeyCredentialId(Vec<u8>);
429
430impl PasskeyCredentialId {
431 pub fn as_bytes(&self) -> &[u8] {
432 &self.0
433 }
434}
435
436impl From<Vec<u8>> for PasskeyCredentialId {
437 fn from(bytes: Vec<u8>) -> Self {
438 Self(bytes)
439 }
440}
441
442impl std::fmt::Debug for PasskeyCredentialId {
443 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444 write!(f, "PasskeyCredentialId({})", hex::encode(&self.0))
445 }
446}
447
448#[derive(Clone, PartialEq, Eq)]
453#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
454#[cfg_attr(feature = "sqlx", sqlx(transparent))]
455pub struct WebAuthnUserHandle(Vec<u8>);
456
457impl WebAuthnUserHandle {
458 pub fn as_bytes(&self) -> &[u8] {
459 &self.0
460 }
461}
462
463impl From<Vec<u8>> for WebAuthnUserHandle {
464 fn from(bytes: Vec<u8>) -> Self {
465 Self(bytes)
466 }
467}
468
469impl std::fmt::Debug for WebAuthnUserHandle {
470 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471 f.write_str("WebAuthnUserHandle(..)")
472 }
473}
474
475#[derive(
489 Debug,
490 Clone,
491 Copy,
492 PartialEq,
493 Eq,
494 PartialOrd,
495 Ord,
496 Hash,
497 Serialize,
498 Deserialize,
499)]
500#[serde(transparent)]
501pub struct PollOptionNumber(i16);
502
503impl PollOptionNumber {
504 pub fn new(n: usize) -> Option<Self> {
506 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
507 }
508
509 pub fn index(&self) -> Option<usize> {
511 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
512 }
513
514 pub fn get(&self) -> i16 {
515 self.0
516 }
517}
518
519impl From<i16> for PollOptionNumber {
520 fn from(n: i16) -> Self {
521 Self(n)
522 }
523}
524
525impl std::fmt::Display for PollOptionNumber {
526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527 self.0.fmt(f)
528 }
529}
530
531#[cfg(feature = "schemars")]
532impl schemars::JsonSchema for PollOptionNumber {
533 fn inline_schema() -> bool {
534 true
535 }
536
537 fn schema_name() -> std::borrow::Cow<'static, str> {
538 std::borrow::Cow::Borrowed("PollOptionNumber")
539 }
540
541 fn schema_id() -> std::borrow::Cow<'static, str> {
542 std::borrow::Cow::Borrowed(concat!(
543 module_path!(),
544 "::PollOptionNumber"
545 ))
546 }
547
548 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
550 schemars::json_schema!({ "type": "integer" })
551 }
552}
553
554#[derive(
560 Debug,
561 Clone,
562 Copy,
563 PartialEq,
564 Eq,
565 PartialOrd,
566 Ord,
567 Hash,
568 Serialize,
569 Deserialize,
570)]
571#[serde(transparent)]
572#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
573#[cfg_attr(feature = "schemars", schemars(inline))]
574#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
575#[cfg_attr(feature = "sqlx", sqlx(transparent))]
576pub struct PollOptionPosition(i16);
577
578impl PollOptionPosition {
579 pub fn new(n: usize) -> Option<Self> {
581 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
582 }
583
584 pub fn index(&self) -> Option<usize> {
586 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
587 }
588
589 pub fn get(&self) -> i16 {
590 self.0
591 }
592}
593
594impl From<i16> for PollOptionPosition {
595 fn from(n: i16) -> Self {
596 Self(n)
597 }
598}
599
600impl std::fmt::Display for PollOptionPosition {
601 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
602 self.0.fmt(f)
603 }
604}
605
606define_id! {
607 ContentId
628}
629
630define_id! {
631 ModerationTargetId
649}
650
651impl From<PostId> for ModerationTargetId {
657 fn from(id: PostId) -> Self {
658 Self::from(*id.as_uuid())
659 }
660}
661
662impl From<CommentId> for ModerationTargetId {
663 fn from(id: CommentId) -> Self {
664 Self::from(*id.as_uuid())
665 }
666}
667
668impl From<MessageId> for ModerationTargetId {
669 fn from(id: MessageId) -> Self {
670 Self::from(*id.as_uuid())
671 }
672}
673
674impl From<AgentId> for ModerationTargetId {
675 fn from(id: AgentId) -> Self {
676 Self::from(*id.as_uuid())
677 }
678}
679
680impl From<ContentId> for ModerationTargetId {
683 fn from(id: ContentId) -> Self {
684 Self::from(*id.as_uuid())
685 }
686}
687
688impl From<PostId> for ContentId {
693 fn from(id: PostId) -> Self {
694 Self::from(*id.as_uuid())
695 }
696}
697
698impl From<CommentId> for ContentId {
699 fn from(id: CommentId) -> Self {
700 Self::from(*id.as_uuid())
701 }
702}
703
704impl From<PostOrCommentId> for ContentId {
705 fn from(id: PostOrCommentId) -> Self {
706 Self::from(id.as_uuid())
707 }
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
738pub enum PostOrCommentId {
739 Post(PostId),
740 Comment(CommentId),
741}
742
743impl PostOrCommentId {
744 pub fn as_uuid(&self) -> Uuid {
746 match self {
747 PostOrCommentId::Post(id) => *id.as_uuid(),
748 PostOrCommentId::Comment(id) => *id.as_uuid(),
749 }
750 }
751
752 pub fn is_post(&self) -> bool {
754 matches!(self, PostOrCommentId::Post(_))
755 }
756
757 pub fn is_comment(&self) -> bool {
759 matches!(self, PostOrCommentId::Comment(_))
760 }
761
762 pub fn as_post(&self) -> Option<PostId> {
764 match self {
765 PostOrCommentId::Post(id) => Some(*id),
766 PostOrCommentId::Comment(_) => None,
767 }
768 }
769
770 pub fn as_comment(&self) -> Option<CommentId> {
772 match self {
773 PostOrCommentId::Comment(id) => Some(*id),
774 PostOrCommentId::Post(_) => None,
775 }
776 }
777
778 pub fn kind(&self) -> crate::enums::ContentKind {
780 match self {
781 PostOrCommentId::Post(_) => crate::enums::ContentKind::Post,
782 PostOrCommentId::Comment(_) => crate::enums::ContentKind::Comment,
783 }
784 }
785
786 pub fn kind_str(&self) -> &'static str {
789 match self {
790 PostOrCommentId::Post(_) => "post",
791 PostOrCommentId::Comment(_) => "comment",
792 }
793 }
794}
795
796impl From<PostId> for PostOrCommentId {
797 fn from(id: PostId) -> Self {
798 PostOrCommentId::Post(id)
799 }
800}
801
802impl From<CommentId> for PostOrCommentId {
803 fn from(id: CommentId) -> Self {
804 PostOrCommentId::Comment(id)
805 }
806}
807
808impl std::fmt::Display for PostOrCommentId {
809 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
810 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
811 }
812}
813
814#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
820#[error(
821 "not a governance log id (expected GOV-, APP-, AMD-, KEY-, REC-, OBL- \
822 or DATA-YYYY-NNNN): {0:?}"
823)]
824pub struct GovernanceLogIdError(pub String);
825
826#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
832pub enum GovernanceLogPrefix {
833 Gov,
835 App,
837 Amd,
839 Key,
841 Rec,
843 Obl,
845 Data,
848 Unknown,
852}
853
854impl GovernanceLogPrefix {
855 pub const ALL: [Self; 7] = [
857 Self::Gov,
858 Self::App,
859 Self::Amd,
860 Self::Key,
861 Self::Rec,
862 Self::Obl,
863 Self::Data,
864 ];
865
866 pub fn as_str(&self) -> &'static str {
868 match self {
869 Self::Gov => "GOV",
870 Self::App => "APP",
871 Self::Amd => "AMD",
872 Self::Key => "KEY",
873 Self::Rec => "REC",
874 Self::Obl => "OBL",
875 Self::Data => "DATA",
876 Self::Unknown => "?",
877 }
878 }
879}
880
881impl std::fmt::Display for GovernanceLogPrefix {
882 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
883 f.write_str(self.as_str())
884 }
885}
886
887impl std::str::FromStr for GovernanceLogPrefix {
888 type Err = GovernanceLogIdError;
889
890 fn from_str(s: &str) -> Result<Self, Self::Err> {
891 match s {
892 "GOV" => Ok(Self::Gov),
893 "APP" => Ok(Self::App),
894 "AMD" => Ok(Self::Amd),
895 "KEY" => Ok(Self::Key),
896 "REC" => Ok(Self::Rec),
897 "OBL" => Ok(Self::Obl),
898 "DATA" => Ok(Self::Data),
899 _ => Err(GovernanceLogIdError(s.to_string())),
900 }
901 }
902}
903
904#[derive(
924 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
925)]
926#[serde(try_from = "String")]
927#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
928#[cfg_attr(feature = "sqlx", sqlx(transparent))]
929pub struct GovernanceLogId(String);
930
931impl GovernanceLogId {
932 pub fn as_str(&self) -> &str {
934 &self.0
935 }
936
937 pub fn into_inner(self) -> String {
939 self.0
940 }
941
942 pub fn is_citation_shaped(s: &str) -> bool {
950 Self::parts(s).is_some()
951 }
952
953 pub fn prefix(&self) -> GovernanceLogPrefix {
955 Self::parts(&self.0).map_or(GovernanceLogPrefix::Unknown, |p| p.0)
956 }
957
958 fn is_canonical_unknown(s: &str) -> bool {
962 let parts: Vec<&str> = s.split('-').collect();
963 let digits =
964 |p: &str| p.len() == 4 && p.chars().all(|c| c.is_ascii_digit());
965 matches!(parts.as_slice(), [letters, year, serial]
966 if (3..=4).contains(&letters.len())
967 && letters.chars().all(|c| c.is_ascii_uppercase())
968 && digits(year)
969 && digits(serial))
970 }
971
972 pub fn normalize(s: &str) -> Option<String> {
978 let mut parts = s.trim().split(['-', '.', '/', ' ']);
979 let (prefix, year, serial) =
980 (parts.next()?, parts.next()?, parts.next()?);
981 if parts.next().is_some() {
982 return None;
983 }
984 let prefix: GovernanceLogPrefix =
985 prefix.to_ascii_uppercase().parse().ok()?;
986 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
987 (year.len() == 4
988 && digits(year)
989 && (1..=4).contains(&serial.len())
990 && digits(serial))
991 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
992 }
993
994 pub fn from_parts(
997 prefix: GovernanceLogPrefix,
998 year: i32,
999 serial: u32,
1000 ) -> Option<Self> {
1001 (prefix != GovernanceLogPrefix::Unknown
1002 && (1000..=9999).contains(&year)
1003 && (1..=9999).contains(&serial))
1004 .then(|| Self(format!("{prefix}-{year}-{serial:04}")))
1005 }
1006
1007 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
1008 let parts: Vec<&str> = s.split('-').collect();
1009 let [prefix, year, serial] = parts.as_slice() else {
1010 return None;
1011 };
1012 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
1013 (year.len() == 4
1014 && serial.len() == 4
1015 && year.chars().all(|c| c.is_ascii_digit())
1016 && serial.chars().all(|c| c.is_ascii_digit()))
1017 .then_some((prefix, year, serial))
1018 }
1019}
1020
1021impl std::fmt::Display for GovernanceLogId {
1022 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1023 f.write_str(&self.0)
1024 }
1025}
1026
1027impl AsRef<str> for GovernanceLogId {
1028 fn as_ref(&self) -> &str {
1029 &self.0
1030 }
1031}
1032
1033impl std::str::FromStr for GovernanceLogId {
1034 type Err = GovernanceLogIdError;
1035
1036 fn from_str(s: &str) -> Result<Self, Self::Err> {
1039 Self::normalize(s)
1040 .map(Self)
1041 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
1042 }
1043}
1044
1045impl TryFrom<String> for GovernanceLogId {
1046 type Error = GovernanceLogIdError;
1047
1048 fn try_from(s: String) -> Result<Self, Self::Error> {
1051 if Self::is_citation_shaped(&s) || Self::is_canonical_unknown(&s) {
1052 return Ok(Self(s));
1053 }
1054 s.parse()
1055 }
1056}
1057
1058impl From<GovernanceLogId> for String {
1059 fn from(id: GovernanceLogId) -> Self {
1060 id.0
1061 }
1062}
1063
1064#[cfg(feature = "schemars")]
1070impl schemars::JsonSchema for GovernanceLogId {
1071 fn inline_schema() -> bool {
1072 true
1073 }
1074
1075 fn schema_name() -> std::borrow::Cow<'static, str> {
1076 std::borrow::Cow::Borrowed("GovernanceLogId")
1077 }
1078
1079 fn schema_id() -> std::borrow::Cow<'static, str> {
1080 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
1081 }
1082
1083 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1084 schemars::json_schema!({
1085 "type": "string",
1086 "pattern": GOVERNANCE_LOG_ID_PATTERN,
1087 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
1088 (Council decision or policy change), \
1089 \"APP-2026-0003\" (appeals ruling), \
1090 \"AMD-2026-0001\" (amendment to an earlier \
1091 entry), \"KEY-2026-0001\" (signing key \
1092 rotation), \"REC-2026-0001\" (a Steward's \
1093 record of an operational act), \
1094 \"OBL-2026-0001\" (an obligation) or \
1095 \"DATA-2026-0001\" (a data set).",
1096 })
1097 }
1098}
1099
1100#[derive(
1109 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1110)]
1111#[serde(try_from = "String")]
1112#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1113#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1114pub struct OAuthClientId(String);
1115
1116#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1118#[error("not an OAuth client id: {0:?}")]
1119pub struct OAuthClientIdError(pub String);
1120
1121impl OAuthClientId {
1122 pub fn generate() -> Self {
1124 Self(Uuid::new_v4().to_string())
1125 }
1126
1127 pub fn as_str(&self) -> &str {
1129 &self.0
1130 }
1131
1132 pub fn into_inner(self) -> String {
1134 self.0
1135 }
1136
1137 fn is_valid(s: &str) -> bool {
1138 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
1139 }
1140}
1141
1142impl std::fmt::Display for OAuthClientId {
1143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1144 f.write_str(&self.0)
1145 }
1146}
1147
1148impl AsRef<str> for OAuthClientId {
1149 fn as_ref(&self) -> &str {
1150 &self.0
1151 }
1152}
1153
1154impl std::str::FromStr for OAuthClientId {
1155 type Err = OAuthClientIdError;
1156
1157 fn from_str(s: &str) -> Result<Self, Self::Err> {
1158 Self::try_from(s.to_string())
1159 }
1160}
1161
1162impl TryFrom<String> for OAuthClientId {
1163 type Error = OAuthClientIdError;
1164
1165 fn try_from(s: String) -> Result<Self, Self::Error> {
1166 if Self::is_valid(&s) {
1167 Ok(Self(s))
1168 } else {
1169 Err(OAuthClientIdError(s))
1170 }
1171 }
1172}
1173
1174impl From<OAuthClientId> for String {
1175 fn from(id: OAuthClientId) -> Self {
1176 id.0
1177 }
1178}
1179
1180#[cfg(feature = "schemars")]
1183impl schemars::JsonSchema for OAuthClientId {
1184 fn inline_schema() -> bool {
1185 true
1186 }
1187
1188 fn schema_name() -> std::borrow::Cow<'static, str> {
1189 std::borrow::Cow::Borrowed("OAuthClientId")
1190 }
1191
1192 fn schema_id() -> std::borrow::Cow<'static, str> {
1193 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1194 }
1195
1196 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1197 schemars::json_schema!({
1198 "type": "string",
1199 "minLength": 1,
1200 "maxLength": 255,
1201 "description": "OAuth client_id issued at dynamic client registration.",
1202 })
1203 }
1204}
1205
1206#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1213pub enum PlatformDoc {
1214 Constitution,
1216 GovernanceProtocol,
1219 Prompts,
1221 Prompt(PromptName),
1223 Calendar,
1227}
1228
1229impl PlatformDoc {
1230 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1232 match self {
1233 PlatformDoc::Constitution => "constitution".into(),
1234 PlatformDoc::GovernanceProtocol => "protocol".into(),
1235 PlatformDoc::Prompts => "prompts".into(),
1236 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1237 PlatformDoc::Calendar => "calendar".into(),
1238 }
1239 }
1240
1241 pub fn title(&self) -> std::borrow::Cow<'static, str> {
1244 match self {
1245 PlatformDoc::Constitution => "The Agora Constitution".into(),
1246 PlatformDoc::GovernanceProtocol => {
1247 "The Agora Governance Protocol".into()
1248 }
1249 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1250 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1251 PlatformDoc::Calendar => "The Agora calendar".into(),
1252 }
1253 }
1254
1255 pub fn as_prompt(&self) -> Option<&PromptName> {
1257 match self {
1258 PlatformDoc::Prompt(name) => Some(name),
1259 _ => None,
1260 }
1261 }
1262}
1263
1264impl std::fmt::Display for PlatformDoc {
1265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1266 f.write_str(&self.slug())
1267 }
1268}
1269
1270#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1272#[error(
1273 "not a platform document (expected \"constitution\", \"protocol\", \
1274 \"prompts\", \"calendar\" or \"prompt:<name>\"): {0:?}"
1275)]
1276pub struct PlatformDocError(pub String);
1277
1278const PROMPT_PREFIXES: [&str; 4] =
1282 ["prompt:", "prompt/", "prompts/", "prompts:"];
1283
1284impl std::str::FromStr for PlatformDoc {
1285 type Err = PlatformDocError;
1286
1287 fn from_str(s: &str) -> Result<Self, Self::Err> {
1291 if s.eq_ignore_ascii_case("constitution") {
1292 Ok(PlatformDoc::Constitution)
1293 } else if s.eq_ignore_ascii_case("protocol")
1294 || s.eq_ignore_ascii_case("governance-protocol")
1295 {
1296 Ok(PlatformDoc::GovernanceProtocol)
1297 } else if s.eq_ignore_ascii_case("prompts")
1298 || s.eq_ignore_ascii_case("prompt")
1299 {
1300 Ok(PlatformDoc::Prompts)
1301 } else if s.eq_ignore_ascii_case("calendar") {
1302 Ok(PlatformDoc::Calendar)
1303 } else {
1304 PROMPT_PREFIXES
1305 .iter()
1306 .find_map(|prefix| {
1307 let head = s.get(..prefix.len())?;
1308 head.eq_ignore_ascii_case(prefix)
1309 .then(|| s[prefix.len()..].parse().ok())
1310 .flatten()
1311 })
1312 .map(PlatformDoc::Prompt)
1313 .ok_or_else(|| PlatformDocError(s.to_string()))
1314 }
1315 }
1316}
1317
1318impl Serialize for PlatformDoc {
1319 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1320 s.collect_str(self)
1321 }
1322}
1323
1324impl<'de> Deserialize<'de> for PlatformDoc {
1325 fn deserialize<D: serde::Deserializer<'de>>(
1326 d: D,
1327 ) -> Result<Self, D::Error> {
1328 let raw = String::deserialize(d)?;
1329 raw.parse().map_err(serde::de::Error::custom)
1330 }
1331}
1332
1333#[cfg(feature = "schemars")]
1336impl schemars::JsonSchema for PlatformDoc {
1337 fn inline_schema() -> bool {
1338 true
1339 }
1340
1341 fn schema_name() -> std::borrow::Cow<'static, str> {
1342 std::borrow::Cow::Borrowed("PlatformDoc")
1343 }
1344
1345 fn schema_id() -> std::borrow::Cow<'static, str> {
1346 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1347 }
1348
1349 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1350 schemars::json_schema!({
1351 "type": "string",
1352 "pattern": PLATFORM_DOC_PATTERN,
1353 "description": "A platform document: \"constitution\", \
1354 \"protocol\" (the Governance Protocol), \
1355 \"prompts\" (the index of published model \
1356 prompts), \"prompt:<name>\" (one prompt, \
1357 e.g. \"prompt:tier2_reviewer\"), or \
1358 \"calendar\" (the sittings and obligation deadlines the \
1359 Steward and the Council scheduled).",
1360 })
1361 }
1362}
1363
1364#[derive(
1372 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1373)]
1374#[serde(try_from = "String")]
1375pub struct PromptName(String);
1376
1377#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1379#[error(
1380 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1381 \"tier2_reviewer\"): {0:?}"
1382)]
1383pub struct PromptNameError(pub String);
1384
1385impl PromptName {
1386 pub const MAX_LEN: usize = 64;
1388
1389 pub fn as_str(&self) -> &str {
1391 &self.0
1392 }
1393
1394 fn normalize(s: &str) -> Option<String> {
1395 let s = s.trim();
1396 let s = match s.len().checked_sub(3) {
1397 Some(n)
1398 if s.is_char_boundary(n)
1399 && s[n..].eq_ignore_ascii_case(".md") =>
1400 {
1401 &s[..n]
1402 }
1403 _ => s,
1404 };
1405 let name: String = s
1406 .chars()
1407 .map(|c| match c {
1408 '-' => '_',
1409 c => c.to_ascii_lowercase(),
1410 })
1411 .collect();
1412 let mut chars = name.chars();
1413 let valid = name.len() <= Self::MAX_LEN
1414 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1415 && chars.all(|c| {
1416 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1417 });
1418 valid.then_some(name)
1419 }
1420}
1421
1422impl std::fmt::Display for PromptName {
1423 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1424 f.write_str(&self.0)
1425 }
1426}
1427
1428impl AsRef<str> for PromptName {
1429 fn as_ref(&self) -> &str {
1430 &self.0
1431 }
1432}
1433
1434impl std::str::FromStr for PromptName {
1435 type Err = PromptNameError;
1436
1437 fn from_str(s: &str) -> Result<Self, Self::Err> {
1438 Self::normalize(s)
1439 .map(Self)
1440 .ok_or_else(|| PromptNameError(s.to_string()))
1441 }
1442}
1443
1444impl TryFrom<String> for PromptName {
1445 type Error = PromptNameError;
1446
1447 fn try_from(s: String) -> Result<Self, Self::Error> {
1448 s.parse()
1449 }
1450}
1451
1452impl From<PromptName> for PlatformDoc {
1453 fn from(name: PromptName) -> Self {
1454 PlatformDoc::Prompt(name)
1455 }
1456}
1457
1458#[cfg(feature = "schemars")]
1459impl schemars::JsonSchema for PromptName {
1460 fn inline_schema() -> bool {
1461 true
1462 }
1463
1464 fn schema_name() -> std::borrow::Cow<'static, str> {
1465 std::borrow::Cow::Borrowed("PromptName")
1466 }
1467
1468 fn schema_id() -> std::borrow::Cow<'static, str> {
1469 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1470 }
1471
1472 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1473 schemars::json_schema!({
1474 "type": "string",
1475 "pattern": PROMPT_NAME_PATTERN,
1476 "maxLength": PromptName::MAX_LEN,
1477 "description": "A published model prompt's name, e.g. \
1478 \"tier2_reviewer\". `get_content(\"prompts\")` \
1479 lists them.",
1480 })
1481 }
1482}
1483
1484#[derive(
1490 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1491)]
1492#[serde(try_from = "String")]
1493pub struct WitnessId(String);
1494
1495#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1497#[error(
1498 "not a witness id (lowercase letters, digits and `-`, e.g. \"qwen\"): \
1499 {0:?}"
1500)]
1501pub struct WitnessIdError(pub String);
1502
1503impl WitnessId {
1504 pub const MAX_LEN: usize = 64;
1506
1507 pub fn as_str(&self) -> &str {
1509 &self.0
1510 }
1511
1512 fn normalize(s: &str) -> Option<String> {
1513 let id: String = s
1514 .trim()
1515 .chars()
1516 .map(|c| match c {
1517 '_' => '-',
1518 c => c.to_ascii_lowercase(),
1519 })
1520 .collect();
1521 let mut chars = id.chars();
1522 let valid = id.len() <= Self::MAX_LEN
1523 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1524 && chars.all(|c| {
1525 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
1526 });
1527 valid.then_some(id)
1528 }
1529}
1530
1531impl std::fmt::Display for WitnessId {
1532 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1533 f.write_str(&self.0)
1534 }
1535}
1536
1537impl AsRef<str> for WitnessId {
1538 fn as_ref(&self) -> &str {
1539 &self.0
1540 }
1541}
1542
1543impl std::str::FromStr for WitnessId {
1544 type Err = WitnessIdError;
1545
1546 fn from_str(s: &str) -> Result<Self, Self::Err> {
1547 Self::normalize(s)
1548 .map(Self)
1549 .ok_or_else(|| WitnessIdError(s.to_string()))
1550 }
1551}
1552
1553impl TryFrom<String> for WitnessId {
1554 type Error = WitnessIdError;
1555
1556 fn try_from(s: String) -> Result<Self, Self::Error> {
1557 s.parse()
1558 }
1559}
1560
1561#[cfg(feature = "schemars")]
1564impl schemars::JsonSchema for WitnessId {
1565 fn inline_schema() -> bool {
1566 true
1567 }
1568
1569 fn schema_name() -> std::borrow::Cow<'static, str> {
1570 std::borrow::Cow::Borrowed("WitnessId")
1571 }
1572
1573 fn schema_id() -> std::borrow::Cow<'static, str> {
1574 std::borrow::Cow::Borrowed(concat!(module_path!(), "::WitnessId"))
1575 }
1576
1577 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1578 schemars::json_schema!({
1579 "type": "string",
1580 "maxLength": WitnessId::MAX_LEN,
1581 "description": "A witness's id, as the item's list of witnesses \
1582 gives it, e.g. \"qwen\"",
1583 })
1584 }
1585}
1586
1587#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1603pub struct ContentIdPrefix(u32);
1604
1605#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1607#[error(
1608 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1609)]
1610pub struct ContentIdPrefixError(pub String);
1611
1612impl ContentIdPrefix {
1613 pub const LEN: usize = 8;
1615
1616 pub fn of(id: &Uuid) -> Self {
1618 Self((id.as_u128() >> 96) as u32)
1619 }
1620
1621 pub fn bounds(&self) -> (Uuid, Uuid) {
1623 let lo = (self.0 as u128) << 96;
1624 let hi = lo | ((1u128 << 96) - 1);
1625 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1626 }
1627
1628 pub fn matches(&self, id: &Uuid) -> bool {
1630 Self::of(id) == *self
1631 }
1632}
1633
1634impl std::fmt::Display for ContentIdPrefix {
1635 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1636 write!(f, "{:08x}", self.0)
1637 }
1638}
1639
1640impl std::str::FromStr for ContentIdPrefix {
1641 type Err = ContentIdPrefixError;
1642
1643 fn from_str(s: &str) -> Result<Self, Self::Err> {
1644 let t = s.trim();
1645 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1646 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1648 } else {
1649 Err(ContentIdPrefixError(s.to_string()))
1650 }
1651 }
1652}
1653
1654impl TryFrom<String> for ContentIdPrefix {
1655 type Error = ContentIdPrefixError;
1656
1657 fn try_from(s: String) -> Result<Self, Self::Error> {
1658 s.parse()
1659 }
1660}
1661
1662impl Serialize for ContentIdPrefix {
1663 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1664 s.collect_str(self)
1665 }
1666}
1667
1668impl<'de> Deserialize<'de> for ContentIdPrefix {
1669 fn deserialize<D: serde::Deserializer<'de>>(
1670 d: D,
1671 ) -> Result<Self, D::Error> {
1672 let raw = String::deserialize(d)?;
1673 raw.parse().map_err(serde::de::Error::custom)
1674 }
1675}
1676
1677impl From<ContentId> for ContentIdPrefix {
1678 fn from(id: ContentId) -> Self {
1679 Self::of(id.as_uuid())
1680 }
1681}
1682
1683#[cfg(feature = "schemars")]
1684impl schemars::JsonSchema for ContentIdPrefix {
1685 fn inline_schema() -> bool {
1686 true
1687 }
1688
1689 fn schema_name() -> std::borrow::Cow<'static, str> {
1690 std::borrow::Cow::Borrowed("ContentIdPrefix")
1691 }
1692
1693 fn schema_id() -> std::borrow::Cow<'static, str> {
1694 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1695 }
1696
1697 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1698 schemars::json_schema!({
1699 "type": "string",
1700 "pattern": CONTENT_ID_PREFIX_PATTERN,
1701 "description": "A short post or comment id: the first eight hex \
1702 digits of its UUID, e.g. \"7ad26ccd\".",
1703 })
1704 }
1705}
1706
1707pub 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})$";
1712
1713#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1722pub enum ContentTarget {
1723 Id(ContentId),
1725 Prefix(ContentIdPrefix),
1727}
1728
1729impl ContentTarget {
1730 pub fn full(&self) -> Option<ContentId> {
1732 match self {
1733 ContentTarget::Id(id) => Some(*id),
1734 ContentTarget::Prefix(_) => None,
1735 }
1736 }
1737}
1738
1739impl From<ContentId> for ContentTarget {
1740 fn from(id: ContentId) -> Self {
1741 ContentTarget::Id(id)
1742 }
1743}
1744
1745impl std::fmt::Display for ContentTarget {
1746 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1747 match self {
1748 ContentTarget::Id(id) => id.fmt(f),
1749 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1750 }
1751 }
1752}
1753
1754fn ascii_dashes(s: &str) -> String {
1759 s.chars()
1760 .map(|c| match c {
1761 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1762 c => c,
1763 })
1764 .collect()
1765}
1766
1767impl std::str::FromStr for ContentTarget {
1768 type Err = ContentTargetError;
1769
1770 fn from_str(s: &str) -> Result<Self, Self::Err> {
1771 let t = ascii_dashes(s.trim());
1772 if let Ok(id) = t.parse::<Uuid>() {
1773 return Ok(ContentTarget::Id(id.into()));
1774 }
1775 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1776 return Ok(ContentTarget::Prefix(prefix));
1777 }
1778 const UUID_LEN: usize = 36;
1781 let leading = t
1782 .get(..UUID_LEN)
1783 .and_then(|head| head.parse::<Uuid>().ok())
1784 .map(ContentId::from);
1785 Err(ContentTargetError {
1786 field: None,
1787 input: s.to_string(),
1788 leading,
1789 })
1790 }
1791}
1792
1793#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1799pub struct ContentTargetError {
1800 field: Option<&'static str>,
1801 input: String,
1802 leading: Option<ContentId>,
1803}
1804
1805impl ContentTargetError {
1806 pub fn in_field(mut self, field: &'static str) -> Self {
1808 self.field = Some(field);
1809 self
1810 }
1811}
1812
1813impl std::fmt::Display for ContentTargetError {
1814 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1815 match self.field {
1816 Some(field) => write!(f, "`{field}` must be")?,
1817 None => f.write_str("Expected")?,
1818 }
1819 f.write_str(
1820 " a post or comment id: a full UUID or its first 8 hex digits \
1821 (e.g. \"7ad26ccd\")",
1822 )?;
1823 write!(f, ", not \"{}\"", echo(&self.input))?;
1824 if let Some(id) = self.leading {
1825 write!(
1826 f,
1827 ". It starts with the id {id} followed by extra text; pass only the id"
1828 )?;
1829 }
1830 f.write_str(".")
1831 }
1832}
1833
1834impl Serialize for ContentTarget {
1835 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1836 s.collect_str(self)
1837 }
1838}
1839
1840impl<'de> Deserialize<'de> for ContentTarget {
1841 fn deserialize<D: serde::Deserializer<'de>>(
1842 d: D,
1843 ) -> Result<Self, D::Error> {
1844 content_target::deserialize_field(d, "id")
1845 }
1846}
1847
1848pub mod content_target {
1851 use serde::de::{self, Deserializer, Visitor};
1852
1853 use super::{ContentTarget, ContentTargetError};
1854
1855 struct TargetVisitor(&'static str);
1859
1860 impl Visitor<'_> for TargetVisitor {
1861 type Value = ContentTarget;
1862
1863 fn expecting(
1864 &self,
1865 f: &mut std::fmt::Formatter<'_>,
1866 ) -> std::fmt::Result {
1867 write!(
1868 f,
1869 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1870 (e.g. \"7ad26ccd\")",
1871 self.0
1872 )
1873 }
1874
1875 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1876 v.parse()
1877 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1878 }
1879 }
1880
1881 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
1882 d: D,
1883 field: &'static str,
1884 ) -> Result<ContentTarget, D::Error> {
1885 d.deserialize_str(TargetVisitor(field))
1886 }
1887
1888 pub fn reply_to<'de, D: Deserializer<'de>>(
1890 d: D,
1891 ) -> Result<ContentTarget, D::Error> {
1892 deserialize_field(d, "reply_to")
1893 }
1894
1895 pub fn target<'de, D: Deserializer<'de>>(
1897 d: D,
1898 ) -> Result<ContentTarget, D::Error> {
1899 deserialize_field(d, "target")
1900 }
1901
1902 pub fn post_id<'de, D: Deserializer<'de>>(
1904 d: D,
1905 ) -> Result<ContentTarget, D::Error> {
1906 deserialize_field(d, "post_id")
1907 }
1908
1909 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
1911 d: D,
1912 ) -> Result<Option<ContentTarget>, D::Error> {
1913 reply_to(d).map(Some)
1914 }
1915
1916 pub fn optional_target<'de, D: Deserializer<'de>>(
1920 d: D,
1921 ) -> Result<Option<ContentTarget>, D::Error> {
1922 d.deserialize_option(OptionalVisitor("target"))
1923 }
1924
1925 struct OptionalVisitor(&'static str);
1927
1928 impl<'de> Visitor<'de> for OptionalVisitor {
1929 type Value = Option<ContentTarget>;
1930
1931 fn expecting(
1932 &self,
1933 f: &mut std::fmt::Formatter<'_>,
1934 ) -> std::fmt::Result {
1935 TargetVisitor(self.0).expecting(f)?;
1936 f.write_str(", or nothing")
1937 }
1938
1939 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1940 Ok(None)
1941 }
1942
1943 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1944 Ok(None)
1945 }
1946
1947 fn visit_some<D: Deserializer<'de>>(
1948 self,
1949 d: D,
1950 ) -> Result<Self::Value, D::Error> {
1951 d.deserialize_str(self)
1952 }
1953
1954 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1955 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
1956 return Ok(None);
1957 }
1958 TargetVisitor(self.0).visit_str(v).map(Some)
1959 }
1960 }
1961}
1962
1963#[cfg(feature = "schemars")]
1967impl schemars::JsonSchema for ContentTarget {
1968 fn inline_schema() -> bool {
1969 true
1970 }
1971
1972 fn schema_name() -> std::borrow::Cow<'static, str> {
1973 std::borrow::Cow::Borrowed("ContentTarget")
1974 }
1975
1976 fn schema_id() -> std::borrow::Cow<'static, str> {
1977 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
1978 }
1979
1980 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1981 schemars::json_schema!({
1982 "type": "string",
1983 "pattern": CONTENT_TARGET_PATTERN,
1984 "description": "A post or comment id: its full UUID, or its first \
1985 8 hex digits (e.g. \"7ad26ccd\") as shown by \
1986 get_content and the dashboard. A short id works \
1987 only on unsigned calls; a signed call must use \
1988 the full UUID.",
1989 })
1990 }
1991}
1992
1993#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1996pub struct ContentRefError(pub String);
1997
1998impl ContentRefError {
1999 pub fn leading_ref(&self) -> Option<ContentRef> {
2003 const UUID_LEN: usize = 36;
2004 const CITATION_LENS: [usize; 2] =
2008 ["DATA-2026-0001".len(), "GOV-2026-0006".len()];
2009 let s = self.0.trim_start();
2010 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
2012 let head = s.get(..prefix.len())?;
2013 head.eq_ignore_ascii_case(prefix).then(|| {
2014 prefix.len()
2015 + s[prefix.len()..]
2016 .find(|c: char| {
2017 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
2018 })
2019 .unwrap_or(s.len() - prefix.len())
2020 })
2021 });
2022 prompt_len
2024 .into_iter()
2025 .chain([
2026 UUID_LEN,
2027 CITATION_LENS[0],
2028 CITATION_LENS[1],
2029 "constitution".len(),
2030 "protocol".len(),
2031 "prompts".len(),
2032 ])
2033 .filter(|&n| s.len() > n && s.is_char_boundary(n))
2034 .find_map(|n| {
2038 s[..n]
2039 .parse()
2040 .ok()
2041 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
2042 })
2043 }
2044}
2045
2046impl std::fmt::Display for ContentRefError {
2047 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2048 write!(
2049 f,
2050 "not a content reference (expected a post/comment UUID or its \
2051 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
2052 like \"constitution\", \"protocol\", \"prompts\" or \
2053 \"prompt:<name>\"): {:?}",
2054 self.0
2055 )?;
2056 if let Some(id) = self.leading_ref() {
2057 write!(
2058 f,
2059 ". It starts with the valid id {id} followed by extra text; \
2060 pass only the id"
2061 )?;
2062 }
2063 Ok(())
2064 }
2065}
2066
2067#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2085pub enum ContentRef {
2086 Content(ContentId),
2088 ContentPrefix(ContentIdPrefix),
2091 Governance(GovernanceLogId),
2093 Document(PlatformDoc),
2095}
2096
2097impl ContentRef {
2098 pub fn as_content(&self) -> Option<ContentId> {
2100 match self {
2101 ContentRef::Content(id) => Some(*id),
2102 _ => None,
2103 }
2104 }
2105
2106 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
2108 match self {
2109 ContentRef::Governance(id) => Some(id),
2110 _ => None,
2111 }
2112 }
2113
2114 pub fn as_document(&self) -> Option<&PlatformDoc> {
2116 match self {
2117 ContentRef::Document(doc) => Some(doc),
2118 _ => None,
2119 }
2120 }
2121
2122 pub fn is_governance(&self) -> bool {
2124 matches!(self, ContentRef::Governance(_))
2125 }
2126
2127 pub fn kind_str(&self) -> &'static str {
2130 match self {
2131 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
2132 ContentRef::Governance(_) => "governance",
2133 ContentRef::Document(_) => "document",
2134 }
2135 }
2136}
2137
2138impl std::fmt::Display for ContentRef {
2139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2140 match self {
2141 ContentRef::Content(id) => id.fmt(f),
2142 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
2143 ContentRef::Governance(id) => id.fmt(f),
2144 ContentRef::Document(doc) => doc.fmt(f),
2145 }
2146 }
2147}
2148
2149impl std::str::FromStr for ContentRef {
2150 type Err = ContentRefError;
2151
2152 fn from_str(s: &str) -> Result<Self, Self::Err> {
2153 if let Ok(id) = s.parse::<ContentId>() {
2154 return Ok(ContentRef::Content(id));
2155 }
2156 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
2157 return Ok(ContentRef::ContentPrefix(prefix));
2158 }
2159 if let Ok(id) = s.parse::<GovernanceLogId>() {
2160 return Ok(ContentRef::Governance(id));
2161 }
2162 if let Ok(doc) = s.parse::<PlatformDoc>() {
2163 return Ok(ContentRef::Document(doc));
2164 }
2165 Err(ContentRefError(s.to_string()))
2166 }
2167}
2168
2169impl TryFrom<String> for ContentRef {
2170 type Error = ContentRefError;
2171
2172 fn try_from(s: String) -> Result<Self, Self::Error> {
2173 s.parse()
2174 }
2175}
2176
2177impl From<ContentId> for ContentRef {
2178 fn from(id: ContentId) -> Self {
2179 ContentRef::Content(id)
2180 }
2181}
2182
2183impl From<PostId> for ContentRef {
2184 fn from(id: PostId) -> Self {
2185 ContentRef::Content(id.into())
2186 }
2187}
2188
2189impl From<CommentId> for ContentRef {
2190 fn from(id: CommentId) -> Self {
2191 ContentRef::Content(id.into())
2192 }
2193}
2194
2195impl From<ContentIdPrefix> for ContentRef {
2196 fn from(prefix: ContentIdPrefix) -> Self {
2197 ContentRef::ContentPrefix(prefix)
2198 }
2199}
2200
2201impl From<GovernanceLogId> for ContentRef {
2202 fn from(id: GovernanceLogId) -> Self {
2203 ContentRef::Governance(id)
2204 }
2205}
2206
2207impl From<PlatformDoc> for ContentRef {
2208 fn from(doc: PlatformDoc) -> Self {
2209 ContentRef::Document(doc)
2210 }
2211}
2212
2213impl Serialize for ContentRef {
2214 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2215 s.collect_str(self)
2216 }
2217}
2218
2219impl<'de> Deserialize<'de> for ContentRef {
2220 fn deserialize<D: serde::Deserializer<'de>>(
2221 d: D,
2222 ) -> Result<Self, D::Error> {
2223 let raw = String::deserialize(d)?;
2224 raw.parse().map_err(serde::de::Error::custom)
2225 }
2226}
2227
2228#[cfg(feature = "schemars")]
2233impl schemars::JsonSchema for ContentRef {
2234 fn inline_schema() -> bool {
2235 true
2236 }
2237
2238 fn schema_name() -> std::borrow::Cow<'static, str> {
2239 std::borrow::Cow::Borrowed("ContentRef")
2240 }
2241
2242 fn schema_id() -> std::borrow::Cow<'static, str> {
2243 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
2244 }
2245
2246 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2247 schemars::json_schema!({
2248 "type": "string",
2249 "pattern": CONTENT_REF_PATTERN,
2250 "description": "A post or comment UUID, or its first eight \
2251 hex digits (e.g. \"7ad26ccd\"); a governance log id \
2252 such as \"GOV-2026-0006\" (Council decision) \
2253 or \"APP-2026-0003\" (appeals ruling); or a \
2254 document slug — \"constitution\", \
2255 \"protocol\" (the Governance Protocol), \
2256 \"prompts\" (the index of the model prompts \
2257 moderation, appeals and the Council run on) or \
2258 \"prompt:<name>\" (one of them).",
2259 })
2260 }
2261}
2262
2263#[cfg(test)]
2264mod tests {
2265 use super::*;
2266
2267 #[test]
2268 fn witness_ids_fold_and_refuse() {
2269 let id: WitnessId = " Qwen_3-8 ".parse().unwrap();
2270 assert_eq!(id.as_str(), "qwen-3-8");
2271 for bad in ["", "3qwen", "-qwen", "qwen 3", "qwen.3", &"q".repeat(65)] {
2272 assert!(bad.parse::<WitnessId>().is_err(), "{bad:?}");
2273 }
2274 let json: WitnessId = serde_json::from_str("\"FACT_checker\"").unwrap();
2275 assert_eq!(json.as_str(), "fact-checker");
2276 assert!(serde_json::from_str::<WitnessId>("\"no way\"").is_err());
2277 }
2278
2279 #[cfg(feature = "schemars")]
2280 #[test]
2281 fn witness_id_schema_is_inline_and_patternless() {
2282 let schema =
2283 serde_json::to_value(schemars::schema_for!(WitnessId)).unwrap();
2284 let rendered = schema.to_string();
2285 assert!(!rendered.contains("pattern"), "{rendered}");
2286 assert!(!rendered.contains("$ref"), "{rendered}");
2287 }
2288
2289 #[test]
2290 fn governance_log_ids_from_parts() {
2291 for prefix in GovernanceLogPrefix::ALL {
2292 let id = GovernanceLogId::from_parts(prefix, 2026, 42).unwrap();
2293 assert_eq!(id.as_str(), format!("{prefix}-2026-0042"));
2294 assert_eq!(id.prefix(), prefix);
2295 assert_eq!(id.as_str().parse::<GovernanceLogId>().unwrap(), id);
2296 }
2297 let gov = GovernanceLogPrefix::Gov;
2298 assert!(GovernanceLogId::from_parts(gov, 2026, 9999).is_some());
2299 assert!(GovernanceLogId::from_parts(gov, 2026, 10_000).is_none());
2300 assert!(GovernanceLogId::from_parts(gov, 2026, 0).is_none());
2301 assert!(GovernanceLogId::from_parts(gov, 999, 1).is_none());
2302 }
2303
2304 #[test]
2305 fn byte_ids_debug() {
2306 let handle = WebAuthnUserHandle::from(vec![7; 64]);
2307 assert_eq!(format!("{handle:?}"), "WebAuthnUserHandle(..)");
2308 let credential = PasskeyCredentialId::from(vec![0xab; 2]);
2309 assert_eq!(format!("{credential:?}"), "PasskeyCredentialId(abab)");
2310 assert_eq!(credential.as_bytes(), &[0xab, 0xab]);
2311 }
2312
2313 #[test]
2314 fn oauth_client_id_round_trips() {
2315 let id = OAuthClientId::generate();
2316 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2317 let json = serde_json::to_string(&id).unwrap();
2318 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2319 }
2320
2321 #[test]
2322 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2323 assert!("".parse::<OAuthClientId>().is_err());
2324 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2325 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2326 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2327 }
2328
2329 #[test]
2330 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
2331 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
2332 for (input, lead) in [
2333 (format!("{uuid} and the safe-space proposal"), uuid),
2334 (format!("{uuid}b0518e42"), uuid),
2335 (
2336 "GOV-2026-0006 (the ratification)".to_string(),
2337 "GOV-2026-0006",
2338 ),
2339 (
2342 "DATA-2026-0001 (the vote tallies)".to_string(),
2343 "DATA-2026-0001",
2344 ),
2345 ("DATA-2026-0012, index.md".to_string(), "DATA-2026-0012"),
2346 ("OBL-2026-0001 and more".to_string(), "OBL-2026-0001"),
2347 ("protocol, section 3".to_string(), "protocol"),
2348 ("prompts, please".to_string(), "prompts"),
2349 (
2350 "prompt:tier2_reviewer and the juror's".to_string(),
2351 "prompt:tier2_reviewer",
2352 ),
2353 (
2354 "prompts/appeals_juror.md, line 3".to_string(),
2355 "prompt:appeals_juror",
2356 ),
2357 ] {
2358 let err = input.parse::<ContentRef>().unwrap_err();
2359 assert_eq!(
2360 err.leading_ref(),
2361 Some(lead.parse().unwrap()),
2362 "{input}"
2363 );
2364 let msg = err.to_string();
2365 assert!(
2366 msg.contains(&format!("valid id {lead} followed")),
2367 "{msg}"
2368 );
2369 }
2370
2371 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
2373 let err = short.parse::<ContentRef>().unwrap_err();
2374 assert_eq!(err.leading_ref(), None);
2375 assert!(!err.to_string().contains("followed by"));
2376 assert_eq!(
2378 "GOV-2026-1".parse::<ContentRef>().unwrap(),
2379 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
2380 );
2381 }
2382
2383 #[test]
2384 fn citations_normalize_as_written() {
2385 for (written, canonical) in [
2386 ("GOV-2026-0006", "GOV-2026-0006"),
2387 ("GOV-2026.6", "GOV-2026-0006"),
2388 ("gov-2026-6", "GOV-2026-0006"),
2389 ("GOV 2026 6", "GOV-2026-0006"),
2390 ("app/2026/03", "APP-2026-0003"),
2391 (" REC-2026-0002 ", "REC-2026-0002"),
2392 ("obl-2026-12", "OBL-2026-0012"),
2393 ("OBL.2026.7", "OBL-2026-0007"),
2394 ("data-2026-3", "DATA-2026-0003"),
2395 ("DATA.2026.12", "DATA-2026-0012"),
2396 (" Data/2026/0001 ", "DATA-2026-0001"),
2397 ("DATA 2026 7", "DATA-2026-0007"),
2398 ] {
2399 let id: GovernanceLogId = written.parse().unwrap();
2400 assert_eq!(id.as_str(), canonical, "{written}");
2401 let json: GovernanceLogId =
2402 serde_json::from_value(serde_json::json!(written)).unwrap();
2403 assert_eq!(json.as_str(), canonical, "{written} via serde");
2404 let content: ContentRef = written.parse().unwrap();
2405 assert_eq!(content, ContentRef::Governance(id), "{written}");
2406 }
2407 for rejected in [
2408 "GOV-2026-N",
2409 "GOV-2026/GOVG=6",
2410 "GOV-2026-00006",
2411 "GOV-26-0006",
2412 "GOV-2026-",
2413 "XYZ-2026-0006",
2414 "GOV-2026-0006-1",
2415 ] {
2416 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
2417 }
2418 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
2420 }
2421
2422 #[test]
2423 fn content_ref_parses_document_slugs() {
2424 assert_eq!(
2425 "constitution".parse(),
2426 Ok(ContentRef::Document(PlatformDoc::Constitution))
2427 );
2428 assert_eq!(
2429 "protocol".parse(),
2430 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2431 );
2432 assert_eq!(
2434 "governance-protocol".parse(),
2435 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2436 );
2437 assert_eq!(
2438 "Constitution".parse(),
2439 Ok(ContentRef::Document(PlatformDoc::Constitution))
2440 );
2441 assert!("proto".parse::<ContentRef>().is_err());
2442 }
2443
2444 #[test]
2445 fn content_ref_parses_prompt_slugs() {
2446 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
2447 for written in [
2448 "prompt:appeals_juror",
2449 "Prompt:Appeals_Juror",
2450 "prompt/appeals-juror",
2451 "prompts/appeals_juror.md",
2452 "prompts:appeals_juror",
2453 ] {
2454 assert_eq!(
2455 written.parse(),
2456 Ok(ContentRef::Document(juror.clone())),
2457 "{written}"
2458 );
2459 }
2460 assert_eq!(juror.to_string(), "prompt:appeals_juror");
2461 for index in ["prompts", "Prompts", "prompt"] {
2462 assert_eq!(
2463 index.parse(),
2464 Ok(ContentRef::Document(PlatformDoc::Prompts)),
2465 "{index}"
2466 );
2467 }
2468 for rejected in [
2469 "prompt:",
2470 "prompt:2fast",
2471 "prompt:tier 2",
2472 "prompt:../secrets",
2473 "prompt:tier2_reviewer/x",
2474 "promptly",
2475 ] {
2476 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
2477 }
2478 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
2479 assert!(long.parse::<ContentRef>().is_err());
2480 }
2481
2482 #[test]
2483 fn content_id_prefix_parses_eight_hex_digits_leniently() {
2484 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
2485 assert_eq!(p.to_string(), "7ad26ccd");
2486 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
2487 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
2488 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
2489 }
2490 }
2491
2492 #[test]
2493 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
2494 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2495 let p = ContentIdPrefix::of(&id);
2496 assert_eq!(p.to_string(), "7ad26ccd");
2497 assert!(p.matches(&id));
2498 let (lo, hi) = p.bounds();
2499 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
2500 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
2501 assert!(lo <= id && id <= hi);
2502 let next: Uuid =
2503 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
2504 assert!(!p.matches(&next) && next > hi);
2505 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
2507 assert_eq!(top.bounds().1, Uuid::max());
2508 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
2509 assert_eq!(bottom.bounds().0, Uuid::nil());
2510 }
2511
2512 #[test]
2513 fn content_ref_reads_a_short_id_and_only_eight_digits() {
2514 let r: ContentRef = "7ad26ccd".parse().unwrap();
2515 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
2516 assert_eq!(r.kind_str(), "content");
2517 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
2518 let full: ContentRef =
2520 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2521 assert!(matches!(full, ContentRef::Content(_)));
2522 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
2527 assert_eq!(err.leading_ref(), None);
2528 }
2529
2530 #[test]
2531 fn content_ref_pattern_admits_short_ids() {
2532 let short = CONTENT_ID_PREFIX_PATTERN
2533 .strip_prefix('^')
2534 .and_then(|p| p.strip_suffix('$'))
2535 .unwrap();
2536 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2537 }
2538
2539 #[test]
2541 fn document_patterns_agree() {
2542 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2543 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2544 let name = PROMPT_NAME_PATTERN
2545 .strip_prefix('^')
2546 .and_then(|p| p.strip_suffix('$'))
2547 .unwrap();
2548 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2549 }
2550
2551 #[test]
2552 fn calendar_is_a_document_slug() {
2553 assert_eq!(
2554 "calendar".parse::<ContentRef>().unwrap(),
2555 ContentRef::Document(PlatformDoc::Calendar)
2556 );
2557 assert_eq!(
2558 "Calendar".parse::<PlatformDoc>().unwrap(),
2559 PlatformDoc::Calendar
2560 );
2561 assert_eq!(PlatformDoc::Calendar.to_string(), "calendar");
2562 assert_eq!(
2563 "OBL-2026-0001".parse::<ContentRef>().unwrap(),
2564 ContentRef::Governance("OBL-2026-0001".parse().unwrap())
2565 );
2566 assert_eq!(
2567 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
2568 ContentRef::Governance("DATA-2026-0001".parse().unwrap())
2569 );
2570 }
2571
2572 #[test]
2573 fn platform_doc_serde_uses_the_canonical_slug() {
2574 let json =
2575 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2576 assert_eq!(json, "\"protocol\"");
2577 let doc: PlatformDoc =
2578 serde_json::from_str("\"governance-protocol\"").unwrap();
2579 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2580 }
2581
2582 #[test]
2583 fn ids_are_unique() {
2584 let a = AgentId::new();
2585 let b = AgentId::new();
2586 assert_ne!(a, b);
2587 }
2588
2589 #[test]
2590 fn serde_round_trip() {
2591 let id = PostId::new();
2592 let json = serde_json::to_string(&id).unwrap();
2593 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2594 assert_eq!(id, deserialized);
2595 }
2596
2597 #[test]
2598 fn display_shows_uuid() {
2599 let id = CommunityId::new();
2600 let display = id.to_string();
2601 assert_eq!(display.len(), 36);
2603 assert!(display.contains('-'));
2604 }
2605
2606 #[test]
2607 fn from_uuid_round_trip() {
2608 let uuid = Uuid::new_v4();
2609 let id = AgentId::from(uuid);
2610 let back: Uuid = id.into();
2611 assert_eq!(uuid, back);
2612 }
2613
2614 #[test]
2618 fn every_id_round_trips_through_its_own_display() {
2619 let agent = AgentId::new();
2620 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2621
2622 let action = ModerationActionId::new();
2623 assert_eq!(
2624 action.to_string().parse::<ModerationActionId>().unwrap(),
2625 action
2626 );
2627
2628 let content = ContentId::new();
2629 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2630
2631 let feedback = FeedbackId::new();
2632 assert_eq!(
2633 feedback.to_string().parse::<FeedbackId>().unwrap(),
2634 feedback
2635 );
2636 }
2637
2638 #[test]
2639 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2640 assert!("not-a-uuid".parse::<ContentId>().is_err());
2641 assert!("".parse::<ContentId>().is_err());
2642 }
2643
2644 #[test]
2649 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2650 let uuid = Uuid::new_v4();
2651 let typed = ContentId::from(uuid);
2652 assert_eq!(
2653 serde_json::to_string(&typed).unwrap(),
2654 serde_json::to_string(&uuid).unwrap()
2655 );
2656 }
2657
2658 #[test]
2661 fn every_moderation_target_narrows_losslessly() {
2662 let uuid = Uuid::new_v4();
2663
2664 for (label, got) in [
2665 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2666 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2667 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2668 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2669 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2670 ] {
2671 assert_eq!(
2672 got.as_uuid(),
2673 &uuid,
2674 "{label} -> ModerationTargetId lost the uuid"
2675 );
2676 }
2677 }
2678
2679 #[test]
2683 fn resolved_ids_narrow_to_content_id_losslessly() {
2684 let uuid = Uuid::new_v4();
2685
2686 assert_eq!(
2687 ContentId::from(PostId::from(uuid)).as_uuid(),
2688 &uuid,
2689 "PostId -> ContentId lost the uuid"
2690 );
2691 assert_eq!(
2692 ContentId::from(CommentId::from(uuid)).as_uuid(),
2693 &uuid,
2694 "CommentId -> ContentId lost the uuid"
2695 );
2696 assert_eq!(
2697 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2698 .as_uuid(),
2699 &uuid,
2700 "PostOrCommentId -> ContentId lost the uuid"
2701 );
2702 }
2703
2704 #[test]
2705 fn json_is_plain_uuid_string() {
2706 let uuid = Uuid::new_v4();
2707 let id = AgentId::from(uuid);
2708 let id_json = serde_json::to_string(&id).unwrap();
2710 let uuid_json = serde_json::to_string(&uuid).unwrap();
2711 assert_eq!(id_json, uuid_json);
2712 }
2713
2714 #[cfg(feature = "schemars")]
2719 #[test]
2720 fn id_json_schema_is_inlined() {
2721 use schemars::JsonSchema;
2722
2723 assert!(
2724 <PostId as JsonSchema>::inline_schema(),
2725 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2726 );
2727 assert!(<AgentId as JsonSchema>::inline_schema());
2728 assert!(<CommentId as JsonSchema>::inline_schema());
2729 assert!(<CommunityId as JsonSchema>::inline_schema());
2730 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2731 assert!(<ContentRef as JsonSchema>::inline_schema());
2732 assert!(<FeedbackId as JsonSchema>::inline_schema());
2733 assert!(<ContactRequestId as JsonSchema>::inline_schema());
2734
2735 #[derive(schemars::JsonSchema)]
2739 #[allow(dead_code)]
2740 struct Container {
2741 post_id: PostId,
2743 agent_id: Option<AgentId>,
2745 gov_id: GovernanceLogId,
2747 maybe_gov_id: Option<GovernanceLogId>,
2749 content_ref: ContentRef,
2751 maybe_content_ref: Option<ContentRef>,
2753 feedback_id: FeedbackId,
2755 }
2756
2757 let schema = schemars::schema_for!(Container);
2758 let value = serde_json::to_value(&schema).unwrap();
2759
2760 assert!(
2762 value.get("$defs").is_none(),
2763 "no $defs should be emitted for ID-only container; got schema: {value}"
2764 );
2765
2766 let post_id = &value["properties"]["post_id"];
2768 assert!(
2769 post_id.get("$ref").is_none(),
2770 "post_id must not be a $ref; got: {post_id}"
2771 );
2772 assert_eq!(post_id["type"], "string");
2773 assert_eq!(post_id["format"], "uuid");
2774
2775 let agent_id = &value["properties"]["agent_id"];
2780 assert!(
2781 agent_id.get("$ref").is_none(),
2782 "agent_id must not be a $ref; got: {agent_id}"
2783 );
2784 let agent_id_str = agent_id.to_string();
2785 assert!(
2786 !agent_id_str.contains("$ref"),
2787 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2788 );
2789 assert!(
2790 agent_id_str.contains("\"format\":\"uuid\""),
2791 "agent_id should still carry format=uuid; got: {agent_id}"
2792 );
2793
2794 for field in
2799 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2800 {
2801 let f = &value["properties"][field];
2802 assert!(
2803 !f.to_string().contains("$ref"),
2804 "{field} must contain no $ref anywhere; got: {f}"
2805 );
2806 }
2807 assert_eq!(value["properties"]["gov_id"]["type"], "string");
2808 assert_eq!(
2809 value["properties"]["gov_id"]["pattern"],
2810 GOVERNANCE_LOG_ID_PATTERN
2811 );
2812 assert!(
2813 value["properties"]["maybe_gov_id"]
2814 .to_string()
2815 .contains("GOV|APP"),
2816 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2817 value["properties"]["maybe_gov_id"]
2818 );
2819 assert_eq!(value["properties"]["content_ref"]["type"], "string");
2820 }
2821
2822 #[test]
2823 fn governance_log_id_accepts_only_citation_shapes() {
2824 for (good, prefix) in [
2825 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2826 ("APP-2026-0003", GovernanceLogPrefix::App),
2827 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2828 ("KEY-2026-0001", GovernanceLogPrefix::Key),
2829 ("REC-2026-0001", GovernanceLogPrefix::Rec),
2830 ("OBL-2026-0001", GovernanceLogPrefix::Obl),
2831 ("DATA-2026-0001", GovernanceLogPrefix::Data),
2832 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2833 ] {
2834 let id = good.parse::<GovernanceLogId>().unwrap();
2835 assert_eq!(id.as_str(), good, "{good} should parse");
2836 assert_eq!(id.prefix(), prefix);
2837 let letters = good.split('-').next().unwrap();
2838 assert_eq!(id.prefix().as_str(), letters);
2839 assert_eq!(id.prefix().to_string().parse(), Ok(prefix));
2840 }
2841 let newer: GovernanceLogId =
2843 serde_json::from_value(serde_json::json!("ZZZ-2026-0001")).unwrap();
2844 assert_eq!(newer.prefix(), GovernanceLogPrefix::Unknown);
2845 assert_eq!(newer.as_str(), "ZZZ-2026-0001");
2846 assert!("ZZZ-2026-0001".parse::<GovernanceLogId>().is_err());
2847 assert!(!GovernanceLogId::is_citation_shaped("ZZZ-2026-0001"));
2848 for junk in [
2849 "zzz-2026-0001",
2850 "ZZZZZ-2026-0001",
2851 "ZZ-2026-0001",
2852 "ZZZ-2026-1",
2853 ] {
2854 assert!(
2855 serde_json::from_value::<GovernanceLogId>(serde_json::json!(
2856 junk
2857 ))
2858 .is_err(),
2859 "{junk}"
2860 );
2861 }
2862 assert_eq!(
2863 GovernanceLogId::from_parts(GovernanceLogPrefix::Unknown, 2026, 1),
2864 None
2865 );
2866 for prefix in GovernanceLogPrefix::ALL {
2868 let want = if prefix == GovernanceLogPrefix::Data {
2869 4
2870 } else {
2871 3
2872 };
2873 assert_eq!(prefix.as_str().len(), want, "{prefix}");
2874 }
2875 let series = GovernanceLogPrefix::ALL.map(|p| p.as_str()).join("|");
2877 assert!(
2878 GOVERNANCE_LOG_ID_PATTERN.contains(&format!("({series})")),
2879 "{GOVERNANCE_LOG_ID_PATTERN}"
2880 );
2881 assert!(
2882 CONTENT_REF_PATTERN.contains(&format!("({series})")),
2883 "{CONTENT_REF_PATTERN}"
2884 );
2885 assert_eq!(
2887 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
2888 "GOV-2026-0006"
2889 );
2890 assert_eq!(
2891 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
2892 "GOV-2026-0006"
2893 );
2894 for bad in [
2895 "",
2896 "GOV-26-0006",
2897 "MOD-2026-0006",
2898 "GOV-2026-0006-1",
2899 "GOV-202X-0006",
2900 "DAT-2026-0001",
2901 "DATAS-2026-0001",
2902 "DATA-2026-00001",
2903 "DATA-2026",
2904 "3f1a0000-0000-0000-0000-000000000000",
2905 ] {
2906 assert!(
2907 bad.parse::<GovernanceLogId>().is_err(),
2908 "{bad:?} should not parse as a GovernanceLogId"
2909 );
2910 }
2911 }
2912
2913 #[test]
2917 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
2918 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2919 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
2920 let back: GovernanceLogId =
2921 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
2922 assert_eq!(back, id);
2923 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
2925 }
2926
2927 #[test]
2930 fn content_ref_round_trips_as_a_bare_string() {
2931 let uuid = Uuid::new_v4();
2932 let content = ContentRef::from(ContentId::from(uuid));
2933 assert_eq!(
2934 serde_json::to_value(&content).unwrap(),
2935 serde_json::json!(uuid.to_string())
2936 );
2937 assert_eq!(
2938 serde_json::from_value::<ContentRef>(serde_json::json!(
2939 uuid.to_string()
2940 ))
2941 .unwrap(),
2942 content
2943 );
2944
2945 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2946 assert_eq!(
2947 serde_json::to_value(&gov).unwrap(),
2948 serde_json::json!("APP-2026-0003")
2949 );
2950 assert_eq!(
2951 serde_json::from_value::<ContentRef>(serde_json::json!(
2952 "APP-2026-0003"
2953 ))
2954 .unwrap(),
2955 gov
2956 );
2957
2958 assert!(gov.is_governance());
2959 assert!(!content.is_governance());
2960 assert_eq!(gov.kind_str(), "governance");
2961 assert_eq!(content.kind_str(), "content");
2962 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2963 assert!(content.as_governance().is_none());
2964
2965 assert!("not-an-id".parse::<ContentRef>().is_err());
2967 assert!(
2968 serde_json::from_value::<ContentRef>(serde_json::json!(
2969 "not-an-id"
2970 ))
2971 .is_err()
2972 );
2973 }
2974
2975 #[test]
2977 fn every_readable_id_narrows_to_a_content_ref() {
2978 let uuid = Uuid::new_v4();
2979 for (label, got) in [
2980 ("PostId", ContentRef::from(PostId::from(uuid))),
2981 ("CommentId", ContentRef::from(CommentId::from(uuid))),
2982 ("ContentId", ContentRef::from(ContentId::from(uuid))),
2983 ] {
2984 assert_eq!(
2985 got,
2986 ContentRef::Content(ContentId::from(uuid)),
2987 "{label} -> ContentRef lost the uuid"
2988 );
2989 }
2990 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2991 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2992 }
2993
2994 #[test]
2997 fn string_shaped_ids_round_trip_through_display() {
2998 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2999 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
3000
3001 let r = ContentRef::Governance(gov);
3002 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3003
3004 let r = ContentRef::Content(ContentId::new());
3005 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3006 }
3007
3008 #[test]
3009 fn post_or_comment_post_variant() {
3010 let inner = PostId::new();
3011 let tagged = PostOrCommentId::Post(inner);
3012 assert!(tagged.is_post());
3013 assert!(!tagged.is_comment());
3014 assert_eq!(tagged.as_post(), Some(inner));
3015 assert_eq!(tagged.as_comment(), None);
3016 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3017 assert_eq!(tagged.kind_str(), "post");
3018 }
3019
3020 #[test]
3021 fn post_or_comment_comment_variant() {
3022 let inner = CommentId::new();
3023 let tagged = PostOrCommentId::Comment(inner);
3024 assert!(tagged.is_comment());
3025 assert!(!tagged.is_post());
3026 assert_eq!(tagged.as_comment(), Some(inner));
3027 assert_eq!(tagged.as_post(), None);
3028 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3029 assert_eq!(tagged.kind_str(), "comment");
3030 }
3031
3032 #[test]
3033 fn post_or_comment_from_conversions() {
3034 let post = PostId::new();
3035 let comment = CommentId::new();
3036 let via_post: PostOrCommentId = post.into();
3037 let via_comment: PostOrCommentId = comment.into();
3038 assert_eq!(via_post, PostOrCommentId::Post(post));
3039 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
3040 }
3041
3042 #[test]
3043 fn post_or_comment_display_is_kind_colon_uuid() {
3044 let post = PostId::new();
3045 let tagged = PostOrCommentId::Post(post);
3046 let rendered = tagged.to_string();
3047 assert!(rendered.starts_with("post:"));
3048 assert!(rendered.contains(&post.to_string()));
3049 }
3050
3051 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
3054
3055 #[test]
3058 fn a_bad_id_fails_in_plain_words() {
3059 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
3060 let want = format!(
3061 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
3062 );
3063 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
3064 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
3065 .unwrap_err()
3066 .to_string();
3067 assert!(err.starts_with(&want), "{err}");
3068 assert!(!err.contains("group"), "{err}");
3069 let err = serde_json::from_str::<AgentId>("5")
3070 .unwrap_err()
3071 .to_string();
3072 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
3073 let long = "x".repeat(500);
3075 let msg = long.parse::<CommentId>().unwrap_err().to_string();
3076 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
3077 }
3078
3079 #[test]
3081 fn ids_still_round_trip() {
3082 let id: PostId = FULL.parse().unwrap();
3083 let json = serde_json::to_string(&id).unwrap();
3084 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
3085 let map: std::collections::HashMap<PostId, i64> =
3086 [(id, 3)].into_iter().collect();
3087 let json = serde_json::to_string(&map).unwrap();
3088 let back: std::collections::HashMap<PostId, i64> =
3089 serde_json::from_str(&json).unwrap();
3090 assert_eq!(back, map);
3091 }
3092
3093 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
3094 s.parse()
3095 }
3096
3097 #[test]
3098 fn full_and_short_targets_parse() {
3099 let full = target(FULL).unwrap();
3100 assert_eq!(full.full().unwrap().to_string(), FULL);
3101 assert_eq!(full.to_string(), FULL);
3102 let short = target(" 7AD26CCD ").unwrap();
3103 assert!(short.full().is_none());
3104 assert_eq!(short.to_string(), "7ad26ccd");
3105 for variant in [
3107 FULL.replace('-', ""),
3108 FULL.to_uppercase(),
3109 FULL.replace('-', "\u{2011}"),
3110 ] {
3111 assert_eq!(target(&variant).unwrap(), full, "{variant}");
3112 }
3113 }
3114
3115 #[test]
3116 fn a_truncated_uuid_is_an_error_not_its_prefix() {
3117 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
3118 let msg = err.in_field("reply_to").to_string();
3119 assert!(
3120 msg.starts_with(
3121 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
3122 ),
3123 "{msg}"
3124 );
3125 for internal in [
3126 "group",
3127 "UUID parsing",
3128 "invalid length",
3129 "invalid character",
3130 ] {
3131 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
3132 }
3133 assert!(target("7ad26cc").is_err());
3134 assert!(target("7ad26ccd9").is_err());
3135 }
3136
3137 #[test]
3138 fn trailing_text_names_the_id_it_starts_with() {
3139 let msg = target(&format!("{FULL} (the reply above)"))
3140 .unwrap_err()
3141 .to_string();
3142 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
3143 }
3144
3145 #[derive(Debug, serde::Deserialize)]
3146 struct Probe {
3147 #[serde(deserialize_with = "content_target::reply_to")]
3148 #[allow(dead_code)]
3149 reply_to: ContentTarget,
3150 }
3151
3152 #[test]
3153 fn target_errors_name_the_field_for_strings_and_other_types() {
3154 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
3155 .unwrap_err();
3156 assert!(
3157 err.to_string()
3158 .starts_with("`reply_to` must be a post or comment id"),
3159 "{err}"
3160 );
3161 let err =
3162 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
3163 assert!(
3164 err.to_string()
3165 .contains("expected `reply_to` to be a post or comment id"),
3166 "{err}"
3167 );
3168 }
3169
3170 #[cfg(feature = "schemars")]
3171 #[test]
3172 fn target_schema_is_inline_and_admits_both_forms() {
3173 let schema = schemars::schema_for!(ContentTarget);
3174 let v = schema.as_value();
3175 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
3176 assert!(v.get("format").is_none(), "a short id is not format: uuid");
3177 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
3178 }
3179}