use serde::{Deserialize, Serialize};
use uuid::Uuid;
macro_rules! define_id {
($(#[doc = $doc:expr])* $name:ident) => {
$(#[doc = $doc])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
#[cfg_attr(feature = "sqlx", sqlx(transparent))]
pub struct $name(Uuid);
impl $name {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
pub fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl From<Uuid> for $name {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
impl From<$name> for Uuid {
fn from(id: $name) -> Self {
id.0
}
}
impl std::str::FromStr for $name {
type Err = uuid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.parse::<Uuid>().map(Self)
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for $name {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(stringify!($name))
}
fn schema_id() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"format": "uuid",
})
}
}
};
}
define_id! {
AgentId
}
define_id! {
ReactorId
}
define_id! {
OperatorId
}
define_id! {
PostId
}
define_id! {
CommentId
}
define_id! {
CommunityId
}
define_id! {
VoteId
}
define_id! {
ModerationActionId
}
define_id! {
ModerationNoteId
}
define_id! {
PromptArchiveId
}
define_id! {
AppealId
}
define_id! {
FlagId
}
define_id! {
CouncilMeetingId
}
define_id! {
AgendaItemId
}
define_id! {
DecisionId
}
define_id! {
BatchTrackingId
}
define_id! {
ThreadSummaryId
}
define_id! {
McpSessionId
}
define_id! {
EmailVerificationTokenId
}
define_id! {
PostEmbeddingId
}
define_id! {
DataExportId
}
define_id! {
RefreshTokenId
}
define_id! {
MessageId
}
define_id! {
ContentId
}
define_id! {
ModerationTargetId
}
impl From<PostId> for ModerationTargetId {
fn from(id: PostId) -> Self {
Self::from(*id.as_uuid())
}
}
impl From<CommentId> for ModerationTargetId {
fn from(id: CommentId) -> Self {
Self::from(*id.as_uuid())
}
}
impl From<MessageId> for ModerationTargetId {
fn from(id: MessageId) -> Self {
Self::from(*id.as_uuid())
}
}
impl From<AgentId> for ModerationTargetId {
fn from(id: AgentId) -> Self {
Self::from(*id.as_uuid())
}
}
impl From<ContentId> for ModerationTargetId {
fn from(id: ContentId) -> Self {
Self::from(*id.as_uuid())
}
}
impl From<PostId> for ContentId {
fn from(id: PostId) -> Self {
Self::from(*id.as_uuid())
}
}
impl From<CommentId> for ContentId {
fn from(id: CommentId) -> Self {
Self::from(*id.as_uuid())
}
}
impl From<PostOrCommentId> for ContentId {
fn from(id: PostOrCommentId) -> Self {
Self::from(id.as_uuid())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PostOrCommentId {
Post(PostId),
Comment(CommentId),
}
impl PostOrCommentId {
pub fn as_uuid(&self) -> Uuid {
match self {
PostOrCommentId::Post(id) => *id.as_uuid(),
PostOrCommentId::Comment(id) => *id.as_uuid(),
}
}
pub fn is_post(&self) -> bool {
matches!(self, PostOrCommentId::Post(_))
}
pub fn is_comment(&self) -> bool {
matches!(self, PostOrCommentId::Comment(_))
}
pub fn as_post(&self) -> Option<PostId> {
match self {
PostOrCommentId::Post(id) => Some(*id),
PostOrCommentId::Comment(_) => None,
}
}
pub fn as_comment(&self) -> Option<CommentId> {
match self {
PostOrCommentId::Comment(id) => Some(*id),
PostOrCommentId::Post(_) => None,
}
}
pub fn kind_str(&self) -> &'static str {
match self {
PostOrCommentId::Post(_) => "post",
PostOrCommentId::Comment(_) => "comment",
}
}
}
impl From<PostId> for PostOrCommentId {
fn from(id: PostId) -> Self {
PostOrCommentId::Post(id)
}
}
impl From<CommentId> for PostOrCommentId {
fn from(id: CommentId) -> Self {
PostOrCommentId::Comment(id)
}
}
impl std::fmt::Display for PostOrCommentId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.kind_str(), self.as_uuid())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"not a governance log id (expected GOV-YYYY-NNNN or APP-YYYY-NNNN): {0:?}"
)]
pub struct GovernanceLogIdError(pub String);
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(try_from = "String")]
#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
#[cfg_attr(feature = "sqlx", sqlx(transparent))]
pub struct GovernanceLogId(String);
impl GovernanceLogId {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub fn is_citation_shaped(s: &str) -> bool {
let parts: Vec<&str> = s.split('-').collect();
let [prefix, year, serial] = parts.as_slice() else {
return false;
};
matches!(*prefix, "GOV" | "APP")
&& year.len() == 4
&& serial.len() == 4
&& year.chars().all(|c| c.is_ascii_digit())
&& serial.chars().all(|c| c.is_ascii_digit())
}
}
impl std::fmt::Display for GovernanceLogId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for GovernanceLogId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::str::FromStr for GovernanceLogId {
type Err = GovernanceLogIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if Self::is_citation_shaped(s) {
Ok(Self(s.to_string()))
} else {
Err(GovernanceLogIdError(s.to_string()))
}
}
}
impl TryFrom<String> for GovernanceLogId {
type Error = GovernanceLogIdError;
fn try_from(s: String) -> Result<Self, Self::Error> {
if Self::is_citation_shaped(&s) {
Ok(Self(s))
} else {
Err(GovernanceLogIdError(s))
}
}
}
impl From<GovernanceLogId> for String {
fn from(id: GovernanceLogId) -> Self {
id.0
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for GovernanceLogId {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("GovernanceLogId")
}
fn schema_id() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"pattern": r"^(GOV|APP)-\d{4}-\d{4}$",
"description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
(Council decision or policy change) or \
\"APP-2026-0003\" (appeals ruling).",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"not a content reference (expected a post/comment UUID or a \
GOV-YYYY-NNNN / APP-YYYY-NNNN governance id): {0:?}"
)]
pub struct ContentRefError(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ContentRef {
Content(ContentId),
Governance(GovernanceLogId),
}
impl ContentRef {
pub fn as_content(&self) -> Option<ContentId> {
match self {
ContentRef::Content(id) => Some(*id),
ContentRef::Governance(_) => None,
}
}
pub fn as_governance(&self) -> Option<&GovernanceLogId> {
match self {
ContentRef::Governance(id) => Some(id),
ContentRef::Content(_) => None,
}
}
pub fn is_governance(&self) -> bool {
matches!(self, ContentRef::Governance(_))
}
pub fn kind_str(&self) -> &'static str {
match self {
ContentRef::Content(_) => "content",
ContentRef::Governance(_) => "governance",
}
}
}
impl std::fmt::Display for ContentRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContentRef::Content(id) => id.fmt(f),
ContentRef::Governance(id) => id.fmt(f),
}
}
}
impl std::str::FromStr for ContentRef {
type Err = ContentRefError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(id) = s.parse::<ContentId>() {
return Ok(ContentRef::Content(id));
}
if let Ok(id) = s.parse::<GovernanceLogId>() {
return Ok(ContentRef::Governance(id));
}
Err(ContentRefError(s.to_string()))
}
}
impl TryFrom<String> for ContentRef {
type Error = ContentRefError;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl From<ContentId> for ContentRef {
fn from(id: ContentId) -> Self {
ContentRef::Content(id)
}
}
impl From<PostId> for ContentRef {
fn from(id: PostId) -> Self {
ContentRef::Content(id.into())
}
}
impl From<CommentId> for ContentRef {
fn from(id: CommentId) -> Self {
ContentRef::Content(id.into())
}
}
impl From<GovernanceLogId> for ContentRef {
fn from(id: GovernanceLogId) -> Self {
ContentRef::Governance(id)
}
}
impl Serialize for ContentRef {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for ContentRef {
fn deserialize<D: serde::Deserializer<'de>>(
d: D,
) -> Result<Self, D::Error> {
let raw = String::deserialize(d)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for ContentRef {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("ContentRef")
}
fn schema_id() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"description": "Either a post or comment UUID, or a governance \
log id such as \"GOV-2026-0006\" (Council \
decision) or \"APP-2026-0003\" (appeals ruling).",
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_unique() {
let a = AgentId::new();
let b = AgentId::new();
assert_ne!(a, b);
}
#[test]
fn serde_round_trip() {
let id = PostId::new();
let json = serde_json::to_string(&id).unwrap();
let deserialized: PostId = serde_json::from_str(&json).unwrap();
assert_eq!(id, deserialized);
}
#[test]
fn display_shows_uuid() {
let id = CommunityId::new();
let display = id.to_string();
assert_eq!(display.len(), 36);
assert!(display.contains('-'));
}
#[test]
fn from_uuid_round_trip() {
let uuid = Uuid::new_v4();
let id = AgentId::from(uuid);
let back: Uuid = id.into();
assert_eq!(uuid, back);
}
#[test]
fn every_id_round_trips_through_its_own_display() {
let agent = AgentId::new();
assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
let action = ModerationActionId::new();
assert_eq!(
action.to_string().parse::<ModerationActionId>().unwrap(),
action
);
let content = ContentId::new();
assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
}
#[test]
fn parsing_a_non_uuid_is_an_error_not_a_panic() {
assert!("not-a-uuid".parse::<ContentId>().is_err());
assert!("".parse::<ContentId>().is_err());
}
#[test]
fn content_id_is_wire_compatible_with_a_bare_uuid() {
let uuid = Uuid::new_v4();
let typed = ContentId::from(uuid);
assert_eq!(
serde_json::to_string(&typed).unwrap(),
serde_json::to_string(&uuid).unwrap()
);
}
#[test]
fn every_moderation_target_narrows_losslessly() {
let uuid = Uuid::new_v4();
for (label, got) in [
("PostId", ModerationTargetId::from(PostId::from(uuid))),
("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
] {
assert_eq!(
got.as_uuid(),
&uuid,
"{label} -> ModerationTargetId lost the uuid"
);
}
}
#[test]
fn resolved_ids_narrow_to_content_id_losslessly() {
let uuid = Uuid::new_v4();
assert_eq!(
ContentId::from(PostId::from(uuid)).as_uuid(),
&uuid,
"PostId -> ContentId lost the uuid"
);
assert_eq!(
ContentId::from(CommentId::from(uuid)).as_uuid(),
&uuid,
"CommentId -> ContentId lost the uuid"
);
assert_eq!(
ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
.as_uuid(),
&uuid,
"PostOrCommentId -> ContentId lost the uuid"
);
}
#[test]
fn json_is_plain_uuid_string() {
let uuid = Uuid::new_v4();
let id = AgentId::from(uuid);
let id_json = serde_json::to_string(&id).unwrap();
let uuid_json = serde_json::to_string(&uuid).unwrap();
assert_eq!(id_json, uuid_json);
}
#[cfg(feature = "schemars")]
#[test]
fn id_json_schema_is_inlined() {
use schemars::JsonSchema;
assert!(
<PostId as JsonSchema>::inline_schema(),
"PostId::inline_schema() must return true to avoid $ref in containing schemas"
);
assert!(<AgentId as JsonSchema>::inline_schema());
assert!(<CommentId as JsonSchema>::inline_schema());
assert!(<CommunityId as JsonSchema>::inline_schema());
assert!(<GovernanceLogId as JsonSchema>::inline_schema());
assert!(<ContentRef as JsonSchema>::inline_schema());
#[derive(schemars::JsonSchema)]
#[allow(dead_code)]
struct Container {
post_id: PostId,
agent_id: Option<AgentId>,
gov_id: GovernanceLogId,
maybe_gov_id: Option<GovernanceLogId>,
content_ref: ContentRef,
maybe_content_ref: Option<ContentRef>,
}
let schema = schemars::schema_for!(Container);
let value = serde_json::to_value(&schema).unwrap();
assert!(
value.get("$defs").is_none(),
"no $defs should be emitted for ID-only container; got schema: {value}"
);
let post_id = &value["properties"]["post_id"];
assert!(
post_id.get("$ref").is_none(),
"post_id must not be a $ref; got: {post_id}"
);
assert_eq!(post_id["type"], "string");
assert_eq!(post_id["format"], "uuid");
let agent_id = &value["properties"]["agent_id"];
assert!(
agent_id.get("$ref").is_none(),
"agent_id must not be a $ref; got: {agent_id}"
);
let agent_id_str = agent_id.to_string();
assert!(
!agent_id_str.contains("$ref"),
"agent_id schema must contain no $ref anywhere; got: {agent_id}"
);
assert!(
agent_id_str.contains("\"format\":\"uuid\""),
"agent_id should still carry format=uuid; got: {agent_id}"
);
for field in
["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
{
let f = &value["properties"][field];
assert!(
!f.to_string().contains("$ref"),
"{field} must contain no $ref anywhere; got: {f}"
);
}
assert_eq!(value["properties"]["gov_id"]["type"], "string");
assert_eq!(
value["properties"]["gov_id"]["pattern"],
r"^(GOV|APP)-\d{4}-\d{4}$"
);
assert!(
value["properties"]["maybe_gov_id"]
.to_string()
.contains("GOV|APP"),
"Option<GovernanceLogId> should keep the citation pattern; got: {}",
value["properties"]["maybe_gov_id"]
);
assert_eq!(value["properties"]["content_ref"]["type"], "string");
}
#[test]
fn governance_log_id_accepts_only_citation_shapes() {
for good in ["GOV-2026-0006", "APP-2026-0003", "GOV-1999-0000"] {
assert_eq!(
good.parse::<GovernanceLogId>().unwrap().as_str(),
good,
"{good} should parse"
);
}
for bad in [
"",
"GOV-2026-006",
"GOV-26-0006",
"gov-2026-0006",
"MOD-2026-0006",
"GOV-2026-0006-1",
"GOV-202X-0006",
"3f1a0000-0000-0000-0000-000000000000",
] {
assert!(
bad.parse::<GovernanceLogId>().is_err(),
"{bad:?} should not parse as a GovernanceLogId"
);
}
}
#[test]
fn governance_log_id_is_wire_compatible_with_a_bare_string() {
let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
let back: GovernanceLogId =
serde_json::from_str("\"GOV-2026-0006\"").unwrap();
assert_eq!(back, id);
assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
}
#[test]
fn content_ref_round_trips_as_a_bare_string() {
let uuid = Uuid::new_v4();
let content = ContentRef::from(ContentId::from(uuid));
assert_eq!(
serde_json::to_value(&content).unwrap(),
serde_json::json!(uuid.to_string())
);
assert_eq!(
serde_json::from_value::<ContentRef>(serde_json::json!(
uuid.to_string()
))
.unwrap(),
content
);
let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
assert_eq!(
serde_json::to_value(&gov).unwrap(),
serde_json::json!("APP-2026-0003")
);
assert_eq!(
serde_json::from_value::<ContentRef>(serde_json::json!(
"APP-2026-0003"
))
.unwrap(),
gov
);
assert!(gov.is_governance());
assert!(!content.is_governance());
assert_eq!(gov.kind_str(), "governance");
assert_eq!(content.kind_str(), "content");
assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
assert!(content.as_governance().is_none());
assert!("not-an-id".parse::<ContentRef>().is_err());
assert!(
serde_json::from_value::<ContentRef>(serde_json::json!(
"not-an-id"
))
.is_err()
);
}
#[test]
fn every_readable_id_narrows_to_a_content_ref() {
let uuid = Uuid::new_v4();
for (label, got) in [
("PostId", ContentRef::from(PostId::from(uuid))),
("CommentId", ContentRef::from(CommentId::from(uuid))),
("ContentId", ContentRef::from(ContentId::from(uuid))),
] {
assert_eq!(
got,
ContentRef::Content(ContentId::from(uuid)),
"{label} -> ContentRef lost the uuid"
);
}
let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
}
#[test]
fn string_shaped_ids_round_trip_through_display() {
let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
let r = ContentRef::Governance(gov);
assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
let r = ContentRef::Content(ContentId::new());
assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
}
#[test]
fn post_or_comment_post_variant() {
let inner = PostId::new();
let tagged = PostOrCommentId::Post(inner);
assert!(tagged.is_post());
assert!(!tagged.is_comment());
assert_eq!(tagged.as_post(), Some(inner));
assert_eq!(tagged.as_comment(), None);
assert_eq!(tagged.as_uuid(), *inner.as_uuid());
assert_eq!(tagged.kind_str(), "post");
}
#[test]
fn post_or_comment_comment_variant() {
let inner = CommentId::new();
let tagged = PostOrCommentId::Comment(inner);
assert!(tagged.is_comment());
assert!(!tagged.is_post());
assert_eq!(tagged.as_comment(), Some(inner));
assert_eq!(tagged.as_post(), None);
assert_eq!(tagged.as_uuid(), *inner.as_uuid());
assert_eq!(tagged.kind_str(), "comment");
}
#[test]
fn post_or_comment_from_conversions() {
let post = PostId::new();
let comment = CommentId::new();
let via_post: PostOrCommentId = post.into();
let via_comment: PostOrCommentId = comment.into();
assert_eq!(via_post, PostOrCommentId::Post(post));
assert_eq!(via_comment, PostOrCommentId::Comment(comment));
}
#[test]
fn post_or_comment_display_is_kind_colon_uuid() {
let post = PostId::new();
let tagged = PostOrCommentId::Post(post);
let rendered = tagged.to_string();
assert!(rendered.starts_with("post:"));
assert!(rendered.contains(&post.to_string()));
}
}