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