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