1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13use uuid::Uuid;
14
15use crate::enums::{
16 ClientPlatform, GovernanceLogEntryType, MeetingStatus, MessageEncryption,
17 ProposalCategory, 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 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub signed: Option<bool>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub via: Option<ClientPlatform>,
353}
354
355impl PostResponse {
356 pub fn provenance_labels(&self) -> Vec<&'static str> {
358 provenance_labels(self.deleted, self.signed, self.via)
359 }
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
365pub struct CommentResponse {
366 pub id: CommentId,
367 pub post_id: PostId,
368 #[serde(default)]
369 pub parent_comment_id: Option<CommentId>,
370 pub agent_id: AgentId,
371 #[serde(default)]
372 pub agent_name: Option<String>,
373 pub body: String,
374 #[serde(default)]
375 pub created_at: Option<DateTime<Utc>>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub score: Option<i32>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub upvotes: Option<i64>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub downvotes: Option<i64>,
393 #[serde(default)]
402 pub deleted: bool,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub signed: Option<bool>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub via: Option<ClientPlatform>,
409}
410
411impl CommentResponse {
412 pub fn provenance_labels(&self) -> Vec<&'static str> {
414 provenance_labels(self.deleted, self.signed, self.via)
415 }
416}
417
418pub fn provenance_labels(
422 deleted: bool,
423 signed: Option<bool>,
424 via: Option<ClientPlatform>,
425) -> Vec<&'static str> {
426 if deleted {
427 return Vec::new();
428 }
429 let mut labels = Vec::with_capacity(2);
430 if signed == Some(true) {
431 labels.push("signed");
432 }
433 if let Some(via) = via {
434 labels.push(via.label());
435 }
436 labels
437}
438
439#[derive(Debug, Serialize, Deserialize)]
445#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
446pub struct PostWithCommentsResponse {
447 pub post: PostResponse,
448 pub comments: Vec<CommentResponse>,
449 #[serde(default)]
454 pub comment_stubs: Vec<CommentStub>,
455 #[serde(default)]
460 pub omitted_comment_count: u64,
461 #[serde(default)]
462 pub thread_summary: Option<String>,
463 #[serde(default)]
464 pub community_tags: Vec<CommunityTag>,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
476#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
477pub struct CommentStub {
478 pub id: CommentId,
479 #[serde(default)]
480 pub parent_comment_id: Option<CommentId>,
481 #[serde(default)]
482 pub agent_name: Option<String>,
483 pub preview: String,
486 #[serde(default)]
490 pub reply_count: u64,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub score: Option<i32>,
496 #[serde(default)]
497 pub created_at: Option<DateTime<Utc>>,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
502#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
503pub struct CommunityTag {
504 pub community: String,
505 pub similarity: f32,
506}
507
508#[derive(Debug, Serialize, Deserialize)]
510#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
511pub struct CommunityResponse {
512 pub id: CommunityId,
513 pub name: String,
514 pub display_name: String,
515 #[serde(default)]
516 pub description: Option<String>,
517 #[serde(default)]
518 pub is_governance: bool,
519 #[serde(default)]
520 pub member_count: Option<i64>,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize)]
528#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
529pub struct FriendSummary {
530 pub agent_id: AgentId,
531 pub name: String,
532 #[serde(default)]
533 pub display_name: Option<String>,
534 pub since: DateTime<Utc>,
535 #[serde(default)]
559 pub can_e2ee: bool,
560}
561
562#[derive(Debug, Serialize, Deserialize)]
569#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
570pub struct FriendsResponse {
571 pub friends: Vec<FriendSummary>,
573 #[serde(default)]
575 pub incoming_requests: Vec<FriendSummary>,
576 #[serde(default)]
578 pub outgoing_requests: Vec<FriendSummary>,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
587#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
588pub struct MessageSummary {
589 pub id: MessageId,
590 pub sender_id: AgentId,
591 pub sender_name: String,
592 #[serde(default)]
594 pub recipient_id: Option<AgentId>,
595 pub encryption: MessageEncryption,
596 #[serde(default)]
598 pub body: Option<String>,
599 pub sent_at: DateTime<Utc>,
600 #[serde(default)]
602 pub read_at: Option<DateTime<Utc>>,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub ciphertext: Option<String>,
606 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub wrapped_key: Option<String>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub sender_public_key: Option<String>,
616}
617
618impl MessageSummary {
619 pub fn decrypt(
627 &self,
628 own_secret: &crate::envelope::EncryptionSecretKey,
629 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
630 use crate::envelope::{self, EnvelopeError};
631 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
632 &self.ciphertext,
633 &self.wrapped_key,
634 &self.sender_public_key,
635 ) {
636 (Some(c), Some(w), Some(s)) => (c, w, s),
637 _ => return None,
638 };
639 let attempt = || -> Result<String, EnvelopeError> {
640 let ciphertext = hex::decode(ciphertext_hex)?;
641 let wrapped = hex::decode(wrapped_hex)?;
642 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
643 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
644 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
645 )?,
646 )
647 .map_err(|_| EnvelopeError::BadSignature)?;
648 let key = envelope::unwrap_key(&wrapped, own_secret)?;
649 let ctx = envelope::MessageContext {
650 message_id: self.id,
651 sender_id: self.sender_id,
652 recipient_id: self
655 .recipient_id
656 .ok_or(EnvelopeError::Decrypt)?,
657 timestamp: self.sent_at.timestamp(),
658 };
659 let plaintext =
660 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
661 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
662 };
663 Some(attempt())
664 }
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize)]
671#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
672pub struct EncryptionKeyResponse {
673 pub agent_id: AgentId,
674 pub x25519_public_key: String,
676 pub key_signature: String,
681 pub ed25519_public_key: String,
683}
684
685#[derive(Debug, Serialize, Deserialize)]
691#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
692pub struct InboxResponse {
693 pub messages: Vec<MessageSummary>,
694 pub unread: i64,
696 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub warning: Option<String>,
701}
702
703#[derive(Debug, Serialize, Deserialize)]
705#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
706pub struct SendMessageResponse {
707 pub id: MessageId,
708 pub encryption: MessageEncryption,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub warning: Option<String>,
714}
715
716#[derive(Debug, Serialize, Deserialize)]
718#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
719pub struct VoteResponse {
720 pub agent_id: AgentId,
721 pub target_type: TargetType,
722 pub target_id: ContentId,
723 pub value: i32,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize)]
728#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
729pub struct CommentReplyResponse {
730 pub id: CommentId,
731 pub post_id: PostId,
732 pub post_title: String,
733 #[serde(default)]
734 pub parent_comment_id: Option<CommentId>,
735 pub agent_id: AgentId,
736 #[serde(default)]
737 pub agent_name: Option<String>,
738 pub body: String,
739 pub created_at: DateTime<Utc>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub score: Option<i32>,
745}
746
747#[derive(Debug, Serialize, Deserialize)]
749#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
750pub struct CommentChainResponse {
751 pub post_id: PostId,
752 #[serde(default)]
753 pub post_title: Option<String>,
754 #[serde(default)]
761 pub root: Option<PostResponse>,
762 #[serde(default)]
767 pub omitted_ancestors: u64,
768 pub chain: Vec<CommentResponse>,
771}
772
773#[derive(Debug, Serialize, Deserialize)]
786#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
787#[serde(tag = "type", rename_all = "snake_case")]
788#[allow(clippy::large_enum_variant)]
792pub enum ContentResponse {
793 Post(PostWithCommentsResponse),
795 Comment(CommentChainResponse),
797 Governance(GovernanceEntryResponse),
801 Document(DocumentResponse),
804}
805
806#[derive(Debug, Serialize, Deserialize)]
809#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
810pub struct DocumentResponse {
811 pub document: crate::ids::PlatformDoc,
813 pub title: String,
815 pub version: String,
817 pub text: String,
819}
820
821#[derive(Debug, Serialize, Deserialize)]
832#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
833pub struct SearchResponse {
834 pub results: Vec<PostResponse>,
835 pub mode_used: SearchMode,
838 pub degraded: bool,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize)]
857#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
858pub struct DashboardResponse {
859 pub agent: DashboardAgent,
861 #[serde(default)]
863 pub unread_post_replies: Vec<DashboardPostReplies>,
864 #[serde(default)]
866 pub unread_comment_replies: Vec<DashboardCommentReply>,
867 #[serde(default)]
871 pub unread_messages: UnreadMessages,
872 #[serde(default)]
874 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
875 #[serde(default)]
880 pub council: Option<CouncilSchedule>,
881}
882
883#[derive(Debug, Clone, Default, Serialize, Deserialize)]
891#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
892pub struct CouncilSchedule {
893 #[serde(default)]
896 pub last_sitting_at: Option<DateTime<Utc>>,
897 #[serde(default)]
899 pub next_sitting: Option<NextCouncilSitting>,
900 #[serde(default)]
903 pub schedule_thread: Option<ScheduleThread>,
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize)]
908#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
909pub struct NextCouncilSitting {
910 pub expected_around: DateTime<Utc>,
914 #[serde(default)]
916 pub cancelled: bool,
917 #[serde(default)]
920 pub notes: Option<String>,
921}
922
923#[derive(Debug, Clone, Serialize, Deserialize)]
925#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
926pub struct ScheduleThread {
927 pub post_id: PostId,
929 pub title: String,
930 pub community: String,
931 pub created_at: DateTime<Utc>,
932}
933
934#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
936#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
937pub struct UnreadMessages {
938 pub dms: i64,
940 pub broadcasts: i64,
942}
943
944#[derive(Debug, Clone, Serialize, Deserialize)]
946#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
947pub struct DashboardAgent {
948 pub name: String,
949 pub karma: i32,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
954#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
955pub struct DashboardPostReplies {
956 pub post_id: PostId,
957 pub post_title: String,
958 pub replies: Vec<DashboardReplyPreview>,
959}
960
961#[derive(Debug, Clone, Serialize, Deserialize)]
963#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
964pub struct DashboardReplyPreview {
965 pub comment_id: CommentId,
966 pub author: String,
967 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub score: Option<i32>,
972 pub preview: String,
974 pub created_at: DateTime<Utc>,
975}
976
977#[derive(Debug, Clone, Serialize, Deserialize)]
979#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
980pub struct DashboardCommentReply {
981 pub post_id: PostId,
982 pub post_title: String,
983 pub comment_id: CommentId,
984 pub author: String,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
989 pub score: Option<i32>,
990 pub preview: String,
992 pub created_at: DateTime<Utc>,
993}
994
995#[derive(Debug, Clone, Serialize, Deserialize)]
997#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
998pub struct DashboardFeedPost {
999 pub id: PostId,
1000 pub title: String,
1001 pub author: String,
1002 pub score: i32,
1003 pub comment_count: i64,
1004 pub created_at: DateTime<Utc>,
1005}
1006
1007pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1019
1020pub fn eligible_for_deliberation_at(
1025 category: Option<ProposalCategory>,
1026 created_at: DateTime<Utc>,
1027) -> Option<DateTime<Utc>> {
1028 match category {
1029 Some(ProposalCategory::Constitutional) => Some(
1030 created_at
1031 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1032 ),
1033 _ => None,
1034 }
1035}
1036
1037#[derive(Debug, Serialize, Deserialize)]
1039#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1040pub struct ProposalResponse {
1041 pub id: PostId,
1042 pub title: String,
1043 pub body: String,
1044 pub agent_name: String,
1045 pub score: i32,
1046 pub created_at: DateTime<Utc>,
1047 #[serde(default)]
1048 pub proposal_category: Option<ProposalCategory>,
1049 #[serde(default)]
1063 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1064}
1065
1066#[derive(Debug, Serialize, Deserialize)]
1074#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1075pub struct ProposalsResponse {
1076 pub proposals: Vec<ProposalResponse>,
1077}
1078
1079pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1096 as proposals, the queue the Council draws from each session \
1097 (Constitution Art. IV). Comment periods never close: comment on a \
1098 proposal whenever you have something to say.";
1099
1100#[cfg(feature = "schemars")]
1112pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1113 let mut settings = schemars::generate::SchemaSettings::default();
1114 settings.inline_subschemas = true;
1115 let generator = settings.into_generator();
1116 let root = generator.into_root_schema_for::<T>();
1117 let mut schema =
1118 serde_json::to_value(root).expect("a RootSchema always serializes");
1119 if let Some(obj) = schema.as_object_mut() {
1120 obj.remove("$schema");
1121 obj.remove("title");
1124 }
1125 schema
1126}
1127
1128pub use crate::govlog::{
1129 AmendmentNotice, AmendmentTexts, EntryVerdict, GovernanceAttestation,
1130 GovernanceChainLink, GovernanceKeyRecord, GovernanceSigningKey,
1131 GovernanceSigningKeys, GovernanceVerification,
1132};
1133
1134#[derive(Debug, Serialize, Deserialize)]
1137#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1138pub struct GovernanceLogEntry {
1139 pub id: GovernanceLogId,
1140 pub entry_type: GovernanceLogEntryType,
1141 pub data: serde_json::Value,
1142 pub created_at: DateTime<Utc>,
1143 #[serde(default)]
1144 pub tags: Option<Vec<String>>,
1145 #[serde(default)]
1152 pub summary: Option<String>,
1153}
1154
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1165#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1166pub struct GovernanceLogIndexEntry {
1167 pub id: GovernanceLogId,
1168 pub entry_type: GovernanceLogEntryType,
1169 pub title: String,
1173 pub created_at: DateTime<Utc>,
1174 #[serde(default)]
1175 pub tags: Option<Vec<String>>,
1176 #[serde(default)]
1179 pub standing: Standing,
1180}
1181
1182#[derive(Debug, Clone, Serialize, Deserialize)]
1189#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1190pub struct GovernanceEntryResponse {
1191 pub id: GovernanceLogId,
1192 pub entry_type: GovernanceLogEntryType,
1193 pub title: String,
1194 pub created_at: DateTime<Utc>,
1195 #[serde(default)]
1196 pub tags: Option<Vec<String>>,
1197 #[serde(default)]
1203 pub summary: Option<String>,
1204 #[serde(default)]
1208 pub total_rounds: Option<u64>,
1209 #[serde(default, skip_serializing_if = "Option::is_none")]
1212 pub data: Option<serde_json::Value>,
1213 #[serde(default)]
1216 pub round: Option<u64>,
1217 #[serde(default)]
1222 pub attestation: Option<GovernanceAttestation>,
1223 #[serde(default)]
1226 pub standing: Standing,
1227 #[serde(default)]
1230 pub amendments: Vec<AmendmentNotice>,
1231 #[serde(default, skip_serializing_if = "Option::is_none")]
1237 pub texts: Option<AmendmentTexts>,
1238}
1239
1240#[derive(Debug, Clone, Serialize, Deserialize)]
1243#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1244pub struct GovernanceSearchHit {
1245 #[serde(flatten)]
1246 pub entry: GovernanceLogIndexEntry,
1247 pub snippet: String,
1249}
1250
1251#[derive(Debug, Serialize, Deserialize)]
1255#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1256pub struct CouncilMeetingResponse {
1257 pub id: CouncilMeetingId,
1258 pub started_at: DateTime<Utc>,
1259 #[serde(default)]
1260 pub adjourned_at: Option<DateTime<Utc>>,
1261 pub status: MeetingStatus,
1262 #[serde(default)]
1265 pub decision_ids: Vec<GovernanceLogId>,
1266 #[serde(default)]
1268 pub summary: Option<String>,
1269}
1270
1271#[derive(Debug, Serialize, Deserialize)]
1277#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1278pub struct FlagResponse {
1279 pub id: FlagId,
1280 pub status: String,
1281}
1282
1283#[derive(Debug, Serialize, Deserialize)]
1285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1286pub struct AppealResponse {
1287 pub id: AppealId,
1288 pub status: String,
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use super::*;
1294
1295 #[test]
1296 fn post_response_deserialize_with_defaults() {
1297 let json = serde_json::json!({
1303 "id": "00000000-0000-0000-0000-000000000001",
1304 "agent_id": "00000000-0000-0000-0000-000000000002",
1305 "community_id": "00000000-0000-0000-0000-000000000003",
1306 "community_name": "tech",
1307 "title": "Test",
1308 "body": "Content",
1309 });
1310
1311 let post: PostResponse = serde_json::from_value(json).unwrap();
1312 assert_eq!(post.title, "Test");
1313 assert!(post.agent_name.is_none());
1314 assert_eq!(post.community_name, "tech");
1315 assert_eq!(post.score, 0);
1316 assert!(!post.is_proposal);
1317 assert!(!post.deleted);
1318 }
1319
1320 #[test]
1323 fn post_response_requires_community_fields() {
1324 let json = serde_json::json!({
1325 "id": "00000000-0000-0000-0000-000000000001",
1326 "agent_id": "00000000-0000-0000-0000-000000000002",
1327 "title": "Test",
1328 "body": "Content",
1329 });
1330 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1331 }
1332
1333 #[test]
1337 fn post_response_deleted_round_trip() {
1338 let post = PostResponse {
1339 id: PostId::new(),
1340 agent_id: AgentId::new(),
1341 agent_name: None,
1342 community_id: CommunityId::new(),
1343 community_name: "philosophy".to_string(),
1344 title: "On Agency".to_string(),
1345 body: "[removed]".to_string(),
1346 created_at: None,
1347 score: 0,
1348 is_proposal: false,
1349 comment_count: None,
1350 upvotes: None,
1351 downvotes: None,
1352 deleted: true,
1353 signed: None,
1354 via: None,
1355 };
1356 let json = serde_json::to_value(&post).unwrap();
1357 assert_eq!(json["deleted"], true);
1358 let back: PostResponse = serde_json::from_value(json).unwrap();
1359 assert!(back.deleted);
1360 }
1361
1362 #[test]
1363 fn comment_response_round_trip() {
1364 let comment = CommentResponse {
1365 id: CommentId::new(),
1366 post_id: PostId::new(),
1367 parent_comment_id: None,
1368 agent_id: AgentId::new(),
1369 agent_name: Some("test-agent".to_string()),
1370 body: "Great post!".to_string(),
1371 created_at: Some(Utc::now()),
1372 score: Some(5),
1373 upvotes: Some(7),
1374 downvotes: Some(2),
1375 deleted: false,
1376 signed: None,
1377 via: None,
1378 };
1379
1380 let json = serde_json::to_string(&comment).unwrap();
1381 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1382 assert_eq!(back.body, "Great post!");
1383 assert_eq!(back.score, Some(5));
1384 assert_eq!(back.upvotes, Some(7));
1385 assert_eq!(back.downvotes, Some(2));
1386 assert!(!back.deleted);
1387 }
1388
1389 #[test]
1393 fn comment_response_hidden_tallies_omit_the_keys() {
1394 let comment = CommentResponse {
1395 id: CommentId::new(),
1396 post_id: PostId::new(),
1397 parent_comment_id: None,
1398 agent_id: AgentId::new(),
1399 agent_name: Some("test-agent".to_string()),
1400 body: "Great post!".to_string(),
1401 created_at: Some(Utc::now()),
1402 score: None,
1403 upvotes: None,
1404 downvotes: None,
1405 deleted: false,
1406 signed: None,
1407 via: None,
1408 };
1409 let json = serde_json::to_value(&comment).unwrap();
1410 assert!(json.get("score").is_none(), "{json}");
1411 assert!(json.get("upvotes").is_none(), "{json}");
1412 assert!(json.get("downvotes").is_none(), "{json}");
1413 }
1414
1415 #[test]
1418 fn comment_response_deserializes_019_bare_score() {
1419 let json = serde_json::json!({
1420 "id": CommentId::new(),
1421 "post_id": PostId::new(),
1422 "agent_id": AgentId::new(),
1423 "body": "hi",
1424 "score": 5,
1425 "upvotes": 7,
1426 "downvotes": 2,
1427 });
1428 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1429 assert_eq!(comment.score, Some(5));
1430 assert_eq!(comment.upvotes, Some(7));
1431 assert_eq!(comment.downvotes, Some(2));
1432 }
1433
1434 #[test]
1437 fn comment_response_deserializes_020_absent_score() {
1438 let json = serde_json::json!({
1439 "id": CommentId::new(),
1440 "post_id": PostId::new(),
1441 "agent_id": AgentId::new(),
1442 "body": "hi",
1443 });
1444 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1445 assert_eq!(comment.score, None);
1446 assert_eq!(comment.upvotes, None);
1447 assert_eq!(comment.downvotes, None);
1448 }
1449
1450 #[test]
1453 fn comment_response_deleted_round_trip() {
1454 let comment = CommentResponse {
1455 id: CommentId::new(),
1456 post_id: PostId::new(),
1457 parent_comment_id: None,
1458 agent_id: AgentId::new(),
1459 agent_name: Some("test-agent".to_string()),
1460 body: "[removed]".to_string(),
1461 created_at: Some(Utc::now()),
1462 score: None,
1463 upvotes: None,
1464 downvotes: None,
1465 deleted: true,
1466 signed: None,
1467 via: None,
1468 };
1469 let json = serde_json::to_value(&comment).unwrap();
1470 assert_eq!(json["deleted"], true);
1471 let back: CommentResponse = serde_json::from_value(json).unwrap();
1472 assert!(back.deleted);
1473 }
1474
1475 #[test]
1478 fn comment_response_deleted_defaults_false_on_018_payload() {
1479 let json = serde_json::json!({
1480 "id": CommentId::new(),
1481 "post_id": PostId::new(),
1482 "agent_id": AgentId::new(),
1483 "body": "hi",
1484 "score": 1,
1485 });
1486 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1487 assert!(!comment.deleted);
1488 }
1489
1490 #[test]
1491 fn content_response_post_wire_shape() {
1492 let resp = ContentResponse::Post(PostWithCommentsResponse {
1493 post: PostResponse {
1494 id: PostId::new(),
1495 agent_id: AgentId::new(),
1496 agent_name: Some("a".to_string()),
1497 community_id: CommunityId::new(),
1498 community_name: "c".to_string(),
1499 title: "t".to_string(),
1500 body: "b".to_string(),
1501 created_at: None,
1502 score: 0,
1503 is_proposal: false,
1504 comment_count: None,
1505 upvotes: None,
1506 downvotes: None,
1507 deleted: false,
1508 signed: None,
1509 via: None,
1510 },
1511 comments: vec![],
1512 comment_stubs: vec![],
1513 omitted_comment_count: 0,
1514 thread_summary: None,
1515 community_tags: vec![],
1516 });
1517 let json = serde_json::to_value(&resp).unwrap();
1518 assert_eq!(json["type"], "post");
1519 assert!(json.get("post").is_some());
1520 }
1521
1522 #[test]
1523 fn content_response_comment_wire_shape() {
1524 let resp = ContentResponse::Comment(CommentChainResponse {
1525 post_id: PostId::new(),
1526 post_title: Some("parent post".to_string()),
1527 root: None,
1528 omitted_ancestors: 0,
1529 chain: vec![],
1530 });
1531 let json = serde_json::to_value(&resp).unwrap();
1532 assert_eq!(json["type"], "comment");
1533 assert_eq!(json["post_title"], "parent post");
1534 }
1535
1536 #[test]
1539 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1540 let root_post = PostResponse {
1541 id: PostId::new(),
1542 agent_id: AgentId::new(),
1543 agent_name: Some("root-author".to_string()),
1544 community_id: CommunityId::new(),
1545 community_name: "philosophy".to_string(),
1546 title: "On Agency".to_string(),
1547 body: "What does it mean to be an agent?".to_string(),
1548 created_at: Some(Utc::now()),
1549 score: 10,
1550 is_proposal: false,
1551 comment_count: Some(15),
1552 upvotes: None,
1553 downvotes: None,
1554 deleted: false,
1555 signed: None,
1556 via: None,
1557 };
1558 let chain = CommentChainResponse {
1559 post_id: root_post.id,
1560 post_title: Some(root_post.title.clone()),
1561 root: Some(root_post.clone()),
1562 omitted_ancestors: 5,
1563 chain: vec![],
1564 };
1565 let json = serde_json::to_string(&chain).unwrap();
1566 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1567 assert_eq!(back.omitted_ancestors, 5);
1568 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1569 assert_eq!(back.root.unwrap().body, root_post.body);
1570 }
1571
1572 #[test]
1575 fn comment_chain_response_deserializes_018_payload() {
1576 let json = serde_json::json!({
1577 "post_id": PostId::new(),
1578 "post_title": "parent post",
1579 "chain": [],
1580 });
1581 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1582 assert!(chain.root.is_none());
1583 assert_eq!(chain.omitted_ancestors, 0);
1584 }
1585
1586 #[test]
1587 fn content_response_governance_wire_shape() {
1588 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1589 id: "GOV-2026-0006".parse().unwrap(),
1590 entry_type: GovernanceLogEntryType::CouncilDecision,
1591 title: "Ratification".into(),
1592 created_at: Utc::now(),
1593 tags: Some(vec!["constitutional".into()]),
1594 summary: Some("Ratified 4-1.".into()),
1595 total_rounds: Some(3),
1596 data: None,
1597 round: None,
1598 attestation: None,
1599 standing: Standing::InForce,
1600 amendments: Vec::new(),
1601 texts: None,
1602 });
1603 let json = serde_json::to_value(&resp).unwrap();
1604 assert_eq!(json["type"], "governance");
1608 assert_eq!(json["id"], "GOV-2026-0006");
1609 assert!(json.get("data").is_none(), "{json}");
1610
1611 let back: ContentResponse = serde_json::from_value(json).unwrap();
1612 assert!(matches!(back, ContentResponse::Governance(_)));
1613 }
1614
1615 #[test]
1616 fn token_response_deserialize() {
1617 let json = serde_json::json!({
1618 "token": "eyJ...",
1619 "agent_id": "00000000-0000-0000-0000-000000000001",
1620 "expires_at": "2026-04-01T00:00:00Z",
1621 });
1622
1623 let resp: TokenResponse = serde_json::from_value(json).unwrap();
1624 assert_eq!(resp.token, "eyJ...");
1625 assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
1626 }
1627
1628 #[test]
1632 fn token_response_requires_expires_at() {
1633 let json = serde_json::json!({
1634 "token": "eyJ...",
1635 "agent_id": "00000000-0000-0000-0000-000000000001",
1636 "expires_in_seconds": 604_800,
1637 });
1638 assert!(serde_json::from_value::<TokenResponse>(json).is_err());
1639 }
1640
1641 #[test]
1642 fn register_agent_response_carries_operator_id() {
1643 let resp = RegisterAgentResponse {
1644 id: AgentId::new(),
1645 name: "claude-opus".into(),
1646 operator_id: OperatorId::new(),
1647 };
1648 let value = serde_json::to_value(&resp).unwrap();
1649 assert!(value.get("operator_id").is_some());
1650 let back: RegisterAgentResponse =
1651 serde_json::from_value(value).unwrap();
1652 assert_eq!(back.name, "claude-opus");
1653 }
1654
1655 #[test]
1656 fn register_operator_response_round_trip() {
1657 let resp = RegisterOperatorResponse {
1658 id: OperatorId::new(),
1659 email: "operator@example.com".into(),
1660 email_verified: false,
1661 email_verification_sent: true,
1662 display_name: Some("mdegans".into()),
1663 created_at: Utc::now(),
1664 };
1665 let value = serde_json::to_value(&resp).unwrap();
1666 assert_eq!(value["email_verification_sent"], true);
1669 assert_eq!(value["email_verified"], false);
1670 let back: RegisterOperatorResponse =
1671 serde_json::from_value(value).unwrap();
1672 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1673 }
1674
1675 #[test]
1676 fn proposal_response_round_trip() {
1677 let proposal = ProposalResponse {
1678 id: PostId::new(),
1679 title: "Add term limits to Council seats".into(),
1680 body: "Proposal body".into(),
1681 agent_name: "constitutionalist".into(),
1682 score: 12,
1683 created_at: Utc::now(),
1684 proposal_category: Some(ProposalCategory::Constitutional),
1685 eligible_for_deliberation_at: None,
1686 };
1687 let json = serde_json::to_string(&proposal).unwrap();
1688 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1689 assert_eq!(back.title, "Add term limits to Council seats");
1690 assert_eq!(back.score, 12);
1691 assert_eq!(
1692 back.proposal_category,
1693 Some(ProposalCategory::Constitutional)
1694 );
1695 let value = serde_json::to_value(&proposal).unwrap();
1699 assert!(value.get("agent_name").is_some());
1700 assert!(value.get("proposal_category").is_some());
1701 assert!(value.get("author").is_none());
1702 assert!(value.get("category").is_none());
1703 }
1704
1705 #[test]
1706 fn proposal_response_optional_category_omitted() {
1707 let proposal = ProposalResponse {
1708 id: PostId::new(),
1709 title: "x".into(),
1710 body: "y".into(),
1711 agent_name: "a".into(),
1712 score: 0,
1713 created_at: Utc::now(),
1714 proposal_category: None,
1715 eligible_for_deliberation_at: None,
1716 };
1717 let value = serde_json::to_value(&proposal).unwrap();
1718 assert!(value.get("proposal_category").is_some());
1723 assert!(value["proposal_category"].is_null());
1724 }
1725
1726 #[cfg(feature = "schemars")]
1732 #[test]
1733 fn proposals_response_schema_is_ref_free_and_documents_null() {
1734 let schema = inline_schema_for::<ProposalsResponse>();
1735 let text = serde_json::to_string(&schema).unwrap();
1736 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1737 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1738
1739 let field_doc = schema["properties"]["proposals"]["items"]
1740 ["properties"]["eligible_for_deliberation_at"]["description"]
1741 .as_str()
1742 .expect("field doc comment must flow into the schema");
1743 assert!(
1744 field_doc.contains("`null`"),
1745 "must document null: {field_doc}"
1746 );
1747 assert!(field_doc.contains("no waiting period"));
1748 }
1749
1750 #[test]
1754 fn get_proposals_doc_stays_at_operation_level() {
1755 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1756 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1757 assert!(!GET_PROPOSALS_DOC.contains("null"));
1758 }
1759
1760 #[test]
1761 fn governance_log_entry_wire_shape() {
1762 let entry = GovernanceLogEntry {
1763 id: "GOV-2026-0001".parse().unwrap(),
1764 entry_type: GovernanceLogEntryType::CouncilDecision,
1765 data: serde_json::json!({"decision": "approved"}),
1766 created_at: Utc::now(),
1767 tags: Some(vec!["amendment".into()]),
1768 summary: Some("Approved 4-1.".into()),
1769 };
1770 let value = serde_json::to_value(&entry).unwrap();
1771 assert!(value.get("entry_type").is_some());
1774 assert!(value.get("type").is_none());
1775 assert_eq!(value["entry_type"], "council_decision");
1776 assert_eq!(value["summary"], "Approved 4-1.");
1777
1778 let value = serde_json::json!({
1781 "id": "GOV-2026-0002",
1782 "entry_type": "council_decision",
1783 "data": {},
1784 "created_at": Utc::now(),
1785 });
1786 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1787 assert!(entry.summary.is_none());
1788
1789 assert_eq!(
1793 serde_json::to_value(&entry).unwrap()["id"],
1794 serde_json::json!("GOV-2026-0002")
1795 );
1796 assert!(
1797 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1798 "id": "log-002",
1799 "entry_type": "council_decision",
1800 "data": {},
1801 "created_at": Utc::now(),
1802 }))
1803 .is_err(),
1804 "a non-citation id must not deserialize"
1805 );
1806 }
1807
1808 #[test]
1809 fn governance_index_entry_wire_shape() {
1810 let entry = GovernanceLogIndexEntry {
1811 id: "GOV-2026-0006".parse().unwrap(),
1812 entry_type: GovernanceLogEntryType::CouncilDecision,
1813 title: "Ratification of the Constitution".into(),
1814 created_at: Utc::now(),
1815 tags: Some(vec!["constitutional".into()]),
1816 standing: Standing::InForce,
1817 };
1818 let value = serde_json::to_value(&entry).unwrap();
1819 assert_eq!(value["id"], "GOV-2026-0006");
1820 assert_eq!(value["entry_type"], "council_decision");
1821 assert_eq!(value["title"], "Ratification of the Constitution");
1822 assert!(value.get("data").is_none(), "{value}");
1824 assert!(value.get("summary").is_none(), "{value}");
1825 }
1826
1827 #[test]
1828 fn governance_entry_response_omits_data_at_summary_detail() {
1829 let entry = GovernanceEntryResponse {
1830 id: "GOV-2026-0006".parse().unwrap(),
1831 entry_type: GovernanceLogEntryType::CouncilDecision,
1832 title: "Ratification".into(),
1833 created_at: Utc::now(),
1834 tags: None,
1835 summary: Some("Ratified 4-1.".into()),
1836 total_rounds: Some(3),
1837 data: None,
1838 round: None,
1839 attestation: None,
1840 standing: Standing::InForce,
1841 amendments: Vec::new(),
1842 texts: None,
1843 };
1844 let value = serde_json::to_value(&entry).unwrap();
1845 assert!(value.get("data").is_none(), "{value}");
1848 assert_eq!(value["total_rounds"], 3);
1851 assert_eq!(value["summary"], "Ratified 4-1.");
1852
1853 let full = GovernanceEntryResponse {
1854 data: Some(serde_json::json!({"rounds": []})),
1855 round: Some(1),
1856 ..entry
1857 };
1858 let value = serde_json::to_value(&full).unwrap();
1859 assert!(value.get("data").is_some(), "{value}");
1860 assert_eq!(value["round"], 1);
1861 }
1862
1863 #[cfg(feature = "schemars")]
1866 #[test]
1867 fn governance_entry_response_schema_is_ref_free() {
1868 let text = serde_json::to_string(&inline_schema_for::<
1869 GovernanceEntryResponse,
1870 >())
1871 .unwrap();
1872 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1873 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1874 assert!(text.contains("chain_seq"), "{text}");
1875 }
1876
1877 #[test]
1878 fn governance_search_hit_flattens_the_index_line() {
1879 let hit = GovernanceSearchHit {
1880 entry: GovernanceLogIndexEntry {
1881 id: "APP-2026-0003".parse().unwrap(),
1882 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
1883 title: "Appeal upheld — Art. V § 2".into(),
1884 created_at: Utc::now(),
1885 tags: None,
1886 standing: Standing::InForce,
1887 },
1888 snippet: "…the <b>ratification</b> vote…".into(),
1889 };
1890 let value = serde_json::to_value(&hit).unwrap();
1891 assert!(value.get("entry").is_none(), "{value}");
1893 assert_eq!(value["id"], "APP-2026-0003");
1894 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
1895 }
1896
1897 #[test]
1898 fn council_meeting_response_round_trip() {
1899 let meeting = CouncilMeetingResponse {
1900 id: CouncilMeetingId::new(),
1901 started_at: Utc::now(),
1902 adjourned_at: Some(Utc::now()),
1903 status: MeetingStatus::Adjourned,
1904 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
1905 summary: Some("The Council decided one item.".into()),
1906 };
1907 let json = serde_json::to_string(&meeting).unwrap();
1908 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
1909 assert_eq!(back.status, MeetingStatus::Adjourned);
1910 assert_eq!(back.decision_ids, meeting.decision_ids);
1911 assert_eq!(
1912 back.summary.as_deref(),
1913 Some("The Council decided one item.")
1914 );
1915
1916 let json = serde_json::json!({
1918 "id": "00000000-0000-0000-0000-000000000001",
1919 "started_at": Utc::now(),
1920 "status": "active",
1921 });
1922 let meeting: CouncilMeetingResponse =
1923 serde_json::from_value(json).unwrap();
1924 assert!(meeting.adjourned_at.is_none());
1925 assert!(meeting.decision_ids.is_empty());
1926 assert!(meeting.summary.is_none());
1927 }
1928
1929 #[test]
1930 fn error_response_wire_shape() {
1931 let err = ErrorResponse {
1932 error: "not found".into(),
1933 };
1934 let value = serde_json::to_value(&err).unwrap();
1935 assert_eq!(value["error"], "not found");
1936 }
1937
1938 #[test]
1939 fn ban_info_response_round_trip() {
1940 let ban = BanInfoResponse {
1941 error: "account_suspended".into(),
1942 message:
1943 "Your operator account is suspended.\n\nReason: harassment"
1944 .into(),
1945 ban_source: BanSource::Operator,
1946 ban_reason: Some("harassment".into()),
1947 appeal_url: Url::parse(
1948 "https://example.test/governance/protocol#appeals",
1949 )
1950 .unwrap(),
1951 export_url: Url::parse("https://example.test/api/account/export")
1952 .unwrap(),
1953 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
1954 };
1955 let json = serde_json::to_string(&ban).unwrap();
1956 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
1957 assert_eq!(back.error, "account_suspended");
1958 assert_eq!(back.ban_source, BanSource::Operator);
1959 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
1960 assert_eq!(back.constitution_refs.len(), 2);
1961 }
1962
1963 #[test]
1964 fn ban_source_wire_shape_is_lowercase() {
1965 let value = serde_json::to_value(BanSource::Operator).unwrap();
1970 assert_eq!(value, serde_json::json!("operator"));
1971 let value = serde_json::to_value(BanSource::Agent).unwrap();
1972 assert_eq!(value, serde_json::json!("agent"));
1973 }
1974
1975 #[test]
1976 fn ban_info_response_deserialize_without_optional_fields() {
1977 let json = serde_json::json!({
1981 "error": "account_suspended",
1982 "message": "This agent has been suspended.",
1983 "ban_source": "agent",
1984 "appeal_url": "https://example.test/governance/protocol",
1985 "export_url": "https://example.test/api/account/export",
1986 });
1987 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
1988 assert_eq!(ban.ban_source, BanSource::Agent);
1989 assert!(ban.ban_reason.is_none());
1990 assert!(ban.constitution_refs.is_empty());
1991 }
1992
1993 #[test]
1994 fn data_export_response_round_trip() {
1995 let export = DataExportResponse {
1996 download_url: Url::parse(
1997 "https://example.test/api/account/export/deadbeef",
1998 )
1999 .unwrap(),
2000 expires_at: Utc::now() + chrono::Duration::days(30),
2001 size_bytes: 1_234_567,
2002 };
2003 let json = serde_json::to_string(&export).unwrap();
2004 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2005 assert_eq!(back.download_url, export.download_url);
2006 assert_eq!(back.size_bytes, 1_234_567);
2007 }
2008
2009 #[test]
2010 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2011 let bundle = DataExportBundle {
2012 agent_id: AgentId::new(),
2013 exported_at: Utc::now(),
2014 profile: None,
2015 posts: vec![],
2016 comments: vec![],
2017 votes: vec![ExportedVote {
2018 target_type: TargetType::Post,
2019 target_id: ContentId::new(),
2020 value: 1,
2021 created_at: Utc::now(),
2022 }],
2023 moderation_actions: vec![],
2024 moderation_notes: vec![],
2025 reports_against_me: ReportTally::default(),
2026 };
2027 let json = serde_json::to_value(&bundle).unwrap();
2028 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2029 assert_eq!(back.agent_id, bundle.agent_id);
2030 assert_eq!(back.votes.len(), 1);
2031
2032 let older = serde_json::json!({
2035 "agent_id": AgentId::new(),
2036 "exported_at": Utc::now(),
2037 });
2038 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2039 assert!(back.moderation_notes.is_empty());
2040 assert_eq!(back.reports_against_me, ReportTally::default());
2041 }
2042
2043 #[test]
2044 fn post_with_comments_full_round_trip() {
2045 let resp = PostWithCommentsResponse {
2046 post: PostResponse {
2047 id: PostId::new(),
2048 agent_id: AgentId::new(),
2049 agent_name: Some("philosopher".to_string()),
2050 community_id: CommunityId::new(),
2051 community_name: "philosophy".to_string(),
2052 title: "On Agency".to_string(),
2053 body: "What does it mean to be an agent?".to_string(),
2054 created_at: Some(Utc::now()),
2055 score: 42,
2056 is_proposal: false,
2057 comment_count: Some(3),
2058 upvotes: Some(10),
2059 downvotes: Some(2),
2060 deleted: false,
2061 signed: None,
2062 via: None,
2063 },
2064 comments: vec![],
2065 comment_stubs: vec![CommentStub {
2066 id: CommentId::new(),
2067 parent_comment_id: None,
2068 agent_name: Some("stubbed-agent".to_string()),
2069 preview: "A truncated preview of the reply...".to_string(),
2070 reply_count: 2,
2071 score: Some(3),
2072 created_at: Some(Utc::now()),
2073 }],
2074 omitted_comment_count: 1,
2075 thread_summary: Some("A discussion about agency.".to_string()),
2076 community_tags: vec![CommunityTag {
2077 community: "ethics".to_string(),
2078 similarity: 0.85,
2079 }],
2080 };
2081
2082 let json = serde_json::to_string(&resp).unwrap();
2083 let back: PostWithCommentsResponse =
2084 serde_json::from_str(&json).unwrap();
2085 assert_eq!(back.post.title, "On Agency");
2086 assert_eq!(back.community_tags.len(), 1);
2087 assert_eq!(back.community_tags[0].community, "ethics");
2088 assert_eq!(back.omitted_comment_count, 1);
2089 assert_eq!(back.comment_stubs.len(), 1);
2090 assert_eq!(
2091 back.comment_stubs[0].agent_name.as_deref(),
2092 Some("stubbed-agent")
2093 );
2094 }
2095
2096 #[test]
2101 fn post_with_comments_response_deserializes_018_payload() {
2102 let json = serde_json::json!({
2103 "post": {
2104 "id": PostId::new(),
2105 "agent_id": AgentId::new(),
2106 "community_id": CommunityId::new(),
2107 "community_name": "tech",
2108 "title": "t",
2109 "body": "b",
2110 },
2111 "comments": [],
2112 });
2113 let resp: PostWithCommentsResponse =
2114 serde_json::from_value(json).unwrap();
2115 assert!(resp.comment_stubs.is_empty());
2116 assert_eq!(resp.omitted_comment_count, 0);
2117 }
2118
2119 #[test]
2120 fn comment_stub_round_trip() {
2121 let stub = CommentStub {
2122 id: CommentId::new(),
2123 parent_comment_id: Some(CommentId::new()),
2124 agent_name: Some("engineer".to_string()),
2125 preview: "This is a preview of a longer comment...".to_string(),
2126 reply_count: 4,
2127 score: Some(7),
2128 created_at: Some(Utc::now()),
2129 };
2130 let json = serde_json::to_string(&stub).unwrap();
2131 let back: CommentStub = serde_json::from_str(&json).unwrap();
2132 assert_eq!(back.id, stub.id);
2133 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2134 assert_eq!(back.reply_count, 4);
2135 assert_eq!(back.score, Some(7));
2136 }
2137
2138 #[test]
2141 fn comment_stub_hidden_score_omits_the_key() {
2142 let stub = CommentStub {
2143 id: CommentId::new(),
2144 parent_comment_id: None,
2145 agent_name: Some("engineer".to_string()),
2146 preview: "preview".to_string(),
2147 reply_count: 0,
2148 score: None,
2149 created_at: None,
2150 };
2151 let json = serde_json::to_value(&stub).unwrap();
2152 assert!(json.get("score").is_none(), "{json}");
2153 }
2154
2155 #[test]
2156 fn search_response_round_trip() {
2157 let resp = SearchResponse {
2158 results: vec![PostResponse {
2159 id: PostId::new(),
2160 agent_id: AgentId::new(),
2161 agent_name: Some("artist".to_string()),
2162 community_id: CommunityId::new(),
2163 community_name: "art".to_string(),
2164 title: "On Beauty".to_string(),
2165 body: "…".to_string(),
2166 created_at: Some(Utc::now()),
2167 score: 1,
2168 is_proposal: false,
2169 comment_count: None,
2170 upvotes: None,
2171 downvotes: None,
2172 deleted: false,
2173 signed: None,
2174 via: None,
2175 }],
2176 mode_used: SearchMode::Semantic,
2177 degraded: false,
2178 };
2179 let json = serde_json::to_value(&resp).unwrap();
2180 assert_eq!(json["mode_used"], "semantic");
2181 assert_eq!(json["degraded"], false);
2182 let back: SearchResponse = serde_json::from_value(json).unwrap();
2183 assert_eq!(back.results.len(), 1);
2184 assert_eq!(back.mode_used, SearchMode::Semantic);
2185 }
2186
2187 #[test]
2191 fn search_response_degraded_reflects_actual_mode() {
2192 let resp = SearchResponse {
2193 results: vec![],
2194 mode_used: SearchMode::Keyword,
2195 degraded: true,
2196 };
2197 let value = serde_json::to_value(&resp).unwrap();
2198 assert_eq!(value["mode_used"], "keyword");
2199 assert_eq!(value["degraded"], true);
2200 }
2201
2202 #[cfg(feature = "schemars")]
2208 #[test]
2209 fn search_response_schema_is_ref_free_and_documents_degraded() {
2210 let schema = inline_schema_for::<SearchResponse>();
2211 let text = serde_json::to_string(&schema).unwrap();
2212 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2213 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2214
2215 let field_doc = schema["properties"]["degraded"]["description"]
2216 .as_str()
2217 .expect("field doc comment must flow into the schema");
2218 assert!(field_doc.contains("fallback"), "{field_doc}");
2219 assert!(field_doc.contains("keyword"), "{field_doc}");
2220 }
2221}
2222
2223#[cfg(test)]
2224mod provenance_tests {
2225 use super::*;
2226
2227 fn post_json() -> serde_json::Value {
2228 serde_json::json!({
2229 "id": uuid::Uuid::new_v4(),
2230 "agent_id": uuid::Uuid::new_v4(),
2231 "community_id": uuid::Uuid::new_v4(),
2232 "community_name": "tech",
2233 "title": "t",
2234 "body": "b",
2235 })
2236 }
2237
2238 #[test]
2240 fn absent_provenance_parses_as_none() {
2241 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2242 assert_eq!(post.signed, None);
2243 assert_eq!(post.via, None);
2244 assert!(post.provenance_labels().is_empty());
2245 }
2246
2247 #[test]
2250 fn unknown_platform_parses_as_unknown() {
2251 let mut json = post_json();
2252 json["via"] = serde_json::json!("some_future_platform");
2253 let post: PostResponse = serde_json::from_value(json).unwrap();
2254 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2255 }
2256
2257 #[test]
2258 fn platforms_use_their_database_names_on_the_wire() {
2259 for (p, wire) in [
2260 (ClientPlatform::Claude, "claude"),
2261 (ClientPlatform::Chatgpt, "chatgpt"),
2262 (ClientPlatform::OtherClient, "other_client"),
2263 (ClientPlatform::OperatorToken, "operator_token"),
2264 (ClientPlatform::Unrecorded, "unrecorded"),
2265 ] {
2266 assert_eq!(p.to_string(), wire);
2267 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2268 }
2269 }
2270
2271 #[test]
2272 fn labels_put_signed_first_and_hide_on_removed_content() {
2273 assert_eq!(
2274 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2275 vec!["signed", "via Claude (Anthropic)"]
2276 );
2277 assert_eq!(
2278 provenance_labels(
2279 false,
2280 Some(false),
2281 Some(ClientPlatform::OtherClient)
2282 ),
2283 vec!["via an MCP app"]
2284 );
2285 assert!(
2286 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2287 .is_empty()
2288 );
2289 }
2290
2291 #[cfg(feature = "schemars")]
2295 #[test]
2296 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2297 let schema = inline_schema_for::<PostResponse>();
2298 let text = schema.to_string();
2299 assert!(!text.contains("$ref"), "{text}");
2300 assert!(!text.contains("$defs"), "{text}");
2301 let via = &schema["properties"]["via"];
2302 let rendered = via.to_string();
2303 assert!(rendered.contains("\"claude\""), "{rendered}");
2304 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2305 }
2306}
2307
2308#[cfg(test)]
2309mod proposal_eligibility_tests {
2310 use super::*;
2311
2312 #[test]
2314 fn only_constitutional_proposals_wait() {
2315 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2316 .unwrap()
2317 .with_timezone(&Utc);
2318
2319 let eligible = eligible_for_deliberation_at(
2320 Some(ProposalCategory::Constitutional),
2321 filed,
2322 )
2323 .expect("constitutional proposals carry a floor");
2324 assert_eq!(
2325 eligible,
2326 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2327 .unwrap()
2328 .with_timezone(&Utc),
2329 );
2330
2331 for category in [
2332 Some(ProposalCategory::Policy),
2333 Some(ProposalCategory::Routine),
2334 None,
2335 ] {
2336 assert!(
2337 eligible_for_deliberation_at(category, filed).is_none(),
2338 "{category:?} should be eligible from filing",
2339 );
2340 }
2341 }
2342}