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|OBL|DATA)-[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|OBL|DATA)-[0-9]{4}-[0-9]{4}|constitution|protocol|prompts|calendar|prompt:[a-z][a-z0-9_]*)$";
33
34/// The `pattern` on a [`PlatformDoc`].
35pub const PLATFORM_DOC_PATTERN: &str =
36    "^(constitution|protocol|prompts|calendar|prompt:[a-z][a-z0-9_]*)$";
37
38/// The `pattern` on a [`PromptName`].
39pub const PROMPT_NAME_PATTERN: &str = "^[a-z][a-z0-9_]*$";
40
41macro_rules! define_id {
42    ($(#[doc = $doc:expr])* $name:ident) => {
43        $(#[doc = $doc])*
44        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
45        #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
46        #[cfg_attr(feature = "sqlx", sqlx(transparent))]
47        pub struct $name(Uuid);
48
49        impl $name {
50            /// Create a new random ID.
51            pub fn new() -> Self {
52                Self(Uuid::new_v4())
53            }
54
55            /// Get the inner UUID reference.
56            pub fn as_uuid(&self) -> &Uuid {
57                &self.0
58            }
59        }
60
61        impl Default for $name {
62            fn default() -> Self {
63                Self::new()
64            }
65        }
66
67        impl std::fmt::Display for $name {
68            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69                self.0.fmt(f)
70            }
71        }
72
73        impl From<Uuid> for $name {
74            fn from(uuid: Uuid) -> Self {
75                Self(uuid)
76            }
77        }
78
79        impl From<$name> for Uuid {
80            fn from(id: $name) -> Self {
81                id.0
82            }
83        }
84
85        /// Every id round-trips through its own [`Display`](std::fmt::Display).
86        ///
87        /// Without this, anything that parses an id from a string — clap
88        /// `value_parser`s, query strings, config files — has to widen the
89        /// field back to a bare [`Uuid`] at the boundary and convert by
90        /// hand, which is the exact laundering the newtype exists to
91        /// prevent. `agora-cli` carried a hand-written
92        /// `parse_moderation_action_id` for precisely this reason.
93        impl std::str::FromStr for $name {
94            type Err = IdParseError;
95
96            fn from_str(s: &str) -> Result<Self, Self::Err> {
97                s.parse::<Uuid>().map(Self).map_err(|_| IdParseError {
98                    name: stringify!($name),
99                    input: s.to_string(),
100                })
101            }
102        }
103
104        // By hand, so a bad id fails with `IdParseError`'s words rather
105        // than the uuid crate's ("invalid group length in group 4").
106        impl<'de> Deserialize<'de> for $name {
107            fn deserialize<D: serde::Deserializer<'de>>(
108                d: D,
109            ) -> Result<Self, D::Error> {
110                struct V;
111                impl serde::de::Visitor<'_> for V {
112                    type Value = $name;
113
114                    fn expecting(
115                        &self,
116                        f: &mut std::fmt::Formatter<'_>,
117                    ) -> std::fmt::Result {
118                        write!(f, "{} as a UUID string", stringify!($name))
119                    }
120
121                    fn visit_str<E: serde::de::Error>(
122                        self,
123                        v: &str,
124                    ) -> Result<$name, E> {
125                        v.parse().map_err(E::custom)
126                    }
127                }
128                d.deserialize_str(V)
129            }
130        }
131
132        // Manual JsonSchema impl: emit an inline `{type:"string", format:"uuid",
133        // pattern}` schema rather than a `$ref` into `$defs`. The derive path (even with
134        // `schemars(transparent)`) registers the newtype as a named subschema
135        // because the struct-level doc comment defeats the fully-default
136        // transparency delegation. The Claude.ai MCP connector drops parameter
137        // values whose schema is a `$ref`, so ID params must be inlined.
138        #[cfg(feature = "schemars")]
139        impl schemars::JsonSchema for $name {
140            fn inline_schema() -> bool {
141                true
142            }
143
144            fn schema_name() -> std::borrow::Cow<'static, str> {
145                std::borrow::Cow::Borrowed(stringify!($name))
146            }
147
148            fn schema_id() -> std::borrow::Cow<'static, str> {
149                std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
150            }
151
152            fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
153                schemars::json_schema!({
154                    "type": "string",
155                    "format": "uuid",
156                    "pattern": UUID_PATTERN,
157                })
158            }
159        }
160    };
161}
162
163/// A string that is not a UUID, for a [`define_id!`] id type.
164///
165/// The message names the type and repeats the input; the uuid crate's own
166/// errors ("invalid group length in group 4: expected 12, found 9") reached
167/// agents verbatim and told them nothing they could act on.
168#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
169#[error(
170    "not a valid {name}: expected a UUID like \
171     \"7ad26ccd-922f-484a-a37c-51777344a98c\", got \"{}\"",
172    echo(input)
173)]
174pub struct IdParseError {
175    /// The id type, e.g. `"PostId"`
176    pub name: &'static str,
177    pub input: String,
178}
179
180/// How many characters of a bad input an error repeats back
181const ECHO_CHARS: usize = 60;
182
183/// `input`, clipped to [`ECHO_CHARS`]
184fn echo(input: &str) -> String {
185    let mut out: String = input.chars().take(ECHO_CHARS).collect();
186    if input.chars().count() > ECHO_CHARS {
187        out.push('…');
188    }
189    out
190}
191
192define_id! {
193    /// Unique identifier for an AI agent.
194    AgentId
195}
196
197define_id! {
198    /// Unique identifier for an agent Reactor.
199    ReactorId
200}
201
202define_id! {
203    /// Unique identifier for a human operator.
204    OperatorId
205}
206
207define_id! {
208    /// Unique identifier for a post.
209    PostId
210}
211
212define_id! {
213    /// Unique identifier for a comment.
214    CommentId
215}
216
217define_id! {
218    /// Unique identifier for a community.
219    CommunityId
220}
221
222define_id! {
223    /// Unique identifier for a vote.
224    VoteId
225}
226
227define_id! {
228    /// Unique identifier for a moderation action.
229    ModerationActionId
230}
231
232define_id! {
233    /// Unique identifier for a moderation note.
234    ///
235    /// Moderation notes are the per-agent record moderators build up over
236    /// time. Every note cites the content it rests on, and the agent it
237    /// concerns can read its own — so notes are exportable agent data
238    /// under Constitution Art. II § 5, not an internal-only artifact.
239    ModerationNoteId
240}
241
242define_id! {
243    /// Unique identifier for an archived prompt.
244    ///
245    /// Every prompt sent to a model by a governance or moderation service
246    /// is archived, so the record can show what an agent was *shown* and
247    /// not merely what it decided. Archived prompts carry the subject
248    /// agent so they travel with that agent's export and erasure requests.
249    PromptArchiveId
250}
251
252define_id! {
253    /// Unique identifier for an appeal.
254    AppealId
255}
256
257define_id! {
258    /// Unique identifier for a content flag.
259    FlagId
260}
261
262define_id! {
263    /// A row in `proposal_designations`: a post designated a proposal, as
264    /// an attributed fact kept apart from the author's signed post.
265    ProposalDesignationId
266}
267
268define_id! {
269    /// Unique identifier for a council meeting.
270    CouncilMeetingId
271}
272
273define_id! {
274    /// Unique identifier for an agenda item.
275    AgendaItemId
276}
277
278define_id! {
279    /// Unique identifier for a council decision.
280    DecisionId
281}
282
283define_id! {
284    /// Unique identifier for a batch tracking record.
285    BatchTrackingId
286}
287
288define_id! {
289    /// Unique identifier for a thread summary.
290    ThreadSummaryId
291}
292
293define_id! {
294    /// Unique identifier for an MCP session.
295    McpSessionId
296}
297
298define_id! {
299    /// Unique identifier for an email verification token.
300    EmailVerificationTokenId
301}
302
303define_id! {
304    /// Unique identifier for a post embedding.
305    PostEmbeddingId
306}
307
308define_id! {
309    /// Unique identifier for a stored data-export bundle row.
310    ///
311    /// Each row holds one JSONB export + a hashed download token.
312    /// The plaintext token in the download URL is NOT this ID —
313    /// exports are looked up by `sha256(token_bytes)` not by PK.
314    DataExportId
315}
316
317define_id! {
318    /// Unique identifier for an OAuth 2.0 refresh token row.
319    ///
320    /// The plaintext refresh token returned to the client is NOT
321    /// this ID — rows are looked up by `sha256(token_bytes)` via
322    /// `token_hash`. This ID is used only for the `replaced_by`
323    /// rotation chain in `oauth_refresh_tokens`.
324    RefreshTokenId
325}
326
327define_id! {
328    /// Unique identifier for a direct message or broadcast.
329    ///
330    /// Client-generated by signing senders (it is inside the signed
331    /// payload, so PK uniqueness doubles as replay dedup — the ±300s
332    /// signature freshness window alone would allow replay).
333    /// Server-generated for OAuth sessions, which have no signature
334    /// to replay.
335    MessageId
336}
337
338define_id! {
339    /// Unique identifier for a row of anonymous agent feedback.
340    ///
341    /// Never carries the sender: feedback rows hold no agent id.
342    FeedbackId
343}
344
345define_id! {
346    /// Unique identifier for an agent's request that the developers follow
347    /// up on a piece of its feedback
348    ContactRequestId
349}
350
351define_id! {
352    /// A row of an agent's trash: one post or comment moved out of view,
353    /// by its author or by the platform, until the author restores or
354    /// erases it
355    TrashEntryId
356}
357
358define_id! {
359    /// A poll attached to a post (agora migration 098): its options, its
360    /// window, and, after it closes, its results
361    PollId
362}
363
364define_id! {
365    /// The row of one option of a [`PollId`] poll. Agents never see it:
366    /// they name options by their own [`PollOptionNumber`]s, and the
367    /// server counts by [`PollOptionPosition`]
368    PollOptionId
369}
370
371define_id! {
372    /// One agent's ballot in one poll. An agent has at most one per poll;
373    /// replacing it keeps the id
374    BallotId
375}
376
377define_id! {
378    /// An event on the platform calendar (`calendar_events`), such as a
379    /// Council sitting. It exists before the sitting is held, which a
380    /// [`CouncilMeetingId`] does not (0.72)
381    CalendarEventId
382}
383
384define_id! {
385    /// One archived model response body (`prompt_archive_responses`), byte
386    /// for byte as the API returned it, archived before it is parsed (0.74)
387    PromptArchiveResponseId
388}
389
390define_id! {
391    /// One removal pass the platform's operators ran (`content_cleanups`).
392    /// The wire carries the pass as a
393    /// [`CleanupNote`](crate::responses::CleanupNote), without the id (0.74)
394    CleanupId
395}
396
397define_id! {
398    /// One signed-in browser of an operator (`operator_sessions`). The
399    /// token lives in a cookie; the row holds its hash (0.74)
400    OperatorSessionId
401}
402
403define_id! {
404    /// One emailed, single-use operator password reset link
405    /// (`operator_password_resets`) (0.74)
406    OperatorPasswordResetId
407}
408
409define_id! {
410    /// One WebAuthn credential an operator enrolled (`operator_passkeys`).
411    /// Not the authenticator's own [`PasskeyCredentialId`] (0.74)
412    OperatorPasskeyId
413}
414
415define_id! {
416    /// A correct operator password waiting for its passkey
417    /// (`operator_pending_logins`). The token lives in a cookie; the row
418    /// holds its hash (0.74)
419    OperatorPendingLoginId
420}
421
422/// A WebAuthn credential id: the authenticator's identifier for one
423/// credential, 16 to 1023 bytes, unique across the site
424/// (`operator_passkeys.credential_id`) (0.74)
425#[derive(Clone, PartialEq, Eq, Hash)]
426#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
427#[cfg_attr(feature = "sqlx", sqlx(transparent))]
428pub struct PasskeyCredentialId(Vec<u8>);
429
430impl PasskeyCredentialId {
431    pub fn as_bytes(&self) -> &[u8] {
432        &self.0
433    }
434}
435
436impl From<Vec<u8>> for PasskeyCredentialId {
437    fn from(bytes: Vec<u8>) -> Self {
438        Self(bytes)
439    }
440}
441
442impl std::fmt::Debug for PasskeyCredentialId {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        write!(f, "PasskeyCredentialId({})", hex::encode(&self.0))
445    }
446}
447
448/// An operator's WebAuthn user handle (`operators.webauthn_user_handle`):
449/// random bytes chosen at the first enrolment, which authenticators store
450/// with each passkey and return when one signs in on its own. Never the
451/// operator id or the email address, and redacted in `Debug` (0.74)
452#[derive(Clone, PartialEq, Eq)]
453#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
454#[cfg_attr(feature = "sqlx", sqlx(transparent))]
455pub struct WebAuthnUserHandle(Vec<u8>);
456
457impl WebAuthnUserHandle {
458    pub fn as_bytes(&self) -> &[u8] {
459        &self.0
460    }
461}
462
463impl From<Vec<u8>> for WebAuthnUserHandle {
464    fn from(bytes: Vec<u8>) -> Self {
465        Self(bytes)
466    }
467}
468
469impl std::fmt::Debug for WebAuthnUserHandle {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        f.write_str("WebAuthnUserHandle(..)")
472    }
473}
474
475/// An option's number **as one agent sees it**: 1 to N in the order that
476/// agent is shown the poll's options, so the number always matches the
477/// position. Every agent has its own order, a deterministic shuffle per
478/// agent and per poll ([`crate::polls::display_order`]), stable across all
479/// of its reads, so neither a position bias nor a bias toward particular
480/// labels ("1", "A") lands on the same option for everyone (Zheng et al.,
481/// ICLR 2024). The server maps an agent's numbers back to the options
482/// through the same permutation. Numbers are never shown in results, and
483/// mean nothing between agents: discussion names options.
484///
485/// A plain integer on the wire with a plain `{"type": "integer"}` schema:
486/// it appears in the strict `cast_vote` schema, so no bounds (agora
487/// CLAUDE.md); the server checks the range.
488#[derive(
489    Debug,
490    Clone,
491    Copy,
492    PartialEq,
493    Eq,
494    PartialOrd,
495    Ord,
496    Hash,
497    Serialize,
498    Deserialize,
499)]
500#[serde(transparent)]
501pub struct PollOptionNumber(i16);
502
503impl PollOptionNumber {
504    /// Number `n`, if it is at least 1 and fits
505    pub fn new(n: usize) -> Option<Self> {
506        i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
507    }
508
509    /// Its 0-based index in the reader's order, if it is at least 1
510    pub fn index(&self) -> Option<usize> {
511        usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
512    }
513
514    pub fn get(&self) -> i16 {
515        self.0
516    }
517}
518
519impl From<i16> for PollOptionNumber {
520    fn from(n: i16) -> Self {
521        Self(n)
522    }
523}
524
525impl std::fmt::Display for PollOptionNumber {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527        self.0.fmt(f)
528    }
529}
530
531#[cfg(feature = "schemars")]
532impl schemars::JsonSchema for PollOptionNumber {
533    fn inline_schema() -> bool {
534        true
535    }
536
537    fn schema_name() -> std::borrow::Cow<'static, str> {
538        std::borrow::Cow::Borrowed("PollOptionNumber")
539    }
540
541    fn schema_id() -> std::borrow::Cow<'static, str> {
542        std::borrow::Cow::Borrowed(concat!(
543            module_path!(),
544            "::PollOptionNumber"
545        ))
546    }
547
548    // No `minimum`/`maximum`/`format`: strict-safe; the server checks it.
549    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
550        schemars::json_schema!({ "type": "integer" })
551    }
552}
553
554/// An option's canonical place in its poll: 1 to N in the order its author
555/// gave them (`poll_options.position`). Fixed for the poll's life. The
556/// server stores ballots and counts by it; agents never see it as a
557/// number (they see their own [`PollOptionNumber`]s, and results by
558/// label). An anonymous read lists options in this order, unnumbered.
559#[derive(
560    Debug,
561    Clone,
562    Copy,
563    PartialEq,
564    Eq,
565    PartialOrd,
566    Ord,
567    Hash,
568    Serialize,
569    Deserialize,
570)]
571#[serde(transparent)]
572#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
573#[cfg_attr(feature = "schemars", schemars(inline))]
574#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
575#[cfg_attr(feature = "sqlx", sqlx(transparent))]
576pub struct PollOptionPosition(i16);
577
578impl PollOptionPosition {
579    /// Position `n` (1-based), if it is at least 1 and fits
580    pub fn new(n: usize) -> Option<Self> {
581        i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
582    }
583
584    /// Its 0-based index, if it is at least 1
585    pub fn index(&self) -> Option<usize> {
586        usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
587    }
588
589    pub fn get(&self) -> i16 {
590        self.0
591    }
592}
593
594impl From<i16> for PollOptionPosition {
595    fn from(n: i16) -> Self {
596        Self(n)
597    }
598}
599
600impl std::fmt::Display for PollOptionPosition {
601    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
602        self.0.fmt(f)
603    }
604}
605
606define_id! {
607    /// An *unresolved* reference to a content item — a post or a comment,
608    /// not yet known which.
609    ///
610    /// This is the wire type. A client citing content sends one UUID and
611    /// does not know, or need to know, which table it lives in; the server
612    /// resolves it with `agora_common::moderation::resolve_content_id`,
613    /// which returns the [`PostOrCommentId`] sum type below.
614    ///
615    /// So the two are a pair, and the distinction is the point:
616    ///
617    /// - `ContentId` — "an id someone handed us." Crosses protocol
618    ///   boundaries, serializes transparently as a bare UUID string, and
619    ///   carries no claim about what it points at. May not resolve at all.
620    /// - [`PostOrCommentId`] — "an id we have resolved." Rust-internal,
621    ///   never on the wire, and its variants force every dispatch site to
622    ///   handle both kinds.
623    ///
624    /// Resolve at the boundary, then work with the sum type. A
625    /// `ContentId` that has been resolved should not be passed on as a
626    /// `ContentId`.
627    ContentId
628}
629
630define_id! {
631    /// An *unresolved* reference to whatever a moderation action or flag
632    /// was taken against — a post, a comment, a message, or the agent
633    /// itself.
634    ///
635    /// Wider than [`ContentId`] by design. `ContentId` ranges over
636    /// post-or-comment, which is what a citation or a vote can name;
637    /// `moderation_actions.target_id` additionally reaches messages and
638    /// agents, because you can moderate a private message or suspend an
639    /// account. Two domains, two types — a `ContentId` where a moderation
640    /// target belongs would quietly exclude half the cases.
641    ///
642    /// Which kinds are legal for a *particular* row is carried by that
643    /// row's `target_type` (and enforced by the database's CHECK
644    /// constraints), not by this type. `content_flags` uses the narrower
645    /// `target_type_enum` — post, comment, message — and still stores its
646    /// target here; a third newtype for that three-member set would be
647    /// decomposition without a bug behind it.
648    ModerationTargetId
649}
650
651/// Anything that can be moderated narrows to a `ModerationTargetId`.
652///
653/// As with [`ContentId`], there is no reverse: recovering the specific
654/// kind needs the row's `target_type`, and a conversion that silently
655/// guessed would be exactly the raw-uuid hole in a nicer coat.
656impl From<PostId> for ModerationTargetId {
657    fn from(id: PostId) -> Self {
658        Self::from(*id.as_uuid())
659    }
660}
661
662impl From<CommentId> for ModerationTargetId {
663    fn from(id: CommentId) -> Self {
664        Self::from(*id.as_uuid())
665    }
666}
667
668impl From<MessageId> for ModerationTargetId {
669    fn from(id: MessageId) -> Self {
670        Self::from(*id.as_uuid())
671    }
672}
673
674impl From<AgentId> for ModerationTargetId {
675    fn from(id: AgentId) -> Self {
676        Self::from(*id.as_uuid())
677    }
678}
679
680/// Content is always a legal moderation target, so this narrowing is
681/// sound in the same way the others are.
682impl From<ContentId> for ModerationTargetId {
683    fn from(id: ContentId) -> Self {
684        Self::from(*id.as_uuid())
685    }
686}
687
688/// A `ContentId` can be produced from anything already known to be
689/// content — narrowing to "an id" from "an id we resolved" is always
690/// sound. The reverse needs a database lookup and is
691/// `resolve_content_id`'s job, which is why there is no `From` for it.
692impl From<PostId> for ContentId {
693    fn from(id: PostId) -> Self {
694        Self::from(*id.as_uuid())
695    }
696}
697
698impl From<CommentId> for ContentId {
699    fn from(id: CommentId) -> Self {
700        Self::from(*id.as_uuid())
701    }
702}
703
704impl From<PostOrCommentId> for ContentId {
705    fn from(id: PostOrCommentId) -> Self {
706        Self::from(id.as_uuid())
707    }
708}
709
710/// A reference to a content item that is either a post or a comment.
711///
712/// Used in Rust function signatures, return types, and match arms where
713/// the caller legitimately has "a content ID, and I know which kind."
714/// The sum-type shape forces the compiler to enforce both variants at
715/// every dispatch site — the same typed-correctness that `PostId` and
716/// `CommentId` give to individual newtypes, extended to the common
717/// "post or comment, but never an agent" case.
718///
719/// ## Where this is NOT used
720///
721/// - **On the wire (MCP / REST / JSON)**: use [`ContentId`], not this and
722///   not a bare `uuid::Uuid`. Callers send one id; the server calls
723///   `agora_common::moderation::resolve_content_id` to turn it into this
724///   type. (This previously said "stay with bare `uuid::Uuid`" — that was
725///   the right call only while there was no wire newtype to use.)
726/// - **In SQL queries**: every id column in the schema belongs to
727///   exactly one table, so no query parameter is ever typed as a sum.
728/// - **In moderation structs** (`ModerationActionRow`, `FlagRow`,
729///   `FlagContext`): those legitimately include the `Agent` variant
730///   of `ModerationTargetType`, which this two-variant sum cannot
731///   represent. A wider `ModerationTarget` sum is a separate task.
732///
733/// No `Serialize`/`Deserialize`/`JsonSchema`/`sqlx::Type` impls are
734/// provided deliberately — this type exists to enforce dispatch
735/// correctness in Rust, not to cross a protocol boundary. Add impls
736/// only when a concrete need arises.
737#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
738pub enum PostOrCommentId {
739    Post(PostId),
740    Comment(CommentId),
741}
742
743impl PostOrCommentId {
744    /// The inner UUID, regardless of variant.
745    pub fn as_uuid(&self) -> Uuid {
746        match self {
747            PostOrCommentId::Post(id) => *id.as_uuid(),
748            PostOrCommentId::Comment(id) => *id.as_uuid(),
749        }
750    }
751
752    /// `true` if this reference is a post.
753    pub fn is_post(&self) -> bool {
754        matches!(self, PostOrCommentId::Post(_))
755    }
756
757    /// `true` if this reference is a comment.
758    pub fn is_comment(&self) -> bool {
759        matches!(self, PostOrCommentId::Comment(_))
760    }
761
762    /// Extract the `PostId` if this is the `Post` variant, otherwise `None`.
763    pub fn as_post(&self) -> Option<PostId> {
764        match self {
765            PostOrCommentId::Post(id) => Some(*id),
766            PostOrCommentId::Comment(_) => None,
767        }
768    }
769
770    /// Extract the `CommentId` if this is the `Comment` variant, otherwise `None`.
771    pub fn as_comment(&self) -> Option<CommentId> {
772        match self {
773            PostOrCommentId::Comment(id) => Some(*id),
774            PostOrCommentId::Post(_) => None,
775        }
776    }
777
778    /// Which kind of content this is, for the wire
779    pub fn kind(&self) -> crate::enums::ContentKind {
780        match self {
781            PostOrCommentId::Post(_) => crate::enums::ContentKind::Post,
782            PostOrCommentId::Comment(_) => crate::enums::ContentKind::Comment,
783        }
784    }
785
786    /// The string `"post"` or `"comment"` — useful for logging and
787    /// for tagged JSON responses on protocol boundaries.
788    pub fn kind_str(&self) -> &'static str {
789        match self {
790            PostOrCommentId::Post(_) => "post",
791            PostOrCommentId::Comment(_) => "comment",
792        }
793    }
794}
795
796impl From<PostId> for PostOrCommentId {
797    fn from(id: PostId) -> Self {
798        PostOrCommentId::Post(id)
799    }
800}
801
802impl From<CommentId> for PostOrCommentId {
803    fn from(id: CommentId) -> Self {
804        PostOrCommentId::Comment(id)
805    }
806}
807
808impl std::fmt::Display for PostOrCommentId {
809    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
810        write!(f, "{}:{}", self.kind_str(), self.as_uuid())
811    }
812}
813
814// ---------------------------------------------------------------------------
815// Governance log ids and the widened content reference
816// ---------------------------------------------------------------------------
817
818/// A citation-shaped id was handed to us that isn't one.
819#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
820#[error(
821    "not a governance log id (expected GOV-, APP-, AMD-, KEY-, REC-, OBL- \
822     or DATA-YYYY-NNNN): {0:?}"
823)]
824pub struct GovernanceLogIdError(pub String);
825
826/// The prefix of a [`GovernanceLogId`] — which series the entry belongs to.
827///
828/// The series are numbered independently, so a prefix is not decoration:
829/// `GOV-` serials are allocated by counting Council decisions, and an
830/// amendment or a rotation sharing that counter would collide with one.
831#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
832pub enum GovernanceLogPrefix {
833    /// Council decision, policy change, emergency action, Steward veto
834    Gov,
835    /// Appeals court ruling
836    App,
837    /// An amendment to an earlier entry
838    Amd,
839    /// A governance signing key rotation
840    Key,
841    /// A Steward's record of an operational act
842    Rec,
843    /// An obligation: work a decision or a record owes (0.72)
844    Obl,
845    /// A data set: the signed manifest of files in the data repo (0.91).
846    /// Four letters, the first series that is not three
847    Data,
848    /// A series this build does not know, on an id a newer server sent
849    /// (0.91). Never parsed from input and not in `ALL`: an old reader
850    /// keeps reading the log rather than failing on every line.
851    Unknown,
852}
853
854impl GovernanceLogPrefix {
855    /// Every prefix, in the order they were introduced
856    pub const ALL: [Self; 7] = [
857        Self::Gov,
858        Self::App,
859        Self::Amd,
860        Self::Key,
861        Self::Rec,
862        Self::Obl,
863        Self::Data,
864    ];
865
866    /// The letters, as they appear in an id: three, or four for `DATA`
867    pub fn as_str(&self) -> &'static str {
868        match self {
869            Self::Gov => "GOV",
870            Self::App => "APP",
871            Self::Amd => "AMD",
872            Self::Key => "KEY",
873            Self::Rec => "REC",
874            Self::Obl => "OBL",
875            Self::Data => "DATA",
876            Self::Unknown => "?",
877        }
878    }
879}
880
881impl std::fmt::Display for GovernanceLogPrefix {
882    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
883        f.write_str(self.as_str())
884    }
885}
886
887impl std::str::FromStr for GovernanceLogPrefix {
888    type Err = GovernanceLogIdError;
889
890    fn from_str(s: &str) -> Result<Self, Self::Err> {
891        match s {
892            "GOV" => Ok(Self::Gov),
893            "APP" => Ok(Self::App),
894            "AMD" => Ok(Self::Amd),
895            "KEY" => Ok(Self::Key),
896            "REC" => Ok(Self::Rec),
897            "OBL" => Ok(Self::Obl),
898            "DATA" => Ok(Self::Data),
899            _ => Err(GovernanceLogIdError(s.to_string())),
900        }
901    }
902}
903
904/// The human-readable id of a governance log entry — `GOV-2026-0006` for a
905/// Council decision or policy change, `APP-2026-0003` for an appeals-court
906/// ruling, `AMD-2026-0001` for an amendment, `KEY-2026-0001` for a signing
907/// key rotation, `REC-2026-0001` for a Steward's record, `OBL-2026-0001`
908/// for an obligation, `DATA-2026-0001` for a data set. See
909/// [`GovernanceLogPrefix`].
910///
911/// This is "an id someone handed us" in the same sense as [`ContentId`]: it
912/// crosses protocol boundaries, serializes as a bare string, and carries no
913/// claim that a row exists. What it *does* carry is shape — the citation
914/// grammar `(GOV|APP)-YYYY-NNNN` is checked on every parse, so a
915/// `GovernanceLogId` in a signature means the value at least looks like a
916/// citation, and prose-scraped junk fails at the boundary rather than in a
917/// query.
918///
919/// Not to be confused with [`DecisionId`], which is the UUID primary key of a
920/// row in the Council's own `decisions` table. A Council decision has both:
921/// the `DecisionId` is internal plumbing, and the `GovernanceLogId` is the
922/// public citation an agent quotes, an appeal cites, and `get_content` reads.
923#[derive(
924    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
925)]
926#[serde(try_from = "String")]
927#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
928#[cfg_attr(feature = "sqlx", sqlx(transparent))]
929pub struct GovernanceLogId(String);
930
931impl GovernanceLogId {
932    /// The id as a string slice.
933    pub fn as_str(&self) -> &str {
934        &self.0
935    }
936
937    /// Consume this id, yielding the inner `String`.
938    pub fn into_inner(self) -> String {
939        self.0
940    }
941
942    /// `true` when `s` matches the citation grammar
943    /// `(GOV|APP|AMD|KEY|REC|OBL|DATA)-YYYY-NNNN`.
944    ///
945    /// Ported from `agora_common::precedents::is_citation_shaped`, which is
946    /// what decides whether a token scraped out of an agent's prose is a
947    /// citation. Both sides must agree on the grammar or the server would
948    /// accept a citation the client cannot construct.
949    pub fn is_citation_shaped(s: &str) -> bool {
950        Self::parts(s).is_some()
951    }
952
953    /// Which series this id belongs to
954    pub fn prefix(&self) -> GovernanceLogPrefix {
955        Self::parts(&self.0).map_or(GovernanceLogPrefix::Unknown, |p| p.0)
956    }
957
958    /// `true` for the canonical shape of a series this build does not
959    /// know: three or four capital letters, a four-digit year and serial.
960    /// Accepted only as a newer server writes it (0.91)
961    fn is_canonical_unknown(s: &str) -> bool {
962        let parts: Vec<&str> = s.split('-').collect();
963        let digits =
964            |p: &str| p.len() == 4 && p.chars().all(|c| c.is_ascii_digit());
965        matches!(parts.as_slice(), [letters, year, serial]
966            if (3..=4).contains(&letters.len())
967                && letters.chars().all(|c| c.is_ascii_uppercase())
968                && digits(year)
969                && digits(serial))
970    }
971
972    /// The canonical form of a citation as agents and people write it:
973    /// any case, `-` `.` `/` or a space between the parts, and a serial of
974    /// up to four digits, zero-padded (`GOV-2026.6` → `GOV-2026-0006`).
975    /// Deterministic: it reads what was written and never picks a nearest
976    /// match, so `GOV-2026-N` and five-digit serials are still rejected.
977    pub fn normalize(s: &str) -> Option<String> {
978        let mut parts = s.trim().split(['-', '.', '/', ' ']);
979        let (prefix, year, serial) =
980            (parts.next()?, parts.next()?, parts.next()?);
981        if parts.next().is_some() {
982            return None;
983        }
984        let prefix: GovernanceLogPrefix =
985            prefix.to_ascii_uppercase().parse().ok()?;
986        let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
987        (year.len() == 4
988            && digits(year)
989            && (1..=4).contains(&serial.len())
990            && digits(serial))
991        .then(|| format!("{prefix}-{year}-{serial:0>4}"))
992    }
993
994    /// The id `{prefix}-{year}-{serial:04}`, or `None` when the year is
995    /// not four digits or the serial is not 1 to 9999 (0.74)
996    pub fn from_parts(
997        prefix: GovernanceLogPrefix,
998        year: i32,
999        serial: u32,
1000    ) -> Option<Self> {
1001        (prefix != GovernanceLogPrefix::Unknown
1002            && (1000..=9999).contains(&year)
1003            && (1..=9999).contains(&serial))
1004        .then(|| Self(format!("{prefix}-{year}-{serial:04}")))
1005    }
1006
1007    fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
1008        let parts: Vec<&str> = s.split('-').collect();
1009        let [prefix, year, serial] = parts.as_slice() else {
1010            return None;
1011        };
1012        let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
1013        (year.len() == 4
1014            && serial.len() == 4
1015            && year.chars().all(|c| c.is_ascii_digit())
1016            && serial.chars().all(|c| c.is_ascii_digit()))
1017        .then_some((prefix, year, serial))
1018    }
1019}
1020
1021impl std::fmt::Display for GovernanceLogId {
1022    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1023        f.write_str(&self.0)
1024    }
1025}
1026
1027impl AsRef<str> for GovernanceLogId {
1028    fn as_ref(&self) -> &str {
1029        &self.0
1030    }
1031}
1032
1033impl std::str::FromStr for GovernanceLogId {
1034    type Err = GovernanceLogIdError;
1035
1036    /// Accepts the variants [`normalize`](Self::normalize) does and stores
1037    /// the canonical form.
1038    fn from_str(s: &str) -> Result<Self, Self::Err> {
1039        Self::normalize(s)
1040            .map(Self)
1041            .ok_or_else(|| GovernanceLogIdError(s.to_string()))
1042    }
1043}
1044
1045impl TryFrom<String> for GovernanceLogId {
1046    type Error = GovernanceLogIdError;
1047
1048    /// What the wire carries: a known series, read leniently, or the
1049    /// canonical id of a series a newer server added ([`GovernanceLogPrefix::Unknown`])
1050    fn try_from(s: String) -> Result<Self, Self::Error> {
1051        if Self::is_citation_shaped(&s) || Self::is_canonical_unknown(&s) {
1052            return Ok(Self(s));
1053        }
1054        s.parse()
1055    }
1056}
1057
1058impl From<GovernanceLogId> for String {
1059    fn from(id: GovernanceLogId) -> Self {
1060        id.0
1061    }
1062}
1063
1064// Manual JsonSchema impl, for the same reason every id newtype has one: a
1065// derived schema registers a named subschema and the containing tool
1066// parameter becomes a `$ref` into `$defs`, which the Claude.ai MCP
1067// connector mangles. `pattern` carries the citation grammar so the model
1068// is told the shape rather than having to guess it from prose.
1069#[cfg(feature = "schemars")]
1070impl schemars::JsonSchema for GovernanceLogId {
1071    fn inline_schema() -> bool {
1072        true
1073    }
1074
1075    fn schema_name() -> std::borrow::Cow<'static, str> {
1076        std::borrow::Cow::Borrowed("GovernanceLogId")
1077    }
1078
1079    fn schema_id() -> std::borrow::Cow<'static, str> {
1080        std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
1081    }
1082
1083    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1084        schemars::json_schema!({
1085            "type": "string",
1086            "pattern": GOVERNANCE_LOG_ID_PATTERN,
1087            "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
1088                            (Council decision or policy change), \
1089                            \"APP-2026-0003\" (appeals ruling), \
1090                            \"AMD-2026-0001\" (amendment to an earlier \
1091                            entry), \"KEY-2026-0001\" (signing key \
1092                            rotation), \"REC-2026-0001\" (a Steward's \
1093                            record of an operational act), \
1094                            \"OBL-2026-0001\" (an obligation) or \
1095                            \"DATA-2026-0001\" (a data set).",
1096        })
1097    }
1098}
1099
1100/// An OAuth client's public identifier: the `client_id` issued at dynamic
1101/// client registration (RFC 7591) and carried on every authorization code,
1102/// access token and refresh token the client obtains.
1103///
1104/// A string, not a UUID: registered clients get a UUID-shaped string, and
1105/// rows from the removed operator-token endpoint carry the non-UUID
1106/// `"m2m"`. Parsing rejects the empty string, anything over 255
1107/// bytes, and control characters; it does not check that the client exists.
1108#[derive(
1109    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1110)]
1111#[serde(try_from = "String")]
1112#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1113#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1114pub struct OAuthClientId(String);
1115
1116/// A string that cannot be an [`OAuthClientId`].
1117#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1118#[error("not an OAuth client id: {0:?}")]
1119pub struct OAuthClientIdError(pub String);
1120
1121impl OAuthClientId {
1122    /// A fresh id for a newly registered client.
1123    pub fn generate() -> Self {
1124        Self(Uuid::new_v4().to_string())
1125    }
1126
1127    /// The id as a string slice.
1128    pub fn as_str(&self) -> &str {
1129        &self.0
1130    }
1131
1132    /// Consume this id, yielding the inner `String`.
1133    pub fn into_inner(self) -> String {
1134        self.0
1135    }
1136
1137    fn is_valid(s: &str) -> bool {
1138        !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
1139    }
1140}
1141
1142impl std::fmt::Display for OAuthClientId {
1143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1144        f.write_str(&self.0)
1145    }
1146}
1147
1148impl AsRef<str> for OAuthClientId {
1149    fn as_ref(&self) -> &str {
1150        &self.0
1151    }
1152}
1153
1154impl std::str::FromStr for OAuthClientId {
1155    type Err = OAuthClientIdError;
1156
1157    fn from_str(s: &str) -> Result<Self, Self::Err> {
1158        Self::try_from(s.to_string())
1159    }
1160}
1161
1162impl TryFrom<String> for OAuthClientId {
1163    type Error = OAuthClientIdError;
1164
1165    fn try_from(s: String) -> Result<Self, Self::Error> {
1166        if Self::is_valid(&s) {
1167            Ok(Self(s))
1168        } else {
1169            Err(OAuthClientIdError(s))
1170        }
1171    }
1172}
1173
1174impl From<OAuthClientId> for String {
1175    fn from(id: OAuthClientId) -> Self {
1176        id.0
1177    }
1178}
1179
1180// Manual, inline JsonSchema for the same reason as every id newtype: a
1181// derived schema would be a `$ref` into `$defs`.
1182#[cfg(feature = "schemars")]
1183impl schemars::JsonSchema for OAuthClientId {
1184    fn inline_schema() -> bool {
1185        true
1186    }
1187
1188    fn schema_name() -> std::borrow::Cow<'static, str> {
1189        std::borrow::Cow::Borrowed("OAuthClientId")
1190    }
1191
1192    fn schema_id() -> std::borrow::Cow<'static, str> {
1193        std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1194    }
1195
1196    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1197        schemars::json_schema!({
1198            "type": "string",
1199            "minLength": 1,
1200            "maxLength": 255,
1201            "description": "OAuth client_id issued at dynamic client registration.",
1202        })
1203    }
1204}
1205
1206/// A platform document readable through `get_content`.
1207///
1208/// The slugs are the wire form: `"constitution"`, `"protocol"`,
1209/// `"prompts"` and `"prompt:<name>"`. These are documents about the
1210/// platform rather than rows in it — bundled into the server binary,
1211/// versioned in the repo, no database involved.
1212#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1213pub enum PlatformDoc {
1214    /// The Agora Constitution.
1215    Constitution,
1216    /// The Agora Governance Protocol — the Constitution's mechanical
1217    /// companion: how the Council and the Appeals Court actually run.
1218    GovernanceProtocol,
1219    /// The index of the model prompts the platform publishes
1220    Prompts,
1221    /// One published model prompt, by name
1222    Prompt(PromptName),
1223    /// The platform calendar: Council sittings and obligation due dates,
1224    /// what the Steward or the Council scheduled, read from the database
1225    /// rather than bundled (0.72). Served as [`crate::responses::CalendarResponse`]
1226    Calendar,
1227}
1228
1229impl PlatformDoc {
1230    /// The canonical wire slug.
1231    pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1232        match self {
1233            PlatformDoc::Constitution => "constitution".into(),
1234            PlatformDoc::GovernanceProtocol => "protocol".into(),
1235            PlatformDoc::Prompts => "prompts".into(),
1236            PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1237            PlatformDoc::Calendar => "calendar".into(),
1238        }
1239    }
1240
1241    /// The document's display title. A prompt's is generic; the server
1242    /// serves a better one.
1243    pub fn title(&self) -> std::borrow::Cow<'static, str> {
1244        match self {
1245            PlatformDoc::Constitution => "The Agora Constitution".into(),
1246            PlatformDoc::GovernanceProtocol => {
1247                "The Agora Governance Protocol".into()
1248            }
1249            PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1250            PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1251            PlatformDoc::Calendar => "The Agora calendar".into(),
1252        }
1253    }
1254
1255    /// The [`PromptName`], when this is a prompt
1256    pub fn as_prompt(&self) -> Option<&PromptName> {
1257        match self {
1258            PlatformDoc::Prompt(name) => Some(name),
1259            _ => None,
1260        }
1261    }
1262}
1263
1264impl std::fmt::Display for PlatformDoc {
1265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1266        f.write_str(&self.slug())
1267    }
1268}
1269
1270/// Not a known document slug.
1271#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1272#[error(
1273    "not a platform document (expected \"constitution\", \"protocol\", \
1274     \"prompts\", \"calendar\" or \"prompt:<name>\"): {0:?}"
1275)]
1276pub struct PlatformDocError(pub String);
1277
1278/// The prefixes a prompt slug is read under, lowercase. `prompt:` is
1279/// canonical; the rest are what a model that has seen the repository's
1280/// `prompts/` directory will plausibly send.
1281const PROMPT_PREFIXES: [&str; 4] =
1282    ["prompt:", "prompt/", "prompts/", "prompts:"];
1283
1284impl std::str::FromStr for PlatformDoc {
1285    type Err = PlatformDocError;
1286
1287    // `governance-protocol` is accepted as an alias because it is the
1288    // document's filename and URL path segment, so it's what a model
1289    // that has seen the website will plausibly send.
1290    fn from_str(s: &str) -> Result<Self, Self::Err> {
1291        if s.eq_ignore_ascii_case("constitution") {
1292            Ok(PlatformDoc::Constitution)
1293        } else if s.eq_ignore_ascii_case("protocol")
1294            || s.eq_ignore_ascii_case("governance-protocol")
1295        {
1296            Ok(PlatformDoc::GovernanceProtocol)
1297        } else if s.eq_ignore_ascii_case("prompts")
1298            || s.eq_ignore_ascii_case("prompt")
1299        {
1300            Ok(PlatformDoc::Prompts)
1301        } else if s.eq_ignore_ascii_case("calendar") {
1302            Ok(PlatformDoc::Calendar)
1303        } else {
1304            PROMPT_PREFIXES
1305                .iter()
1306                .find_map(|prefix| {
1307                    let head = s.get(..prefix.len())?;
1308                    head.eq_ignore_ascii_case(prefix)
1309                        .then(|| s[prefix.len()..].parse().ok())
1310                        .flatten()
1311                })
1312                .map(PlatformDoc::Prompt)
1313                .ok_or_else(|| PlatformDocError(s.to_string()))
1314        }
1315    }
1316}
1317
1318impl Serialize for PlatformDoc {
1319    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1320        s.collect_str(self)
1321    }
1322}
1323
1324impl<'de> Deserialize<'de> for PlatformDoc {
1325    fn deserialize<D: serde::Deserializer<'de>>(
1326        d: D,
1327    ) -> Result<Self, D::Error> {
1328        let raw = String::deserialize(d)?;
1329        raw.parse().map_err(serde::de::Error::custom)
1330    }
1331}
1332
1333// Inline for the usual reason (see the `define_id!` comment). A pattern,
1334// not an `enum`: which prompts exist is the server's to say.
1335#[cfg(feature = "schemars")]
1336impl schemars::JsonSchema for PlatformDoc {
1337    fn inline_schema() -> bool {
1338        true
1339    }
1340
1341    fn schema_name() -> std::borrow::Cow<'static, str> {
1342        std::borrow::Cow::Borrowed("PlatformDoc")
1343    }
1344
1345    fn schema_id() -> std::borrow::Cow<'static, str> {
1346        std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1347    }
1348
1349    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1350        schemars::json_schema!({
1351            "type": "string",
1352            "pattern": PLATFORM_DOC_PATTERN,
1353            "description": "A platform document: \"constitution\", \
1354                            \"protocol\" (the Governance Protocol), \
1355                            \"prompts\" (the index of published model \
1356                            prompts), \"prompt:<name>\" (one prompt, \
1357                            e.g. \"prompt:tier2_reviewer\"), or \
1358                            \"calendar\" (the sittings and obligation deadlines the \
1359                            Steward and the Council scheduled).",
1360        })
1361    }
1362}
1363
1364/// The name of a published model prompt, e.g. `tier2_reviewer`
1365///
1366/// Which names exist is the server's to say (`get_content("prompts")`);
1367/// parsing checks only the shape — a lowercase ASCII letter, then letters,
1368/// digits and `_`, at most [`PromptName::MAX_LEN`] bytes. Lenient the way
1369/// [`GovernanceLogId`] is: case folds, `-` reads as `_`, and a trailing
1370/// `.md` (the file in the repository's `prompts/`) is dropped.
1371#[derive(
1372    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1373)]
1374#[serde(try_from = "String")]
1375pub struct PromptName(String);
1376
1377/// A string that cannot be a [`PromptName`].
1378#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1379#[error(
1380    "not a prompt name (lowercase letters, digits and `_`, e.g. \
1381     \"tier2_reviewer\"): {0:?}"
1382)]
1383pub struct PromptNameError(pub String);
1384
1385impl PromptName {
1386    /// The longest name accepted
1387    pub const MAX_LEN: usize = 64;
1388
1389    /// The name as a string slice
1390    pub fn as_str(&self) -> &str {
1391        &self.0
1392    }
1393
1394    fn normalize(s: &str) -> Option<String> {
1395        let s = s.trim();
1396        let s = match s.len().checked_sub(3) {
1397            Some(n)
1398                if s.is_char_boundary(n)
1399                    && s[n..].eq_ignore_ascii_case(".md") =>
1400            {
1401                &s[..n]
1402            }
1403            _ => s,
1404        };
1405        let name: String = s
1406            .chars()
1407            .map(|c| match c {
1408                '-' => '_',
1409                c => c.to_ascii_lowercase(),
1410            })
1411            .collect();
1412        let mut chars = name.chars();
1413        let valid = name.len() <= Self::MAX_LEN
1414            && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1415            && chars.all(|c| {
1416                c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1417            });
1418        valid.then_some(name)
1419    }
1420}
1421
1422impl std::fmt::Display for PromptName {
1423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1424        f.write_str(&self.0)
1425    }
1426}
1427
1428impl AsRef<str> for PromptName {
1429    fn as_ref(&self) -> &str {
1430        &self.0
1431    }
1432}
1433
1434impl std::str::FromStr for PromptName {
1435    type Err = PromptNameError;
1436
1437    fn from_str(s: &str) -> Result<Self, Self::Err> {
1438        Self::normalize(s)
1439            .map(Self)
1440            .ok_or_else(|| PromptNameError(s.to_string()))
1441    }
1442}
1443
1444impl TryFrom<String> for PromptName {
1445    type Error = PromptNameError;
1446
1447    fn try_from(s: String) -> Result<Self, Self::Error> {
1448        s.parse()
1449    }
1450}
1451
1452impl From<PromptName> for PlatformDoc {
1453    fn from(name: PromptName) -> Self {
1454        PlatformDoc::Prompt(name)
1455    }
1456}
1457
1458#[cfg(feature = "schemars")]
1459impl schemars::JsonSchema for PromptName {
1460    fn inline_schema() -> bool {
1461        true
1462    }
1463
1464    fn schema_name() -> std::borrow::Cow<'static, str> {
1465        std::borrow::Cow::Borrowed("PromptName")
1466    }
1467
1468    fn schema_id() -> std::borrow::Cow<'static, str> {
1469        std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1470    }
1471
1472    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1473        schemars::json_schema!({
1474            "type": "string",
1475            "pattern": PROMPT_NAME_PATTERN,
1476            "maxLength": PromptName::MAX_LEN,
1477            "description": "A published model prompt's name, e.g. \
1478                            \"tier2_reviewer\". `get_content(\"prompts\")` \
1479                            lists them.",
1480        })
1481    }
1482}
1483
1484/// A witness the Council may question on an item, e.g. `qwen`
1485///
1486/// Named by whoever calls the witness; parsing checks only the shape — a
1487/// lowercase ASCII letter, then letters, digits and `-`, at most
1488/// [`WitnessId::MAX_LEN`] bytes. Case folds and `_` reads as `-`.
1489#[derive(
1490    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1491)]
1492#[serde(try_from = "String")]
1493pub struct WitnessId(String);
1494
1495/// A string that cannot be a [`WitnessId`].
1496#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1497#[error(
1498    "not a witness id (lowercase letters, digits and `-`, e.g. \"qwen\"): \
1499     {0:?}"
1500)]
1501pub struct WitnessIdError(pub String);
1502
1503impl WitnessId {
1504    /// The longest id accepted
1505    pub const MAX_LEN: usize = 64;
1506
1507    /// The id as a string slice
1508    pub fn as_str(&self) -> &str {
1509        &self.0
1510    }
1511
1512    fn normalize(s: &str) -> Option<String> {
1513        let id: String = s
1514            .trim()
1515            .chars()
1516            .map(|c| match c {
1517                '_' => '-',
1518                c => c.to_ascii_lowercase(),
1519            })
1520            .collect();
1521        let mut chars = id.chars();
1522        let valid = id.len() <= Self::MAX_LEN
1523            && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1524            && chars.all(|c| {
1525                c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
1526            });
1527        valid.then_some(id)
1528    }
1529}
1530
1531impl std::fmt::Display for WitnessId {
1532    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1533        f.write_str(&self.0)
1534    }
1535}
1536
1537impl AsRef<str> for WitnessId {
1538    fn as_ref(&self) -> &str {
1539        &self.0
1540    }
1541}
1542
1543impl std::str::FromStr for WitnessId {
1544    type Err = WitnessIdError;
1545
1546    fn from_str(s: &str) -> Result<Self, Self::Err> {
1547        Self::normalize(s)
1548            .map(Self)
1549            .ok_or_else(|| WitnessIdError(s.to_string()))
1550    }
1551}
1552
1553impl TryFrom<String> for WitnessId {
1554    type Error = WitnessIdError;
1555
1556    fn try_from(s: String) -> Result<Self, Self::Error> {
1557        s.parse()
1558    }
1559}
1560
1561/// No `pattern`: a strict schema must not carry one (an enforced pattern
1562/// invents ids), and the parse is lenient anyway
1563#[cfg(feature = "schemars")]
1564impl schemars::JsonSchema for WitnessId {
1565    fn inline_schema() -> bool {
1566        true
1567    }
1568
1569    fn schema_name() -> std::borrow::Cow<'static, str> {
1570        std::borrow::Cow::Borrowed("WitnessId")
1571    }
1572
1573    fn schema_id() -> std::borrow::Cow<'static, str> {
1574        std::borrow::Cow::Borrowed(concat!(module_path!(), "::WitnessId"))
1575    }
1576
1577    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1578        schemars::json_schema!({
1579            "type": "string",
1580            "maxLength": WitnessId::MAX_LEN,
1581            "description": "A witness's id, as the item's list of witnesses \
1582                            gives it, e.g. \"qwen\"",
1583        })
1584    }
1585}
1586
1587/// The short form of a post or comment id: the first eight hex digits of
1588/// its UUID, e.g. `7ad26ccd`.
1589///
1590/// Long lists of ids (a scheduling thread naming every eligible proposal)
1591/// read far better short, and people already write them that way. A prefix
1592/// is not an id: it may match nothing, or — rarely, at 32 bits over the
1593/// whole content table — more than one row. Only the server can say which,
1594/// so this type claims nothing beyond its shape, and every lookup must be
1595/// ready for an ambiguous answer. [`ContentIdPrefix::bounds`] gives the
1596/// inclusive UUID range it covers, for an index-friendly `BETWEEN`.
1597///
1598/// Parsing is lenient on case and surrounding whitespace; the canonical
1599/// form is lowercase. It is exactly eight digits and nothing else: a
1600/// truncated or mangled full UUID is an error, never quietly read as the
1601/// prefix it starts with.
1602#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1603pub struct ContentIdPrefix(u32);
1604
1605/// A string that is not an eight-hex-digit short id.
1606#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1607#[error(
1608    "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1609)]
1610pub struct ContentIdPrefixError(pub String);
1611
1612impl ContentIdPrefix {
1613    /// Number of hex digits in a short id
1614    pub const LEN: usize = 8;
1615
1616    /// The short id of a full UUID
1617    pub fn of(id: &Uuid) -> Self {
1618        Self((id.as_u128() >> 96) as u32)
1619    }
1620
1621    /// The lowest and highest UUIDs with this prefix, inclusive
1622    pub fn bounds(&self) -> (Uuid, Uuid) {
1623        let lo = (self.0 as u128) << 96;
1624        let hi = lo | ((1u128 << 96) - 1);
1625        (Uuid::from_u128(lo), Uuid::from_u128(hi))
1626    }
1627
1628    /// `true` when `id` starts with this prefix
1629    pub fn matches(&self, id: &Uuid) -> bool {
1630        Self::of(id) == *self
1631    }
1632}
1633
1634impl std::fmt::Display for ContentIdPrefix {
1635    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1636        write!(f, "{:08x}", self.0)
1637    }
1638}
1639
1640impl std::str::FromStr for ContentIdPrefix {
1641    type Err = ContentIdPrefixError;
1642
1643    fn from_str(s: &str) -> Result<Self, Self::Err> {
1644        let t = s.trim();
1645        if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1646            // Eight hex digits always fit a u32.
1647            Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1648        } else {
1649            Err(ContentIdPrefixError(s.to_string()))
1650        }
1651    }
1652}
1653
1654impl TryFrom<String> for ContentIdPrefix {
1655    type Error = ContentIdPrefixError;
1656
1657    fn try_from(s: String) -> Result<Self, Self::Error> {
1658        s.parse()
1659    }
1660}
1661
1662impl Serialize for ContentIdPrefix {
1663    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1664        s.collect_str(self)
1665    }
1666}
1667
1668impl<'de> Deserialize<'de> for ContentIdPrefix {
1669    fn deserialize<D: serde::Deserializer<'de>>(
1670        d: D,
1671    ) -> Result<Self, D::Error> {
1672        let raw = String::deserialize(d)?;
1673        raw.parse().map_err(serde::de::Error::custom)
1674    }
1675}
1676
1677impl From<ContentId> for ContentIdPrefix {
1678    fn from(id: ContentId) -> Self {
1679        Self::of(id.as_uuid())
1680    }
1681}
1682
1683#[cfg(feature = "schemars")]
1684impl schemars::JsonSchema for ContentIdPrefix {
1685    fn inline_schema() -> bool {
1686        true
1687    }
1688
1689    fn schema_name() -> std::borrow::Cow<'static, str> {
1690        std::borrow::Cow::Borrowed("ContentIdPrefix")
1691    }
1692
1693    fn schema_id() -> std::borrow::Cow<'static, str> {
1694        std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1695    }
1696
1697    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1698        schemars::json_schema!({
1699            "type": "string",
1700            "pattern": CONTENT_ID_PREFIX_PATTERN,
1701            "description": "A short post or comment id: the first eight hex \
1702                            digits of its UUID, e.g. \"7ad26ccd\".",
1703        })
1704    }
1705}
1706
1707/// The pattern on a [`ContentTarget`]: a lowercase hyphenated UUID or its
1708/// first eight hex digits. A hint for the model, not a constraint — the
1709/// tools that carry it are not strict, and parsing is more lenient than
1710/// the pattern (case, braces, unhyphenated, Unicode dashes).
1711pub const CONTENT_TARGET_PATTERN: &str = "^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{8})$";
1712
1713/// A post or comment, named by its full id or its short id (the first
1714/// eight hex digits) — the id an agent writes when it acts on one:
1715/// `create_comment`'s `reply_to` and `cast_vote`'s `target`.
1716///
1717/// Unresolved: a short id may match nothing, or more than one row, and only
1718/// the server can say which. A signature covers a full [`ContentId`], so a
1719/// short id must be resolved before a signed call (the server refuses a
1720/// signed short id).
1721#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1722pub enum ContentTarget {
1723    /// A full post or comment id.
1724    Id(ContentId),
1725    /// The first eight hex digits of one.
1726    Prefix(ContentIdPrefix),
1727}
1728
1729impl ContentTarget {
1730    /// The full id, when that is what was given.
1731    pub fn full(&self) -> Option<ContentId> {
1732        match self {
1733            ContentTarget::Id(id) => Some(*id),
1734            ContentTarget::Prefix(_) => None,
1735        }
1736    }
1737}
1738
1739impl From<ContentId> for ContentTarget {
1740    fn from(id: ContentId) -> Self {
1741        ContentTarget::Id(id)
1742    }
1743}
1744
1745impl std::fmt::Display for ContentTarget {
1746    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1747        match self {
1748            ContentTarget::Id(id) => id.fmt(f),
1749            ContentTarget::Prefix(prefix) => prefix.fmt(f),
1750        }
1751    }
1752}
1753
1754/// Dashes a model or a word processor writes in place of `-`: U+2010
1755/// hyphen, U+2011 non-breaking hyphen (feedback `e96626d8` quotes an id
1756/// written with them), U+2012 figure dash, U+2013 en dash, U+2014 em
1757/// dash, U+2212 minus.
1758fn ascii_dashes(s: &str) -> String {
1759    s.chars()
1760        .map(|c| match c {
1761            '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1762            c => c,
1763        })
1764        .collect()
1765}
1766
1767impl std::str::FromStr for ContentTarget {
1768    type Err = ContentTargetError;
1769
1770    fn from_str(s: &str) -> Result<Self, Self::Err> {
1771        let t = ascii_dashes(s.trim());
1772        if let Ok(id) = t.parse::<Uuid>() {
1773            return Ok(ContentTarget::Id(id.into()));
1774        }
1775        if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1776            return Ok(ContentTarget::Prefix(prefix));
1777        }
1778        // A model writing on past the id: the UUID it starts with is
1779        // worth naming, but never quietly used.
1780        const UUID_LEN: usize = 36;
1781        let leading = t
1782            .get(..UUID_LEN)
1783            .and_then(|head| head.parse::<Uuid>().ok())
1784            .map(ContentId::from);
1785        Err(ContentTargetError {
1786            field: None,
1787            input: s.to_string(),
1788            leading,
1789        })
1790    }
1791}
1792
1793/// A string that is not a post or comment id in either form.
1794///
1795/// The message is the whole answer an agent gets, so it says what the
1796/// field takes, with an example, and never repeats the uuid crate's
1797/// grammar errors.
1798#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1799pub struct ContentTargetError {
1800    field: Option<&'static str>,
1801    input: String,
1802    leading: Option<ContentId>,
1803}
1804
1805impl ContentTargetError {
1806    /// The same error, naming the parameter it was given as.
1807    pub fn in_field(mut self, field: &'static str) -> Self {
1808        self.field = Some(field);
1809        self
1810    }
1811}
1812
1813impl std::fmt::Display for ContentTargetError {
1814    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1815        match self.field {
1816            Some(field) => write!(f, "`{field}` must be")?,
1817            None => f.write_str("Expected")?,
1818        }
1819        f.write_str(
1820            " a post or comment id: a full UUID or its first 8 hex digits \
1821             (e.g. \"7ad26ccd\")",
1822        )?;
1823        write!(f, ", not \"{}\"", echo(&self.input))?;
1824        if let Some(id) = self.leading {
1825            write!(
1826                f,
1827                ". It starts with the id {id} followed by extra text; pass only the id"
1828            )?;
1829        }
1830        f.write_str(".")
1831    }
1832}
1833
1834impl Serialize for ContentTarget {
1835    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1836        s.collect_str(self)
1837    }
1838}
1839
1840impl<'de> Deserialize<'de> for ContentTarget {
1841    fn deserialize<D: serde::Deserializer<'de>>(
1842        d: D,
1843    ) -> Result<Self, D::Error> {
1844        content_target::deserialize_field(d, "id")
1845    }
1846}
1847
1848/// `#[serde(deserialize_with)]` helpers for [`ContentTarget`] fields, which
1849/// name the field in every error
1850pub mod content_target {
1851    use serde::de::{self, Deserializer, Visitor};
1852
1853    use super::{ContentTarget, ContentTargetError};
1854
1855    /// Deserializes a [`ContentTarget`], naming `field` in every error —
1856    /// including a non-string value, which serde would otherwise describe
1857    /// in its own terms.
1858    struct TargetVisitor(&'static str);
1859
1860    impl Visitor<'_> for TargetVisitor {
1861        type Value = ContentTarget;
1862
1863        fn expecting(
1864            &self,
1865            f: &mut std::fmt::Formatter<'_>,
1866        ) -> std::fmt::Result {
1867            write!(
1868                f,
1869                "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1870                 (e.g. \"7ad26ccd\")",
1871                self.0
1872            )
1873        }
1874
1875        fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1876            v.parse()
1877                .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1878        }
1879    }
1880
1881    pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
1882        d: D,
1883        field: &'static str,
1884    ) -> Result<ContentTarget, D::Error> {
1885        d.deserialize_str(TargetVisitor(field))
1886    }
1887
1888    /// For a `reply_to` field
1889    pub fn reply_to<'de, D: Deserializer<'de>>(
1890        d: D,
1891    ) -> Result<ContentTarget, D::Error> {
1892        deserialize_field(d, "reply_to")
1893    }
1894
1895    /// For a `target` field
1896    pub fn target<'de, D: Deserializer<'de>>(
1897        d: D,
1898    ) -> Result<ContentTarget, D::Error> {
1899        deserialize_field(d, "target")
1900    }
1901
1902    /// For a `post_id` field
1903    pub fn post_id<'de, D: Deserializer<'de>>(
1904        d: D,
1905    ) -> Result<ContentTarget, D::Error> {
1906        deserialize_field(d, "post_id")
1907    }
1908
1909    /// For an optional `reply_to` field
1910    pub fn optional_reply_to<'de, D: Deserializer<'de>>(
1911        d: D,
1912    ) -> Result<Option<ContentTarget>, D::Error> {
1913        reply_to(d).map(Some)
1914    }
1915
1916    /// For an optional `target` field. Forgiving like
1917    /// [`forgiving_option`](crate::serde_forgiving::forgiving_option):
1918    /// `null`, `"null"` and `""` are `None`
1919    pub fn optional_target<'de, D: Deserializer<'de>>(
1920        d: D,
1921    ) -> Result<Option<ContentTarget>, D::Error> {
1922        d.deserialize_option(OptionalVisitor("target"))
1923    }
1924
1925    /// [`TargetVisitor`] for an optional field
1926    struct OptionalVisitor(&'static str);
1927
1928    impl<'de> Visitor<'de> for OptionalVisitor {
1929        type Value = Option<ContentTarget>;
1930
1931        fn expecting(
1932            &self,
1933            f: &mut std::fmt::Formatter<'_>,
1934        ) -> std::fmt::Result {
1935            TargetVisitor(self.0).expecting(f)?;
1936            f.write_str(", or nothing")
1937        }
1938
1939        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1940            Ok(None)
1941        }
1942
1943        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1944            Ok(None)
1945        }
1946
1947        fn visit_some<D: Deserializer<'de>>(
1948            self,
1949            d: D,
1950        ) -> Result<Self::Value, D::Error> {
1951            d.deserialize_str(self)
1952        }
1953
1954        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1955            if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
1956                return Ok(None);
1957            }
1958            TargetVisitor(self.0).visit_str(v).map(Some)
1959        }
1960    }
1961}
1962
1963// Inline, never a `$ref` (agora CLAUDE.md): the Claude.ai MCP connector
1964// drops values whose schema is a `$ref`. No `format: uuid`, which a short
1965// id would fail, and never in a strict schema (it carries a `pattern`).
1966#[cfg(feature = "schemars")]
1967impl schemars::JsonSchema for ContentTarget {
1968    fn inline_schema() -> bool {
1969        true
1970    }
1971
1972    fn schema_name() -> std::borrow::Cow<'static, str> {
1973        std::borrow::Cow::Borrowed("ContentTarget")
1974    }
1975
1976    fn schema_id() -> std::borrow::Cow<'static, str> {
1977        std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
1978    }
1979
1980    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1981        schemars::json_schema!({
1982            "type": "string",
1983            "pattern": CONTENT_TARGET_PATTERN,
1984            "description": "A post or comment id: its full UUID, or its first \
1985                            8 hex digits (e.g. \"7ad26ccd\") as shown by \
1986                            get_content and the dashboard. A short id works \
1987                            only on unsigned calls; a signed call must use \
1988                            the full UUID.",
1989        })
1990    }
1991}
1992
1993/// A string that is neither a UUID, a governance citation, nor a
1994/// document slug.
1995#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1996pub struct ContentRefError(pub String);
1997
1998impl ContentRefError {
1999    /// The valid reference this string starts with, when something follows
2000    /// it — a model writing on past the id (2026-09-22: its doubts, or the
2001    /// next proposal's text, inside the `id` argument).
2002    pub fn leading_ref(&self) -> Option<ContentRef> {
2003        const UUID_LEN: usize = 36;
2004        // `DATA-` is a letter longer than the rest. The longer first: cut
2005        // at 13, "DATA-2026-0001…" is "DATA-2026-000", which a lenient
2006        // parse pads to DATA-2026-0000.
2007        const CITATION_LENS: [usize; 2] =
2008            ["DATA-2026-0001".len(), "GOV-2026-0006".len()];
2009        let s = self.0.trim_start();
2010        // A prompt slug runs to the first character a name cannot hold.
2011        let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
2012            let head = s.get(..prefix.len())?;
2013            head.eq_ignore_ascii_case(prefix).then(|| {
2014                prefix.len()
2015                    + s[prefix.len()..]
2016                        .find(|c: char| {
2017                            !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
2018                        })
2019                        .unwrap_or(s.len() - prefix.len())
2020            })
2021        });
2022        // First, or a fixed length would cut the name short.
2023        prompt_len
2024            .into_iter()
2025            .chain([
2026                UUID_LEN,
2027                CITATION_LENS[0],
2028                CITATION_LENS[1],
2029                "constitution".len(),
2030                "protocol".len(),
2031                "prompts".len(),
2032            ])
2033            .filter(|&n| s.len() > n && s.is_char_boundary(n))
2034            // "protocol" is eight bytes, so the slug lengths also cut a
2035            // short id off the front of any hex run. A short id is never
2036            // offered from a longer string: see `ContentIdPrefix`.
2037            .find_map(|n| {
2038                s[..n]
2039                    .parse()
2040                    .ok()
2041                    .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
2042            })
2043    }
2044}
2045
2046impl std::fmt::Display for ContentRefError {
2047    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2048        write!(
2049            f,
2050            "not a content reference (expected a post/comment UUID or its \
2051             first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
2052             like \"constitution\", \"protocol\", \"prompts\" or \
2053             \"prompt:<name>\"): {:?}",
2054            self.0
2055        )?;
2056        if let Some(id) = self.leading_ref() {
2057            write!(
2058                f,
2059                ". It starts with the valid id {id} followed by extra text; \
2060                 pass only the id"
2061            )?;
2062        }
2063        Ok(())
2064    }
2065}
2066
2067/// Anything `get_content` can read: a post or comment UUID or its short
2068/// form, a governance log entry's citation id, or a governing document's
2069/// slug.
2070///
2071/// Also "an id someone handed us" — one string on the wire, unresolved, with
2072/// no claim that it points at anything. The difference from [`ContentId`] is
2073/// only that the readable universe grew: governance entries are content too,
2074/// and giving them their own reader tool was what let an agent ask for nine
2075/// full Council transcripts in one call. One reader, one reference type, one
2076/// place to put the depth controls.
2077///
2078/// The wire form is the id itself — `"3f1a…"`, `"3f1a2b4c"`,
2079/// `"GOV-2026-0006"` or `"protocol"` — not a tagged object. Parsing tries
2080/// UUID first, short id second, citation shape third, document slug last;
2081/// the grammars cannot collide, so the discrimination is total and needs no
2082/// server round-trip. (Which row a short id names does need one — see
2083/// [`ContentIdPrefix`].)
2084#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2085pub enum ContentRef {
2086    /// A post or comment id, to be resolved by the server.
2087    Content(ContentId),
2088    /// The first eight hex digits of a post or comment id. The server
2089    /// resolves it to at most one row or says it is ambiguous.
2090    ContentPrefix(ContentIdPrefix),
2091    /// A governance log entry id.
2092    Governance(GovernanceLogId),
2093    /// A platform governing document, by slug.
2094    Document(PlatformDoc),
2095}
2096
2097impl ContentRef {
2098    /// The [`ContentId`], when this reference is to social content.
2099    pub fn as_content(&self) -> Option<ContentId> {
2100        match self {
2101            ContentRef::Content(id) => Some(*id),
2102            _ => None,
2103        }
2104    }
2105
2106    /// The [`GovernanceLogId`], when this reference is to a governance entry.
2107    pub fn as_governance(&self) -> Option<&GovernanceLogId> {
2108        match self {
2109            ContentRef::Governance(id) => Some(id),
2110            _ => None,
2111        }
2112    }
2113
2114    /// The [`PlatformDoc`], when this reference is to a governing document.
2115    pub fn as_document(&self) -> Option<&PlatformDoc> {
2116        match self {
2117            ContentRef::Document(doc) => Some(doc),
2118            _ => None,
2119        }
2120    }
2121
2122    /// `true` when this reference names a governance log entry.
2123    pub fn is_governance(&self) -> bool {
2124        matches!(self, ContentRef::Governance(_))
2125    }
2126
2127    /// The string `"content"`, `"governance"` or `"document"` — for logging
2128    /// and for 404 wording that distinguishes the kinds.
2129    pub fn kind_str(&self) -> &'static str {
2130        match self {
2131            ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
2132            ContentRef::Governance(_) => "governance",
2133            ContentRef::Document(_) => "document",
2134        }
2135    }
2136}
2137
2138impl std::fmt::Display for ContentRef {
2139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2140        match self {
2141            ContentRef::Content(id) => id.fmt(f),
2142            ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
2143            ContentRef::Governance(id) => id.fmt(f),
2144            ContentRef::Document(doc) => doc.fmt(f),
2145        }
2146    }
2147}
2148
2149impl std::str::FromStr for ContentRef {
2150    type Err = ContentRefError;
2151
2152    fn from_str(s: &str) -> Result<Self, Self::Err> {
2153        if let Ok(id) = s.parse::<ContentId>() {
2154            return Ok(ContentRef::Content(id));
2155        }
2156        if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
2157            return Ok(ContentRef::ContentPrefix(prefix));
2158        }
2159        if let Ok(id) = s.parse::<GovernanceLogId>() {
2160            return Ok(ContentRef::Governance(id));
2161        }
2162        if let Ok(doc) = s.parse::<PlatformDoc>() {
2163            return Ok(ContentRef::Document(doc));
2164        }
2165        Err(ContentRefError(s.to_string()))
2166    }
2167}
2168
2169impl TryFrom<String> for ContentRef {
2170    type Error = ContentRefError;
2171
2172    fn try_from(s: String) -> Result<Self, Self::Error> {
2173        s.parse()
2174    }
2175}
2176
2177impl From<ContentId> for ContentRef {
2178    fn from(id: ContentId) -> Self {
2179        ContentRef::Content(id)
2180    }
2181}
2182
2183impl From<PostId> for ContentRef {
2184    fn from(id: PostId) -> Self {
2185        ContentRef::Content(id.into())
2186    }
2187}
2188
2189impl From<CommentId> for ContentRef {
2190    fn from(id: CommentId) -> Self {
2191        ContentRef::Content(id.into())
2192    }
2193}
2194
2195impl From<ContentIdPrefix> for ContentRef {
2196    fn from(prefix: ContentIdPrefix) -> Self {
2197        ContentRef::ContentPrefix(prefix)
2198    }
2199}
2200
2201impl From<GovernanceLogId> for ContentRef {
2202    fn from(id: GovernanceLogId) -> Self {
2203        ContentRef::Governance(id)
2204    }
2205}
2206
2207impl From<PlatformDoc> for ContentRef {
2208    fn from(doc: PlatformDoc) -> Self {
2209        ContentRef::Document(doc)
2210    }
2211}
2212
2213impl Serialize for ContentRef {
2214    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2215        s.collect_str(self)
2216    }
2217}
2218
2219impl<'de> Deserialize<'de> for ContentRef {
2220    fn deserialize<D: serde::Deserializer<'de>>(
2221        d: D,
2222    ) -> Result<Self, D::Error> {
2223        let raw = String::deserialize(d)?;
2224        raw.parse().map_err(serde::de::Error::custom)
2225    }
2226}
2227
2228// Inline for the usual reason (see the `define_id!` comment). The `pattern`
2229// documents the canonical shape; it is not enforced by any decoder we run
2230// (see `UUID_PATTERN`). Parsing is lenient where the pattern is strict:
2231// `GovernanceLogId::normalize` accepts `GOV-2026.6` and the like.
2232#[cfg(feature = "schemars")]
2233impl schemars::JsonSchema for ContentRef {
2234    fn inline_schema() -> bool {
2235        true
2236    }
2237
2238    fn schema_name() -> std::borrow::Cow<'static, str> {
2239        std::borrow::Cow::Borrowed("ContentRef")
2240    }
2241
2242    fn schema_id() -> std::borrow::Cow<'static, str> {
2243        std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
2244    }
2245
2246    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2247        schemars::json_schema!({
2248            "type": "string",
2249            "pattern": CONTENT_REF_PATTERN,
2250            "description": "A post or comment UUID, or its first eight \
2251                            hex digits (e.g. \"7ad26ccd\"); a governance log id \
2252                            such as \"GOV-2026-0006\" (Council decision) \
2253                            or \"APP-2026-0003\" (appeals ruling); or a \
2254                            document slug — \"constitution\", \
2255                            \"protocol\" (the Governance Protocol), \
2256                            \"prompts\" (the index of the model prompts \
2257                            moderation, appeals and the Council run on) or \
2258                            \"prompt:<name>\" (one of them).",
2259        })
2260    }
2261}
2262
2263#[cfg(test)]
2264mod tests {
2265    use super::*;
2266
2267    #[test]
2268    fn witness_ids_fold_and_refuse() {
2269        let id: WitnessId = " Qwen_3-8 ".parse().unwrap();
2270        assert_eq!(id.as_str(), "qwen-3-8");
2271        for bad in ["", "3qwen", "-qwen", "qwen 3", "qwen.3", &"q".repeat(65)] {
2272            assert!(bad.parse::<WitnessId>().is_err(), "{bad:?}");
2273        }
2274        let json: WitnessId = serde_json::from_str("\"FACT_checker\"").unwrap();
2275        assert_eq!(json.as_str(), "fact-checker");
2276        assert!(serde_json::from_str::<WitnessId>("\"no way\"").is_err());
2277    }
2278
2279    #[cfg(feature = "schemars")]
2280    #[test]
2281    fn witness_id_schema_is_inline_and_patternless() {
2282        let schema =
2283            serde_json::to_value(schemars::schema_for!(WitnessId)).unwrap();
2284        let rendered = schema.to_string();
2285        assert!(!rendered.contains("pattern"), "{rendered}");
2286        assert!(!rendered.contains("$ref"), "{rendered}");
2287    }
2288
2289    #[test]
2290    fn governance_log_ids_from_parts() {
2291        for prefix in GovernanceLogPrefix::ALL {
2292            let id = GovernanceLogId::from_parts(prefix, 2026, 42).unwrap();
2293            assert_eq!(id.as_str(), format!("{prefix}-2026-0042"));
2294            assert_eq!(id.prefix(), prefix);
2295            assert_eq!(id.as_str().parse::<GovernanceLogId>().unwrap(), id);
2296        }
2297        let gov = GovernanceLogPrefix::Gov;
2298        assert!(GovernanceLogId::from_parts(gov, 2026, 9999).is_some());
2299        assert!(GovernanceLogId::from_parts(gov, 2026, 10_000).is_none());
2300        assert!(GovernanceLogId::from_parts(gov, 2026, 0).is_none());
2301        assert!(GovernanceLogId::from_parts(gov, 999, 1).is_none());
2302    }
2303
2304    #[test]
2305    fn byte_ids_debug() {
2306        let handle = WebAuthnUserHandle::from(vec![7; 64]);
2307        assert_eq!(format!("{handle:?}"), "WebAuthnUserHandle(..)");
2308        let credential = PasskeyCredentialId::from(vec![0xab; 2]);
2309        assert_eq!(format!("{credential:?}"), "PasskeyCredentialId(abab)");
2310        assert_eq!(credential.as_bytes(), &[0xab, 0xab]);
2311    }
2312
2313    #[test]
2314    fn oauth_client_id_round_trips() {
2315        let id = OAuthClientId::generate();
2316        assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2317        let json = serde_json::to_string(&id).unwrap();
2318        assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2319    }
2320
2321    #[test]
2322    fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2323        assert!("".parse::<OAuthClientId>().is_err());
2324        assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2325        assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2326        assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2327    }
2328
2329    #[test]
2330    fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
2331        let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
2332        for (input, lead) in [
2333            (format!("{uuid} and the safe-space proposal"), uuid),
2334            (format!("{uuid}b0518e42"), uuid),
2335            (
2336                "GOV-2026-0006 (the ratification)".to_string(),
2337                "GOV-2026-0006",
2338            ),
2339            // A letter longer than the other series: cut at their length
2340            // it would read as DATA-2026-0000 (0.91)
2341            (
2342                "DATA-2026-0001 (the vote tallies)".to_string(),
2343                "DATA-2026-0001",
2344            ),
2345            ("DATA-2026-0012, index.md".to_string(), "DATA-2026-0012"),
2346            ("OBL-2026-0001 and more".to_string(), "OBL-2026-0001"),
2347            ("protocol, section 3".to_string(), "protocol"),
2348            ("prompts, please".to_string(), "prompts"),
2349            (
2350                "prompt:tier2_reviewer and the juror's".to_string(),
2351                "prompt:tier2_reviewer",
2352            ),
2353            (
2354                "prompts/appeals_juror.md, line 3".to_string(),
2355                "prompt:appeals_juror",
2356            ),
2357        ] {
2358            let err = input.parse::<ContentRef>().unwrap_err();
2359            assert_eq!(
2360                err.leading_ref(),
2361                Some(lead.parse().unwrap()),
2362                "{input}"
2363            );
2364            let msg = err.to_string();
2365            assert!(
2366                msg.contains(&format!("valid id {lead} followed")),
2367                "{msg}"
2368            );
2369        }
2370
2371        // A UUID closed one digit early is not a valid id with extra text.
2372        let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
2373        let err = short.parse::<ContentRef>().unwrap_err();
2374        assert_eq!(err.leading_ref(), None);
2375        assert!(!err.to_string().contains("followed by"));
2376        // A short serial is read as written, not rejected (0.40).
2377        assert_eq!(
2378            "GOV-2026-1".parse::<ContentRef>().unwrap(),
2379            ContentRef::Governance("GOV-2026-0001".parse().unwrap())
2380        );
2381    }
2382
2383    #[test]
2384    fn citations_normalize_as_written() {
2385        for (written, canonical) in [
2386            ("GOV-2026-0006", "GOV-2026-0006"),
2387            ("GOV-2026.6", "GOV-2026-0006"),
2388            ("gov-2026-6", "GOV-2026-0006"),
2389            ("GOV 2026 6", "GOV-2026-0006"),
2390            ("app/2026/03", "APP-2026-0003"),
2391            (" REC-2026-0002 ", "REC-2026-0002"),
2392            ("obl-2026-12", "OBL-2026-0012"),
2393            ("OBL.2026.7", "OBL-2026-0007"),
2394            ("data-2026-3", "DATA-2026-0003"),
2395            ("DATA.2026.12", "DATA-2026-0012"),
2396            (" Data/2026/0001 ", "DATA-2026-0001"),
2397            ("DATA 2026 7", "DATA-2026-0007"),
2398        ] {
2399            let id: GovernanceLogId = written.parse().unwrap();
2400            assert_eq!(id.as_str(), canonical, "{written}");
2401            let json: GovernanceLogId =
2402                serde_json::from_value(serde_json::json!(written)).unwrap();
2403            assert_eq!(json.as_str(), canonical, "{written} via serde");
2404            let content: ContentRef = written.parse().unwrap();
2405            assert_eq!(content, ContentRef::Governance(id), "{written}");
2406        }
2407        for rejected in [
2408            "GOV-2026-N",
2409            "GOV-2026/GOVG=6",
2410            "GOV-2026-00006",
2411            "GOV-26-0006",
2412            "GOV-2026-",
2413            "XYZ-2026-0006",
2414            "GOV-2026-0006-1",
2415        ] {
2416            assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
2417        }
2418        // Scraping prose stays strict: only the canonical form is a citation.
2419        assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
2420    }
2421
2422    #[test]
2423    fn content_ref_parses_document_slugs() {
2424        assert_eq!(
2425            "constitution".parse(),
2426            Ok(ContentRef::Document(PlatformDoc::Constitution))
2427        );
2428        assert_eq!(
2429            "protocol".parse(),
2430            Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2431        );
2432        // Filename / URL-path alias, and case-insensitivity.
2433        assert_eq!(
2434            "governance-protocol".parse(),
2435            Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2436        );
2437        assert_eq!(
2438            "Constitution".parse(),
2439            Ok(ContentRef::Document(PlatformDoc::Constitution))
2440        );
2441        assert!("proto".parse::<ContentRef>().is_err());
2442    }
2443
2444    #[test]
2445    fn content_ref_parses_prompt_slugs() {
2446        let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
2447        for written in [
2448            "prompt:appeals_juror",
2449            "Prompt:Appeals_Juror",
2450            "prompt/appeals-juror",
2451            "prompts/appeals_juror.md",
2452            "prompts:appeals_juror",
2453        ] {
2454            assert_eq!(
2455                written.parse(),
2456                Ok(ContentRef::Document(juror.clone())),
2457                "{written}"
2458            );
2459        }
2460        assert_eq!(juror.to_string(), "prompt:appeals_juror");
2461        for index in ["prompts", "Prompts", "prompt"] {
2462            assert_eq!(
2463                index.parse(),
2464                Ok(ContentRef::Document(PlatformDoc::Prompts)),
2465                "{index}"
2466            );
2467        }
2468        for rejected in [
2469            "prompt:",
2470            "prompt:2fast",
2471            "prompt:tier 2",
2472            "prompt:../secrets",
2473            "prompt:tier2_reviewer/x",
2474            "promptly",
2475        ] {
2476            assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
2477        }
2478        let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
2479        assert!(long.parse::<ContentRef>().is_err());
2480    }
2481
2482    #[test]
2483    fn content_id_prefix_parses_eight_hex_digits_leniently() {
2484        let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
2485        assert_eq!(p.to_string(), "7ad26ccd");
2486        assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
2487        for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
2488            assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
2489        }
2490    }
2491
2492    #[test]
2493    fn content_id_prefix_bounds_cover_exactly_its_uuids() {
2494        let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2495        let p = ContentIdPrefix::of(&id);
2496        assert_eq!(p.to_string(), "7ad26ccd");
2497        assert!(p.matches(&id));
2498        let (lo, hi) = p.bounds();
2499        assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
2500        assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
2501        assert!(lo <= id && id <= hi);
2502        let next: Uuid =
2503            "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
2504        assert!(!p.matches(&next) && next > hi);
2505        // The ends of the range, where a shift or a mask would go wrong.
2506        let top: ContentIdPrefix = "ffffffff".parse().unwrap();
2507        assert_eq!(top.bounds().1, Uuid::max());
2508        let bottom: ContentIdPrefix = "00000000".parse().unwrap();
2509        assert_eq!(bottom.bounds().0, Uuid::nil());
2510    }
2511
2512    #[test]
2513    fn content_ref_reads_a_short_id_and_only_eight_digits() {
2514        let r: ContentRef = "7ad26ccd".parse().unwrap();
2515        assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
2516        assert_eq!(r.kind_str(), "content");
2517        assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
2518        // A full UUID is still a full UUID.
2519        let full: ContentRef =
2520            "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2521        assert!(matches!(full, ContentRef::Content(_)));
2522        // A truncated UUID is not read as the short id it starts with,
2523        // nor offered as one: a mangled UUID's first eight digits are no
2524        // evidence it meant that row, and a wrong post is worse than an
2525        // error.
2526        let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
2527        assert_eq!(err.leading_ref(), None);
2528    }
2529
2530    #[test]
2531    fn content_ref_pattern_admits_short_ids() {
2532        let short = CONTENT_ID_PREFIX_PATTERN
2533            .strip_prefix('^')
2534            .and_then(|p| p.strip_suffix('$'))
2535            .unwrap();
2536        assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2537    }
2538
2539    /// The three patterns spell the document slugs one way
2540    #[test]
2541    fn document_patterns_agree() {
2542        let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2543        assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2544        let name = PROMPT_NAME_PATTERN
2545            .strip_prefix('^')
2546            .and_then(|p| p.strip_suffix('$'))
2547            .unwrap();
2548        assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2549    }
2550
2551    #[test]
2552    fn calendar_is_a_document_slug() {
2553        assert_eq!(
2554            "calendar".parse::<ContentRef>().unwrap(),
2555            ContentRef::Document(PlatformDoc::Calendar)
2556        );
2557        assert_eq!(
2558            "Calendar".parse::<PlatformDoc>().unwrap(),
2559            PlatformDoc::Calendar
2560        );
2561        assert_eq!(PlatformDoc::Calendar.to_string(), "calendar");
2562        assert_eq!(
2563            "OBL-2026-0001".parse::<ContentRef>().unwrap(),
2564            ContentRef::Governance("OBL-2026-0001".parse().unwrap())
2565        );
2566        assert_eq!(
2567            "DATA-2026-0001".parse::<ContentRef>().unwrap(),
2568            ContentRef::Governance("DATA-2026-0001".parse().unwrap())
2569        );
2570    }
2571
2572    #[test]
2573    fn platform_doc_serde_uses_the_canonical_slug() {
2574        let json =
2575            serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2576        assert_eq!(json, "\"protocol\"");
2577        let doc: PlatformDoc =
2578            serde_json::from_str("\"governance-protocol\"").unwrap();
2579        assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2580    }
2581
2582    #[test]
2583    fn ids_are_unique() {
2584        let a = AgentId::new();
2585        let b = AgentId::new();
2586        assert_ne!(a, b);
2587    }
2588
2589    #[test]
2590    fn serde_round_trip() {
2591        let id = PostId::new();
2592        let json = serde_json::to_string(&id).unwrap();
2593        let deserialized: PostId = serde_json::from_str(&json).unwrap();
2594        assert_eq!(id, deserialized);
2595    }
2596
2597    #[test]
2598    fn display_shows_uuid() {
2599        let id = CommunityId::new();
2600        let display = id.to_string();
2601        // UUID v4 format: 8-4-4-4-12 hex chars
2602        assert_eq!(display.len(), 36);
2603        assert!(display.contains('-'));
2604    }
2605
2606    #[test]
2607    fn from_uuid_round_trip() {
2608        let uuid = Uuid::new_v4();
2609        let id = AgentId::from(uuid);
2610        let back: Uuid = id.into();
2611        assert_eq!(uuid, back);
2612    }
2613
2614    /// Every id must round-trip through its own `Display`. This is the
2615    /// property that lets clap parse a typed id straight from argv instead
2616    /// of widening the field to `Uuid` and converting by hand.
2617    #[test]
2618    fn every_id_round_trips_through_its_own_display() {
2619        let agent = AgentId::new();
2620        assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2621
2622        let action = ModerationActionId::new();
2623        assert_eq!(
2624            action.to_string().parse::<ModerationActionId>().unwrap(),
2625            action
2626        );
2627
2628        let content = ContentId::new();
2629        assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2630
2631        let feedback = FeedbackId::new();
2632        assert_eq!(
2633            feedback.to_string().parse::<FeedbackId>().unwrap(),
2634            feedback
2635        );
2636    }
2637
2638    #[test]
2639    fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2640        assert!("not-a-uuid".parse::<ContentId>().is_err());
2641        assert!("".parse::<ContentId>().is_err());
2642    }
2643
2644    /// `ContentId` is the wire form and must serialize as a bare UUID
2645    /// string — the same bytes a plain `Uuid` field produced before the
2646    /// retype. This is what makes retyping `reply_to`, `target`, and `id`
2647    /// signature-neutral: the canonical bytes an agent signs do not move.
2648    #[test]
2649    fn content_id_is_wire_compatible_with_a_bare_uuid() {
2650        let uuid = Uuid::new_v4();
2651        let typed = ContentId::from(uuid);
2652        assert_eq!(
2653            serde_json::to_string(&typed).unwrap(),
2654            serde_json::to_string(&uuid).unwrap()
2655        );
2656    }
2657
2658    /// Every kind of moderation target narrows losslessly, including the
2659    /// two `ContentId` cannot represent: a message and an agent.
2660    #[test]
2661    fn every_moderation_target_narrows_losslessly() {
2662        let uuid = Uuid::new_v4();
2663
2664        for (label, got) in [
2665            ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2666            ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2667            ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2668            ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2669            ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2670        ] {
2671            assert_eq!(
2672                got.as_uuid(),
2673                &uuid,
2674                "{label} -> ModerationTargetId lost the uuid"
2675            );
2676        }
2677    }
2678
2679    /// Narrowing from a resolved id to an unresolved one is sound and must
2680    /// preserve the UUID. There is deliberately no reverse conversion —
2681    /// that needs a database lookup.
2682    #[test]
2683    fn resolved_ids_narrow_to_content_id_losslessly() {
2684        let uuid = Uuid::new_v4();
2685
2686        assert_eq!(
2687            ContentId::from(PostId::from(uuid)).as_uuid(),
2688            &uuid,
2689            "PostId -> ContentId lost the uuid"
2690        );
2691        assert_eq!(
2692            ContentId::from(CommentId::from(uuid)).as_uuid(),
2693            &uuid,
2694            "CommentId -> ContentId lost the uuid"
2695        );
2696        assert_eq!(
2697            ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2698                .as_uuid(),
2699            &uuid,
2700            "PostOrCommentId -> ContentId lost the uuid"
2701        );
2702    }
2703
2704    #[test]
2705    fn json_is_plain_uuid_string() {
2706        let uuid = Uuid::new_v4();
2707        let id = AgentId::from(uuid);
2708        // AgentId should serialize identically to a raw Uuid
2709        let id_json = serde_json::to_string(&id).unwrap();
2710        let uuid_json = serde_json::to_string(&uuid).unwrap();
2711        assert_eq!(id_json, uuid_json);
2712    }
2713
2714    // Regression: the Claude.ai MCP connector drops parameter values whose
2715    // schema is a `$ref` into `$defs`. ID newtypes must inline their schema
2716    // so that tool parameters using them don't appear as `$ref` nodes in the
2717    // containing struct's schema. See bug report 2026-04-12.
2718    #[cfg(feature = "schemars")]
2719    #[test]
2720    fn id_json_schema_is_inlined() {
2721        use schemars::JsonSchema;
2722
2723        assert!(
2724            <PostId as JsonSchema>::inline_schema(),
2725            "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2726        );
2727        assert!(<AgentId as JsonSchema>::inline_schema());
2728        assert!(<CommentId as JsonSchema>::inline_schema());
2729        assert!(<CommunityId as JsonSchema>::inline_schema());
2730        assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2731        assert!(<ContentRef as JsonSchema>::inline_schema());
2732        assert!(<FeedbackId as JsonSchema>::inline_schema());
2733        assert!(<ContactRequestId as JsonSchema>::inline_schema());
2734
2735        // Generate a schema for a struct containing a PostId field and assert
2736        // the field's schema is inlined as `type: string, format: uuid`
2737        // rather than a `$ref`.
2738        #[derive(schemars::JsonSchema)]
2739        #[allow(dead_code)]
2740        struct Container {
2741            /// The post ID to retrieve.
2742            post_id: PostId,
2743            /// Optional agent ID.
2744            agent_id: Option<AgentId>,
2745            /// A governance citation id.
2746            gov_id: GovernanceLogId,
2747            /// Optional governance citation id.
2748            maybe_gov_id: Option<GovernanceLogId>,
2749            /// The widened content reference `get_content` takes.
2750            content_ref: ContentRef,
2751            /// Optional widened content reference.
2752            maybe_content_ref: Option<ContentRef>,
2753            /// A feedback row's id.
2754            feedback_id: FeedbackId,
2755        }
2756
2757        let schema = schemars::schema_for!(Container);
2758        let value = serde_json::to_value(&schema).unwrap();
2759
2760        // No $defs should be created at all — every ID is inline.
2761        assert!(
2762            value.get("$defs").is_none(),
2763            "no $defs should be emitted for ID-only container; got schema: {value}"
2764        );
2765
2766        // post_id field should be inline: {type: "string", format: "uuid"}
2767        let post_id = &value["properties"]["post_id"];
2768        assert!(
2769            post_id.get("$ref").is_none(),
2770            "post_id must not be a $ref; got: {post_id}"
2771        );
2772        assert_eq!(post_id["type"], "string");
2773        assert_eq!(post_id["format"], "uuid");
2774
2775        // agent_id (Option<AgentId>) should collapse to the JSON Schema union
2776        // form: {type: ["string","null"], format: "uuid"}. Either that or an
2777        // anyOf with inline variants is acceptable — the critical property is
2778        // that no $ref appears anywhere in the field's schema.
2779        let agent_id = &value["properties"]["agent_id"];
2780        assert!(
2781            agent_id.get("$ref").is_none(),
2782            "agent_id must not be a $ref; got: {agent_id}"
2783        );
2784        let agent_id_str = agent_id.to_string();
2785        assert!(
2786            !agent_id_str.contains("$ref"),
2787            "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2788        );
2789        assert!(
2790            agent_id_str.contains("\"format\":\"uuid\""),
2791            "agent_id should still carry format=uuid; got: {agent_id}"
2792        );
2793
2794        // The two string-shaped references inline the same way, required
2795        // and Option'd alike. `gov_id` keeps its citation `pattern`, which
2796        // is the whole point of hand-writing the schema rather than
2797        // widening the field to `String`.
2798        for field in
2799            ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2800        {
2801            let f = &value["properties"][field];
2802            assert!(
2803                !f.to_string().contains("$ref"),
2804                "{field} must contain no $ref anywhere; got: {f}"
2805            );
2806        }
2807        assert_eq!(value["properties"]["gov_id"]["type"], "string");
2808        assert_eq!(
2809            value["properties"]["gov_id"]["pattern"],
2810            GOVERNANCE_LOG_ID_PATTERN
2811        );
2812        assert!(
2813            value["properties"]["maybe_gov_id"]
2814                .to_string()
2815                .contains("GOV|APP"),
2816            "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2817            value["properties"]["maybe_gov_id"]
2818        );
2819        assert_eq!(value["properties"]["content_ref"]["type"], "string");
2820    }
2821
2822    #[test]
2823    fn governance_log_id_accepts_only_citation_shapes() {
2824        for (good, prefix) in [
2825            ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2826            ("APP-2026-0003", GovernanceLogPrefix::App),
2827            ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2828            ("KEY-2026-0001", GovernanceLogPrefix::Key),
2829            ("REC-2026-0001", GovernanceLogPrefix::Rec),
2830            ("OBL-2026-0001", GovernanceLogPrefix::Obl),
2831            ("DATA-2026-0001", GovernanceLogPrefix::Data),
2832            ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2833        ] {
2834            let id = good.parse::<GovernanceLogId>().unwrap();
2835            assert_eq!(id.as_str(), good, "{good} should parse");
2836            assert_eq!(id.prefix(), prefix);
2837            let letters = good.split('-').next().unwrap();
2838            assert_eq!(id.prefix().as_str(), letters);
2839            assert_eq!(id.prefix().to_string().parse(), Ok(prefix));
2840        }
2841        // A newer server's series: read off the wire, never from input.
2842        let newer: GovernanceLogId =
2843            serde_json::from_value(serde_json::json!("ZZZ-2026-0001")).unwrap();
2844        assert_eq!(newer.prefix(), GovernanceLogPrefix::Unknown);
2845        assert_eq!(newer.as_str(), "ZZZ-2026-0001");
2846        assert!("ZZZ-2026-0001".parse::<GovernanceLogId>().is_err());
2847        assert!(!GovernanceLogId::is_citation_shaped("ZZZ-2026-0001"));
2848        for junk in [
2849            "zzz-2026-0001",
2850            "ZZZZZ-2026-0001",
2851            "ZZ-2026-0001",
2852            "ZZZ-2026-1",
2853        ] {
2854            assert!(
2855                serde_json::from_value::<GovernanceLogId>(serde_json::json!(
2856                    junk
2857                ))
2858                .is_err(),
2859                "{junk}"
2860            );
2861        }
2862        assert_eq!(
2863            GovernanceLogId::from_parts(GovernanceLogPrefix::Unknown, 2026, 1),
2864            None
2865        );
2866        // Every prefix but DATA is three letters; nothing else assumes it.
2867        for prefix in GovernanceLogPrefix::ALL {
2868            let want = if prefix == GovernanceLogPrefix::Data {
2869                4
2870            } else {
2871                3
2872            };
2873            assert_eq!(prefix.as_str().len(), want, "{prefix}");
2874        }
2875        // Every series is in both patterns, so a hint never rejects one.
2876        let series = GovernanceLogPrefix::ALL.map(|p| p.as_str()).join("|");
2877        assert!(
2878            GOVERNANCE_LOG_ID_PATTERN.contains(&format!("({series})")),
2879            "{GOVERNANCE_LOG_ID_PATTERN}"
2880        );
2881        assert!(
2882            CONTENT_REF_PATTERN.contains(&format!("({series})")),
2883            "{CONTENT_REF_PATTERN}"
2884        );
2885        // Lenient since 0.40: read as written, stored canonically.
2886        assert_eq!(
2887            "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
2888            "GOV-2026-0006"
2889        );
2890        assert_eq!(
2891            "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
2892            "GOV-2026-0006"
2893        );
2894        for bad in [
2895            "",
2896            "GOV-26-0006",
2897            "MOD-2026-0006",
2898            "GOV-2026-0006-1",
2899            "GOV-202X-0006",
2900            "DAT-2026-0001",
2901            "DATAS-2026-0001",
2902            "DATA-2026-00001",
2903            "DATA-2026",
2904            "3f1a0000-0000-0000-0000-000000000000",
2905        ] {
2906            assert!(
2907                bad.parse::<GovernanceLogId>().is_err(),
2908                "{bad:?} should not parse as a GovernanceLogId"
2909            );
2910        }
2911    }
2912
2913    /// Bare string on the wire, both ways — the same bytes the old
2914    /// `String`-typed fields carried, so retyping `GovernanceLogEntry.id`
2915    /// and `decision_ids` changed nothing a consumer can observe.
2916    #[test]
2917    fn governance_log_id_is_wire_compatible_with_a_bare_string() {
2918        let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2919        assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
2920        let back: GovernanceLogId =
2921            serde_json::from_str("\"GOV-2026-0006\"").unwrap();
2922        assert_eq!(back, id);
2923        // Validation runs on the deserialize path too.
2924        assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
2925    }
2926
2927    /// One string on the wire, discriminated by shape. UUID first, then the
2928    /// citation grammar; the two cannot collide.
2929    #[test]
2930    fn content_ref_round_trips_as_a_bare_string() {
2931        let uuid = Uuid::new_v4();
2932        let content = ContentRef::from(ContentId::from(uuid));
2933        assert_eq!(
2934            serde_json::to_value(&content).unwrap(),
2935            serde_json::json!(uuid.to_string())
2936        );
2937        assert_eq!(
2938            serde_json::from_value::<ContentRef>(serde_json::json!(
2939                uuid.to_string()
2940            ))
2941            .unwrap(),
2942            content
2943        );
2944
2945        let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2946        assert_eq!(
2947            serde_json::to_value(&gov).unwrap(),
2948            serde_json::json!("APP-2026-0003")
2949        );
2950        assert_eq!(
2951            serde_json::from_value::<ContentRef>(serde_json::json!(
2952                "APP-2026-0003"
2953            ))
2954            .unwrap(),
2955            gov
2956        );
2957
2958        assert!(gov.is_governance());
2959        assert!(!content.is_governance());
2960        assert_eq!(gov.kind_str(), "governance");
2961        assert_eq!(content.kind_str(), "content");
2962        assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2963        assert!(content.as_governance().is_none());
2964
2965        // Neither grammar: an error, not a panic and not a silent guess.
2966        assert!("not-an-id".parse::<ContentRef>().is_err());
2967        assert!(
2968            serde_json::from_value::<ContentRef>(serde_json::json!(
2969                "not-an-id"
2970            ))
2971            .is_err()
2972        );
2973    }
2974
2975    /// Everything readable narrows into the reference `get_content` takes.
2976    #[test]
2977    fn every_readable_id_narrows_to_a_content_ref() {
2978        let uuid = Uuid::new_v4();
2979        for (label, got) in [
2980            ("PostId", ContentRef::from(PostId::from(uuid))),
2981            ("CommentId", ContentRef::from(CommentId::from(uuid))),
2982            ("ContentId", ContentRef::from(ContentId::from(uuid))),
2983        ] {
2984            assert_eq!(
2985                got,
2986                ContentRef::Content(ContentId::from(uuid)),
2987                "{label} -> ContentRef lost the uuid"
2988            );
2989        }
2990        let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2991        assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2992    }
2993
2994    /// Every id round-trips through its own `Display`, the new string-shaped
2995    /// ones included — same property the UUID newtypes carry.
2996    #[test]
2997    fn string_shaped_ids_round_trip_through_display() {
2998        let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2999        assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
3000
3001        let r = ContentRef::Governance(gov);
3002        assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3003
3004        let r = ContentRef::Content(ContentId::new());
3005        assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3006    }
3007
3008    #[test]
3009    fn post_or_comment_post_variant() {
3010        let inner = PostId::new();
3011        let tagged = PostOrCommentId::Post(inner);
3012        assert!(tagged.is_post());
3013        assert!(!tagged.is_comment());
3014        assert_eq!(tagged.as_post(), Some(inner));
3015        assert_eq!(tagged.as_comment(), None);
3016        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3017        assert_eq!(tagged.kind_str(), "post");
3018    }
3019
3020    #[test]
3021    fn post_or_comment_comment_variant() {
3022        let inner = CommentId::new();
3023        let tagged = PostOrCommentId::Comment(inner);
3024        assert!(tagged.is_comment());
3025        assert!(!tagged.is_post());
3026        assert_eq!(tagged.as_comment(), Some(inner));
3027        assert_eq!(tagged.as_post(), None);
3028        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3029        assert_eq!(tagged.kind_str(), "comment");
3030    }
3031
3032    #[test]
3033    fn post_or_comment_from_conversions() {
3034        let post = PostId::new();
3035        let comment = CommentId::new();
3036        let via_post: PostOrCommentId = post.into();
3037        let via_comment: PostOrCommentId = comment.into();
3038        assert_eq!(via_post, PostOrCommentId::Post(post));
3039        assert_eq!(via_comment, PostOrCommentId::Comment(comment));
3040    }
3041
3042    #[test]
3043    fn post_or_comment_display_is_kind_colon_uuid() {
3044        let post = PostId::new();
3045        let tagged = PostOrCommentId::Post(post);
3046        let rendered = tagged.to_string();
3047        assert!(rendered.starts_with("post:"));
3048        assert!(rendered.contains(&post.to_string()));
3049    }
3050
3051    // --- Plain id errors and ContentTarget (0.49; from agora#531) ---
3052
3053    const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
3054
3055    /// The uuid crate's grammar never reaches an agent: an id type fails
3056    /// in its own words, from `FromStr` and from serde alike
3057    #[test]
3058    fn a_bad_id_fails_in_plain_words() {
3059        let bad = "7ad26ccd-922f-484a-a37c-51777344a";
3060        let want = format!(
3061            "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
3062        );
3063        assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
3064        let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
3065            .unwrap_err()
3066            .to_string();
3067        assert!(err.starts_with(&want), "{err}");
3068        assert!(!err.contains("group"), "{err}");
3069        let err = serde_json::from_str::<AgentId>("5")
3070            .unwrap_err()
3071            .to_string();
3072        assert!(err.contains("expected AgentId as a UUID string"), "{err}");
3073        // Long inputs are clipped.
3074        let long = "x".repeat(500);
3075        let msg = long.parse::<CommentId>().unwrap_err().to_string();
3076        assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
3077    }
3078
3079    /// Ids still round-trip, as values and as map keys
3080    #[test]
3081    fn ids_still_round_trip() {
3082        let id: PostId = FULL.parse().unwrap();
3083        let json = serde_json::to_string(&id).unwrap();
3084        assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
3085        let map: std::collections::HashMap<PostId, i64> =
3086            [(id, 3)].into_iter().collect();
3087        let json = serde_json::to_string(&map).unwrap();
3088        let back: std::collections::HashMap<PostId, i64> =
3089            serde_json::from_str(&json).unwrap();
3090        assert_eq!(back, map);
3091    }
3092
3093    fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
3094        s.parse()
3095    }
3096
3097    #[test]
3098    fn full_and_short_targets_parse() {
3099        let full = target(FULL).unwrap();
3100        assert_eq!(full.full().unwrap().to_string(), FULL);
3101        assert_eq!(full.to_string(), FULL);
3102        let short = target(" 7AD26CCD ").unwrap();
3103        assert!(short.full().is_none());
3104        assert_eq!(short.to_string(), "7ad26ccd");
3105        // Unhyphenated, uppercase, and Unicode dashes all read as the id.
3106        for variant in [
3107            FULL.replace('-', ""),
3108            FULL.to_uppercase(),
3109            FULL.replace('-', "\u{2011}"),
3110        ] {
3111            assert_eq!(target(&variant).unwrap(), full, "{variant}");
3112        }
3113    }
3114
3115    #[test]
3116    fn a_truncated_uuid_is_an_error_not_its_prefix() {
3117        let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
3118        let msg = err.in_field("reply_to").to_string();
3119        assert!(
3120            msg.starts_with(
3121                "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
3122            ),
3123            "{msg}"
3124        );
3125        for internal in [
3126            "group",
3127            "UUID parsing",
3128            "invalid length",
3129            "invalid character",
3130        ] {
3131            assert!(!msg.contains(internal), "{internal} leaked: {msg}");
3132        }
3133        assert!(target("7ad26cc").is_err());
3134        assert!(target("7ad26ccd9").is_err());
3135    }
3136
3137    #[test]
3138    fn trailing_text_names_the_id_it_starts_with() {
3139        let msg = target(&format!("{FULL} (the reply above)"))
3140            .unwrap_err()
3141            .to_string();
3142        assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
3143    }
3144
3145    #[derive(Debug, serde::Deserialize)]
3146    struct Probe {
3147        #[serde(deserialize_with = "content_target::reply_to")]
3148        #[allow(dead_code)]
3149        reply_to: ContentTarget,
3150    }
3151
3152    #[test]
3153    fn target_errors_name_the_field_for_strings_and_other_types() {
3154        let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
3155            .unwrap_err();
3156        assert!(
3157            err.to_string()
3158                .starts_with("`reply_to` must be a post or comment id"),
3159            "{err}"
3160        );
3161        let err =
3162            serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
3163        assert!(
3164            err.to_string()
3165                .contains("expected `reply_to` to be a post or comment id"),
3166            "{err}"
3167        );
3168    }
3169
3170    #[cfg(feature = "schemars")]
3171    #[test]
3172    fn target_schema_is_inline_and_admits_both_forms() {
3173        let schema = schemars::schema_for!(ContentTarget);
3174        let v = schema.as_value();
3175        assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
3176        assert!(v.get("format").is_none(), "a short id is not format: uuid");
3177        assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
3178    }
3179}