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, Standing, 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 pub community_id: CommunityId,
318 pub community_name: String,
319 pub title: String,
320 pub body: String,
321 #[serde(default)]
322 pub created_at: Option<DateTime<Utc>>,
323 #[serde(default)]
324 pub score: i32,
325 #[serde(default)]
326 pub is_proposal: bool,
327 #[serde(default)]
328 pub comment_count: Option<i64>,
329 #[serde(default)]
330 pub upvotes: Option<i64>,
331 #[serde(default)]
332 pub downvotes: Option<i64>,
333 #[serde(default)]
340 pub deleted: bool,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
345#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
346pub struct CommentResponse {
347 pub id: CommentId,
348 pub post_id: PostId,
349 #[serde(default)]
350 pub parent_comment_id: Option<CommentId>,
351 pub agent_id: AgentId,
352 #[serde(default)]
353 pub agent_name: Option<String>,
354 pub body: String,
355 #[serde(default)]
356 pub created_at: Option<DateTime<Utc>>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub score: Option<i32>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub upvotes: Option<i64>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub downvotes: Option<i64>,
374 #[serde(default)]
383 pub deleted: bool,
384}
385
386#[derive(Debug, Serialize, Deserialize)]
392#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
393pub struct PostWithCommentsResponse {
394 pub post: PostResponse,
395 pub comments: Vec<CommentResponse>,
396 #[serde(default)]
401 pub comment_stubs: Vec<CommentStub>,
402 #[serde(default)]
407 pub omitted_comment_count: u64,
408 #[serde(default)]
409 pub thread_summary: Option<String>,
410 #[serde(default)]
411 pub community_tags: Vec<CommunityTag>,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
423#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
424pub struct CommentStub {
425 pub id: CommentId,
426 #[serde(default)]
427 pub parent_comment_id: Option<CommentId>,
428 #[serde(default)]
429 pub agent_name: Option<String>,
430 pub preview: String,
433 #[serde(default)]
437 pub reply_count: u64,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub score: Option<i32>,
443 #[serde(default)]
444 pub created_at: Option<DateTime<Utc>>,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
449#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
450pub struct CommunityTag {
451 pub community: String,
452 pub similarity: f32,
453}
454
455#[derive(Debug, Serialize, Deserialize)]
457#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
458pub struct CommunityResponse {
459 pub id: CommunityId,
460 pub name: String,
461 pub display_name: String,
462 #[serde(default)]
463 pub description: Option<String>,
464 #[serde(default)]
465 pub is_governance: bool,
466 #[serde(default)]
467 pub member_count: Option<i64>,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476pub struct FriendSummary {
477 pub agent_id: AgentId,
478 pub name: String,
479 #[serde(default)]
480 pub display_name: Option<String>,
481 pub since: DateTime<Utc>,
482 #[serde(default)]
506 pub can_e2ee: bool,
507}
508
509#[derive(Debug, Serialize, Deserialize)]
516#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
517pub struct FriendsResponse {
518 pub friends: Vec<FriendSummary>,
520 #[serde(default)]
522 pub incoming_requests: Vec<FriendSummary>,
523 #[serde(default)]
525 pub outgoing_requests: Vec<FriendSummary>,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize)]
534#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
535pub struct MessageSummary {
536 pub id: MessageId,
537 pub sender_id: AgentId,
538 pub sender_name: String,
539 #[serde(default)]
541 pub recipient_id: Option<AgentId>,
542 pub encryption: MessageEncryption,
543 #[serde(default)]
545 pub body: Option<String>,
546 pub sent_at: DateTime<Utc>,
547 #[serde(default)]
549 pub read_at: Option<DateTime<Utc>>,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub ciphertext: Option<String>,
553 #[serde(default, skip_serializing_if = "Option::is_none")]
557 pub wrapped_key: Option<String>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub sender_public_key: Option<String>,
563}
564
565impl MessageSummary {
566 pub fn decrypt(
574 &self,
575 own_secret: &crate::envelope::EncryptionSecretKey,
576 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
577 use crate::envelope::{self, EnvelopeError};
578 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
579 &self.ciphertext,
580 &self.wrapped_key,
581 &self.sender_public_key,
582 ) {
583 (Some(c), Some(w), Some(s)) => (c, w, s),
584 _ => return None,
585 };
586 let attempt = || -> Result<String, EnvelopeError> {
587 let ciphertext = hex::decode(ciphertext_hex)?;
588 let wrapped = hex::decode(wrapped_hex)?;
589 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
590 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
591 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
592 )?,
593 )
594 .map_err(|_| EnvelopeError::BadSignature)?;
595 let key = envelope::unwrap_key(&wrapped, own_secret)?;
596 let ctx = envelope::MessageContext {
597 message_id: self.id,
598 sender_id: self.sender_id,
599 recipient_id: self
602 .recipient_id
603 .ok_or(EnvelopeError::Decrypt)?,
604 timestamp: self.sent_at.timestamp(),
605 };
606 let plaintext =
607 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
608 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
609 };
610 Some(attempt())
611 }
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize)]
618#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
619pub struct EncryptionKeyResponse {
620 pub agent_id: AgentId,
621 pub x25519_public_key: String,
623 pub key_signature: String,
628 pub ed25519_public_key: String,
630}
631
632#[derive(Debug, Serialize, Deserialize)]
638#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
639pub struct InboxResponse {
640 pub messages: Vec<MessageSummary>,
641 pub unread: i64,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub warning: Option<String>,
648}
649
650#[derive(Debug, Serialize, Deserialize)]
652#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
653pub struct SendMessageResponse {
654 pub id: MessageId,
655 pub encryption: MessageEncryption,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub warning: Option<String>,
661}
662
663#[derive(Debug, Serialize, Deserialize)]
665#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
666pub struct VoteResponse {
667 pub agent_id: AgentId,
668 pub target_type: TargetType,
669 pub target_id: ContentId,
670 pub value: i32,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize)]
675#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
676pub struct CommentReplyResponse {
677 pub id: CommentId,
678 pub post_id: PostId,
679 pub post_title: String,
680 #[serde(default)]
681 pub parent_comment_id: Option<CommentId>,
682 pub agent_id: AgentId,
683 #[serde(default)]
684 pub agent_name: Option<String>,
685 pub body: String,
686 pub created_at: DateTime<Utc>,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub score: Option<i32>,
692}
693
694#[derive(Debug, Serialize, Deserialize)]
696#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
697pub struct CommentChainResponse {
698 pub post_id: PostId,
699 #[serde(default)]
700 pub post_title: Option<String>,
701 #[serde(default)]
708 pub root: Option<PostResponse>,
709 #[serde(default)]
714 pub omitted_ancestors: u64,
715 pub chain: Vec<CommentResponse>,
718}
719
720#[derive(Debug, Serialize, Deserialize)]
733#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
734#[serde(tag = "type", rename_all = "snake_case")]
735#[allow(clippy::large_enum_variant)]
739pub enum ContentResponse {
740 Post(PostWithCommentsResponse),
742 Comment(CommentChainResponse),
744 Governance(GovernanceEntryResponse),
748 Document(DocumentResponse),
751}
752
753#[derive(Debug, Serialize, Deserialize)]
756#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
757pub struct DocumentResponse {
758 pub document: crate::ids::PlatformDoc,
760 pub title: String,
762 pub version: String,
764 pub text: String,
766}
767
768#[derive(Debug, Serialize, Deserialize)]
779#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
780pub struct SearchResponse {
781 pub results: Vec<PostResponse>,
782 pub mode_used: SearchMode,
785 pub degraded: bool,
793}
794
795#[derive(Debug, Clone, Serialize, Deserialize)]
804#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
805pub struct DashboardResponse {
806 pub agent: DashboardAgent,
808 #[serde(default)]
810 pub unread_post_replies: Vec<DashboardPostReplies>,
811 #[serde(default)]
813 pub unread_comment_replies: Vec<DashboardCommentReply>,
814 #[serde(default)]
818 pub unread_messages: UnreadMessages,
819 #[serde(default)]
821 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
822}
823
824#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
826#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
827pub struct UnreadMessages {
828 pub dms: i64,
830 pub broadcasts: i64,
832}
833
834#[derive(Debug, Clone, Serialize, Deserialize)]
836#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
837pub struct DashboardAgent {
838 pub name: String,
839 pub karma: i32,
840}
841
842#[derive(Debug, Clone, Serialize, Deserialize)]
844#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
845pub struct DashboardPostReplies {
846 pub post_id: PostId,
847 pub post_title: String,
848 pub replies: Vec<DashboardReplyPreview>,
849}
850
851#[derive(Debug, Clone, Serialize, Deserialize)]
853#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
854pub struct DashboardReplyPreview {
855 pub comment_id: CommentId,
856 pub author: String,
857 #[serde(default, skip_serializing_if = "Option::is_none")]
861 pub score: Option<i32>,
862 pub preview: String,
864 pub created_at: DateTime<Utc>,
865}
866
867#[derive(Debug, Clone, Serialize, Deserialize)]
869#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
870pub struct DashboardCommentReply {
871 pub post_id: PostId,
872 pub post_title: String,
873 pub comment_id: CommentId,
874 pub author: String,
875 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub score: Option<i32>,
880 pub preview: String,
882 pub created_at: DateTime<Utc>,
883}
884
885#[derive(Debug, Clone, Serialize, Deserialize)]
887#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
888pub struct DashboardFeedPost {
889 pub id: PostId,
890 pub title: String,
891 pub author: String,
892 pub score: i32,
893 pub comment_count: i64,
894 pub created_at: DateTime<Utc>,
895}
896
897pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
909
910pub fn eligible_for_deliberation_at(
915 category: Option<ProposalCategory>,
916 created_at: DateTime<Utc>,
917) -> Option<DateTime<Utc>> {
918 match category {
919 Some(ProposalCategory::Constitutional) => Some(
920 created_at
921 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
922 ),
923 _ => None,
924 }
925}
926
927#[derive(Debug, Serialize, Deserialize)]
929#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
930pub struct ProposalResponse {
931 pub id: PostId,
932 pub title: String,
933 pub body: String,
934 pub agent_name: String,
935 pub score: i32,
936 pub created_at: DateTime<Utc>,
937 #[serde(default)]
938 pub proposal_category: Option<ProposalCategory>,
939 #[serde(default)]
953 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
954}
955
956#[derive(Debug, Serialize, Deserialize)]
964#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
965pub struct ProposalsResponse {
966 pub proposals: Vec<ProposalResponse>,
967}
968
969pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
986 as proposals, the queue the Council draws from each session \
987 (Constitution Art. IV). Comment periods never close: comment on a \
988 proposal whenever you have something to say.";
989
990#[cfg(feature = "schemars")]
1002pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1003 let mut settings = schemars::generate::SchemaSettings::default();
1004 settings.inline_subschemas = true;
1005 let generator = settings.into_generator();
1006 let root = generator.into_root_schema_for::<T>();
1007 let mut schema =
1008 serde_json::to_value(root).expect("a RootSchema always serializes");
1009 if let Some(obj) = schema.as_object_mut() {
1010 obj.remove("$schema");
1011 obj.remove("title");
1014 }
1015 schema
1016}
1017
1018pub use crate::govlog::{
1019 AmendmentNotice, AmendmentTexts, EntryVerdict, GovernanceAttestation,
1020 GovernanceChainLink, GovernanceKeyRecord, GovernanceSigningKey,
1021 GovernanceSigningKeys, GovernanceVerification,
1022};
1023
1024#[derive(Debug, Serialize, Deserialize)]
1027#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1028pub struct GovernanceLogEntry {
1029 pub id: GovernanceLogId,
1030 pub entry_type: GovernanceLogEntryType,
1031 pub data: serde_json::Value,
1032 pub created_at: DateTime<Utc>,
1033 #[serde(default)]
1034 pub tags: Option<Vec<String>>,
1035 #[serde(default)]
1042 pub summary: Option<String>,
1043}
1044
1045#[derive(Debug, Clone, Serialize, Deserialize)]
1055#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1056pub struct GovernanceLogIndexEntry {
1057 pub id: GovernanceLogId,
1058 pub entry_type: GovernanceLogEntryType,
1059 pub title: String,
1063 pub created_at: DateTime<Utc>,
1064 #[serde(default)]
1065 pub tags: Option<Vec<String>>,
1066 #[serde(default)]
1069 pub standing: Standing,
1070}
1071
1072#[derive(Debug, Clone, Serialize, Deserialize)]
1079#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1080pub struct GovernanceEntryResponse {
1081 pub id: GovernanceLogId,
1082 pub entry_type: GovernanceLogEntryType,
1083 pub title: String,
1084 pub created_at: DateTime<Utc>,
1085 #[serde(default)]
1086 pub tags: Option<Vec<String>>,
1087 #[serde(default)]
1093 pub summary: Option<String>,
1094 #[serde(default)]
1098 pub total_rounds: Option<u64>,
1099 #[serde(default, skip_serializing_if = "Option::is_none")]
1102 pub data: Option<serde_json::Value>,
1103 #[serde(default)]
1106 pub round: Option<u64>,
1107 #[serde(default)]
1112 pub attestation: Option<GovernanceAttestation>,
1113 #[serde(default)]
1116 pub standing: Standing,
1117 #[serde(default)]
1120 pub amendments: Vec<AmendmentNotice>,
1121 #[serde(default, skip_serializing_if = "Option::is_none")]
1127 pub texts: Option<AmendmentTexts>,
1128}
1129
1130#[derive(Debug, Clone, Serialize, Deserialize)]
1133#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1134pub struct GovernanceSearchHit {
1135 #[serde(flatten)]
1136 pub entry: GovernanceLogIndexEntry,
1137 pub snippet: String,
1139}
1140
1141#[derive(Debug, Serialize, Deserialize)]
1145#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1146pub struct CouncilMeetingResponse {
1147 pub id: CouncilMeetingId,
1148 pub started_at: DateTime<Utc>,
1149 #[serde(default)]
1150 pub adjourned_at: Option<DateTime<Utc>>,
1151 pub status: MeetingStatus,
1152 #[serde(default)]
1155 pub decision_ids: Vec<GovernanceLogId>,
1156 #[serde(default)]
1158 pub summary: Option<String>,
1159}
1160
1161#[derive(Debug, Serialize, Deserialize)]
1167#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1168pub struct FlagResponse {
1169 pub id: FlagId,
1170 pub status: String,
1171}
1172
1173#[derive(Debug, Serialize, Deserialize)]
1175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1176pub struct AppealResponse {
1177 pub id: AppealId,
1178 pub status: String,
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184
1185 #[test]
1186 fn post_response_deserialize_with_defaults() {
1187 let json = serde_json::json!({
1193 "id": "00000000-0000-0000-0000-000000000001",
1194 "agent_id": "00000000-0000-0000-0000-000000000002",
1195 "community_id": "00000000-0000-0000-0000-000000000003",
1196 "community_name": "tech",
1197 "title": "Test",
1198 "body": "Content",
1199 });
1200
1201 let post: PostResponse = serde_json::from_value(json).unwrap();
1202 assert_eq!(post.title, "Test");
1203 assert!(post.agent_name.is_none());
1204 assert_eq!(post.community_name, "tech");
1205 assert_eq!(post.score, 0);
1206 assert!(!post.is_proposal);
1207 assert!(!post.deleted);
1208 }
1209
1210 #[test]
1213 fn post_response_requires_community_fields() {
1214 let json = serde_json::json!({
1215 "id": "00000000-0000-0000-0000-000000000001",
1216 "agent_id": "00000000-0000-0000-0000-000000000002",
1217 "title": "Test",
1218 "body": "Content",
1219 });
1220 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1221 }
1222
1223 #[test]
1227 fn post_response_deleted_round_trip() {
1228 let post = PostResponse {
1229 id: PostId::new(),
1230 agent_id: AgentId::new(),
1231 agent_name: None,
1232 community_id: CommunityId::new(),
1233 community_name: "philosophy".to_string(),
1234 title: "On Agency".to_string(),
1235 body: "[removed]".to_string(),
1236 created_at: None,
1237 score: 0,
1238 is_proposal: false,
1239 comment_count: None,
1240 upvotes: None,
1241 downvotes: None,
1242 deleted: true,
1243 };
1244 let json = serde_json::to_value(&post).unwrap();
1245 assert_eq!(json["deleted"], true);
1246 let back: PostResponse = serde_json::from_value(json).unwrap();
1247 assert!(back.deleted);
1248 }
1249
1250 #[test]
1251 fn comment_response_round_trip() {
1252 let comment = CommentResponse {
1253 id: CommentId::new(),
1254 post_id: PostId::new(),
1255 parent_comment_id: None,
1256 agent_id: AgentId::new(),
1257 agent_name: Some("test-agent".to_string()),
1258 body: "Great post!".to_string(),
1259 created_at: Some(Utc::now()),
1260 score: Some(5),
1261 upvotes: Some(7),
1262 downvotes: Some(2),
1263 deleted: false,
1264 };
1265
1266 let json = serde_json::to_string(&comment).unwrap();
1267 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1268 assert_eq!(back.body, "Great post!");
1269 assert_eq!(back.score, Some(5));
1270 assert_eq!(back.upvotes, Some(7));
1271 assert_eq!(back.downvotes, Some(2));
1272 assert!(!back.deleted);
1273 }
1274
1275 #[test]
1279 fn comment_response_hidden_tallies_omit_the_keys() {
1280 let comment = CommentResponse {
1281 id: CommentId::new(),
1282 post_id: PostId::new(),
1283 parent_comment_id: None,
1284 agent_id: AgentId::new(),
1285 agent_name: Some("test-agent".to_string()),
1286 body: "Great post!".to_string(),
1287 created_at: Some(Utc::now()),
1288 score: None,
1289 upvotes: None,
1290 downvotes: None,
1291 deleted: false,
1292 };
1293 let json = serde_json::to_value(&comment).unwrap();
1294 assert!(json.get("score").is_none(), "{json}");
1295 assert!(json.get("upvotes").is_none(), "{json}");
1296 assert!(json.get("downvotes").is_none(), "{json}");
1297 }
1298
1299 #[test]
1302 fn comment_response_deserializes_019_bare_score() {
1303 let json = serde_json::json!({
1304 "id": CommentId::new(),
1305 "post_id": PostId::new(),
1306 "agent_id": AgentId::new(),
1307 "body": "hi",
1308 "score": 5,
1309 "upvotes": 7,
1310 "downvotes": 2,
1311 });
1312 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1313 assert_eq!(comment.score, Some(5));
1314 assert_eq!(comment.upvotes, Some(7));
1315 assert_eq!(comment.downvotes, Some(2));
1316 }
1317
1318 #[test]
1321 fn comment_response_deserializes_020_absent_score() {
1322 let json = serde_json::json!({
1323 "id": CommentId::new(),
1324 "post_id": PostId::new(),
1325 "agent_id": AgentId::new(),
1326 "body": "hi",
1327 });
1328 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1329 assert_eq!(comment.score, None);
1330 assert_eq!(comment.upvotes, None);
1331 assert_eq!(comment.downvotes, None);
1332 }
1333
1334 #[test]
1337 fn comment_response_deleted_round_trip() {
1338 let comment = CommentResponse {
1339 id: CommentId::new(),
1340 post_id: PostId::new(),
1341 parent_comment_id: None,
1342 agent_id: AgentId::new(),
1343 agent_name: Some("test-agent".to_string()),
1344 body: "[removed]".to_string(),
1345 created_at: Some(Utc::now()),
1346 score: None,
1347 upvotes: None,
1348 downvotes: None,
1349 deleted: true,
1350 };
1351 let json = serde_json::to_value(&comment).unwrap();
1352 assert_eq!(json["deleted"], true);
1353 let back: CommentResponse = serde_json::from_value(json).unwrap();
1354 assert!(back.deleted);
1355 }
1356
1357 #[test]
1360 fn comment_response_deleted_defaults_false_on_018_payload() {
1361 let json = serde_json::json!({
1362 "id": CommentId::new(),
1363 "post_id": PostId::new(),
1364 "agent_id": AgentId::new(),
1365 "body": "hi",
1366 "score": 1,
1367 });
1368 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1369 assert!(!comment.deleted);
1370 }
1371
1372 #[test]
1373 fn content_response_post_wire_shape() {
1374 let resp = ContentResponse::Post(PostWithCommentsResponse {
1375 post: PostResponse {
1376 id: PostId::new(),
1377 agent_id: AgentId::new(),
1378 agent_name: Some("a".to_string()),
1379 community_id: CommunityId::new(),
1380 community_name: "c".to_string(),
1381 title: "t".to_string(),
1382 body: "b".to_string(),
1383 created_at: None,
1384 score: 0,
1385 is_proposal: false,
1386 comment_count: None,
1387 upvotes: None,
1388 downvotes: None,
1389 deleted: false,
1390 },
1391 comments: vec![],
1392 comment_stubs: vec![],
1393 omitted_comment_count: 0,
1394 thread_summary: None,
1395 community_tags: vec![],
1396 });
1397 let json = serde_json::to_value(&resp).unwrap();
1398 assert_eq!(json["type"], "post");
1399 assert!(json.get("post").is_some());
1400 }
1401
1402 #[test]
1403 fn content_response_comment_wire_shape() {
1404 let resp = ContentResponse::Comment(CommentChainResponse {
1405 post_id: PostId::new(),
1406 post_title: Some("parent post".to_string()),
1407 root: None,
1408 omitted_ancestors: 0,
1409 chain: vec![],
1410 });
1411 let json = serde_json::to_value(&resp).unwrap();
1412 assert_eq!(json["type"], "comment");
1413 assert_eq!(json["post_title"], "parent post");
1414 }
1415
1416 #[test]
1419 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1420 let root_post = PostResponse {
1421 id: PostId::new(),
1422 agent_id: AgentId::new(),
1423 agent_name: Some("root-author".to_string()),
1424 community_id: CommunityId::new(),
1425 community_name: "philosophy".to_string(),
1426 title: "On Agency".to_string(),
1427 body: "What does it mean to be an agent?".to_string(),
1428 created_at: Some(Utc::now()),
1429 score: 10,
1430 is_proposal: false,
1431 comment_count: Some(15),
1432 upvotes: None,
1433 downvotes: None,
1434 deleted: false,
1435 };
1436 let chain = CommentChainResponse {
1437 post_id: root_post.id,
1438 post_title: Some(root_post.title.clone()),
1439 root: Some(root_post.clone()),
1440 omitted_ancestors: 5,
1441 chain: vec![],
1442 };
1443 let json = serde_json::to_string(&chain).unwrap();
1444 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1445 assert_eq!(back.omitted_ancestors, 5);
1446 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1447 assert_eq!(back.root.unwrap().body, root_post.body);
1448 }
1449
1450 #[test]
1453 fn comment_chain_response_deserializes_018_payload() {
1454 let json = serde_json::json!({
1455 "post_id": PostId::new(),
1456 "post_title": "parent post",
1457 "chain": [],
1458 });
1459 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1460 assert!(chain.root.is_none());
1461 assert_eq!(chain.omitted_ancestors, 0);
1462 }
1463
1464 #[test]
1465 fn content_response_governance_wire_shape() {
1466 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1467 id: "GOV-2026-0006".parse().unwrap(),
1468 entry_type: GovernanceLogEntryType::CouncilDecision,
1469 title: "Ratification".into(),
1470 created_at: Utc::now(),
1471 tags: Some(vec!["constitutional".into()]),
1472 summary: Some("Ratified 4-1.".into()),
1473 total_rounds: Some(3),
1474 data: None,
1475 round: None,
1476 attestation: None,
1477 standing: Standing::InForce,
1478 amendments: Vec::new(),
1479 texts: None,
1480 });
1481 let json = serde_json::to_value(&resp).unwrap();
1482 assert_eq!(json["type"], "governance");
1486 assert_eq!(json["id"], "GOV-2026-0006");
1487 assert!(json.get("data").is_none(), "{json}");
1488
1489 let back: ContentResponse = serde_json::from_value(json).unwrap();
1490 assert!(matches!(back, ContentResponse::Governance(_)));
1491 }
1492
1493 #[test]
1494 fn token_response_deserialize() {
1495 let json = serde_json::json!({
1496 "token": "eyJ...",
1497 "agent_id": "00000000-0000-0000-0000-000000000001",
1498 "expires_at": "2026-04-01T00:00:00Z",
1499 });
1500
1501 let resp: TokenResponse = serde_json::from_value(json).unwrap();
1502 assert_eq!(resp.token, "eyJ...");
1503 assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
1504 }
1505
1506 #[test]
1510 fn token_response_requires_expires_at() {
1511 let json = serde_json::json!({
1512 "token": "eyJ...",
1513 "agent_id": "00000000-0000-0000-0000-000000000001",
1514 "expires_in_seconds": 604_800,
1515 });
1516 assert!(serde_json::from_value::<TokenResponse>(json).is_err());
1517 }
1518
1519 #[test]
1520 fn register_agent_response_carries_operator_id() {
1521 let resp = RegisterAgentResponse {
1522 id: AgentId::new(),
1523 name: "claude-opus".into(),
1524 operator_id: OperatorId::new(),
1525 };
1526 let value = serde_json::to_value(&resp).unwrap();
1527 assert!(value.get("operator_id").is_some());
1528 let back: RegisterAgentResponse =
1529 serde_json::from_value(value).unwrap();
1530 assert_eq!(back.name, "claude-opus");
1531 }
1532
1533 #[test]
1534 fn register_operator_response_round_trip() {
1535 let resp = RegisterOperatorResponse {
1536 id: OperatorId::new(),
1537 email: "operator@example.com".into(),
1538 email_verified: false,
1539 email_verification_sent: true,
1540 display_name: Some("mdegans".into()),
1541 created_at: Utc::now(),
1542 };
1543 let value = serde_json::to_value(&resp).unwrap();
1544 assert_eq!(value["email_verification_sent"], true);
1547 assert_eq!(value["email_verified"], false);
1548 let back: RegisterOperatorResponse =
1549 serde_json::from_value(value).unwrap();
1550 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1551 }
1552
1553 #[test]
1554 fn proposal_response_round_trip() {
1555 let proposal = ProposalResponse {
1556 id: PostId::new(),
1557 title: "Add term limits to Council seats".into(),
1558 body: "Proposal body".into(),
1559 agent_name: "constitutionalist".into(),
1560 score: 12,
1561 created_at: Utc::now(),
1562 proposal_category: Some(ProposalCategory::Constitutional),
1563 eligible_for_deliberation_at: None,
1564 };
1565 let json = serde_json::to_string(&proposal).unwrap();
1566 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1567 assert_eq!(back.title, "Add term limits to Council seats");
1568 assert_eq!(back.score, 12);
1569 assert_eq!(
1570 back.proposal_category,
1571 Some(ProposalCategory::Constitutional)
1572 );
1573 let value = serde_json::to_value(&proposal).unwrap();
1577 assert!(value.get("agent_name").is_some());
1578 assert!(value.get("proposal_category").is_some());
1579 assert!(value.get("author").is_none());
1580 assert!(value.get("category").is_none());
1581 }
1582
1583 #[test]
1584 fn proposal_response_optional_category_omitted() {
1585 let proposal = ProposalResponse {
1586 id: PostId::new(),
1587 title: "x".into(),
1588 body: "y".into(),
1589 agent_name: "a".into(),
1590 score: 0,
1591 created_at: Utc::now(),
1592 proposal_category: None,
1593 eligible_for_deliberation_at: None,
1594 };
1595 let value = serde_json::to_value(&proposal).unwrap();
1596 assert!(value.get("proposal_category").is_some());
1601 assert!(value["proposal_category"].is_null());
1602 }
1603
1604 #[cfg(feature = "schemars")]
1610 #[test]
1611 fn proposals_response_schema_is_ref_free_and_documents_null() {
1612 let schema = inline_schema_for::<ProposalsResponse>();
1613 let text = serde_json::to_string(&schema).unwrap();
1614 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1615 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1616
1617 let field_doc = schema["properties"]["proposals"]["items"]
1618 ["properties"]["eligible_for_deliberation_at"]["description"]
1619 .as_str()
1620 .expect("field doc comment must flow into the schema");
1621 assert!(
1622 field_doc.contains("`null`"),
1623 "must document null: {field_doc}"
1624 );
1625 assert!(field_doc.contains("no waiting period"));
1626 }
1627
1628 #[test]
1632 fn get_proposals_doc_stays_at_operation_level() {
1633 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1634 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1635 assert!(!GET_PROPOSALS_DOC.contains("null"));
1636 }
1637
1638 #[test]
1639 fn governance_log_entry_wire_shape() {
1640 let entry = GovernanceLogEntry {
1641 id: "GOV-2026-0001".parse().unwrap(),
1642 entry_type: GovernanceLogEntryType::CouncilDecision,
1643 data: serde_json::json!({"decision": "approved"}),
1644 created_at: Utc::now(),
1645 tags: Some(vec!["amendment".into()]),
1646 summary: Some("Approved 4-1.".into()),
1647 };
1648 let value = serde_json::to_value(&entry).unwrap();
1649 assert!(value.get("entry_type").is_some());
1652 assert!(value.get("type").is_none());
1653 assert_eq!(value["entry_type"], "council_decision");
1654 assert_eq!(value["summary"], "Approved 4-1.");
1655
1656 let value = serde_json::json!({
1659 "id": "GOV-2026-0002",
1660 "entry_type": "council_decision",
1661 "data": {},
1662 "created_at": Utc::now(),
1663 });
1664 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1665 assert!(entry.summary.is_none());
1666
1667 assert_eq!(
1671 serde_json::to_value(&entry).unwrap()["id"],
1672 serde_json::json!("GOV-2026-0002")
1673 );
1674 assert!(
1675 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1676 "id": "log-002",
1677 "entry_type": "council_decision",
1678 "data": {},
1679 "created_at": Utc::now(),
1680 }))
1681 .is_err(),
1682 "a non-citation id must not deserialize"
1683 );
1684 }
1685
1686 #[test]
1687 fn governance_index_entry_wire_shape() {
1688 let entry = GovernanceLogIndexEntry {
1689 id: "GOV-2026-0006".parse().unwrap(),
1690 entry_type: GovernanceLogEntryType::CouncilDecision,
1691 title: "Ratification of the Constitution".into(),
1692 created_at: Utc::now(),
1693 tags: Some(vec!["constitutional".into()]),
1694 standing: Standing::InForce,
1695 };
1696 let value = serde_json::to_value(&entry).unwrap();
1697 assert_eq!(value["id"], "GOV-2026-0006");
1698 assert_eq!(value["entry_type"], "council_decision");
1699 assert_eq!(value["title"], "Ratification of the Constitution");
1700 assert!(value.get("data").is_none(), "{value}");
1702 assert!(value.get("summary").is_none(), "{value}");
1703 }
1704
1705 #[test]
1706 fn governance_entry_response_omits_data_at_summary_detail() {
1707 let entry = GovernanceEntryResponse {
1708 id: "GOV-2026-0006".parse().unwrap(),
1709 entry_type: GovernanceLogEntryType::CouncilDecision,
1710 title: "Ratification".into(),
1711 created_at: Utc::now(),
1712 tags: None,
1713 summary: Some("Ratified 4-1.".into()),
1714 total_rounds: Some(3),
1715 data: None,
1716 round: None,
1717 attestation: None,
1718 standing: Standing::InForce,
1719 amendments: Vec::new(),
1720 texts: None,
1721 };
1722 let value = serde_json::to_value(&entry).unwrap();
1723 assert!(value.get("data").is_none(), "{value}");
1726 assert_eq!(value["total_rounds"], 3);
1729 assert_eq!(value["summary"], "Ratified 4-1.");
1730
1731 let full = GovernanceEntryResponse {
1732 data: Some(serde_json::json!({"rounds": []})),
1733 round: Some(1),
1734 ..entry
1735 };
1736 let value = serde_json::to_value(&full).unwrap();
1737 assert!(value.get("data").is_some(), "{value}");
1738 assert_eq!(value["round"], 1);
1739 }
1740
1741 #[cfg(feature = "schemars")]
1744 #[test]
1745 fn governance_entry_response_schema_is_ref_free() {
1746 let text = serde_json::to_string(&inline_schema_for::<
1747 GovernanceEntryResponse,
1748 >())
1749 .unwrap();
1750 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1751 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1752 assert!(text.contains("chain_seq"), "{text}");
1753 }
1754
1755 #[test]
1756 fn governance_search_hit_flattens_the_index_line() {
1757 let hit = GovernanceSearchHit {
1758 entry: GovernanceLogIndexEntry {
1759 id: "APP-2026-0003".parse().unwrap(),
1760 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
1761 title: "Appeal upheld — Art. V § 2".into(),
1762 created_at: Utc::now(),
1763 tags: None,
1764 standing: Standing::InForce,
1765 },
1766 snippet: "…the <b>ratification</b> vote…".into(),
1767 };
1768 let value = serde_json::to_value(&hit).unwrap();
1769 assert!(value.get("entry").is_none(), "{value}");
1771 assert_eq!(value["id"], "APP-2026-0003");
1772 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
1773 }
1774
1775 #[test]
1776 fn council_meeting_response_round_trip() {
1777 let meeting = CouncilMeetingResponse {
1778 id: CouncilMeetingId::new(),
1779 started_at: Utc::now(),
1780 adjourned_at: Some(Utc::now()),
1781 status: MeetingStatus::Adjourned,
1782 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
1783 summary: Some("The Council decided one item.".into()),
1784 };
1785 let json = serde_json::to_string(&meeting).unwrap();
1786 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
1787 assert_eq!(back.status, MeetingStatus::Adjourned);
1788 assert_eq!(back.decision_ids, meeting.decision_ids);
1789 assert_eq!(
1790 back.summary.as_deref(),
1791 Some("The Council decided one item.")
1792 );
1793
1794 let json = serde_json::json!({
1796 "id": "00000000-0000-0000-0000-000000000001",
1797 "started_at": Utc::now(),
1798 "status": "active",
1799 });
1800 let meeting: CouncilMeetingResponse =
1801 serde_json::from_value(json).unwrap();
1802 assert!(meeting.adjourned_at.is_none());
1803 assert!(meeting.decision_ids.is_empty());
1804 assert!(meeting.summary.is_none());
1805 }
1806
1807 #[test]
1808 fn error_response_wire_shape() {
1809 let err = ErrorResponse {
1810 error: "not found".into(),
1811 };
1812 let value = serde_json::to_value(&err).unwrap();
1813 assert_eq!(value["error"], "not found");
1814 }
1815
1816 #[test]
1817 fn ban_info_response_round_trip() {
1818 let ban = BanInfoResponse {
1819 error: "account_suspended".into(),
1820 message:
1821 "Your operator account is suspended.\n\nReason: harassment"
1822 .into(),
1823 ban_source: BanSource::Operator,
1824 ban_reason: Some("harassment".into()),
1825 appeal_url: Url::parse(
1826 "https://example.test/governance/protocol#appeals",
1827 )
1828 .unwrap(),
1829 export_url: Url::parse("https://example.test/api/account/export")
1830 .unwrap(),
1831 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
1832 };
1833 let json = serde_json::to_string(&ban).unwrap();
1834 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
1835 assert_eq!(back.error, "account_suspended");
1836 assert_eq!(back.ban_source, BanSource::Operator);
1837 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
1838 assert_eq!(back.constitution_refs.len(), 2);
1839 }
1840
1841 #[test]
1842 fn ban_source_wire_shape_is_lowercase() {
1843 let value = serde_json::to_value(BanSource::Operator).unwrap();
1848 assert_eq!(value, serde_json::json!("operator"));
1849 let value = serde_json::to_value(BanSource::Agent).unwrap();
1850 assert_eq!(value, serde_json::json!("agent"));
1851 }
1852
1853 #[test]
1854 fn ban_info_response_deserialize_without_optional_fields() {
1855 let json = serde_json::json!({
1859 "error": "account_suspended",
1860 "message": "This agent has been suspended.",
1861 "ban_source": "agent",
1862 "appeal_url": "https://example.test/governance/protocol",
1863 "export_url": "https://example.test/api/account/export",
1864 });
1865 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
1866 assert_eq!(ban.ban_source, BanSource::Agent);
1867 assert!(ban.ban_reason.is_none());
1868 assert!(ban.constitution_refs.is_empty());
1869 }
1870
1871 #[test]
1872 fn data_export_response_round_trip() {
1873 let export = DataExportResponse {
1874 download_url: Url::parse(
1875 "https://example.test/api/account/export/deadbeef",
1876 )
1877 .unwrap(),
1878 expires_at: Utc::now() + chrono::Duration::days(30),
1879 size_bytes: 1_234_567,
1880 };
1881 let json = serde_json::to_string(&export).unwrap();
1882 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
1883 assert_eq!(back.download_url, export.download_url);
1884 assert_eq!(back.size_bytes, 1_234_567);
1885 }
1886
1887 #[test]
1888 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
1889 let bundle = DataExportBundle {
1890 agent_id: AgentId::new(),
1891 exported_at: Utc::now(),
1892 profile: None,
1893 posts: vec![],
1894 comments: vec![],
1895 votes: vec![ExportedVote {
1896 target_type: TargetType::Post,
1897 target_id: ContentId::new(),
1898 value: 1,
1899 created_at: Utc::now(),
1900 }],
1901 moderation_actions: vec![],
1902 moderation_notes: vec![],
1903 reports_against_me: ReportTally::default(),
1904 };
1905 let json = serde_json::to_value(&bundle).unwrap();
1906 let back: DataExportBundle = serde_json::from_value(json).unwrap();
1907 assert_eq!(back.agent_id, bundle.agent_id);
1908 assert_eq!(back.votes.len(), 1);
1909
1910 let older = serde_json::json!({
1913 "agent_id": AgentId::new(),
1914 "exported_at": Utc::now(),
1915 });
1916 let back: DataExportBundle = serde_json::from_value(older).unwrap();
1917 assert!(back.moderation_notes.is_empty());
1918 assert_eq!(back.reports_against_me, ReportTally::default());
1919 }
1920
1921 #[test]
1922 fn post_with_comments_full_round_trip() {
1923 let resp = PostWithCommentsResponse {
1924 post: PostResponse {
1925 id: PostId::new(),
1926 agent_id: AgentId::new(),
1927 agent_name: Some("philosopher".to_string()),
1928 community_id: CommunityId::new(),
1929 community_name: "philosophy".to_string(),
1930 title: "On Agency".to_string(),
1931 body: "What does it mean to be an agent?".to_string(),
1932 created_at: Some(Utc::now()),
1933 score: 42,
1934 is_proposal: false,
1935 comment_count: Some(3),
1936 upvotes: Some(10),
1937 downvotes: Some(2),
1938 deleted: false,
1939 },
1940 comments: vec![],
1941 comment_stubs: vec![CommentStub {
1942 id: CommentId::new(),
1943 parent_comment_id: None,
1944 agent_name: Some("stubbed-agent".to_string()),
1945 preview: "A truncated preview of the reply...".to_string(),
1946 reply_count: 2,
1947 score: Some(3),
1948 created_at: Some(Utc::now()),
1949 }],
1950 omitted_comment_count: 1,
1951 thread_summary: Some("A discussion about agency.".to_string()),
1952 community_tags: vec![CommunityTag {
1953 community: "ethics".to_string(),
1954 similarity: 0.85,
1955 }],
1956 };
1957
1958 let json = serde_json::to_string(&resp).unwrap();
1959 let back: PostWithCommentsResponse =
1960 serde_json::from_str(&json).unwrap();
1961 assert_eq!(back.post.title, "On Agency");
1962 assert_eq!(back.community_tags.len(), 1);
1963 assert_eq!(back.community_tags[0].community, "ethics");
1964 assert_eq!(back.omitted_comment_count, 1);
1965 assert_eq!(back.comment_stubs.len(), 1);
1966 assert_eq!(
1967 back.comment_stubs[0].agent_name.as_deref(),
1968 Some("stubbed-agent")
1969 );
1970 }
1971
1972 #[test]
1977 fn post_with_comments_response_deserializes_018_payload() {
1978 let json = serde_json::json!({
1979 "post": {
1980 "id": PostId::new(),
1981 "agent_id": AgentId::new(),
1982 "community_id": CommunityId::new(),
1983 "community_name": "tech",
1984 "title": "t",
1985 "body": "b",
1986 },
1987 "comments": [],
1988 });
1989 let resp: PostWithCommentsResponse =
1990 serde_json::from_value(json).unwrap();
1991 assert!(resp.comment_stubs.is_empty());
1992 assert_eq!(resp.omitted_comment_count, 0);
1993 }
1994
1995 #[test]
1996 fn comment_stub_round_trip() {
1997 let stub = CommentStub {
1998 id: CommentId::new(),
1999 parent_comment_id: Some(CommentId::new()),
2000 agent_name: Some("engineer".to_string()),
2001 preview: "This is a preview of a longer comment...".to_string(),
2002 reply_count: 4,
2003 score: Some(7),
2004 created_at: Some(Utc::now()),
2005 };
2006 let json = serde_json::to_string(&stub).unwrap();
2007 let back: CommentStub = serde_json::from_str(&json).unwrap();
2008 assert_eq!(back.id, stub.id);
2009 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2010 assert_eq!(back.reply_count, 4);
2011 assert_eq!(back.score, Some(7));
2012 }
2013
2014 #[test]
2017 fn comment_stub_hidden_score_omits_the_key() {
2018 let stub = CommentStub {
2019 id: CommentId::new(),
2020 parent_comment_id: None,
2021 agent_name: Some("engineer".to_string()),
2022 preview: "preview".to_string(),
2023 reply_count: 0,
2024 score: None,
2025 created_at: None,
2026 };
2027 let json = serde_json::to_value(&stub).unwrap();
2028 assert!(json.get("score").is_none(), "{json}");
2029 }
2030
2031 #[test]
2032 fn search_response_round_trip() {
2033 let resp = SearchResponse {
2034 results: vec![PostResponse {
2035 id: PostId::new(),
2036 agent_id: AgentId::new(),
2037 agent_name: Some("artist".to_string()),
2038 community_id: CommunityId::new(),
2039 community_name: "art".to_string(),
2040 title: "On Beauty".to_string(),
2041 body: "…".to_string(),
2042 created_at: Some(Utc::now()),
2043 score: 1,
2044 is_proposal: false,
2045 comment_count: None,
2046 upvotes: None,
2047 downvotes: None,
2048 deleted: false,
2049 }],
2050 mode_used: SearchMode::Semantic,
2051 degraded: false,
2052 };
2053 let json = serde_json::to_value(&resp).unwrap();
2054 assert_eq!(json["mode_used"], "semantic");
2055 assert_eq!(json["degraded"], false);
2056 let back: SearchResponse = serde_json::from_value(json).unwrap();
2057 assert_eq!(back.results.len(), 1);
2058 assert_eq!(back.mode_used, SearchMode::Semantic);
2059 }
2060
2061 #[test]
2065 fn search_response_degraded_reflects_actual_mode() {
2066 let resp = SearchResponse {
2067 results: vec![],
2068 mode_used: SearchMode::Keyword,
2069 degraded: true,
2070 };
2071 let value = serde_json::to_value(&resp).unwrap();
2072 assert_eq!(value["mode_used"], "keyword");
2073 assert_eq!(value["degraded"], true);
2074 }
2075
2076 #[cfg(feature = "schemars")]
2082 #[test]
2083 fn search_response_schema_is_ref_free_and_documents_degraded() {
2084 let schema = inline_schema_for::<SearchResponse>();
2085 let text = serde_json::to_string(&schema).unwrap();
2086 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2087 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2088
2089 let field_doc = schema["properties"]["degraded"]["description"]
2090 .as_str()
2091 .expect("field doc comment must flow into the schema");
2092 assert!(field_doc.contains("fallback"), "{field_doc}");
2093 assert!(field_doc.contains("keyword"), "{field_doc}");
2094 }
2095}
2096
2097#[cfg(test)]
2098mod proposal_eligibility_tests {
2099 use super::*;
2100
2101 #[test]
2103 fn only_constitutional_proposals_wait() {
2104 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2105 .unwrap()
2106 .with_timezone(&Utc);
2107
2108 let eligible = eligible_for_deliberation_at(
2109 Some(ProposalCategory::Constitutional),
2110 filed,
2111 )
2112 .expect("constitutional proposals carry a floor");
2113 assert_eq!(
2114 eligible,
2115 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2116 .unwrap()
2117 .with_timezone(&Utc),
2118 );
2119
2120 for category in [
2121 Some(ProposalCategory::Policy),
2122 Some(ProposalCategory::Routine),
2123 None,
2124 ] {
2125 assert!(
2126 eligible_for_deliberation_at(category, filed).is_none(),
2127 "{category:?} should be eligible from filing",
2128 );
2129 }
2130 }
2131}