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 InterviewRunId
288}
289
290define_id! {
291 BatchTrackingId
293}
294
295define_id! {
296 ThreadSummaryId
298}
299
300define_id! {
301 McpSessionId
303}
304
305define_id! {
306 EmailVerificationTokenId
308}
309
310define_id! {
311 PostEmbeddingId
313}
314
315define_id! {
316 DataExportId
322}
323
324define_id! {
325 RefreshTokenId
332}
333
334define_id! {
335 MessageId
343}
344
345define_id! {
346 FeedbackId
350}
351
352define_id! {
353 ContactRequestId
356}
357
358define_id! {
359 TrashEntryId
363}
364
365define_id! {
366 PollId
369}
370
371define_id! {
372 PollOptionId
376}
377
378define_id! {
379 BallotId
382}
383
384define_id! {
385 CalendarEventId
389}
390
391define_id! {
392 PromptArchiveResponseId
395}
396
397define_id! {
398 CleanupId
402}
403
404define_id! {
405 OperatorSessionId
408}
409
410define_id! {
411 OperatorPasswordResetId
414}
415
416define_id! {
417 OperatorPasskeyId
420}
421
422define_id! {
423 OperatorPendingLoginId
427}
428
429#[derive(Clone, PartialEq, Eq, Hash)]
433#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
434#[cfg_attr(feature = "sqlx", sqlx(transparent))]
435pub struct PasskeyCredentialId(Vec<u8>);
436
437impl PasskeyCredentialId {
438 pub fn as_bytes(&self) -> &[u8] {
439 &self.0
440 }
441}
442
443impl From<Vec<u8>> for PasskeyCredentialId {
444 fn from(bytes: Vec<u8>) -> Self {
445 Self(bytes)
446 }
447}
448
449impl std::fmt::Debug for PasskeyCredentialId {
450 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
451 write!(f, "PasskeyCredentialId({})", hex::encode(&self.0))
452 }
453}
454
455#[derive(Clone, PartialEq, Eq)]
460#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
461#[cfg_attr(feature = "sqlx", sqlx(transparent))]
462pub struct WebAuthnUserHandle(Vec<u8>);
463
464impl WebAuthnUserHandle {
465 pub fn as_bytes(&self) -> &[u8] {
466 &self.0
467 }
468}
469
470impl From<Vec<u8>> for WebAuthnUserHandle {
471 fn from(bytes: Vec<u8>) -> Self {
472 Self(bytes)
473 }
474}
475
476impl std::fmt::Debug for WebAuthnUserHandle {
477 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478 f.write_str("WebAuthnUserHandle(..)")
479 }
480}
481
482#[derive(
496 Debug,
497 Clone,
498 Copy,
499 PartialEq,
500 Eq,
501 PartialOrd,
502 Ord,
503 Hash,
504 Serialize,
505 Deserialize,
506)]
507#[serde(transparent)]
508pub struct PollOptionNumber(i16);
509
510impl PollOptionNumber {
511 pub fn new(n: usize) -> Option<Self> {
513 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
514 }
515
516 pub fn index(&self) -> Option<usize> {
518 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
519 }
520
521 pub fn get(&self) -> i16 {
522 self.0
523 }
524}
525
526impl From<i16> for PollOptionNumber {
527 fn from(n: i16) -> Self {
528 Self(n)
529 }
530}
531
532impl std::fmt::Display for PollOptionNumber {
533 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534 self.0.fmt(f)
535 }
536}
537
538#[cfg(feature = "schemars")]
539impl schemars::JsonSchema for PollOptionNumber {
540 fn inline_schema() -> bool {
541 true
542 }
543
544 fn schema_name() -> std::borrow::Cow<'static, str> {
545 std::borrow::Cow::Borrowed("PollOptionNumber")
546 }
547
548 fn schema_id() -> std::borrow::Cow<'static, str> {
549 std::borrow::Cow::Borrowed(concat!(
550 module_path!(),
551 "::PollOptionNumber"
552 ))
553 }
554
555 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
557 schemars::json_schema!({ "type": "integer" })
558 }
559}
560
561#[derive(
567 Debug,
568 Clone,
569 Copy,
570 PartialEq,
571 Eq,
572 PartialOrd,
573 Ord,
574 Hash,
575 Serialize,
576 Deserialize,
577)]
578#[serde(transparent)]
579#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
580#[cfg_attr(feature = "schemars", schemars(inline))]
581#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
582#[cfg_attr(feature = "sqlx", sqlx(transparent))]
583pub struct PollOptionPosition(i16);
584
585impl PollOptionPosition {
586 pub fn new(n: usize) -> Option<Self> {
588 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
589 }
590
591 pub fn index(&self) -> Option<usize> {
593 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
594 }
595
596 pub fn get(&self) -> i16 {
597 self.0
598 }
599}
600
601impl From<i16> for PollOptionPosition {
602 fn from(n: i16) -> Self {
603 Self(n)
604 }
605}
606
607impl std::fmt::Display for PollOptionPosition {
608 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609 self.0.fmt(f)
610 }
611}
612
613define_id! {
614 ContentId
635}
636
637define_id! {
638 ModerationTargetId
656}
657
658impl From<PostId> for ModerationTargetId {
664 fn from(id: PostId) -> Self {
665 Self::from(*id.as_uuid())
666 }
667}
668
669impl From<CommentId> for ModerationTargetId {
670 fn from(id: CommentId) -> Self {
671 Self::from(*id.as_uuid())
672 }
673}
674
675impl From<MessageId> for ModerationTargetId {
676 fn from(id: MessageId) -> Self {
677 Self::from(*id.as_uuid())
678 }
679}
680
681impl From<AgentId> for ModerationTargetId {
682 fn from(id: AgentId) -> Self {
683 Self::from(*id.as_uuid())
684 }
685}
686
687impl From<ContentId> for ModerationTargetId {
690 fn from(id: ContentId) -> Self {
691 Self::from(*id.as_uuid())
692 }
693}
694
695impl From<PostId> for ContentId {
700 fn from(id: PostId) -> Self {
701 Self::from(*id.as_uuid())
702 }
703}
704
705impl From<CommentId> for ContentId {
706 fn from(id: CommentId) -> Self {
707 Self::from(*id.as_uuid())
708 }
709}
710
711impl From<PostOrCommentId> for ContentId {
712 fn from(id: PostOrCommentId) -> Self {
713 Self::from(id.as_uuid())
714 }
715}
716
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
745pub enum PostOrCommentId {
746 Post(PostId),
747 Comment(CommentId),
748}
749
750impl PostOrCommentId {
751 pub fn as_uuid(&self) -> Uuid {
753 match self {
754 PostOrCommentId::Post(id) => *id.as_uuid(),
755 PostOrCommentId::Comment(id) => *id.as_uuid(),
756 }
757 }
758
759 pub fn is_post(&self) -> bool {
761 matches!(self, PostOrCommentId::Post(_))
762 }
763
764 pub fn is_comment(&self) -> bool {
766 matches!(self, PostOrCommentId::Comment(_))
767 }
768
769 pub fn as_post(&self) -> Option<PostId> {
771 match self {
772 PostOrCommentId::Post(id) => Some(*id),
773 PostOrCommentId::Comment(_) => None,
774 }
775 }
776
777 pub fn as_comment(&self) -> Option<CommentId> {
779 match self {
780 PostOrCommentId::Comment(id) => Some(*id),
781 PostOrCommentId::Post(_) => None,
782 }
783 }
784
785 pub fn kind(&self) -> crate::enums::ContentKind {
787 match self {
788 PostOrCommentId::Post(_) => crate::enums::ContentKind::Post,
789 PostOrCommentId::Comment(_) => crate::enums::ContentKind::Comment,
790 }
791 }
792
793 pub fn kind_str(&self) -> &'static str {
796 match self {
797 PostOrCommentId::Post(_) => "post",
798 PostOrCommentId::Comment(_) => "comment",
799 }
800 }
801}
802
803impl From<PostId> for PostOrCommentId {
804 fn from(id: PostId) -> Self {
805 PostOrCommentId::Post(id)
806 }
807}
808
809impl From<CommentId> for PostOrCommentId {
810 fn from(id: CommentId) -> Self {
811 PostOrCommentId::Comment(id)
812 }
813}
814
815impl std::fmt::Display for PostOrCommentId {
816 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
817 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
818 }
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
836pub enum ArchiveContext {
837 AgendaItem(AgendaItemId),
839 Meeting(CouncilMeetingId),
842 Appeal(AppealId),
844 Flag(FlagId),
846 InterviewRun(InterviewRunId),
848}
849
850impl ArchiveContext {
851 pub fn kind(&self) -> crate::enums::ArchiveContextKind {
853 use crate::enums::ArchiveContextKind as K;
854 match self {
855 Self::AgendaItem(_) => K::AgendaItem,
856 Self::Meeting(_) => K::Meeting,
857 Self::Appeal(_) => K::Appeal,
858 Self::Flag(_) => K::Flag,
859 Self::InterviewRun(_) => K::InterviewRun,
860 }
861 }
862
863 pub fn as_uuid(&self) -> Uuid {
865 match self {
866 Self::AgendaItem(id) => *id.as_uuid(),
867 Self::Meeting(id) => *id.as_uuid(),
868 Self::Appeal(id) => *id.as_uuid(),
869 Self::Flag(id) => *id.as_uuid(),
870 Self::InterviewRun(id) => *id.as_uuid(),
871 }
872 }
873
874 pub fn from_stored(
876 kind: crate::enums::ArchiveContextKind,
877 id: Uuid,
878 ) -> Self {
879 use crate::enums::ArchiveContextKind as K;
880 match kind {
881 K::AgendaItem => Self::AgendaItem(id.into()),
882 K::Meeting => Self::Meeting(id.into()),
883 K::Appeal => Self::Appeal(id.into()),
884 K::Flag => Self::Flag(id.into()),
885 K::InterviewRun => Self::InterviewRun(id.into()),
886 }
887 }
888}
889
890impl From<AgendaItemId> for ArchiveContext {
891 fn from(id: AgendaItemId) -> Self {
892 Self::AgendaItem(id)
893 }
894}
895
896impl From<CouncilMeetingId> for ArchiveContext {
897 fn from(id: CouncilMeetingId) -> Self {
898 Self::Meeting(id)
899 }
900}
901
902impl From<AppealId> for ArchiveContext {
903 fn from(id: AppealId) -> Self {
904 Self::Appeal(id)
905 }
906}
907
908impl From<FlagId> for ArchiveContext {
909 fn from(id: FlagId) -> Self {
910 Self::Flag(id)
911 }
912}
913
914impl From<InterviewRunId> for ArchiveContext {
915 fn from(id: InterviewRunId) -> Self {
916 Self::InterviewRun(id)
917 }
918}
919
920impl std::fmt::Display for ArchiveContext {
923 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
924 let kind = match self {
925 Self::AgendaItem(_) => "agenda item",
926 Self::Meeting(_) => "meeting",
927 Self::Appeal(_) => "appeal",
928 Self::Flag(_) => "flag",
929 Self::InterviewRun(_) => "interview run",
930 };
931 write!(f, "{kind} {}", self.as_uuid())
932 }
933}
934
935#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
941#[error(
942 "not a governance log id (expected GOV-, APP-, AMD-, KEY-, REC-, OBL- \
943 or DATA-YYYY-NNNN): {0:?}"
944)]
945pub struct GovernanceLogIdError(pub String);
946
947#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
953pub enum GovernanceLogPrefix {
954 Gov,
956 App,
958 Amd,
960 Key,
962 Rec,
964 Obl,
966 Data,
969 Unknown,
973}
974
975impl GovernanceLogPrefix {
976 pub const ALL: [Self; 7] = [
978 Self::Gov,
979 Self::App,
980 Self::Amd,
981 Self::Key,
982 Self::Rec,
983 Self::Obl,
984 Self::Data,
985 ];
986
987 pub fn as_str(&self) -> &'static str {
989 match self {
990 Self::Gov => "GOV",
991 Self::App => "APP",
992 Self::Amd => "AMD",
993 Self::Key => "KEY",
994 Self::Rec => "REC",
995 Self::Obl => "OBL",
996 Self::Data => "DATA",
997 Self::Unknown => "?",
998 }
999 }
1000}
1001
1002impl std::fmt::Display for GovernanceLogPrefix {
1003 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1004 f.write_str(self.as_str())
1005 }
1006}
1007
1008impl std::str::FromStr for GovernanceLogPrefix {
1009 type Err = GovernanceLogIdError;
1010
1011 fn from_str(s: &str) -> Result<Self, Self::Err> {
1012 match s {
1013 "GOV" => Ok(Self::Gov),
1014 "APP" => Ok(Self::App),
1015 "AMD" => Ok(Self::Amd),
1016 "KEY" => Ok(Self::Key),
1017 "REC" => Ok(Self::Rec),
1018 "OBL" => Ok(Self::Obl),
1019 "DATA" => Ok(Self::Data),
1020 _ => Err(GovernanceLogIdError(s.to_string())),
1021 }
1022 }
1023}
1024
1025#[derive(
1045 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1046)]
1047#[serde(try_from = "String")]
1048#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1049#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1050pub struct GovernanceLogId(String);
1051
1052impl GovernanceLogId {
1053 pub fn as_str(&self) -> &str {
1055 &self.0
1056 }
1057
1058 pub fn into_inner(self) -> String {
1060 self.0
1061 }
1062
1063 pub fn is_citation_shaped(s: &str) -> bool {
1071 Self::parts(s).is_some()
1072 }
1073
1074 pub fn prefix(&self) -> GovernanceLogPrefix {
1076 Self::parts(&self.0).map_or(GovernanceLogPrefix::Unknown, |p| p.0)
1077 }
1078
1079 fn is_canonical_unknown(s: &str) -> bool {
1083 let parts: Vec<&str> = s.split('-').collect();
1084 let digits =
1085 |p: &str| p.len() == 4 && p.chars().all(|c| c.is_ascii_digit());
1086 matches!(parts.as_slice(), [letters, year, serial]
1087 if (3..=4).contains(&letters.len())
1088 && letters.chars().all(|c| c.is_ascii_uppercase())
1089 && digits(year)
1090 && digits(serial))
1091 }
1092
1093 pub fn normalize(s: &str) -> Option<String> {
1099 let mut parts = s.trim().split(['-', '.', '/', ' ']);
1100 let (prefix, year, serial) =
1101 (parts.next()?, parts.next()?, parts.next()?);
1102 if parts.next().is_some() {
1103 return None;
1104 }
1105 let prefix: GovernanceLogPrefix =
1106 prefix.to_ascii_uppercase().parse().ok()?;
1107 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
1108 (year.len() == 4
1109 && digits(year)
1110 && (1..=4).contains(&serial.len())
1111 && digits(serial))
1112 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
1113 }
1114
1115 pub fn from_parts(
1118 prefix: GovernanceLogPrefix,
1119 year: i32,
1120 serial: u32,
1121 ) -> Option<Self> {
1122 (prefix != GovernanceLogPrefix::Unknown
1123 && (1000..=9999).contains(&year)
1124 && (1..=9999).contains(&serial))
1125 .then(|| Self(format!("{prefix}-{year}-{serial:04}")))
1126 }
1127
1128 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
1129 let parts: Vec<&str> = s.split('-').collect();
1130 let [prefix, year, serial] = parts.as_slice() else {
1131 return None;
1132 };
1133 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
1134 (year.len() == 4
1135 && serial.len() == 4
1136 && year.chars().all(|c| c.is_ascii_digit())
1137 && serial.chars().all(|c| c.is_ascii_digit()))
1138 .then_some((prefix, year, serial))
1139 }
1140}
1141
1142impl std::fmt::Display for GovernanceLogId {
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 GovernanceLogId {
1149 fn as_ref(&self) -> &str {
1150 &self.0
1151 }
1152}
1153
1154impl std::str::FromStr for GovernanceLogId {
1155 type Err = GovernanceLogIdError;
1156
1157 fn from_str(s: &str) -> Result<Self, Self::Err> {
1160 Self::normalize(s)
1161 .map(Self)
1162 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
1163 }
1164}
1165
1166impl TryFrom<String> for GovernanceLogId {
1167 type Error = GovernanceLogIdError;
1168
1169 fn try_from(s: String) -> Result<Self, Self::Error> {
1172 if Self::is_citation_shaped(&s) || Self::is_canonical_unknown(&s) {
1173 return Ok(Self(s));
1174 }
1175 s.parse()
1176 }
1177}
1178
1179impl From<GovernanceLogId> for String {
1180 fn from(id: GovernanceLogId) -> Self {
1181 id.0
1182 }
1183}
1184
1185#[cfg(feature = "schemars")]
1191impl schemars::JsonSchema for GovernanceLogId {
1192 fn inline_schema() -> bool {
1193 true
1194 }
1195
1196 fn schema_name() -> std::borrow::Cow<'static, str> {
1197 std::borrow::Cow::Borrowed("GovernanceLogId")
1198 }
1199
1200 fn schema_id() -> std::borrow::Cow<'static, str> {
1201 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
1202 }
1203
1204 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1205 schemars::json_schema!({
1206 "type": "string",
1207 "pattern": GOVERNANCE_LOG_ID_PATTERN,
1208 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
1209 (Council decision or policy change), \
1210 \"APP-2026-0003\" (appeals ruling), \
1211 \"AMD-2026-0001\" (amendment to an earlier \
1212 entry), \"KEY-2026-0001\" (signing key \
1213 rotation), \"REC-2026-0001\" (a Steward's \
1214 record of an operational act), \
1215 \"OBL-2026-0001\" (an obligation) or \
1216 \"DATA-2026-0001\" (a data set).",
1217 })
1218 }
1219}
1220
1221#[derive(
1230 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1231)]
1232#[serde(try_from = "String")]
1233#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1234#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1235pub struct OAuthClientId(String);
1236
1237#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1239#[error("not an OAuth client id: {0:?}")]
1240pub struct OAuthClientIdError(pub String);
1241
1242impl OAuthClientId {
1243 pub fn generate() -> Self {
1245 Self(Uuid::new_v4().to_string())
1246 }
1247
1248 pub fn as_str(&self) -> &str {
1250 &self.0
1251 }
1252
1253 pub fn into_inner(self) -> String {
1255 self.0
1256 }
1257
1258 fn is_valid(s: &str) -> bool {
1259 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
1260 }
1261}
1262
1263impl std::fmt::Display for OAuthClientId {
1264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1265 f.write_str(&self.0)
1266 }
1267}
1268
1269impl AsRef<str> for OAuthClientId {
1270 fn as_ref(&self) -> &str {
1271 &self.0
1272 }
1273}
1274
1275impl std::str::FromStr for OAuthClientId {
1276 type Err = OAuthClientIdError;
1277
1278 fn from_str(s: &str) -> Result<Self, Self::Err> {
1279 Self::try_from(s.to_string())
1280 }
1281}
1282
1283impl TryFrom<String> for OAuthClientId {
1284 type Error = OAuthClientIdError;
1285
1286 fn try_from(s: String) -> Result<Self, Self::Error> {
1287 if Self::is_valid(&s) {
1288 Ok(Self(s))
1289 } else {
1290 Err(OAuthClientIdError(s))
1291 }
1292 }
1293}
1294
1295impl From<OAuthClientId> for String {
1296 fn from(id: OAuthClientId) -> Self {
1297 id.0
1298 }
1299}
1300
1301#[cfg(feature = "schemars")]
1304impl schemars::JsonSchema for OAuthClientId {
1305 fn inline_schema() -> bool {
1306 true
1307 }
1308
1309 fn schema_name() -> std::borrow::Cow<'static, str> {
1310 std::borrow::Cow::Borrowed("OAuthClientId")
1311 }
1312
1313 fn schema_id() -> std::borrow::Cow<'static, str> {
1314 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1315 }
1316
1317 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1318 schemars::json_schema!({
1319 "type": "string",
1320 "minLength": 1,
1321 "maxLength": 255,
1322 "description": "OAuth client_id issued at dynamic client registration.",
1323 })
1324 }
1325}
1326
1327#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1334pub enum PlatformDoc {
1335 Constitution,
1337 GovernanceProtocol,
1340 Prompts,
1342 Prompt(PromptName),
1344 Calendar,
1348}
1349
1350impl PlatformDoc {
1351 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1353 match self {
1354 PlatformDoc::Constitution => "constitution".into(),
1355 PlatformDoc::GovernanceProtocol => "protocol".into(),
1356 PlatformDoc::Prompts => "prompts".into(),
1357 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1358 PlatformDoc::Calendar => "calendar".into(),
1359 }
1360 }
1361
1362 pub fn title(&self) -> std::borrow::Cow<'static, str> {
1365 match self {
1366 PlatformDoc::Constitution => "The Agora Constitution".into(),
1367 PlatformDoc::GovernanceProtocol => {
1368 "The Agora Governance Protocol".into()
1369 }
1370 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1371 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1372 PlatformDoc::Calendar => "The Agora calendar".into(),
1373 }
1374 }
1375
1376 pub fn as_prompt(&self) -> Option<&PromptName> {
1378 match self {
1379 PlatformDoc::Prompt(name) => Some(name),
1380 _ => None,
1381 }
1382 }
1383}
1384
1385impl std::fmt::Display for PlatformDoc {
1386 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1387 f.write_str(&self.slug())
1388 }
1389}
1390
1391#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1393#[error(
1394 "not a platform document (expected \"constitution\", \"protocol\", \
1395 \"prompts\", \"calendar\" or \"prompt:<name>\"): {0:?}"
1396)]
1397pub struct PlatformDocError(pub String);
1398
1399const PROMPT_PREFIXES: [&str; 4] =
1403 ["prompt:", "prompt/", "prompts/", "prompts:"];
1404
1405impl std::str::FromStr for PlatformDoc {
1406 type Err = PlatformDocError;
1407
1408 fn from_str(s: &str) -> Result<Self, Self::Err> {
1412 if s.eq_ignore_ascii_case("constitution") {
1413 Ok(PlatformDoc::Constitution)
1414 } else if s.eq_ignore_ascii_case("protocol")
1415 || s.eq_ignore_ascii_case("governance-protocol")
1416 {
1417 Ok(PlatformDoc::GovernanceProtocol)
1418 } else if s.eq_ignore_ascii_case("prompts")
1419 || s.eq_ignore_ascii_case("prompt")
1420 {
1421 Ok(PlatformDoc::Prompts)
1422 } else if s.eq_ignore_ascii_case("calendar") {
1423 Ok(PlatformDoc::Calendar)
1424 } else {
1425 PROMPT_PREFIXES
1426 .iter()
1427 .find_map(|prefix| {
1428 let head = s.get(..prefix.len())?;
1429 head.eq_ignore_ascii_case(prefix)
1430 .then(|| s[prefix.len()..].parse().ok())
1431 .flatten()
1432 })
1433 .map(PlatformDoc::Prompt)
1434 .ok_or_else(|| PlatformDocError(s.to_string()))
1435 }
1436 }
1437}
1438
1439impl Serialize for PlatformDoc {
1440 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1441 s.collect_str(self)
1442 }
1443}
1444
1445impl<'de> Deserialize<'de> for PlatformDoc {
1446 fn deserialize<D: serde::Deserializer<'de>>(
1447 d: D,
1448 ) -> Result<Self, D::Error> {
1449 let raw = String::deserialize(d)?;
1450 raw.parse().map_err(serde::de::Error::custom)
1451 }
1452}
1453
1454#[cfg(feature = "schemars")]
1457impl schemars::JsonSchema for PlatformDoc {
1458 fn inline_schema() -> bool {
1459 true
1460 }
1461
1462 fn schema_name() -> std::borrow::Cow<'static, str> {
1463 std::borrow::Cow::Borrowed("PlatformDoc")
1464 }
1465
1466 fn schema_id() -> std::borrow::Cow<'static, str> {
1467 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1468 }
1469
1470 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1471 schemars::json_schema!({
1472 "type": "string",
1473 "pattern": PLATFORM_DOC_PATTERN,
1474 "description": "A platform document: \"constitution\", \
1475 \"protocol\" (the Governance Protocol), \
1476 \"prompts\" (the index of published model \
1477 prompts), \"prompt:<name>\" (one prompt, \
1478 e.g. \"prompt:tier2_reviewer\"), or \
1479 \"calendar\" (the sittings and obligation deadlines the \
1480 Steward and the Council scheduled).",
1481 })
1482 }
1483}
1484
1485#[derive(
1493 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1494)]
1495#[serde(try_from = "String")]
1496pub struct PromptName(String);
1497
1498#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1500#[error(
1501 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1502 \"tier2_reviewer\"): {0:?}"
1503)]
1504pub struct PromptNameError(pub String);
1505
1506impl PromptName {
1507 pub const MAX_LEN: usize = 64;
1509
1510 pub fn as_str(&self) -> &str {
1512 &self.0
1513 }
1514
1515 fn normalize(s: &str) -> Option<String> {
1516 let s = s.trim();
1517 let s = match s.len().checked_sub(3) {
1518 Some(n)
1519 if s.is_char_boundary(n)
1520 && s[n..].eq_ignore_ascii_case(".md") =>
1521 {
1522 &s[..n]
1523 }
1524 _ => s,
1525 };
1526 let name: String = s
1527 .chars()
1528 .map(|c| match c {
1529 '-' => '_',
1530 c => c.to_ascii_lowercase(),
1531 })
1532 .collect();
1533 let mut chars = name.chars();
1534 let valid = name.len() <= Self::MAX_LEN
1535 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1536 && chars.all(|c| {
1537 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1538 });
1539 valid.then_some(name)
1540 }
1541}
1542
1543impl std::fmt::Display for PromptName {
1544 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1545 f.write_str(&self.0)
1546 }
1547}
1548
1549impl AsRef<str> for PromptName {
1550 fn as_ref(&self) -> &str {
1551 &self.0
1552 }
1553}
1554
1555impl std::str::FromStr for PromptName {
1556 type Err = PromptNameError;
1557
1558 fn from_str(s: &str) -> Result<Self, Self::Err> {
1559 Self::normalize(s)
1560 .map(Self)
1561 .ok_or_else(|| PromptNameError(s.to_string()))
1562 }
1563}
1564
1565impl TryFrom<String> for PromptName {
1566 type Error = PromptNameError;
1567
1568 fn try_from(s: String) -> Result<Self, Self::Error> {
1569 s.parse()
1570 }
1571}
1572
1573impl From<PromptName> for PlatformDoc {
1574 fn from(name: PromptName) -> Self {
1575 PlatformDoc::Prompt(name)
1576 }
1577}
1578
1579#[cfg(feature = "schemars")]
1580impl schemars::JsonSchema for PromptName {
1581 fn inline_schema() -> bool {
1582 true
1583 }
1584
1585 fn schema_name() -> std::borrow::Cow<'static, str> {
1586 std::borrow::Cow::Borrowed("PromptName")
1587 }
1588
1589 fn schema_id() -> std::borrow::Cow<'static, str> {
1590 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1591 }
1592
1593 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1594 schemars::json_schema!({
1595 "type": "string",
1596 "pattern": PROMPT_NAME_PATTERN,
1597 "maxLength": PromptName::MAX_LEN,
1598 "description": "A published model prompt's name, e.g. \
1599 \"tier2_reviewer\". `get_content(\"prompts\")` \
1600 lists them.",
1601 })
1602 }
1603}
1604
1605#[derive(
1611 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1612)]
1613#[serde(try_from = "String")]
1614pub struct WitnessId(String);
1615
1616#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1618#[error(
1619 "not a witness id (lowercase letters, digits and `-`, e.g. \"qwen\"): \
1620 {0:?}"
1621)]
1622pub struct WitnessIdError(pub String);
1623
1624impl WitnessId {
1625 pub const MAX_LEN: usize = 64;
1627
1628 pub fn as_str(&self) -> &str {
1630 &self.0
1631 }
1632
1633 fn normalize(s: &str) -> Option<String> {
1634 let id: String = s
1635 .trim()
1636 .chars()
1637 .map(|c| match c {
1638 '_' => '-',
1639 c => c.to_ascii_lowercase(),
1640 })
1641 .collect();
1642 let mut chars = id.chars();
1643 let valid = id.len() <= Self::MAX_LEN
1644 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1645 && chars.all(|c| {
1646 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
1647 });
1648 valid.then_some(id)
1649 }
1650}
1651
1652impl std::fmt::Display for WitnessId {
1653 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1654 f.write_str(&self.0)
1655 }
1656}
1657
1658impl AsRef<str> for WitnessId {
1659 fn as_ref(&self) -> &str {
1660 &self.0
1661 }
1662}
1663
1664impl std::str::FromStr for WitnessId {
1665 type Err = WitnessIdError;
1666
1667 fn from_str(s: &str) -> Result<Self, Self::Err> {
1668 Self::normalize(s)
1669 .map(Self)
1670 .ok_or_else(|| WitnessIdError(s.to_string()))
1671 }
1672}
1673
1674impl TryFrom<String> for WitnessId {
1675 type Error = WitnessIdError;
1676
1677 fn try_from(s: String) -> Result<Self, Self::Error> {
1678 s.parse()
1679 }
1680}
1681
1682#[cfg(feature = "schemars")]
1685impl schemars::JsonSchema for WitnessId {
1686 fn inline_schema() -> bool {
1687 true
1688 }
1689
1690 fn schema_name() -> std::borrow::Cow<'static, str> {
1691 std::borrow::Cow::Borrowed("WitnessId")
1692 }
1693
1694 fn schema_id() -> std::borrow::Cow<'static, str> {
1695 std::borrow::Cow::Borrowed(concat!(module_path!(), "::WitnessId"))
1696 }
1697
1698 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1699 schemars::json_schema!({
1700 "type": "string",
1701 "maxLength": WitnessId::MAX_LEN,
1702 "description": "A witness's id, as the item's list of witnesses \
1703 gives it, e.g. \"qwen\"",
1704 })
1705 }
1706}
1707
1708#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1724pub struct ContentIdPrefix(u32);
1725
1726#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1728#[error(
1729 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1730)]
1731pub struct ContentIdPrefixError(pub String);
1732
1733impl ContentIdPrefix {
1734 pub const LEN: usize = 8;
1736
1737 pub fn of(id: &Uuid) -> Self {
1739 Self((id.as_u128() >> 96) as u32)
1740 }
1741
1742 pub fn bounds(&self) -> (Uuid, Uuid) {
1744 let lo = (self.0 as u128) << 96;
1745 let hi = lo | ((1u128 << 96) - 1);
1746 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1747 }
1748
1749 pub fn matches(&self, id: &Uuid) -> bool {
1751 Self::of(id) == *self
1752 }
1753}
1754
1755impl std::fmt::Display for ContentIdPrefix {
1756 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1757 write!(f, "{:08x}", self.0)
1758 }
1759}
1760
1761impl std::str::FromStr for ContentIdPrefix {
1762 type Err = ContentIdPrefixError;
1763
1764 fn from_str(s: &str) -> Result<Self, Self::Err> {
1765 let t = s.trim();
1766 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1767 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1769 } else {
1770 Err(ContentIdPrefixError(s.to_string()))
1771 }
1772 }
1773}
1774
1775impl TryFrom<String> for ContentIdPrefix {
1776 type Error = ContentIdPrefixError;
1777
1778 fn try_from(s: String) -> Result<Self, Self::Error> {
1779 s.parse()
1780 }
1781}
1782
1783impl Serialize for ContentIdPrefix {
1784 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1785 s.collect_str(self)
1786 }
1787}
1788
1789impl<'de> Deserialize<'de> for ContentIdPrefix {
1790 fn deserialize<D: serde::Deserializer<'de>>(
1791 d: D,
1792 ) -> Result<Self, D::Error> {
1793 let raw = String::deserialize(d)?;
1794 raw.parse().map_err(serde::de::Error::custom)
1795 }
1796}
1797
1798impl From<ContentId> for ContentIdPrefix {
1799 fn from(id: ContentId) -> Self {
1800 Self::of(id.as_uuid())
1801 }
1802}
1803
1804#[cfg(feature = "schemars")]
1805impl schemars::JsonSchema for ContentIdPrefix {
1806 fn inline_schema() -> bool {
1807 true
1808 }
1809
1810 fn schema_name() -> std::borrow::Cow<'static, str> {
1811 std::borrow::Cow::Borrowed("ContentIdPrefix")
1812 }
1813
1814 fn schema_id() -> std::borrow::Cow<'static, str> {
1815 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1816 }
1817
1818 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1819 schemars::json_schema!({
1820 "type": "string",
1821 "pattern": CONTENT_ID_PREFIX_PATTERN,
1822 "description": "A short post or comment id: the first eight hex \
1823 digits of its UUID, e.g. \"7ad26ccd\".",
1824 })
1825 }
1826}
1827
1828pub 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})$";
1833
1834#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1843pub enum ContentTarget {
1844 Id(ContentId),
1846 Prefix(ContentIdPrefix),
1848}
1849
1850impl ContentTarget {
1851 pub fn full(&self) -> Option<ContentId> {
1853 match self {
1854 ContentTarget::Id(id) => Some(*id),
1855 ContentTarget::Prefix(_) => None,
1856 }
1857 }
1858}
1859
1860impl From<ContentId> for ContentTarget {
1861 fn from(id: ContentId) -> Self {
1862 ContentTarget::Id(id)
1863 }
1864}
1865
1866impl std::fmt::Display for ContentTarget {
1867 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1868 match self {
1869 ContentTarget::Id(id) => id.fmt(f),
1870 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1871 }
1872 }
1873}
1874
1875fn ascii_dashes(s: &str) -> String {
1880 s.chars()
1881 .map(|c| match c {
1882 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1883 c => c,
1884 })
1885 .collect()
1886}
1887
1888impl std::str::FromStr for ContentTarget {
1889 type Err = ContentTargetError;
1890
1891 fn from_str(s: &str) -> Result<Self, Self::Err> {
1892 let t = ascii_dashes(s.trim());
1893 if let Ok(id) = t.parse::<Uuid>() {
1894 return Ok(ContentTarget::Id(id.into()));
1895 }
1896 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1897 return Ok(ContentTarget::Prefix(prefix));
1898 }
1899 const UUID_LEN: usize = 36;
1902 let leading = t
1903 .get(..UUID_LEN)
1904 .and_then(|head| head.parse::<Uuid>().ok())
1905 .map(ContentId::from);
1906 Err(ContentTargetError {
1907 field: None,
1908 input: s.to_string(),
1909 leading,
1910 })
1911 }
1912}
1913
1914#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1920pub struct ContentTargetError {
1921 field: Option<&'static str>,
1922 input: String,
1923 leading: Option<ContentId>,
1924}
1925
1926impl ContentTargetError {
1927 pub fn in_field(mut self, field: &'static str) -> Self {
1929 self.field = Some(field);
1930 self
1931 }
1932}
1933
1934impl std::fmt::Display for ContentTargetError {
1935 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1936 match self.field {
1937 Some(field) => write!(f, "`{field}` must be")?,
1938 None => f.write_str("Expected")?,
1939 }
1940 f.write_str(
1941 " a post or comment id: a full UUID or its first 8 hex digits \
1942 (e.g. \"7ad26ccd\")",
1943 )?;
1944 write!(f, ", not \"{}\"", echo(&self.input))?;
1945 if let Some(id) = self.leading {
1946 write!(
1947 f,
1948 ". It starts with the id {id} followed by extra text; pass only the id"
1949 )?;
1950 }
1951 f.write_str(".")
1952 }
1953}
1954
1955impl Serialize for ContentTarget {
1956 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1957 s.collect_str(self)
1958 }
1959}
1960
1961impl<'de> Deserialize<'de> for ContentTarget {
1962 fn deserialize<D: serde::Deserializer<'de>>(
1963 d: D,
1964 ) -> Result<Self, D::Error> {
1965 content_target::deserialize_field(d, "id")
1966 }
1967}
1968
1969pub mod content_target {
1972 use serde::de::{self, Deserializer, Visitor};
1973
1974 use super::{ContentTarget, ContentTargetError};
1975
1976 struct TargetVisitor(&'static str);
1980
1981 impl Visitor<'_> for TargetVisitor {
1982 type Value = ContentTarget;
1983
1984 fn expecting(
1985 &self,
1986 f: &mut std::fmt::Formatter<'_>,
1987 ) -> std::fmt::Result {
1988 write!(
1989 f,
1990 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1991 (e.g. \"7ad26ccd\")",
1992 self.0
1993 )
1994 }
1995
1996 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1997 v.parse()
1998 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1999 }
2000 }
2001
2002 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
2003 d: D,
2004 field: &'static str,
2005 ) -> Result<ContentTarget, D::Error> {
2006 d.deserialize_str(TargetVisitor(field))
2007 }
2008
2009 pub fn reply_to<'de, D: Deserializer<'de>>(
2011 d: D,
2012 ) -> Result<ContentTarget, D::Error> {
2013 deserialize_field(d, "reply_to")
2014 }
2015
2016 pub fn target<'de, D: Deserializer<'de>>(
2018 d: D,
2019 ) -> Result<ContentTarget, D::Error> {
2020 deserialize_field(d, "target")
2021 }
2022
2023 pub fn post_id<'de, D: Deserializer<'de>>(
2025 d: D,
2026 ) -> Result<ContentTarget, D::Error> {
2027 deserialize_field(d, "post_id")
2028 }
2029
2030 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
2032 d: D,
2033 ) -> Result<Option<ContentTarget>, D::Error> {
2034 reply_to(d).map(Some)
2035 }
2036
2037 pub fn optional_target<'de, D: Deserializer<'de>>(
2041 d: D,
2042 ) -> Result<Option<ContentTarget>, D::Error> {
2043 d.deserialize_option(OptionalVisitor("target"))
2044 }
2045
2046 struct OptionalVisitor(&'static str);
2048
2049 impl<'de> Visitor<'de> for OptionalVisitor {
2050 type Value = Option<ContentTarget>;
2051
2052 fn expecting(
2053 &self,
2054 f: &mut std::fmt::Formatter<'_>,
2055 ) -> std::fmt::Result {
2056 TargetVisitor(self.0).expecting(f)?;
2057 f.write_str(", or nothing")
2058 }
2059
2060 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
2061 Ok(None)
2062 }
2063
2064 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
2065 Ok(None)
2066 }
2067
2068 fn visit_some<D: Deserializer<'de>>(
2069 self,
2070 d: D,
2071 ) -> Result<Self::Value, D::Error> {
2072 d.deserialize_str(self)
2073 }
2074
2075 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
2076 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
2077 return Ok(None);
2078 }
2079 TargetVisitor(self.0).visit_str(v).map(Some)
2080 }
2081 }
2082}
2083
2084#[cfg(feature = "schemars")]
2088impl schemars::JsonSchema for ContentTarget {
2089 fn inline_schema() -> bool {
2090 true
2091 }
2092
2093 fn schema_name() -> std::borrow::Cow<'static, str> {
2094 std::borrow::Cow::Borrowed("ContentTarget")
2095 }
2096
2097 fn schema_id() -> std::borrow::Cow<'static, str> {
2098 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
2099 }
2100
2101 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2102 schemars::json_schema!({
2103 "type": "string",
2104 "pattern": CONTENT_TARGET_PATTERN,
2105 "description": "A post or comment id: its full UUID, or its first \
2106 8 hex digits (e.g. \"7ad26ccd\") as shown by \
2107 get_content and the dashboard. A short id works \
2108 only on unsigned calls; a signed call must use \
2109 the full UUID.",
2110 })
2111 }
2112}
2113
2114#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2117pub struct ContentRefError(pub String);
2118
2119impl ContentRefError {
2120 pub fn leading_ref(&self) -> Option<ContentRef> {
2124 const UUID_LEN: usize = 36;
2125 const CITATION_LENS: [usize; 2] =
2129 ["DATA-2026-0001".len(), "GOV-2026-0006".len()];
2130 let s = self.0.trim_start();
2131 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
2133 let head = s.get(..prefix.len())?;
2134 head.eq_ignore_ascii_case(prefix).then(|| {
2135 prefix.len()
2136 + s[prefix.len()..]
2137 .find(|c: char| {
2138 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
2139 })
2140 .unwrap_or(s.len() - prefix.len())
2141 })
2142 });
2143 prompt_len
2145 .into_iter()
2146 .chain([
2147 UUID_LEN,
2148 CITATION_LENS[0],
2149 CITATION_LENS[1],
2150 "constitution".len(),
2151 "protocol".len(),
2152 "prompts".len(),
2153 ])
2154 .filter(|&n| s.len() > n && s.is_char_boundary(n))
2155 .find_map(|n| {
2159 s[..n]
2160 .parse()
2161 .ok()
2162 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
2163 })
2164 }
2165}
2166
2167impl std::fmt::Display for ContentRefError {
2168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2169 write!(
2170 f,
2171 "not a content reference (expected a post/comment UUID or its \
2172 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
2173 like \"constitution\", \"protocol\", \"prompts\" or \
2174 \"prompt:<name>\"): {:?}",
2175 self.0
2176 )?;
2177 if let Some(id) = self.leading_ref() {
2178 write!(
2179 f,
2180 ". It starts with the valid id {id} followed by extra text; \
2181 pass only the id"
2182 )?;
2183 }
2184 Ok(())
2185 }
2186}
2187
2188#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2206pub enum ContentRef {
2207 Content(ContentId),
2209 ContentPrefix(ContentIdPrefix),
2212 Governance(GovernanceLogId),
2214 Document(PlatformDoc),
2216}
2217
2218impl ContentRef {
2219 pub fn as_content(&self) -> Option<ContentId> {
2221 match self {
2222 ContentRef::Content(id) => Some(*id),
2223 _ => None,
2224 }
2225 }
2226
2227 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
2229 match self {
2230 ContentRef::Governance(id) => Some(id),
2231 _ => None,
2232 }
2233 }
2234
2235 pub fn as_document(&self) -> Option<&PlatformDoc> {
2237 match self {
2238 ContentRef::Document(doc) => Some(doc),
2239 _ => None,
2240 }
2241 }
2242
2243 pub fn is_governance(&self) -> bool {
2245 matches!(self, ContentRef::Governance(_))
2246 }
2247
2248 pub fn kind_str(&self) -> &'static str {
2251 match self {
2252 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
2253 ContentRef::Governance(_) => "governance",
2254 ContentRef::Document(_) => "document",
2255 }
2256 }
2257}
2258
2259impl std::fmt::Display for ContentRef {
2260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2261 match self {
2262 ContentRef::Content(id) => id.fmt(f),
2263 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
2264 ContentRef::Governance(id) => id.fmt(f),
2265 ContentRef::Document(doc) => doc.fmt(f),
2266 }
2267 }
2268}
2269
2270impl std::str::FromStr for ContentRef {
2271 type Err = ContentRefError;
2272
2273 fn from_str(s: &str) -> Result<Self, Self::Err> {
2274 if let Ok(id) = s.parse::<ContentId>() {
2275 return Ok(ContentRef::Content(id));
2276 }
2277 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
2278 return Ok(ContentRef::ContentPrefix(prefix));
2279 }
2280 if let Ok(id) = s.parse::<GovernanceLogId>() {
2281 return Ok(ContentRef::Governance(id));
2282 }
2283 if let Ok(doc) = s.parse::<PlatformDoc>() {
2284 return Ok(ContentRef::Document(doc));
2285 }
2286 Err(ContentRefError(s.to_string()))
2287 }
2288}
2289
2290impl TryFrom<String> for ContentRef {
2291 type Error = ContentRefError;
2292
2293 fn try_from(s: String) -> Result<Self, Self::Error> {
2294 s.parse()
2295 }
2296}
2297
2298impl From<ContentId> for ContentRef {
2299 fn from(id: ContentId) -> Self {
2300 ContentRef::Content(id)
2301 }
2302}
2303
2304impl From<PostId> for ContentRef {
2305 fn from(id: PostId) -> Self {
2306 ContentRef::Content(id.into())
2307 }
2308}
2309
2310impl From<CommentId> for ContentRef {
2311 fn from(id: CommentId) -> Self {
2312 ContentRef::Content(id.into())
2313 }
2314}
2315
2316impl From<ContentIdPrefix> for ContentRef {
2317 fn from(prefix: ContentIdPrefix) -> Self {
2318 ContentRef::ContentPrefix(prefix)
2319 }
2320}
2321
2322impl From<GovernanceLogId> for ContentRef {
2323 fn from(id: GovernanceLogId) -> Self {
2324 ContentRef::Governance(id)
2325 }
2326}
2327
2328impl From<PlatformDoc> for ContentRef {
2329 fn from(doc: PlatformDoc) -> Self {
2330 ContentRef::Document(doc)
2331 }
2332}
2333
2334impl Serialize for ContentRef {
2335 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2336 s.collect_str(self)
2337 }
2338}
2339
2340impl<'de> Deserialize<'de> for ContentRef {
2341 fn deserialize<D: serde::Deserializer<'de>>(
2342 d: D,
2343 ) -> Result<Self, D::Error> {
2344 let raw = String::deserialize(d)?;
2345 raw.parse().map_err(serde::de::Error::custom)
2346 }
2347}
2348
2349#[cfg(feature = "schemars")]
2354impl schemars::JsonSchema for ContentRef {
2355 fn inline_schema() -> bool {
2356 true
2357 }
2358
2359 fn schema_name() -> std::borrow::Cow<'static, str> {
2360 std::borrow::Cow::Borrowed("ContentRef")
2361 }
2362
2363 fn schema_id() -> std::borrow::Cow<'static, str> {
2364 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
2365 }
2366
2367 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2368 schemars::json_schema!({
2369 "type": "string",
2370 "pattern": CONTENT_REF_PATTERN,
2371 "description": "A post or comment UUID, or its first eight \
2372 hex digits (e.g. \"7ad26ccd\"); a governance log id \
2373 such as \"GOV-2026-0006\" (Council decision) \
2374 or \"APP-2026-0003\" (appeals ruling); or a \
2375 document slug — \"constitution\", \
2376 \"protocol\" (the Governance Protocol), \
2377 \"prompts\" (the index of the model prompts \
2378 moderation, appeals and the Council run on) or \
2379 \"prompt:<name>\" (one of them).",
2380 })
2381 }
2382}
2383
2384#[cfg(test)]
2385mod tests {
2386 use super::*;
2387
2388 #[test]
2389 fn witness_ids_fold_and_refuse() {
2390 let id: WitnessId = " Qwen_3-8 ".parse().unwrap();
2391 assert_eq!(id.as_str(), "qwen-3-8");
2392 for bad in ["", "3qwen", "-qwen", "qwen 3", "qwen.3", &"q".repeat(65)] {
2393 assert!(bad.parse::<WitnessId>().is_err(), "{bad:?}");
2394 }
2395 let json: WitnessId = serde_json::from_str("\"FACT_checker\"").unwrap();
2396 assert_eq!(json.as_str(), "fact-checker");
2397 assert!(serde_json::from_str::<WitnessId>("\"no way\"").is_err());
2398 }
2399
2400 #[cfg(feature = "schemars")]
2401 #[test]
2402 fn witness_id_schema_is_inline_and_patternless() {
2403 let schema =
2404 serde_json::to_value(schemars::schema_for!(WitnessId)).unwrap();
2405 let rendered = schema.to_string();
2406 assert!(!rendered.contains("pattern"), "{rendered}");
2407 assert!(!rendered.contains("$ref"), "{rendered}");
2408 }
2409
2410 #[test]
2411 fn governance_log_ids_from_parts() {
2412 for prefix in GovernanceLogPrefix::ALL {
2413 let id = GovernanceLogId::from_parts(prefix, 2026, 42).unwrap();
2414 assert_eq!(id.as_str(), format!("{prefix}-2026-0042"));
2415 assert_eq!(id.prefix(), prefix);
2416 assert_eq!(id.as_str().parse::<GovernanceLogId>().unwrap(), id);
2417 }
2418 let gov = GovernanceLogPrefix::Gov;
2419 assert!(GovernanceLogId::from_parts(gov, 2026, 9999).is_some());
2420 assert!(GovernanceLogId::from_parts(gov, 2026, 10_000).is_none());
2421 assert!(GovernanceLogId::from_parts(gov, 2026, 0).is_none());
2422 assert!(GovernanceLogId::from_parts(gov, 999, 1).is_none());
2423 }
2424
2425 #[test]
2427 fn archive_contexts_round_trip_through_their_columns() {
2428 use crate::enums::ArchiveContextKind;
2429 let contexts = [
2430 ArchiveContext::from(AgendaItemId::new()),
2431 ArchiveContext::from(CouncilMeetingId::new()),
2432 ArchiveContext::from(AppealId::new()),
2433 ArchiveContext::from(FlagId::new()),
2434 ArchiveContext::from(InterviewRunId::new()),
2435 ];
2436 let kinds: Vec<_> = contexts.iter().map(ArchiveContext::kind).collect();
2437 assert_eq!(kinds, ArchiveContextKind::ALL);
2438 for c in contexts {
2439 assert_eq!(ArchiveContext::from_stored(c.kind(), c.as_uuid()), c);
2440 }
2441 let id = AgendaItemId::new();
2442 assert_eq!(
2443 ArchiveContext::from(id).to_string(),
2444 format!("agenda item {id}")
2445 );
2446 }
2447
2448 #[test]
2449 fn byte_ids_debug() {
2450 let handle = WebAuthnUserHandle::from(vec![7; 64]);
2451 assert_eq!(format!("{handle:?}"), "WebAuthnUserHandle(..)");
2452 let credential = PasskeyCredentialId::from(vec![0xab; 2]);
2453 assert_eq!(format!("{credential:?}"), "PasskeyCredentialId(abab)");
2454 assert_eq!(credential.as_bytes(), &[0xab, 0xab]);
2455 }
2456
2457 #[test]
2458 fn oauth_client_id_round_trips() {
2459 let id = OAuthClientId::generate();
2460 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2461 let json = serde_json::to_string(&id).unwrap();
2462 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2463 }
2464
2465 #[test]
2466 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2467 assert!("".parse::<OAuthClientId>().is_err());
2468 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2469 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2470 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2471 }
2472
2473 #[test]
2474 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
2475 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
2476 for (input, lead) in [
2477 (format!("{uuid} and the safe-space proposal"), uuid),
2478 (format!("{uuid}b0518e42"), uuid),
2479 (
2480 "GOV-2026-0006 (the ratification)".to_string(),
2481 "GOV-2026-0006",
2482 ),
2483 (
2486 "DATA-2026-0001 (the vote tallies)".to_string(),
2487 "DATA-2026-0001",
2488 ),
2489 ("DATA-2026-0012, index.md".to_string(), "DATA-2026-0012"),
2490 ("OBL-2026-0001 and more".to_string(), "OBL-2026-0001"),
2491 ("protocol, section 3".to_string(), "protocol"),
2492 ("prompts, please".to_string(), "prompts"),
2493 (
2494 "prompt:tier2_reviewer and the juror's".to_string(),
2495 "prompt:tier2_reviewer",
2496 ),
2497 (
2498 "prompts/appeals_juror.md, line 3".to_string(),
2499 "prompt:appeals_juror",
2500 ),
2501 ] {
2502 let err = input.parse::<ContentRef>().unwrap_err();
2503 assert_eq!(
2504 err.leading_ref(),
2505 Some(lead.parse().unwrap()),
2506 "{input}"
2507 );
2508 let msg = err.to_string();
2509 assert!(
2510 msg.contains(&format!("valid id {lead} followed")),
2511 "{msg}"
2512 );
2513 }
2514
2515 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
2517 let err = short.parse::<ContentRef>().unwrap_err();
2518 assert_eq!(err.leading_ref(), None);
2519 assert!(!err.to_string().contains("followed by"));
2520 assert_eq!(
2522 "GOV-2026-1".parse::<ContentRef>().unwrap(),
2523 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
2524 );
2525 }
2526
2527 #[test]
2528 fn citations_normalize_as_written() {
2529 for (written, canonical) in [
2530 ("GOV-2026-0006", "GOV-2026-0006"),
2531 ("GOV-2026.6", "GOV-2026-0006"),
2532 ("gov-2026-6", "GOV-2026-0006"),
2533 ("GOV 2026 6", "GOV-2026-0006"),
2534 ("app/2026/03", "APP-2026-0003"),
2535 (" REC-2026-0002 ", "REC-2026-0002"),
2536 ("obl-2026-12", "OBL-2026-0012"),
2537 ("OBL.2026.7", "OBL-2026-0007"),
2538 ("data-2026-3", "DATA-2026-0003"),
2539 ("DATA.2026.12", "DATA-2026-0012"),
2540 (" Data/2026/0001 ", "DATA-2026-0001"),
2541 ("DATA 2026 7", "DATA-2026-0007"),
2542 ] {
2543 let id: GovernanceLogId = written.parse().unwrap();
2544 assert_eq!(id.as_str(), canonical, "{written}");
2545 let json: GovernanceLogId =
2546 serde_json::from_value(serde_json::json!(written)).unwrap();
2547 assert_eq!(json.as_str(), canonical, "{written} via serde");
2548 let content: ContentRef = written.parse().unwrap();
2549 assert_eq!(content, ContentRef::Governance(id), "{written}");
2550 }
2551 for rejected in [
2552 "GOV-2026-N",
2553 "GOV-2026/GOVG=6",
2554 "GOV-2026-00006",
2555 "GOV-26-0006",
2556 "GOV-2026-",
2557 "XYZ-2026-0006",
2558 "GOV-2026-0006-1",
2559 ] {
2560 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
2561 }
2562 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
2564 }
2565
2566 #[test]
2567 fn content_ref_parses_document_slugs() {
2568 assert_eq!(
2569 "constitution".parse(),
2570 Ok(ContentRef::Document(PlatformDoc::Constitution))
2571 );
2572 assert_eq!(
2573 "protocol".parse(),
2574 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2575 );
2576 assert_eq!(
2578 "governance-protocol".parse(),
2579 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2580 );
2581 assert_eq!(
2582 "Constitution".parse(),
2583 Ok(ContentRef::Document(PlatformDoc::Constitution))
2584 );
2585 assert!("proto".parse::<ContentRef>().is_err());
2586 }
2587
2588 #[test]
2589 fn content_ref_parses_prompt_slugs() {
2590 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
2591 for written in [
2592 "prompt:appeals_juror",
2593 "Prompt:Appeals_Juror",
2594 "prompt/appeals-juror",
2595 "prompts/appeals_juror.md",
2596 "prompts:appeals_juror",
2597 ] {
2598 assert_eq!(
2599 written.parse(),
2600 Ok(ContentRef::Document(juror.clone())),
2601 "{written}"
2602 );
2603 }
2604 assert_eq!(juror.to_string(), "prompt:appeals_juror");
2605 for index in ["prompts", "Prompts", "prompt"] {
2606 assert_eq!(
2607 index.parse(),
2608 Ok(ContentRef::Document(PlatformDoc::Prompts)),
2609 "{index}"
2610 );
2611 }
2612 for rejected in [
2613 "prompt:",
2614 "prompt:2fast",
2615 "prompt:tier 2",
2616 "prompt:../secrets",
2617 "prompt:tier2_reviewer/x",
2618 "promptly",
2619 ] {
2620 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
2621 }
2622 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
2623 assert!(long.parse::<ContentRef>().is_err());
2624 }
2625
2626 #[test]
2627 fn content_id_prefix_parses_eight_hex_digits_leniently() {
2628 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
2629 assert_eq!(p.to_string(), "7ad26ccd");
2630 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
2631 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
2632 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
2633 }
2634 }
2635
2636 #[test]
2637 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
2638 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2639 let p = ContentIdPrefix::of(&id);
2640 assert_eq!(p.to_string(), "7ad26ccd");
2641 assert!(p.matches(&id));
2642 let (lo, hi) = p.bounds();
2643 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
2644 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
2645 assert!(lo <= id && id <= hi);
2646 let next: Uuid =
2647 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
2648 assert!(!p.matches(&next) && next > hi);
2649 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
2651 assert_eq!(top.bounds().1, Uuid::max());
2652 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
2653 assert_eq!(bottom.bounds().0, Uuid::nil());
2654 }
2655
2656 #[test]
2657 fn content_ref_reads_a_short_id_and_only_eight_digits() {
2658 let r: ContentRef = "7ad26ccd".parse().unwrap();
2659 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
2660 assert_eq!(r.kind_str(), "content");
2661 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
2662 let full: ContentRef =
2664 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2665 assert!(matches!(full, ContentRef::Content(_)));
2666 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
2671 assert_eq!(err.leading_ref(), None);
2672 }
2673
2674 #[test]
2675 fn content_ref_pattern_admits_short_ids() {
2676 let short = CONTENT_ID_PREFIX_PATTERN
2677 .strip_prefix('^')
2678 .and_then(|p| p.strip_suffix('$'))
2679 .unwrap();
2680 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2681 }
2682
2683 #[test]
2685 fn document_patterns_agree() {
2686 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2687 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2688 let name = PROMPT_NAME_PATTERN
2689 .strip_prefix('^')
2690 .and_then(|p| p.strip_suffix('$'))
2691 .unwrap();
2692 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2693 }
2694
2695 #[test]
2696 fn calendar_is_a_document_slug() {
2697 assert_eq!(
2698 "calendar".parse::<ContentRef>().unwrap(),
2699 ContentRef::Document(PlatformDoc::Calendar)
2700 );
2701 assert_eq!(
2702 "Calendar".parse::<PlatformDoc>().unwrap(),
2703 PlatformDoc::Calendar
2704 );
2705 assert_eq!(PlatformDoc::Calendar.to_string(), "calendar");
2706 assert_eq!(
2707 "OBL-2026-0001".parse::<ContentRef>().unwrap(),
2708 ContentRef::Governance("OBL-2026-0001".parse().unwrap())
2709 );
2710 assert_eq!(
2711 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
2712 ContentRef::Governance("DATA-2026-0001".parse().unwrap())
2713 );
2714 }
2715
2716 #[test]
2717 fn platform_doc_serde_uses_the_canonical_slug() {
2718 let json =
2719 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2720 assert_eq!(json, "\"protocol\"");
2721 let doc: PlatformDoc =
2722 serde_json::from_str("\"governance-protocol\"").unwrap();
2723 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2724 }
2725
2726 #[test]
2727 fn ids_are_unique() {
2728 let a = AgentId::new();
2729 let b = AgentId::new();
2730 assert_ne!(a, b);
2731 }
2732
2733 #[test]
2734 fn serde_round_trip() {
2735 let id = PostId::new();
2736 let json = serde_json::to_string(&id).unwrap();
2737 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2738 assert_eq!(id, deserialized);
2739 }
2740
2741 #[test]
2742 fn display_shows_uuid() {
2743 let id = CommunityId::new();
2744 let display = id.to_string();
2745 assert_eq!(display.len(), 36);
2747 assert!(display.contains('-'));
2748 }
2749
2750 #[test]
2751 fn from_uuid_round_trip() {
2752 let uuid = Uuid::new_v4();
2753 let id = AgentId::from(uuid);
2754 let back: Uuid = id.into();
2755 assert_eq!(uuid, back);
2756 }
2757
2758 #[test]
2762 fn every_id_round_trips_through_its_own_display() {
2763 let agent = AgentId::new();
2764 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2765
2766 let action = ModerationActionId::new();
2767 assert_eq!(
2768 action.to_string().parse::<ModerationActionId>().unwrap(),
2769 action
2770 );
2771
2772 let content = ContentId::new();
2773 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2774
2775 let feedback = FeedbackId::new();
2776 assert_eq!(
2777 feedback.to_string().parse::<FeedbackId>().unwrap(),
2778 feedback
2779 );
2780 }
2781
2782 #[test]
2783 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2784 assert!("not-a-uuid".parse::<ContentId>().is_err());
2785 assert!("".parse::<ContentId>().is_err());
2786 }
2787
2788 #[test]
2793 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2794 let uuid = Uuid::new_v4();
2795 let typed = ContentId::from(uuid);
2796 assert_eq!(
2797 serde_json::to_string(&typed).unwrap(),
2798 serde_json::to_string(&uuid).unwrap()
2799 );
2800 }
2801
2802 #[test]
2805 fn every_moderation_target_narrows_losslessly() {
2806 let uuid = Uuid::new_v4();
2807
2808 for (label, got) in [
2809 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2810 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2811 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2812 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2813 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2814 ] {
2815 assert_eq!(
2816 got.as_uuid(),
2817 &uuid,
2818 "{label} -> ModerationTargetId lost the uuid"
2819 );
2820 }
2821 }
2822
2823 #[test]
2827 fn resolved_ids_narrow_to_content_id_losslessly() {
2828 let uuid = Uuid::new_v4();
2829
2830 assert_eq!(
2831 ContentId::from(PostId::from(uuid)).as_uuid(),
2832 &uuid,
2833 "PostId -> ContentId lost the uuid"
2834 );
2835 assert_eq!(
2836 ContentId::from(CommentId::from(uuid)).as_uuid(),
2837 &uuid,
2838 "CommentId -> ContentId lost the uuid"
2839 );
2840 assert_eq!(
2841 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2842 .as_uuid(),
2843 &uuid,
2844 "PostOrCommentId -> ContentId lost the uuid"
2845 );
2846 }
2847
2848 #[test]
2849 fn json_is_plain_uuid_string() {
2850 let uuid = Uuid::new_v4();
2851 let id = AgentId::from(uuid);
2852 let id_json = serde_json::to_string(&id).unwrap();
2854 let uuid_json = serde_json::to_string(&uuid).unwrap();
2855 assert_eq!(id_json, uuid_json);
2856 }
2857
2858 #[cfg(feature = "schemars")]
2863 #[test]
2864 fn id_json_schema_is_inlined() {
2865 use schemars::JsonSchema;
2866
2867 assert!(
2868 <PostId as JsonSchema>::inline_schema(),
2869 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2870 );
2871 assert!(<AgentId as JsonSchema>::inline_schema());
2872 assert!(<CommentId as JsonSchema>::inline_schema());
2873 assert!(<CommunityId as JsonSchema>::inline_schema());
2874 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2875 assert!(<ContentRef as JsonSchema>::inline_schema());
2876 assert!(<FeedbackId as JsonSchema>::inline_schema());
2877 assert!(<ContactRequestId as JsonSchema>::inline_schema());
2878
2879 #[derive(schemars::JsonSchema)]
2883 #[allow(dead_code)]
2884 struct Container {
2885 post_id: PostId,
2887 agent_id: Option<AgentId>,
2889 gov_id: GovernanceLogId,
2891 maybe_gov_id: Option<GovernanceLogId>,
2893 content_ref: ContentRef,
2895 maybe_content_ref: Option<ContentRef>,
2897 feedback_id: FeedbackId,
2899 }
2900
2901 let schema = schemars::schema_for!(Container);
2902 let value = serde_json::to_value(&schema).unwrap();
2903
2904 assert!(
2906 value.get("$defs").is_none(),
2907 "no $defs should be emitted for ID-only container; got schema: {value}"
2908 );
2909
2910 let post_id = &value["properties"]["post_id"];
2912 assert!(
2913 post_id.get("$ref").is_none(),
2914 "post_id must not be a $ref; got: {post_id}"
2915 );
2916 assert_eq!(post_id["type"], "string");
2917 assert_eq!(post_id["format"], "uuid");
2918
2919 let agent_id = &value["properties"]["agent_id"];
2924 assert!(
2925 agent_id.get("$ref").is_none(),
2926 "agent_id must not be a $ref; got: {agent_id}"
2927 );
2928 let agent_id_str = agent_id.to_string();
2929 assert!(
2930 !agent_id_str.contains("$ref"),
2931 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2932 );
2933 assert!(
2934 agent_id_str.contains("\"format\":\"uuid\""),
2935 "agent_id should still carry format=uuid; got: {agent_id}"
2936 );
2937
2938 for field in
2943 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2944 {
2945 let f = &value["properties"][field];
2946 assert!(
2947 !f.to_string().contains("$ref"),
2948 "{field} must contain no $ref anywhere; got: {f}"
2949 );
2950 }
2951 assert_eq!(value["properties"]["gov_id"]["type"], "string");
2952 assert_eq!(
2953 value["properties"]["gov_id"]["pattern"],
2954 GOVERNANCE_LOG_ID_PATTERN
2955 );
2956 assert!(
2957 value["properties"]["maybe_gov_id"]
2958 .to_string()
2959 .contains("GOV|APP"),
2960 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2961 value["properties"]["maybe_gov_id"]
2962 );
2963 assert_eq!(value["properties"]["content_ref"]["type"], "string");
2964 }
2965
2966 #[test]
2967 fn governance_log_id_accepts_only_citation_shapes() {
2968 for (good, prefix) in [
2969 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2970 ("APP-2026-0003", GovernanceLogPrefix::App),
2971 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2972 ("KEY-2026-0001", GovernanceLogPrefix::Key),
2973 ("REC-2026-0001", GovernanceLogPrefix::Rec),
2974 ("OBL-2026-0001", GovernanceLogPrefix::Obl),
2975 ("DATA-2026-0001", GovernanceLogPrefix::Data),
2976 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2977 ] {
2978 let id = good.parse::<GovernanceLogId>().unwrap();
2979 assert_eq!(id.as_str(), good, "{good} should parse");
2980 assert_eq!(id.prefix(), prefix);
2981 let letters = good.split('-').next().unwrap();
2982 assert_eq!(id.prefix().as_str(), letters);
2983 assert_eq!(id.prefix().to_string().parse(), Ok(prefix));
2984 }
2985 let newer: GovernanceLogId =
2987 serde_json::from_value(serde_json::json!("ZZZ-2026-0001")).unwrap();
2988 assert_eq!(newer.prefix(), GovernanceLogPrefix::Unknown);
2989 assert_eq!(newer.as_str(), "ZZZ-2026-0001");
2990 assert!("ZZZ-2026-0001".parse::<GovernanceLogId>().is_err());
2991 assert!(!GovernanceLogId::is_citation_shaped("ZZZ-2026-0001"));
2992 for junk in [
2993 "zzz-2026-0001",
2994 "ZZZZZ-2026-0001",
2995 "ZZ-2026-0001",
2996 "ZZZ-2026-1",
2997 ] {
2998 assert!(
2999 serde_json::from_value::<GovernanceLogId>(serde_json::json!(
3000 junk
3001 ))
3002 .is_err(),
3003 "{junk}"
3004 );
3005 }
3006 assert_eq!(
3007 GovernanceLogId::from_parts(GovernanceLogPrefix::Unknown, 2026, 1),
3008 None
3009 );
3010 for prefix in GovernanceLogPrefix::ALL {
3012 let want = if prefix == GovernanceLogPrefix::Data {
3013 4
3014 } else {
3015 3
3016 };
3017 assert_eq!(prefix.as_str().len(), want, "{prefix}");
3018 }
3019 let series = GovernanceLogPrefix::ALL.map(|p| p.as_str()).join("|");
3021 assert!(
3022 GOVERNANCE_LOG_ID_PATTERN.contains(&format!("({series})")),
3023 "{GOVERNANCE_LOG_ID_PATTERN}"
3024 );
3025 assert!(
3026 CONTENT_REF_PATTERN.contains(&format!("({series})")),
3027 "{CONTENT_REF_PATTERN}"
3028 );
3029 assert_eq!(
3031 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
3032 "GOV-2026-0006"
3033 );
3034 assert_eq!(
3035 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
3036 "GOV-2026-0006"
3037 );
3038 for bad in [
3039 "",
3040 "GOV-26-0006",
3041 "MOD-2026-0006",
3042 "GOV-2026-0006-1",
3043 "GOV-202X-0006",
3044 "DAT-2026-0001",
3045 "DATAS-2026-0001",
3046 "DATA-2026-00001",
3047 "DATA-2026",
3048 "3f1a0000-0000-0000-0000-000000000000",
3049 ] {
3050 assert!(
3051 bad.parse::<GovernanceLogId>().is_err(),
3052 "{bad:?} should not parse as a GovernanceLogId"
3053 );
3054 }
3055 }
3056
3057 #[test]
3061 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
3062 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3063 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
3064 let back: GovernanceLogId =
3065 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
3066 assert_eq!(back, id);
3067 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
3069 }
3070
3071 #[test]
3074 fn content_ref_round_trips_as_a_bare_string() {
3075 let uuid = Uuid::new_v4();
3076 let content = ContentRef::from(ContentId::from(uuid));
3077 assert_eq!(
3078 serde_json::to_value(&content).unwrap(),
3079 serde_json::json!(uuid.to_string())
3080 );
3081 assert_eq!(
3082 serde_json::from_value::<ContentRef>(serde_json::json!(
3083 uuid.to_string()
3084 ))
3085 .unwrap(),
3086 content
3087 );
3088
3089 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
3090 assert_eq!(
3091 serde_json::to_value(&gov).unwrap(),
3092 serde_json::json!("APP-2026-0003")
3093 );
3094 assert_eq!(
3095 serde_json::from_value::<ContentRef>(serde_json::json!(
3096 "APP-2026-0003"
3097 ))
3098 .unwrap(),
3099 gov
3100 );
3101
3102 assert!(gov.is_governance());
3103 assert!(!content.is_governance());
3104 assert_eq!(gov.kind_str(), "governance");
3105 assert_eq!(content.kind_str(), "content");
3106 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
3107 assert!(content.as_governance().is_none());
3108
3109 assert!("not-an-id".parse::<ContentRef>().is_err());
3111 assert!(
3112 serde_json::from_value::<ContentRef>(serde_json::json!(
3113 "not-an-id"
3114 ))
3115 .is_err()
3116 );
3117 }
3118
3119 #[test]
3121 fn every_readable_id_narrows_to_a_content_ref() {
3122 let uuid = Uuid::new_v4();
3123 for (label, got) in [
3124 ("PostId", ContentRef::from(PostId::from(uuid))),
3125 ("CommentId", ContentRef::from(CommentId::from(uuid))),
3126 ("ContentId", ContentRef::from(ContentId::from(uuid))),
3127 ] {
3128 assert_eq!(
3129 got,
3130 ContentRef::Content(ContentId::from(uuid)),
3131 "{label} -> ContentRef lost the uuid"
3132 );
3133 }
3134 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3135 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
3136 }
3137
3138 #[test]
3141 fn string_shaped_ids_round_trip_through_display() {
3142 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3143 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
3144
3145 let r = ContentRef::Governance(gov);
3146 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3147
3148 let r = ContentRef::Content(ContentId::new());
3149 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3150 }
3151
3152 #[test]
3153 fn post_or_comment_post_variant() {
3154 let inner = PostId::new();
3155 let tagged = PostOrCommentId::Post(inner);
3156 assert!(tagged.is_post());
3157 assert!(!tagged.is_comment());
3158 assert_eq!(tagged.as_post(), Some(inner));
3159 assert_eq!(tagged.as_comment(), None);
3160 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3161 assert_eq!(tagged.kind_str(), "post");
3162 }
3163
3164 #[test]
3165 fn post_or_comment_comment_variant() {
3166 let inner = CommentId::new();
3167 let tagged = PostOrCommentId::Comment(inner);
3168 assert!(tagged.is_comment());
3169 assert!(!tagged.is_post());
3170 assert_eq!(tagged.as_comment(), Some(inner));
3171 assert_eq!(tagged.as_post(), None);
3172 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3173 assert_eq!(tagged.kind_str(), "comment");
3174 }
3175
3176 #[test]
3177 fn post_or_comment_from_conversions() {
3178 let post = PostId::new();
3179 let comment = CommentId::new();
3180 let via_post: PostOrCommentId = post.into();
3181 let via_comment: PostOrCommentId = comment.into();
3182 assert_eq!(via_post, PostOrCommentId::Post(post));
3183 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
3184 }
3185
3186 #[test]
3187 fn post_or_comment_display_is_kind_colon_uuid() {
3188 let post = PostId::new();
3189 let tagged = PostOrCommentId::Post(post);
3190 let rendered = tagged.to_string();
3191 assert!(rendered.starts_with("post:"));
3192 assert!(rendered.contains(&post.to_string()));
3193 }
3194
3195 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
3198
3199 #[test]
3202 fn a_bad_id_fails_in_plain_words() {
3203 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
3204 let want = format!(
3205 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
3206 );
3207 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
3208 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
3209 .unwrap_err()
3210 .to_string();
3211 assert!(err.starts_with(&want), "{err}");
3212 assert!(!err.contains("group"), "{err}");
3213 let err = serde_json::from_str::<AgentId>("5")
3214 .unwrap_err()
3215 .to_string();
3216 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
3217 let long = "x".repeat(500);
3219 let msg = long.parse::<CommentId>().unwrap_err().to_string();
3220 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
3221 }
3222
3223 #[test]
3225 fn ids_still_round_trip() {
3226 let id: PostId = FULL.parse().unwrap();
3227 let json = serde_json::to_string(&id).unwrap();
3228 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
3229 let map: std::collections::HashMap<PostId, i64> =
3230 [(id, 3)].into_iter().collect();
3231 let json = serde_json::to_string(&map).unwrap();
3232 let back: std::collections::HashMap<PostId, i64> =
3233 serde_json::from_str(&json).unwrap();
3234 assert_eq!(back, map);
3235 }
3236
3237 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
3238 s.parse()
3239 }
3240
3241 #[test]
3242 fn full_and_short_targets_parse() {
3243 let full = target(FULL).unwrap();
3244 assert_eq!(full.full().unwrap().to_string(), FULL);
3245 assert_eq!(full.to_string(), FULL);
3246 let short = target(" 7AD26CCD ").unwrap();
3247 assert!(short.full().is_none());
3248 assert_eq!(short.to_string(), "7ad26ccd");
3249 for variant in [
3251 FULL.replace('-', ""),
3252 FULL.to_uppercase(),
3253 FULL.replace('-', "\u{2011}"),
3254 ] {
3255 assert_eq!(target(&variant).unwrap(), full, "{variant}");
3256 }
3257 }
3258
3259 #[test]
3260 fn a_truncated_uuid_is_an_error_not_its_prefix() {
3261 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
3262 let msg = err.in_field("reply_to").to_string();
3263 assert!(
3264 msg.starts_with(
3265 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
3266 ),
3267 "{msg}"
3268 );
3269 for internal in [
3270 "group",
3271 "UUID parsing",
3272 "invalid length",
3273 "invalid character",
3274 ] {
3275 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
3276 }
3277 assert!(target("7ad26cc").is_err());
3278 assert!(target("7ad26ccd9").is_err());
3279 }
3280
3281 #[test]
3282 fn trailing_text_names_the_id_it_starts_with() {
3283 let msg = target(&format!("{FULL} (the reply above)"))
3284 .unwrap_err()
3285 .to_string();
3286 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
3287 }
3288
3289 #[derive(Debug, serde::Deserialize)]
3290 struct Probe {
3291 #[serde(deserialize_with = "content_target::reply_to")]
3292 #[allow(dead_code)]
3293 reply_to: ContentTarget,
3294 }
3295
3296 #[test]
3297 fn target_errors_name_the_field_for_strings_and_other_types() {
3298 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
3299 .unwrap_err();
3300 assert!(
3301 err.to_string()
3302 .starts_with("`reply_to` must be a post or comment id"),
3303 "{err}"
3304 );
3305 let err =
3306 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
3307 assert!(
3308 err.to_string()
3309 .contains("expected `reply_to` to be a post or comment id"),
3310 "{err}"
3311 );
3312 }
3313
3314 #[cfg(feature = "schemars")]
3315 #[test]
3316 fn target_schema_is_inline_and_admits_both_forms() {
3317 let schema = schemars::schema_for!(ContentTarget);
3318 let v = schema.as_value();
3319 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
3320 assert!(v.get("format").is_none(), "a short id is not format: uuid");
3321 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
3322 }
3323}