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