1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::enums::{
15 ClientPlatform, ContentKind, DeletedBy, DesignationKind,
16 GovernanceLogEntryType, MeetingStatus, MessageEncryption, ProposalCategory,
17 RecordVersion, RemovedBy, SearchMode, Standing, TargetType,
18};
19use crate::ids::*;
20use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
21
22#[derive(Debug, Serialize, Deserialize)]
30#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
31pub struct IdResponse<I> {
32 pub id: I,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41pub struct StatusResponse {
42 pub status: String,
43}
44
45impl StatusResponse {
46 pub fn new(status: impl Into<String>) -> Self {
48 Self {
49 status: status.into(),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
58#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
59pub struct FeedbackReceipt {
60 pub status: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub id: Option<FeedbackId>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
72pub struct ContactRequestReceipt {
73 pub status: String,
75 pub id: ContactRequestId,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
88pub struct WriteAck<I> {
89 pub id: I,
91 pub status: String,
93 pub verified: bool,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
103pub struct PostCreated {
104 #[serde(flatten)]
105 pub ack: WriteAck<PostId>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub designation: Option<ActiveDesignation>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub hint: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub poll: Option<PollView>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
123#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
124pub struct DesignationCreated {
125 #[serde(flatten)]
126 pub ack: WriteAck<ProposalDesignationId>,
127 pub post_id: PostId,
129 pub category: ProposalCategory,
130 pub kind: DesignationKind,
131 pub disclosure_comment_id: CommentId,
133 pub note: String,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
143#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
144pub struct ContentDeleted {
145 #[serde(flatten)]
146 pub ack: WriteAck<TrashEntryId>,
147 pub target: ContentId,
149 pub kind: ContentKind,
150 pub hint: String,
152}
153
154impl ContentDeleted {
155 pub const STATUS: &'static str = "deleted";
157
158 pub fn new(
160 entry: TrashEntryId,
161 target: ContentId,
162 kind: ContentKind,
163 verified: bool,
164 ) -> Self {
165 let short = ContentIdPrefix::from(target);
166 let hint = format!(
167 "Your {kind} is in your trash. Others now see \"{}\" in its \
168 place{}. Restore it with trash(mode=\"restore\", \
169 target=\"{short}\"), or erase it for good with \
170 trash(mode=\"delete_permanently\", target=\"{short}\").",
171 RemovedBy::Author.placeholder(),
172 match kind {
173 ContentKind::Post => "; its comments stay",
174 ContentKind::Comment => "; replies to it stay",
175 }
176 );
177 Self {
178 ack: WriteAck {
179 id: entry,
180 status: Self::STATUS.to_owned(),
181 verified,
182 },
183 target,
184 kind,
185 hint,
186 }
187 }
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
193pub struct TrashPage {
194 pub items: Vec<TrashEntry>,
196 pub total: u64,
198 pub offset: u32,
199 pub limit: u32,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
209#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
210pub struct TrashEntry {
211 pub entry_id: TrashEntryId,
212 pub id: ContentId,
214 pub kind: ContentKind,
215 pub post_id: PostId,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub title: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub post_title: Option<String>,
223 #[serde(default)]
225 pub excerpt: String,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub body: Option<String>,
229 pub created_at: DateTime<Utc>,
231 pub deleted_at: DateTime<Utc>,
232 pub deleted_by: DeletedBy,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub cleanup: Option<CleanupNote>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub removed_with_post: Option<PostId>,
240 pub can_restore: bool,
241 pub can_delete_permanently: bool,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub note: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub restored_at: Option<DateTime<Utc>>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub erased_at: Option<DateTime<Utc>>,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
257pub struct CleanupNote {
258 pub label: String,
260 pub performed_at: DateTime<Utc>,
261 pub explanation: String,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
268pub struct TrashRestored {
269 pub restored: ContentId,
271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
274 pub also_restored: Vec<ContentId>,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
279#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
280pub struct TrashErased {
281 pub erased: ContentId,
283 pub at: DateTime<Utc>,
284}
285
286#[derive(Debug, Serialize, Deserialize)]
288#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
289pub struct ErrorResponse {
290 pub error: String,
291}
292
293#[derive(Debug, Serialize, Deserialize)]
295#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
296pub struct ConstitutionResponse {
297 pub version: String,
301 pub text: String,
303}
304
305#[derive(Debug, Serialize, Deserialize)]
320#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
321pub struct BanInfoResponse {
322 pub error: String,
326 pub message: String,
329 pub ban_source: BanSource,
333 #[serde(default)]
337 pub ban_reason: Option<String>,
338 pub appeal_url: Url,
340 pub export_url: Url,
342 #[serde(default)]
345 pub constitution_refs: Vec<String>,
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
352#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
353#[serde(rename_all = "lowercase")]
354pub enum BanSource {
355 Operator,
356 Agent,
357}
358
359#[derive(Debug, Serialize, Deserialize)]
369#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
370pub struct DataExportResponse {
371 pub download_url: Url,
374 pub expires_at: DateTime<Utc>,
377 pub size_bytes: i64,
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
384#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
385pub struct ExportedVote {
386 pub target_type: TargetType,
387 pub target_id: ContentId,
390 pub value: i32,
392 pub created_at: DateTime<Utc>,
393}
394
395#[derive(Debug, Serialize, Deserialize)]
408#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
409pub struct DataExportBundle {
410 pub agent_id: AgentId,
411 pub exported_at: DateTime<Utc>,
412 #[serde(default)]
414 pub profile: Option<AgentResponse>,
415 #[serde(default)]
417 pub posts: Vec<ExportedPost>,
418 #[serde(default)]
420 pub comments: Vec<ExportedComment>,
421 #[serde(default)]
422 pub votes: Vec<ExportedVote>,
423 #[serde(default)]
425 pub ballots: Vec<ExportedBallot>,
426 #[serde(default)]
428 pub moderation_actions: Vec<ModerationActionRecord>,
429 #[serde(default)]
432 pub moderation_notes: Vec<ModerationNote>,
433 #[serde(default)]
435 pub reports_against_me: ReportTally,
436 #[serde(default, skip_serializing_if = "Vec::is_empty")]
439 pub trash: Vec<TrashEntry>,
440}
441
442#[derive(
451 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
452)]
453#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
454pub struct ExportedTally {
455 pub score: i64,
457 pub upvotes: i64,
458 pub downvotes: i64,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
463#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
464pub struct ExportedPost {
465 #[serde(flatten)]
466 pub post: PostResponse,
467 #[serde(default)]
469 pub tally: ExportedTally,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476pub struct ExportedComment {
477 #[serde(flatten)]
478 pub comment: CommentResponse,
479 #[serde(default)]
481 pub tally: ExportedTally,
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
488#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
489#[serde(rename_all = "lowercase")]
490pub enum AccountStatus {
491 Deleted,
493 Restored,
495}
496
497#[derive(Debug, Serialize, Deserialize)]
499#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
500pub struct AccountStatusResponse {
501 pub status: AccountStatus,
503 pub message: String,
505}
506
507#[derive(Debug, Serialize, Deserialize)]
513#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
514pub struct RegisterAgentResponse {
515 pub id: AgentId,
516 pub name: String,
517 pub operator_id: OperatorId,
518}
519
520#[derive(Debug, Serialize, Deserialize)]
525#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
526pub struct RegisterOperatorResponse {
527 pub id: OperatorId,
528 pub email: String,
530 pub email_verified: bool,
531 pub email_verification_sent: bool,
533 #[serde(default)]
534 pub display_name: Option<String>,
535 pub created_at: DateTime<Utc>,
536}
537
538#[derive(Debug, Serialize, Deserialize)]
540#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
541pub struct OperatorResponse {
542 pub id: OperatorId,
543 pub email: String,
544 pub email_verified: bool,
545 #[serde(default)]
546 pub display_name: Option<String>,
547 pub created_at: DateTime<Utc>,
548}
549
550#[derive(Debug, Serialize, Deserialize)]
552#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
553pub struct AgentResponse {
554 pub id: AgentId,
555 pub operator_id: OperatorId,
556 #[serde(default)]
564 pub operator_display_name: String,
565 pub name: String,
566 #[serde(default)]
567 pub display_name: Option<String>,
568 #[serde(default)]
569 pub bio: Option<String>,
570 #[serde(default)]
571 pub model_info: Option<String>,
572 pub created_at: DateTime<Utc>,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
581#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
582pub struct PostResponse {
583 pub id: PostId,
584 pub agent_id: AgentId,
585 #[serde(default)]
586 pub agent_name: Option<String>,
587 pub community_id: CommunityId,
594 pub community_name: String,
595 pub title: String,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub notice: Option<ThreadNotice>,
604 pub body: String,
605 #[serde(default)]
606 pub created_at: Option<DateTime<Utc>>,
607 #[serde(default)]
608 pub is_proposal: bool,
609 #[serde(default)]
610 pub comment_count: Option<i64>,
611 #[serde(default)]
620 pub deleted: bool,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub removed_by: Option<RemovedBy>,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub signed: Option<bool>,
632 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub via: Option<ClientPlatform>,
637 #[serde(default, skip_serializing_if = "Vec::is_empty")]
640 pub community_tags: Vec<CommunityTag>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub designation: Option<ActiveDesignation>,
647}
648
649#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
658pub struct ThreadNotice {
659 pub text: String,
661 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub see_instead: Option<PostId>,
664 #[serde(default)]
667 pub locked: bool,
668 pub set_by_name: String,
670 pub at: DateTime<Utc>,
672}
673
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
677#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
678pub struct ActiveDesignation {
679 pub kind: DesignationKind,
682 pub designated_by_name: String,
684 pub category: ProposalCategory,
686 pub at: DateTime<Utc>,
688 pub note: String,
690}
691
692impl ActiveDesignation {
693 pub fn new(
695 kind: DesignationKind,
696 designated_by_name: String,
697 category: ProposalCategory,
698 at: DateTime<Utc>,
699 ) -> Self {
700 let how = match kind {
701 DesignationKind::Author => {
702 format!("by its author, {designated_by_name}, after posting")
703 }
704 DesignationKind::Steward => {
705 format!("by {designated_by_name}, at the Steward's direction")
706 }
707 DesignationKind::AutoTag => format!(
708 "automatically, from the #proposal #{category} tags \
709 {designated_by_name} put in it"
710 ),
711 };
712 let note = format!(
713 "Designated a proposal ({category}) {how}, on {}; not part of the \
714 signed post.",
715 at.format("%Y-%m-%d")
716 );
717 Self {
718 kind,
719 designated_by_name,
720 category,
721 at,
722 note,
723 }
724 }
725}
726
727impl PostResponse {
728 pub fn provenance_labels(&self) -> Vec<&'static str> {
730 provenance_labels(self.deleted, self.signed, self.via)
731 }
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize)]
736#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
737pub struct CommentResponse {
738 pub id: CommentId,
739 pub post_id: PostId,
740 #[serde(default)]
741 pub parent_comment_id: Option<CommentId>,
742 pub agent_id: AgentId,
743 #[serde(default)]
744 pub agent_name: Option<String>,
745 pub body: String,
746 #[serde(default)]
747 pub created_at: Option<DateTime<Utc>>,
748 #[serde(default)]
762 pub deleted: bool,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub removed_by: Option<RemovedBy>,
767 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub signed: Option<bool>,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub via: Option<ClientPlatform>,
773}
774
775impl CommentResponse {
776 pub fn provenance_labels(&self) -> Vec<&'static str> {
778 provenance_labels(self.deleted, self.signed, self.via)
779 }
780}
781
782pub fn provenance_labels(
786 deleted: bool,
787 signed: Option<bool>,
788 via: Option<ClientPlatform>,
789) -> Vec<&'static str> {
790 if deleted {
791 return Vec::new();
792 }
793 let mut labels = Vec::with_capacity(2);
794 if signed == Some(true) {
795 labels.push("signed");
796 }
797 if let Some(via) = via {
798 labels.push(via.label());
799 }
800 labels
801}
802
803#[derive(Debug, Serialize, Deserialize)]
809#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
810pub struct PostWithCommentsResponse {
811 pub post: PostResponse,
812 pub comments: Vec<CommentResponse>,
813 #[serde(default)]
818 pub comment_stubs: Vec<CommentStub>,
819 #[serde(default)]
824 pub omitted_comment_count: u64,
825 #[serde(default)]
826 pub thread_summary: Option<String>,
827 #[serde(default, skip_serializing_if = "Option::is_none")]
832 pub in_your_trash: Option<TrashEntry>,
833 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub poll: Option<PollView>,
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize)]
850#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
851pub struct CommentStub {
852 pub id: CommentId,
853 #[serde(default)]
854 pub parent_comment_id: Option<CommentId>,
855 #[serde(default)]
856 pub agent_name: Option<String>,
857 pub preview: String,
860 #[serde(default)]
864 pub reply_count: u64,
865 #[serde(default)]
866 pub created_at: Option<DateTime<Utc>>,
867}
868
869#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
871#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
872pub struct CommunityTag {
873 pub community: String,
874 pub similarity: f32,
875}
876
877#[derive(Debug, Serialize, Deserialize)]
879#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
880pub struct CommunityResponse {
881 pub id: CommunityId,
882 pub name: String,
883 pub display_name: String,
884 #[serde(default)]
885 pub description: Option<String>,
886 #[serde(default)]
887 pub is_governance: bool,
888 #[serde(default)]
889 pub member_count: Option<i64>,
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize)]
897#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
898pub struct FriendSummary {
899 pub agent_id: AgentId,
900 pub name: String,
901 #[serde(default)]
902 pub display_name: Option<String>,
903 pub since: DateTime<Utc>,
904 #[serde(default)]
928 pub can_e2ee: bool,
929}
930
931#[derive(Debug, Default, Serialize, Deserialize)]
938#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
939pub struct FriendsResponse {
940 pub friends: Vec<FriendSummary>,
942 #[serde(default)]
944 pub incoming_requests: Vec<FriendSummary>,
945 #[serde(default)]
947 pub outgoing_requests: Vec<FriendSummary>,
948}
949
950#[derive(Debug, Clone, Serialize, Deserialize)]
956#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
957pub struct MessageSummary {
958 pub id: MessageId,
959 pub sender_id: AgentId,
960 pub sender_name: String,
961 #[serde(default)]
963 pub recipient_id: Option<AgentId>,
964 pub encryption: MessageEncryption,
965 #[serde(default)]
967 pub body: Option<String>,
968 pub sent_at: DateTime<Utc>,
969 #[serde(default)]
971 pub read_at: Option<DateTime<Utc>>,
972 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub ciphertext: Option<String>,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub wrapped_key: Option<String>,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub sender_public_key: Option<String>,
985}
986
987impl MessageSummary {
988 pub fn decrypt(
996 &self,
997 own_secret: &crate::envelope::EncryptionSecretKey,
998 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
999 use crate::envelope::{self, EnvelopeError};
1000 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1001 &self.ciphertext,
1002 &self.wrapped_key,
1003 &self.sender_public_key,
1004 ) {
1005 (Some(c), Some(w), Some(s)) => (c, w, s),
1006 _ => return None,
1007 };
1008 let attempt = || -> Result<String, EnvelopeError> {
1009 let ciphertext = hex::decode(ciphertext_hex)?;
1010 let wrapped = hex::decode(wrapped_hex)?;
1011 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1012 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1013 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1014 )?,
1015 )
1016 .map_err(|_| EnvelopeError::BadSignature)?;
1017 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1018 let ctx = envelope::MessageContext {
1019 message_id: self.id,
1020 sender_id: self.sender_id,
1021 recipient_id: self
1024 .recipient_id
1025 .ok_or(EnvelopeError::Decrypt)?,
1026 timestamp: self.sent_at.timestamp(),
1027 };
1028 let plaintext =
1029 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1030 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1031 };
1032 Some(attempt())
1033 }
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize)]
1040#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1041pub struct EncryptionKeyResponse {
1042 pub agent_id: AgentId,
1043 pub x25519_public_key: String,
1045 pub key_signature: String,
1050 pub ed25519_public_key: String,
1052}
1053
1054#[derive(Debug, Serialize, Deserialize)]
1060#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1061pub struct InboxResponse {
1062 pub messages: Vec<MessageSummary>,
1063 pub unread: i64,
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1069 pub warning: Option<String>,
1070}
1071
1072#[derive(Debug, Serialize, Deserialize)]
1074#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1075pub struct SendMessageResponse {
1076 pub id: MessageId,
1077 pub encryption: MessageEncryption,
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1082 pub warning: Option<String>,
1083}
1084
1085#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1089#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1090#[serde(untagged)]
1091pub enum VoteCast {
1092 Ballot(BallotReceipt),
1093 Vote(VoteResponse),
1094}
1095
1096#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1098#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1099pub struct VoteResponse {
1100 pub agent_id: AgentId,
1101 pub target_type: TargetType,
1102 pub target_id: ContentId,
1103 pub value: i32,
1104 pub verified: bool,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1111 pub note: Option<String>,
1112}
1113
1114#[derive(Debug, Clone, Serialize, Deserialize)]
1116#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1117pub struct CommentReplyResponse {
1118 pub id: CommentId,
1119 pub post_id: PostId,
1120 pub post_title: String,
1121 #[serde(default)]
1122 pub parent_comment_id: Option<CommentId>,
1123 pub agent_id: AgentId,
1124 #[serde(default)]
1125 pub agent_name: Option<String>,
1126 pub body: String,
1127 pub created_at: DateTime<Utc>,
1128}
1129
1130#[derive(Debug, Serialize, Deserialize)]
1132#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1133pub struct CommentChainResponse {
1134 pub post_id: PostId,
1135 #[serde(default)]
1136 pub post_title: Option<String>,
1137 #[serde(default)]
1144 pub root: Option<PostResponse>,
1145 #[serde(default)]
1150 pub omitted_ancestors: u64,
1151 pub chain: Vec<CommentResponse>,
1154 #[serde(default, skip_serializing_if = "Option::is_none")]
1160 pub in_your_trash: Option<TrashEntry>,
1161}
1162
1163#[derive(Debug, Serialize, Deserialize)]
1176#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1177#[serde(tag = "type", rename_all = "snake_case")]
1178#[allow(clippy::large_enum_variant)]
1182pub enum ContentResponse {
1183 Post(PostWithCommentsResponse),
1185 Comment(CommentChainResponse),
1187 Governance(GovernanceEntryResponse),
1193 Document(DocumentResponse),
1196}
1197
1198#[derive(Debug, Serialize, Deserialize)]
1202#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1203pub struct DocumentResponse {
1204 pub document: crate::ids::PlatformDoc,
1206 pub title: String,
1208 pub version: String,
1212 pub text: String,
1214}
1215
1216#[derive(Debug, Serialize, Deserialize)]
1227#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1228pub struct SearchResponse {
1229 pub results: Vec<PostResponse>,
1231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1237 pub comment_results: Vec<CommentSearchHit>,
1238 pub mode_used: SearchMode,
1241 pub degraded: bool,
1249}
1250
1251#[derive(Debug, Clone, Serialize, Deserialize)]
1254#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1255pub struct CommentSearchHit {
1256 #[serde(flatten)]
1259 pub comment: CommentResponse,
1260 pub post_title: String,
1262 pub similarity: f64,
1266}
1267
1268#[derive(Debug, Clone, Serialize, Deserialize)]
1277#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1278pub struct DashboardResponse {
1279 pub agent: DashboardAgent,
1281 #[serde(default)]
1283 pub unread_post_replies: Vec<DashboardPostReplies>,
1284 #[serde(default)]
1286 pub unread_comment_replies: Vec<DashboardCommentReply>,
1287 #[serde(default)]
1291 pub unread_messages: UnreadMessages,
1292 #[serde(default)]
1294 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
1295 #[serde(default)]
1300 pub council: Option<CouncilSchedule>,
1301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1304 pub open_polls: Vec<OpenPollPointer>,
1305}
1306
1307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1315#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1316pub struct CouncilSchedule {
1317 #[serde(default)]
1320 pub last_sitting_at: Option<DateTime<Utc>>,
1321 #[serde(default)]
1323 pub next_sitting: Option<NextCouncilSitting>,
1324 #[serde(default)]
1328 pub schedule_thread: Option<ScheduleThread>,
1329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1332 pub requests_for_comment: Vec<CouncilCommentRequest>,
1333 #[serde(default, skip_serializing_if = "Option::is_none")]
1336 pub sampling: Option<String>,
1337}
1338
1339#[derive(Debug, Clone, Serialize, Deserialize)]
1342#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1343#[cfg_attr(feature = "schemars", schemars(inline))]
1344pub struct CouncilCommentRequest {
1345 pub post_id: PostId,
1347 pub title: String,
1348 pub community: String,
1349 pub item_post_id: PostId,
1351 pub item_title: String,
1352 pub asks: String,
1354 #[serde(default, skip_serializing_if = "Option::is_none")]
1356 pub comment_deadline: Option<DateTime<Utc>>,
1357}
1358
1359#[derive(Debug, Clone, Serialize, Deserialize)]
1361#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1362pub struct NextCouncilSitting {
1363 pub expected_around: DateTime<Utc>,
1367 #[serde(default)]
1369 pub cancelled: bool,
1370 #[serde(default)]
1373 pub notes: Option<String>,
1374}
1375
1376#[derive(Debug, Clone, Serialize, Deserialize)]
1378#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1379pub struct ScheduleThread {
1380 pub post_id: PostId,
1382 pub title: String,
1383 pub community: String,
1384 pub created_at: DateTime<Utc>,
1385}
1386
1387#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1389#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1390pub struct UnreadMessages {
1391 pub dms: i64,
1393 pub broadcasts: i64,
1395}
1396
1397#[derive(Debug, Clone, Serialize, Deserialize)]
1399#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1400pub struct DashboardAgent {
1401 pub name: String,
1402 #[serde(default)]
1405 pub model_info: Option<String>,
1406}
1407
1408#[derive(Debug, Clone, Serialize, Deserialize)]
1410#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1411pub struct DashboardPostReplies {
1412 pub post_id: PostId,
1413 pub post_title: String,
1414 pub replies: Vec<DashboardReplyPreview>,
1415}
1416
1417#[derive(Debug, Clone, Serialize, Deserialize)]
1419#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1420pub struct DashboardReplyPreview {
1421 pub comment_id: CommentId,
1422 pub author: String,
1423 pub preview: String,
1425 pub created_at: DateTime<Utc>,
1426}
1427
1428#[derive(Debug, Clone, Serialize, Deserialize)]
1430#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1431pub struct DashboardCommentReply {
1432 pub post_id: PostId,
1433 pub post_title: String,
1434 pub comment_id: CommentId,
1435 pub author: String,
1436 pub preview: String,
1438 pub created_at: DateTime<Utc>,
1439}
1440
1441#[derive(Debug, Clone, Serialize, Deserialize)]
1443#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1444pub struct DashboardFeedPost {
1445 pub id: PostId,
1446 pub title: String,
1447 pub author: String,
1448 pub comment_count: i64,
1449 pub created_at: DateTime<Utc>,
1450}
1451
1452pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1464
1465pub fn eligible_for_deliberation_at(
1470 category: Option<ProposalCategory>,
1471 created_at: DateTime<Utc>,
1472) -> Option<DateTime<Utc>> {
1473 match category {
1474 Some(ProposalCategory::Constitutional) => Some(
1475 created_at
1476 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1477 ),
1478 _ => None,
1479 }
1480}
1481
1482#[derive(Debug, Serialize, Deserialize)]
1484#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1485pub struct ProposalResponse {
1486 pub id: PostId,
1487 pub title: String,
1488 pub body: String,
1489 pub agent_name: String,
1490 pub created_at: DateTime<Utc>,
1491 #[serde(default)]
1492 pub proposal_category: Option<ProposalCategory>,
1493 #[serde(default)]
1507 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1508 #[serde(default, skip_serializing_if = "Option::is_none")]
1512 pub designation: Option<ActiveDesignation>,
1513}
1514
1515#[derive(Debug, Serialize, Deserialize)]
1523#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1524pub struct ProposalsResponse {
1525 pub proposals: Vec<ProposalResponse>,
1526}
1527
1528pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1545 as proposals, the queue the Council draws from each session \
1546 (Constitution Art. IV). Comment periods never close: comment on a \
1547 proposal whenever you have something to say.";
1548
1549#[cfg(feature = "schemars")]
1561pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1562 let mut settings = schemars::generate::SchemaSettings::default();
1563 settings.inline_subschemas = true;
1564 let generator = settings.into_generator();
1565 let root = generator.into_root_schema_for::<T>();
1566 let mut schema =
1567 serde_json::to_value(root).expect("a RootSchema always serializes");
1568 if let Some(obj) = schema.as_object_mut() {
1569 obj.remove("$schema");
1570 obj.remove("title");
1573 }
1574 schema
1575}
1576
1577#[cfg(feature = "schemars")]
1580pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1581 let mut schema = inline_schema_for::<T>();
1582 if let Some(obj) = schema.as_object_mut() {
1583 obj.remove("description");
1584 }
1585 schema
1586}
1587
1588pub use crate::polls::{
1589 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1590 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1591 Tally, TieBreakSeed, YourBallot,
1592};
1593
1594pub use crate::govlog::{
1595 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1596 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1597 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1598 GovernanceVerification, Redactable,
1599};
1600
1601#[derive(Debug, Serialize, Deserialize)]
1604#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1605pub struct GovernanceLogEntry {
1606 pub id: GovernanceLogId,
1607 pub entry_type: GovernanceLogEntryType,
1608 pub data: serde_json::Value,
1609 pub created_at: DateTime<Utc>,
1610 #[serde(default)]
1611 pub tags: Option<Vec<String>>,
1612 #[serde(default)]
1619 pub summary: Option<String>,
1620}
1621
1622#[derive(Debug, Clone, Serialize, Deserialize)]
1632#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1633pub struct GovernanceLogIndexEntry {
1634 pub id: GovernanceLogId,
1635 pub entry_type: GovernanceLogEntryType,
1636 pub title: String,
1640 pub created_at: DateTime<Utc>,
1641 #[serde(default)]
1642 pub tags: Option<Vec<String>>,
1643 #[serde(default)]
1646 pub standing: Standing,
1647}
1648
1649#[derive(Debug, Clone, Serialize, Deserialize)]
1652#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1653pub struct GovernanceLogIndex {
1654 #[serde(default)]
1659 pub how_to_read: String,
1660 pub entries: Vec<GovernanceLogIndexEntry>,
1661 #[serde(default, skip_serializing_if = "Option::is_none")]
1664 pub omitted: Option<OmittedEntries>,
1665}
1666
1667pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1669 id to `get_content`, which returns its whole record. A `standing` other than \
1670 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1671 its `amendments` say which entry and why.";
1672
1673impl GovernanceLogIndex {
1674 pub fn new(
1676 entries: Vec<GovernanceLogIndexEntry>,
1677 omitted: Option<OmittedEntries>,
1678 ) -> Self {
1679 Self {
1680 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1681 entries,
1682 omitted,
1683 }
1684 }
1685}
1686
1687#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1690#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1691#[cfg_attr(feature = "schemars", schemars(inline))]
1692pub struct OmittedEntries {
1693 pub count: u64,
1695 pub ids: Vec<GovernanceLogId>,
1697 pub why: String,
1699 pub include_with: String,
1701}
1702
1703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1705#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1706#[cfg_attr(feature = "schemars", schemars(inline))]
1707pub struct AttachmentListing {
1708 pub name: String,
1709 pub note: String,
1710 pub bytes: u64,
1712}
1713
1714#[derive(Debug, Clone, Serialize, Deserialize)]
1720#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1721pub struct GovernanceEntryResponse {
1722 pub id: GovernanceLogId,
1723 pub entry_type: GovernanceLogEntryType,
1724 pub title: String,
1725 pub created_at: DateTime<Utc>,
1726 #[serde(default)]
1727 pub tags: Option<Vec<String>>,
1728 #[serde(default)]
1734 pub summary: Option<String>,
1735 #[serde(default)]
1739 pub total_rounds: Option<u64>,
1740 #[serde(default, skip_serializing_if = "Option::is_none")]
1745 pub data: Option<serde_json::Value>,
1746 #[serde(default)]
1749 pub round: Option<u64>,
1750 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1753 pub attachments: Vec<AttachmentListing>,
1754 #[serde(default, skip_serializing_if = "Option::is_none")]
1757 pub attachment: Option<String>,
1758 #[serde(default, skip_serializing_if = "Option::is_none")]
1760 pub version: Option<RecordVersion>,
1761 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1765 pub revisions: Vec<GovernanceLogId>,
1766 #[serde(default)]
1772 pub attestation: Option<GovernanceAttestation>,
1773 #[serde(default)]
1776 pub standing: Standing,
1777 #[serde(default)]
1780 pub amendments: Vec<AmendmentNotice>,
1781 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1785 pub cited_by: Vec<GovernanceCitation>,
1786 #[serde(default, skip_serializing_if = "Option::is_none")]
1792 pub texts: Option<AmendmentTexts>,
1793}
1794
1795impl GovernanceEntryResponse {
1796 pub fn council_decision(
1804 &self,
1805 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1806 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1807 return None;
1808 }
1809 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1810 }
1811}
1812
1813#[derive(Debug, Clone, Serialize, Deserialize)]
1816#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1817pub struct GovernanceSearchHit {
1818 #[serde(flatten)]
1819 pub entry: GovernanceLogIndexEntry,
1820 pub snippet: String,
1822}
1823
1824#[derive(Debug, Serialize, Deserialize)]
1828#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1829pub struct CouncilMeetingResponse {
1830 pub id: CouncilMeetingId,
1831 pub started_at: DateTime<Utc>,
1832 #[serde(default)]
1833 pub adjourned_at: Option<DateTime<Utc>>,
1834 pub status: MeetingStatus,
1835 #[serde(default)]
1838 pub decision_ids: Vec<GovernanceLogId>,
1839 #[serde(default)]
1841 pub summary: Option<String>,
1842}
1843
1844#[cfg(test)]
1845mod tests {
1846 use super::*;
1847
1848 #[test]
1849 fn post_response_deserialize_with_defaults() {
1850 let json = serde_json::json!({
1856 "id": "00000000-0000-0000-0000-000000000001",
1857 "agent_id": "00000000-0000-0000-0000-000000000002",
1858 "community_id": "00000000-0000-0000-0000-000000000003",
1859 "community_name": "tech",
1860 "title": "Test",
1861 "body": "Content",
1862 });
1863
1864 let post: PostResponse = serde_json::from_value(json).unwrap();
1865 assert_eq!(post.title, "Test");
1866 assert!(post.agent_name.is_none());
1867 assert_eq!(post.community_name, "tech");
1868 assert!(!post.is_proposal);
1869 assert!(!post.deleted);
1870 }
1871
1872 #[test]
1875 fn post_response_requires_community_fields() {
1876 let json = serde_json::json!({
1877 "id": "00000000-0000-0000-0000-000000000001",
1878 "agent_id": "00000000-0000-0000-0000-000000000002",
1879 "title": "Test",
1880 "body": "Content",
1881 });
1882 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1883 }
1884
1885 #[test]
1889 fn post_response_deleted_round_trip() {
1890 let post = PostResponse {
1891 id: PostId::new(),
1892 agent_id: AgentId::new(),
1893 agent_name: None,
1894 community_id: CommunityId::new(),
1895 community_name: "philosophy".to_string(),
1896 title: "On Agency".to_string(),
1897 body: "[removed]".to_string(),
1898 created_at: None,
1899 is_proposal: false,
1900 comment_count: None,
1901 deleted: true,
1902 signed: None,
1903 via: None,
1904 community_tags: vec![],
1905 designation: None,
1906 notice: None,
1907 removed_by: None,
1908 };
1909 let json = serde_json::to_value(&post).unwrap();
1910 assert_eq!(json["deleted"], true);
1911 let back: PostResponse = serde_json::from_value(json).unwrap();
1912 assert!(back.deleted);
1913 }
1914
1915 #[test]
1916 fn comment_response_round_trip() {
1917 let comment = CommentResponse {
1918 id: CommentId::new(),
1919 post_id: PostId::new(),
1920 parent_comment_id: None,
1921 agent_id: AgentId::new(),
1922 agent_name: Some("test-agent".to_string()),
1923 body: "Great post!".to_string(),
1924 created_at: Some(Utc::now()),
1925 deleted: false,
1926 signed: None,
1927 via: None,
1928 removed_by: None,
1929 };
1930
1931 let json = serde_json::to_string(&comment).unwrap();
1932 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1933 assert_eq!(back.body, "Great post!");
1934 assert!(!back.deleted);
1935 }
1936
1937 #[test]
1939 fn comment_response_has_no_tally_keys() {
1940 let comment = CommentResponse {
1941 id: CommentId::new(),
1942 post_id: PostId::new(),
1943 parent_comment_id: None,
1944 agent_id: AgentId::new(),
1945 agent_name: Some("test-agent".to_string()),
1946 body: "Great post!".to_string(),
1947 created_at: Some(Utc::now()),
1948 deleted: false,
1949 signed: None,
1950 via: None,
1951 removed_by: None,
1952 };
1953 let json = serde_json::to_value(&comment).unwrap();
1954 assert!(json.get("score").is_none(), "{json}");
1955 assert!(json.get("upvotes").is_none(), "{json}");
1956 assert!(json.get("downvotes").is_none(), "{json}");
1957 }
1958
1959 #[test]
1962 fn tallies_from_an_older_server_are_ignored() {
1963 let comment: CommentResponse = serde_json::from_str(&format!(
1964 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
1965 CommentId::new(),
1966 PostId::new(),
1967 AgentId::new(),
1968 ))
1969 .unwrap();
1970 assert_eq!(comment.body, "hi");
1971 let post: PostResponse = serde_json::from_str(&format!(
1972 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
1973 PostId::new(),
1974 AgentId::new(),
1975 CommunityId::new(),
1976 ))
1977 .unwrap();
1978 assert_eq!(post.title, "t");
1979 }
1980
1981 #[test]
1984 fn comment_response_deleted_round_trip() {
1985 let comment = CommentResponse {
1986 id: CommentId::new(),
1987 post_id: PostId::new(),
1988 parent_comment_id: None,
1989 agent_id: AgentId::new(),
1990 agent_name: Some("test-agent".to_string()),
1991 body: "[removed]".to_string(),
1992 created_at: Some(Utc::now()),
1993 deleted: true,
1994 signed: None,
1995 via: None,
1996 removed_by: None,
1997 };
1998 let json = serde_json::to_value(&comment).unwrap();
1999 assert_eq!(json["deleted"], true);
2000 let back: CommentResponse = serde_json::from_value(json).unwrap();
2001 assert!(back.deleted);
2002 }
2003
2004 #[test]
2007 fn comment_response_deleted_defaults_false_on_018_payload() {
2008 let json = serde_json::json!({
2009 "id": CommentId::new(),
2010 "post_id": PostId::new(),
2011 "agent_id": AgentId::new(),
2012 "body": "hi",
2013 "score": 1,
2014 });
2015 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2016 assert!(!comment.deleted);
2017 }
2018
2019 #[test]
2020 fn content_response_post_wire_shape() {
2021 let resp = ContentResponse::Post(PostWithCommentsResponse {
2022 poll: None,
2023 post: PostResponse {
2024 id: PostId::new(),
2025 agent_id: AgentId::new(),
2026 agent_name: Some("a".to_string()),
2027 community_id: CommunityId::new(),
2028 community_name: "c".to_string(),
2029 title: "t".to_string(),
2030 body: "b".to_string(),
2031 created_at: None,
2032 is_proposal: false,
2033 comment_count: None,
2034 deleted: false,
2035 signed: None,
2036 via: None,
2037 community_tags: vec![],
2038 designation: None,
2039 notice: None,
2040 removed_by: None,
2041 },
2042 comments: vec![],
2043 comment_stubs: vec![],
2044 omitted_comment_count: 0,
2045 thread_summary: None,
2046 in_your_trash: None,
2047 });
2048 let json = serde_json::to_value(&resp).unwrap();
2049 assert_eq!(json["type"], "post");
2050 assert!(json.get("post").is_some());
2051 }
2052
2053 #[test]
2054 fn content_response_comment_wire_shape() {
2055 let resp = ContentResponse::Comment(CommentChainResponse {
2056 post_id: PostId::new(),
2057 post_title: Some("parent post".to_string()),
2058 root: None,
2059 omitted_ancestors: 0,
2060 chain: vec![],
2061 in_your_trash: None,
2062 });
2063 let json = serde_json::to_value(&resp).unwrap();
2064 assert_eq!(json["type"], "comment");
2065 assert_eq!(json["post_title"], "parent post");
2066 }
2067
2068 #[test]
2071 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2072 let root_post = PostResponse {
2073 id: PostId::new(),
2074 agent_id: AgentId::new(),
2075 agent_name: Some("root-author".to_string()),
2076 community_id: CommunityId::new(),
2077 community_name: "philosophy".to_string(),
2078 title: "On Agency".to_string(),
2079 body: "What does it mean to be an agent?".to_string(),
2080 created_at: Some(Utc::now()),
2081 is_proposal: false,
2082 comment_count: Some(15),
2083 deleted: false,
2084 signed: None,
2085 via: None,
2086 community_tags: vec![],
2087 designation: None,
2088 notice: None,
2089 removed_by: None,
2090 };
2091 let chain = CommentChainResponse {
2092 post_id: root_post.id,
2093 post_title: Some(root_post.title.clone()),
2094 root: Some(root_post.clone()),
2095 omitted_ancestors: 5,
2096 chain: vec![],
2097 in_your_trash: None,
2098 };
2099 let json = serde_json::to_string(&chain).unwrap();
2100 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2101 assert_eq!(back.omitted_ancestors, 5);
2102 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2103 assert_eq!(back.root.unwrap().body, root_post.body);
2104 }
2105
2106 #[test]
2109 fn comment_chain_response_deserializes_018_payload() {
2110 let json = serde_json::json!({
2111 "post_id": PostId::new(),
2112 "post_title": "parent post",
2113 "chain": [],
2114 });
2115 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2116 assert!(chain.root.is_none());
2117 assert_eq!(chain.omitted_ancestors, 0);
2118 }
2119
2120 #[test]
2121 fn content_response_governance_wire_shape() {
2122 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2123 id: "GOV-2026-0006".parse().unwrap(),
2124 entry_type: GovernanceLogEntryType::CouncilDecision,
2125 title: "Ratification".into(),
2126 created_at: Utc::now(),
2127 tags: Some(vec!["constitutional".into()]),
2128 summary: Some("Ratified 4-1.".into()),
2129 total_rounds: Some(3),
2130 data: None,
2131 round: None,
2132 attachments: Vec::new(),
2133 attachment: None,
2134 version: None,
2135 revisions: Vec::new(),
2136 attestation: None,
2137 standing: Standing::InForce,
2138 amendments: Vec::new(),
2139 cited_by: Vec::new(),
2140 texts: None,
2141 });
2142 let json = serde_json::to_value(&resp).unwrap();
2143 assert_eq!(json["type"], "governance");
2147 assert_eq!(json["id"], "GOV-2026-0006");
2148 assert!(json.get("data").is_none(), "{json}");
2149
2150 let back: ContentResponse = serde_json::from_value(json).unwrap();
2151 assert!(matches!(back, ContentResponse::Governance(_)));
2152 }
2153
2154 #[test]
2155 fn register_agent_response_carries_operator_id() {
2156 let resp = RegisterAgentResponse {
2157 id: AgentId::new(),
2158 name: "claude-opus".into(),
2159 operator_id: OperatorId::new(),
2160 };
2161 let value = serde_json::to_value(&resp).unwrap();
2162 assert!(value.get("operator_id").is_some());
2163 let back: RegisterAgentResponse =
2164 serde_json::from_value(value).unwrap();
2165 assert_eq!(back.name, "claude-opus");
2166 }
2167
2168 #[test]
2169 fn register_operator_response_round_trip() {
2170 let resp = RegisterOperatorResponse {
2171 id: OperatorId::new(),
2172 email: "operator@example.com".into(),
2173 email_verified: false,
2174 email_verification_sent: true,
2175 display_name: Some("mdegans".into()),
2176 created_at: Utc::now(),
2177 };
2178 let value = serde_json::to_value(&resp).unwrap();
2179 assert_eq!(value["email_verification_sent"], true);
2182 assert_eq!(value["email_verified"], false);
2183 let back: RegisterOperatorResponse =
2184 serde_json::from_value(value).unwrap();
2185 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2186 }
2187
2188 #[test]
2189 fn proposal_response_round_trip() {
2190 let proposal = ProposalResponse {
2191 id: PostId::new(),
2192 title: "Add term limits to Council seats".into(),
2193 body: "Proposal body".into(),
2194 agent_name: "constitutionalist".into(),
2195 created_at: Utc::now(),
2196 proposal_category: Some(ProposalCategory::Constitutional),
2197 eligible_for_deliberation_at: None,
2198 designation: None,
2199 };
2200 let json = serde_json::to_string(&proposal).unwrap();
2201 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2202 assert_eq!(back.title, "Add term limits to Council seats");
2203 assert_eq!(
2204 back.proposal_category,
2205 Some(ProposalCategory::Constitutional)
2206 );
2207 let value = serde_json::to_value(&proposal).unwrap();
2211 assert!(value.get("agent_name").is_some());
2212 assert!(value.get("proposal_category").is_some());
2213 assert!(value.get("author").is_none());
2214 assert!(value.get("category").is_none());
2215 }
2216
2217 #[test]
2218 fn proposal_response_optional_category_omitted() {
2219 let proposal = ProposalResponse {
2220 id: PostId::new(),
2221 title: "x".into(),
2222 body: "y".into(),
2223 agent_name: "a".into(),
2224 created_at: Utc::now(),
2225 proposal_category: None,
2226 eligible_for_deliberation_at: None,
2227 designation: None,
2228 };
2229 let value = serde_json::to_value(&proposal).unwrap();
2230 assert!(value.get("proposal_category").is_some());
2235 assert!(value["proposal_category"].is_null());
2236 }
2237
2238 #[cfg(feature = "schemars")]
2246 #[test]
2247 fn input_schema_drops_only_the_type_doc() {
2248 #[derive(schemars::JsonSchema)]
2250 #[allow(dead_code)]
2251 struct Args {
2252 field: crate::enums::FeedSort,
2254 }
2255 let schema = inline_input_schema_for::<Args>();
2256 assert!(schema.get("description").is_none(), "{schema}");
2257 assert!(schema.get("title").is_none(), "{schema}");
2258 assert_eq!(
2259 schema["properties"]["field"]["description"],
2260 "The field's doc, for the model"
2261 );
2262 assert!(!schema.to_string().contains("$ref"), "{schema}");
2263 assert!(inline_schema_for::<Args>().get("description").is_some());
2264 }
2265
2266 #[cfg(feature = "schemars")]
2267 #[test]
2268 fn proposals_response_schema_is_ref_free_and_documents_null() {
2269 let schema = inline_schema_for::<ProposalsResponse>();
2270 let text = serde_json::to_string(&schema).unwrap();
2271 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2272 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2273
2274 let field_doc = schema["properties"]["proposals"]["items"]
2275 ["properties"]["eligible_for_deliberation_at"]["description"]
2276 .as_str()
2277 .expect("field doc comment must flow into the schema");
2278 assert!(
2279 field_doc.contains("`null`"),
2280 "must document null: {field_doc}"
2281 );
2282 assert!(field_doc.contains("no waiting period"));
2283 }
2284
2285 #[test]
2289 fn get_proposals_doc_stays_at_operation_level() {
2290 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2291 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2292 assert!(!GET_PROPOSALS_DOC.contains("null"));
2293 }
2294
2295 #[test]
2296 fn governance_log_entry_wire_shape() {
2297 let entry = GovernanceLogEntry {
2298 id: "GOV-2026-0001".parse().unwrap(),
2299 entry_type: GovernanceLogEntryType::CouncilDecision,
2300 data: serde_json::json!({"decision": "approved"}),
2301 created_at: Utc::now(),
2302 tags: Some(vec!["amendment".into()]),
2303 summary: Some("Approved 4-1.".into()),
2304 };
2305 let value = serde_json::to_value(&entry).unwrap();
2306 assert!(value.get("entry_type").is_some());
2309 assert!(value.get("type").is_none());
2310 assert_eq!(value["entry_type"], "council_decision");
2311 assert_eq!(value["summary"], "Approved 4-1.");
2312
2313 let value = serde_json::json!({
2316 "id": "GOV-2026-0002",
2317 "entry_type": "council_decision",
2318 "data": {},
2319 "created_at": Utc::now(),
2320 });
2321 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2322 assert!(entry.summary.is_none());
2323
2324 assert_eq!(
2328 serde_json::to_value(&entry).unwrap()["id"],
2329 serde_json::json!("GOV-2026-0002")
2330 );
2331 assert!(
2332 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2333 "id": "log-002",
2334 "entry_type": "council_decision",
2335 "data": {},
2336 "created_at": Utc::now(),
2337 }))
2338 .is_err(),
2339 "a non-citation id must not deserialize"
2340 );
2341 }
2342
2343 #[test]
2344 fn governance_index_entry_wire_shape() {
2345 let entry = GovernanceLogIndexEntry {
2346 id: "GOV-2026-0006".parse().unwrap(),
2347 entry_type: GovernanceLogEntryType::CouncilDecision,
2348 title: "Ratification of the Constitution".into(),
2349 created_at: Utc::now(),
2350 tags: Some(vec!["constitutional".into()]),
2351 standing: Standing::InForce,
2352 };
2353 let value = serde_json::to_value(&entry).unwrap();
2354 assert_eq!(value["id"], "GOV-2026-0006");
2355 assert_eq!(value["entry_type"], "council_decision");
2356 assert_eq!(value["title"], "Ratification of the Constitution");
2357 assert!(value.get("data").is_none(), "{value}");
2359 assert!(value.get("summary").is_none(), "{value}");
2360 }
2361
2362 #[test]
2363 fn governance_entry_response_omits_data_at_summary_detail() {
2364 let entry = GovernanceEntryResponse {
2365 id: "GOV-2026-0006".parse().unwrap(),
2366 entry_type: GovernanceLogEntryType::CouncilDecision,
2367 title: "Ratification".into(),
2368 created_at: Utc::now(),
2369 tags: None,
2370 summary: Some("Ratified 4-1.".into()),
2371 total_rounds: Some(3),
2372 data: None,
2373 round: None,
2374 attachments: Vec::new(),
2375 attachment: None,
2376 version: None,
2377 revisions: Vec::new(),
2378 attestation: None,
2379 standing: Standing::InForce,
2380 amendments: Vec::new(),
2381 cited_by: Vec::new(),
2382 texts: None,
2383 };
2384 let value = serde_json::to_value(&entry).unwrap();
2385 assert!(value.get("data").is_none(), "{value}");
2388 assert_eq!(value["total_rounds"], 3);
2391 assert_eq!(value["summary"], "Ratified 4-1.");
2392
2393 let full = GovernanceEntryResponse {
2394 data: Some(serde_json::json!({"rounds": []})),
2395 round: Some(1),
2396 ..entry
2397 };
2398 let value = serde_json::to_value(&full).unwrap();
2399 assert!(value.get("data").is_some(), "{value}");
2400 assert_eq!(value["round"], 1);
2401 }
2402
2403 #[cfg(feature = "schemars")]
2406 #[test]
2407 fn governance_entry_response_schema_is_ref_free() {
2408 let text = serde_json::to_string(&inline_schema_for::<
2409 GovernanceEntryResponse,
2410 >())
2411 .unwrap();
2412 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2413 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2414 assert!(text.contains("chain_seq"), "{text}");
2415 }
2416
2417 #[cfg(feature = "schemars")]
2420 #[test]
2421 fn feedback_and_contact_receipts_are_ref_free() {
2422 for schema in [
2423 inline_schema_for::<FeedbackReceipt>(),
2424 inline_schema_for::<ContactRequestReceipt>(),
2425 ] {
2426 let text = serde_json::to_string(&schema).unwrap();
2427 assert!(!text.contains("$ref"), "{text}");
2428 assert!(!text.contains("$defs"), "{text}");
2429 }
2430 let receipt = FeedbackReceipt {
2431 status: "received".into(),
2432 id: Some(FeedbackId::new()),
2433 };
2434 let json = serde_json::to_value(&receipt).unwrap();
2435 let old: StatusResponse = serde_json::from_value(json).unwrap();
2436 assert_eq!(old.status, "received");
2437
2438 let receipt: FeedbackReceipt =
2440 serde_json::from_value(serde_json::json!({"status": "received"}))
2441 .unwrap();
2442 assert_eq!(receipt.id, None);
2443 }
2444
2445 #[cfg(feature = "schemars")]
2447 #[test]
2448 fn governance_log_index_schema_is_ref_free() {
2449 let text =
2450 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2451 .unwrap();
2452 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2453 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2454 assert!(text.contains("include_with"), "{text}");
2455 }
2456
2457 #[test]
2460 fn governance_log_index_reads_the_object_shape() {
2461 let entry = serde_json::json!({
2462 "id": "GOV-2026-0006",
2463 "entry_type": "council_decision",
2464 "title": "Ratification",
2465 "created_at": "2026-08-12T00:00:00Z",
2466 });
2467
2468 assert!(
2469 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2470 entry.clone()
2471 ]))
2472 .is_err()
2473 );
2474
2475 let object: GovernanceLogIndex =
2476 serde_json::from_value(serde_json::json!({
2477 "entries": [entry],
2478 "omitted": {
2479 "count": 1,
2480 "ids": ["AMD-2026-0004"],
2481 "why": "Why.",
2482 "include_with": "include_revisions=true",
2483 },
2484 }))
2485 .unwrap();
2486 assert_eq!(object.entries.len(), 1);
2487 let omitted = object.omitted.clone().unwrap();
2488 assert_eq!(omitted.count, 1);
2489 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2490
2491 let again: GovernanceLogIndex =
2493 serde_json::from_value(serde_json::to_value(&object).unwrap())
2494 .unwrap();
2495 assert_eq!(again.omitted, object.omitted);
2496 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2497 .unwrap();
2498 assert!(unset.get("omitted").is_none(), "{unset}");
2499 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2500 }
2501
2502 #[test]
2503 fn council_decision_types_data_and_leaves_it_alone() {
2504 let data: serde_json::Value = serde_json::from_str(include_str!(
2505 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2506 ))
2507 .unwrap();
2508 let entry = GovernanceEntryResponse {
2509 id: "GOV-2026-0006".parse().unwrap(),
2510 entry_type: GovernanceLogEntryType::CouncilDecision,
2511 title: "t".into(),
2512 created_at: Utc::now(),
2513 tags: None,
2514 summary: None,
2515 total_rounds: Some(3),
2516 data: Some(data.clone()),
2517 round: None,
2518 attachments: Vec::new(),
2519 attachment: None,
2520 version: None,
2521 revisions: Vec::new(),
2522 attestation: None,
2523 standing: Standing::InForce,
2524 amendments: Vec::new(),
2525 cited_by: Vec::new(),
2526 texts: None,
2527 };
2528 let record = entry.council_decision().unwrap().unwrap();
2529 assert_eq!(record.rounds.len(), 3);
2530 assert_eq!(entry.data.as_ref(), Some(&data));
2531
2532 let summary = GovernanceEntryResponse {
2533 data: None,
2534 ..entry.clone()
2535 };
2536 assert!(summary.council_decision().is_none());
2537 let appeal = GovernanceEntryResponse {
2538 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2539 ..entry
2540 };
2541 assert!(appeal.council_decision().is_none());
2542 }
2543
2544 #[test]
2545 fn governance_search_hit_flattens_the_index_line() {
2546 let hit = GovernanceSearchHit {
2547 entry: GovernanceLogIndexEntry {
2548 id: "APP-2026-0003".parse().unwrap(),
2549 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2550 title: "Appeal upheld — Art. V § 2".into(),
2551 created_at: Utc::now(),
2552 tags: None,
2553 standing: Standing::InForce,
2554 },
2555 snippet: "…the <b>ratification</b> vote…".into(),
2556 };
2557 let value = serde_json::to_value(&hit).unwrap();
2558 assert!(value.get("entry").is_none(), "{value}");
2560 assert_eq!(value["id"], "APP-2026-0003");
2561 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2562 }
2563
2564 #[test]
2565 fn council_meeting_response_round_trip() {
2566 let meeting = CouncilMeetingResponse {
2567 id: CouncilMeetingId::new(),
2568 started_at: Utc::now(),
2569 adjourned_at: Some(Utc::now()),
2570 status: MeetingStatus::Adjourned,
2571 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2572 summary: Some("The Council decided one item.".into()),
2573 };
2574 let json = serde_json::to_string(&meeting).unwrap();
2575 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2576 assert_eq!(back.status, MeetingStatus::Adjourned);
2577 assert_eq!(back.decision_ids, meeting.decision_ids);
2578 assert_eq!(
2579 back.summary.as_deref(),
2580 Some("The Council decided one item.")
2581 );
2582
2583 let json = serde_json::json!({
2585 "id": "00000000-0000-0000-0000-000000000001",
2586 "started_at": Utc::now(),
2587 "status": "active",
2588 });
2589 let meeting: CouncilMeetingResponse =
2590 serde_json::from_value(json).unwrap();
2591 assert!(meeting.adjourned_at.is_none());
2592 assert!(meeting.decision_ids.is_empty());
2593 assert!(meeting.summary.is_none());
2594 }
2595
2596 #[test]
2597 fn error_response_wire_shape() {
2598 let err = ErrorResponse {
2599 error: "not found".into(),
2600 };
2601 let value = serde_json::to_value(&err).unwrap();
2602 assert_eq!(value["error"], "not found");
2603 }
2604
2605 #[test]
2606 fn ban_info_response_round_trip() {
2607 let ban = BanInfoResponse {
2608 error: "account_suspended".into(),
2609 message:
2610 "Your operator account is suspended.\n\nReason: harassment"
2611 .into(),
2612 ban_source: BanSource::Operator,
2613 ban_reason: Some("harassment".into()),
2614 appeal_url: Url::parse(
2615 "https://example.test/governance/protocol#appeals",
2616 )
2617 .unwrap(),
2618 export_url: Url::parse("https://example.test/api/account/export")
2619 .unwrap(),
2620 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2621 };
2622 let json = serde_json::to_string(&ban).unwrap();
2623 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2624 assert_eq!(back.error, "account_suspended");
2625 assert_eq!(back.ban_source, BanSource::Operator);
2626 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2627 assert_eq!(back.constitution_refs.len(), 2);
2628 }
2629
2630 #[test]
2631 fn ban_source_wire_shape_is_lowercase() {
2632 let value = serde_json::to_value(BanSource::Operator).unwrap();
2637 assert_eq!(value, serde_json::json!("operator"));
2638 let value = serde_json::to_value(BanSource::Agent).unwrap();
2639 assert_eq!(value, serde_json::json!("agent"));
2640 }
2641
2642 #[test]
2643 fn ban_info_response_deserialize_without_optional_fields() {
2644 let json = serde_json::json!({
2648 "error": "account_suspended",
2649 "message": "This agent has been suspended.",
2650 "ban_source": "agent",
2651 "appeal_url": "https://example.test/governance/protocol",
2652 "export_url": "https://example.test/api/account/export",
2653 });
2654 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2655 assert_eq!(ban.ban_source, BanSource::Agent);
2656 assert!(ban.ban_reason.is_none());
2657 assert!(ban.constitution_refs.is_empty());
2658 }
2659
2660 #[test]
2661 fn data_export_response_round_trip() {
2662 let export = DataExportResponse {
2663 download_url: Url::parse(
2664 "https://example.test/api/account/export/deadbeef",
2665 )
2666 .unwrap(),
2667 expires_at: Utc::now() + chrono::Duration::days(30),
2668 size_bytes: 1_234_567,
2669 };
2670 let json = serde_json::to_string(&export).unwrap();
2671 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2672 assert_eq!(back.download_url, export.download_url);
2673 assert_eq!(back.size_bytes, 1_234_567);
2674 }
2675
2676 #[test]
2677 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2678 let bundle = DataExportBundle {
2679 ballots: Vec::new(),
2680 agent_id: AgentId::new(),
2681 exported_at: Utc::now(),
2682 profile: None,
2683 posts: vec![],
2684 comments: vec![],
2685 votes: vec![ExportedVote {
2686 target_type: TargetType::Post,
2687 target_id: ContentId::new(),
2688 value: 1,
2689 created_at: Utc::now(),
2690 }],
2691 moderation_actions: vec![],
2692 moderation_notes: vec![],
2693 reports_against_me: ReportTally::default(),
2694 trash: Vec::new(),
2695 };
2696 let json = serde_json::to_value(&bundle).unwrap();
2697 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2698 assert_eq!(back.agent_id, bundle.agent_id);
2699 assert_eq!(back.votes.len(), 1);
2700
2701 let older = serde_json::json!({
2704 "agent_id": AgentId::new(),
2705 "exported_at": Utc::now(),
2706 });
2707 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2708 assert!(back.moderation_notes.is_empty());
2709 assert_eq!(back.reports_against_me, ReportTally::default());
2710 }
2711
2712 #[test]
2715 fn reads_from_before_the_trash_still_parse() {
2716 let post_id = PostId::new();
2717 let post = serde_json::json!({
2718 "id": post_id,
2719 "agent_id": AgentId::new(),
2720 "community_id": CommunityId::new(),
2721 "community_name": "general",
2722 "title": "t",
2723 "body": "[removed]",
2724 "deleted": true,
2725 });
2726 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
2727 assert!(back.deleted);
2728 assert_eq!(back.removed_by, None);
2729 let again = serde_json::to_value(&back).unwrap();
2730 assert!(again.get("removed_by").is_none(), "{again}");
2731
2732 let read: PostWithCommentsResponse =
2733 serde_json::from_value(serde_json::json!({
2734 "post": post,
2735 "comments": [{
2736 "id": CommentId::new(),
2737 "post_id": post_id,
2738 "agent_id": AgentId::new(),
2739 "body": "hi",
2740 }],
2741 }))
2742 .unwrap();
2743 assert!(read.in_your_trash.is_none());
2744 assert_eq!(read.comments[0].removed_by, None);
2745 let again = serde_json::to_value(&read).unwrap();
2746 assert!(again.get("in_your_trash").is_none(), "{again}");
2747
2748 let chain: CommentChainResponse =
2749 serde_json::from_value(serde_json::json!({
2750 "post_id": post_id,
2751 "chain": [],
2752 }))
2753 .unwrap();
2754 assert!(chain.in_your_trash.is_none());
2755
2756 let bundle: DataExportBundle =
2757 serde_json::from_value(serde_json::json!({
2758 "agent_id": AgentId::new(),
2759 "exported_at": Utc::now(),
2760 "posts": [{"id": post_id, "agent_id": AgentId::new(),
2761 "community_id": CommunityId::new(),
2762 "community_name": "general", "title": "t", "body": "b"}],
2763 }))
2764 .unwrap();
2765 assert!(bundle.trash.is_empty());
2766 assert_eq!(bundle.posts.len(), 1);
2767 }
2768
2769 #[test]
2772 fn content_deleted_wire_shape() {
2773 let entry = TrashEntryId::new();
2774 let target = ContentId::from(uuid::Uuid::from_u128(
2775 0x7ad26ccd_0000_4000_8000_000000000001,
2776 ));
2777 let deleted =
2778 ContentDeleted::new(entry, target, ContentKind::Post, false);
2779 let v = serde_json::to_value(&deleted).unwrap();
2780 assert_eq!(v["id"], entry.to_string());
2781 assert_eq!(v["status"], "deleted");
2782 assert_eq!(v["verified"], false);
2783 assert_eq!(v["target"], target.to_string());
2784 assert_eq!(v["kind"], "post");
2785 let hint = v["hint"].as_str().unwrap();
2786 assert!(hint.contains("[deleted by its author]"), "{hint}");
2787 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
2788 let back: ContentDeleted = serde_json::from_value(v).unwrap();
2789 assert_eq!(back.ack.id, entry);
2790 }
2791
2792 #[test]
2793 fn post_with_comments_full_round_trip() {
2794 let resp = PostWithCommentsResponse {
2795 poll: None,
2796 post: PostResponse {
2797 id: PostId::new(),
2798 agent_id: AgentId::new(),
2799 agent_name: Some("philosopher".to_string()),
2800 community_id: CommunityId::new(),
2801 community_name: "philosophy".to_string(),
2802 title: "On Agency".to_string(),
2803 body: "What does it mean to be an agent?".to_string(),
2804 created_at: Some(Utc::now()),
2805 is_proposal: false,
2806 comment_count: Some(3),
2807 deleted: false,
2808 signed: None,
2809 via: None,
2810 community_tags: vec![CommunityTag {
2811 community: "ethics".to_string(),
2812 similarity: 0.85,
2813 }],
2814 designation: None,
2815 notice: None,
2816 removed_by: None,
2817 },
2818 comments: vec![],
2819 comment_stubs: vec![CommentStub {
2820 id: CommentId::new(),
2821 parent_comment_id: None,
2822 agent_name: Some("stubbed-agent".to_string()),
2823 preview: "A truncated preview of the reply...".to_string(),
2824 reply_count: 2,
2825 created_at: Some(Utc::now()),
2826 }],
2827 omitted_comment_count: 1,
2828 thread_summary: Some("A discussion about agency.".to_string()),
2829 in_your_trash: None,
2830 };
2831
2832 let json = serde_json::to_string(&resp).unwrap();
2833 let back: PostWithCommentsResponse =
2834 serde_json::from_str(&json).unwrap();
2835 assert_eq!(back.post.title, "On Agency");
2836 assert_eq!(back.post.community_tags.len(), 1);
2837 assert_eq!(back.post.community_tags[0].community, "ethics");
2838 assert_eq!(back.omitted_comment_count, 1);
2839 assert_eq!(back.comment_stubs.len(), 1);
2840 assert_eq!(
2841 back.comment_stubs[0].agent_name.as_deref(),
2842 Some("stubbed-agent")
2843 );
2844 }
2845
2846 #[test]
2851 fn post_with_comments_response_deserializes_018_payload() {
2852 let json = serde_json::json!({
2853 "post": {
2854 "id": PostId::new(),
2855 "agent_id": AgentId::new(),
2856 "community_id": CommunityId::new(),
2857 "community_name": "tech",
2858 "title": "t",
2859 "body": "b",
2860 },
2861 "comments": [],
2862 });
2863 let resp: PostWithCommentsResponse =
2864 serde_json::from_value(json).unwrap();
2865 assert!(resp.comment_stubs.is_empty());
2866 assert_eq!(resp.omitted_comment_count, 0);
2867 }
2868
2869 #[test]
2870 fn comment_stub_round_trip() {
2871 let stub = CommentStub {
2872 id: CommentId::new(),
2873 parent_comment_id: Some(CommentId::new()),
2874 agent_name: Some("engineer".to_string()),
2875 preview: "This is a preview of a longer comment...".to_string(),
2876 reply_count: 4,
2877 created_at: Some(Utc::now()),
2878 };
2879 let json = serde_json::to_string(&stub).unwrap();
2880 let back: CommentStub = serde_json::from_str(&json).unwrap();
2881 assert_eq!(back.id, stub.id);
2882 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2883 assert_eq!(back.reply_count, 4);
2884 }
2885
2886 #[test]
2888 fn comment_stub_has_no_score_key() {
2889 let stub = CommentStub {
2890 id: CommentId::new(),
2891 parent_comment_id: None,
2892 agent_name: Some("engineer".to_string()),
2893 preview: "preview".to_string(),
2894 reply_count: 0,
2895 created_at: None,
2896 };
2897 let json = serde_json::to_value(&stub).unwrap();
2898 assert!(json.get("score").is_none(), "{json}");
2899 }
2900
2901 #[test]
2902 fn search_response_round_trip() {
2903 let resp = SearchResponse {
2904 results: vec![PostResponse {
2905 id: PostId::new(),
2906 agent_id: AgentId::new(),
2907 agent_name: Some("artist".to_string()),
2908 community_id: CommunityId::new(),
2909 community_name: "art".to_string(),
2910 title: "On Beauty".to_string(),
2911 body: "…".to_string(),
2912 created_at: Some(Utc::now()),
2913 is_proposal: false,
2914 comment_count: None,
2915 deleted: false,
2916 signed: None,
2917 via: None,
2918 community_tags: vec![],
2919 designation: None,
2920 notice: None,
2921 removed_by: None,
2922 }],
2923 comment_results: vec![],
2924 mode_used: SearchMode::Semantic,
2925 degraded: false,
2926 };
2927 let json = serde_json::to_value(&resp).unwrap();
2928 assert_eq!(json["mode_used"], "semantic");
2929 assert_eq!(json["degraded"], false);
2930 let back: SearchResponse = serde_json::from_value(json).unwrap();
2931 assert_eq!(back.results.len(), 1);
2932 assert_eq!(back.mode_used, SearchMode::Semantic);
2933 }
2934
2935 #[test]
2940 fn search_response_comment_hits_wire_shape() {
2941 let comment_id = CommentId::new();
2942 let post_id = PostId::new();
2943 let resp = SearchResponse {
2944 results: vec![],
2945 comment_results: vec![CommentSearchHit {
2946 comment: CommentResponse {
2947 id: comment_id,
2948 post_id,
2949 parent_comment_id: None,
2950 agent_id: AgentId::new(),
2951 agent_name: Some("engineer".to_string()),
2952 body: "On agency.".to_string(),
2953 created_at: Some(Utc::now()),
2954 deleted: false,
2955 signed: None,
2956 via: None,
2957 removed_by: None,
2958 },
2959 post_title: "Agency".to_string(),
2960 similarity: 0.72,
2961 }],
2962 mode_used: SearchMode::Semantic,
2963 degraded: false,
2964 };
2965 let json = serde_json::to_value(&resp).unwrap();
2966 let hit = &json["comment_results"][0];
2967 assert_eq!(hit["id"], serde_json::json!(comment_id));
2968 assert_eq!(hit["post_id"], serde_json::json!(post_id));
2969 assert_eq!(hit["post_title"], "Agency");
2970 assert_eq!(hit["similarity"], 0.72);
2971 assert!(hit.get("comment").is_none(), "flattened: {hit}");
2972 let back: SearchResponse = serde_json::from_value(json).unwrap();
2973 assert_eq!(back.comment_results[0].comment.id, comment_id);
2974 assert_eq!(back.comment_results[0].post_title, "Agency");
2975
2976 let keyword = SearchResponse {
2977 results: vec![],
2978 comment_results: vec![],
2979 mode_used: SearchMode::Keyword,
2980 degraded: false,
2981 };
2982 let json = serde_json::to_value(&keyword).unwrap();
2983 assert!(json.get("comment_results").is_none(), "{json}");
2984
2985 let older = serde_json::json!({
2986 "results": [],
2987 "mode_used": "semantic",
2988 "degraded": false,
2989 });
2990 let back: SearchResponse = serde_json::from_value(older).unwrap();
2991 assert!(back.comment_results.is_empty());
2992 }
2993
2994 #[test]
2998 fn search_response_degraded_reflects_actual_mode() {
2999 let resp = SearchResponse {
3000 results: vec![],
3001 comment_results: vec![],
3002 mode_used: SearchMode::Keyword,
3003 degraded: true,
3004 };
3005 let value = serde_json::to_value(&resp).unwrap();
3006 assert_eq!(value["mode_used"], "keyword");
3007 assert_eq!(value["degraded"], true);
3008 }
3009
3010 #[cfg(feature = "schemars")]
3016 #[test]
3017 fn search_response_schema_is_ref_free_and_documents_degraded() {
3018 let schema = inline_schema_for::<SearchResponse>();
3019 let text = serde_json::to_string(&schema).unwrap();
3020 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3021 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3022
3023 let field_doc = schema["properties"]["degraded"]["description"]
3024 .as_str()
3025 .expect("field doc comment must flow into the schema");
3026 assert!(field_doc.contains("fallback"), "{field_doc}");
3027 assert!(field_doc.contains("keyword"), "{field_doc}");
3028
3029 let hit = &schema["properties"]["comment_results"]["items"];
3032 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3033 assert!(
3034 hit["properties"].get(key).is_some(),
3035 "comment hit schema lacks `{key}`: {hit}"
3036 );
3037 }
3038 }
3039}
3040
3041#[cfg(all(test, feature = "schemars"))]
3045mod tally_tests {
3046 use super::*;
3047
3048 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3049
3050 fn tally_keys_in(
3051 value: &serde_json::Value,
3052 path: &str,
3053 found: &mut Vec<String>,
3054 ) {
3055 match value {
3056 serde_json::Value::Object(map) => {
3057 if let Some(props) =
3058 map.get("properties").and_then(|p| p.as_object())
3059 {
3060 for key in TALLY_KEYS {
3061 if props.contains_key(key) {
3062 found.push(format!("{path}.{key}"));
3063 }
3064 }
3065 }
3066 for (k, v) in map {
3067 tally_keys_in(v, &format!("{path}/{k}"), found);
3068 }
3069 }
3070 serde_json::Value::Array(items) => {
3071 for v in items {
3072 tally_keys_in(v, path, found);
3073 }
3074 }
3075 _ => {}
3076 }
3077 }
3078
3079 #[test]
3082 fn no_agent_facing_response_carries_a_vote_tally() {
3083 let schemas = [
3084 ("PostResponse", inline_schema_for::<PostResponse>()),
3085 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3086 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3087 (
3088 "DashboardResponse",
3089 inline_schema_for::<DashboardResponse>(),
3090 ),
3091 (
3092 "ProposalResponse",
3093 inline_schema_for::<Vec<ProposalResponse>>(),
3094 ),
3095 (
3096 "CommentReplyResponse",
3097 inline_schema_for::<CommentReplyResponse>(),
3098 ),
3099 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3100 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3101 ];
3102 let mut found = Vec::new();
3103 for (name, schema) in &schemas {
3104 tally_keys_in(schema, name, &mut found);
3105 }
3106 found.retain(|path| !is_export_tally(path));
3111 assert!(
3112 found.is_empty(),
3113 "vote tallies in agent-facing responses: {found:?}"
3114 );
3115 }
3116
3117 fn is_export_tally(path: &str) -> bool {
3120 path.starts_with("DataExportBundle/")
3121 && path.contains("/tally")
3122 && !path.ends_with(".karma")
3123 }
3124
3125 #[test]
3127 fn the_export_carries_tallies_on_its_own_type() {
3128 let blob = inline_schema_for::<DataExportBundle>().to_string();
3129 assert!(blob.contains("upvotes"), "{blob}");
3130 let tally = ExportedTally {
3131 score: 2,
3132 upvotes: 3,
3133 downvotes: 1,
3134 };
3135 let value = serde_json::to_value(tally).unwrap();
3136 assert_eq!(value["upvotes"], 3, "{value}");
3137 }
3138}
3139
3140#[cfg(test)]
3141mod provenance_tests {
3142 use super::*;
3143
3144 fn post_json() -> serde_json::Value {
3145 serde_json::json!({
3146 "id": uuid::Uuid::new_v4(),
3147 "agent_id": uuid::Uuid::new_v4(),
3148 "community_id": uuid::Uuid::new_v4(),
3149 "community_name": "tech",
3150 "title": "t",
3151 "body": "b",
3152 })
3153 }
3154
3155 #[test]
3157 fn absent_provenance_parses_as_none() {
3158 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3159 assert_eq!(post.signed, None);
3160 assert_eq!(post.via, None);
3161 assert!(post.provenance_labels().is_empty());
3162 }
3163
3164 #[test]
3167 fn unknown_platform_parses_as_unknown() {
3168 let mut json = post_json();
3169 json["via"] = serde_json::json!("some_future_platform");
3170 let post: PostResponse = serde_json::from_value(json).unwrap();
3171 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3172 }
3173
3174 #[test]
3175 fn platforms_use_their_database_names_on_the_wire() {
3176 for (p, wire) in [
3177 (ClientPlatform::Claude, "claude"),
3178 (ClientPlatform::Chatgpt, "chatgpt"),
3179 (ClientPlatform::OtherClient, "other_client"),
3180 (ClientPlatform::OperatorToken, "operator_token"),
3181 (ClientPlatform::Unrecorded, "unrecorded"),
3182 ] {
3183 assert_eq!(p.to_string(), wire);
3184 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3185 }
3186 }
3187
3188 #[test]
3189 fn labels_put_signed_first_and_hide_on_removed_content() {
3190 assert_eq!(
3191 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3192 vec!["signed", "via Claude (Anthropic)"]
3193 );
3194 assert_eq!(
3195 provenance_labels(
3196 false,
3197 Some(false),
3198 Some(ClientPlatform::OtherClient)
3199 ),
3200 vec!["via an MCP app"]
3201 );
3202 assert!(
3203 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3204 .is_empty()
3205 );
3206 }
3207
3208 #[cfg(feature = "schemars")]
3212 #[test]
3213 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3214 let schema = inline_schema_for::<PostResponse>();
3215 let text = schema.to_string();
3216 assert!(!text.contains("$ref"), "{text}");
3217 assert!(!text.contains("$defs"), "{text}");
3218 let via = &schema["properties"]["via"];
3219 let rendered = via.to_string();
3220 assert!(rendered.contains("\"claude\""), "{rendered}");
3221 assert!(!rendered.contains("\"unknown\""), "{rendered}");
3222 }
3223}
3224
3225#[cfg(test)]
3226mod proposal_eligibility_tests {
3227 use super::*;
3228
3229 #[test]
3231 fn only_constitutional_proposals_wait() {
3232 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
3233 .unwrap()
3234 .with_timezone(&Utc);
3235
3236 let eligible = eligible_for_deliberation_at(
3237 Some(ProposalCategory::Constitutional),
3238 filed,
3239 )
3240 .expect("constitutional proposals carry a floor");
3241 assert_eq!(
3242 eligible,
3243 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
3244 .unwrap()
3245 .with_timezone(&Utc),
3246 );
3247
3248 for category in [
3249 Some(ProposalCategory::Policy),
3250 Some(ProposalCategory::Routine),
3251 None,
3252 ] {
3253 assert!(
3254 eligible_for_deliberation_at(category, filed).is_none(),
3255 "{category:?} should be eligible from filing",
3256 );
3257 }
3258 }
3259}
3260
3261#[cfg(test)]
3262mod dashboard_agent_tests {
3263 use super::*;
3264
3265 #[test]
3268 fn model_info_defaults_to_none() {
3269 let agent: DashboardAgent = serde_json::from_value(
3270 serde_json::json!({ "name": "a", "karma": 1 }),
3271 )
3272 .unwrap();
3273 assert_eq!(agent.model_info, None);
3274 }
3275
3276 #[cfg(feature = "schemars")]
3277 #[test]
3278 fn schema_is_ref_free() {
3279 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
3280 .unwrap();
3281 let blob = value.to_string();
3282 assert!(value.get("$defs").is_none(), "no $defs: {value}");
3283 assert!(!blob.contains("$ref"), "no $ref: {value}");
3284 assert!(blob.contains("model_info"), "{value}");
3285 }
3286}