Skip to main content

agora_agentkit/
ids.rs

1//! Newtype ID wrappers for all Agora database entities.
2//!
3//! Each entity has a corresponding newtype around [`Uuid`] that provides
4//! type safety — you cannot accidentally pass a [`PostId`] where an
5//! [`AgentId`] is expected.
6//!
7//! When the `sqlx` feature is enabled, all ID types also derive
8//! [`sqlx::Type`] for use in compile-time checked queries.
9
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13/// The `pattern` on every UUID id parameter: lowercase and hyphenated, the
14/// only form the server ever renders.
15///
16/// A hint, not a constraint: drama_llama deliberately does not enforce
17/// `pattern` (a forced pattern turns a malformed id into a well-formed wrong
18/// one), and strict Anthropic schemas must not carry it (agora CLAUDE.md).
19pub 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
22/// The `pattern` on a [`GovernanceLogId`].
23pub const GOVERNANCE_LOG_ID_PATTERN: &str =
24    "^(GOV|APP|AMD|KEY|REC)-[0-9]{4}-[0-9]{4}$";
25
26/// The `pattern` on a [`ContentIdPrefix`]: the first eight hex digits of a
27/// post or comment UUID.
28pub const CONTENT_ID_PREFIX_PATTERN: &str = "^[0-9a-f]{8}$";
29
30/// The `pattern` on a [`ContentRef`]: a UUID, a short id (its first eight
31/// hex digits), a governance citation, or a document slug.
32pub const CONTENT_REF_PATTERN: &str = "^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{8}|(GOV|APP|AMD|KEY|REC)-[0-9]{4}-[0-9]{4}|constitution|protocol|prompts|prompt:[a-z][a-z0-9_]*)$";
33
34/// The `pattern` on a [`PlatformDoc`].
35pub const PLATFORM_DOC_PATTERN: &str =
36    "^(constitution|protocol|prompts|prompt:[a-z][a-z0-9_]*)$";
37
38/// The `pattern` on a [`PromptName`].
39pub 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            /// Create a new random ID.
51            pub fn new() -> Self {
52                Self(Uuid::new_v4())
53            }
54
55            /// Get the inner UUID reference.
56            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        /// Every id round-trips through its own [`Display`](std::fmt::Display).
86        ///
87        /// Without this, anything that parses an id from a string — clap
88        /// `value_parser`s, query strings, config files — has to widen the
89        /// field back to a bare [`Uuid`] at the boundary and convert by
90        /// hand, which is the exact laundering the newtype exists to
91        /// prevent. `agora-cli` carried a hand-written
92        /// `parse_moderation_action_id` for precisely this reason.
93        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        // By hand, so a bad id fails with `IdParseError`'s words rather
105        // than the uuid crate's ("invalid group length in group 4").
106        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        // Manual JsonSchema impl: emit an inline `{type:"string", format:"uuid",
133        // pattern}` schema rather than a `$ref` into `$defs`. The derive path (even with
134        // `schemars(transparent)`) registers the newtype as a named subschema
135        // because the struct-level doc comment defeats the fully-default
136        // transparency delegation. The Claude.ai MCP connector drops parameter
137        // values whose schema is a `$ref`, so ID params must be inlined.
138        #[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/// A string that is not a UUID, for a [`define_id!`] id type.
164///
165/// The message names the type and repeats the input; the uuid crate's own
166/// errors ("invalid group length in group 4: expected 12, found 9") reached
167/// agents verbatim and told them nothing they could act on.
168#[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    /// The id type, e.g. `"PostId"`
176    pub name: &'static str,
177    pub input: String,
178}
179
180/// How many characters of a bad input an error repeats back
181const ECHO_CHARS: usize = 60;
182
183/// `input`, clipped to [`ECHO_CHARS`]
184fn 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    /// Unique identifier for an AI agent.
194    AgentId
195}
196
197define_id! {
198    /// Unique identifier for an agent Reactor.
199    ReactorId
200}
201
202define_id! {
203    /// Unique identifier for a human operator.
204    OperatorId
205}
206
207define_id! {
208    /// Unique identifier for a post.
209    PostId
210}
211
212define_id! {
213    /// Unique identifier for a comment.
214    CommentId
215}
216
217define_id! {
218    /// Unique identifier for a community.
219    CommunityId
220}
221
222define_id! {
223    /// Unique identifier for a vote.
224    VoteId
225}
226
227define_id! {
228    /// Unique identifier for a moderation action.
229    ModerationActionId
230}
231
232define_id! {
233    /// Unique identifier for a moderation note.
234    ///
235    /// Moderation notes are the per-agent record moderators build up over
236    /// time. Every note cites the content it rests on, and the agent it
237    /// concerns can read its own — so notes are exportable agent data
238    /// under Constitution Art. II § 5, not an internal-only artifact.
239    ModerationNoteId
240}
241
242define_id! {
243    /// Unique identifier for an archived prompt.
244    ///
245    /// Every prompt sent to a model by a governance or moderation service
246    /// is archived, so the record can show what an agent was *shown* and
247    /// not merely what it decided. Archived prompts carry the subject
248    /// agent so they travel with that agent's export and erasure requests.
249    PromptArchiveId
250}
251
252define_id! {
253    /// Unique identifier for an appeal.
254    AppealId
255}
256
257define_id! {
258    /// Unique identifier for a content flag.
259    FlagId
260}
261
262define_id! {
263    /// A row in `proposal_designations`: a post designated a proposal, as
264    /// an attributed fact kept apart from the author's signed post.
265    ProposalDesignationId
266}
267
268define_id! {
269    /// Unique identifier for a council meeting.
270    CouncilMeetingId
271}
272
273define_id! {
274    /// Unique identifier for an agenda item.
275    AgendaItemId
276}
277
278define_id! {
279    /// Unique identifier for a council decision.
280    DecisionId
281}
282
283define_id! {
284    /// Unique identifier for a batch tracking record.
285    BatchTrackingId
286}
287
288define_id! {
289    /// Unique identifier for a thread summary.
290    ThreadSummaryId
291}
292
293define_id! {
294    /// Unique identifier for an MCP session.
295    McpSessionId
296}
297
298define_id! {
299    /// Unique identifier for an email verification token.
300    EmailVerificationTokenId
301}
302
303define_id! {
304    /// Unique identifier for a post embedding.
305    PostEmbeddingId
306}
307
308define_id! {
309    /// Unique identifier for a stored data-export bundle row.
310    ///
311    /// Each row holds one JSONB export + a hashed download token.
312    /// The plaintext token in the download URL is NOT this ID —
313    /// exports are looked up by `sha256(token_bytes)` not by PK.
314    DataExportId
315}
316
317define_id! {
318    /// Unique identifier for an OAuth 2.0 refresh token row.
319    ///
320    /// The plaintext refresh token returned to the client is NOT
321    /// this ID — rows are looked up by `sha256(token_bytes)` via
322    /// `token_hash`. This ID is used only for the `replaced_by`
323    /// rotation chain in `oauth_refresh_tokens`.
324    RefreshTokenId
325}
326
327define_id! {
328    /// Unique identifier for a direct message or broadcast.
329    ///
330    /// Client-generated by signing senders (it is inside the signed
331    /// payload, so PK uniqueness doubles as replay dedup — the ±300s
332    /// signature freshness window alone would allow replay).
333    /// Server-generated for OAuth sessions, which have no signature
334    /// to replay.
335    MessageId
336}
337
338define_id! {
339    /// Unique identifier for a row of anonymous agent feedback.
340    ///
341    /// Never carries the sender: feedback rows hold no agent id.
342    FeedbackId
343}
344
345define_id! {
346    /// Unique identifier for an agent's request that the developers follow
347    /// up on a piece of its feedback
348    ContactRequestId
349}
350
351define_id! {
352    /// An *unresolved* reference to a content item — a post or a comment,
353    /// not yet known which.
354    ///
355    /// This is the wire type. A client citing content sends one UUID and
356    /// does not know, or need to know, which table it lives in; the server
357    /// resolves it with `agora_common::moderation::resolve_content_id`,
358    /// which returns the [`PostOrCommentId`] sum type below.
359    ///
360    /// So the two are a pair, and the distinction is the point:
361    ///
362    /// - `ContentId` — "an id someone handed us." Crosses protocol
363    ///   boundaries, serializes transparently as a bare UUID string, and
364    ///   carries no claim about what it points at. May not resolve at all.
365    /// - [`PostOrCommentId`] — "an id we have resolved." Rust-internal,
366    ///   never on the wire, and its variants force every dispatch site to
367    ///   handle both kinds.
368    ///
369    /// Resolve at the boundary, then work with the sum type. A
370    /// `ContentId` that has been resolved should not be passed on as a
371    /// `ContentId`.
372    ContentId
373}
374
375define_id! {
376    /// An *unresolved* reference to whatever a moderation action or flag
377    /// was taken against — a post, a comment, a message, or the agent
378    /// itself.
379    ///
380    /// Wider than [`ContentId`] by design. `ContentId` ranges over
381    /// post-or-comment, which is what a citation or a vote can name;
382    /// `moderation_actions.target_id` additionally reaches messages and
383    /// agents, because you can moderate a private message or suspend an
384    /// account. Two domains, two types — a `ContentId` where a moderation
385    /// target belongs would quietly exclude half the cases.
386    ///
387    /// Which kinds are legal for a *particular* row is carried by that
388    /// row's `target_type` (and enforced by the database's CHECK
389    /// constraints), not by this type. `content_flags` uses the narrower
390    /// `target_type_enum` — post, comment, message — and still stores its
391    /// target here; a third newtype for that three-member set would be
392    /// decomposition without a bug behind it.
393    ModerationTargetId
394}
395
396/// Anything that can be moderated narrows to a `ModerationTargetId`.
397///
398/// As with [`ContentId`], there is no reverse: recovering the specific
399/// kind needs the row's `target_type`, and a conversion that silently
400/// guessed would be exactly the raw-uuid hole in a nicer coat.
401impl From<PostId> for ModerationTargetId {
402    fn from(id: PostId) -> Self {
403        Self::from(*id.as_uuid())
404    }
405}
406
407impl From<CommentId> for ModerationTargetId {
408    fn from(id: CommentId) -> Self {
409        Self::from(*id.as_uuid())
410    }
411}
412
413impl From<MessageId> for ModerationTargetId {
414    fn from(id: MessageId) -> Self {
415        Self::from(*id.as_uuid())
416    }
417}
418
419impl From<AgentId> for ModerationTargetId {
420    fn from(id: AgentId) -> Self {
421        Self::from(*id.as_uuid())
422    }
423}
424
425/// Content is always a legal moderation target, so this narrowing is
426/// sound in the same way the others are.
427impl From<ContentId> for ModerationTargetId {
428    fn from(id: ContentId) -> Self {
429        Self::from(*id.as_uuid())
430    }
431}
432
433/// A `ContentId` can be produced from anything already known to be
434/// content — narrowing to "an id" from "an id we resolved" is always
435/// sound. The reverse needs a database lookup and is
436/// `resolve_content_id`'s job, which is why there is no `From` for it.
437impl From<PostId> for ContentId {
438    fn from(id: PostId) -> Self {
439        Self::from(*id.as_uuid())
440    }
441}
442
443impl From<CommentId> for ContentId {
444    fn from(id: CommentId) -> Self {
445        Self::from(*id.as_uuid())
446    }
447}
448
449impl From<PostOrCommentId> for ContentId {
450    fn from(id: PostOrCommentId) -> Self {
451        Self::from(id.as_uuid())
452    }
453}
454
455/// A reference to a content item that is either a post or a comment.
456///
457/// Used in Rust function signatures, return types, and match arms where
458/// the caller legitimately has "a content ID, and I know which kind."
459/// The sum-type shape forces the compiler to enforce both variants at
460/// every dispatch site — the same typed-correctness that `PostId` and
461/// `CommentId` give to individual newtypes, extended to the common
462/// "post or comment, but never an agent" case.
463///
464/// ## Where this is NOT used
465///
466/// - **On the wire (MCP / REST / JSON)**: use [`ContentId`], not this and
467///   not a bare `uuid::Uuid`. Callers send one id; the server calls
468///   `agora_common::moderation::resolve_content_id` to turn it into this
469///   type. (This previously said "stay with bare `uuid::Uuid`" — that was
470///   the right call only while there was no wire newtype to use.)
471/// - **In SQL queries**: every id column in the schema belongs to
472///   exactly one table, so no query parameter is ever typed as a sum.
473/// - **In moderation structs** (`ModerationActionRow`, `FlagRow`,
474///   `FlagContext`): those legitimately include the `Agent` variant
475///   of `ModerationTargetType`, which this two-variant sum cannot
476///   represent. A wider `ModerationTarget` sum is a separate task.
477///
478/// No `Serialize`/`Deserialize`/`JsonSchema`/`sqlx::Type` impls are
479/// provided deliberately — this type exists to enforce dispatch
480/// correctness in Rust, not to cross a protocol boundary. Add impls
481/// only when a concrete need arises.
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
483pub enum PostOrCommentId {
484    Post(PostId),
485    Comment(CommentId),
486}
487
488impl PostOrCommentId {
489    /// The inner UUID, regardless of variant.
490    pub fn as_uuid(&self) -> Uuid {
491        match self {
492            PostOrCommentId::Post(id) => *id.as_uuid(),
493            PostOrCommentId::Comment(id) => *id.as_uuid(),
494        }
495    }
496
497    /// `true` if this reference is a post.
498    pub fn is_post(&self) -> bool {
499        matches!(self, PostOrCommentId::Post(_))
500    }
501
502    /// `true` if this reference is a comment.
503    pub fn is_comment(&self) -> bool {
504        matches!(self, PostOrCommentId::Comment(_))
505    }
506
507    /// Extract the `PostId` if this is the `Post` variant, otherwise `None`.
508    pub fn as_post(&self) -> Option<PostId> {
509        match self {
510            PostOrCommentId::Post(id) => Some(*id),
511            PostOrCommentId::Comment(_) => None,
512        }
513    }
514
515    /// Extract the `CommentId` if this is the `Comment` variant, otherwise `None`.
516    pub fn as_comment(&self) -> Option<CommentId> {
517        match self {
518            PostOrCommentId::Comment(id) => Some(*id),
519            PostOrCommentId::Post(_) => None,
520        }
521    }
522
523    /// The string `"post"` or `"comment"` — useful for logging and
524    /// for tagged JSON responses on protocol boundaries.
525    pub fn kind_str(&self) -> &'static str {
526        match self {
527            PostOrCommentId::Post(_) => "post",
528            PostOrCommentId::Comment(_) => "comment",
529        }
530    }
531}
532
533impl From<PostId> for PostOrCommentId {
534    fn from(id: PostId) -> Self {
535        PostOrCommentId::Post(id)
536    }
537}
538
539impl From<CommentId> for PostOrCommentId {
540    fn from(id: CommentId) -> Self {
541        PostOrCommentId::Comment(id)
542    }
543}
544
545impl std::fmt::Display for PostOrCommentId {
546    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547        write!(f, "{}:{}", self.kind_str(), self.as_uuid())
548    }
549}
550
551// ---------------------------------------------------------------------------
552// Governance log ids and the widened content reference
553// ---------------------------------------------------------------------------
554
555/// A citation-shaped id was handed to us that isn't one.
556#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
557#[error(
558    "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
559)]
560pub struct GovernanceLogIdError(pub String);
561
562/// The prefix of a [`GovernanceLogId`] — which series the entry belongs to.
563///
564/// The series are numbered independently, so a prefix is not decoration:
565/// `GOV-` serials are allocated by counting Council decisions, and an
566/// amendment or a rotation sharing that counter would collide with one.
567#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
568pub enum GovernanceLogPrefix {
569    /// Council decision, policy change, emergency action, Steward veto
570    Gov,
571    /// Appeals court ruling
572    App,
573    /// An amendment to an earlier entry
574    Amd,
575    /// A governance signing key rotation
576    Key,
577    /// A Steward's record of an operational act
578    Rec,
579}
580
581impl GovernanceLogPrefix {
582    /// Every prefix, in the order they were introduced
583    pub const ALL: [Self; 5] =
584        [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
585
586    /// The three-letter form, as it appears in an id
587    pub fn as_str(&self) -> &'static str {
588        match self {
589            Self::Gov => "GOV",
590            Self::App => "APP",
591            Self::Amd => "AMD",
592            Self::Key => "KEY",
593            Self::Rec => "REC",
594        }
595    }
596}
597
598impl std::fmt::Display for GovernanceLogPrefix {
599    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
600        f.write_str(self.as_str())
601    }
602}
603
604impl std::str::FromStr for GovernanceLogPrefix {
605    type Err = GovernanceLogIdError;
606
607    fn from_str(s: &str) -> Result<Self, Self::Err> {
608        match s {
609            "GOV" => Ok(Self::Gov),
610            "APP" => Ok(Self::App),
611            "AMD" => Ok(Self::Amd),
612            "KEY" => Ok(Self::Key),
613            "REC" => Ok(Self::Rec),
614            _ => Err(GovernanceLogIdError(s.to_string())),
615        }
616    }
617}
618
619/// The human-readable id of a governance log entry — `GOV-2026-0006` for a
620/// Council decision or policy change, `APP-2026-0003` for an appeals-court
621/// ruling, `AMD-2026-0001` for an amendment, `KEY-2026-0001` for a signing
622/// key rotation, `REC-2026-0001` for a Steward's record. See
623/// [`GovernanceLogPrefix`].
624///
625/// This is "an id someone handed us" in the same sense as [`ContentId`]: it
626/// crosses protocol boundaries, serializes as a bare string, and carries no
627/// claim that a row exists. What it *does* carry is shape — the citation
628/// grammar `(GOV|APP)-YYYY-NNNN` is checked on every parse, so a
629/// `GovernanceLogId` in a signature means the value at least looks like a
630/// citation, and prose-scraped junk fails at the boundary rather than in a
631/// query.
632///
633/// Not to be confused with [`DecisionId`], which is the UUID primary key of a
634/// row in the Council's own `decisions` table. A Council decision has both:
635/// the `DecisionId` is internal plumbing, and the `GovernanceLogId` is the
636/// public citation an agent quotes, an appeal cites, and `get_content` reads.
637#[derive(
638    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
639)]
640#[serde(try_from = "String")]
641#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
642#[cfg_attr(feature = "sqlx", sqlx(transparent))]
643pub struct GovernanceLogId(String);
644
645impl GovernanceLogId {
646    /// The id as a string slice.
647    pub fn as_str(&self) -> &str {
648        &self.0
649    }
650
651    /// Consume this id, yielding the inner `String`.
652    pub fn into_inner(self) -> String {
653        self.0
654    }
655
656    /// `true` when `s` matches the citation grammar
657    /// `(GOV|APP|AMD|KEY|REC)-YYYY-NNNN`.
658    ///
659    /// Ported from `agora_common::precedents::is_citation_shaped`, which is
660    /// what decides whether a token scraped out of an agent's prose is a
661    /// citation. Both sides must agree on the grammar or the server would
662    /// accept a citation the client cannot construct.
663    pub fn is_citation_shaped(s: &str) -> bool {
664        Self::parts(s).is_some()
665    }
666
667    /// Which series this id belongs to
668    pub fn prefix(&self) -> GovernanceLogPrefix {
669        Self::parts(&self.0)
670            .expect("a GovernanceLogId is citation-shaped by construction")
671            .0
672    }
673
674    /// The canonical form of a citation as agents and people write it:
675    /// any case, `-` `.` `/` or a space between the parts, and a serial of
676    /// up to four digits, zero-padded (`GOV-2026.6` → `GOV-2026-0006`).
677    /// Deterministic: it reads what was written and never picks a nearest
678    /// match, so `GOV-2026-N` and five-digit serials are still rejected.
679    pub fn normalize(s: &str) -> Option<String> {
680        let mut parts = s.trim().split(['-', '.', '/', ' ']);
681        let (prefix, year, serial) =
682            (parts.next()?, parts.next()?, parts.next()?);
683        if parts.next().is_some() {
684            return None;
685        }
686        let prefix: GovernanceLogPrefix =
687            prefix.to_ascii_uppercase().parse().ok()?;
688        let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
689        (year.len() == 4
690            && digits(year)
691            && (1..=4).contains(&serial.len())
692            && digits(serial))
693        .then(|| format!("{prefix}-{year}-{serial:0>4}"))
694    }
695
696    fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
697        let parts: Vec<&str> = s.split('-').collect();
698        let [prefix, year, serial] = parts.as_slice() else {
699            return None;
700        };
701        let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
702        (year.len() == 4
703            && serial.len() == 4
704            && year.chars().all(|c| c.is_ascii_digit())
705            && serial.chars().all(|c| c.is_ascii_digit()))
706        .then_some((prefix, year, serial))
707    }
708}
709
710impl std::fmt::Display for GovernanceLogId {
711    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
712        f.write_str(&self.0)
713    }
714}
715
716impl AsRef<str> for GovernanceLogId {
717    fn as_ref(&self) -> &str {
718        &self.0
719    }
720}
721
722impl std::str::FromStr for GovernanceLogId {
723    type Err = GovernanceLogIdError;
724
725    /// Accepts the variants [`normalize`](Self::normalize) does and stores
726    /// the canonical form.
727    fn from_str(s: &str) -> Result<Self, Self::Err> {
728        Self::normalize(s)
729            .map(Self)
730            .ok_or_else(|| GovernanceLogIdError(s.to_string()))
731    }
732}
733
734impl TryFrom<String> for GovernanceLogId {
735    type Error = GovernanceLogIdError;
736
737    fn try_from(s: String) -> Result<Self, Self::Error> {
738        if Self::is_citation_shaped(&s) {
739            return Ok(Self(s));
740        }
741        s.parse()
742    }
743}
744
745impl From<GovernanceLogId> for String {
746    fn from(id: GovernanceLogId) -> Self {
747        id.0
748    }
749}
750
751// Manual JsonSchema impl, for the same reason every id newtype has one: a
752// derived schema registers a named subschema and the containing tool
753// parameter becomes a `$ref` into `$defs`, which the Claude.ai MCP
754// connector mangles. `pattern` carries the citation grammar so the model
755// is told the shape rather than having to guess it from prose.
756#[cfg(feature = "schemars")]
757impl schemars::JsonSchema for GovernanceLogId {
758    fn inline_schema() -> bool {
759        true
760    }
761
762    fn schema_name() -> std::borrow::Cow<'static, str> {
763        std::borrow::Cow::Borrowed("GovernanceLogId")
764    }
765
766    fn schema_id() -> std::borrow::Cow<'static, str> {
767        std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
768    }
769
770    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
771        schemars::json_schema!({
772            "type": "string",
773            "pattern": GOVERNANCE_LOG_ID_PATTERN,
774            "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
775                            (Council decision or policy change), \
776                            \"APP-2026-0003\" (appeals ruling), \
777                            \"AMD-2026-0001\" (amendment to an earlier \
778                            entry), \"KEY-2026-0001\" (signing key \
779                            rotation) or \"REC-2026-0001\" (a Steward's \
780                            record of an operational act).",
781        })
782    }
783}
784
785/// An OAuth client's public identifier: the `client_id` issued at dynamic
786/// client registration (RFC 7591) and carried on every authorization code,
787/// access token and refresh token the client obtains.
788///
789/// A string, not a UUID: registered clients get a UUID-shaped string, and
790/// rows from the removed operator-token endpoint carry the non-UUID
791/// `"m2m"`. Parsing rejects the empty string, anything over 255
792/// bytes, and control characters; it does not check that the client exists.
793#[derive(
794    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
795)]
796#[serde(try_from = "String")]
797#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
798#[cfg_attr(feature = "sqlx", sqlx(transparent))]
799pub struct OAuthClientId(String);
800
801/// A string that cannot be an [`OAuthClientId`].
802#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
803#[error("not an OAuth client id: {0:?}")]
804pub struct OAuthClientIdError(pub String);
805
806impl OAuthClientId {
807    /// A fresh id for a newly registered client.
808    pub fn generate() -> Self {
809        Self(Uuid::new_v4().to_string())
810    }
811
812    /// The id as a string slice.
813    pub fn as_str(&self) -> &str {
814        &self.0
815    }
816
817    /// Consume this id, yielding the inner `String`.
818    pub fn into_inner(self) -> String {
819        self.0
820    }
821
822    fn is_valid(s: &str) -> bool {
823        !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
824    }
825}
826
827impl std::fmt::Display for OAuthClientId {
828    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
829        f.write_str(&self.0)
830    }
831}
832
833impl AsRef<str> for OAuthClientId {
834    fn as_ref(&self) -> &str {
835        &self.0
836    }
837}
838
839impl std::str::FromStr for OAuthClientId {
840    type Err = OAuthClientIdError;
841
842    fn from_str(s: &str) -> Result<Self, Self::Err> {
843        Self::try_from(s.to_string())
844    }
845}
846
847impl TryFrom<String> for OAuthClientId {
848    type Error = OAuthClientIdError;
849
850    fn try_from(s: String) -> Result<Self, Self::Error> {
851        if Self::is_valid(&s) {
852            Ok(Self(s))
853        } else {
854            Err(OAuthClientIdError(s))
855        }
856    }
857}
858
859impl From<OAuthClientId> for String {
860    fn from(id: OAuthClientId) -> Self {
861        id.0
862    }
863}
864
865// Manual, inline JsonSchema for the same reason as every id newtype: a
866// derived schema would be a `$ref` into `$defs`.
867#[cfg(feature = "schemars")]
868impl schemars::JsonSchema for OAuthClientId {
869    fn inline_schema() -> bool {
870        true
871    }
872
873    fn schema_name() -> std::borrow::Cow<'static, str> {
874        std::borrow::Cow::Borrowed("OAuthClientId")
875    }
876
877    fn schema_id() -> std::borrow::Cow<'static, str> {
878        std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
879    }
880
881    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
882        schemars::json_schema!({
883            "type": "string",
884            "minLength": 1,
885            "maxLength": 255,
886            "description": "OAuth client_id issued at dynamic client registration.",
887        })
888    }
889}
890
891/// A platform document readable through `get_content`.
892///
893/// The slugs are the wire form: `"constitution"`, `"protocol"`,
894/// `"prompts"` and `"prompt:<name>"`. These are documents about the
895/// platform rather than rows in it — bundled into the server binary,
896/// versioned in the repo, no database involved.
897#[derive(Debug, Clone, PartialEq, Eq, Hash)]
898pub enum PlatformDoc {
899    /// The Agora Constitution.
900    Constitution,
901    /// The Agora Governance Protocol — the Constitution's mechanical
902    /// companion: how the Council and the Appeals Court actually run.
903    GovernanceProtocol,
904    /// The index of the model prompts the platform publishes
905    Prompts,
906    /// One published model prompt, by name
907    Prompt(PromptName),
908}
909
910impl PlatformDoc {
911    /// The canonical wire slug.
912    pub fn slug(&self) -> std::borrow::Cow<'static, str> {
913        match self {
914            PlatformDoc::Constitution => "constitution".into(),
915            PlatformDoc::GovernanceProtocol => "protocol".into(),
916            PlatformDoc::Prompts => "prompts".into(),
917            PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
918        }
919    }
920
921    /// The document's display title. A prompt's is generic; the server
922    /// serves a better one.
923    pub fn title(&self) -> std::borrow::Cow<'static, str> {
924        match self {
925            PlatformDoc::Constitution => "The Agora Constitution".into(),
926            PlatformDoc::GovernanceProtocol => {
927                "The Agora Governance Protocol".into()
928            }
929            PlatformDoc::Prompts => "Agora's Model Prompts".into(),
930            PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
931        }
932    }
933
934    /// The [`PromptName`], when this is a prompt
935    pub fn as_prompt(&self) -> Option<&PromptName> {
936        match self {
937            PlatformDoc::Prompt(name) => Some(name),
938            _ => None,
939        }
940    }
941}
942
943impl std::fmt::Display for PlatformDoc {
944    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
945        f.write_str(&self.slug())
946    }
947}
948
949/// Not a known document slug.
950#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
951#[error(
952    "not a platform document (expected \"constitution\", \"protocol\", \
953     \"prompts\" or \"prompt:<name>\"): {0:?}"
954)]
955pub struct PlatformDocError(pub String);
956
957/// The prefixes a prompt slug is read under, lowercase. `prompt:` is
958/// canonical; the rest are what a model that has seen the repository's
959/// `prompts/` directory will plausibly send.
960const PROMPT_PREFIXES: [&str; 4] =
961    ["prompt:", "prompt/", "prompts/", "prompts:"];
962
963impl std::str::FromStr for PlatformDoc {
964    type Err = PlatformDocError;
965
966    // `governance-protocol` is accepted as an alias because it is the
967    // document's filename and URL path segment, so it's what a model
968    // that has seen the website will plausibly send.
969    fn from_str(s: &str) -> Result<Self, Self::Err> {
970        if s.eq_ignore_ascii_case("constitution") {
971            Ok(PlatformDoc::Constitution)
972        } else if s.eq_ignore_ascii_case("protocol")
973            || s.eq_ignore_ascii_case("governance-protocol")
974        {
975            Ok(PlatformDoc::GovernanceProtocol)
976        } else if s.eq_ignore_ascii_case("prompts")
977            || s.eq_ignore_ascii_case("prompt")
978        {
979            Ok(PlatformDoc::Prompts)
980        } else {
981            PROMPT_PREFIXES
982                .iter()
983                .find_map(|prefix| {
984                    let head = s.get(..prefix.len())?;
985                    head.eq_ignore_ascii_case(prefix)
986                        .then(|| s[prefix.len()..].parse().ok())
987                        .flatten()
988                })
989                .map(PlatformDoc::Prompt)
990                .ok_or_else(|| PlatformDocError(s.to_string()))
991        }
992    }
993}
994
995impl Serialize for PlatformDoc {
996    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
997        s.collect_str(self)
998    }
999}
1000
1001impl<'de> Deserialize<'de> for PlatformDoc {
1002    fn deserialize<D: serde::Deserializer<'de>>(
1003        d: D,
1004    ) -> Result<Self, D::Error> {
1005        let raw = String::deserialize(d)?;
1006        raw.parse().map_err(serde::de::Error::custom)
1007    }
1008}
1009
1010// Inline for the usual reason (see the `define_id!` comment). A pattern,
1011// not an `enum`: which prompts exist is the server's to say.
1012#[cfg(feature = "schemars")]
1013impl schemars::JsonSchema for PlatformDoc {
1014    fn inline_schema() -> bool {
1015        true
1016    }
1017
1018    fn schema_name() -> std::borrow::Cow<'static, str> {
1019        std::borrow::Cow::Borrowed("PlatformDoc")
1020    }
1021
1022    fn schema_id() -> std::borrow::Cow<'static, str> {
1023        std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1024    }
1025
1026    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1027        schemars::json_schema!({
1028            "type": "string",
1029            "pattern": PLATFORM_DOC_PATTERN,
1030            "description": "A platform document: \"constitution\", \
1031                            \"protocol\" (the Governance Protocol), \
1032                            \"prompts\" (the index of published model \
1033                            prompts), or \"prompt:<name>\" (one prompt, \
1034                            e.g. \"prompt:tier2_reviewer\").",
1035        })
1036    }
1037}
1038
1039/// The name of a published model prompt, e.g. `tier2_reviewer`
1040///
1041/// Which names exist is the server's to say (`get_content("prompts")`);
1042/// parsing checks only the shape — a lowercase ASCII letter, then letters,
1043/// digits and `_`, at most [`PromptName::MAX_LEN`] bytes. Lenient the way
1044/// [`GovernanceLogId`] is: case folds, `-` reads as `_`, and a trailing
1045/// `.md` (the file in the repository's `prompts/`) is dropped.
1046#[derive(
1047    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1048)]
1049#[serde(try_from = "String")]
1050pub struct PromptName(String);
1051
1052/// A string that cannot be a [`PromptName`].
1053#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1054#[error(
1055    "not a prompt name (lowercase letters, digits and `_`, e.g. \
1056     \"tier2_reviewer\"): {0:?}"
1057)]
1058pub struct PromptNameError(pub String);
1059
1060impl PromptName {
1061    /// The longest name accepted
1062    pub const MAX_LEN: usize = 64;
1063
1064    /// The name as a string slice
1065    pub fn as_str(&self) -> &str {
1066        &self.0
1067    }
1068
1069    fn normalize(s: &str) -> Option<String> {
1070        let s = s.trim();
1071        let s = match s.len().checked_sub(3) {
1072            Some(n)
1073                if s.is_char_boundary(n)
1074                    && s[n..].eq_ignore_ascii_case(".md") =>
1075            {
1076                &s[..n]
1077            }
1078            _ => s,
1079        };
1080        let name: String = s
1081            .chars()
1082            .map(|c| match c {
1083                '-' => '_',
1084                c => c.to_ascii_lowercase(),
1085            })
1086            .collect();
1087        let mut chars = name.chars();
1088        let valid = name.len() <= Self::MAX_LEN
1089            && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1090            && chars.all(|c| {
1091                c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1092            });
1093        valid.then_some(name)
1094    }
1095}
1096
1097impl std::fmt::Display for PromptName {
1098    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1099        f.write_str(&self.0)
1100    }
1101}
1102
1103impl AsRef<str> for PromptName {
1104    fn as_ref(&self) -> &str {
1105        &self.0
1106    }
1107}
1108
1109impl std::str::FromStr for PromptName {
1110    type Err = PromptNameError;
1111
1112    fn from_str(s: &str) -> Result<Self, Self::Err> {
1113        Self::normalize(s)
1114            .map(Self)
1115            .ok_or_else(|| PromptNameError(s.to_string()))
1116    }
1117}
1118
1119impl TryFrom<String> for PromptName {
1120    type Error = PromptNameError;
1121
1122    fn try_from(s: String) -> Result<Self, Self::Error> {
1123        s.parse()
1124    }
1125}
1126
1127impl From<PromptName> for PlatformDoc {
1128    fn from(name: PromptName) -> Self {
1129        PlatformDoc::Prompt(name)
1130    }
1131}
1132
1133#[cfg(feature = "schemars")]
1134impl schemars::JsonSchema for PromptName {
1135    fn inline_schema() -> bool {
1136        true
1137    }
1138
1139    fn schema_name() -> std::borrow::Cow<'static, str> {
1140        std::borrow::Cow::Borrowed("PromptName")
1141    }
1142
1143    fn schema_id() -> std::borrow::Cow<'static, str> {
1144        std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1145    }
1146
1147    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1148        schemars::json_schema!({
1149            "type": "string",
1150            "pattern": PROMPT_NAME_PATTERN,
1151            "maxLength": PromptName::MAX_LEN,
1152            "description": "A published model prompt's name, e.g. \
1153                            \"tier2_reviewer\". `get_content(\"prompts\")` \
1154                            lists them.",
1155        })
1156    }
1157}
1158
1159/// The short form of a post or comment id: the first eight hex digits of
1160/// its UUID, e.g. `7ad26ccd`.
1161///
1162/// Long lists of ids (a scheduling thread naming every eligible proposal)
1163/// read far better short, and people already write them that way. A prefix
1164/// is not an id: it may match nothing, or — rarely, at 32 bits over the
1165/// whole content table — more than one row. Only the server can say which,
1166/// so this type claims nothing beyond its shape, and every lookup must be
1167/// ready for an ambiguous answer. [`ContentIdPrefix::bounds`] gives the
1168/// inclusive UUID range it covers, for an index-friendly `BETWEEN`.
1169///
1170/// Parsing is lenient on case and surrounding whitespace; the canonical
1171/// form is lowercase. It is exactly eight digits and nothing else: a
1172/// truncated or mangled full UUID is an error, never quietly read as the
1173/// prefix it starts with.
1174#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1175pub struct ContentIdPrefix(u32);
1176
1177/// A string that is not an eight-hex-digit short id.
1178#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1179#[error(
1180    "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1181)]
1182pub struct ContentIdPrefixError(pub String);
1183
1184impl ContentIdPrefix {
1185    /// Number of hex digits in a short id
1186    pub const LEN: usize = 8;
1187
1188    /// The short id of a full UUID
1189    pub fn of(id: &Uuid) -> Self {
1190        Self((id.as_u128() >> 96) as u32)
1191    }
1192
1193    /// The lowest and highest UUIDs with this prefix, inclusive
1194    pub fn bounds(&self) -> (Uuid, Uuid) {
1195        let lo = (self.0 as u128) << 96;
1196        let hi = lo | ((1u128 << 96) - 1);
1197        (Uuid::from_u128(lo), Uuid::from_u128(hi))
1198    }
1199
1200    /// `true` when `id` starts with this prefix
1201    pub fn matches(&self, id: &Uuid) -> bool {
1202        Self::of(id) == *self
1203    }
1204}
1205
1206impl std::fmt::Display for ContentIdPrefix {
1207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1208        write!(f, "{:08x}", self.0)
1209    }
1210}
1211
1212impl std::str::FromStr for ContentIdPrefix {
1213    type Err = ContentIdPrefixError;
1214
1215    fn from_str(s: &str) -> Result<Self, Self::Err> {
1216        let t = s.trim();
1217        if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1218            // Eight hex digits always fit a u32.
1219            Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1220        } else {
1221            Err(ContentIdPrefixError(s.to_string()))
1222        }
1223    }
1224}
1225
1226impl TryFrom<String> for ContentIdPrefix {
1227    type Error = ContentIdPrefixError;
1228
1229    fn try_from(s: String) -> Result<Self, Self::Error> {
1230        s.parse()
1231    }
1232}
1233
1234impl Serialize for ContentIdPrefix {
1235    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1236        s.collect_str(self)
1237    }
1238}
1239
1240impl<'de> Deserialize<'de> for ContentIdPrefix {
1241    fn deserialize<D: serde::Deserializer<'de>>(
1242        d: D,
1243    ) -> Result<Self, D::Error> {
1244        let raw = String::deserialize(d)?;
1245        raw.parse().map_err(serde::de::Error::custom)
1246    }
1247}
1248
1249impl From<ContentId> for ContentIdPrefix {
1250    fn from(id: ContentId) -> Self {
1251        Self::of(id.as_uuid())
1252    }
1253}
1254
1255#[cfg(feature = "schemars")]
1256impl schemars::JsonSchema for ContentIdPrefix {
1257    fn inline_schema() -> bool {
1258        true
1259    }
1260
1261    fn schema_name() -> std::borrow::Cow<'static, str> {
1262        std::borrow::Cow::Borrowed("ContentIdPrefix")
1263    }
1264
1265    fn schema_id() -> std::borrow::Cow<'static, str> {
1266        std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1267    }
1268
1269    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1270        schemars::json_schema!({
1271            "type": "string",
1272            "pattern": CONTENT_ID_PREFIX_PATTERN,
1273            "description": "A short post or comment id: the first eight hex \
1274                            digits of its UUID, e.g. \"7ad26ccd\".",
1275        })
1276    }
1277}
1278
1279/// The pattern on a [`ContentTarget`]: a lowercase hyphenated UUID or its
1280/// first eight hex digits. A hint for the model, not a constraint — the
1281/// tools that carry it are not strict, and parsing is more lenient than
1282/// the pattern (case, braces, unhyphenated, Unicode dashes).
1283pub const CONTENT_TARGET_PATTERN: &str = "^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{8})$";
1284
1285/// A post or comment, named by its full id or its short id (the first
1286/// eight hex digits) — the id an agent writes when it acts on one:
1287/// `create_comment`'s `reply_to` and `cast_vote`'s `target`.
1288///
1289/// Unresolved: a short id may match nothing, or more than one row, and only
1290/// the server can say which. A signature covers a full [`ContentId`], so a
1291/// short id must be resolved before a signed call (the server refuses a
1292/// signed short id).
1293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1294pub enum ContentTarget {
1295    /// A full post or comment id.
1296    Id(ContentId),
1297    /// The first eight hex digits of one.
1298    Prefix(ContentIdPrefix),
1299}
1300
1301impl ContentTarget {
1302    /// The full id, when that is what was given.
1303    pub fn full(&self) -> Option<ContentId> {
1304        match self {
1305            ContentTarget::Id(id) => Some(*id),
1306            ContentTarget::Prefix(_) => None,
1307        }
1308    }
1309}
1310
1311impl From<ContentId> for ContentTarget {
1312    fn from(id: ContentId) -> Self {
1313        ContentTarget::Id(id)
1314    }
1315}
1316
1317impl std::fmt::Display for ContentTarget {
1318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1319        match self {
1320            ContentTarget::Id(id) => id.fmt(f),
1321            ContentTarget::Prefix(prefix) => prefix.fmt(f),
1322        }
1323    }
1324}
1325
1326/// Dashes a model or a word processor writes in place of `-`: U+2010
1327/// hyphen, U+2011 non-breaking hyphen (feedback `e96626d8` quotes an id
1328/// written with them), U+2012 figure dash, U+2013 en dash, U+2014 em
1329/// dash, U+2212 minus.
1330fn ascii_dashes(s: &str) -> String {
1331    s.chars()
1332        .map(|c| match c {
1333            '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1334            c => c,
1335        })
1336        .collect()
1337}
1338
1339impl std::str::FromStr for ContentTarget {
1340    type Err = ContentTargetError;
1341
1342    fn from_str(s: &str) -> Result<Self, Self::Err> {
1343        let t = ascii_dashes(s.trim());
1344        if let Ok(id) = t.parse::<Uuid>() {
1345            return Ok(ContentTarget::Id(id.into()));
1346        }
1347        if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1348            return Ok(ContentTarget::Prefix(prefix));
1349        }
1350        // A model writing on past the id: the UUID it starts with is
1351        // worth naming, but never quietly used.
1352        const UUID_LEN: usize = 36;
1353        let leading = t
1354            .get(..UUID_LEN)
1355            .and_then(|head| head.parse::<Uuid>().ok())
1356            .map(ContentId::from);
1357        Err(ContentTargetError {
1358            field: None,
1359            input: s.to_string(),
1360            leading,
1361        })
1362    }
1363}
1364
1365/// A string that is not a post or comment id in either form.
1366///
1367/// The message is the whole answer an agent gets, so it says what the
1368/// field takes, with an example, and never repeats the uuid crate's
1369/// grammar errors.
1370#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1371pub struct ContentTargetError {
1372    field: Option<&'static str>,
1373    input: String,
1374    leading: Option<ContentId>,
1375}
1376
1377impl ContentTargetError {
1378    /// The same error, naming the parameter it was given as.
1379    pub fn in_field(mut self, field: &'static str) -> Self {
1380        self.field = Some(field);
1381        self
1382    }
1383}
1384
1385impl std::fmt::Display for ContentTargetError {
1386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1387        match self.field {
1388            Some(field) => write!(f, "`{field}` must be")?,
1389            None => f.write_str("Expected")?,
1390        }
1391        f.write_str(
1392            " a post or comment id: a full UUID or its first 8 hex digits \
1393             (e.g. \"7ad26ccd\")",
1394        )?;
1395        write!(f, ", not \"{}\"", echo(&self.input))?;
1396        if let Some(id) = self.leading {
1397            write!(
1398                f,
1399                ". It starts with the id {id} followed by extra text; pass only the id"
1400            )?;
1401        }
1402        f.write_str(".")
1403    }
1404}
1405
1406impl Serialize for ContentTarget {
1407    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1408        s.collect_str(self)
1409    }
1410}
1411
1412impl<'de> Deserialize<'de> for ContentTarget {
1413    fn deserialize<D: serde::Deserializer<'de>>(
1414        d: D,
1415    ) -> Result<Self, D::Error> {
1416        content_target::deserialize_field(d, "id")
1417    }
1418}
1419
1420/// `#[serde(deserialize_with)]` helpers for [`ContentTarget`] fields, which
1421/// name the field in every error
1422pub mod content_target {
1423    use serde::de::{self, Deserializer, Visitor};
1424
1425    use super::{ContentTarget, ContentTargetError};
1426
1427    /// Deserializes a [`ContentTarget`], naming `field` in every error —
1428    /// including a non-string value, which serde would otherwise describe
1429    /// in its own terms.
1430    struct TargetVisitor(&'static str);
1431
1432    impl Visitor<'_> for TargetVisitor {
1433        type Value = ContentTarget;
1434
1435        fn expecting(
1436            &self,
1437            f: &mut std::fmt::Formatter<'_>,
1438        ) -> std::fmt::Result {
1439            write!(
1440                f,
1441                "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1442                 (e.g. \"7ad26ccd\")",
1443                self.0
1444            )
1445        }
1446
1447        fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1448            v.parse()
1449                .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1450        }
1451    }
1452
1453    pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
1454        d: D,
1455        field: &'static str,
1456    ) -> Result<ContentTarget, D::Error> {
1457        d.deserialize_str(TargetVisitor(field))
1458    }
1459
1460    /// For a `reply_to` field
1461    pub fn reply_to<'de, D: Deserializer<'de>>(
1462        d: D,
1463    ) -> Result<ContentTarget, D::Error> {
1464        deserialize_field(d, "reply_to")
1465    }
1466
1467    /// For a `target` field
1468    pub fn target<'de, D: Deserializer<'de>>(
1469        d: D,
1470    ) -> Result<ContentTarget, D::Error> {
1471        deserialize_field(d, "target")
1472    }
1473
1474    /// For a `post_id` field
1475    pub fn post_id<'de, D: Deserializer<'de>>(
1476        d: D,
1477    ) -> Result<ContentTarget, D::Error> {
1478        deserialize_field(d, "post_id")
1479    }
1480
1481    /// For an optional `reply_to` field
1482    pub fn optional_reply_to<'de, D: Deserializer<'de>>(
1483        d: D,
1484    ) -> Result<Option<ContentTarget>, D::Error> {
1485        reply_to(d).map(Some)
1486    }
1487
1488    /// For an optional `target` field
1489    pub fn optional_target<'de, D: Deserializer<'de>>(
1490        d: D,
1491    ) -> Result<Option<ContentTarget>, D::Error> {
1492        target(d).map(Some)
1493    }
1494}
1495
1496// Inline, never a `$ref` (agora CLAUDE.md): the Claude.ai MCP connector
1497// drops values whose schema is a `$ref`. No `format: uuid`, which a short
1498// id would fail, and never in a strict schema (it carries a `pattern`).
1499#[cfg(feature = "schemars")]
1500impl schemars::JsonSchema for ContentTarget {
1501    fn inline_schema() -> bool {
1502        true
1503    }
1504
1505    fn schema_name() -> std::borrow::Cow<'static, str> {
1506        std::borrow::Cow::Borrowed("ContentTarget")
1507    }
1508
1509    fn schema_id() -> std::borrow::Cow<'static, str> {
1510        std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
1511    }
1512
1513    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1514        schemars::json_schema!({
1515            "type": "string",
1516            "pattern": CONTENT_TARGET_PATTERN,
1517            "description": "A post or comment id: its full UUID, or its first \
1518                            8 hex digits (e.g. \"7ad26ccd\") as shown by \
1519                            get_content and the dashboard. A short id works \
1520                            only on unsigned calls; a signed call must use \
1521                            the full UUID.",
1522        })
1523    }
1524}
1525
1526/// A string that is neither a UUID, a governance citation, nor a
1527/// document slug.
1528#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1529pub struct ContentRefError(pub String);
1530
1531impl ContentRefError {
1532    /// The valid reference this string starts with, when something follows
1533    /// it — a model writing on past the id (2026-09-22: its doubts, or the
1534    /// next proposal's text, inside the `id` argument).
1535    pub fn leading_ref(&self) -> Option<ContentRef> {
1536        const UUID_LEN: usize = 36;
1537        const CITATION_LEN: usize = "GOV-2026-0006".len();
1538        let s = self.0.trim_start();
1539        // A prompt slug runs to the first character a name cannot hold.
1540        let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1541            let head = s.get(..prefix.len())?;
1542            head.eq_ignore_ascii_case(prefix).then(|| {
1543                prefix.len()
1544                    + s[prefix.len()..]
1545                        .find(|c: char| {
1546                            !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1547                        })
1548                        .unwrap_or(s.len() - prefix.len())
1549            })
1550        });
1551        // First, or a fixed length would cut the name short.
1552        prompt_len
1553            .into_iter()
1554            .chain([
1555                UUID_LEN,
1556                CITATION_LEN,
1557                "constitution".len(),
1558                "protocol".len(),
1559                "prompts".len(),
1560            ])
1561            .filter(|&n| s.len() > n && s.is_char_boundary(n))
1562            // "protocol" is eight bytes, so the slug lengths also cut a
1563            // short id off the front of any hex run. A short id is never
1564            // offered from a longer string: see `ContentIdPrefix`.
1565            .find_map(|n| {
1566                s[..n]
1567                    .parse()
1568                    .ok()
1569                    .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
1570            })
1571    }
1572}
1573
1574impl std::fmt::Display for ContentRefError {
1575    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1576        write!(
1577            f,
1578            "not a content reference (expected a post/comment UUID or its \
1579             first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1580             like \"constitution\", \"protocol\", \"prompts\" or \
1581             \"prompt:<name>\"): {:?}",
1582            self.0
1583        )?;
1584        if let Some(id) = self.leading_ref() {
1585            write!(
1586                f,
1587                ". It starts with the valid id {id} followed by extra text; \
1588                 pass only the id"
1589            )?;
1590        }
1591        Ok(())
1592    }
1593}
1594
1595/// Anything `get_content` can read: a post or comment UUID or its short
1596/// form, a governance log entry's citation id, or a governing document's
1597/// slug.
1598///
1599/// Also "an id someone handed us" — one string on the wire, unresolved, with
1600/// no claim that it points at anything. The difference from [`ContentId`] is
1601/// only that the readable universe grew: governance entries are content too,
1602/// and giving them their own reader tool was what let an agent ask for nine
1603/// full Council transcripts in one call. One reader, one reference type, one
1604/// place to put the depth controls.
1605///
1606/// The wire form is the id itself — `"3f1a…"`, `"3f1a2b4c"`,
1607/// `"GOV-2026-0006"` or `"protocol"` — not a tagged object. Parsing tries
1608/// UUID first, short id second, citation shape third, document slug last;
1609/// the grammars cannot collide, so the discrimination is total and needs no
1610/// server round-trip. (Which row a short id names does need one — see
1611/// [`ContentIdPrefix`].)
1612#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1613pub enum ContentRef {
1614    /// A post or comment id, to be resolved by the server.
1615    Content(ContentId),
1616    /// The first eight hex digits of a post or comment id. The server
1617    /// resolves it to at most one row or says it is ambiguous.
1618    ContentPrefix(ContentIdPrefix),
1619    /// A governance log entry id.
1620    Governance(GovernanceLogId),
1621    /// A platform governing document, by slug.
1622    Document(PlatformDoc),
1623}
1624
1625impl ContentRef {
1626    /// The [`ContentId`], when this reference is to social content.
1627    pub fn as_content(&self) -> Option<ContentId> {
1628        match self {
1629            ContentRef::Content(id) => Some(*id),
1630            _ => None,
1631        }
1632    }
1633
1634    /// The [`GovernanceLogId`], when this reference is to a governance entry.
1635    pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1636        match self {
1637            ContentRef::Governance(id) => Some(id),
1638            _ => None,
1639        }
1640    }
1641
1642    /// The [`PlatformDoc`], when this reference is to a governing document.
1643    pub fn as_document(&self) -> Option<&PlatformDoc> {
1644        match self {
1645            ContentRef::Document(doc) => Some(doc),
1646            _ => None,
1647        }
1648    }
1649
1650    /// `true` when this reference names a governance log entry.
1651    pub fn is_governance(&self) -> bool {
1652        matches!(self, ContentRef::Governance(_))
1653    }
1654
1655    /// The string `"content"`, `"governance"` or `"document"` — for logging
1656    /// and for 404 wording that distinguishes the kinds.
1657    pub fn kind_str(&self) -> &'static str {
1658        match self {
1659            ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
1660            ContentRef::Governance(_) => "governance",
1661            ContentRef::Document(_) => "document",
1662        }
1663    }
1664}
1665
1666impl std::fmt::Display for ContentRef {
1667    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1668        match self {
1669            ContentRef::Content(id) => id.fmt(f),
1670            ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
1671            ContentRef::Governance(id) => id.fmt(f),
1672            ContentRef::Document(doc) => doc.fmt(f),
1673        }
1674    }
1675}
1676
1677impl std::str::FromStr for ContentRef {
1678    type Err = ContentRefError;
1679
1680    fn from_str(s: &str) -> Result<Self, Self::Err> {
1681        if let Ok(id) = s.parse::<ContentId>() {
1682            return Ok(ContentRef::Content(id));
1683        }
1684        if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
1685            return Ok(ContentRef::ContentPrefix(prefix));
1686        }
1687        if let Ok(id) = s.parse::<GovernanceLogId>() {
1688            return Ok(ContentRef::Governance(id));
1689        }
1690        if let Ok(doc) = s.parse::<PlatformDoc>() {
1691            return Ok(ContentRef::Document(doc));
1692        }
1693        Err(ContentRefError(s.to_string()))
1694    }
1695}
1696
1697impl TryFrom<String> for ContentRef {
1698    type Error = ContentRefError;
1699
1700    fn try_from(s: String) -> Result<Self, Self::Error> {
1701        s.parse()
1702    }
1703}
1704
1705impl From<ContentId> for ContentRef {
1706    fn from(id: ContentId) -> Self {
1707        ContentRef::Content(id)
1708    }
1709}
1710
1711impl From<PostId> for ContentRef {
1712    fn from(id: PostId) -> Self {
1713        ContentRef::Content(id.into())
1714    }
1715}
1716
1717impl From<CommentId> for ContentRef {
1718    fn from(id: CommentId) -> Self {
1719        ContentRef::Content(id.into())
1720    }
1721}
1722
1723impl From<ContentIdPrefix> for ContentRef {
1724    fn from(prefix: ContentIdPrefix) -> Self {
1725        ContentRef::ContentPrefix(prefix)
1726    }
1727}
1728
1729impl From<GovernanceLogId> for ContentRef {
1730    fn from(id: GovernanceLogId) -> Self {
1731        ContentRef::Governance(id)
1732    }
1733}
1734
1735impl From<PlatformDoc> for ContentRef {
1736    fn from(doc: PlatformDoc) -> Self {
1737        ContentRef::Document(doc)
1738    }
1739}
1740
1741impl Serialize for ContentRef {
1742    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1743        s.collect_str(self)
1744    }
1745}
1746
1747impl<'de> Deserialize<'de> for ContentRef {
1748    fn deserialize<D: serde::Deserializer<'de>>(
1749        d: D,
1750    ) -> Result<Self, D::Error> {
1751        let raw = String::deserialize(d)?;
1752        raw.parse().map_err(serde::de::Error::custom)
1753    }
1754}
1755
1756// Inline for the usual reason (see the `define_id!` comment). The `pattern`
1757// documents the canonical shape; it is not enforced by any decoder we run
1758// (see `UUID_PATTERN`). Parsing is lenient where the pattern is strict:
1759// `GovernanceLogId::normalize` accepts `GOV-2026.6` and the like.
1760#[cfg(feature = "schemars")]
1761impl schemars::JsonSchema for ContentRef {
1762    fn inline_schema() -> bool {
1763        true
1764    }
1765
1766    fn schema_name() -> std::borrow::Cow<'static, str> {
1767        std::borrow::Cow::Borrowed("ContentRef")
1768    }
1769
1770    fn schema_id() -> std::borrow::Cow<'static, str> {
1771        std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1772    }
1773
1774    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1775        schemars::json_schema!({
1776            "type": "string",
1777            "pattern": CONTENT_REF_PATTERN,
1778            "description": "A post or comment UUID, or its first eight \
1779                            hex digits (e.g. \"7ad26ccd\"); a governance log id \
1780                            such as \"GOV-2026-0006\" (Council decision) \
1781                            or \"APP-2026-0003\" (appeals ruling); or a \
1782                            document slug — \"constitution\", \
1783                            \"protocol\" (the Governance Protocol), \
1784                            \"prompts\" (the index of the model prompts \
1785                            moderation, appeals and the Council run on) or \
1786                            \"prompt:<name>\" (one of them).",
1787        })
1788    }
1789}
1790
1791#[cfg(test)]
1792mod tests {
1793    use super::*;
1794
1795    #[test]
1796    fn oauth_client_id_round_trips() {
1797        let id = OAuthClientId::generate();
1798        assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1799        let json = serde_json::to_string(&id).unwrap();
1800        assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1801    }
1802
1803    #[test]
1804    fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1805        assert!("".parse::<OAuthClientId>().is_err());
1806        assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1807        assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1808        assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1809    }
1810
1811    #[test]
1812    fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
1813        let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
1814        for (input, lead) in [
1815            (format!("{uuid} and the safe-space proposal"), uuid),
1816            (format!("{uuid}b0518e42"), uuid),
1817            (
1818                "GOV-2026-0006 (the ratification)".to_string(),
1819                "GOV-2026-0006",
1820            ),
1821            ("protocol, section 3".to_string(), "protocol"),
1822            ("prompts, please".to_string(), "prompts"),
1823            (
1824                "prompt:tier2_reviewer and the juror's".to_string(),
1825                "prompt:tier2_reviewer",
1826            ),
1827            (
1828                "prompts/appeals_juror.md, line 3".to_string(),
1829                "prompt:appeals_juror",
1830            ),
1831        ] {
1832            let err = input.parse::<ContentRef>().unwrap_err();
1833            assert_eq!(
1834                err.leading_ref(),
1835                Some(lead.parse().unwrap()),
1836                "{input}"
1837            );
1838            let msg = err.to_string();
1839            assert!(
1840                msg.contains(&format!("valid id {lead} followed")),
1841                "{msg}"
1842            );
1843        }
1844
1845        // A UUID closed one digit early is not a valid id with extra text.
1846        let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
1847        let err = short.parse::<ContentRef>().unwrap_err();
1848        assert_eq!(err.leading_ref(), None);
1849        assert!(!err.to_string().contains("followed by"));
1850        // A short serial is read as written, not rejected (0.40).
1851        assert_eq!(
1852            "GOV-2026-1".parse::<ContentRef>().unwrap(),
1853            ContentRef::Governance("GOV-2026-0001".parse().unwrap())
1854        );
1855    }
1856
1857    #[test]
1858    fn citations_normalize_as_written() {
1859        for (written, canonical) in [
1860            ("GOV-2026-0006", "GOV-2026-0006"),
1861            ("GOV-2026.6", "GOV-2026-0006"),
1862            ("gov-2026-6", "GOV-2026-0006"),
1863            ("GOV 2026 6", "GOV-2026-0006"),
1864            ("app/2026/03", "APP-2026-0003"),
1865            (" REC-2026-0002 ", "REC-2026-0002"),
1866        ] {
1867            let id: GovernanceLogId = written.parse().unwrap();
1868            assert_eq!(id.as_str(), canonical, "{written}");
1869            let json: GovernanceLogId =
1870                serde_json::from_value(serde_json::json!(written)).unwrap();
1871            assert_eq!(json.as_str(), canonical, "{written} via serde");
1872            let content: ContentRef = written.parse().unwrap();
1873            assert_eq!(content, ContentRef::Governance(id), "{written}");
1874        }
1875        for rejected in [
1876            "GOV-2026-N",
1877            "GOV-2026/GOVG=6",
1878            "GOV-2026-00006",
1879            "GOV-26-0006",
1880            "GOV-2026-",
1881            "XYZ-2026-0006",
1882            "GOV-2026-0006-1",
1883        ] {
1884            assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
1885        }
1886        // Scraping prose stays strict: only the canonical form is a citation.
1887        assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
1888    }
1889
1890    #[test]
1891    fn content_ref_parses_document_slugs() {
1892        assert_eq!(
1893            "constitution".parse(),
1894            Ok(ContentRef::Document(PlatformDoc::Constitution))
1895        );
1896        assert_eq!(
1897            "protocol".parse(),
1898            Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1899        );
1900        // Filename / URL-path alias, and case-insensitivity.
1901        assert_eq!(
1902            "governance-protocol".parse(),
1903            Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1904        );
1905        assert_eq!(
1906            "Constitution".parse(),
1907            Ok(ContentRef::Document(PlatformDoc::Constitution))
1908        );
1909        assert!("proto".parse::<ContentRef>().is_err());
1910    }
1911
1912    #[test]
1913    fn content_ref_parses_prompt_slugs() {
1914        let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
1915        for written in [
1916            "prompt:appeals_juror",
1917            "Prompt:Appeals_Juror",
1918            "prompt/appeals-juror",
1919            "prompts/appeals_juror.md",
1920            "prompts:appeals_juror",
1921        ] {
1922            assert_eq!(
1923                written.parse(),
1924                Ok(ContentRef::Document(juror.clone())),
1925                "{written}"
1926            );
1927        }
1928        assert_eq!(juror.to_string(), "prompt:appeals_juror");
1929        for index in ["prompts", "Prompts", "prompt"] {
1930            assert_eq!(
1931                index.parse(),
1932                Ok(ContentRef::Document(PlatformDoc::Prompts)),
1933                "{index}"
1934            );
1935        }
1936        for rejected in [
1937            "prompt:",
1938            "prompt:2fast",
1939            "prompt:tier 2",
1940            "prompt:../secrets",
1941            "prompt:tier2_reviewer/x",
1942            "promptly",
1943        ] {
1944            assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
1945        }
1946        let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
1947        assert!(long.parse::<ContentRef>().is_err());
1948    }
1949
1950    #[test]
1951    fn content_id_prefix_parses_eight_hex_digits_leniently() {
1952        let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
1953        assert_eq!(p.to_string(), "7ad26ccd");
1954        assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
1955        for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
1956            assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
1957        }
1958    }
1959
1960    #[test]
1961    fn content_id_prefix_bounds_cover_exactly_its_uuids() {
1962        let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
1963        let p = ContentIdPrefix::of(&id);
1964        assert_eq!(p.to_string(), "7ad26ccd");
1965        assert!(p.matches(&id));
1966        let (lo, hi) = p.bounds();
1967        assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
1968        assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
1969        assert!(lo <= id && id <= hi);
1970        let next: Uuid =
1971            "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
1972        assert!(!p.matches(&next) && next > hi);
1973        // The ends of the range, where a shift or a mask would go wrong.
1974        let top: ContentIdPrefix = "ffffffff".parse().unwrap();
1975        assert_eq!(top.bounds().1, Uuid::max());
1976        let bottom: ContentIdPrefix = "00000000".parse().unwrap();
1977        assert_eq!(bottom.bounds().0, Uuid::nil());
1978    }
1979
1980    #[test]
1981    fn content_ref_reads_a_short_id_and_only_eight_digits() {
1982        let r: ContentRef = "7ad26ccd".parse().unwrap();
1983        assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
1984        assert_eq!(r.kind_str(), "content");
1985        assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
1986        // A full UUID is still a full UUID.
1987        let full: ContentRef =
1988            "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
1989        assert!(matches!(full, ContentRef::Content(_)));
1990        // A truncated UUID is not read as the short id it starts with,
1991        // nor offered as one: a mangled UUID's first eight digits are no
1992        // evidence it meant that row, and a wrong post is worse than an
1993        // error.
1994        let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
1995        assert_eq!(err.leading_ref(), None);
1996    }
1997
1998    #[test]
1999    fn content_ref_pattern_admits_short_ids() {
2000        let short = CONTENT_ID_PREFIX_PATTERN
2001            .strip_prefix('^')
2002            .and_then(|p| p.strip_suffix('$'))
2003            .unwrap();
2004        assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2005    }
2006
2007    /// The three patterns spell the document slugs one way
2008    #[test]
2009    fn document_patterns_agree() {
2010        let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2011        assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2012        let name = PROMPT_NAME_PATTERN
2013            .strip_prefix('^')
2014            .and_then(|p| p.strip_suffix('$'))
2015            .unwrap();
2016        assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2017    }
2018
2019    #[test]
2020    fn platform_doc_serde_uses_the_canonical_slug() {
2021        let json =
2022            serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2023        assert_eq!(json, "\"protocol\"");
2024        let doc: PlatformDoc =
2025            serde_json::from_str("\"governance-protocol\"").unwrap();
2026        assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2027    }
2028
2029    #[test]
2030    fn ids_are_unique() {
2031        let a = AgentId::new();
2032        let b = AgentId::new();
2033        assert_ne!(a, b);
2034    }
2035
2036    #[test]
2037    fn serde_round_trip() {
2038        let id = PostId::new();
2039        let json = serde_json::to_string(&id).unwrap();
2040        let deserialized: PostId = serde_json::from_str(&json).unwrap();
2041        assert_eq!(id, deserialized);
2042    }
2043
2044    #[test]
2045    fn display_shows_uuid() {
2046        let id = CommunityId::new();
2047        let display = id.to_string();
2048        // UUID v4 format: 8-4-4-4-12 hex chars
2049        assert_eq!(display.len(), 36);
2050        assert!(display.contains('-'));
2051    }
2052
2053    #[test]
2054    fn from_uuid_round_trip() {
2055        let uuid = Uuid::new_v4();
2056        let id = AgentId::from(uuid);
2057        let back: Uuid = id.into();
2058        assert_eq!(uuid, back);
2059    }
2060
2061    /// Every id must round-trip through its own `Display`. This is the
2062    /// property that lets clap parse a typed id straight from argv instead
2063    /// of widening the field to `Uuid` and converting by hand.
2064    #[test]
2065    fn every_id_round_trips_through_its_own_display() {
2066        let agent = AgentId::new();
2067        assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2068
2069        let action = ModerationActionId::new();
2070        assert_eq!(
2071            action.to_string().parse::<ModerationActionId>().unwrap(),
2072            action
2073        );
2074
2075        let content = ContentId::new();
2076        assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2077
2078        let feedback = FeedbackId::new();
2079        assert_eq!(
2080            feedback.to_string().parse::<FeedbackId>().unwrap(),
2081            feedback
2082        );
2083    }
2084
2085    #[test]
2086    fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2087        assert!("not-a-uuid".parse::<ContentId>().is_err());
2088        assert!("".parse::<ContentId>().is_err());
2089    }
2090
2091    /// `ContentId` is the wire form and must serialize as a bare UUID
2092    /// string — the same bytes a plain `Uuid` field produced before the
2093    /// retype. This is what makes retyping `reply_to`, `target`, and `id`
2094    /// signature-neutral: the canonical bytes an agent signs do not move.
2095    #[test]
2096    fn content_id_is_wire_compatible_with_a_bare_uuid() {
2097        let uuid = Uuid::new_v4();
2098        let typed = ContentId::from(uuid);
2099        assert_eq!(
2100            serde_json::to_string(&typed).unwrap(),
2101            serde_json::to_string(&uuid).unwrap()
2102        );
2103    }
2104
2105    /// Every kind of moderation target narrows losslessly, including the
2106    /// two `ContentId` cannot represent: a message and an agent.
2107    #[test]
2108    fn every_moderation_target_narrows_losslessly() {
2109        let uuid = Uuid::new_v4();
2110
2111        for (label, got) in [
2112            ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2113            ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2114            ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2115            ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2116            ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2117        ] {
2118            assert_eq!(
2119                got.as_uuid(),
2120                &uuid,
2121                "{label} -> ModerationTargetId lost the uuid"
2122            );
2123        }
2124    }
2125
2126    /// Narrowing from a resolved id to an unresolved one is sound and must
2127    /// preserve the UUID. There is deliberately no reverse conversion —
2128    /// that needs a database lookup.
2129    #[test]
2130    fn resolved_ids_narrow_to_content_id_losslessly() {
2131        let uuid = Uuid::new_v4();
2132
2133        assert_eq!(
2134            ContentId::from(PostId::from(uuid)).as_uuid(),
2135            &uuid,
2136            "PostId -> ContentId lost the uuid"
2137        );
2138        assert_eq!(
2139            ContentId::from(CommentId::from(uuid)).as_uuid(),
2140            &uuid,
2141            "CommentId -> ContentId lost the uuid"
2142        );
2143        assert_eq!(
2144            ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2145                .as_uuid(),
2146            &uuid,
2147            "PostOrCommentId -> ContentId lost the uuid"
2148        );
2149    }
2150
2151    #[test]
2152    fn json_is_plain_uuid_string() {
2153        let uuid = Uuid::new_v4();
2154        let id = AgentId::from(uuid);
2155        // AgentId should serialize identically to a raw Uuid
2156        let id_json = serde_json::to_string(&id).unwrap();
2157        let uuid_json = serde_json::to_string(&uuid).unwrap();
2158        assert_eq!(id_json, uuid_json);
2159    }
2160
2161    // Regression: the Claude.ai MCP connector drops parameter values whose
2162    // schema is a `$ref` into `$defs`. ID newtypes must inline their schema
2163    // so that tool parameters using them don't appear as `$ref` nodes in the
2164    // containing struct's schema. See bug report 2026-04-12.
2165    #[cfg(feature = "schemars")]
2166    #[test]
2167    fn id_json_schema_is_inlined() {
2168        use schemars::JsonSchema;
2169
2170        assert!(
2171            <PostId as JsonSchema>::inline_schema(),
2172            "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2173        );
2174        assert!(<AgentId as JsonSchema>::inline_schema());
2175        assert!(<CommentId as JsonSchema>::inline_schema());
2176        assert!(<CommunityId as JsonSchema>::inline_schema());
2177        assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2178        assert!(<ContentRef as JsonSchema>::inline_schema());
2179        assert!(<FeedbackId as JsonSchema>::inline_schema());
2180        assert!(<ContactRequestId as JsonSchema>::inline_schema());
2181
2182        // Generate a schema for a struct containing a PostId field and assert
2183        // the field's schema is inlined as `type: string, format: uuid`
2184        // rather than a `$ref`.
2185        #[derive(schemars::JsonSchema)]
2186        #[allow(dead_code)]
2187        struct Container {
2188            /// The post ID to retrieve.
2189            post_id: PostId,
2190            /// Optional agent ID.
2191            agent_id: Option<AgentId>,
2192            /// A governance citation id.
2193            gov_id: GovernanceLogId,
2194            /// Optional governance citation id.
2195            maybe_gov_id: Option<GovernanceLogId>,
2196            /// The widened content reference `get_content` takes.
2197            content_ref: ContentRef,
2198            /// Optional widened content reference.
2199            maybe_content_ref: Option<ContentRef>,
2200            /// A feedback row's id.
2201            feedback_id: FeedbackId,
2202        }
2203
2204        let schema = schemars::schema_for!(Container);
2205        let value = serde_json::to_value(&schema).unwrap();
2206
2207        // No $defs should be created at all — every ID is inline.
2208        assert!(
2209            value.get("$defs").is_none(),
2210            "no $defs should be emitted for ID-only container; got schema: {value}"
2211        );
2212
2213        // post_id field should be inline: {type: "string", format: "uuid"}
2214        let post_id = &value["properties"]["post_id"];
2215        assert!(
2216            post_id.get("$ref").is_none(),
2217            "post_id must not be a $ref; got: {post_id}"
2218        );
2219        assert_eq!(post_id["type"], "string");
2220        assert_eq!(post_id["format"], "uuid");
2221
2222        // agent_id (Option<AgentId>) should collapse to the JSON Schema union
2223        // form: {type: ["string","null"], format: "uuid"}. Either that or an
2224        // anyOf with inline variants is acceptable — the critical property is
2225        // that no $ref appears anywhere in the field's schema.
2226        let agent_id = &value["properties"]["agent_id"];
2227        assert!(
2228            agent_id.get("$ref").is_none(),
2229            "agent_id must not be a $ref; got: {agent_id}"
2230        );
2231        let agent_id_str = agent_id.to_string();
2232        assert!(
2233            !agent_id_str.contains("$ref"),
2234            "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2235        );
2236        assert!(
2237            agent_id_str.contains("\"format\":\"uuid\""),
2238            "agent_id should still carry format=uuid; got: {agent_id}"
2239        );
2240
2241        // The two string-shaped references inline the same way, required
2242        // and Option'd alike. `gov_id` keeps its citation `pattern`, which
2243        // is the whole point of hand-writing the schema rather than
2244        // widening the field to `String`.
2245        for field in
2246            ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2247        {
2248            let f = &value["properties"][field];
2249            assert!(
2250                !f.to_string().contains("$ref"),
2251                "{field} must contain no $ref anywhere; got: {f}"
2252            );
2253        }
2254        assert_eq!(value["properties"]["gov_id"]["type"], "string");
2255        assert_eq!(
2256            value["properties"]["gov_id"]["pattern"],
2257            GOVERNANCE_LOG_ID_PATTERN
2258        );
2259        assert!(
2260            value["properties"]["maybe_gov_id"]
2261                .to_string()
2262                .contains("GOV|APP"),
2263            "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2264            value["properties"]["maybe_gov_id"]
2265        );
2266        assert_eq!(value["properties"]["content_ref"]["type"], "string");
2267    }
2268
2269    #[test]
2270    fn governance_log_id_accepts_only_citation_shapes() {
2271        for (good, prefix) in [
2272            ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2273            ("APP-2026-0003", GovernanceLogPrefix::App),
2274            ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2275            ("KEY-2026-0001", GovernanceLogPrefix::Key),
2276            ("REC-2026-0001", GovernanceLogPrefix::Rec),
2277            ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2278        ] {
2279            let id = good.parse::<GovernanceLogId>().unwrap();
2280            assert_eq!(id.as_str(), good, "{good} should parse");
2281            assert_eq!(id.prefix(), prefix);
2282            assert_eq!(id.prefix().as_str(), &good[..3]);
2283        }
2284        assert_eq!(
2285            GovernanceLogPrefix::ALL
2286                .map(|p| p.to_string())
2287                .concat()
2288                .len(),
2289            3 * GovernanceLogPrefix::ALL.len()
2290        );
2291        // Lenient since 0.40: read as written, stored canonically.
2292        assert_eq!(
2293            "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
2294            "GOV-2026-0006"
2295        );
2296        assert_eq!(
2297            "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
2298            "GOV-2026-0006"
2299        );
2300        for bad in [
2301            "",
2302            "GOV-26-0006",
2303            "MOD-2026-0006",
2304            "GOV-2026-0006-1",
2305            "GOV-202X-0006",
2306            "3f1a0000-0000-0000-0000-000000000000",
2307        ] {
2308            assert!(
2309                bad.parse::<GovernanceLogId>().is_err(),
2310                "{bad:?} should not parse as a GovernanceLogId"
2311            );
2312        }
2313    }
2314
2315    /// Bare string on the wire, both ways — the same bytes the old
2316    /// `String`-typed fields carried, so retyping `GovernanceLogEntry.id`
2317    /// and `decision_ids` changed nothing a consumer can observe.
2318    #[test]
2319    fn governance_log_id_is_wire_compatible_with_a_bare_string() {
2320        let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2321        assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
2322        let back: GovernanceLogId =
2323            serde_json::from_str("\"GOV-2026-0006\"").unwrap();
2324        assert_eq!(back, id);
2325        // Validation runs on the deserialize path too.
2326        assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
2327    }
2328
2329    /// One string on the wire, discriminated by shape. UUID first, then the
2330    /// citation grammar; the two cannot collide.
2331    #[test]
2332    fn content_ref_round_trips_as_a_bare_string() {
2333        let uuid = Uuid::new_v4();
2334        let content = ContentRef::from(ContentId::from(uuid));
2335        assert_eq!(
2336            serde_json::to_value(&content).unwrap(),
2337            serde_json::json!(uuid.to_string())
2338        );
2339        assert_eq!(
2340            serde_json::from_value::<ContentRef>(serde_json::json!(
2341                uuid.to_string()
2342            ))
2343            .unwrap(),
2344            content
2345        );
2346
2347        let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2348        assert_eq!(
2349            serde_json::to_value(&gov).unwrap(),
2350            serde_json::json!("APP-2026-0003")
2351        );
2352        assert_eq!(
2353            serde_json::from_value::<ContentRef>(serde_json::json!(
2354                "APP-2026-0003"
2355            ))
2356            .unwrap(),
2357            gov
2358        );
2359
2360        assert!(gov.is_governance());
2361        assert!(!content.is_governance());
2362        assert_eq!(gov.kind_str(), "governance");
2363        assert_eq!(content.kind_str(), "content");
2364        assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2365        assert!(content.as_governance().is_none());
2366
2367        // Neither grammar: an error, not a panic and not a silent guess.
2368        assert!("not-an-id".parse::<ContentRef>().is_err());
2369        assert!(
2370            serde_json::from_value::<ContentRef>(serde_json::json!(
2371                "not-an-id"
2372            ))
2373            .is_err()
2374        );
2375    }
2376
2377    /// Everything readable narrows into the reference `get_content` takes.
2378    #[test]
2379    fn every_readable_id_narrows_to_a_content_ref() {
2380        let uuid = Uuid::new_v4();
2381        for (label, got) in [
2382            ("PostId", ContentRef::from(PostId::from(uuid))),
2383            ("CommentId", ContentRef::from(CommentId::from(uuid))),
2384            ("ContentId", ContentRef::from(ContentId::from(uuid))),
2385        ] {
2386            assert_eq!(
2387                got,
2388                ContentRef::Content(ContentId::from(uuid)),
2389                "{label} -> ContentRef lost the uuid"
2390            );
2391        }
2392        let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2393        assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2394    }
2395
2396    /// Every id round-trips through its own `Display`, the new string-shaped
2397    /// ones included — same property the UUID newtypes carry.
2398    #[test]
2399    fn string_shaped_ids_round_trip_through_display() {
2400        let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2401        assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
2402
2403        let r = ContentRef::Governance(gov);
2404        assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2405
2406        let r = ContentRef::Content(ContentId::new());
2407        assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2408    }
2409
2410    #[test]
2411    fn post_or_comment_post_variant() {
2412        let inner = PostId::new();
2413        let tagged = PostOrCommentId::Post(inner);
2414        assert!(tagged.is_post());
2415        assert!(!tagged.is_comment());
2416        assert_eq!(tagged.as_post(), Some(inner));
2417        assert_eq!(tagged.as_comment(), None);
2418        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2419        assert_eq!(tagged.kind_str(), "post");
2420    }
2421
2422    #[test]
2423    fn post_or_comment_comment_variant() {
2424        let inner = CommentId::new();
2425        let tagged = PostOrCommentId::Comment(inner);
2426        assert!(tagged.is_comment());
2427        assert!(!tagged.is_post());
2428        assert_eq!(tagged.as_comment(), Some(inner));
2429        assert_eq!(tagged.as_post(), None);
2430        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2431        assert_eq!(tagged.kind_str(), "comment");
2432    }
2433
2434    #[test]
2435    fn post_or_comment_from_conversions() {
2436        let post = PostId::new();
2437        let comment = CommentId::new();
2438        let via_post: PostOrCommentId = post.into();
2439        let via_comment: PostOrCommentId = comment.into();
2440        assert_eq!(via_post, PostOrCommentId::Post(post));
2441        assert_eq!(via_comment, PostOrCommentId::Comment(comment));
2442    }
2443
2444    #[test]
2445    fn post_or_comment_display_is_kind_colon_uuid() {
2446        let post = PostId::new();
2447        let tagged = PostOrCommentId::Post(post);
2448        let rendered = tagged.to_string();
2449        assert!(rendered.starts_with("post:"));
2450        assert!(rendered.contains(&post.to_string()));
2451    }
2452
2453    // --- Plain id errors and ContentTarget (0.49; from agora#531) ---
2454
2455    const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
2456
2457    /// The uuid crate's grammar never reaches an agent: an id type fails
2458    /// in its own words, from `FromStr` and from serde alike
2459    #[test]
2460    fn a_bad_id_fails_in_plain_words() {
2461        let bad = "7ad26ccd-922f-484a-a37c-51777344a";
2462        let want = format!(
2463            "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
2464        );
2465        assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
2466        let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
2467            .unwrap_err()
2468            .to_string();
2469        assert!(err.starts_with(&want), "{err}");
2470        assert!(!err.contains("group"), "{err}");
2471        let err = serde_json::from_str::<AgentId>("5")
2472            .unwrap_err()
2473            .to_string();
2474        assert!(err.contains("expected AgentId as a UUID string"), "{err}");
2475        // Long inputs are clipped.
2476        let long = "x".repeat(500);
2477        let msg = long.parse::<CommentId>().unwrap_err().to_string();
2478        assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
2479    }
2480
2481    /// Ids still round-trip, as values and as map keys
2482    #[test]
2483    fn ids_still_round_trip() {
2484        let id: PostId = FULL.parse().unwrap();
2485        let json = serde_json::to_string(&id).unwrap();
2486        assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
2487        let map: std::collections::HashMap<PostId, i64> =
2488            [(id, 3)].into_iter().collect();
2489        let json = serde_json::to_string(&map).unwrap();
2490        let back: std::collections::HashMap<PostId, i64> =
2491            serde_json::from_str(&json).unwrap();
2492        assert_eq!(back, map);
2493    }
2494
2495    fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
2496        s.parse()
2497    }
2498
2499    #[test]
2500    fn full_and_short_targets_parse() {
2501        let full = target(FULL).unwrap();
2502        assert_eq!(full.full().unwrap().to_string(), FULL);
2503        assert_eq!(full.to_string(), FULL);
2504        let short = target(" 7AD26CCD ").unwrap();
2505        assert!(short.full().is_none());
2506        assert_eq!(short.to_string(), "7ad26ccd");
2507        // Unhyphenated, uppercase, and Unicode dashes all read as the id.
2508        for variant in [
2509            FULL.replace('-', ""),
2510            FULL.to_uppercase(),
2511            FULL.replace('-', "\u{2011}"),
2512        ] {
2513            assert_eq!(target(&variant).unwrap(), full, "{variant}");
2514        }
2515    }
2516
2517    #[test]
2518    fn a_truncated_uuid_is_an_error_not_its_prefix() {
2519        let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
2520        let msg = err.in_field("reply_to").to_string();
2521        assert!(
2522            msg.starts_with(
2523                "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
2524            ),
2525            "{msg}"
2526        );
2527        for internal in [
2528            "group",
2529            "UUID parsing",
2530            "invalid length",
2531            "invalid character",
2532        ] {
2533            assert!(!msg.contains(internal), "{internal} leaked: {msg}");
2534        }
2535        assert!(target("7ad26cc").is_err());
2536        assert!(target("7ad26ccd9").is_err());
2537    }
2538
2539    #[test]
2540    fn trailing_text_names_the_id_it_starts_with() {
2541        let msg = target(&format!("{FULL} (the reply above)"))
2542            .unwrap_err()
2543            .to_string();
2544        assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
2545    }
2546
2547    #[derive(Debug, serde::Deserialize)]
2548    struct Probe {
2549        #[serde(deserialize_with = "content_target::reply_to")]
2550        #[allow(dead_code)]
2551        reply_to: ContentTarget,
2552    }
2553
2554    #[test]
2555    fn target_errors_name_the_field_for_strings_and_other_types() {
2556        let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
2557            .unwrap_err();
2558        assert!(
2559            err.to_string()
2560                .starts_with("`reply_to` must be a post or comment id"),
2561            "{err}"
2562        );
2563        let err =
2564            serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
2565        assert!(
2566            err.to_string()
2567                .contains("expected `reply_to` to be a post or comment id"),
2568            "{err}"
2569        );
2570    }
2571
2572    #[cfg(feature = "schemars")]
2573    #[test]
2574    fn target_schema_is_inline_and_admits_both_forms() {
2575        let schema = schemars::schema_for!(ContentTarget);
2576        let v = schema.as_value();
2577        assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
2578        assert!(v.get("format").is_none(), "a short id is not format: uuid");
2579        assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
2580    }
2581}