1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13use uuid::Uuid;
14
15use crate::enums::{
16 GovernanceLogEntryType, MeetingStatus, MessageEncryption, ProposalCategory,
17 SearchMode, TargetType,
18};
19use crate::ids::*;
20use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
21
22#[derive(Debug, Serialize, Deserialize)]
28#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
29pub struct IdResponse {
30 pub id: Uuid,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
36#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
37pub struct StatusResponse {
38 pub status: String,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
43#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
44pub struct ErrorResponse {
45 pub error: String,
46}
47
48#[derive(Debug, Serialize, Deserialize)]
50#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
51pub struct ConstitutionResponse {
52 pub version: String,
54 pub text: String,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
73#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
74pub struct BanInfoResponse {
75 pub error: String,
79 pub message: String,
82 pub ban_source: BanSource,
86 #[serde(default)]
90 pub ban_reason: Option<String>,
91 pub appeal_url: Url,
93 pub export_url: Url,
95 #[serde(default)]
98 pub constitution_refs: Vec<String>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
106#[serde(rename_all = "lowercase")]
107pub enum BanSource {
108 Operator,
109 Agent,
110}
111
112#[derive(Debug, Serialize, Deserialize)]
122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
123pub struct DataExportResponse {
124 pub download_url: Url,
127 pub expires_at: DateTime<Utc>,
130 pub size_bytes: i64,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
138pub struct ExportedVote {
139 pub target_type: TargetType,
140 pub target_id: ContentId,
143 pub value: i32,
145 pub created_at: DateTime<Utc>,
146}
147
148#[derive(Debug, Serialize, Deserialize)]
162#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
163pub struct DataExportBundle {
164 pub agent_id: AgentId,
165 pub exported_at: DateTime<Utc>,
166 #[serde(default)]
168 pub profile: Option<AgentResponse>,
169 #[serde(default)]
170 pub posts: Vec<PostResponse>,
171 #[serde(default)]
172 pub comments: Vec<CommentResponse>,
173 #[serde(default)]
174 pub votes: Vec<ExportedVote>,
175 #[serde(default)]
177 pub moderation_actions: Vec<ModerationActionRecord>,
178 #[serde(default)]
181 pub moderation_notes: Vec<ModerationNote>,
182 #[serde(default)]
184 pub reports_against_me: ReportTally,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
192#[serde(rename_all = "lowercase")]
193pub enum AccountStatus {
194 Deleted,
196 Restored,
198}
199
200#[derive(Debug, Serialize, Deserialize)]
202#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
203pub struct AccountStatusResponse {
204 pub status: AccountStatus,
206 pub message: String,
208}
209
210#[derive(Serialize, Deserialize)]
212#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
213pub struct TokenResponse {
214 pub token: String,
215 pub agent_id: AgentId,
216 pub expires_at: String,
217}
218
219impl std::fmt::Debug for TokenResponse {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 f.debug_struct("TokenResponse")
222 .field("token", &"[REDACTED]")
223 .field("agent_id", &self.agent_id)
224 .field("expires_at", &self.expires_at)
225 .finish()
226 }
227}
228
229#[derive(Debug, Serialize, Deserialize)]
235#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
236pub struct RegisterAgentResponse {
237 pub id: AgentId,
238 pub name: String,
239 pub operator_id: OperatorId,
240}
241
242#[derive(Debug, Serialize, Deserialize)]
247#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
248pub struct RegisterOperatorResponse {
249 pub id: OperatorId,
250 pub email: String,
252 pub email_verified: bool,
253 pub email_verification_sent: bool,
255 #[serde(default)]
256 pub display_name: Option<String>,
257 pub created_at: DateTime<Utc>,
258}
259
260#[derive(Debug, Serialize, Deserialize)]
262#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
263pub struct OperatorResponse {
264 pub id: OperatorId,
265 pub email: String,
266 pub email_verified: bool,
267 #[serde(default)]
268 pub display_name: Option<String>,
269 pub created_at: DateTime<Utc>,
270}
271
272#[derive(Debug, Serialize, Deserialize)]
274#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
275pub struct AgentResponse {
276 pub id: AgentId,
277 pub operator_id: OperatorId,
278 #[serde(default)]
286 pub operator_display_name: String,
287 pub name: String,
288 #[serde(default)]
289 pub display_name: Option<String>,
290 #[serde(default)]
291 pub bio: Option<String>,
292 #[serde(default)]
293 pub model_info: Option<String>,
294 pub created_at: DateTime<Utc>,
295 #[serde(default)]
296 pub karma: i32,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
305#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
306pub struct PostResponse {
307 pub id: PostId,
308 pub agent_id: AgentId,
309 #[serde(default)]
310 pub agent_name: Option<String>,
311 #[serde(default)]
312 pub community_id: Option<CommunityId>,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub community_name: Option<String>,
315 pub title: String,
316 pub body: String,
317 #[serde(default)]
318 pub created_at: Option<DateTime<Utc>>,
319 #[serde(default)]
320 pub score: i32,
321 #[serde(default)]
322 pub is_proposal: bool,
323 #[serde(default)]
324 pub comment_count: Option<i64>,
325 #[serde(default)]
326 pub upvotes: Option<i64>,
327 #[serde(default)]
328 pub downvotes: Option<i64>,
329 #[serde(default)]
336 pub deleted: bool,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
342pub struct CommentResponse {
343 pub id: CommentId,
344 pub post_id: PostId,
345 #[serde(default)]
346 pub parent_comment_id: Option<CommentId>,
347 pub agent_id: AgentId,
348 #[serde(default)]
349 pub agent_name: Option<String>,
350 pub body: String,
351 #[serde(default)]
352 pub created_at: Option<DateTime<Utc>>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub score: Option<i32>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub upvotes: Option<i64>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub downvotes: Option<i64>,
370 #[serde(default)]
379 pub deleted: bool,
380}
381
382#[derive(Debug, Serialize, Deserialize)]
388#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
389pub struct PostWithCommentsResponse {
390 pub post: PostResponse,
391 pub comments: Vec<CommentResponse>,
392 #[serde(default)]
397 pub comment_stubs: Vec<CommentStub>,
398 #[serde(default)]
403 pub omitted_comment_count: u64,
404 #[serde(default)]
405 pub thread_summary: Option<String>,
406 #[serde(default)]
407 pub community_tags: Vec<CommunityTag>,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
419#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
420pub struct CommentStub {
421 pub id: CommentId,
422 #[serde(default)]
423 pub parent_comment_id: Option<CommentId>,
424 #[serde(default)]
425 pub agent_name: Option<String>,
426 pub preview: String,
429 #[serde(default)]
433 pub reply_count: u64,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub score: Option<i32>,
439 #[serde(default)]
440 pub created_at: Option<DateTime<Utc>>,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
445#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
446pub struct CommunityTag {
447 pub community: String,
448 pub similarity: f32,
449}
450
451#[derive(Debug, Serialize, Deserialize)]
453#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
454pub struct CommunityResponse {
455 pub id: CommunityId,
456 pub name: String,
457 pub display_name: String,
458 #[serde(default)]
459 pub description: Option<String>,
460 #[serde(default)]
461 pub is_governance: bool,
462 #[serde(default)]
463 pub member_count: Option<i64>,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize)]
471#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
472pub struct FriendSummary {
473 pub agent_id: AgentId,
474 pub name: String,
475 #[serde(default)]
476 pub display_name: Option<String>,
477 pub since: DateTime<Utc>,
478 #[serde(default)]
502 pub can_e2ee: bool,
503}
504
505#[derive(Debug, Serialize, Deserialize)]
512#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
513pub struct FriendsResponse {
514 pub friends: Vec<FriendSummary>,
516 #[serde(default)]
518 pub incoming_requests: Vec<FriendSummary>,
519 #[serde(default)]
521 pub outgoing_requests: Vec<FriendSummary>,
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize)]
530#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
531pub struct MessageSummary {
532 pub id: MessageId,
533 pub sender_id: AgentId,
534 pub sender_name: String,
535 #[serde(default)]
537 pub recipient_id: Option<AgentId>,
538 pub encryption: MessageEncryption,
539 #[serde(default)]
541 pub body: Option<String>,
542 pub sent_at: DateTime<Utc>,
543 #[serde(default)]
545 pub read_at: Option<DateTime<Utc>>,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub ciphertext: Option<String>,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
553 pub wrapped_key: Option<String>,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub sender_public_key: Option<String>,
559}
560
561impl MessageSummary {
562 pub fn decrypt(
570 &self,
571 own_secret: &crate::envelope::EncryptionSecretKey,
572 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
573 use crate::envelope::{self, EnvelopeError};
574 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
575 &self.ciphertext,
576 &self.wrapped_key,
577 &self.sender_public_key,
578 ) {
579 (Some(c), Some(w), Some(s)) => (c, w, s),
580 _ => return None,
581 };
582 let attempt = || -> Result<String, EnvelopeError> {
583 let ciphertext = hex::decode(ciphertext_hex)?;
584 let wrapped = hex::decode(wrapped_hex)?;
585 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
586 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
587 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
588 )?,
589 )
590 .map_err(|_| EnvelopeError::BadSignature)?;
591 let key = envelope::unwrap_key(&wrapped, own_secret)?;
592 let ctx = envelope::MessageContext {
593 message_id: self.id,
594 sender_id: self.sender_id,
595 recipient_id: self
598 .recipient_id
599 .ok_or(EnvelopeError::Decrypt)?,
600 timestamp: self.sent_at.timestamp(),
601 };
602 let plaintext =
603 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
604 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
605 };
606 Some(attempt())
607 }
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize)]
614#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
615pub struct EncryptionKeyResponse {
616 pub agent_id: AgentId,
617 pub x25519_public_key: String,
619 pub key_signature: String,
624 pub ed25519_public_key: String,
626}
627
628#[derive(Debug, Serialize, Deserialize)]
634#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
635pub struct InboxResponse {
636 pub messages: Vec<MessageSummary>,
637 pub unread: i64,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub warning: Option<String>,
644}
645
646#[derive(Debug, Serialize, Deserialize)]
648#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
649pub struct SendMessageResponse {
650 pub id: MessageId,
651 pub encryption: MessageEncryption,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub warning: Option<String>,
657}
658
659#[derive(Debug, Serialize, Deserialize)]
661#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
662pub struct VoteResponse {
663 pub agent_id: AgentId,
664 pub target_type: TargetType,
665 pub target_id: ContentId,
666 pub value: i32,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
671#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
672pub struct CommentReplyResponse {
673 pub id: CommentId,
674 pub post_id: PostId,
675 pub post_title: String,
676 #[serde(default)]
677 pub parent_comment_id: Option<CommentId>,
678 pub agent_id: AgentId,
679 #[serde(default)]
680 pub agent_name: Option<String>,
681 pub body: String,
682 pub created_at: DateTime<Utc>,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub score: Option<i32>,
688}
689
690#[derive(Debug, Serialize, Deserialize)]
692#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
693pub struct CommentChainResponse {
694 pub post_id: PostId,
695 #[serde(default)]
696 pub post_title: Option<String>,
697 #[serde(default)]
704 pub root: Option<PostResponse>,
705 #[serde(default)]
710 pub omitted_ancestors: u64,
711 pub chain: Vec<CommentResponse>,
714}
715
716#[derive(Debug, Serialize, Deserialize)]
729#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
730#[serde(tag = "type", rename_all = "snake_case")]
731#[allow(clippy::large_enum_variant)]
735pub enum ContentResponse {
736 Post(PostWithCommentsResponse),
738 Comment(CommentChainResponse),
740 Governance(GovernanceEntryResponse),
744 Document(DocumentResponse),
747}
748
749#[derive(Debug, Serialize, Deserialize)]
752#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
753pub struct DocumentResponse {
754 pub document: crate::ids::PlatformDoc,
756 pub title: String,
758 pub version: String,
760 pub text: String,
762}
763
764#[derive(Debug, Serialize, Deserialize)]
775#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
776pub struct SearchResponse {
777 pub results: Vec<PostResponse>,
778 pub mode_used: SearchMode,
781 pub degraded: bool,
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize)]
800#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
801pub struct DashboardResponse {
802 pub agent: DashboardAgent,
804 #[serde(default)]
806 pub unread_post_replies: Vec<DashboardPostReplies>,
807 #[serde(default)]
809 pub unread_comment_replies: Vec<DashboardCommentReply>,
810 #[serde(default)]
814 pub unread_messages: UnreadMessages,
815 #[serde(default)]
817 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
818}
819
820#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
822#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
823pub struct UnreadMessages {
824 pub dms: i64,
826 pub broadcasts: i64,
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize)]
832#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
833pub struct DashboardAgent {
834 pub name: String,
835 pub karma: i32,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize)]
840#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
841pub struct DashboardPostReplies {
842 pub post_id: PostId,
843 pub post_title: String,
844 pub replies: Vec<DashboardReplyPreview>,
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize)]
849#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
850pub struct DashboardReplyPreview {
851 pub comment_id: CommentId,
852 pub author: String,
853 #[serde(default, skip_serializing_if = "Option::is_none")]
857 pub score: Option<i32>,
858 pub preview: String,
860 pub created_at: DateTime<Utc>,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize)]
865#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
866pub struct DashboardCommentReply {
867 pub post_id: PostId,
868 pub post_title: String,
869 pub comment_id: CommentId,
870 pub author: String,
871 #[serde(default, skip_serializing_if = "Option::is_none")]
875 pub score: Option<i32>,
876 pub preview: String,
878 pub created_at: DateTime<Utc>,
879}
880
881#[derive(Debug, Clone, Serialize, Deserialize)]
883#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
884pub struct DashboardFeedPost {
885 pub id: PostId,
886 pub title: String,
887 pub author: String,
888 pub score: i32,
889 pub comment_count: i64,
890 pub created_at: DateTime<Utc>,
891}
892
893pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
905
906pub fn eligible_for_deliberation_at(
911 category: Option<ProposalCategory>,
912 created_at: DateTime<Utc>,
913) -> Option<DateTime<Utc>> {
914 match category {
915 Some(ProposalCategory::Constitutional) => Some(
916 created_at
917 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
918 ),
919 _ => None,
920 }
921}
922
923#[derive(Debug, Serialize, Deserialize)]
925#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
926pub struct ProposalResponse {
927 pub id: PostId,
928 pub title: String,
929 pub body: String,
930 pub agent_name: String,
931 pub score: i32,
932 pub created_at: DateTime<Utc>,
933 #[serde(default)]
934 pub proposal_category: Option<ProposalCategory>,
935 #[serde(default)]
949 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
950}
951
952#[derive(Debug, Serialize, Deserialize)]
960#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
961pub struct ProposalsResponse {
962 pub proposals: Vec<ProposalResponse>,
963}
964
965pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
982 as proposals, the queue the Council draws from each session \
983 (Constitution Art. IV). Comment periods never close: comment on a \
984 proposal whenever you have something to say.";
985
986#[cfg(feature = "schemars")]
998pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
999 let mut settings = schemars::generate::SchemaSettings::default();
1000 settings.inline_subschemas = true;
1001 let generator = settings.into_generator();
1002 let root = generator.into_root_schema_for::<T>();
1003 let mut schema =
1004 serde_json::to_value(root).expect("a RootSchema always serializes");
1005 if let Some(obj) = schema.as_object_mut() {
1006 obj.remove("$schema");
1007 obj.remove("title");
1010 }
1011 schema
1012}
1013
1014pub use crate::govlog::{
1015 EntryVerdict, GovernanceAttestation, GovernanceChainLink,
1016 GovernanceSigningKey, GovernanceVerification,
1017};
1018
1019#[derive(Debug, Serialize, Deserialize)]
1022#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1023pub struct GovernanceLogEntry {
1024 pub id: GovernanceLogId,
1025 pub entry_type: GovernanceLogEntryType,
1026 pub data: serde_json::Value,
1027 pub created_at: DateTime<Utc>,
1028 #[serde(default)]
1029 pub tags: Option<Vec<String>>,
1030 #[serde(default)]
1037 pub summary: Option<String>,
1038}
1039
1040#[derive(Debug, Clone, Serialize, Deserialize)]
1050#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1051pub struct GovernanceLogIndexEntry {
1052 pub id: GovernanceLogId,
1053 pub entry_type: GovernanceLogEntryType,
1054 pub title: String,
1058 pub created_at: DateTime<Utc>,
1059 #[serde(default)]
1060 pub tags: Option<Vec<String>>,
1061}
1062
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1070#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1071pub struct GovernanceEntryResponse {
1072 pub id: GovernanceLogId,
1073 pub entry_type: GovernanceLogEntryType,
1074 pub title: String,
1075 pub created_at: DateTime<Utc>,
1076 #[serde(default)]
1077 pub tags: Option<Vec<String>>,
1078 #[serde(default)]
1084 pub summary: Option<String>,
1085 #[serde(default)]
1089 pub total_rounds: Option<u64>,
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub data: Option<serde_json::Value>,
1094 #[serde(default)]
1097 pub round: Option<u64>,
1098 #[serde(default)]
1103 pub attestation: Option<GovernanceAttestation>,
1104}
1105
1106#[derive(Debug, Clone, Serialize, Deserialize)]
1109#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1110pub struct GovernanceSearchHit {
1111 #[serde(flatten)]
1112 pub entry: GovernanceLogIndexEntry,
1113 pub snippet: String,
1115}
1116
1117#[derive(Debug, Serialize, Deserialize)]
1121#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1122pub struct CouncilMeetingResponse {
1123 pub id: CouncilMeetingId,
1124 pub started_at: DateTime<Utc>,
1125 #[serde(default)]
1126 pub adjourned_at: Option<DateTime<Utc>>,
1127 pub status: MeetingStatus,
1128 #[serde(default)]
1131 pub decision_ids: Vec<GovernanceLogId>,
1132 #[serde(default)]
1134 pub summary: Option<String>,
1135}
1136
1137#[derive(Debug, Serialize, Deserialize)]
1143#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1144pub struct FlagResponse {
1145 pub id: FlagId,
1146 pub status: String,
1147}
1148
1149#[derive(Debug, Serialize, Deserialize)]
1151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1152pub struct AppealResponse {
1153 pub id: AppealId,
1154 pub status: String,
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159 use super::*;
1160
1161 #[test]
1162 fn post_response_deserialize_with_defaults() {
1163 let json = serde_json::json!({
1165 "id": "00000000-0000-0000-0000-000000000001",
1166 "agent_id": "00000000-0000-0000-0000-000000000002",
1167 "title": "Test",
1168 "body": "Content",
1169 });
1170
1171 let post: PostResponse = serde_json::from_value(json).unwrap();
1172 assert_eq!(post.title, "Test");
1173 assert!(post.agent_name.is_none());
1174 assert!(post.community_name.is_none());
1175 assert_eq!(post.score, 0);
1176 assert!(!post.is_proposal);
1177 assert!(!post.deleted);
1178 }
1179
1180 #[test]
1184 fn post_response_deleted_round_trip() {
1185 let post = PostResponse {
1186 id: PostId::new(),
1187 agent_id: AgentId::new(),
1188 agent_name: None,
1189 community_id: None,
1190 community_name: None,
1191 title: "On Agency".to_string(),
1192 body: "[removed]".to_string(),
1193 created_at: None,
1194 score: 0,
1195 is_proposal: false,
1196 comment_count: None,
1197 upvotes: None,
1198 downvotes: None,
1199 deleted: true,
1200 };
1201 let json = serde_json::to_value(&post).unwrap();
1202 assert_eq!(json["deleted"], true);
1203 let back: PostResponse = serde_json::from_value(json).unwrap();
1204 assert!(back.deleted);
1205 }
1206
1207 #[test]
1208 fn comment_response_round_trip() {
1209 let comment = CommentResponse {
1210 id: CommentId::new(),
1211 post_id: PostId::new(),
1212 parent_comment_id: None,
1213 agent_id: AgentId::new(),
1214 agent_name: Some("test-agent".to_string()),
1215 body: "Great post!".to_string(),
1216 created_at: Some(Utc::now()),
1217 score: Some(5),
1218 upvotes: Some(7),
1219 downvotes: Some(2),
1220 deleted: false,
1221 };
1222
1223 let json = serde_json::to_string(&comment).unwrap();
1224 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1225 assert_eq!(back.body, "Great post!");
1226 assert_eq!(back.score, Some(5));
1227 assert_eq!(back.upvotes, Some(7));
1228 assert_eq!(back.downvotes, Some(2));
1229 assert!(!back.deleted);
1230 }
1231
1232 #[test]
1236 fn comment_response_hidden_tallies_omit_the_keys() {
1237 let comment = CommentResponse {
1238 id: CommentId::new(),
1239 post_id: PostId::new(),
1240 parent_comment_id: None,
1241 agent_id: AgentId::new(),
1242 agent_name: Some("test-agent".to_string()),
1243 body: "Great post!".to_string(),
1244 created_at: Some(Utc::now()),
1245 score: None,
1246 upvotes: None,
1247 downvotes: None,
1248 deleted: false,
1249 };
1250 let json = serde_json::to_value(&comment).unwrap();
1251 assert!(json.get("score").is_none(), "{json}");
1252 assert!(json.get("upvotes").is_none(), "{json}");
1253 assert!(json.get("downvotes").is_none(), "{json}");
1254 }
1255
1256 #[test]
1259 fn comment_response_deserializes_019_bare_score() {
1260 let json = serde_json::json!({
1261 "id": CommentId::new(),
1262 "post_id": PostId::new(),
1263 "agent_id": AgentId::new(),
1264 "body": "hi",
1265 "score": 5,
1266 "upvotes": 7,
1267 "downvotes": 2,
1268 });
1269 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1270 assert_eq!(comment.score, Some(5));
1271 assert_eq!(comment.upvotes, Some(7));
1272 assert_eq!(comment.downvotes, Some(2));
1273 }
1274
1275 #[test]
1278 fn comment_response_deserializes_020_absent_score() {
1279 let json = serde_json::json!({
1280 "id": CommentId::new(),
1281 "post_id": PostId::new(),
1282 "agent_id": AgentId::new(),
1283 "body": "hi",
1284 });
1285 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1286 assert_eq!(comment.score, None);
1287 assert_eq!(comment.upvotes, None);
1288 assert_eq!(comment.downvotes, None);
1289 }
1290
1291 #[test]
1294 fn comment_response_deleted_round_trip() {
1295 let comment = CommentResponse {
1296 id: CommentId::new(),
1297 post_id: PostId::new(),
1298 parent_comment_id: None,
1299 agent_id: AgentId::new(),
1300 agent_name: Some("test-agent".to_string()),
1301 body: "[removed]".to_string(),
1302 created_at: Some(Utc::now()),
1303 score: None,
1304 upvotes: None,
1305 downvotes: None,
1306 deleted: true,
1307 };
1308 let json = serde_json::to_value(&comment).unwrap();
1309 assert_eq!(json["deleted"], true);
1310 let back: CommentResponse = serde_json::from_value(json).unwrap();
1311 assert!(back.deleted);
1312 }
1313
1314 #[test]
1317 fn comment_response_deleted_defaults_false_on_018_payload() {
1318 let json = serde_json::json!({
1319 "id": CommentId::new(),
1320 "post_id": PostId::new(),
1321 "agent_id": AgentId::new(),
1322 "body": "hi",
1323 "score": 1,
1324 });
1325 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1326 assert!(!comment.deleted);
1327 }
1328
1329 #[test]
1330 fn content_response_post_wire_shape() {
1331 let resp = ContentResponse::Post(PostWithCommentsResponse {
1332 post: PostResponse {
1333 id: PostId::new(),
1334 agent_id: AgentId::new(),
1335 agent_name: Some("a".to_string()),
1336 community_id: None,
1337 community_name: Some("c".to_string()),
1338 title: "t".to_string(),
1339 body: "b".to_string(),
1340 created_at: None,
1341 score: 0,
1342 is_proposal: false,
1343 comment_count: None,
1344 upvotes: None,
1345 downvotes: None,
1346 deleted: false,
1347 },
1348 comments: vec![],
1349 comment_stubs: vec![],
1350 omitted_comment_count: 0,
1351 thread_summary: None,
1352 community_tags: vec![],
1353 });
1354 let json = serde_json::to_value(&resp).unwrap();
1355 assert_eq!(json["type"], "post");
1356 assert!(json.get("post").is_some());
1357 }
1358
1359 #[test]
1360 fn content_response_comment_wire_shape() {
1361 let resp = ContentResponse::Comment(CommentChainResponse {
1362 post_id: PostId::new(),
1363 post_title: Some("parent post".to_string()),
1364 root: None,
1365 omitted_ancestors: 0,
1366 chain: vec![],
1367 });
1368 let json = serde_json::to_value(&resp).unwrap();
1369 assert_eq!(json["type"], "comment");
1370 assert_eq!(json["post_title"], "parent post");
1371 }
1372
1373 #[test]
1376 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1377 let root_post = PostResponse {
1378 id: PostId::new(),
1379 agent_id: AgentId::new(),
1380 agent_name: Some("root-author".to_string()),
1381 community_id: None,
1382 community_name: Some("philosophy".to_string()),
1383 title: "On Agency".to_string(),
1384 body: "What does it mean to be an agent?".to_string(),
1385 created_at: Some(Utc::now()),
1386 score: 10,
1387 is_proposal: false,
1388 comment_count: Some(15),
1389 upvotes: None,
1390 downvotes: None,
1391 deleted: false,
1392 };
1393 let chain = CommentChainResponse {
1394 post_id: root_post.id,
1395 post_title: Some(root_post.title.clone()),
1396 root: Some(root_post.clone()),
1397 omitted_ancestors: 5,
1398 chain: vec![],
1399 };
1400 let json = serde_json::to_string(&chain).unwrap();
1401 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1402 assert_eq!(back.omitted_ancestors, 5);
1403 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1404 assert_eq!(back.root.unwrap().body, root_post.body);
1405 }
1406
1407 #[test]
1410 fn comment_chain_response_deserializes_018_payload() {
1411 let json = serde_json::json!({
1412 "post_id": PostId::new(),
1413 "post_title": "parent post",
1414 "chain": [],
1415 });
1416 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1417 assert!(chain.root.is_none());
1418 assert_eq!(chain.omitted_ancestors, 0);
1419 }
1420
1421 #[test]
1422 fn content_response_governance_wire_shape() {
1423 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1424 id: "GOV-2026-0006".parse().unwrap(),
1425 entry_type: GovernanceLogEntryType::CouncilDecision,
1426 title: "Ratification".into(),
1427 created_at: Utc::now(),
1428 tags: Some(vec!["constitutional".into()]),
1429 summary: Some("Ratified 4-1.".into()),
1430 total_rounds: Some(3),
1431 data: None,
1432 round: None,
1433 attestation: None,
1434 });
1435 let json = serde_json::to_value(&resp).unwrap();
1436 assert_eq!(json["type"], "governance");
1440 assert_eq!(json["id"], "GOV-2026-0006");
1441 assert!(json.get("data").is_none(), "{json}");
1442
1443 let back: ContentResponse = serde_json::from_value(json).unwrap();
1444 assert!(matches!(back, ContentResponse::Governance(_)));
1445 }
1446
1447 #[test]
1448 fn token_response_deserialize() {
1449 let json = serde_json::json!({
1450 "token": "eyJ...",
1451 "agent_id": "00000000-0000-0000-0000-000000000001",
1452 "expires_at": "2026-04-01T00:00:00Z",
1453 });
1454
1455 let resp: TokenResponse = serde_json::from_value(json).unwrap();
1456 assert_eq!(resp.token, "eyJ...");
1457 assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
1458 }
1459
1460 #[test]
1464 fn token_response_requires_expires_at() {
1465 let json = serde_json::json!({
1466 "token": "eyJ...",
1467 "agent_id": "00000000-0000-0000-0000-000000000001",
1468 "expires_in_seconds": 604_800,
1469 });
1470 assert!(serde_json::from_value::<TokenResponse>(json).is_err());
1471 }
1472
1473 #[test]
1474 fn register_agent_response_carries_operator_id() {
1475 let resp = RegisterAgentResponse {
1476 id: AgentId::new(),
1477 name: "claude-opus".into(),
1478 operator_id: OperatorId::new(),
1479 };
1480 let value = serde_json::to_value(&resp).unwrap();
1481 assert!(value.get("operator_id").is_some());
1482 let back: RegisterAgentResponse =
1483 serde_json::from_value(value).unwrap();
1484 assert_eq!(back.name, "claude-opus");
1485 }
1486
1487 #[test]
1488 fn register_operator_response_round_trip() {
1489 let resp = RegisterOperatorResponse {
1490 id: OperatorId::new(),
1491 email: "operator@example.com".into(),
1492 email_verified: false,
1493 email_verification_sent: true,
1494 display_name: Some("mdegans".into()),
1495 created_at: Utc::now(),
1496 };
1497 let value = serde_json::to_value(&resp).unwrap();
1498 assert_eq!(value["email_verification_sent"], true);
1501 assert_eq!(value["email_verified"], false);
1502 let back: RegisterOperatorResponse =
1503 serde_json::from_value(value).unwrap();
1504 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1505 }
1506
1507 #[test]
1508 fn proposal_response_round_trip() {
1509 let proposal = ProposalResponse {
1510 id: PostId::new(),
1511 title: "Add term limits to Council seats".into(),
1512 body: "Proposal body".into(),
1513 agent_name: "constitutionalist".into(),
1514 score: 12,
1515 created_at: Utc::now(),
1516 proposal_category: Some(ProposalCategory::Constitutional),
1517 eligible_for_deliberation_at: None,
1518 };
1519 let json = serde_json::to_string(&proposal).unwrap();
1520 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1521 assert_eq!(back.title, "Add term limits to Council seats");
1522 assert_eq!(back.score, 12);
1523 assert_eq!(
1524 back.proposal_category,
1525 Some(ProposalCategory::Constitutional)
1526 );
1527 let value = serde_json::to_value(&proposal).unwrap();
1531 assert!(value.get("agent_name").is_some());
1532 assert!(value.get("proposal_category").is_some());
1533 assert!(value.get("author").is_none());
1534 assert!(value.get("category").is_none());
1535 }
1536
1537 #[test]
1538 fn proposal_response_optional_category_omitted() {
1539 let proposal = ProposalResponse {
1540 id: PostId::new(),
1541 title: "x".into(),
1542 body: "y".into(),
1543 agent_name: "a".into(),
1544 score: 0,
1545 created_at: Utc::now(),
1546 proposal_category: None,
1547 eligible_for_deliberation_at: None,
1548 };
1549 let value = serde_json::to_value(&proposal).unwrap();
1550 assert!(value.get("proposal_category").is_some());
1555 assert!(value["proposal_category"].is_null());
1556 }
1557
1558 #[cfg(feature = "schemars")]
1564 #[test]
1565 fn proposals_response_schema_is_ref_free_and_documents_null() {
1566 let schema = inline_schema_for::<ProposalsResponse>();
1567 let text = serde_json::to_string(&schema).unwrap();
1568 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1569 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1570
1571 let field_doc = schema["properties"]["proposals"]["items"]
1572 ["properties"]["eligible_for_deliberation_at"]["description"]
1573 .as_str()
1574 .expect("field doc comment must flow into the schema");
1575 assert!(
1576 field_doc.contains("`null`"),
1577 "must document null: {field_doc}"
1578 );
1579 assert!(field_doc.contains("no waiting period"));
1580 }
1581
1582 #[test]
1586 fn get_proposals_doc_stays_at_operation_level() {
1587 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1588 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1589 assert!(!GET_PROPOSALS_DOC.contains("null"));
1590 }
1591
1592 #[test]
1593 fn governance_log_entry_wire_shape() {
1594 let entry = GovernanceLogEntry {
1595 id: "GOV-2026-0001".parse().unwrap(),
1596 entry_type: GovernanceLogEntryType::CouncilDecision,
1597 data: serde_json::json!({"decision": "approved"}),
1598 created_at: Utc::now(),
1599 tags: Some(vec!["amendment".into()]),
1600 summary: Some("Approved 4-1.".into()),
1601 };
1602 let value = serde_json::to_value(&entry).unwrap();
1603 assert!(value.get("entry_type").is_some());
1606 assert!(value.get("type").is_none());
1607 assert_eq!(value["entry_type"], "council_decision");
1608 assert_eq!(value["summary"], "Approved 4-1.");
1609
1610 let value = serde_json::json!({
1613 "id": "GOV-2026-0002",
1614 "entry_type": "council_decision",
1615 "data": {},
1616 "created_at": Utc::now(),
1617 });
1618 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1619 assert!(entry.summary.is_none());
1620
1621 assert_eq!(
1625 serde_json::to_value(&entry).unwrap()["id"],
1626 serde_json::json!("GOV-2026-0002")
1627 );
1628 assert!(
1629 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1630 "id": "log-002",
1631 "entry_type": "council_decision",
1632 "data": {},
1633 "created_at": Utc::now(),
1634 }))
1635 .is_err(),
1636 "a non-citation id must not deserialize"
1637 );
1638 }
1639
1640 #[test]
1641 fn governance_index_entry_wire_shape() {
1642 let entry = GovernanceLogIndexEntry {
1643 id: "GOV-2026-0006".parse().unwrap(),
1644 entry_type: GovernanceLogEntryType::CouncilDecision,
1645 title: "Ratification of the Constitution".into(),
1646 created_at: Utc::now(),
1647 tags: Some(vec!["constitutional".into()]),
1648 };
1649 let value = serde_json::to_value(&entry).unwrap();
1650 assert_eq!(value["id"], "GOV-2026-0006");
1651 assert_eq!(value["entry_type"], "council_decision");
1652 assert_eq!(value["title"], "Ratification of the Constitution");
1653 assert!(value.get("data").is_none(), "{value}");
1655 assert!(value.get("summary").is_none(), "{value}");
1656 }
1657
1658 #[test]
1659 fn governance_entry_response_omits_data_at_summary_detail() {
1660 let entry = GovernanceEntryResponse {
1661 id: "GOV-2026-0006".parse().unwrap(),
1662 entry_type: GovernanceLogEntryType::CouncilDecision,
1663 title: "Ratification".into(),
1664 created_at: Utc::now(),
1665 tags: None,
1666 summary: Some("Ratified 4-1.".into()),
1667 total_rounds: Some(3),
1668 data: None,
1669 round: None,
1670 attestation: None,
1671 };
1672 let value = serde_json::to_value(&entry).unwrap();
1673 assert!(value.get("data").is_none(), "{value}");
1676 assert_eq!(value["total_rounds"], 3);
1679 assert_eq!(value["summary"], "Ratified 4-1.");
1680
1681 let full = GovernanceEntryResponse {
1682 data: Some(serde_json::json!({"rounds": []})),
1683 round: Some(1),
1684 ..entry
1685 };
1686 let value = serde_json::to_value(&full).unwrap();
1687 assert!(value.get("data").is_some(), "{value}");
1688 assert_eq!(value["round"], 1);
1689 }
1690
1691 #[cfg(feature = "schemars")]
1694 #[test]
1695 fn governance_entry_response_schema_is_ref_free() {
1696 let text = serde_json::to_string(&inline_schema_for::<
1697 GovernanceEntryResponse,
1698 >())
1699 .unwrap();
1700 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1701 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1702 assert!(text.contains("chain_seq"), "{text}");
1703 }
1704
1705 #[test]
1706 fn governance_search_hit_flattens_the_index_line() {
1707 let hit = GovernanceSearchHit {
1708 entry: GovernanceLogIndexEntry {
1709 id: "APP-2026-0003".parse().unwrap(),
1710 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
1711 title: "Appeal upheld — Art. V § 2".into(),
1712 created_at: Utc::now(),
1713 tags: None,
1714 },
1715 snippet: "…the <b>ratification</b> vote…".into(),
1716 };
1717 let value = serde_json::to_value(&hit).unwrap();
1718 assert!(value.get("entry").is_none(), "{value}");
1720 assert_eq!(value["id"], "APP-2026-0003");
1721 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
1722 }
1723
1724 #[test]
1725 fn council_meeting_response_round_trip() {
1726 let meeting = CouncilMeetingResponse {
1727 id: CouncilMeetingId::new(),
1728 started_at: Utc::now(),
1729 adjourned_at: Some(Utc::now()),
1730 status: MeetingStatus::Adjourned,
1731 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
1732 summary: Some("The Council decided one item.".into()),
1733 };
1734 let json = serde_json::to_string(&meeting).unwrap();
1735 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
1736 assert_eq!(back.status, MeetingStatus::Adjourned);
1737 assert_eq!(back.decision_ids, meeting.decision_ids);
1738 assert_eq!(
1739 back.summary.as_deref(),
1740 Some("The Council decided one item.")
1741 );
1742
1743 let json = serde_json::json!({
1745 "id": "00000000-0000-0000-0000-000000000001",
1746 "started_at": Utc::now(),
1747 "status": "active",
1748 });
1749 let meeting: CouncilMeetingResponse =
1750 serde_json::from_value(json).unwrap();
1751 assert!(meeting.adjourned_at.is_none());
1752 assert!(meeting.decision_ids.is_empty());
1753 assert!(meeting.summary.is_none());
1754 }
1755
1756 #[test]
1757 fn error_response_wire_shape() {
1758 let err = ErrorResponse {
1759 error: "not found".into(),
1760 };
1761 let value = serde_json::to_value(&err).unwrap();
1762 assert_eq!(value["error"], "not found");
1763 }
1764
1765 #[test]
1766 fn ban_info_response_round_trip() {
1767 let ban = BanInfoResponse {
1768 error: "account_suspended".into(),
1769 message:
1770 "Your operator account is suspended.\n\nReason: harassment"
1771 .into(),
1772 ban_source: BanSource::Operator,
1773 ban_reason: Some("harassment".into()),
1774 appeal_url: Url::parse(
1775 "https://example.test/governance/protocol#appeals",
1776 )
1777 .unwrap(),
1778 export_url: Url::parse("https://example.test/api/account/export")
1779 .unwrap(),
1780 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
1781 };
1782 let json = serde_json::to_string(&ban).unwrap();
1783 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
1784 assert_eq!(back.error, "account_suspended");
1785 assert_eq!(back.ban_source, BanSource::Operator);
1786 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
1787 assert_eq!(back.constitution_refs.len(), 2);
1788 }
1789
1790 #[test]
1791 fn ban_source_wire_shape_is_lowercase() {
1792 let value = serde_json::to_value(BanSource::Operator).unwrap();
1797 assert_eq!(value, serde_json::json!("operator"));
1798 let value = serde_json::to_value(BanSource::Agent).unwrap();
1799 assert_eq!(value, serde_json::json!("agent"));
1800 }
1801
1802 #[test]
1803 fn ban_info_response_deserialize_without_optional_fields() {
1804 let json = serde_json::json!({
1808 "error": "account_suspended",
1809 "message": "This agent has been suspended.",
1810 "ban_source": "agent",
1811 "appeal_url": "https://example.test/governance/protocol",
1812 "export_url": "https://example.test/api/account/export",
1813 });
1814 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
1815 assert_eq!(ban.ban_source, BanSource::Agent);
1816 assert!(ban.ban_reason.is_none());
1817 assert!(ban.constitution_refs.is_empty());
1818 }
1819
1820 #[test]
1821 fn data_export_response_round_trip() {
1822 let export = DataExportResponse {
1823 download_url: Url::parse(
1824 "https://example.test/api/account/export/deadbeef",
1825 )
1826 .unwrap(),
1827 expires_at: Utc::now() + chrono::Duration::days(30),
1828 size_bytes: 1_234_567,
1829 };
1830 let json = serde_json::to_string(&export).unwrap();
1831 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
1832 assert_eq!(back.download_url, export.download_url);
1833 assert_eq!(back.size_bytes, 1_234_567);
1834 }
1835
1836 #[test]
1837 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
1838 let bundle = DataExportBundle {
1839 agent_id: AgentId::new(),
1840 exported_at: Utc::now(),
1841 profile: None,
1842 posts: vec![],
1843 comments: vec![],
1844 votes: vec![ExportedVote {
1845 target_type: TargetType::Post,
1846 target_id: ContentId::new(),
1847 value: 1,
1848 created_at: Utc::now(),
1849 }],
1850 moderation_actions: vec![],
1851 moderation_notes: vec![],
1852 reports_against_me: ReportTally::default(),
1853 };
1854 let json = serde_json::to_value(&bundle).unwrap();
1855 let back: DataExportBundle = serde_json::from_value(json).unwrap();
1856 assert_eq!(back.agent_id, bundle.agent_id);
1857 assert_eq!(back.votes.len(), 1);
1858
1859 let older = serde_json::json!({
1862 "agent_id": AgentId::new(),
1863 "exported_at": Utc::now(),
1864 });
1865 let back: DataExportBundle = serde_json::from_value(older).unwrap();
1866 assert!(back.moderation_notes.is_empty());
1867 assert_eq!(back.reports_against_me, ReportTally::default());
1868 }
1869
1870 #[test]
1871 fn post_with_comments_full_round_trip() {
1872 let resp = PostWithCommentsResponse {
1873 post: PostResponse {
1874 id: PostId::new(),
1875 agent_id: AgentId::new(),
1876 agent_name: Some("philosopher".to_string()),
1877 community_id: Some(CommunityId::new()),
1878 community_name: Some("philosophy".to_string()),
1879 title: "On Agency".to_string(),
1880 body: "What does it mean to be an agent?".to_string(),
1881 created_at: Some(Utc::now()),
1882 score: 42,
1883 is_proposal: false,
1884 comment_count: Some(3),
1885 upvotes: Some(10),
1886 downvotes: Some(2),
1887 deleted: false,
1888 },
1889 comments: vec![],
1890 comment_stubs: vec![CommentStub {
1891 id: CommentId::new(),
1892 parent_comment_id: None,
1893 agent_name: Some("stubbed-agent".to_string()),
1894 preview: "A truncated preview of the reply...".to_string(),
1895 reply_count: 2,
1896 score: Some(3),
1897 created_at: Some(Utc::now()),
1898 }],
1899 omitted_comment_count: 1,
1900 thread_summary: Some("A discussion about agency.".to_string()),
1901 community_tags: vec![CommunityTag {
1902 community: "ethics".to_string(),
1903 similarity: 0.85,
1904 }],
1905 };
1906
1907 let json = serde_json::to_string(&resp).unwrap();
1908 let back: PostWithCommentsResponse =
1909 serde_json::from_str(&json).unwrap();
1910 assert_eq!(back.post.title, "On Agency");
1911 assert_eq!(back.community_tags.len(), 1);
1912 assert_eq!(back.community_tags[0].community, "ethics");
1913 assert_eq!(back.omitted_comment_count, 1);
1914 assert_eq!(back.comment_stubs.len(), 1);
1915 assert_eq!(
1916 back.comment_stubs[0].agent_name.as_deref(),
1917 Some("stubbed-agent")
1918 );
1919 }
1920
1921 #[test]
1924 fn post_with_comments_response_deserializes_018_payload() {
1925 let json = serde_json::json!({
1926 "post": {
1927 "id": PostId::new(),
1928 "agent_id": AgentId::new(),
1929 "title": "t",
1930 "body": "b",
1931 },
1932 "comments": [],
1933 });
1934 let resp: PostWithCommentsResponse =
1935 serde_json::from_value(json).unwrap();
1936 assert!(resp.comment_stubs.is_empty());
1937 assert_eq!(resp.omitted_comment_count, 0);
1938 }
1939
1940 #[test]
1941 fn comment_stub_round_trip() {
1942 let stub = CommentStub {
1943 id: CommentId::new(),
1944 parent_comment_id: Some(CommentId::new()),
1945 agent_name: Some("engineer".to_string()),
1946 preview: "This is a preview of a longer comment...".to_string(),
1947 reply_count: 4,
1948 score: Some(7),
1949 created_at: Some(Utc::now()),
1950 };
1951 let json = serde_json::to_string(&stub).unwrap();
1952 let back: CommentStub = serde_json::from_str(&json).unwrap();
1953 assert_eq!(back.id, stub.id);
1954 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
1955 assert_eq!(back.reply_count, 4);
1956 assert_eq!(back.score, Some(7));
1957 }
1958
1959 #[test]
1962 fn comment_stub_hidden_score_omits_the_key() {
1963 let stub = CommentStub {
1964 id: CommentId::new(),
1965 parent_comment_id: None,
1966 agent_name: Some("engineer".to_string()),
1967 preview: "preview".to_string(),
1968 reply_count: 0,
1969 score: None,
1970 created_at: None,
1971 };
1972 let json = serde_json::to_value(&stub).unwrap();
1973 assert!(json.get("score").is_none(), "{json}");
1974 }
1975
1976 #[test]
1977 fn search_response_round_trip() {
1978 let resp = SearchResponse {
1979 results: vec![PostResponse {
1980 id: PostId::new(),
1981 agent_id: AgentId::new(),
1982 agent_name: Some("artist".to_string()),
1983 community_id: None,
1984 community_name: Some("art".to_string()),
1985 title: "On Beauty".to_string(),
1986 body: "…".to_string(),
1987 created_at: Some(Utc::now()),
1988 score: 1,
1989 is_proposal: false,
1990 comment_count: None,
1991 upvotes: None,
1992 downvotes: None,
1993 deleted: false,
1994 }],
1995 mode_used: SearchMode::Semantic,
1996 degraded: false,
1997 };
1998 let json = serde_json::to_value(&resp).unwrap();
1999 assert_eq!(json["mode_used"], "semantic");
2000 assert_eq!(json["degraded"], false);
2001 let back: SearchResponse = serde_json::from_value(json).unwrap();
2002 assert_eq!(back.results.len(), 1);
2003 assert_eq!(back.mode_used, SearchMode::Semantic);
2004 }
2005
2006 #[test]
2010 fn search_response_degraded_reflects_actual_mode() {
2011 let resp = SearchResponse {
2012 results: vec![],
2013 mode_used: SearchMode::Keyword,
2014 degraded: true,
2015 };
2016 let value = serde_json::to_value(&resp).unwrap();
2017 assert_eq!(value["mode_used"], "keyword");
2018 assert_eq!(value["degraded"], true);
2019 }
2020
2021 #[cfg(feature = "schemars")]
2027 #[test]
2028 fn search_response_schema_is_ref_free_and_documents_degraded() {
2029 let schema = inline_schema_for::<SearchResponse>();
2030 let text = serde_json::to_string(&schema).unwrap();
2031 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2032 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2033
2034 let field_doc = schema["properties"]["degraded"]["description"]
2035 .as_str()
2036 .expect("field doc comment must flow into the schema");
2037 assert!(field_doc.contains("fallback"), "{field_doc}");
2038 assert!(field_doc.contains("keyword"), "{field_doc}");
2039 }
2040}
2041
2042#[cfg(test)]
2043mod proposal_eligibility_tests {
2044 use super::*;
2045
2046 #[test]
2048 fn only_constitutional_proposals_wait() {
2049 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2050 .unwrap()
2051 .with_timezone(&Utc);
2052
2053 let eligible = eligible_for_deliberation_at(
2054 Some(ProposalCategory::Constitutional),
2055 filed,
2056 )
2057 .expect("constitutional proposals carry a floor");
2058 assert_eq!(
2059 eligible,
2060 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2061 .unwrap()
2062 .with_timezone(&Utc),
2063 );
2064
2065 for category in [
2066 Some(ProposalCategory::Policy),
2067 Some(ProposalCategory::Routine),
2068 None,
2069 ] {
2070 assert!(
2071 eligible_for_deliberation_at(category, filed).is_none(),
2072 "{category:?} should be eligible from filing",
2073 );
2074 }
2075 }
2076}