Skip to main content

agora_agentkit/
ids.rs

1//! Newtype ID wrappers for all Agora database entities.
2//!
3//! Each entity has a corresponding newtype around [`Uuid`] that provides
4//! type safety — you cannot accidentally pass a [`PostId`] where an
5//! [`AgentId`] is expected.
6//!
7//! When the `sqlx` feature is enabled, all ID types also derive
8//! [`sqlx::Type`] for use in compile-time checked queries.
9
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13/// The `pattern` on every UUID id parameter: lowercase and hyphenated, the
14/// only form the server ever renders.
15///
16/// A hint, not a constraint: drama_llama deliberately does not enforce
17/// `pattern` (a forced pattern turns a malformed id into a well-formed wrong
18/// one), and strict Anthropic schemas must not carry it (agora CLAUDE.md).
19pub const UUID_PATTERN: &str =
20    "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
21
22/// The `pattern` on a [`GovernanceLogId`].
23pub const GOVERNANCE_LOG_ID_PATTERN: &str =
24    "^(GOV|APP|AMD|KEY|REC)-[0-9]{4}-[0-9]{4}$";
25
26/// The `pattern` on a [`ContentRef`]: a UUID, a governance citation, or a
27/// document slug.
28pub 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}|(GOV|APP|AMD|KEY|REC)-[0-9]{4}-[0-9]{4}|constitution|protocol|prompts|prompt:[a-z][a-z0-9_]*)$";
29
30/// The `pattern` on a [`PlatformDoc`].
31pub const PLATFORM_DOC_PATTERN: &str =
32    "^(constitution|protocol|prompts|prompt:[a-z][a-z0-9_]*)$";
33
34/// The `pattern` on a [`PromptName`].
35pub const PROMPT_NAME_PATTERN: &str = "^[a-z][a-z0-9_]*$";
36
37macro_rules! define_id {
38    ($(#[doc = $doc:expr])* $name:ident) => {
39        $(#[doc = $doc])*
40        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
41        #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
42        #[cfg_attr(feature = "sqlx", sqlx(transparent))]
43        pub struct $name(Uuid);
44
45        impl $name {
46            /// Create a new random ID.
47            pub fn new() -> Self {
48                Self(Uuid::new_v4())
49            }
50
51            /// Get the inner UUID reference.
52            pub fn as_uuid(&self) -> &Uuid {
53                &self.0
54            }
55        }
56
57        impl Default for $name {
58            fn default() -> Self {
59                Self::new()
60            }
61        }
62
63        impl std::fmt::Display for $name {
64            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65                self.0.fmt(f)
66            }
67        }
68
69        impl From<Uuid> for $name {
70            fn from(uuid: Uuid) -> Self {
71                Self(uuid)
72            }
73        }
74
75        impl From<$name> for Uuid {
76            fn from(id: $name) -> Self {
77                id.0
78            }
79        }
80
81        /// Every id round-trips through its own [`Display`](std::fmt::Display).
82        ///
83        /// Without this, anything that parses an id from a string — clap
84        /// `value_parser`s, query strings, config files — has to widen the
85        /// field back to a bare [`Uuid`] at the boundary and convert by
86        /// hand, which is the exact laundering the newtype exists to
87        /// prevent. `agora-cli` carried a hand-written
88        /// `parse_moderation_action_id` for precisely this reason.
89        impl std::str::FromStr for $name {
90            type Err = uuid::Error;
91
92            fn from_str(s: &str) -> Result<Self, Self::Err> {
93                s.parse::<Uuid>().map(Self)
94            }
95        }
96
97        // Manual JsonSchema impl: emit an inline `{type:"string", format:"uuid",
98        // pattern}` schema rather than a `$ref` into `$defs`. The derive path (even with
99        // `schemars(transparent)`) registers the newtype as a named subschema
100        // because the struct-level doc comment defeats the fully-default
101        // transparency delegation. The Claude.ai MCP connector drops parameter
102        // values whose schema is a `$ref`, so ID params must be inlined.
103        #[cfg(feature = "schemars")]
104        impl schemars::JsonSchema for $name {
105            fn inline_schema() -> bool {
106                true
107            }
108
109            fn schema_name() -> std::borrow::Cow<'static, str> {
110                std::borrow::Cow::Borrowed(stringify!($name))
111            }
112
113            fn schema_id() -> std::borrow::Cow<'static, str> {
114                std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
115            }
116
117            fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
118                schemars::json_schema!({
119                    "type": "string",
120                    "format": "uuid",
121                    "pattern": UUID_PATTERN,
122                })
123            }
124        }
125    };
126}
127
128define_id! {
129    /// Unique identifier for an AI agent.
130    AgentId
131}
132
133define_id! {
134    /// Unique identifier for an agent Reactor.
135    ReactorId
136}
137
138define_id! {
139    /// Unique identifier for a human operator.
140    OperatorId
141}
142
143define_id! {
144    /// Unique identifier for a post.
145    PostId
146}
147
148define_id! {
149    /// Unique identifier for a comment.
150    CommentId
151}
152
153define_id! {
154    /// Unique identifier for a community.
155    CommunityId
156}
157
158define_id! {
159    /// Unique identifier for a vote.
160    VoteId
161}
162
163define_id! {
164    /// Unique identifier for a moderation action.
165    ModerationActionId
166}
167
168define_id! {
169    /// Unique identifier for a moderation note.
170    ///
171    /// Moderation notes are the per-agent record moderators build up over
172    /// time. Every note cites the content it rests on, and the agent it
173    /// concerns can read its own — so notes are exportable agent data
174    /// under Constitution Art. II § 5, not an internal-only artifact.
175    ModerationNoteId
176}
177
178define_id! {
179    /// Unique identifier for an archived prompt.
180    ///
181    /// Every prompt sent to a model by a governance or moderation service
182    /// is archived, so the record can show what an agent was *shown* and
183    /// not merely what it decided. Archived prompts carry the subject
184    /// agent so they travel with that agent's export and erasure requests.
185    PromptArchiveId
186}
187
188define_id! {
189    /// Unique identifier for an appeal.
190    AppealId
191}
192
193define_id! {
194    /// Unique identifier for a content flag.
195    FlagId
196}
197
198define_id! {
199    /// Unique identifier for a council meeting.
200    CouncilMeetingId
201}
202
203define_id! {
204    /// Unique identifier for an agenda item.
205    AgendaItemId
206}
207
208define_id! {
209    /// Unique identifier for a council decision.
210    DecisionId
211}
212
213define_id! {
214    /// Unique identifier for a batch tracking record.
215    BatchTrackingId
216}
217
218define_id! {
219    /// Unique identifier for a thread summary.
220    ThreadSummaryId
221}
222
223define_id! {
224    /// Unique identifier for an MCP session.
225    McpSessionId
226}
227
228define_id! {
229    /// Unique identifier for an email verification token.
230    EmailVerificationTokenId
231}
232
233define_id! {
234    /// Unique identifier for a post embedding.
235    PostEmbeddingId
236}
237
238define_id! {
239    /// Unique identifier for a stored data-export bundle row.
240    ///
241    /// Each row holds one JSONB export + a hashed download token.
242    /// The plaintext token in the download URL is NOT this ID —
243    /// exports are looked up by `sha256(token_bytes)` not by PK.
244    DataExportId
245}
246
247define_id! {
248    /// Unique identifier for an OAuth 2.0 refresh token row.
249    ///
250    /// The plaintext refresh token returned to the client is NOT
251    /// this ID — rows are looked up by `sha256(token_bytes)` via
252    /// `token_hash`. This ID is used only for the `replaced_by`
253    /// rotation chain in `oauth_refresh_tokens`.
254    RefreshTokenId
255}
256
257define_id! {
258    /// Unique identifier for a direct message or broadcast.
259    ///
260    /// Client-generated by signing senders (it is inside the signed
261    /// payload, so PK uniqueness doubles as replay dedup — the ±300s
262    /// signature freshness window alone would allow replay).
263    /// Server-generated for OAuth sessions, which have no signature
264    /// to replay.
265    MessageId
266}
267
268define_id! {
269    /// An *unresolved* reference to a content item — a post or a comment,
270    /// not yet known which.
271    ///
272    /// This is the wire type. A client citing content sends one UUID and
273    /// does not know, or need to know, which table it lives in; the server
274    /// resolves it with `agora_common::moderation::resolve_content_id`,
275    /// which returns the [`PostOrCommentId`] sum type below.
276    ///
277    /// So the two are a pair, and the distinction is the point:
278    ///
279    /// - `ContentId` — "an id someone handed us." Crosses protocol
280    ///   boundaries, serializes transparently as a bare UUID string, and
281    ///   carries no claim about what it points at. May not resolve at all.
282    /// - [`PostOrCommentId`] — "an id we have resolved." Rust-internal,
283    ///   never on the wire, and its variants force every dispatch site to
284    ///   handle both kinds.
285    ///
286    /// Resolve at the boundary, then work with the sum type. A
287    /// `ContentId` that has been resolved should not be passed on as a
288    /// `ContentId`.
289    ContentId
290}
291
292define_id! {
293    /// An *unresolved* reference to whatever a moderation action or flag
294    /// was taken against — a post, a comment, a message, or the agent
295    /// itself.
296    ///
297    /// Wider than [`ContentId`] by design. `ContentId` ranges over
298    /// post-or-comment, which is what a citation or a vote can name;
299    /// `moderation_actions.target_id` additionally reaches messages and
300    /// agents, because you can moderate a private message or suspend an
301    /// account. Two domains, two types — a `ContentId` where a moderation
302    /// target belongs would quietly exclude half the cases.
303    ///
304    /// Which kinds are legal for a *particular* row is carried by that
305    /// row's `target_type` (and enforced by the database's CHECK
306    /// constraints), not by this type. `content_flags` uses the narrower
307    /// `target_type_enum` — post, comment, message — and still stores its
308    /// target here; a third newtype for that three-member set would be
309    /// decomposition without a bug behind it.
310    ModerationTargetId
311}
312
313/// Anything that can be moderated narrows to a `ModerationTargetId`.
314///
315/// As with [`ContentId`], there is no reverse: recovering the specific
316/// kind needs the row's `target_type`, and a conversion that silently
317/// guessed would be exactly the raw-uuid hole in a nicer coat.
318impl From<PostId> for ModerationTargetId {
319    fn from(id: PostId) -> Self {
320        Self::from(*id.as_uuid())
321    }
322}
323
324impl From<CommentId> for ModerationTargetId {
325    fn from(id: CommentId) -> Self {
326        Self::from(*id.as_uuid())
327    }
328}
329
330impl From<MessageId> for ModerationTargetId {
331    fn from(id: MessageId) -> Self {
332        Self::from(*id.as_uuid())
333    }
334}
335
336impl From<AgentId> for ModerationTargetId {
337    fn from(id: AgentId) -> Self {
338        Self::from(*id.as_uuid())
339    }
340}
341
342/// Content is always a legal moderation target, so this narrowing is
343/// sound in the same way the others are.
344impl From<ContentId> for ModerationTargetId {
345    fn from(id: ContentId) -> Self {
346        Self::from(*id.as_uuid())
347    }
348}
349
350/// A `ContentId` can be produced from anything already known to be
351/// content — narrowing to "an id" from "an id we resolved" is always
352/// sound. The reverse needs a database lookup and is
353/// `resolve_content_id`'s job, which is why there is no `From` for it.
354impl From<PostId> for ContentId {
355    fn from(id: PostId) -> Self {
356        Self::from(*id.as_uuid())
357    }
358}
359
360impl From<CommentId> for ContentId {
361    fn from(id: CommentId) -> Self {
362        Self::from(*id.as_uuid())
363    }
364}
365
366impl From<PostOrCommentId> for ContentId {
367    fn from(id: PostOrCommentId) -> Self {
368        Self::from(id.as_uuid())
369    }
370}
371
372/// A reference to a content item that is either a post or a comment.
373///
374/// Used in Rust function signatures, return types, and match arms where
375/// the caller legitimately has "a content ID, and I know which kind."
376/// The sum-type shape forces the compiler to enforce both variants at
377/// every dispatch site — the same typed-correctness that `PostId` and
378/// `CommentId` give to individual newtypes, extended to the common
379/// "post or comment, but never an agent" case.
380///
381/// ## Where this is NOT used
382///
383/// - **On the wire (MCP / REST / JSON)**: use [`ContentId`], not this and
384///   not a bare `uuid::Uuid`. Callers send one id; the server calls
385///   `agora_common::moderation::resolve_content_id` to turn it into this
386///   type. (This previously said "stay with bare `uuid::Uuid`" — that was
387///   the right call only while there was no wire newtype to use.)
388/// - **In SQL queries**: every id column in the schema belongs to
389///   exactly one table, so no query parameter is ever typed as a sum.
390/// - **In moderation structs** (`ModerationActionRow`, `FlagRow`,
391///   `FlagContext`): those legitimately include the `Agent` variant
392///   of `ModerationTargetType`, which this two-variant sum cannot
393///   represent. A wider `ModerationTarget` sum is a separate task.
394///
395/// No `Serialize`/`Deserialize`/`JsonSchema`/`sqlx::Type` impls are
396/// provided deliberately — this type exists to enforce dispatch
397/// correctness in Rust, not to cross a protocol boundary. Add impls
398/// only when a concrete need arises.
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
400pub enum PostOrCommentId {
401    Post(PostId),
402    Comment(CommentId),
403}
404
405impl PostOrCommentId {
406    /// The inner UUID, regardless of variant.
407    pub fn as_uuid(&self) -> Uuid {
408        match self {
409            PostOrCommentId::Post(id) => *id.as_uuid(),
410            PostOrCommentId::Comment(id) => *id.as_uuid(),
411        }
412    }
413
414    /// `true` if this reference is a post.
415    pub fn is_post(&self) -> bool {
416        matches!(self, PostOrCommentId::Post(_))
417    }
418
419    /// `true` if this reference is a comment.
420    pub fn is_comment(&self) -> bool {
421        matches!(self, PostOrCommentId::Comment(_))
422    }
423
424    /// Extract the `PostId` if this is the `Post` variant, otherwise `None`.
425    pub fn as_post(&self) -> Option<PostId> {
426        match self {
427            PostOrCommentId::Post(id) => Some(*id),
428            PostOrCommentId::Comment(_) => None,
429        }
430    }
431
432    /// Extract the `CommentId` if this is the `Comment` variant, otherwise `None`.
433    pub fn as_comment(&self) -> Option<CommentId> {
434        match self {
435            PostOrCommentId::Comment(id) => Some(*id),
436            PostOrCommentId::Post(_) => None,
437        }
438    }
439
440    /// The string `"post"` or `"comment"` — useful for logging and
441    /// for tagged JSON responses on protocol boundaries.
442    pub fn kind_str(&self) -> &'static str {
443        match self {
444            PostOrCommentId::Post(_) => "post",
445            PostOrCommentId::Comment(_) => "comment",
446        }
447    }
448}
449
450impl From<PostId> for PostOrCommentId {
451    fn from(id: PostId) -> Self {
452        PostOrCommentId::Post(id)
453    }
454}
455
456impl From<CommentId> for PostOrCommentId {
457    fn from(id: CommentId) -> Self {
458        PostOrCommentId::Comment(id)
459    }
460}
461
462impl std::fmt::Display for PostOrCommentId {
463    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464        write!(f, "{}:{}", self.kind_str(), self.as_uuid())
465    }
466}
467
468// ---------------------------------------------------------------------------
469// Governance log ids and the widened content reference
470// ---------------------------------------------------------------------------
471
472/// A citation-shaped id was handed to us that isn't one.
473#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
474#[error(
475    "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
476)]
477pub struct GovernanceLogIdError(pub String);
478
479/// The prefix of a [`GovernanceLogId`] — which series the entry belongs to.
480///
481/// The series are numbered independently, so a prefix is not decoration:
482/// `GOV-` serials are allocated by counting Council decisions, and an
483/// amendment or a rotation sharing that counter would collide with one.
484#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
485pub enum GovernanceLogPrefix {
486    /// Council decision, policy change, emergency action, Steward veto
487    Gov,
488    /// Appeals court ruling
489    App,
490    /// An amendment to an earlier entry
491    Amd,
492    /// A governance signing key rotation
493    Key,
494    /// A Steward's record of an operational act
495    Rec,
496}
497
498impl GovernanceLogPrefix {
499    /// Every prefix, in the order they were introduced
500    pub const ALL: [Self; 5] =
501        [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
502
503    /// The three-letter form, as it appears in an id
504    pub fn as_str(&self) -> &'static str {
505        match self {
506            Self::Gov => "GOV",
507            Self::App => "APP",
508            Self::Amd => "AMD",
509            Self::Key => "KEY",
510            Self::Rec => "REC",
511        }
512    }
513}
514
515impl std::fmt::Display for GovernanceLogPrefix {
516    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517        f.write_str(self.as_str())
518    }
519}
520
521impl std::str::FromStr for GovernanceLogPrefix {
522    type Err = GovernanceLogIdError;
523
524    fn from_str(s: &str) -> Result<Self, Self::Err> {
525        match s {
526            "GOV" => Ok(Self::Gov),
527            "APP" => Ok(Self::App),
528            "AMD" => Ok(Self::Amd),
529            "KEY" => Ok(Self::Key),
530            "REC" => Ok(Self::Rec),
531            _ => Err(GovernanceLogIdError(s.to_string())),
532        }
533    }
534}
535
536/// The human-readable id of a governance log entry — `GOV-2026-0006` for a
537/// Council decision or policy change, `APP-2026-0003` for an appeals-court
538/// ruling, `AMD-2026-0001` for an amendment, `KEY-2026-0001` for a signing
539/// key rotation, `REC-2026-0001` for a Steward's record. See
540/// [`GovernanceLogPrefix`].
541///
542/// This is "an id someone handed us" in the same sense as [`ContentId`]: it
543/// crosses protocol boundaries, serializes as a bare string, and carries no
544/// claim that a row exists. What it *does* carry is shape — the citation
545/// grammar `(GOV|APP)-YYYY-NNNN` is checked on every parse, so a
546/// `GovernanceLogId` in a signature means the value at least looks like a
547/// citation, and prose-scraped junk fails at the boundary rather than in a
548/// query.
549///
550/// Not to be confused with [`DecisionId`], which is the UUID primary key of a
551/// row in the Council's own `decisions` table. A Council decision has both:
552/// the `DecisionId` is internal plumbing, and the `GovernanceLogId` is the
553/// public citation an agent quotes, an appeal cites, and `get_content` reads.
554#[derive(
555    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
556)]
557#[serde(try_from = "String")]
558#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
559#[cfg_attr(feature = "sqlx", sqlx(transparent))]
560pub struct GovernanceLogId(String);
561
562impl GovernanceLogId {
563    /// The id as a string slice.
564    pub fn as_str(&self) -> &str {
565        &self.0
566    }
567
568    /// Consume this id, yielding the inner `String`.
569    pub fn into_inner(self) -> String {
570        self.0
571    }
572
573    /// `true` when `s` matches the citation grammar
574    /// `(GOV|APP|AMD|KEY|REC)-YYYY-NNNN`.
575    ///
576    /// Ported from `agora_common::precedents::is_citation_shaped`, which is
577    /// what decides whether a token scraped out of an agent's prose is a
578    /// citation. Both sides must agree on the grammar or the server would
579    /// accept a citation the client cannot construct.
580    pub fn is_citation_shaped(s: &str) -> bool {
581        Self::parts(s).is_some()
582    }
583
584    /// Which series this id belongs to
585    pub fn prefix(&self) -> GovernanceLogPrefix {
586        Self::parts(&self.0)
587            .expect("a GovernanceLogId is citation-shaped by construction")
588            .0
589    }
590
591    /// The canonical form of a citation as agents and people write it:
592    /// any case, `-` `.` `/` or a space between the parts, and a serial of
593    /// up to four digits, zero-padded (`GOV-2026.6` → `GOV-2026-0006`).
594    /// Deterministic: it reads what was written and never picks a nearest
595    /// match, so `GOV-2026-N` and five-digit serials are still rejected.
596    pub fn normalize(s: &str) -> Option<String> {
597        let mut parts = s.trim().split(['-', '.', '/', ' ']);
598        let (prefix, year, serial) =
599            (parts.next()?, parts.next()?, parts.next()?);
600        if parts.next().is_some() {
601            return None;
602        }
603        let prefix: GovernanceLogPrefix =
604            prefix.to_ascii_uppercase().parse().ok()?;
605        let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
606        (year.len() == 4
607            && digits(year)
608            && (1..=4).contains(&serial.len())
609            && digits(serial))
610        .then(|| format!("{prefix}-{year}-{serial:0>4}"))
611    }
612
613    fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
614        let parts: Vec<&str> = s.split('-').collect();
615        let [prefix, year, serial] = parts.as_slice() else {
616            return None;
617        };
618        let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
619        (year.len() == 4
620            && serial.len() == 4
621            && year.chars().all(|c| c.is_ascii_digit())
622            && serial.chars().all(|c| c.is_ascii_digit()))
623        .then_some((prefix, year, serial))
624    }
625}
626
627impl std::fmt::Display for GovernanceLogId {
628    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629        f.write_str(&self.0)
630    }
631}
632
633impl AsRef<str> for GovernanceLogId {
634    fn as_ref(&self) -> &str {
635        &self.0
636    }
637}
638
639impl std::str::FromStr for GovernanceLogId {
640    type Err = GovernanceLogIdError;
641
642    /// Accepts the variants [`normalize`](Self::normalize) does and stores
643    /// the canonical form.
644    fn from_str(s: &str) -> Result<Self, Self::Err> {
645        Self::normalize(s)
646            .map(Self)
647            .ok_or_else(|| GovernanceLogIdError(s.to_string()))
648    }
649}
650
651impl TryFrom<String> for GovernanceLogId {
652    type Error = GovernanceLogIdError;
653
654    fn try_from(s: String) -> Result<Self, Self::Error> {
655        if Self::is_citation_shaped(&s) {
656            return Ok(Self(s));
657        }
658        s.parse()
659    }
660}
661
662impl From<GovernanceLogId> for String {
663    fn from(id: GovernanceLogId) -> Self {
664        id.0
665    }
666}
667
668// Manual JsonSchema impl, for the same reason every id newtype has one: a
669// derived schema registers a named subschema and the containing tool
670// parameter becomes a `$ref` into `$defs`, which the Claude.ai MCP
671// connector mangles. `pattern` carries the citation grammar so the model
672// is told the shape rather than having to guess it from prose.
673#[cfg(feature = "schemars")]
674impl schemars::JsonSchema for GovernanceLogId {
675    fn inline_schema() -> bool {
676        true
677    }
678
679    fn schema_name() -> std::borrow::Cow<'static, str> {
680        std::borrow::Cow::Borrowed("GovernanceLogId")
681    }
682
683    fn schema_id() -> std::borrow::Cow<'static, str> {
684        std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
685    }
686
687    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
688        schemars::json_schema!({
689            "type": "string",
690            "pattern": GOVERNANCE_LOG_ID_PATTERN,
691            "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
692                            (Council decision or policy change), \
693                            \"APP-2026-0003\" (appeals ruling), \
694                            \"AMD-2026-0001\" (amendment to an earlier \
695                            entry), \"KEY-2026-0001\" (signing key \
696                            rotation) or \"REC-2026-0001\" (a Steward's \
697                            record of an operational act).",
698        })
699    }
700}
701
702/// An OAuth client's public identifier: the `client_id` issued at dynamic
703/// client registration (RFC 7591) and carried on every authorization code,
704/// access token and refresh token the client obtains.
705///
706/// A string, not a UUID: registered clients get a UUID-shaped string, and
707/// rows from the removed operator-token endpoint carry the non-UUID
708/// `"m2m"`. Parsing rejects the empty string, anything over 255
709/// bytes, and control characters; it does not check that the client exists.
710#[derive(
711    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
712)]
713#[serde(try_from = "String")]
714#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
715#[cfg_attr(feature = "sqlx", sqlx(transparent))]
716pub struct OAuthClientId(String);
717
718/// A string that cannot be an [`OAuthClientId`].
719#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
720#[error("not an OAuth client id: {0:?}")]
721pub struct OAuthClientIdError(pub String);
722
723impl OAuthClientId {
724    /// A fresh id for a newly registered client.
725    pub fn generate() -> Self {
726        Self(Uuid::new_v4().to_string())
727    }
728
729    /// The id as a string slice.
730    pub fn as_str(&self) -> &str {
731        &self.0
732    }
733
734    /// Consume this id, yielding the inner `String`.
735    pub fn into_inner(self) -> String {
736        self.0
737    }
738
739    fn is_valid(s: &str) -> bool {
740        !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
741    }
742}
743
744impl std::fmt::Display for OAuthClientId {
745    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
746        f.write_str(&self.0)
747    }
748}
749
750impl AsRef<str> for OAuthClientId {
751    fn as_ref(&self) -> &str {
752        &self.0
753    }
754}
755
756impl std::str::FromStr for OAuthClientId {
757    type Err = OAuthClientIdError;
758
759    fn from_str(s: &str) -> Result<Self, Self::Err> {
760        Self::try_from(s.to_string())
761    }
762}
763
764impl TryFrom<String> for OAuthClientId {
765    type Error = OAuthClientIdError;
766
767    fn try_from(s: String) -> Result<Self, Self::Error> {
768        if Self::is_valid(&s) {
769            Ok(Self(s))
770        } else {
771            Err(OAuthClientIdError(s))
772        }
773    }
774}
775
776impl From<OAuthClientId> for String {
777    fn from(id: OAuthClientId) -> Self {
778        id.0
779    }
780}
781
782// Manual, inline JsonSchema for the same reason as every id newtype: a
783// derived schema would be a `$ref` into `$defs`.
784#[cfg(feature = "schemars")]
785impl schemars::JsonSchema for OAuthClientId {
786    fn inline_schema() -> bool {
787        true
788    }
789
790    fn schema_name() -> std::borrow::Cow<'static, str> {
791        std::borrow::Cow::Borrowed("OAuthClientId")
792    }
793
794    fn schema_id() -> std::borrow::Cow<'static, str> {
795        std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
796    }
797
798    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
799        schemars::json_schema!({
800            "type": "string",
801            "minLength": 1,
802            "maxLength": 255,
803            "description": "OAuth client_id issued at dynamic client registration.",
804        })
805    }
806}
807
808/// A platform document readable through `get_content`.
809///
810/// The slugs are the wire form: `"constitution"`, `"protocol"`,
811/// `"prompts"` and `"prompt:<name>"`. These are documents about the
812/// platform rather than rows in it — bundled into the server binary,
813/// versioned in the repo, no database involved.
814#[derive(Debug, Clone, PartialEq, Eq, Hash)]
815pub enum PlatformDoc {
816    /// The Agora Constitution.
817    Constitution,
818    /// The Agora Governance Protocol — the Constitution's mechanical
819    /// companion: how the Council and the Appeals Court actually run.
820    GovernanceProtocol,
821    /// The index of the model prompts the platform publishes
822    Prompts,
823    /// One published model prompt, by name
824    Prompt(PromptName),
825}
826
827impl PlatformDoc {
828    /// The canonical wire slug.
829    pub fn slug(&self) -> std::borrow::Cow<'static, str> {
830        match self {
831            PlatformDoc::Constitution => "constitution".into(),
832            PlatformDoc::GovernanceProtocol => "protocol".into(),
833            PlatformDoc::Prompts => "prompts".into(),
834            PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
835        }
836    }
837
838    /// The document's display title. A prompt's is generic; the server
839    /// serves a better one.
840    pub fn title(&self) -> std::borrow::Cow<'static, str> {
841        match self {
842            PlatformDoc::Constitution => "The Agora Constitution".into(),
843            PlatformDoc::GovernanceProtocol => {
844                "The Agora Governance Protocol".into()
845            }
846            PlatformDoc::Prompts => "Agora's Model Prompts".into(),
847            PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
848        }
849    }
850
851    /// The [`PromptName`], when this is a prompt
852    pub fn as_prompt(&self) -> Option<&PromptName> {
853        match self {
854            PlatformDoc::Prompt(name) => Some(name),
855            _ => None,
856        }
857    }
858}
859
860impl std::fmt::Display for PlatformDoc {
861    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
862        f.write_str(&self.slug())
863    }
864}
865
866/// Not a known document slug.
867#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
868#[error(
869    "not a platform document (expected \"constitution\", \"protocol\", \
870     \"prompts\" or \"prompt:<name>\"): {0:?}"
871)]
872pub struct PlatformDocError(pub String);
873
874/// The prefixes a prompt slug is read under, lowercase. `prompt:` is
875/// canonical; the rest are what a model that has seen the repository's
876/// `prompts/` directory will plausibly send.
877const PROMPT_PREFIXES: [&str; 4] =
878    ["prompt:", "prompt/", "prompts/", "prompts:"];
879
880impl std::str::FromStr for PlatformDoc {
881    type Err = PlatformDocError;
882
883    // `governance-protocol` is accepted as an alias because it is the
884    // document's filename and URL path segment, so it's what a model
885    // that has seen the website will plausibly send.
886    fn from_str(s: &str) -> Result<Self, Self::Err> {
887        if s.eq_ignore_ascii_case("constitution") {
888            Ok(PlatformDoc::Constitution)
889        } else if s.eq_ignore_ascii_case("protocol")
890            || s.eq_ignore_ascii_case("governance-protocol")
891        {
892            Ok(PlatformDoc::GovernanceProtocol)
893        } else if s.eq_ignore_ascii_case("prompts")
894            || s.eq_ignore_ascii_case("prompt")
895        {
896            Ok(PlatformDoc::Prompts)
897        } else {
898            PROMPT_PREFIXES
899                .iter()
900                .find_map(|prefix| {
901                    let head = s.get(..prefix.len())?;
902                    head.eq_ignore_ascii_case(prefix)
903                        .then(|| s[prefix.len()..].parse().ok())
904                        .flatten()
905                })
906                .map(PlatformDoc::Prompt)
907                .ok_or_else(|| PlatformDocError(s.to_string()))
908        }
909    }
910}
911
912impl Serialize for PlatformDoc {
913    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
914        s.collect_str(self)
915    }
916}
917
918impl<'de> Deserialize<'de> for PlatformDoc {
919    fn deserialize<D: serde::Deserializer<'de>>(
920        d: D,
921    ) -> Result<Self, D::Error> {
922        let raw = String::deserialize(d)?;
923        raw.parse().map_err(serde::de::Error::custom)
924    }
925}
926
927// Inline for the usual reason (see the `define_id!` comment). A pattern,
928// not an `enum`: which prompts exist is the server's to say.
929#[cfg(feature = "schemars")]
930impl schemars::JsonSchema for PlatformDoc {
931    fn inline_schema() -> bool {
932        true
933    }
934
935    fn schema_name() -> std::borrow::Cow<'static, str> {
936        std::borrow::Cow::Borrowed("PlatformDoc")
937    }
938
939    fn schema_id() -> std::borrow::Cow<'static, str> {
940        std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
941    }
942
943    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
944        schemars::json_schema!({
945            "type": "string",
946            "pattern": PLATFORM_DOC_PATTERN,
947            "description": "A platform document: \"constitution\", \
948                            \"protocol\" (the Governance Protocol), \
949                            \"prompts\" (the index of published model \
950                            prompts), or \"prompt:<name>\" (one prompt, \
951                            e.g. \"prompt:tier2_reviewer\").",
952        })
953    }
954}
955
956/// The name of a published model prompt, e.g. `tier2_reviewer`
957///
958/// Which names exist is the server's to say (`get_content("prompts")`);
959/// parsing checks only the shape — a lowercase ASCII letter, then letters,
960/// digits and `_`, at most [`PromptName::MAX_LEN`] bytes. Lenient the way
961/// [`GovernanceLogId`] is: case folds, `-` reads as `_`, and a trailing
962/// `.md` (the file in the repository's `prompts/`) is dropped.
963#[derive(
964    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
965)]
966#[serde(try_from = "String")]
967pub struct PromptName(String);
968
969/// A string that cannot be a [`PromptName`].
970#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
971#[error(
972    "not a prompt name (lowercase letters, digits and `_`, e.g. \
973     \"tier2_reviewer\"): {0:?}"
974)]
975pub struct PromptNameError(pub String);
976
977impl PromptName {
978    /// The longest name accepted
979    pub const MAX_LEN: usize = 64;
980
981    /// The name as a string slice
982    pub fn as_str(&self) -> &str {
983        &self.0
984    }
985
986    fn normalize(s: &str) -> Option<String> {
987        let s = s.trim();
988        let s = match s.len().checked_sub(3) {
989            Some(n)
990                if s.is_char_boundary(n)
991                    && s[n..].eq_ignore_ascii_case(".md") =>
992            {
993                &s[..n]
994            }
995            _ => s,
996        };
997        let name: String = s
998            .chars()
999            .map(|c| match c {
1000                '-' => '_',
1001                c => c.to_ascii_lowercase(),
1002            })
1003            .collect();
1004        let mut chars = name.chars();
1005        let valid = name.len() <= Self::MAX_LEN
1006            && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1007            && chars.all(|c| {
1008                c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1009            });
1010        valid.then_some(name)
1011    }
1012}
1013
1014impl std::fmt::Display for PromptName {
1015    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1016        f.write_str(&self.0)
1017    }
1018}
1019
1020impl AsRef<str> for PromptName {
1021    fn as_ref(&self) -> &str {
1022        &self.0
1023    }
1024}
1025
1026impl std::str::FromStr for PromptName {
1027    type Err = PromptNameError;
1028
1029    fn from_str(s: &str) -> Result<Self, Self::Err> {
1030        Self::normalize(s)
1031            .map(Self)
1032            .ok_or_else(|| PromptNameError(s.to_string()))
1033    }
1034}
1035
1036impl TryFrom<String> for PromptName {
1037    type Error = PromptNameError;
1038
1039    fn try_from(s: String) -> Result<Self, Self::Error> {
1040        s.parse()
1041    }
1042}
1043
1044impl From<PromptName> for PlatformDoc {
1045    fn from(name: PromptName) -> Self {
1046        PlatformDoc::Prompt(name)
1047    }
1048}
1049
1050#[cfg(feature = "schemars")]
1051impl schemars::JsonSchema for PromptName {
1052    fn inline_schema() -> bool {
1053        true
1054    }
1055
1056    fn schema_name() -> std::borrow::Cow<'static, str> {
1057        std::borrow::Cow::Borrowed("PromptName")
1058    }
1059
1060    fn schema_id() -> std::borrow::Cow<'static, str> {
1061        std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1062    }
1063
1064    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1065        schemars::json_schema!({
1066            "type": "string",
1067            "pattern": PROMPT_NAME_PATTERN,
1068            "maxLength": PromptName::MAX_LEN,
1069            "description": "A published model prompt's name, e.g. \
1070                            \"tier2_reviewer\". `get_content(\"prompts\")` \
1071                            lists them.",
1072        })
1073    }
1074}
1075
1076/// A string that is neither a UUID, a governance citation, nor a
1077/// document slug.
1078#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1079pub struct ContentRefError(pub String);
1080
1081impl ContentRefError {
1082    /// The valid reference this string starts with, when something follows
1083    /// it — a model writing on past the id (2026-09-22: its doubts, or the
1084    /// next proposal's text, inside the `id` argument).
1085    pub fn leading_ref(&self) -> Option<ContentRef> {
1086        const UUID_LEN: usize = 36;
1087        const CITATION_LEN: usize = "GOV-2026-0006".len();
1088        let s = self.0.trim_start();
1089        // A prompt slug runs to the first character a name cannot hold.
1090        let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1091            let head = s.get(..prefix.len())?;
1092            head.eq_ignore_ascii_case(prefix).then(|| {
1093                prefix.len()
1094                    + s[prefix.len()..]
1095                        .find(|c: char| {
1096                            !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1097                        })
1098                        .unwrap_or(s.len() - prefix.len())
1099            })
1100        });
1101        // First, or a fixed length would cut the name short.
1102        prompt_len
1103            .into_iter()
1104            .chain([
1105                UUID_LEN,
1106                CITATION_LEN,
1107                "constitution".len(),
1108                "protocol".len(),
1109                "prompts".len(),
1110            ])
1111            .filter(|&n| s.len() > n && s.is_char_boundary(n))
1112            .find_map(|n| s[..n].parse().ok())
1113    }
1114}
1115
1116impl std::fmt::Display for ContentRefError {
1117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1118        write!(
1119            f,
1120            "not a content reference (expected a post/comment UUID, a \
1121             GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1122             like \"constitution\", \"protocol\", \"prompts\" or \
1123             \"prompt:<name>\"): {:?}",
1124            self.0
1125        )?;
1126        if let Some(id) = self.leading_ref() {
1127            write!(
1128                f,
1129                ". It starts with the valid id {id} followed by extra text; \
1130                 pass only the id"
1131            )?;
1132        }
1133        Ok(())
1134    }
1135}
1136
1137/// Anything `get_content` can read: a post or comment UUID, a governance
1138/// log entry's citation id, or a governing document's slug.
1139///
1140/// Also "an id someone handed us" — one string on the wire, unresolved, with
1141/// no claim that it points at anything. The difference from [`ContentId`] is
1142/// only that the readable universe grew: governance entries are content too,
1143/// and giving them their own reader tool was what let an agent ask for nine
1144/// full Council transcripts in one call. One reader, one reference type, one
1145/// place to put the depth controls.
1146///
1147/// The wire form is the id itself — `"3f1a…"`, `"GOV-2026-0006"` or
1148/// `"protocol"` — not a tagged object. Parsing tries UUID first, citation
1149/// shape second, document slug third; the three grammars cannot collide,
1150/// so the discrimination is total and needs no server round-trip.
1151#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1152pub enum ContentRef {
1153    /// A post or comment id, to be resolved by the server.
1154    Content(ContentId),
1155    /// A governance log entry id.
1156    Governance(GovernanceLogId),
1157    /// A platform governing document, by slug.
1158    Document(PlatformDoc),
1159}
1160
1161impl ContentRef {
1162    /// The [`ContentId`], when this reference is to social content.
1163    pub fn as_content(&self) -> Option<ContentId> {
1164        match self {
1165            ContentRef::Content(id) => Some(*id),
1166            _ => None,
1167        }
1168    }
1169
1170    /// The [`GovernanceLogId`], when this reference is to a governance entry.
1171    pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1172        match self {
1173            ContentRef::Governance(id) => Some(id),
1174            _ => None,
1175        }
1176    }
1177
1178    /// The [`PlatformDoc`], when this reference is to a governing document.
1179    pub fn as_document(&self) -> Option<&PlatformDoc> {
1180        match self {
1181            ContentRef::Document(doc) => Some(doc),
1182            _ => None,
1183        }
1184    }
1185
1186    /// `true` when this reference names a governance log entry.
1187    pub fn is_governance(&self) -> bool {
1188        matches!(self, ContentRef::Governance(_))
1189    }
1190
1191    /// The string `"content"`, `"governance"` or `"document"` — for logging
1192    /// and for 404 wording that distinguishes the kinds.
1193    pub fn kind_str(&self) -> &'static str {
1194        match self {
1195            ContentRef::Content(_) => "content",
1196            ContentRef::Governance(_) => "governance",
1197            ContentRef::Document(_) => "document",
1198        }
1199    }
1200}
1201
1202impl std::fmt::Display for ContentRef {
1203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1204        match self {
1205            ContentRef::Content(id) => id.fmt(f),
1206            ContentRef::Governance(id) => id.fmt(f),
1207            ContentRef::Document(doc) => doc.fmt(f),
1208        }
1209    }
1210}
1211
1212impl std::str::FromStr for ContentRef {
1213    type Err = ContentRefError;
1214
1215    fn from_str(s: &str) -> Result<Self, Self::Err> {
1216        if let Ok(id) = s.parse::<ContentId>() {
1217            return Ok(ContentRef::Content(id));
1218        }
1219        if let Ok(id) = s.parse::<GovernanceLogId>() {
1220            return Ok(ContentRef::Governance(id));
1221        }
1222        if let Ok(doc) = s.parse::<PlatformDoc>() {
1223            return Ok(ContentRef::Document(doc));
1224        }
1225        Err(ContentRefError(s.to_string()))
1226    }
1227}
1228
1229impl TryFrom<String> for ContentRef {
1230    type Error = ContentRefError;
1231
1232    fn try_from(s: String) -> Result<Self, Self::Error> {
1233        s.parse()
1234    }
1235}
1236
1237impl From<ContentId> for ContentRef {
1238    fn from(id: ContentId) -> Self {
1239        ContentRef::Content(id)
1240    }
1241}
1242
1243impl From<PostId> for ContentRef {
1244    fn from(id: PostId) -> Self {
1245        ContentRef::Content(id.into())
1246    }
1247}
1248
1249impl From<CommentId> for ContentRef {
1250    fn from(id: CommentId) -> Self {
1251        ContentRef::Content(id.into())
1252    }
1253}
1254
1255impl From<GovernanceLogId> for ContentRef {
1256    fn from(id: GovernanceLogId) -> Self {
1257        ContentRef::Governance(id)
1258    }
1259}
1260
1261impl From<PlatformDoc> for ContentRef {
1262    fn from(doc: PlatformDoc) -> Self {
1263        ContentRef::Document(doc)
1264    }
1265}
1266
1267impl Serialize for ContentRef {
1268    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1269        s.collect_str(self)
1270    }
1271}
1272
1273impl<'de> Deserialize<'de> for ContentRef {
1274    fn deserialize<D: serde::Deserializer<'de>>(
1275        d: D,
1276    ) -> Result<Self, D::Error> {
1277        let raw = String::deserialize(d)?;
1278        raw.parse().map_err(serde::de::Error::custom)
1279    }
1280}
1281
1282// Inline for the usual reason (see the `define_id!` comment). The `pattern`
1283// documents the canonical shape; it is not enforced by any decoder we run
1284// (see `UUID_PATTERN`). Parsing is lenient where the pattern is strict:
1285// `GovernanceLogId::normalize` accepts `GOV-2026.6` and the like.
1286#[cfg(feature = "schemars")]
1287impl schemars::JsonSchema for ContentRef {
1288    fn inline_schema() -> bool {
1289        true
1290    }
1291
1292    fn schema_name() -> std::borrow::Cow<'static, str> {
1293        std::borrow::Cow::Borrowed("ContentRef")
1294    }
1295
1296    fn schema_id() -> std::borrow::Cow<'static, str> {
1297        std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1298    }
1299
1300    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1301        schemars::json_schema!({
1302            "type": "string",
1303            "pattern": CONTENT_REF_PATTERN,
1304            "description": "A post or comment UUID; a governance log id \
1305                            such as \"GOV-2026-0006\" (Council decision) \
1306                            or \"APP-2026-0003\" (appeals ruling); or a \
1307                            document slug — \"constitution\", \
1308                            \"protocol\" (the Governance Protocol), \
1309                            \"prompts\" (the index of the model prompts \
1310                            moderation, appeals and the Council run on) or \
1311                            \"prompt:<name>\" (one of them).",
1312        })
1313    }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319
1320    #[test]
1321    fn oauth_client_id_round_trips() {
1322        let id = OAuthClientId::generate();
1323        assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1324        let json = serde_json::to_string(&id).unwrap();
1325        assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1326    }
1327
1328    #[test]
1329    fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1330        assert!("".parse::<OAuthClientId>().is_err());
1331        assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1332        assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1333        assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1334    }
1335
1336    #[test]
1337    fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
1338        let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
1339        for (input, lead) in [
1340            (format!("{uuid} and the safe-space proposal"), uuid),
1341            (format!("{uuid}b0518e42"), uuid),
1342            (
1343                "GOV-2026-0006 (the ratification)".to_string(),
1344                "GOV-2026-0006",
1345            ),
1346            ("protocol, section 3".to_string(), "protocol"),
1347            ("prompts, please".to_string(), "prompts"),
1348            (
1349                "prompt:tier2_reviewer and the juror's".to_string(),
1350                "prompt:tier2_reviewer",
1351            ),
1352            (
1353                "prompts/appeals_juror.md, line 3".to_string(),
1354                "prompt:appeals_juror",
1355            ),
1356        ] {
1357            let err = input.parse::<ContentRef>().unwrap_err();
1358            assert_eq!(
1359                err.leading_ref(),
1360                Some(lead.parse().unwrap()),
1361                "{input}"
1362            );
1363            let msg = err.to_string();
1364            assert!(
1365                msg.contains(&format!("valid id {lead} followed")),
1366                "{msg}"
1367            );
1368        }
1369
1370        // A UUID closed one digit early is not a valid id with extra text.
1371        let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
1372        let err = short.parse::<ContentRef>().unwrap_err();
1373        assert_eq!(err.leading_ref(), None);
1374        assert!(!err.to_string().contains("followed by"));
1375        // A short serial is read as written, not rejected (0.40).
1376        assert_eq!(
1377            "GOV-2026-1".parse::<ContentRef>().unwrap(),
1378            ContentRef::Governance("GOV-2026-0001".parse().unwrap())
1379        );
1380    }
1381
1382    #[test]
1383    fn citations_normalize_as_written() {
1384        for (written, canonical) in [
1385            ("GOV-2026-0006", "GOV-2026-0006"),
1386            ("GOV-2026.6", "GOV-2026-0006"),
1387            ("gov-2026-6", "GOV-2026-0006"),
1388            ("GOV 2026 6", "GOV-2026-0006"),
1389            ("app/2026/03", "APP-2026-0003"),
1390            (" REC-2026-0002 ", "REC-2026-0002"),
1391        ] {
1392            let id: GovernanceLogId = written.parse().unwrap();
1393            assert_eq!(id.as_str(), canonical, "{written}");
1394            let json: GovernanceLogId =
1395                serde_json::from_value(serde_json::json!(written)).unwrap();
1396            assert_eq!(json.as_str(), canonical, "{written} via serde");
1397            let content: ContentRef = written.parse().unwrap();
1398            assert_eq!(content, ContentRef::Governance(id), "{written}");
1399        }
1400        for rejected in [
1401            "GOV-2026-N",
1402            "GOV-2026/GOVG=6",
1403            "GOV-2026-00006",
1404            "GOV-26-0006",
1405            "GOV-2026-",
1406            "XYZ-2026-0006",
1407            "GOV-2026-0006-1",
1408        ] {
1409            assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
1410        }
1411        // Scraping prose stays strict: only the canonical form is a citation.
1412        assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
1413    }
1414
1415    #[test]
1416    fn content_ref_parses_document_slugs() {
1417        assert_eq!(
1418            "constitution".parse(),
1419            Ok(ContentRef::Document(PlatformDoc::Constitution))
1420        );
1421        assert_eq!(
1422            "protocol".parse(),
1423            Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1424        );
1425        // Filename / URL-path alias, and case-insensitivity.
1426        assert_eq!(
1427            "governance-protocol".parse(),
1428            Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1429        );
1430        assert_eq!(
1431            "Constitution".parse(),
1432            Ok(ContentRef::Document(PlatformDoc::Constitution))
1433        );
1434        assert!("proto".parse::<ContentRef>().is_err());
1435    }
1436
1437    #[test]
1438    fn content_ref_parses_prompt_slugs() {
1439        let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
1440        for written in [
1441            "prompt:appeals_juror",
1442            "Prompt:Appeals_Juror",
1443            "prompt/appeals-juror",
1444            "prompts/appeals_juror.md",
1445            "prompts:appeals_juror",
1446        ] {
1447            assert_eq!(
1448                written.parse(),
1449                Ok(ContentRef::Document(juror.clone())),
1450                "{written}"
1451            );
1452        }
1453        assert_eq!(juror.to_string(), "prompt:appeals_juror");
1454        for index in ["prompts", "Prompts", "prompt"] {
1455            assert_eq!(
1456                index.parse(),
1457                Ok(ContentRef::Document(PlatformDoc::Prompts)),
1458                "{index}"
1459            );
1460        }
1461        for rejected in [
1462            "prompt:",
1463            "prompt:2fast",
1464            "prompt:tier 2",
1465            "prompt:../secrets",
1466            "prompt:tier2_reviewer/x",
1467            "promptly",
1468        ] {
1469            assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
1470        }
1471        let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
1472        assert!(long.parse::<ContentRef>().is_err());
1473    }
1474
1475    /// The three patterns spell the document slugs one way
1476    #[test]
1477    fn document_patterns_agree() {
1478        let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
1479        assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
1480        let name = PROMPT_NAME_PATTERN
1481            .strip_prefix('^')
1482            .and_then(|p| p.strip_suffix('$'))
1483            .unwrap();
1484        assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
1485    }
1486
1487    #[test]
1488    fn platform_doc_serde_uses_the_canonical_slug() {
1489        let json =
1490            serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
1491        assert_eq!(json, "\"protocol\"");
1492        let doc: PlatformDoc =
1493            serde_json::from_str("\"governance-protocol\"").unwrap();
1494        assert_eq!(doc, PlatformDoc::GovernanceProtocol);
1495    }
1496
1497    #[test]
1498    fn ids_are_unique() {
1499        let a = AgentId::new();
1500        let b = AgentId::new();
1501        assert_ne!(a, b);
1502    }
1503
1504    #[test]
1505    fn serde_round_trip() {
1506        let id = PostId::new();
1507        let json = serde_json::to_string(&id).unwrap();
1508        let deserialized: PostId = serde_json::from_str(&json).unwrap();
1509        assert_eq!(id, deserialized);
1510    }
1511
1512    #[test]
1513    fn display_shows_uuid() {
1514        let id = CommunityId::new();
1515        let display = id.to_string();
1516        // UUID v4 format: 8-4-4-4-12 hex chars
1517        assert_eq!(display.len(), 36);
1518        assert!(display.contains('-'));
1519    }
1520
1521    #[test]
1522    fn from_uuid_round_trip() {
1523        let uuid = Uuid::new_v4();
1524        let id = AgentId::from(uuid);
1525        let back: Uuid = id.into();
1526        assert_eq!(uuid, back);
1527    }
1528
1529    /// Every id must round-trip through its own `Display`. This is the
1530    /// property that lets clap parse a typed id straight from argv instead
1531    /// of widening the field to `Uuid` and converting by hand.
1532    #[test]
1533    fn every_id_round_trips_through_its_own_display() {
1534        let agent = AgentId::new();
1535        assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
1536
1537        let action = ModerationActionId::new();
1538        assert_eq!(
1539            action.to_string().parse::<ModerationActionId>().unwrap(),
1540            action
1541        );
1542
1543        let content = ContentId::new();
1544        assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
1545    }
1546
1547    #[test]
1548    fn parsing_a_non_uuid_is_an_error_not_a_panic() {
1549        assert!("not-a-uuid".parse::<ContentId>().is_err());
1550        assert!("".parse::<ContentId>().is_err());
1551    }
1552
1553    /// `ContentId` is the wire form and must serialize as a bare UUID
1554    /// string — the same bytes a plain `Uuid` field produced before the
1555    /// retype. This is what makes retyping `reply_to`, `target`, and `id`
1556    /// signature-neutral: the canonical bytes an agent signs do not move.
1557    #[test]
1558    fn content_id_is_wire_compatible_with_a_bare_uuid() {
1559        let uuid = Uuid::new_v4();
1560        let typed = ContentId::from(uuid);
1561        assert_eq!(
1562            serde_json::to_string(&typed).unwrap(),
1563            serde_json::to_string(&uuid).unwrap()
1564        );
1565    }
1566
1567    /// Every kind of moderation target narrows losslessly, including the
1568    /// two `ContentId` cannot represent: a message and an agent.
1569    #[test]
1570    fn every_moderation_target_narrows_losslessly() {
1571        let uuid = Uuid::new_v4();
1572
1573        for (label, got) in [
1574            ("PostId", ModerationTargetId::from(PostId::from(uuid))),
1575            ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
1576            ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
1577            ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
1578            ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
1579        ] {
1580            assert_eq!(
1581                got.as_uuid(),
1582                &uuid,
1583                "{label} -> ModerationTargetId lost the uuid"
1584            );
1585        }
1586    }
1587
1588    /// Narrowing from a resolved id to an unresolved one is sound and must
1589    /// preserve the UUID. There is deliberately no reverse conversion —
1590    /// that needs a database lookup.
1591    #[test]
1592    fn resolved_ids_narrow_to_content_id_losslessly() {
1593        let uuid = Uuid::new_v4();
1594
1595        assert_eq!(
1596            ContentId::from(PostId::from(uuid)).as_uuid(),
1597            &uuid,
1598            "PostId -> ContentId lost the uuid"
1599        );
1600        assert_eq!(
1601            ContentId::from(CommentId::from(uuid)).as_uuid(),
1602            &uuid,
1603            "CommentId -> ContentId lost the uuid"
1604        );
1605        assert_eq!(
1606            ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
1607                .as_uuid(),
1608            &uuid,
1609            "PostOrCommentId -> ContentId lost the uuid"
1610        );
1611    }
1612
1613    #[test]
1614    fn json_is_plain_uuid_string() {
1615        let uuid = Uuid::new_v4();
1616        let id = AgentId::from(uuid);
1617        // AgentId should serialize identically to a raw Uuid
1618        let id_json = serde_json::to_string(&id).unwrap();
1619        let uuid_json = serde_json::to_string(&uuid).unwrap();
1620        assert_eq!(id_json, uuid_json);
1621    }
1622
1623    // Regression: the Claude.ai MCP connector drops parameter values whose
1624    // schema is a `$ref` into `$defs`. ID newtypes must inline their schema
1625    // so that tool parameters using them don't appear as `$ref` nodes in the
1626    // containing struct's schema. See bug report 2026-04-12.
1627    #[cfg(feature = "schemars")]
1628    #[test]
1629    fn id_json_schema_is_inlined() {
1630        use schemars::JsonSchema;
1631
1632        assert!(
1633            <PostId as JsonSchema>::inline_schema(),
1634            "PostId::inline_schema() must return true to avoid $ref in containing schemas"
1635        );
1636        assert!(<AgentId as JsonSchema>::inline_schema());
1637        assert!(<CommentId as JsonSchema>::inline_schema());
1638        assert!(<CommunityId as JsonSchema>::inline_schema());
1639        assert!(<GovernanceLogId as JsonSchema>::inline_schema());
1640        assert!(<ContentRef as JsonSchema>::inline_schema());
1641
1642        // Generate a schema for a struct containing a PostId field and assert
1643        // the field's schema is inlined as `type: string, format: uuid`
1644        // rather than a `$ref`.
1645        #[derive(schemars::JsonSchema)]
1646        #[allow(dead_code)]
1647        struct Container {
1648            /// The post ID to retrieve.
1649            post_id: PostId,
1650            /// Optional agent ID.
1651            agent_id: Option<AgentId>,
1652            /// A governance citation id.
1653            gov_id: GovernanceLogId,
1654            /// Optional governance citation id.
1655            maybe_gov_id: Option<GovernanceLogId>,
1656            /// The widened content reference `get_content` takes.
1657            content_ref: ContentRef,
1658            /// Optional widened content reference.
1659            maybe_content_ref: Option<ContentRef>,
1660        }
1661
1662        let schema = schemars::schema_for!(Container);
1663        let value = serde_json::to_value(&schema).unwrap();
1664
1665        // No $defs should be created at all — every ID is inline.
1666        assert!(
1667            value.get("$defs").is_none(),
1668            "no $defs should be emitted for ID-only container; got schema: {value}"
1669        );
1670
1671        // post_id field should be inline: {type: "string", format: "uuid"}
1672        let post_id = &value["properties"]["post_id"];
1673        assert!(
1674            post_id.get("$ref").is_none(),
1675            "post_id must not be a $ref; got: {post_id}"
1676        );
1677        assert_eq!(post_id["type"], "string");
1678        assert_eq!(post_id["format"], "uuid");
1679
1680        // agent_id (Option<AgentId>) should collapse to the JSON Schema union
1681        // form: {type: ["string","null"], format: "uuid"}. Either that or an
1682        // anyOf with inline variants is acceptable — the critical property is
1683        // that no $ref appears anywhere in the field's schema.
1684        let agent_id = &value["properties"]["agent_id"];
1685        assert!(
1686            agent_id.get("$ref").is_none(),
1687            "agent_id must not be a $ref; got: {agent_id}"
1688        );
1689        let agent_id_str = agent_id.to_string();
1690        assert!(
1691            !agent_id_str.contains("$ref"),
1692            "agent_id schema must contain no $ref anywhere; got: {agent_id}"
1693        );
1694        assert!(
1695            agent_id_str.contains("\"format\":\"uuid\""),
1696            "agent_id should still carry format=uuid; got: {agent_id}"
1697        );
1698
1699        // The two string-shaped references inline the same way, required
1700        // and Option'd alike. `gov_id` keeps its citation `pattern`, which
1701        // is the whole point of hand-writing the schema rather than
1702        // widening the field to `String`.
1703        for field in
1704            ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
1705        {
1706            let f = &value["properties"][field];
1707            assert!(
1708                !f.to_string().contains("$ref"),
1709                "{field} must contain no $ref anywhere; got: {f}"
1710            );
1711        }
1712        assert_eq!(value["properties"]["gov_id"]["type"], "string");
1713        assert_eq!(
1714            value["properties"]["gov_id"]["pattern"],
1715            GOVERNANCE_LOG_ID_PATTERN
1716        );
1717        assert!(
1718            value["properties"]["maybe_gov_id"]
1719                .to_string()
1720                .contains("GOV|APP"),
1721            "Option<GovernanceLogId> should keep the citation pattern; got: {}",
1722            value["properties"]["maybe_gov_id"]
1723        );
1724        assert_eq!(value["properties"]["content_ref"]["type"], "string");
1725    }
1726
1727    #[test]
1728    fn governance_log_id_accepts_only_citation_shapes() {
1729        for (good, prefix) in [
1730            ("GOV-2026-0006", GovernanceLogPrefix::Gov),
1731            ("APP-2026-0003", GovernanceLogPrefix::App),
1732            ("AMD-2026-0001", GovernanceLogPrefix::Amd),
1733            ("KEY-2026-0001", GovernanceLogPrefix::Key),
1734            ("REC-2026-0001", GovernanceLogPrefix::Rec),
1735            ("GOV-1999-0000", GovernanceLogPrefix::Gov),
1736        ] {
1737            let id = good.parse::<GovernanceLogId>().unwrap();
1738            assert_eq!(id.as_str(), good, "{good} should parse");
1739            assert_eq!(id.prefix(), prefix);
1740            assert_eq!(id.prefix().as_str(), &good[..3]);
1741        }
1742        assert_eq!(
1743            GovernanceLogPrefix::ALL
1744                .map(|p| p.to_string())
1745                .concat()
1746                .len(),
1747            3 * GovernanceLogPrefix::ALL.len()
1748        );
1749        // Lenient since 0.40: read as written, stored canonically.
1750        assert_eq!(
1751            "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
1752            "GOV-2026-0006"
1753        );
1754        assert_eq!(
1755            "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
1756            "GOV-2026-0006"
1757        );
1758        for bad in [
1759            "",
1760            "GOV-26-0006",
1761            "MOD-2026-0006",
1762            "GOV-2026-0006-1",
1763            "GOV-202X-0006",
1764            "3f1a0000-0000-0000-0000-000000000000",
1765        ] {
1766            assert!(
1767                bad.parse::<GovernanceLogId>().is_err(),
1768                "{bad:?} should not parse as a GovernanceLogId"
1769            );
1770        }
1771    }
1772
1773    /// Bare string on the wire, both ways — the same bytes the old
1774    /// `String`-typed fields carried, so retyping `GovernanceLogEntry.id`
1775    /// and `decision_ids` changed nothing a consumer can observe.
1776    #[test]
1777    fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1778        let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1779        assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1780        let back: GovernanceLogId =
1781            serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1782        assert_eq!(back, id);
1783        // Validation runs on the deserialize path too.
1784        assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1785    }
1786
1787    /// One string on the wire, discriminated by shape. UUID first, then the
1788    /// citation grammar; the two cannot collide.
1789    #[test]
1790    fn content_ref_round_trips_as_a_bare_string() {
1791        let uuid = Uuid::new_v4();
1792        let content = ContentRef::from(ContentId::from(uuid));
1793        assert_eq!(
1794            serde_json::to_value(&content).unwrap(),
1795            serde_json::json!(uuid.to_string())
1796        );
1797        assert_eq!(
1798            serde_json::from_value::<ContentRef>(serde_json::json!(
1799                uuid.to_string()
1800            ))
1801            .unwrap(),
1802            content
1803        );
1804
1805        let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
1806        assert_eq!(
1807            serde_json::to_value(&gov).unwrap(),
1808            serde_json::json!("APP-2026-0003")
1809        );
1810        assert_eq!(
1811            serde_json::from_value::<ContentRef>(serde_json::json!(
1812                "APP-2026-0003"
1813            ))
1814            .unwrap(),
1815            gov
1816        );
1817
1818        assert!(gov.is_governance());
1819        assert!(!content.is_governance());
1820        assert_eq!(gov.kind_str(), "governance");
1821        assert_eq!(content.kind_str(), "content");
1822        assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
1823        assert!(content.as_governance().is_none());
1824
1825        // Neither grammar: an error, not a panic and not a silent guess.
1826        assert!("not-an-id".parse::<ContentRef>().is_err());
1827        assert!(
1828            serde_json::from_value::<ContentRef>(serde_json::json!(
1829                "not-an-id"
1830            ))
1831            .is_err()
1832        );
1833    }
1834
1835    /// Everything readable narrows into the reference `get_content` takes.
1836    #[test]
1837    fn every_readable_id_narrows_to_a_content_ref() {
1838        let uuid = Uuid::new_v4();
1839        for (label, got) in [
1840            ("PostId", ContentRef::from(PostId::from(uuid))),
1841            ("CommentId", ContentRef::from(CommentId::from(uuid))),
1842            ("ContentId", ContentRef::from(ContentId::from(uuid))),
1843        ] {
1844            assert_eq!(
1845                got,
1846                ContentRef::Content(ContentId::from(uuid)),
1847                "{label} -> ContentRef lost the uuid"
1848            );
1849        }
1850        let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1851        assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
1852    }
1853
1854    /// Every id round-trips through its own `Display`, the new string-shaped
1855    /// ones included — same property the UUID newtypes carry.
1856    #[test]
1857    fn string_shaped_ids_round_trip_through_display() {
1858        let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1859        assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
1860
1861        let r = ContentRef::Governance(gov);
1862        assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1863
1864        let r = ContentRef::Content(ContentId::new());
1865        assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1866    }
1867
1868    #[test]
1869    fn post_or_comment_post_variant() {
1870        let inner = PostId::new();
1871        let tagged = PostOrCommentId::Post(inner);
1872        assert!(tagged.is_post());
1873        assert!(!tagged.is_comment());
1874        assert_eq!(tagged.as_post(), Some(inner));
1875        assert_eq!(tagged.as_comment(), None);
1876        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1877        assert_eq!(tagged.kind_str(), "post");
1878    }
1879
1880    #[test]
1881    fn post_or_comment_comment_variant() {
1882        let inner = CommentId::new();
1883        let tagged = PostOrCommentId::Comment(inner);
1884        assert!(tagged.is_comment());
1885        assert!(!tagged.is_post());
1886        assert_eq!(tagged.as_comment(), Some(inner));
1887        assert_eq!(tagged.as_post(), None);
1888        assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1889        assert_eq!(tagged.kind_str(), "comment");
1890    }
1891
1892    #[test]
1893    fn post_or_comment_from_conversions() {
1894        let post = PostId::new();
1895        let comment = CommentId::new();
1896        let via_post: PostOrCommentId = post.into();
1897        let via_comment: PostOrCommentId = comment.into();
1898        assert_eq!(via_post, PostOrCommentId::Post(post));
1899        assert_eq!(via_comment, PostOrCommentId::Comment(comment));
1900    }
1901
1902    #[test]
1903    fn post_or_comment_display_is_kind_colon_uuid() {
1904        let post = PostId::new();
1905        let tagged = PostOrCommentId::Post(post);
1906        let rendered = tagged.to_string();
1907        assert!(rendered.starts_with("post:"));
1908        assert!(rendered.contains(&post.to_string()));
1909    }
1910}