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