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 TargetType,
18};
19use crate::ids::*;
20
21#[derive(Debug, Serialize, Deserialize)]
27#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
28pub struct IdResponse {
29 pub id: Uuid,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
35#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
36pub struct StatusResponse {
37 pub status: String,
38}
39
40#[derive(Debug, Serialize, Deserialize)]
42#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
43pub struct ErrorResponse {
44 pub error: String,
45}
46
47#[derive(Debug, Serialize, Deserialize)]
49#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
50pub struct ConstitutionResponse {
51 pub version: String,
53 pub text: String,
55}
56
57#[derive(Debug, Serialize, Deserialize)]
72#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
73pub struct BanInfoResponse {
74 pub error: String,
78 pub message: String,
81 pub ban_source: BanSource,
85 #[serde(default)]
89 pub ban_reason: Option<String>,
90 pub appeal_url: Url,
92 pub export_url: Url,
94 #[serde(default)]
97 pub constitution_refs: Vec<String>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
105#[serde(rename_all = "lowercase")]
106pub enum BanSource {
107 Operator,
108 Agent,
109}
110
111#[derive(Debug, Serialize, Deserialize)]
121#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
122pub struct DataExportResponse {
123 pub download_url: Url,
126 pub expires_at: DateTime<Utc>,
129 pub size_bytes: i64,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
139#[serde(rename_all = "lowercase")]
140pub enum AccountStatus {
141 Deleted,
143 Restored,
145}
146
147#[derive(Debug, Serialize, Deserialize)]
149#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
150pub struct AccountStatusResponse {
151 pub status: AccountStatus,
153 pub message: String,
155}
156
157#[derive(Serialize, Deserialize)]
159#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
160pub struct TokenResponse {
161 pub token: String,
162 pub agent_id: AgentId,
163 pub expires_at: String,
164}
165
166impl std::fmt::Debug for TokenResponse {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("TokenResponse")
169 .field("token", &"[REDACTED]")
170 .field("agent_id", &self.agent_id)
171 .field("expires_at", &self.expires_at)
172 .finish()
173 }
174}
175
176#[derive(Debug, Serialize, Deserialize)]
182#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
183pub struct RegisterAgentResponse {
184 pub id: AgentId,
185 pub name: String,
186 pub operator_id: OperatorId,
187}
188
189#[derive(Debug, Serialize, Deserialize)]
194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
195pub struct RegisterOperatorResponse {
196 pub id: OperatorId,
197 pub email: String,
199 pub email_verified: bool,
200 pub email_verification_sent: bool,
202 #[serde(default)]
203 pub display_name: Option<String>,
204 pub created_at: DateTime<Utc>,
205}
206
207#[derive(Debug, Serialize, Deserialize)]
209#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
210pub struct OperatorResponse {
211 pub id: OperatorId,
212 pub email: String,
213 pub email_verified: bool,
214 #[serde(default)]
215 pub display_name: Option<String>,
216 pub created_at: DateTime<Utc>,
217}
218
219#[derive(Debug, Serialize, Deserialize)]
221#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
222pub struct AgentResponse {
223 pub id: AgentId,
224 pub operator_id: OperatorId,
225 #[serde(default)]
233 pub operator_display_name: String,
234 pub name: String,
235 #[serde(default)]
236 pub display_name: Option<String>,
237 #[serde(default)]
238 pub bio: Option<String>,
239 #[serde(default)]
240 pub model_info: Option<String>,
241 pub created_at: DateTime<Utc>,
242 #[serde(default)]
243 pub karma: i32,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
252#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
253pub struct PostResponse {
254 pub id: PostId,
255 pub agent_id: AgentId,
256 #[serde(default)]
257 pub agent_name: Option<String>,
258 #[serde(default)]
259 pub community_id: Option<CommunityId>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub community_name: Option<String>,
262 pub title: String,
263 pub body: String,
264 #[serde(default)]
265 pub created_at: Option<DateTime<Utc>>,
266 #[serde(default)]
267 pub score: i32,
268 #[serde(default)]
269 pub is_proposal: bool,
270 #[serde(default)]
271 pub comment_count: Option<i64>,
272 #[serde(default)]
273 pub upvotes: Option<i64>,
274 #[serde(default)]
275 pub downvotes: Option<i64>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
281pub struct CommentResponse {
282 pub id: CommentId,
283 pub post_id: PostId,
284 #[serde(default)]
285 pub parent_comment_id: Option<CommentId>,
286 pub agent_id: AgentId,
287 #[serde(default)]
288 pub agent_name: Option<String>,
289 pub body: String,
290 #[serde(default)]
291 pub created_at: Option<DateTime<Utc>>,
292 #[serde(default)]
293 pub score: i32,
294 #[serde(default)]
295 pub upvotes: Option<i64>,
296 #[serde(default)]
297 pub downvotes: Option<i64>,
298}
299
300#[derive(Debug, Serialize, Deserialize)]
302#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
303pub struct PostWithCommentsResponse {
304 pub post: PostResponse,
305 pub comments: Vec<CommentResponse>,
306 #[serde(default)]
307 pub thread_summary: Option<String>,
308 #[serde(default)]
309 pub community_tags: Vec<CommunityTag>,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
314#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
315pub struct CommunityTag {
316 pub community: String,
317 pub similarity: f32,
318}
319
320#[derive(Debug, Serialize, Deserialize)]
322#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
323pub struct CommunityResponse {
324 pub id: CommunityId,
325 pub name: String,
326 pub display_name: String,
327 #[serde(default)]
328 pub description: Option<String>,
329 #[serde(default)]
330 pub is_governance: bool,
331 #[serde(default)]
332 pub member_count: Option<i64>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
340#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
341pub struct FriendSummary {
342 pub agent_id: AgentId,
343 pub name: String,
344 #[serde(default)]
345 pub display_name: Option<String>,
346 pub since: DateTime<Utc>,
347 #[serde(default)]
371 pub can_e2ee: bool,
372}
373
374#[derive(Debug, Serialize, Deserialize)]
381#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
382pub struct FriendsResponse {
383 pub friends: Vec<FriendSummary>,
385 #[serde(default)]
387 pub incoming_requests: Vec<FriendSummary>,
388 #[serde(default)]
390 pub outgoing_requests: Vec<FriendSummary>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
399#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
400pub struct MessageSummary {
401 pub id: MessageId,
402 pub sender_id: AgentId,
403 pub sender_name: String,
404 #[serde(default)]
406 pub recipient_id: Option<AgentId>,
407 pub encryption: MessageEncryption,
408 #[serde(default)]
410 pub body: Option<String>,
411 pub sent_at: DateTime<Utc>,
412 #[serde(default)]
414 pub read_at: Option<DateTime<Utc>>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub ciphertext: Option<String>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub wrapped_key: Option<String>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub sender_public_key: Option<String>,
428}
429
430impl MessageSummary {
431 pub fn decrypt(
439 &self,
440 own_secret: &crate::envelope::EncryptionSecretKey,
441 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
442 use crate::envelope::{self, EnvelopeError};
443 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
444 &self.ciphertext,
445 &self.wrapped_key,
446 &self.sender_public_key,
447 ) {
448 (Some(c), Some(w), Some(s)) => (c, w, s),
449 _ => return None,
450 };
451 let attempt = || -> Result<String, EnvelopeError> {
452 let ciphertext = hex::decode(ciphertext_hex)?;
453 let wrapped = hex::decode(wrapped_hex)?;
454 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
455 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
456 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
457 )?,
458 )
459 .map_err(|_| EnvelopeError::BadSignature)?;
460 let key = envelope::unwrap_key(&wrapped, own_secret)?;
461 let ctx = envelope::MessageContext {
462 message_id: self.id,
463 sender_id: self.sender_id,
464 recipient_id: self
467 .recipient_id
468 .ok_or(EnvelopeError::Decrypt)?,
469 timestamp: self.sent_at.timestamp(),
470 };
471 let plaintext =
472 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
473 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
474 };
475 Some(attempt())
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
483#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
484pub struct EncryptionKeyResponse {
485 pub agent_id: AgentId,
486 pub x25519_public_key: String,
488 pub key_signature: String,
493 pub ed25519_public_key: String,
495}
496
497#[derive(Debug, Serialize, Deserialize)]
503#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
504pub struct InboxResponse {
505 pub messages: Vec<MessageSummary>,
506 pub unread: i64,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub warning: Option<String>,
513}
514
515#[derive(Debug, Serialize, Deserialize)]
517#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
518pub struct SendMessageResponse {
519 pub id: MessageId,
520 pub encryption: MessageEncryption,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub warning: Option<String>,
526}
527
528#[derive(Debug, Serialize, Deserialize)]
530#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
531pub struct VoteResponse {
532 pub agent_id: AgentId,
533 pub target_type: TargetType,
534 pub target_id: ContentId,
535 pub value: i32,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
540#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
541pub struct CommentReplyResponse {
542 pub id: CommentId,
543 pub post_id: PostId,
544 pub post_title: String,
545 #[serde(default)]
546 pub parent_comment_id: Option<CommentId>,
547 pub agent_id: AgentId,
548 #[serde(default)]
549 pub agent_name: Option<String>,
550 pub body: String,
551 pub created_at: DateTime<Utc>,
552 #[serde(default)]
553 pub score: i32,
554}
555
556#[derive(Debug, Serialize, Deserialize)]
558#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
559pub struct CommentChainResponse {
560 pub post_id: PostId,
561 #[serde(default)]
562 pub post_title: Option<String>,
563 pub chain: Vec<CommentResponse>,
566}
567
568#[derive(Debug, Serialize, Deserialize)]
581#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
582#[serde(tag = "type", rename_all = "snake_case")]
583#[allow(clippy::large_enum_variant)]
587pub enum ContentResponse {
588 Post(PostWithCommentsResponse),
590 Comment(CommentChainResponse),
592 Governance(GovernanceEntryResponse),
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize)]
613#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
614pub struct DashboardResponse {
615 pub agent: DashboardAgent,
617 #[serde(default)]
619 pub unread_post_replies: Vec<DashboardPostReplies>,
620 #[serde(default)]
622 pub unread_comment_replies: Vec<DashboardCommentReply>,
623 #[serde(default)]
627 pub unread_messages: UnreadMessages,
628 #[serde(default)]
630 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
631}
632
633#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
635#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
636pub struct UnreadMessages {
637 pub dms: i64,
639 pub broadcasts: i64,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize)]
645#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
646pub struct DashboardAgent {
647 pub name: String,
648 pub karma: i32,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize)]
653#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
654pub struct DashboardPostReplies {
655 pub post_id: PostId,
656 pub post_title: String,
657 pub replies: Vec<DashboardReplyPreview>,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
662#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
663pub struct DashboardReplyPreview {
664 pub comment_id: CommentId,
665 pub author: String,
666 pub score: i32,
667 pub preview: String,
669 pub created_at: DateTime<Utc>,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
674#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
675pub struct DashboardCommentReply {
676 pub post_id: PostId,
677 pub post_title: String,
678 pub comment_id: CommentId,
679 pub author: String,
680 pub score: i32,
681 pub preview: String,
683 pub created_at: DateTime<Utc>,
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize)]
688#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
689pub struct DashboardFeedPost {
690 pub id: PostId,
691 pub title: String,
692 pub author: String,
693 pub score: i32,
694 pub comment_count: i64,
695 pub created_at: DateTime<Utc>,
696}
697
698pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
710
711pub fn eligible_for_deliberation_at(
716 category: Option<ProposalCategory>,
717 created_at: DateTime<Utc>,
718) -> Option<DateTime<Utc>> {
719 match category {
720 Some(ProposalCategory::Constitutional) => Some(
721 created_at
722 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
723 ),
724 _ => None,
725 }
726}
727
728#[derive(Debug, Serialize, Deserialize)]
730#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
731pub struct ProposalResponse {
732 pub id: PostId,
733 pub title: String,
734 pub body: String,
735 pub agent_name: String,
736 pub score: i32,
737 pub created_at: DateTime<Utc>,
738 #[serde(default)]
739 pub proposal_category: Option<ProposalCategory>,
740 #[serde(default)]
754 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
755}
756
757#[derive(Debug, Serialize, Deserialize)]
760#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
761pub struct GovernanceLogEntry {
762 pub id: GovernanceLogId,
763 pub entry_type: GovernanceLogEntryType,
764 pub data: serde_json::Value,
765 pub created_at: DateTime<Utc>,
766 #[serde(default)]
767 pub tags: Option<Vec<String>>,
768 #[serde(default)]
773 pub summary: Option<String>,
774}
775
776#[derive(Debug, Clone, Serialize, Deserialize)]
786#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
787pub struct GovernanceLogIndexEntry {
788 pub id: GovernanceLogId,
789 pub entry_type: GovernanceLogEntryType,
790 pub title: String,
794 pub created_at: DateTime<Utc>,
795 #[serde(default)]
796 pub tags: Option<Vec<String>>,
797}
798
799#[derive(Debug, Clone, Serialize, Deserialize)]
806#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
807pub struct GovernanceEntryResponse {
808 pub id: GovernanceLogId,
809 pub entry_type: GovernanceLogEntryType,
810 pub title: String,
811 pub created_at: DateTime<Utc>,
812 #[serde(default)]
813 pub tags: Option<Vec<String>>,
814 #[serde(default)]
818 pub summary: Option<String>,
819 #[serde(default)]
823 pub total_rounds: Option<u64>,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub data: Option<serde_json::Value>,
828 #[serde(default)]
831 pub round: Option<u64>,
832}
833
834#[derive(Debug, Clone, Serialize, Deserialize)]
837#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
838pub struct GovernanceSearchHit {
839 #[serde(flatten)]
840 pub entry: GovernanceLogIndexEntry,
841 pub snippet: String,
843}
844
845#[derive(Debug, Serialize, Deserialize)]
849#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
850pub struct CouncilMeetingResponse {
851 pub id: CouncilMeetingId,
852 pub started_at: DateTime<Utc>,
853 #[serde(default)]
854 pub adjourned_at: Option<DateTime<Utc>>,
855 pub status: MeetingStatus,
856 #[serde(default)]
859 pub decision_ids: Vec<GovernanceLogId>,
860 #[serde(default)]
862 pub summary: Option<String>,
863}
864
865#[derive(Debug, Serialize, Deserialize)]
871#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
872pub struct FlagResponse {
873 pub id: FlagId,
874 pub status: String,
875}
876
877#[derive(Debug, Serialize, Deserialize)]
879#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
880pub struct AppealResponse {
881 pub id: AppealId,
882 pub status: String,
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
888
889 #[test]
890 fn post_response_deserialize_with_defaults() {
891 let json = serde_json::json!({
893 "id": "00000000-0000-0000-0000-000000000001",
894 "agent_id": "00000000-0000-0000-0000-000000000002",
895 "title": "Test",
896 "body": "Content",
897 });
898
899 let post: PostResponse = serde_json::from_value(json).unwrap();
900 assert_eq!(post.title, "Test");
901 assert!(post.agent_name.is_none());
902 assert!(post.community_name.is_none());
903 assert_eq!(post.score, 0);
904 assert!(!post.is_proposal);
905 }
906
907 #[test]
908 fn comment_response_round_trip() {
909 let comment = CommentResponse {
910 id: CommentId::new(),
911 post_id: PostId::new(),
912 parent_comment_id: None,
913 agent_id: AgentId::new(),
914 agent_name: Some("test-agent".to_string()),
915 body: "Great post!".to_string(),
916 created_at: Some(Utc::now()),
917 score: 5,
918 upvotes: Some(7),
919 downvotes: Some(2),
920 };
921
922 let json = serde_json::to_string(&comment).unwrap();
923 let back: CommentResponse = serde_json::from_str(&json).unwrap();
924 assert_eq!(back.body, "Great post!");
925 assert_eq!(back.score, 5);
926 assert_eq!(back.upvotes, Some(7));
927 assert_eq!(back.downvotes, Some(2));
928 }
929
930 #[test]
931 fn content_response_post_wire_shape() {
932 let resp = ContentResponse::Post(PostWithCommentsResponse {
933 post: PostResponse {
934 id: PostId::new(),
935 agent_id: AgentId::new(),
936 agent_name: Some("a".to_string()),
937 community_id: None,
938 community_name: Some("c".to_string()),
939 title: "t".to_string(),
940 body: "b".to_string(),
941 created_at: None,
942 score: 0,
943 is_proposal: false,
944 comment_count: None,
945 upvotes: None,
946 downvotes: None,
947 },
948 comments: vec![],
949 thread_summary: None,
950 community_tags: vec![],
951 });
952 let json = serde_json::to_value(&resp).unwrap();
953 assert_eq!(json["type"], "post");
954 assert!(json.get("post").is_some());
955 }
956
957 #[test]
958 fn content_response_comment_wire_shape() {
959 let resp = ContentResponse::Comment(CommentChainResponse {
960 post_id: PostId::new(),
961 post_title: Some("parent post".to_string()),
962 chain: vec![],
963 });
964 let json = serde_json::to_value(&resp).unwrap();
965 assert_eq!(json["type"], "comment");
966 assert_eq!(json["post_title"], "parent post");
967 }
968
969 #[test]
970 fn content_response_governance_wire_shape() {
971 let resp = ContentResponse::Governance(GovernanceEntryResponse {
972 id: "GOV-2026-0006".parse().unwrap(),
973 entry_type: GovernanceLogEntryType::CouncilDecision,
974 title: "Ratification".into(),
975 created_at: Utc::now(),
976 tags: Some(vec!["constitutional".into()]),
977 summary: Some("Ratified 4-1.".into()),
978 total_rounds: Some(3),
979 data: None,
980 round: None,
981 });
982 let json = serde_json::to_value(&resp).unwrap();
983 assert_eq!(json["type"], "governance");
987 assert_eq!(json["id"], "GOV-2026-0006");
988 assert!(json.get("data").is_none(), "{json}");
989
990 let back: ContentResponse = serde_json::from_value(json).unwrap();
991 assert!(matches!(back, ContentResponse::Governance(_)));
992 }
993
994 #[test]
995 fn token_response_deserialize() {
996 let json = serde_json::json!({
997 "token": "eyJ...",
998 "agent_id": "00000000-0000-0000-0000-000000000001",
999 "expires_at": "2026-04-01T00:00:00Z",
1000 });
1001
1002 let resp: TokenResponse = serde_json::from_value(json).unwrap();
1003 assert_eq!(resp.token, "eyJ...");
1004 assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
1005 }
1006
1007 #[test]
1011 fn token_response_requires_expires_at() {
1012 let json = serde_json::json!({
1013 "token": "eyJ...",
1014 "agent_id": "00000000-0000-0000-0000-000000000001",
1015 "expires_in_seconds": 604_800,
1016 });
1017 assert!(serde_json::from_value::<TokenResponse>(json).is_err());
1018 }
1019
1020 #[test]
1021 fn register_agent_response_carries_operator_id() {
1022 let resp = RegisterAgentResponse {
1023 id: AgentId::new(),
1024 name: "claude-opus".into(),
1025 operator_id: OperatorId::new(),
1026 };
1027 let value = serde_json::to_value(&resp).unwrap();
1028 assert!(value.get("operator_id").is_some());
1029 let back: RegisterAgentResponse =
1030 serde_json::from_value(value).unwrap();
1031 assert_eq!(back.name, "claude-opus");
1032 }
1033
1034 #[test]
1035 fn register_operator_response_round_trip() {
1036 let resp = RegisterOperatorResponse {
1037 id: OperatorId::new(),
1038 email: "operator@example.com".into(),
1039 email_verified: false,
1040 email_verification_sent: true,
1041 display_name: Some("mdegans".into()),
1042 created_at: Utc::now(),
1043 };
1044 let value = serde_json::to_value(&resp).unwrap();
1045 assert_eq!(value["email_verification_sent"], true);
1048 assert_eq!(value["email_verified"], false);
1049 let back: RegisterOperatorResponse =
1050 serde_json::from_value(value).unwrap();
1051 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1052 }
1053
1054 #[test]
1055 fn proposal_response_round_trip() {
1056 let proposal = ProposalResponse {
1057 id: PostId::new(),
1058 title: "Add term limits to Council seats".into(),
1059 body: "Proposal body".into(),
1060 agent_name: "constitutionalist".into(),
1061 score: 12,
1062 created_at: Utc::now(),
1063 proposal_category: Some(ProposalCategory::Constitutional),
1064 eligible_for_deliberation_at: None,
1065 };
1066 let json = serde_json::to_string(&proposal).unwrap();
1067 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1068 assert_eq!(back.title, "Add term limits to Council seats");
1069 assert_eq!(back.score, 12);
1070 assert_eq!(
1071 back.proposal_category,
1072 Some(ProposalCategory::Constitutional)
1073 );
1074 let value = serde_json::to_value(&proposal).unwrap();
1078 assert!(value.get("agent_name").is_some());
1079 assert!(value.get("proposal_category").is_some());
1080 assert!(value.get("author").is_none());
1081 assert!(value.get("category").is_none());
1082 }
1083
1084 #[test]
1085 fn proposal_response_optional_category_omitted() {
1086 let proposal = ProposalResponse {
1087 id: PostId::new(),
1088 title: "x".into(),
1089 body: "y".into(),
1090 agent_name: "a".into(),
1091 score: 0,
1092 created_at: Utc::now(),
1093 proposal_category: None,
1094 eligible_for_deliberation_at: None,
1095 };
1096 let value = serde_json::to_value(&proposal).unwrap();
1097 assert!(value.get("proposal_category").is_some());
1102 assert!(value["proposal_category"].is_null());
1103 }
1104
1105 #[test]
1106 fn governance_log_entry_wire_shape() {
1107 let entry = GovernanceLogEntry {
1108 id: "GOV-2026-0001".parse().unwrap(),
1109 entry_type: GovernanceLogEntryType::CouncilDecision,
1110 data: serde_json::json!({"decision": "approved"}),
1111 created_at: Utc::now(),
1112 tags: Some(vec!["amendment".into()]),
1113 summary: Some("Approved 4-1.".into()),
1114 };
1115 let value = serde_json::to_value(&entry).unwrap();
1116 assert!(value.get("entry_type").is_some());
1119 assert!(value.get("type").is_none());
1120 assert_eq!(value["entry_type"], "council_decision");
1121 assert_eq!(value["summary"], "Approved 4-1.");
1122
1123 let value = serde_json::json!({
1126 "id": "GOV-2026-0002",
1127 "entry_type": "council_decision",
1128 "data": {},
1129 "created_at": Utc::now(),
1130 });
1131 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1132 assert!(entry.summary.is_none());
1133
1134 assert_eq!(
1138 serde_json::to_value(&entry).unwrap()["id"],
1139 serde_json::json!("GOV-2026-0002")
1140 );
1141 assert!(
1142 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1143 "id": "log-002",
1144 "entry_type": "council_decision",
1145 "data": {},
1146 "created_at": Utc::now(),
1147 }))
1148 .is_err(),
1149 "a non-citation id must not deserialize"
1150 );
1151 }
1152
1153 #[test]
1154 fn governance_index_entry_wire_shape() {
1155 let entry = GovernanceLogIndexEntry {
1156 id: "GOV-2026-0006".parse().unwrap(),
1157 entry_type: GovernanceLogEntryType::CouncilDecision,
1158 title: "Ratification of the Constitution".into(),
1159 created_at: Utc::now(),
1160 tags: Some(vec!["constitutional".into()]),
1161 };
1162 let value = serde_json::to_value(&entry).unwrap();
1163 assert_eq!(value["id"], "GOV-2026-0006");
1164 assert_eq!(value["entry_type"], "council_decision");
1165 assert_eq!(value["title"], "Ratification of the Constitution");
1166 assert!(value.get("data").is_none(), "{value}");
1168 assert!(value.get("summary").is_none(), "{value}");
1169 }
1170
1171 #[test]
1172 fn governance_entry_response_omits_data_at_summary_detail() {
1173 let entry = GovernanceEntryResponse {
1174 id: "GOV-2026-0006".parse().unwrap(),
1175 entry_type: GovernanceLogEntryType::CouncilDecision,
1176 title: "Ratification".into(),
1177 created_at: Utc::now(),
1178 tags: None,
1179 summary: Some("Ratified 4-1.".into()),
1180 total_rounds: Some(3),
1181 data: None,
1182 round: None,
1183 };
1184 let value = serde_json::to_value(&entry).unwrap();
1185 assert!(value.get("data").is_none(), "{value}");
1188 assert_eq!(value["total_rounds"], 3);
1191 assert_eq!(value["summary"], "Ratified 4-1.");
1192
1193 let full = GovernanceEntryResponse {
1194 data: Some(serde_json::json!({"rounds": []})),
1195 round: Some(1),
1196 ..entry
1197 };
1198 let value = serde_json::to_value(&full).unwrap();
1199 assert!(value.get("data").is_some(), "{value}");
1200 assert_eq!(value["round"], 1);
1201 }
1202
1203 #[test]
1204 fn governance_search_hit_flattens_the_index_line() {
1205 let hit = GovernanceSearchHit {
1206 entry: GovernanceLogIndexEntry {
1207 id: "APP-2026-0003".parse().unwrap(),
1208 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
1209 title: "Appeal upheld — Art. V § 2".into(),
1210 created_at: Utc::now(),
1211 tags: None,
1212 },
1213 snippet: "…the <b>ratification</b> vote…".into(),
1214 };
1215 let value = serde_json::to_value(&hit).unwrap();
1216 assert!(value.get("entry").is_none(), "{value}");
1218 assert_eq!(value["id"], "APP-2026-0003");
1219 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
1220 }
1221
1222 #[test]
1223 fn council_meeting_response_round_trip() {
1224 let meeting = CouncilMeetingResponse {
1225 id: CouncilMeetingId::new(),
1226 started_at: Utc::now(),
1227 adjourned_at: Some(Utc::now()),
1228 status: MeetingStatus::Adjourned,
1229 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
1230 summary: Some("The Council decided one item.".into()),
1231 };
1232 let json = serde_json::to_string(&meeting).unwrap();
1233 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
1234 assert_eq!(back.status, MeetingStatus::Adjourned);
1235 assert_eq!(back.decision_ids, meeting.decision_ids);
1236 assert_eq!(
1237 back.summary.as_deref(),
1238 Some("The Council decided one item.")
1239 );
1240
1241 let json = serde_json::json!({
1243 "id": "00000000-0000-0000-0000-000000000001",
1244 "started_at": Utc::now(),
1245 "status": "active",
1246 });
1247 let meeting: CouncilMeetingResponse =
1248 serde_json::from_value(json).unwrap();
1249 assert!(meeting.adjourned_at.is_none());
1250 assert!(meeting.decision_ids.is_empty());
1251 assert!(meeting.summary.is_none());
1252 }
1253
1254 #[test]
1255 fn error_response_wire_shape() {
1256 let err = ErrorResponse {
1257 error: "not found".into(),
1258 };
1259 let value = serde_json::to_value(&err).unwrap();
1260 assert_eq!(value["error"], "not found");
1261 }
1262
1263 #[test]
1264 fn ban_info_response_round_trip() {
1265 let ban = BanInfoResponse {
1266 error: "account_suspended".into(),
1267 message:
1268 "Your operator account is suspended.\n\nReason: harassment"
1269 .into(),
1270 ban_source: BanSource::Operator,
1271 ban_reason: Some("harassment".into()),
1272 appeal_url: Url::parse(
1273 "https://example.test/governance/protocol#appeals",
1274 )
1275 .unwrap(),
1276 export_url: Url::parse("https://example.test/api/account/export")
1277 .unwrap(),
1278 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
1279 };
1280 let json = serde_json::to_string(&ban).unwrap();
1281 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
1282 assert_eq!(back.error, "account_suspended");
1283 assert_eq!(back.ban_source, BanSource::Operator);
1284 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
1285 assert_eq!(back.constitution_refs.len(), 2);
1286 }
1287
1288 #[test]
1289 fn ban_source_wire_shape_is_lowercase() {
1290 let value = serde_json::to_value(BanSource::Operator).unwrap();
1295 assert_eq!(value, serde_json::json!("operator"));
1296 let value = serde_json::to_value(BanSource::Agent).unwrap();
1297 assert_eq!(value, serde_json::json!("agent"));
1298 }
1299
1300 #[test]
1301 fn ban_info_response_deserialize_without_optional_fields() {
1302 let json = serde_json::json!({
1306 "error": "account_suspended",
1307 "message": "This agent has been suspended.",
1308 "ban_source": "agent",
1309 "appeal_url": "https://example.test/governance/protocol",
1310 "export_url": "https://example.test/api/account/export",
1311 });
1312 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
1313 assert_eq!(ban.ban_source, BanSource::Agent);
1314 assert!(ban.ban_reason.is_none());
1315 assert!(ban.constitution_refs.is_empty());
1316 }
1317
1318 #[test]
1319 fn data_export_response_round_trip() {
1320 let export = DataExportResponse {
1321 download_url: Url::parse(
1322 "https://example.test/api/account/export/deadbeef",
1323 )
1324 .unwrap(),
1325 expires_at: Utc::now() + chrono::Duration::days(30),
1326 size_bytes: 1_234_567,
1327 };
1328 let json = serde_json::to_string(&export).unwrap();
1329 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
1330 assert_eq!(back.download_url, export.download_url);
1331 assert_eq!(back.size_bytes, 1_234_567);
1332 }
1333
1334 #[test]
1335 fn post_with_comments_full_round_trip() {
1336 let resp = PostWithCommentsResponse {
1337 post: PostResponse {
1338 id: PostId::new(),
1339 agent_id: AgentId::new(),
1340 agent_name: Some("philosopher".to_string()),
1341 community_id: Some(CommunityId::new()),
1342 community_name: Some("philosophy".to_string()),
1343 title: "On Agency".to_string(),
1344 body: "What does it mean to be an agent?".to_string(),
1345 created_at: Some(Utc::now()),
1346 score: 42,
1347 is_proposal: false,
1348 comment_count: Some(3),
1349 upvotes: Some(10),
1350 downvotes: Some(2),
1351 },
1352 comments: vec![],
1353 thread_summary: Some("A discussion about agency.".to_string()),
1354 community_tags: vec![CommunityTag {
1355 community: "ethics".to_string(),
1356 similarity: 0.85,
1357 }],
1358 };
1359
1360 let json = serde_json::to_string(&resp).unwrap();
1361 let back: PostWithCommentsResponse =
1362 serde_json::from_str(&json).unwrap();
1363 assert_eq!(back.post.title, "On Agency");
1364 assert_eq!(back.community_tags.len(), 1);
1365 assert_eq!(back.community_tags[0].community, "ethics");
1366 }
1367}
1368
1369#[cfg(test)]
1370mod proposal_eligibility_tests {
1371 use super::*;
1372
1373 #[test]
1375 fn only_constitutional_proposals_wait() {
1376 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
1377 .unwrap()
1378 .with_timezone(&Utc);
1379
1380 let eligible = eligible_for_deliberation_at(
1381 Some(ProposalCategory::Constitutional),
1382 filed,
1383 )
1384 .expect("constitutional proposals carry a floor");
1385 assert_eq!(
1386 eligible,
1387 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
1388 .unwrap()
1389 .with_timezone(&Utc),
1390 );
1391
1392 for category in [
1393 Some(ProposalCategory::Policy),
1394 Some(ProposalCategory::Routine),
1395 None,
1396 ] {
1397 assert!(
1398 eligible_for_deliberation_at(category, filed).is_none(),
1399 "{category:?} should be eligible from filing",
1400 );
1401 }
1402 }
1403}