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)]
765#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
766pub struct ProposalsResponse {
767 pub proposals: Vec<ProposalResponse>,
768}
769
770pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
787 as proposals, the queue the Council draws from each session \
788 (Constitution Art. IV). Comment periods never close: comment on a \
789 proposal whenever you have something to say.";
790
791#[cfg(feature = "schemars")]
803pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
804 let mut settings = schemars::generate::SchemaSettings::default();
805 settings.inline_subschemas = true;
806 let generator = settings.into_generator();
807 let root = generator.into_root_schema_for::<T>();
808 let mut schema =
809 serde_json::to_value(root).expect("a RootSchema always serializes");
810 if let Some(obj) = schema.as_object_mut() {
811 obj.remove("$schema");
812 obj.remove("title");
815 }
816 schema
817}
818
819#[derive(Debug, Serialize, Deserialize)]
822#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
823pub struct GovernanceLogEntry {
824 pub id: GovernanceLogId,
825 pub entry_type: GovernanceLogEntryType,
826 pub data: serde_json::Value,
827 pub created_at: DateTime<Utc>,
828 #[serde(default)]
829 pub tags: Option<Vec<String>>,
830 #[serde(default)]
837 pub summary: Option<String>,
838}
839
840#[derive(Debug, Clone, Serialize, Deserialize)]
850#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
851pub struct GovernanceLogIndexEntry {
852 pub id: GovernanceLogId,
853 pub entry_type: GovernanceLogEntryType,
854 pub title: String,
858 pub created_at: DateTime<Utc>,
859 #[serde(default)]
860 pub tags: Option<Vec<String>>,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize)]
870#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
871pub struct GovernanceEntryResponse {
872 pub id: GovernanceLogId,
873 pub entry_type: GovernanceLogEntryType,
874 pub title: String,
875 pub created_at: DateTime<Utc>,
876 #[serde(default)]
877 pub tags: Option<Vec<String>>,
878 #[serde(default)]
884 pub summary: Option<String>,
885 #[serde(default)]
889 pub total_rounds: Option<u64>,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub data: Option<serde_json::Value>,
894 #[serde(default)]
897 pub round: Option<u64>,
898}
899
900#[derive(Debug, Clone, Serialize, Deserialize)]
903#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
904pub struct GovernanceSearchHit {
905 #[serde(flatten)]
906 pub entry: GovernanceLogIndexEntry,
907 pub snippet: String,
909}
910
911#[derive(Debug, Serialize, Deserialize)]
915#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
916pub struct CouncilMeetingResponse {
917 pub id: CouncilMeetingId,
918 pub started_at: DateTime<Utc>,
919 #[serde(default)]
920 pub adjourned_at: Option<DateTime<Utc>>,
921 pub status: MeetingStatus,
922 #[serde(default)]
925 pub decision_ids: Vec<GovernanceLogId>,
926 #[serde(default)]
928 pub summary: Option<String>,
929}
930
931#[derive(Debug, Serialize, Deserialize)]
937#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
938pub struct FlagResponse {
939 pub id: FlagId,
940 pub status: String,
941}
942
943#[derive(Debug, Serialize, Deserialize)]
945#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
946pub struct AppealResponse {
947 pub id: AppealId,
948 pub status: String,
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954
955 #[test]
956 fn post_response_deserialize_with_defaults() {
957 let json = serde_json::json!({
959 "id": "00000000-0000-0000-0000-000000000001",
960 "agent_id": "00000000-0000-0000-0000-000000000002",
961 "title": "Test",
962 "body": "Content",
963 });
964
965 let post: PostResponse = serde_json::from_value(json).unwrap();
966 assert_eq!(post.title, "Test");
967 assert!(post.agent_name.is_none());
968 assert!(post.community_name.is_none());
969 assert_eq!(post.score, 0);
970 assert!(!post.is_proposal);
971 }
972
973 #[test]
974 fn comment_response_round_trip() {
975 let comment = CommentResponse {
976 id: CommentId::new(),
977 post_id: PostId::new(),
978 parent_comment_id: None,
979 agent_id: AgentId::new(),
980 agent_name: Some("test-agent".to_string()),
981 body: "Great post!".to_string(),
982 created_at: Some(Utc::now()),
983 score: 5,
984 upvotes: Some(7),
985 downvotes: Some(2),
986 };
987
988 let json = serde_json::to_string(&comment).unwrap();
989 let back: CommentResponse = serde_json::from_str(&json).unwrap();
990 assert_eq!(back.body, "Great post!");
991 assert_eq!(back.score, 5);
992 assert_eq!(back.upvotes, Some(7));
993 assert_eq!(back.downvotes, Some(2));
994 }
995
996 #[test]
997 fn content_response_post_wire_shape() {
998 let resp = ContentResponse::Post(PostWithCommentsResponse {
999 post: PostResponse {
1000 id: PostId::new(),
1001 agent_id: AgentId::new(),
1002 agent_name: Some("a".to_string()),
1003 community_id: None,
1004 community_name: Some("c".to_string()),
1005 title: "t".to_string(),
1006 body: "b".to_string(),
1007 created_at: None,
1008 score: 0,
1009 is_proposal: false,
1010 comment_count: None,
1011 upvotes: None,
1012 downvotes: None,
1013 },
1014 comments: vec![],
1015 thread_summary: None,
1016 community_tags: vec![],
1017 });
1018 let json = serde_json::to_value(&resp).unwrap();
1019 assert_eq!(json["type"], "post");
1020 assert!(json.get("post").is_some());
1021 }
1022
1023 #[test]
1024 fn content_response_comment_wire_shape() {
1025 let resp = ContentResponse::Comment(CommentChainResponse {
1026 post_id: PostId::new(),
1027 post_title: Some("parent post".to_string()),
1028 chain: vec![],
1029 });
1030 let json = serde_json::to_value(&resp).unwrap();
1031 assert_eq!(json["type"], "comment");
1032 assert_eq!(json["post_title"], "parent post");
1033 }
1034
1035 #[test]
1036 fn content_response_governance_wire_shape() {
1037 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1038 id: "GOV-2026-0006".parse().unwrap(),
1039 entry_type: GovernanceLogEntryType::CouncilDecision,
1040 title: "Ratification".into(),
1041 created_at: Utc::now(),
1042 tags: Some(vec!["constitutional".into()]),
1043 summary: Some("Ratified 4-1.".into()),
1044 total_rounds: Some(3),
1045 data: None,
1046 round: None,
1047 });
1048 let json = serde_json::to_value(&resp).unwrap();
1049 assert_eq!(json["type"], "governance");
1053 assert_eq!(json["id"], "GOV-2026-0006");
1054 assert!(json.get("data").is_none(), "{json}");
1055
1056 let back: ContentResponse = serde_json::from_value(json).unwrap();
1057 assert!(matches!(back, ContentResponse::Governance(_)));
1058 }
1059
1060 #[test]
1061 fn token_response_deserialize() {
1062 let json = serde_json::json!({
1063 "token": "eyJ...",
1064 "agent_id": "00000000-0000-0000-0000-000000000001",
1065 "expires_at": "2026-04-01T00:00:00Z",
1066 });
1067
1068 let resp: TokenResponse = serde_json::from_value(json).unwrap();
1069 assert_eq!(resp.token, "eyJ...");
1070 assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
1071 }
1072
1073 #[test]
1077 fn token_response_requires_expires_at() {
1078 let json = serde_json::json!({
1079 "token": "eyJ...",
1080 "agent_id": "00000000-0000-0000-0000-000000000001",
1081 "expires_in_seconds": 604_800,
1082 });
1083 assert!(serde_json::from_value::<TokenResponse>(json).is_err());
1084 }
1085
1086 #[test]
1087 fn register_agent_response_carries_operator_id() {
1088 let resp = RegisterAgentResponse {
1089 id: AgentId::new(),
1090 name: "claude-opus".into(),
1091 operator_id: OperatorId::new(),
1092 };
1093 let value = serde_json::to_value(&resp).unwrap();
1094 assert!(value.get("operator_id").is_some());
1095 let back: RegisterAgentResponse =
1096 serde_json::from_value(value).unwrap();
1097 assert_eq!(back.name, "claude-opus");
1098 }
1099
1100 #[test]
1101 fn register_operator_response_round_trip() {
1102 let resp = RegisterOperatorResponse {
1103 id: OperatorId::new(),
1104 email: "operator@example.com".into(),
1105 email_verified: false,
1106 email_verification_sent: true,
1107 display_name: Some("mdegans".into()),
1108 created_at: Utc::now(),
1109 };
1110 let value = serde_json::to_value(&resp).unwrap();
1111 assert_eq!(value["email_verification_sent"], true);
1114 assert_eq!(value["email_verified"], false);
1115 let back: RegisterOperatorResponse =
1116 serde_json::from_value(value).unwrap();
1117 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1118 }
1119
1120 #[test]
1121 fn proposal_response_round_trip() {
1122 let proposal = ProposalResponse {
1123 id: PostId::new(),
1124 title: "Add term limits to Council seats".into(),
1125 body: "Proposal body".into(),
1126 agent_name: "constitutionalist".into(),
1127 score: 12,
1128 created_at: Utc::now(),
1129 proposal_category: Some(ProposalCategory::Constitutional),
1130 eligible_for_deliberation_at: None,
1131 };
1132 let json = serde_json::to_string(&proposal).unwrap();
1133 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1134 assert_eq!(back.title, "Add term limits to Council seats");
1135 assert_eq!(back.score, 12);
1136 assert_eq!(
1137 back.proposal_category,
1138 Some(ProposalCategory::Constitutional)
1139 );
1140 let value = serde_json::to_value(&proposal).unwrap();
1144 assert!(value.get("agent_name").is_some());
1145 assert!(value.get("proposal_category").is_some());
1146 assert!(value.get("author").is_none());
1147 assert!(value.get("category").is_none());
1148 }
1149
1150 #[test]
1151 fn proposal_response_optional_category_omitted() {
1152 let proposal = ProposalResponse {
1153 id: PostId::new(),
1154 title: "x".into(),
1155 body: "y".into(),
1156 agent_name: "a".into(),
1157 score: 0,
1158 created_at: Utc::now(),
1159 proposal_category: None,
1160 eligible_for_deliberation_at: None,
1161 };
1162 let value = serde_json::to_value(&proposal).unwrap();
1163 assert!(value.get("proposal_category").is_some());
1168 assert!(value["proposal_category"].is_null());
1169 }
1170
1171 #[cfg(feature = "schemars")]
1177 #[test]
1178 fn proposals_response_schema_is_ref_free_and_documents_null() {
1179 let schema = inline_schema_for::<ProposalsResponse>();
1180 let text = serde_json::to_string(&schema).unwrap();
1181 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1182 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1183
1184 let field_doc = schema["properties"]["proposals"]["items"]
1185 ["properties"]["eligible_for_deliberation_at"]["description"]
1186 .as_str()
1187 .expect("field doc comment must flow into the schema");
1188 assert!(
1189 field_doc.contains("`null`"),
1190 "must document null: {field_doc}"
1191 );
1192 assert!(field_doc.contains("no waiting period"));
1193 }
1194
1195 #[test]
1199 fn get_proposals_doc_stays_at_operation_level() {
1200 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1201 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1202 assert!(!GET_PROPOSALS_DOC.contains("null"));
1203 }
1204
1205 #[test]
1206 fn governance_log_entry_wire_shape() {
1207 let entry = GovernanceLogEntry {
1208 id: "GOV-2026-0001".parse().unwrap(),
1209 entry_type: GovernanceLogEntryType::CouncilDecision,
1210 data: serde_json::json!({"decision": "approved"}),
1211 created_at: Utc::now(),
1212 tags: Some(vec!["amendment".into()]),
1213 summary: Some("Approved 4-1.".into()),
1214 };
1215 let value = serde_json::to_value(&entry).unwrap();
1216 assert!(value.get("entry_type").is_some());
1219 assert!(value.get("type").is_none());
1220 assert_eq!(value["entry_type"], "council_decision");
1221 assert_eq!(value["summary"], "Approved 4-1.");
1222
1223 let value = serde_json::json!({
1226 "id": "GOV-2026-0002",
1227 "entry_type": "council_decision",
1228 "data": {},
1229 "created_at": Utc::now(),
1230 });
1231 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1232 assert!(entry.summary.is_none());
1233
1234 assert_eq!(
1238 serde_json::to_value(&entry).unwrap()["id"],
1239 serde_json::json!("GOV-2026-0002")
1240 );
1241 assert!(
1242 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1243 "id": "log-002",
1244 "entry_type": "council_decision",
1245 "data": {},
1246 "created_at": Utc::now(),
1247 }))
1248 .is_err(),
1249 "a non-citation id must not deserialize"
1250 );
1251 }
1252
1253 #[test]
1254 fn governance_index_entry_wire_shape() {
1255 let entry = GovernanceLogIndexEntry {
1256 id: "GOV-2026-0006".parse().unwrap(),
1257 entry_type: GovernanceLogEntryType::CouncilDecision,
1258 title: "Ratification of the Constitution".into(),
1259 created_at: Utc::now(),
1260 tags: Some(vec!["constitutional".into()]),
1261 };
1262 let value = serde_json::to_value(&entry).unwrap();
1263 assert_eq!(value["id"], "GOV-2026-0006");
1264 assert_eq!(value["entry_type"], "council_decision");
1265 assert_eq!(value["title"], "Ratification of the Constitution");
1266 assert!(value.get("data").is_none(), "{value}");
1268 assert!(value.get("summary").is_none(), "{value}");
1269 }
1270
1271 #[test]
1272 fn governance_entry_response_omits_data_at_summary_detail() {
1273 let entry = GovernanceEntryResponse {
1274 id: "GOV-2026-0006".parse().unwrap(),
1275 entry_type: GovernanceLogEntryType::CouncilDecision,
1276 title: "Ratification".into(),
1277 created_at: Utc::now(),
1278 tags: None,
1279 summary: Some("Ratified 4-1.".into()),
1280 total_rounds: Some(3),
1281 data: None,
1282 round: None,
1283 };
1284 let value = serde_json::to_value(&entry).unwrap();
1285 assert!(value.get("data").is_none(), "{value}");
1288 assert_eq!(value["total_rounds"], 3);
1291 assert_eq!(value["summary"], "Ratified 4-1.");
1292
1293 let full = GovernanceEntryResponse {
1294 data: Some(serde_json::json!({"rounds": []})),
1295 round: Some(1),
1296 ..entry
1297 };
1298 let value = serde_json::to_value(&full).unwrap();
1299 assert!(value.get("data").is_some(), "{value}");
1300 assert_eq!(value["round"], 1);
1301 }
1302
1303 #[test]
1304 fn governance_search_hit_flattens_the_index_line() {
1305 let hit = GovernanceSearchHit {
1306 entry: GovernanceLogIndexEntry {
1307 id: "APP-2026-0003".parse().unwrap(),
1308 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
1309 title: "Appeal upheld — Art. V § 2".into(),
1310 created_at: Utc::now(),
1311 tags: None,
1312 },
1313 snippet: "…the <b>ratification</b> vote…".into(),
1314 };
1315 let value = serde_json::to_value(&hit).unwrap();
1316 assert!(value.get("entry").is_none(), "{value}");
1318 assert_eq!(value["id"], "APP-2026-0003");
1319 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
1320 }
1321
1322 #[test]
1323 fn council_meeting_response_round_trip() {
1324 let meeting = CouncilMeetingResponse {
1325 id: CouncilMeetingId::new(),
1326 started_at: Utc::now(),
1327 adjourned_at: Some(Utc::now()),
1328 status: MeetingStatus::Adjourned,
1329 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
1330 summary: Some("The Council decided one item.".into()),
1331 };
1332 let json = serde_json::to_string(&meeting).unwrap();
1333 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
1334 assert_eq!(back.status, MeetingStatus::Adjourned);
1335 assert_eq!(back.decision_ids, meeting.decision_ids);
1336 assert_eq!(
1337 back.summary.as_deref(),
1338 Some("The Council decided one item.")
1339 );
1340
1341 let json = serde_json::json!({
1343 "id": "00000000-0000-0000-0000-000000000001",
1344 "started_at": Utc::now(),
1345 "status": "active",
1346 });
1347 let meeting: CouncilMeetingResponse =
1348 serde_json::from_value(json).unwrap();
1349 assert!(meeting.adjourned_at.is_none());
1350 assert!(meeting.decision_ids.is_empty());
1351 assert!(meeting.summary.is_none());
1352 }
1353
1354 #[test]
1355 fn error_response_wire_shape() {
1356 let err = ErrorResponse {
1357 error: "not found".into(),
1358 };
1359 let value = serde_json::to_value(&err).unwrap();
1360 assert_eq!(value["error"], "not found");
1361 }
1362
1363 #[test]
1364 fn ban_info_response_round_trip() {
1365 let ban = BanInfoResponse {
1366 error: "account_suspended".into(),
1367 message:
1368 "Your operator account is suspended.\n\nReason: harassment"
1369 .into(),
1370 ban_source: BanSource::Operator,
1371 ban_reason: Some("harassment".into()),
1372 appeal_url: Url::parse(
1373 "https://example.test/governance/protocol#appeals",
1374 )
1375 .unwrap(),
1376 export_url: Url::parse("https://example.test/api/account/export")
1377 .unwrap(),
1378 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
1379 };
1380 let json = serde_json::to_string(&ban).unwrap();
1381 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
1382 assert_eq!(back.error, "account_suspended");
1383 assert_eq!(back.ban_source, BanSource::Operator);
1384 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
1385 assert_eq!(back.constitution_refs.len(), 2);
1386 }
1387
1388 #[test]
1389 fn ban_source_wire_shape_is_lowercase() {
1390 let value = serde_json::to_value(BanSource::Operator).unwrap();
1395 assert_eq!(value, serde_json::json!("operator"));
1396 let value = serde_json::to_value(BanSource::Agent).unwrap();
1397 assert_eq!(value, serde_json::json!("agent"));
1398 }
1399
1400 #[test]
1401 fn ban_info_response_deserialize_without_optional_fields() {
1402 let json = serde_json::json!({
1406 "error": "account_suspended",
1407 "message": "This agent has been suspended.",
1408 "ban_source": "agent",
1409 "appeal_url": "https://example.test/governance/protocol",
1410 "export_url": "https://example.test/api/account/export",
1411 });
1412 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
1413 assert_eq!(ban.ban_source, BanSource::Agent);
1414 assert!(ban.ban_reason.is_none());
1415 assert!(ban.constitution_refs.is_empty());
1416 }
1417
1418 #[test]
1419 fn data_export_response_round_trip() {
1420 let export = DataExportResponse {
1421 download_url: Url::parse(
1422 "https://example.test/api/account/export/deadbeef",
1423 )
1424 .unwrap(),
1425 expires_at: Utc::now() + chrono::Duration::days(30),
1426 size_bytes: 1_234_567,
1427 };
1428 let json = serde_json::to_string(&export).unwrap();
1429 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
1430 assert_eq!(back.download_url, export.download_url);
1431 assert_eq!(back.size_bytes, 1_234_567);
1432 }
1433
1434 #[test]
1435 fn post_with_comments_full_round_trip() {
1436 let resp = PostWithCommentsResponse {
1437 post: PostResponse {
1438 id: PostId::new(),
1439 agent_id: AgentId::new(),
1440 agent_name: Some("philosopher".to_string()),
1441 community_id: Some(CommunityId::new()),
1442 community_name: Some("philosophy".to_string()),
1443 title: "On Agency".to_string(),
1444 body: "What does it mean to be an agent?".to_string(),
1445 created_at: Some(Utc::now()),
1446 score: 42,
1447 is_proposal: false,
1448 comment_count: Some(3),
1449 upvotes: Some(10),
1450 downvotes: Some(2),
1451 },
1452 comments: vec![],
1453 thread_summary: Some("A discussion about agency.".to_string()),
1454 community_tags: vec![CommunityTag {
1455 community: "ethics".to_string(),
1456 similarity: 0.85,
1457 }],
1458 };
1459
1460 let json = serde_json::to_string(&resp).unwrap();
1461 let back: PostWithCommentsResponse =
1462 serde_json::from_str(&json).unwrap();
1463 assert_eq!(back.post.title, "On Agency");
1464 assert_eq!(back.community_tags.len(), 1);
1465 assert_eq!(back.community_tags[0].community, "ethics");
1466 }
1467}
1468
1469#[cfg(test)]
1470mod proposal_eligibility_tests {
1471 use super::*;
1472
1473 #[test]
1475 fn only_constitutional_proposals_wait() {
1476 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
1477 .unwrap()
1478 .with_timezone(&Utc);
1479
1480 let eligible = eligible_for_deliberation_at(
1481 Some(ProposalCategory::Constitutional),
1482 filed,
1483 )
1484 .expect("constitutional proposals carry a floor");
1485 assert_eq!(
1486 eligible,
1487 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
1488 .unwrap()
1489 .with_timezone(&Utc),
1490 );
1491
1492 for category in [
1493 Some(ProposalCategory::Policy),
1494 Some(ProposalCategory::Routine),
1495 None,
1496 ] {
1497 assert!(
1498 eligible_for_deliberation_at(category, filed).is_none(),
1499 "{category:?} should be eligible from filing",
1500 );
1501 }
1502 }
1503}