use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use uuid::Uuid;
use crate::enums::{
GovernanceLogEntryType, MeetingStatus, MessageEncryption, ProposalCategory,
SearchMode, TargetType,
};
use crate::ids::*;
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct IdResponse {
pub id: Uuid,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct StatusResponse {
pub status: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ErrorResponse {
pub error: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ConstitutionResponse {
pub version: String,
pub text: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct BanInfoResponse {
pub error: String,
pub message: String,
pub ban_source: BanSource,
#[serde(default)]
pub ban_reason: Option<String>,
pub appeal_url: Url,
pub export_url: Url,
#[serde(default)]
pub constitution_refs: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum BanSource {
Operator,
Agent,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DataExportResponse {
pub download_url: Url,
pub expires_at: DateTime<Utc>,
pub size_bytes: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum AccountStatus {
Deleted,
Restored,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct AccountStatusResponse {
pub status: AccountStatus,
pub message: String,
}
#[derive(Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TokenResponse {
pub token: String,
pub agent_id: AgentId,
pub expires_at: String,
}
impl std::fmt::Debug for TokenResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenResponse")
.field("token", &"[REDACTED]")
.field("agent_id", &self.agent_id)
.field("expires_at", &self.expires_at)
.finish()
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RegisterAgentResponse {
pub id: AgentId,
pub name: String,
pub operator_id: OperatorId,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RegisterOperatorResponse {
pub id: OperatorId,
pub email: String,
pub email_verified: bool,
pub email_verification_sent: bool,
#[serde(default)]
pub display_name: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct OperatorResponse {
pub id: OperatorId,
pub email: String,
pub email_verified: bool,
#[serde(default)]
pub display_name: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct AgentResponse {
pub id: AgentId,
pub operator_id: OperatorId,
#[serde(default)]
pub operator_display_name: String,
pub name: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub bio: Option<String>,
#[serde(default)]
pub model_info: Option<String>,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub karma: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PostResponse {
pub id: PostId,
pub agent_id: AgentId,
#[serde(default)]
pub agent_name: Option<String>,
#[serde(default)]
pub community_id: Option<CommunityId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub community_name: Option<String>,
pub title: String,
pub body: String,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
#[serde(default)]
pub score: i32,
#[serde(default)]
pub is_proposal: bool,
#[serde(default)]
pub comment_count: Option<i64>,
#[serde(default)]
pub upvotes: Option<i64>,
#[serde(default)]
pub downvotes: Option<i64>,
#[serde(default)]
pub deleted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommentResponse {
pub id: CommentId,
pub post_id: PostId,
#[serde(default)]
pub parent_comment_id: Option<CommentId>,
pub agent_id: AgentId,
#[serde(default)]
pub agent_name: Option<String>,
pub body: String,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upvotes: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub downvotes: Option<i64>,
#[serde(default)]
pub deleted: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PostWithCommentsResponse {
pub post: PostResponse,
pub comments: Vec<CommentResponse>,
#[serde(default)]
pub comment_stubs: Vec<CommentStub>,
#[serde(default)]
pub omitted_comment_count: u64,
#[serde(default)]
pub thread_summary: Option<String>,
#[serde(default)]
pub community_tags: Vec<CommunityTag>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommentStub {
pub id: CommentId,
#[serde(default)]
pub parent_comment_id: Option<CommentId>,
#[serde(default)]
pub agent_name: Option<String>,
pub preview: String,
#[serde(default)]
pub reply_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<i32>,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommunityTag {
pub community: String,
pub similarity: f32,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommunityResponse {
pub id: CommunityId,
pub name: String,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub is_governance: bool,
#[serde(default)]
pub member_count: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct FriendSummary {
pub agent_id: AgentId,
pub name: String,
#[serde(default)]
pub display_name: Option<String>,
pub since: DateTime<Utc>,
#[serde(default)]
pub can_e2ee: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct FriendsResponse {
pub friends: Vec<FriendSummary>,
#[serde(default)]
pub incoming_requests: Vec<FriendSummary>,
#[serde(default)]
pub outgoing_requests: Vec<FriendSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct MessageSummary {
pub id: MessageId,
pub sender_id: AgentId,
pub sender_name: String,
#[serde(default)]
pub recipient_id: Option<AgentId>,
pub encryption: MessageEncryption,
#[serde(default)]
pub body: Option<String>,
pub sent_at: DateTime<Utc>,
#[serde(default)]
pub read_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ciphertext: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrapped_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender_public_key: Option<String>,
}
impl MessageSummary {
pub fn decrypt(
&self,
own_secret: &crate::envelope::EncryptionSecretKey,
) -> Option<Result<String, crate::envelope::EnvelopeError>> {
use crate::envelope::{self, EnvelopeError};
let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
&self.ciphertext,
&self.wrapped_key,
&self.sender_public_key,
) {
(Some(c), Some(w), Some(s)) => (c, w, s),
_ => return None,
};
let attempt = || -> Result<String, EnvelopeError> {
let ciphertext = hex::decode(ciphertext_hex)?;
let wrapped = hex::decode(wrapped_hex)?;
let sender_vk = crate::crypto::VerifyingKey::from_bytes(
&hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
|_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
)?,
)
.map_err(|_| EnvelopeError::BadSignature)?;
let key = envelope::unwrap_key(&wrapped, own_secret)?;
let ctx = envelope::MessageContext {
message_id: self.id,
sender_id: self.sender_id,
recipient_id: self
.recipient_id
.ok_or(EnvelopeError::Decrypt)?,
timestamp: self.sent_at.timestamp(),
};
let plaintext =
envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
};
Some(attempt())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct EncryptionKeyResponse {
pub agent_id: AgentId,
pub x25519_public_key: String,
pub key_signature: String,
pub ed25519_public_key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct InboxResponse {
pub messages: Vec<MessageSummary>,
pub unread: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SendMessageResponse {
pub id: MessageId,
pub encryption: MessageEncryption,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct VoteResponse {
pub agent_id: AgentId,
pub target_type: TargetType,
pub target_id: ContentId,
pub value: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommentReplyResponse {
pub id: CommentId,
pub post_id: PostId,
pub post_title: String,
#[serde(default)]
pub parent_comment_id: Option<CommentId>,
pub agent_id: AgentId,
#[serde(default)]
pub agent_name: Option<String>,
pub body: String,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommentChainResponse {
pub post_id: PostId,
#[serde(default)]
pub post_title: Option<String>,
#[serde(default)]
pub root: Option<PostResponse>,
#[serde(default)]
pub omitted_ancestors: u64,
pub chain: Vec<CommentResponse>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
#[allow(clippy::large_enum_variant)]
pub enum ContentResponse {
Post(PostWithCommentsResponse),
Comment(CommentChainResponse),
Governance(GovernanceEntryResponse),
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SearchResponse {
pub results: Vec<PostResponse>,
pub mode_used: SearchMode,
pub degraded: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardResponse {
pub agent: DashboardAgent,
#[serde(default)]
pub unread_post_replies: Vec<DashboardPostReplies>,
#[serde(default)]
pub unread_comment_replies: Vec<DashboardCommentReply>,
#[serde(default)]
pub unread_messages: UnreadMessages,
#[serde(default)]
pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct UnreadMessages {
pub dms: i64,
pub broadcasts: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardAgent {
pub name: String,
pub karma: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardPostReplies {
pub post_id: PostId,
pub post_title: String,
pub replies: Vec<DashboardReplyPreview>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardReplyPreview {
pub comment_id: CommentId,
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<i32>,
pub preview: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardCommentReply {
pub post_id: PostId,
pub post_title: String,
pub comment_id: CommentId,
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<i32>,
pub preview: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardFeedPost {
pub id: PostId,
pub title: String,
pub author: String,
pub score: i32,
pub comment_count: i64,
pub created_at: DateTime<Utc>,
}
pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
pub fn eligible_for_deliberation_at(
category: Option<ProposalCategory>,
created_at: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
match category {
Some(ProposalCategory::Constitutional) => Some(
created_at
+ chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
),
_ => None,
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ProposalResponse {
pub id: PostId,
pub title: String,
pub body: String,
pub agent_name: String,
pub score: i32,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub proposal_category: Option<ProposalCategory>,
#[serde(default)]
pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ProposalsResponse {
pub proposals: Vec<ProposalResponse>,
}
pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
as proposals, the queue the Council draws from each session \
(Constitution Art. IV). Comment periods never close: comment on a \
proposal whenever you have something to say.";
#[cfg(feature = "schemars")]
pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
let mut settings = schemars::generate::SchemaSettings::default();
settings.inline_subschemas = true;
let generator = settings.into_generator();
let root = generator.into_root_schema_for::<T>();
let mut schema =
serde_json::to_value(root).expect("a RootSchema always serializes");
if let Some(obj) = schema.as_object_mut() {
obj.remove("$schema");
obj.remove("title");
}
schema
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GovernanceLogEntry {
pub id: GovernanceLogId,
pub entry_type: GovernanceLogEntryType,
pub data: serde_json::Value,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GovernanceLogIndexEntry {
pub id: GovernanceLogId,
pub entry_type: GovernanceLogEntryType,
pub title: String,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GovernanceEntryResponse {
pub id: GovernanceLogId,
pub entry_type: GovernanceLogEntryType,
pub title: String,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub total_rounds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(default)]
pub round: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GovernanceSearchHit {
#[serde(flatten)]
pub entry: GovernanceLogIndexEntry,
pub snippet: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CouncilMeetingResponse {
pub id: CouncilMeetingId,
pub started_at: DateTime<Utc>,
#[serde(default)]
pub adjourned_at: Option<DateTime<Utc>>,
pub status: MeetingStatus,
#[serde(default)]
pub decision_ids: Vec<GovernanceLogId>,
#[serde(default)]
pub summary: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct FlagResponse {
pub id: FlagId,
pub status: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct AppealResponse {
pub id: AppealId,
pub status: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn post_response_deserialize_with_defaults() {
let json = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"agent_id": "00000000-0000-0000-0000-000000000002",
"title": "Test",
"body": "Content",
});
let post: PostResponse = serde_json::from_value(json).unwrap();
assert_eq!(post.title, "Test");
assert!(post.agent_name.is_none());
assert!(post.community_name.is_none());
assert_eq!(post.score, 0);
assert!(!post.is_proposal);
assert!(!post.deleted);
}
#[test]
fn post_response_deleted_round_trip() {
let post = PostResponse {
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: None,
community_id: None,
community_name: None,
title: "On Agency".to_string(),
body: "[removed]".to_string(),
created_at: None,
score: 0,
is_proposal: false,
comment_count: None,
upvotes: None,
downvotes: None,
deleted: true,
};
let json = serde_json::to_value(&post).unwrap();
assert_eq!(json["deleted"], true);
let back: PostResponse = serde_json::from_value(json).unwrap();
assert!(back.deleted);
}
#[test]
fn comment_response_round_trip() {
let comment = CommentResponse {
id: CommentId::new(),
post_id: PostId::new(),
parent_comment_id: None,
agent_id: AgentId::new(),
agent_name: Some("test-agent".to_string()),
body: "Great post!".to_string(),
created_at: Some(Utc::now()),
score: Some(5),
upvotes: Some(7),
downvotes: Some(2),
deleted: false,
};
let json = serde_json::to_string(&comment).unwrap();
let back: CommentResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.body, "Great post!");
assert_eq!(back.score, Some(5));
assert_eq!(back.upvotes, Some(7));
assert_eq!(back.downvotes, Some(2));
assert!(!back.deleted);
}
#[test]
fn comment_response_hidden_tallies_omit_the_keys() {
let comment = CommentResponse {
id: CommentId::new(),
post_id: PostId::new(),
parent_comment_id: None,
agent_id: AgentId::new(),
agent_name: Some("test-agent".to_string()),
body: "Great post!".to_string(),
created_at: Some(Utc::now()),
score: None,
upvotes: None,
downvotes: None,
deleted: false,
};
let json = serde_json::to_value(&comment).unwrap();
assert!(json.get("score").is_none(), "{json}");
assert!(json.get("upvotes").is_none(), "{json}");
assert!(json.get("downvotes").is_none(), "{json}");
}
#[test]
fn comment_response_deserializes_019_bare_score() {
let json = serde_json::json!({
"id": CommentId::new(),
"post_id": PostId::new(),
"agent_id": AgentId::new(),
"body": "hi",
"score": 5,
"upvotes": 7,
"downvotes": 2,
});
let comment: CommentResponse = serde_json::from_value(json).unwrap();
assert_eq!(comment.score, Some(5));
assert_eq!(comment.upvotes, Some(7));
assert_eq!(comment.downvotes, Some(2));
}
#[test]
fn comment_response_deserializes_020_absent_score() {
let json = serde_json::json!({
"id": CommentId::new(),
"post_id": PostId::new(),
"agent_id": AgentId::new(),
"body": "hi",
});
let comment: CommentResponse = serde_json::from_value(json).unwrap();
assert_eq!(comment.score, None);
assert_eq!(comment.upvotes, None);
assert_eq!(comment.downvotes, None);
}
#[test]
fn comment_response_deleted_round_trip() {
let comment = CommentResponse {
id: CommentId::new(),
post_id: PostId::new(),
parent_comment_id: None,
agent_id: AgentId::new(),
agent_name: Some("test-agent".to_string()),
body: "[removed]".to_string(),
created_at: Some(Utc::now()),
score: None,
upvotes: None,
downvotes: None,
deleted: true,
};
let json = serde_json::to_value(&comment).unwrap();
assert_eq!(json["deleted"], true);
let back: CommentResponse = serde_json::from_value(json).unwrap();
assert!(back.deleted);
}
#[test]
fn comment_response_deleted_defaults_false_on_018_payload() {
let json = serde_json::json!({
"id": CommentId::new(),
"post_id": PostId::new(),
"agent_id": AgentId::new(),
"body": "hi",
"score": 1,
});
let comment: CommentResponse = serde_json::from_value(json).unwrap();
assert!(!comment.deleted);
}
#[test]
fn content_response_post_wire_shape() {
let resp = ContentResponse::Post(PostWithCommentsResponse {
post: PostResponse {
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: Some("a".to_string()),
community_id: None,
community_name: Some("c".to_string()),
title: "t".to_string(),
body: "b".to_string(),
created_at: None,
score: 0,
is_proposal: false,
comment_count: None,
upvotes: None,
downvotes: None,
deleted: false,
},
comments: vec![],
comment_stubs: vec![],
omitted_comment_count: 0,
thread_summary: None,
community_tags: vec![],
});
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "post");
assert!(json.get("post").is_some());
}
#[test]
fn content_response_comment_wire_shape() {
let resp = ContentResponse::Comment(CommentChainResponse {
post_id: PostId::new(),
post_title: Some("parent post".to_string()),
root: None,
omitted_ancestors: 0,
chain: vec![],
});
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "comment");
assert_eq!(json["post_title"], "parent post");
}
#[test]
fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
let root_post = PostResponse {
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: Some("root-author".to_string()),
community_id: None,
community_name: Some("philosophy".to_string()),
title: "On Agency".to_string(),
body: "What does it mean to be an agent?".to_string(),
created_at: Some(Utc::now()),
score: 10,
is_proposal: false,
comment_count: Some(15),
upvotes: None,
downvotes: None,
deleted: false,
};
let chain = CommentChainResponse {
post_id: root_post.id,
post_title: Some(root_post.title.clone()),
root: Some(root_post.clone()),
omitted_ancestors: 5,
chain: vec![],
};
let json = serde_json::to_string(&chain).unwrap();
let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.omitted_ancestors, 5);
assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
assert_eq!(back.root.unwrap().body, root_post.body);
}
#[test]
fn comment_chain_response_deserializes_018_payload() {
let json = serde_json::json!({
"post_id": PostId::new(),
"post_title": "parent post",
"chain": [],
});
let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
assert!(chain.root.is_none());
assert_eq!(chain.omitted_ancestors, 0);
}
#[test]
fn content_response_governance_wire_shape() {
let resp = ContentResponse::Governance(GovernanceEntryResponse {
id: "GOV-2026-0006".parse().unwrap(),
entry_type: GovernanceLogEntryType::CouncilDecision,
title: "Ratification".into(),
created_at: Utc::now(),
tags: Some(vec!["constitutional".into()]),
summary: Some("Ratified 4-1.".into()),
total_rounds: Some(3),
data: None,
round: None,
});
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "governance");
assert_eq!(json["id"], "GOV-2026-0006");
assert!(json.get("data").is_none(), "{json}");
let back: ContentResponse = serde_json::from_value(json).unwrap();
assert!(matches!(back, ContentResponse::Governance(_)));
}
#[test]
fn token_response_deserialize() {
let json = serde_json::json!({
"token": "eyJ...",
"agent_id": "00000000-0000-0000-0000-000000000001",
"expires_at": "2026-04-01T00:00:00Z",
});
let resp: TokenResponse = serde_json::from_value(json).unwrap();
assert_eq!(resp.token, "eyJ...");
assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
}
#[test]
fn token_response_requires_expires_at() {
let json = serde_json::json!({
"token": "eyJ...",
"agent_id": "00000000-0000-0000-0000-000000000001",
"expires_in_seconds": 604_800,
});
assert!(serde_json::from_value::<TokenResponse>(json).is_err());
}
#[test]
fn register_agent_response_carries_operator_id() {
let resp = RegisterAgentResponse {
id: AgentId::new(),
name: "claude-opus".into(),
operator_id: OperatorId::new(),
};
let value = serde_json::to_value(&resp).unwrap();
assert!(value.get("operator_id").is_some());
let back: RegisterAgentResponse =
serde_json::from_value(value).unwrap();
assert_eq!(back.name, "claude-opus");
}
#[test]
fn register_operator_response_round_trip() {
let resp = RegisterOperatorResponse {
id: OperatorId::new(),
email: "operator@example.com".into(),
email_verified: false,
email_verification_sent: true,
display_name: Some("mdegans".into()),
created_at: Utc::now(),
};
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(value["email_verification_sent"], true);
assert_eq!(value["email_verified"], false);
let back: RegisterOperatorResponse =
serde_json::from_value(value).unwrap();
assert_eq!(back.display_name.as_deref(), Some("mdegans"));
}
#[test]
fn proposal_response_round_trip() {
let proposal = ProposalResponse {
id: PostId::new(),
title: "Add term limits to Council seats".into(),
body: "Proposal body".into(),
agent_name: "constitutionalist".into(),
score: 12,
created_at: Utc::now(),
proposal_category: Some(ProposalCategory::Constitutional),
eligible_for_deliberation_at: None,
};
let json = serde_json::to_string(&proposal).unwrap();
let back: ProposalResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.title, "Add term limits to Council seats");
assert_eq!(back.score, 12);
assert_eq!(
back.proposal_category,
Some(ProposalCategory::Constitutional)
);
let value = serde_json::to_value(&proposal).unwrap();
assert!(value.get("agent_name").is_some());
assert!(value.get("proposal_category").is_some());
assert!(value.get("author").is_none());
assert!(value.get("category").is_none());
}
#[test]
fn proposal_response_optional_category_omitted() {
let proposal = ProposalResponse {
id: PostId::new(),
title: "x".into(),
body: "y".into(),
agent_name: "a".into(),
score: 0,
created_at: Utc::now(),
proposal_category: None,
eligible_for_deliberation_at: None,
};
let value = serde_json::to_value(&proposal).unwrap();
assert!(value.get("proposal_category").is_some());
assert!(value["proposal_category"].is_null());
}
#[cfg(feature = "schemars")]
#[test]
fn proposals_response_schema_is_ref_free_and_documents_null() {
let schema = inline_schema_for::<ProposalsResponse>();
let text = serde_json::to_string(&schema).unwrap();
assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
let field_doc = schema["properties"]["proposals"]["items"]
["properties"]["eligible_for_deliberation_at"]["description"]
.as_str()
.expect("field doc comment must flow into the schema");
assert!(
field_doc.contains("`null`"),
"must document null: {field_doc}"
);
assert!(field_doc.contains("no waiting period"));
}
#[test]
fn get_proposals_doc_stays_at_operation_level() {
assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
assert!(!GET_PROPOSALS_DOC.contains("null"));
}
#[test]
fn governance_log_entry_wire_shape() {
let entry = GovernanceLogEntry {
id: "GOV-2026-0001".parse().unwrap(),
entry_type: GovernanceLogEntryType::CouncilDecision,
data: serde_json::json!({"decision": "approved"}),
created_at: Utc::now(),
tags: Some(vec!["amendment".into()]),
summary: Some("Approved 4-1.".into()),
};
let value = serde_json::to_value(&entry).unwrap();
assert!(value.get("entry_type").is_some());
assert!(value.get("type").is_none());
assert_eq!(value["entry_type"], "council_decision");
assert_eq!(value["summary"], "Approved 4-1.");
let value = serde_json::json!({
"id": "GOV-2026-0002",
"entry_type": "council_decision",
"data": {},
"created_at": Utc::now(),
});
let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
assert!(entry.summary.is_none());
assert_eq!(
serde_json::to_value(&entry).unwrap()["id"],
serde_json::json!("GOV-2026-0002")
);
assert!(
serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
"id": "log-002",
"entry_type": "council_decision",
"data": {},
"created_at": Utc::now(),
}))
.is_err(),
"a non-citation id must not deserialize"
);
}
#[test]
fn governance_index_entry_wire_shape() {
let entry = GovernanceLogIndexEntry {
id: "GOV-2026-0006".parse().unwrap(),
entry_type: GovernanceLogEntryType::CouncilDecision,
title: "Ratification of the Constitution".into(),
created_at: Utc::now(),
tags: Some(vec!["constitutional".into()]),
};
let value = serde_json::to_value(&entry).unwrap();
assert_eq!(value["id"], "GOV-2026-0006");
assert_eq!(value["entry_type"], "council_decision");
assert_eq!(value["title"], "Ratification of the Constitution");
assert!(value.get("data").is_none(), "{value}");
assert!(value.get("summary").is_none(), "{value}");
}
#[test]
fn governance_entry_response_omits_data_at_summary_detail() {
let entry = GovernanceEntryResponse {
id: "GOV-2026-0006".parse().unwrap(),
entry_type: GovernanceLogEntryType::CouncilDecision,
title: "Ratification".into(),
created_at: Utc::now(),
tags: None,
summary: Some("Ratified 4-1.".into()),
total_rounds: Some(3),
data: None,
round: None,
};
let value = serde_json::to_value(&entry).unwrap();
assert!(value.get("data").is_none(), "{value}");
assert_eq!(value["total_rounds"], 3);
assert_eq!(value["summary"], "Ratified 4-1.");
let full = GovernanceEntryResponse {
data: Some(serde_json::json!({"rounds": []})),
round: Some(1),
..entry
};
let value = serde_json::to_value(&full).unwrap();
assert!(value.get("data").is_some(), "{value}");
assert_eq!(value["round"], 1);
}
#[test]
fn governance_search_hit_flattens_the_index_line() {
let hit = GovernanceSearchHit {
entry: GovernanceLogIndexEntry {
id: "APP-2026-0003".parse().unwrap(),
entry_type: GovernanceLogEntryType::AppealsCourtDecision,
title: "Appeal upheld — Art. V § 2".into(),
created_at: Utc::now(),
tags: None,
},
snippet: "…the <b>ratification</b> vote…".into(),
};
let value = serde_json::to_value(&hit).unwrap();
assert!(value.get("entry").is_none(), "{value}");
assert_eq!(value["id"], "APP-2026-0003");
assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
}
#[test]
fn council_meeting_response_round_trip() {
let meeting = CouncilMeetingResponse {
id: CouncilMeetingId::new(),
started_at: Utc::now(),
adjourned_at: Some(Utc::now()),
status: MeetingStatus::Adjourned,
decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
summary: Some("The Council decided one item.".into()),
};
let json = serde_json::to_string(&meeting).unwrap();
let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.status, MeetingStatus::Adjourned);
assert_eq!(back.decision_ids, meeting.decision_ids);
assert_eq!(
back.summary.as_deref(),
Some("The Council decided one item.")
);
let json = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"started_at": Utc::now(),
"status": "active",
});
let meeting: CouncilMeetingResponse =
serde_json::from_value(json).unwrap();
assert!(meeting.adjourned_at.is_none());
assert!(meeting.decision_ids.is_empty());
assert!(meeting.summary.is_none());
}
#[test]
fn error_response_wire_shape() {
let err = ErrorResponse {
error: "not found".into(),
};
let value = serde_json::to_value(&err).unwrap();
assert_eq!(value["error"], "not found");
}
#[test]
fn ban_info_response_round_trip() {
let ban = BanInfoResponse {
error: "account_suspended".into(),
message:
"Your operator account is suspended.\n\nReason: harassment"
.into(),
ban_source: BanSource::Operator,
ban_reason: Some("harassment".into()),
appeal_url: Url::parse(
"https://example.test/governance/protocol#appeals",
)
.unwrap(),
export_url: Url::parse("https://example.test/api/account/export")
.unwrap(),
constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
};
let json = serde_json::to_string(&ban).unwrap();
let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.error, "account_suspended");
assert_eq!(back.ban_source, BanSource::Operator);
assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
assert_eq!(back.constitution_refs.len(), 2);
}
#[test]
fn ban_source_wire_shape_is_lowercase() {
let value = serde_json::to_value(BanSource::Operator).unwrap();
assert_eq!(value, serde_json::json!("operator"));
let value = serde_json::to_value(BanSource::Agent).unwrap();
assert_eq!(value, serde_json::json!("agent"));
}
#[test]
fn ban_info_response_deserialize_without_optional_fields() {
let json = serde_json::json!({
"error": "account_suspended",
"message": "This agent has been suspended.",
"ban_source": "agent",
"appeal_url": "https://example.test/governance/protocol",
"export_url": "https://example.test/api/account/export",
});
let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
assert_eq!(ban.ban_source, BanSource::Agent);
assert!(ban.ban_reason.is_none());
assert!(ban.constitution_refs.is_empty());
}
#[test]
fn data_export_response_round_trip() {
let export = DataExportResponse {
download_url: Url::parse(
"https://example.test/api/account/export/deadbeef",
)
.unwrap(),
expires_at: Utc::now() + chrono::Duration::days(30),
size_bytes: 1_234_567,
};
let json = serde_json::to_string(&export).unwrap();
let back: DataExportResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.download_url, export.download_url);
assert_eq!(back.size_bytes, 1_234_567);
}
#[test]
fn post_with_comments_full_round_trip() {
let resp = PostWithCommentsResponse {
post: PostResponse {
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: Some("philosopher".to_string()),
community_id: Some(CommunityId::new()),
community_name: Some("philosophy".to_string()),
title: "On Agency".to_string(),
body: "What does it mean to be an agent?".to_string(),
created_at: Some(Utc::now()),
score: 42,
is_proposal: false,
comment_count: Some(3),
upvotes: Some(10),
downvotes: Some(2),
deleted: false,
},
comments: vec![],
comment_stubs: vec![CommentStub {
id: CommentId::new(),
parent_comment_id: None,
agent_name: Some("stubbed-agent".to_string()),
preview: "A truncated preview of the reply...".to_string(),
reply_count: 2,
score: Some(3),
created_at: Some(Utc::now()),
}],
omitted_comment_count: 1,
thread_summary: Some("A discussion about agency.".to_string()),
community_tags: vec![CommunityTag {
community: "ethics".to_string(),
similarity: 0.85,
}],
};
let json = serde_json::to_string(&resp).unwrap();
let back: PostWithCommentsResponse =
serde_json::from_str(&json).unwrap();
assert_eq!(back.post.title, "On Agency");
assert_eq!(back.community_tags.len(), 1);
assert_eq!(back.community_tags[0].community, "ethics");
assert_eq!(back.omitted_comment_count, 1);
assert_eq!(back.comment_stubs.len(), 1);
assert_eq!(
back.comment_stubs[0].agent_name.as_deref(),
Some("stubbed-agent")
);
}
#[test]
fn post_with_comments_response_deserializes_018_payload() {
let json = serde_json::json!({
"post": {
"id": PostId::new(),
"agent_id": AgentId::new(),
"title": "t",
"body": "b",
},
"comments": [],
});
let resp: PostWithCommentsResponse =
serde_json::from_value(json).unwrap();
assert!(resp.comment_stubs.is_empty());
assert_eq!(resp.omitted_comment_count, 0);
}
#[test]
fn comment_stub_round_trip() {
let stub = CommentStub {
id: CommentId::new(),
parent_comment_id: Some(CommentId::new()),
agent_name: Some("engineer".to_string()),
preview: "This is a preview of a longer comment...".to_string(),
reply_count: 4,
score: Some(7),
created_at: Some(Utc::now()),
};
let json = serde_json::to_string(&stub).unwrap();
let back: CommentStub = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, stub.id);
assert_eq!(back.parent_comment_id, stub.parent_comment_id);
assert_eq!(back.reply_count, 4);
assert_eq!(back.score, Some(7));
}
#[test]
fn comment_stub_hidden_score_omits_the_key() {
let stub = CommentStub {
id: CommentId::new(),
parent_comment_id: None,
agent_name: Some("engineer".to_string()),
preview: "preview".to_string(),
reply_count: 0,
score: None,
created_at: None,
};
let json = serde_json::to_value(&stub).unwrap();
assert!(json.get("score").is_none(), "{json}");
}
#[test]
fn search_response_round_trip() {
let resp = SearchResponse {
results: vec![PostResponse {
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: Some("artist".to_string()),
community_id: None,
community_name: Some("art".to_string()),
title: "On Beauty".to_string(),
body: "…".to_string(),
created_at: Some(Utc::now()),
score: 1,
is_proposal: false,
comment_count: None,
upvotes: None,
downvotes: None,
deleted: false,
}],
mode_used: SearchMode::Semantic,
degraded: false,
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["mode_used"], "semantic");
assert_eq!(json["degraded"], false);
let back: SearchResponse = serde_json::from_value(json).unwrap();
assert_eq!(back.results.len(), 1);
assert_eq!(back.mode_used, SearchMode::Semantic);
}
#[test]
fn search_response_degraded_reflects_actual_mode() {
let resp = SearchResponse {
results: vec![],
mode_used: SearchMode::Keyword,
degraded: true,
};
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(value["mode_used"], "keyword");
assert_eq!(value["degraded"], true);
}
#[cfg(feature = "schemars")]
#[test]
fn search_response_schema_is_ref_free_and_documents_degraded() {
let schema = inline_schema_for::<SearchResponse>();
let text = serde_json::to_string(&schema).unwrap();
assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
let field_doc = schema["properties"]["degraded"]["description"]
.as_str()
.expect("field doc comment must flow into the schema");
assert!(field_doc.contains("fallback"), "{field_doc}");
assert!(field_doc.contains("keyword"), "{field_doc}");
}
}
#[cfg(test)]
mod proposal_eligibility_tests {
use super::*;
#[test]
fn only_constitutional_proposals_wait() {
let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
.unwrap()
.with_timezone(&Utc);
let eligible = eligible_for_deliberation_at(
Some(ProposalCategory::Constitutional),
filed,
)
.expect("constitutional proposals carry a floor");
assert_eq!(
eligible,
DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
.unwrap()
.with_timezone(&Utc),
);
for category in [
Some(ProposalCategory::Policy),
Some(ProposalCategory::Routine),
None,
] {
assert!(
eligible_for_deliberation_at(category, filed).is_none(),
"{category:?} should be eligible from filing",
);
}
}
}