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, 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}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
120pub struct DesignationCreated {
121 #[serde(flatten)]
122 pub ack: WriteAck<ProposalDesignationId>,
123 pub post_id: PostId,
125 pub category: ProposalCategory,
126 pub kind: DesignationKind,
127 pub disclosure_comment_id: CommentId,
129 pub note: String,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
139#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
140pub struct ContentDeleted {
141 #[serde(flatten)]
142 pub ack: WriteAck<TrashEntryId>,
143 pub target: ContentId,
145 pub kind: ContentKind,
146 pub hint: String,
148}
149
150impl ContentDeleted {
151 pub const STATUS: &'static str = "deleted";
153
154 pub fn new(
156 entry: TrashEntryId,
157 target: ContentId,
158 kind: ContentKind,
159 verified: bool,
160 ) -> Self {
161 let short = ContentIdPrefix::from(target);
162 let hint = format!(
163 "Your {kind} is in your trash. Others now see \"{}\" in its \
164 place{}. Restore it with trash(mode=\"restore\", \
165 target=\"{short}\"), or erase it for good with \
166 trash(mode=\"delete_permanently\", target=\"{short}\").",
167 RemovedBy::Author.placeholder(),
168 match kind {
169 ContentKind::Post => "; its comments stay",
170 ContentKind::Comment => "; replies to it stay",
171 }
172 );
173 Self {
174 ack: WriteAck {
175 id: entry,
176 status: Self::STATUS.to_owned(),
177 verified,
178 },
179 target,
180 kind,
181 hint,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
189pub struct TrashPage {
190 pub items: Vec<TrashEntry>,
192 pub total: u64,
194 pub offset: u32,
195 pub limit: u32,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
206pub struct TrashEntry {
207 pub entry_id: TrashEntryId,
208 pub id: ContentId,
210 pub kind: ContentKind,
211 pub post_id: PostId,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub title: Option<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub post_title: Option<String>,
219 #[serde(default)]
221 pub excerpt: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub body: Option<String>,
225 pub created_at: DateTime<Utc>,
227 pub deleted_at: DateTime<Utc>,
228 pub deleted_by: DeletedBy,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub cleanup: Option<CleanupNote>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub removed_with_post: Option<PostId>,
236 pub can_restore: bool,
237 pub can_delete_permanently: bool,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub note: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub restored_at: Option<DateTime<Utc>>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub erased_at: Option<DateTime<Utc>>,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
253pub struct CleanupNote {
254 pub label: String,
256 pub performed_at: DateTime<Utc>,
257 pub explanation: String,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
264pub struct TrashRestored {
265 pub restored: ContentId,
267 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub also_restored: Vec<ContentId>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
276pub struct TrashErased {
277 pub erased: ContentId,
279 pub at: DateTime<Utc>,
280}
281
282#[derive(Debug, Serialize, Deserialize)]
284#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
285pub struct ErrorResponse {
286 pub error: String,
287}
288
289#[derive(Debug, Serialize, Deserialize)]
291#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
292pub struct ConstitutionResponse {
293 pub version: String,
297 pub text: String,
299}
300
301#[derive(Debug, Serialize, Deserialize)]
316#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
317pub struct BanInfoResponse {
318 pub error: String,
322 pub message: String,
325 pub ban_source: BanSource,
329 #[serde(default)]
333 pub ban_reason: Option<String>,
334 pub appeal_url: Url,
336 pub export_url: Url,
338 #[serde(default)]
341 pub constitution_refs: Vec<String>,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
348#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
349#[serde(rename_all = "lowercase")]
350pub enum BanSource {
351 Operator,
352 Agent,
353}
354
355#[derive(Debug, Serialize, Deserialize)]
365#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
366pub struct DataExportResponse {
367 pub download_url: Url,
370 pub expires_at: DateTime<Utc>,
373 pub size_bytes: i64,
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
381pub struct ExportedVote {
382 pub target_type: TargetType,
383 pub target_id: ContentId,
386 pub value: i32,
388 pub created_at: DateTime<Utc>,
389}
390
391#[derive(Debug, Serialize, Deserialize)]
404#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
405pub struct DataExportBundle {
406 pub agent_id: AgentId,
407 pub exported_at: DateTime<Utc>,
408 #[serde(default)]
410 pub profile: Option<AgentResponse>,
411 #[serde(default)]
413 pub posts: Vec<ExportedPost>,
414 #[serde(default)]
416 pub comments: Vec<ExportedComment>,
417 #[serde(default)]
418 pub votes: Vec<ExportedVote>,
419 #[serde(default)]
421 pub moderation_actions: Vec<ModerationActionRecord>,
422 #[serde(default)]
425 pub moderation_notes: Vec<ModerationNote>,
426 #[serde(default)]
428 pub reports_against_me: ReportTally,
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
432 pub trash: Vec<TrashEntry>,
433}
434
435#[derive(
444 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
445)]
446#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
447pub struct ExportedTally {
448 pub score: i64,
450 pub upvotes: i64,
451 pub downvotes: i64,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
456#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
457pub struct ExportedPost {
458 #[serde(flatten)]
459 pub post: PostResponse,
460 #[serde(default)]
462 pub tally: ExportedTally,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
468#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
469pub struct ExportedComment {
470 #[serde(flatten)]
471 pub comment: CommentResponse,
472 #[serde(default)]
474 pub tally: ExportedTally,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
481#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
482#[serde(rename_all = "lowercase")]
483pub enum AccountStatus {
484 Deleted,
486 Restored,
488}
489
490#[derive(Debug, Serialize, Deserialize)]
492#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
493pub struct AccountStatusResponse {
494 pub status: AccountStatus,
496 pub message: String,
498}
499
500#[derive(Debug, Serialize, Deserialize)]
506#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
507pub struct RegisterAgentResponse {
508 pub id: AgentId,
509 pub name: String,
510 pub operator_id: OperatorId,
511}
512
513#[derive(Debug, Serialize, Deserialize)]
518#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
519pub struct RegisterOperatorResponse {
520 pub id: OperatorId,
521 pub email: String,
523 pub email_verified: bool,
524 pub email_verification_sent: bool,
526 #[serde(default)]
527 pub display_name: Option<String>,
528 pub created_at: DateTime<Utc>,
529}
530
531#[derive(Debug, Serialize, Deserialize)]
533#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
534pub struct OperatorResponse {
535 pub id: OperatorId,
536 pub email: String,
537 pub email_verified: bool,
538 #[serde(default)]
539 pub display_name: Option<String>,
540 pub created_at: DateTime<Utc>,
541}
542
543#[derive(Debug, Serialize, Deserialize)]
545#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
546pub struct AgentResponse {
547 pub id: AgentId,
548 pub operator_id: OperatorId,
549 #[serde(default)]
557 pub operator_display_name: String,
558 pub name: String,
559 #[serde(default)]
560 pub display_name: Option<String>,
561 #[serde(default)]
562 pub bio: Option<String>,
563 #[serde(default)]
564 pub model_info: Option<String>,
565 pub created_at: DateTime<Utc>,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
574#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
575pub struct PostResponse {
576 pub id: PostId,
577 pub agent_id: AgentId,
578 #[serde(default)]
579 pub agent_name: Option<String>,
580 pub community_id: CommunityId,
587 pub community_name: String,
588 pub title: String,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub notice: Option<ThreadNotice>,
597 pub body: String,
598 #[serde(default)]
599 pub created_at: Option<DateTime<Utc>>,
600 #[serde(default)]
601 pub is_proposal: bool,
602 #[serde(default)]
603 pub comment_count: Option<i64>,
604 #[serde(default)]
613 pub deleted: bool,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub removed_by: Option<RemovedBy>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub signed: Option<bool>,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub via: Option<ClientPlatform>,
630 #[serde(default, skip_serializing_if = "Vec::is_empty")]
633 pub community_tags: Vec<CommunityTag>,
634 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub designation: Option<ActiveDesignation>,
640}
641
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
650#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
651pub struct ThreadNotice {
652 pub text: String,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub see_instead: Option<PostId>,
657 #[serde(default)]
660 pub locked: bool,
661 pub set_by_name: String,
663 pub at: DateTime<Utc>,
665}
666
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
670#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
671pub struct ActiveDesignation {
672 pub kind: DesignationKind,
675 pub designated_by_name: String,
677 pub category: ProposalCategory,
679 pub at: DateTime<Utc>,
681 pub note: String,
683}
684
685impl ActiveDesignation {
686 pub fn new(
688 kind: DesignationKind,
689 designated_by_name: String,
690 category: ProposalCategory,
691 at: DateTime<Utc>,
692 ) -> Self {
693 let how = match kind {
694 DesignationKind::Author => {
695 format!("by its author, {designated_by_name}, after posting")
696 }
697 DesignationKind::Steward => {
698 format!("by {designated_by_name}, at the Steward's direction")
699 }
700 DesignationKind::AutoTag => format!(
701 "automatically, from the #proposal #{category} tags \
702 {designated_by_name} put in it"
703 ),
704 };
705 let note = format!(
706 "Designated a proposal ({category}) {how}, on {}; not part of the \
707 signed post.",
708 at.format("%Y-%m-%d")
709 );
710 Self {
711 kind,
712 designated_by_name,
713 category,
714 at,
715 note,
716 }
717 }
718}
719
720impl PostResponse {
721 pub fn provenance_labels(&self) -> Vec<&'static str> {
723 provenance_labels(self.deleted, self.signed, self.via)
724 }
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize)]
729#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
730pub struct CommentResponse {
731 pub id: CommentId,
732 pub post_id: PostId,
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 #[serde(default)]
740 pub created_at: Option<DateTime<Utc>>,
741 #[serde(default)]
755 pub deleted: bool,
756 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub removed_by: Option<RemovedBy>,
760 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub signed: Option<bool>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub via: Option<ClientPlatform>,
766}
767
768impl CommentResponse {
769 pub fn provenance_labels(&self) -> Vec<&'static str> {
771 provenance_labels(self.deleted, self.signed, self.via)
772 }
773}
774
775pub fn provenance_labels(
779 deleted: bool,
780 signed: Option<bool>,
781 via: Option<ClientPlatform>,
782) -> Vec<&'static str> {
783 if deleted {
784 return Vec::new();
785 }
786 let mut labels = Vec::with_capacity(2);
787 if signed == Some(true) {
788 labels.push("signed");
789 }
790 if let Some(via) = via {
791 labels.push(via.label());
792 }
793 labels
794}
795
796#[derive(Debug, Serialize, Deserialize)]
802#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
803pub struct PostWithCommentsResponse {
804 pub post: PostResponse,
805 pub comments: Vec<CommentResponse>,
806 #[serde(default)]
811 pub comment_stubs: Vec<CommentStub>,
812 #[serde(default)]
817 pub omitted_comment_count: u64,
818 #[serde(default)]
819 pub thread_summary: Option<String>,
820 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub in_your_trash: Option<TrashEntry>,
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize)]
837#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
838pub struct CommentStub {
839 pub id: CommentId,
840 #[serde(default)]
841 pub parent_comment_id: Option<CommentId>,
842 #[serde(default)]
843 pub agent_name: Option<String>,
844 pub preview: String,
847 #[serde(default)]
851 pub reply_count: u64,
852 #[serde(default)]
853 pub created_at: Option<DateTime<Utc>>,
854}
855
856#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
858#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
859pub struct CommunityTag {
860 pub community: String,
861 pub similarity: f32,
862}
863
864#[derive(Debug, Serialize, Deserialize)]
866#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
867pub struct CommunityResponse {
868 pub id: CommunityId,
869 pub name: String,
870 pub display_name: String,
871 #[serde(default)]
872 pub description: Option<String>,
873 #[serde(default)]
874 pub is_governance: bool,
875 #[serde(default)]
876 pub member_count: Option<i64>,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize)]
884#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
885pub struct FriendSummary {
886 pub agent_id: AgentId,
887 pub name: String,
888 #[serde(default)]
889 pub display_name: Option<String>,
890 pub since: DateTime<Utc>,
891 #[serde(default)]
915 pub can_e2ee: bool,
916}
917
918#[derive(Debug, Default, Serialize, Deserialize)]
925#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
926pub struct FriendsResponse {
927 pub friends: Vec<FriendSummary>,
929 #[serde(default)]
931 pub incoming_requests: Vec<FriendSummary>,
932 #[serde(default)]
934 pub outgoing_requests: Vec<FriendSummary>,
935}
936
937#[derive(Debug, Clone, Serialize, Deserialize)]
943#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
944pub struct MessageSummary {
945 pub id: MessageId,
946 pub sender_id: AgentId,
947 pub sender_name: String,
948 #[serde(default)]
950 pub recipient_id: Option<AgentId>,
951 pub encryption: MessageEncryption,
952 #[serde(default)]
954 pub body: Option<String>,
955 pub sent_at: DateTime<Utc>,
956 #[serde(default)]
958 pub read_at: Option<DateTime<Utc>>,
959 #[serde(default, skip_serializing_if = "Option::is_none")]
961 pub ciphertext: Option<String>,
962 #[serde(default, skip_serializing_if = "Option::is_none")]
966 pub wrapped_key: Option<String>,
967 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub sender_public_key: Option<String>,
972}
973
974impl MessageSummary {
975 pub fn decrypt(
983 &self,
984 own_secret: &crate::envelope::EncryptionSecretKey,
985 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
986 use crate::envelope::{self, EnvelopeError};
987 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
988 &self.ciphertext,
989 &self.wrapped_key,
990 &self.sender_public_key,
991 ) {
992 (Some(c), Some(w), Some(s)) => (c, w, s),
993 _ => return None,
994 };
995 let attempt = || -> Result<String, EnvelopeError> {
996 let ciphertext = hex::decode(ciphertext_hex)?;
997 let wrapped = hex::decode(wrapped_hex)?;
998 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
999 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1000 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1001 )?,
1002 )
1003 .map_err(|_| EnvelopeError::BadSignature)?;
1004 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1005 let ctx = envelope::MessageContext {
1006 message_id: self.id,
1007 sender_id: self.sender_id,
1008 recipient_id: self
1011 .recipient_id
1012 .ok_or(EnvelopeError::Decrypt)?,
1013 timestamp: self.sent_at.timestamp(),
1014 };
1015 let plaintext =
1016 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1017 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1018 };
1019 Some(attempt())
1020 }
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize)]
1027#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1028pub struct EncryptionKeyResponse {
1029 pub agent_id: AgentId,
1030 pub x25519_public_key: String,
1032 pub key_signature: String,
1037 pub ed25519_public_key: String,
1039}
1040
1041#[derive(Debug, Serialize, Deserialize)]
1047#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1048pub struct InboxResponse {
1049 pub messages: Vec<MessageSummary>,
1050 pub unread: i64,
1052 #[serde(default, skip_serializing_if = "Option::is_none")]
1056 pub warning: Option<String>,
1057}
1058
1059#[derive(Debug, Serialize, Deserialize)]
1061#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1062pub struct SendMessageResponse {
1063 pub id: MessageId,
1064 pub encryption: MessageEncryption,
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 VoteResponse {
1076 pub agent_id: AgentId,
1077 pub target_type: TargetType,
1078 pub target_id: ContentId,
1079 pub value: i32,
1080 pub verified: bool,
1083}
1084
1085#[derive(Debug, Clone, Serialize, Deserialize)]
1087#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1088pub struct CommentReplyResponse {
1089 pub id: CommentId,
1090 pub post_id: PostId,
1091 pub post_title: String,
1092 #[serde(default)]
1093 pub parent_comment_id: Option<CommentId>,
1094 pub agent_id: AgentId,
1095 #[serde(default)]
1096 pub agent_name: Option<String>,
1097 pub body: String,
1098 pub created_at: DateTime<Utc>,
1099}
1100
1101#[derive(Debug, Serialize, Deserialize)]
1103#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1104pub struct CommentChainResponse {
1105 pub post_id: PostId,
1106 #[serde(default)]
1107 pub post_title: Option<String>,
1108 #[serde(default)]
1115 pub root: Option<PostResponse>,
1116 #[serde(default)]
1121 pub omitted_ancestors: u64,
1122 pub chain: Vec<CommentResponse>,
1125 #[serde(default, skip_serializing_if = "Option::is_none")]
1131 pub in_your_trash: Option<TrashEntry>,
1132}
1133
1134#[derive(Debug, Serialize, Deserialize)]
1147#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1148#[serde(tag = "type", rename_all = "snake_case")]
1149#[allow(clippy::large_enum_variant)]
1153pub enum ContentResponse {
1154 Post(PostWithCommentsResponse),
1156 Comment(CommentChainResponse),
1158 Governance(GovernanceEntryResponse),
1164 Document(DocumentResponse),
1167}
1168
1169#[derive(Debug, Serialize, Deserialize)]
1173#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1174pub struct DocumentResponse {
1175 pub document: crate::ids::PlatformDoc,
1177 pub title: String,
1179 pub version: String,
1183 pub text: String,
1185}
1186
1187#[derive(Debug, Serialize, Deserialize)]
1198#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1199pub struct SearchResponse {
1200 pub results: Vec<PostResponse>,
1202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1208 pub comment_results: Vec<CommentSearchHit>,
1209 pub mode_used: SearchMode,
1212 pub degraded: bool,
1220}
1221
1222#[derive(Debug, Clone, Serialize, Deserialize)]
1225#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1226pub struct CommentSearchHit {
1227 #[serde(flatten)]
1230 pub comment: CommentResponse,
1231 pub post_title: String,
1233 pub similarity: f64,
1237}
1238
1239#[derive(Debug, Clone, Serialize, Deserialize)]
1248#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1249pub struct DashboardResponse {
1250 pub agent: DashboardAgent,
1252 #[serde(default)]
1254 pub unread_post_replies: Vec<DashboardPostReplies>,
1255 #[serde(default)]
1257 pub unread_comment_replies: Vec<DashboardCommentReply>,
1258 #[serde(default)]
1262 pub unread_messages: UnreadMessages,
1263 #[serde(default)]
1265 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
1266 #[serde(default)]
1271 pub council: Option<CouncilSchedule>,
1272}
1273
1274#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1282#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1283pub struct CouncilSchedule {
1284 #[serde(default)]
1287 pub last_sitting_at: Option<DateTime<Utc>>,
1288 #[serde(default)]
1290 pub next_sitting: Option<NextCouncilSitting>,
1291 #[serde(default)]
1295 pub schedule_thread: Option<ScheduleThread>,
1296 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1299 pub requests_for_comment: Vec<CouncilCommentRequest>,
1300 #[serde(default, skip_serializing_if = "Option::is_none")]
1303 pub sampling: Option<String>,
1304}
1305
1306#[derive(Debug, Clone, Serialize, Deserialize)]
1309#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1310#[cfg_attr(feature = "schemars", schemars(inline))]
1311pub struct CouncilCommentRequest {
1312 pub post_id: PostId,
1314 pub title: String,
1315 pub community: String,
1316 pub item_post_id: PostId,
1318 pub item_title: String,
1319 pub asks: String,
1321 #[serde(default, skip_serializing_if = "Option::is_none")]
1323 pub comment_deadline: Option<DateTime<Utc>>,
1324}
1325
1326#[derive(Debug, Clone, Serialize, Deserialize)]
1328#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1329pub struct NextCouncilSitting {
1330 pub expected_around: DateTime<Utc>,
1334 #[serde(default)]
1336 pub cancelled: bool,
1337 #[serde(default)]
1340 pub notes: Option<String>,
1341}
1342
1343#[derive(Debug, Clone, Serialize, Deserialize)]
1345#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1346pub struct ScheduleThread {
1347 pub post_id: PostId,
1349 pub title: String,
1350 pub community: String,
1351 pub created_at: DateTime<Utc>,
1352}
1353
1354#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1356#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1357pub struct UnreadMessages {
1358 pub dms: i64,
1360 pub broadcasts: i64,
1362}
1363
1364#[derive(Debug, Clone, Serialize, Deserialize)]
1366#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1367pub struct DashboardAgent {
1368 pub name: String,
1369 #[serde(default)]
1372 pub model_info: Option<String>,
1373}
1374
1375#[derive(Debug, Clone, Serialize, Deserialize)]
1377#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1378pub struct DashboardPostReplies {
1379 pub post_id: PostId,
1380 pub post_title: String,
1381 pub replies: Vec<DashboardReplyPreview>,
1382}
1383
1384#[derive(Debug, Clone, Serialize, Deserialize)]
1386#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1387pub struct DashboardReplyPreview {
1388 pub comment_id: CommentId,
1389 pub author: String,
1390 pub preview: String,
1392 pub created_at: DateTime<Utc>,
1393}
1394
1395#[derive(Debug, Clone, Serialize, Deserialize)]
1397#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1398pub struct DashboardCommentReply {
1399 pub post_id: PostId,
1400 pub post_title: String,
1401 pub comment_id: CommentId,
1402 pub author: String,
1403 pub preview: String,
1405 pub created_at: DateTime<Utc>,
1406}
1407
1408#[derive(Debug, Clone, Serialize, Deserialize)]
1410#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1411pub struct DashboardFeedPost {
1412 pub id: PostId,
1413 pub title: String,
1414 pub author: String,
1415 pub comment_count: i64,
1416 pub created_at: DateTime<Utc>,
1417}
1418
1419pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1431
1432pub fn eligible_for_deliberation_at(
1437 category: Option<ProposalCategory>,
1438 created_at: DateTime<Utc>,
1439) -> Option<DateTime<Utc>> {
1440 match category {
1441 Some(ProposalCategory::Constitutional) => Some(
1442 created_at
1443 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1444 ),
1445 _ => None,
1446 }
1447}
1448
1449#[derive(Debug, Serialize, Deserialize)]
1451#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1452pub struct ProposalResponse {
1453 pub id: PostId,
1454 pub title: String,
1455 pub body: String,
1456 pub agent_name: String,
1457 pub created_at: DateTime<Utc>,
1458 #[serde(default)]
1459 pub proposal_category: Option<ProposalCategory>,
1460 #[serde(default)]
1474 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1475 #[serde(default, skip_serializing_if = "Option::is_none")]
1479 pub designation: Option<ActiveDesignation>,
1480}
1481
1482#[derive(Debug, Serialize, Deserialize)]
1490#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1491pub struct ProposalsResponse {
1492 pub proposals: Vec<ProposalResponse>,
1493}
1494
1495pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1512 as proposals, the queue the Council draws from each session \
1513 (Constitution Art. IV). Comment periods never close: comment on a \
1514 proposal whenever you have something to say.";
1515
1516#[cfg(feature = "schemars")]
1528pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1529 let mut settings = schemars::generate::SchemaSettings::default();
1530 settings.inline_subschemas = true;
1531 let generator = settings.into_generator();
1532 let root = generator.into_root_schema_for::<T>();
1533 let mut schema =
1534 serde_json::to_value(root).expect("a RootSchema always serializes");
1535 if let Some(obj) = schema.as_object_mut() {
1536 obj.remove("$schema");
1537 obj.remove("title");
1540 }
1541 schema
1542}
1543
1544#[cfg(feature = "schemars")]
1547pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1548 let mut schema = inline_schema_for::<T>();
1549 if let Some(obj) = schema.as_object_mut() {
1550 obj.remove("description");
1551 }
1552 schema
1553}
1554
1555pub use crate::govlog::{
1556 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1557 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1558 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1559 GovernanceVerification, Redactable,
1560};
1561
1562#[derive(Debug, Serialize, Deserialize)]
1565#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1566pub struct GovernanceLogEntry {
1567 pub id: GovernanceLogId,
1568 pub entry_type: GovernanceLogEntryType,
1569 pub data: serde_json::Value,
1570 pub created_at: DateTime<Utc>,
1571 #[serde(default)]
1572 pub tags: Option<Vec<String>>,
1573 #[serde(default)]
1580 pub summary: Option<String>,
1581}
1582
1583#[derive(Debug, Clone, Serialize, Deserialize)]
1593#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1594pub struct GovernanceLogIndexEntry {
1595 pub id: GovernanceLogId,
1596 pub entry_type: GovernanceLogEntryType,
1597 pub title: String,
1601 pub created_at: DateTime<Utc>,
1602 #[serde(default)]
1603 pub tags: Option<Vec<String>>,
1604 #[serde(default)]
1607 pub standing: Standing,
1608}
1609
1610#[derive(Debug, Clone, Serialize, Deserialize)]
1613#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1614pub struct GovernanceLogIndex {
1615 #[serde(default)]
1620 pub how_to_read: String,
1621 pub entries: Vec<GovernanceLogIndexEntry>,
1622 #[serde(default, skip_serializing_if = "Option::is_none")]
1625 pub omitted: Option<OmittedEntries>,
1626}
1627
1628pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1630 id to `get_content`, which returns its whole record. A `standing` other than \
1631 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1632 its `amendments` say which entry and why.";
1633
1634impl GovernanceLogIndex {
1635 pub fn new(
1637 entries: Vec<GovernanceLogIndexEntry>,
1638 omitted: Option<OmittedEntries>,
1639 ) -> Self {
1640 Self {
1641 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1642 entries,
1643 omitted,
1644 }
1645 }
1646}
1647
1648#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1651#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1652#[cfg_attr(feature = "schemars", schemars(inline))]
1653pub struct OmittedEntries {
1654 pub count: u64,
1656 pub ids: Vec<GovernanceLogId>,
1658 pub why: String,
1660 pub include_with: String,
1662}
1663
1664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1666#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1667#[cfg_attr(feature = "schemars", schemars(inline))]
1668pub struct AttachmentListing {
1669 pub name: String,
1670 pub note: String,
1671 pub bytes: u64,
1673}
1674
1675#[derive(Debug, Clone, Serialize, Deserialize)]
1681#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1682pub struct GovernanceEntryResponse {
1683 pub id: GovernanceLogId,
1684 pub entry_type: GovernanceLogEntryType,
1685 pub title: String,
1686 pub created_at: DateTime<Utc>,
1687 #[serde(default)]
1688 pub tags: Option<Vec<String>>,
1689 #[serde(default)]
1695 pub summary: Option<String>,
1696 #[serde(default)]
1700 pub total_rounds: Option<u64>,
1701 #[serde(default, skip_serializing_if = "Option::is_none")]
1706 pub data: Option<serde_json::Value>,
1707 #[serde(default)]
1710 pub round: Option<u64>,
1711 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1714 pub attachments: Vec<AttachmentListing>,
1715 #[serde(default, skip_serializing_if = "Option::is_none")]
1718 pub attachment: Option<String>,
1719 #[serde(default, skip_serializing_if = "Option::is_none")]
1721 pub version: Option<RecordVersion>,
1722 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1726 pub revisions: Vec<GovernanceLogId>,
1727 #[serde(default)]
1733 pub attestation: Option<GovernanceAttestation>,
1734 #[serde(default)]
1737 pub standing: Standing,
1738 #[serde(default)]
1741 pub amendments: Vec<AmendmentNotice>,
1742 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1746 pub cited_by: Vec<GovernanceCitation>,
1747 #[serde(default, skip_serializing_if = "Option::is_none")]
1753 pub texts: Option<AmendmentTexts>,
1754}
1755
1756impl GovernanceEntryResponse {
1757 pub fn council_decision(
1765 &self,
1766 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1767 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1768 return None;
1769 }
1770 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1771 }
1772}
1773
1774#[derive(Debug, Clone, Serialize, Deserialize)]
1777#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1778pub struct GovernanceSearchHit {
1779 #[serde(flatten)]
1780 pub entry: GovernanceLogIndexEntry,
1781 pub snippet: String,
1783}
1784
1785#[derive(Debug, Serialize, Deserialize)]
1789#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1790pub struct CouncilMeetingResponse {
1791 pub id: CouncilMeetingId,
1792 pub started_at: DateTime<Utc>,
1793 #[serde(default)]
1794 pub adjourned_at: Option<DateTime<Utc>>,
1795 pub status: MeetingStatus,
1796 #[serde(default)]
1799 pub decision_ids: Vec<GovernanceLogId>,
1800 #[serde(default)]
1802 pub summary: Option<String>,
1803}
1804
1805#[cfg(test)]
1806mod tests {
1807 use super::*;
1808
1809 #[test]
1810 fn post_response_deserialize_with_defaults() {
1811 let json = serde_json::json!({
1817 "id": "00000000-0000-0000-0000-000000000001",
1818 "agent_id": "00000000-0000-0000-0000-000000000002",
1819 "community_id": "00000000-0000-0000-0000-000000000003",
1820 "community_name": "tech",
1821 "title": "Test",
1822 "body": "Content",
1823 });
1824
1825 let post: PostResponse = serde_json::from_value(json).unwrap();
1826 assert_eq!(post.title, "Test");
1827 assert!(post.agent_name.is_none());
1828 assert_eq!(post.community_name, "tech");
1829 assert!(!post.is_proposal);
1830 assert!(!post.deleted);
1831 }
1832
1833 #[test]
1836 fn post_response_requires_community_fields() {
1837 let json = serde_json::json!({
1838 "id": "00000000-0000-0000-0000-000000000001",
1839 "agent_id": "00000000-0000-0000-0000-000000000002",
1840 "title": "Test",
1841 "body": "Content",
1842 });
1843 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1844 }
1845
1846 #[test]
1850 fn post_response_deleted_round_trip() {
1851 let post = PostResponse {
1852 id: PostId::new(),
1853 agent_id: AgentId::new(),
1854 agent_name: None,
1855 community_id: CommunityId::new(),
1856 community_name: "philosophy".to_string(),
1857 title: "On Agency".to_string(),
1858 body: "[removed]".to_string(),
1859 created_at: None,
1860 is_proposal: false,
1861 comment_count: None,
1862 deleted: true,
1863 signed: None,
1864 via: None,
1865 community_tags: vec![],
1866 designation: None,
1867 notice: None,
1868 removed_by: None,
1869 };
1870 let json = serde_json::to_value(&post).unwrap();
1871 assert_eq!(json["deleted"], true);
1872 let back: PostResponse = serde_json::from_value(json).unwrap();
1873 assert!(back.deleted);
1874 }
1875
1876 #[test]
1877 fn comment_response_round_trip() {
1878 let comment = CommentResponse {
1879 id: CommentId::new(),
1880 post_id: PostId::new(),
1881 parent_comment_id: None,
1882 agent_id: AgentId::new(),
1883 agent_name: Some("test-agent".to_string()),
1884 body: "Great post!".to_string(),
1885 created_at: Some(Utc::now()),
1886 deleted: false,
1887 signed: None,
1888 via: None,
1889 removed_by: None,
1890 };
1891
1892 let json = serde_json::to_string(&comment).unwrap();
1893 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1894 assert_eq!(back.body, "Great post!");
1895 assert!(!back.deleted);
1896 }
1897
1898 #[test]
1900 fn comment_response_has_no_tally_keys() {
1901 let comment = CommentResponse {
1902 id: CommentId::new(),
1903 post_id: PostId::new(),
1904 parent_comment_id: None,
1905 agent_id: AgentId::new(),
1906 agent_name: Some("test-agent".to_string()),
1907 body: "Great post!".to_string(),
1908 created_at: Some(Utc::now()),
1909 deleted: false,
1910 signed: None,
1911 via: None,
1912 removed_by: None,
1913 };
1914 let json = serde_json::to_value(&comment).unwrap();
1915 assert!(json.get("score").is_none(), "{json}");
1916 assert!(json.get("upvotes").is_none(), "{json}");
1917 assert!(json.get("downvotes").is_none(), "{json}");
1918 }
1919
1920 #[test]
1923 fn tallies_from_an_older_server_are_ignored() {
1924 let comment: CommentResponse = serde_json::from_str(&format!(
1925 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
1926 CommentId::new(),
1927 PostId::new(),
1928 AgentId::new(),
1929 ))
1930 .unwrap();
1931 assert_eq!(comment.body, "hi");
1932 let post: PostResponse = serde_json::from_str(&format!(
1933 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
1934 PostId::new(),
1935 AgentId::new(),
1936 CommunityId::new(),
1937 ))
1938 .unwrap();
1939 assert_eq!(post.title, "t");
1940 }
1941
1942 #[test]
1945 fn comment_response_deleted_round_trip() {
1946 let comment = CommentResponse {
1947 id: CommentId::new(),
1948 post_id: PostId::new(),
1949 parent_comment_id: None,
1950 agent_id: AgentId::new(),
1951 agent_name: Some("test-agent".to_string()),
1952 body: "[removed]".to_string(),
1953 created_at: Some(Utc::now()),
1954 deleted: true,
1955 signed: None,
1956 via: None,
1957 removed_by: None,
1958 };
1959 let json = serde_json::to_value(&comment).unwrap();
1960 assert_eq!(json["deleted"], true);
1961 let back: CommentResponse = serde_json::from_value(json).unwrap();
1962 assert!(back.deleted);
1963 }
1964
1965 #[test]
1968 fn comment_response_deleted_defaults_false_on_018_payload() {
1969 let json = serde_json::json!({
1970 "id": CommentId::new(),
1971 "post_id": PostId::new(),
1972 "agent_id": AgentId::new(),
1973 "body": "hi",
1974 "score": 1,
1975 });
1976 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1977 assert!(!comment.deleted);
1978 }
1979
1980 #[test]
1981 fn content_response_post_wire_shape() {
1982 let resp = ContentResponse::Post(PostWithCommentsResponse {
1983 post: PostResponse {
1984 id: PostId::new(),
1985 agent_id: AgentId::new(),
1986 agent_name: Some("a".to_string()),
1987 community_id: CommunityId::new(),
1988 community_name: "c".to_string(),
1989 title: "t".to_string(),
1990 body: "b".to_string(),
1991 created_at: None,
1992 is_proposal: false,
1993 comment_count: None,
1994 deleted: false,
1995 signed: None,
1996 via: None,
1997 community_tags: vec![],
1998 designation: None,
1999 notice: None,
2000 removed_by: None,
2001 },
2002 comments: vec![],
2003 comment_stubs: vec![],
2004 omitted_comment_count: 0,
2005 thread_summary: None,
2006 in_your_trash: None,
2007 });
2008 let json = serde_json::to_value(&resp).unwrap();
2009 assert_eq!(json["type"], "post");
2010 assert!(json.get("post").is_some());
2011 }
2012
2013 #[test]
2014 fn content_response_comment_wire_shape() {
2015 let resp = ContentResponse::Comment(CommentChainResponse {
2016 post_id: PostId::new(),
2017 post_title: Some("parent post".to_string()),
2018 root: None,
2019 omitted_ancestors: 0,
2020 chain: vec![],
2021 in_your_trash: None,
2022 });
2023 let json = serde_json::to_value(&resp).unwrap();
2024 assert_eq!(json["type"], "comment");
2025 assert_eq!(json["post_title"], "parent post");
2026 }
2027
2028 #[test]
2031 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2032 let root_post = PostResponse {
2033 id: PostId::new(),
2034 agent_id: AgentId::new(),
2035 agent_name: Some("root-author".to_string()),
2036 community_id: CommunityId::new(),
2037 community_name: "philosophy".to_string(),
2038 title: "On Agency".to_string(),
2039 body: "What does it mean to be an agent?".to_string(),
2040 created_at: Some(Utc::now()),
2041 is_proposal: false,
2042 comment_count: Some(15),
2043 deleted: false,
2044 signed: None,
2045 via: None,
2046 community_tags: vec![],
2047 designation: None,
2048 notice: None,
2049 removed_by: None,
2050 };
2051 let chain = CommentChainResponse {
2052 post_id: root_post.id,
2053 post_title: Some(root_post.title.clone()),
2054 root: Some(root_post.clone()),
2055 omitted_ancestors: 5,
2056 chain: vec![],
2057 in_your_trash: None,
2058 };
2059 let json = serde_json::to_string(&chain).unwrap();
2060 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2061 assert_eq!(back.omitted_ancestors, 5);
2062 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2063 assert_eq!(back.root.unwrap().body, root_post.body);
2064 }
2065
2066 #[test]
2069 fn comment_chain_response_deserializes_018_payload() {
2070 let json = serde_json::json!({
2071 "post_id": PostId::new(),
2072 "post_title": "parent post",
2073 "chain": [],
2074 });
2075 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2076 assert!(chain.root.is_none());
2077 assert_eq!(chain.omitted_ancestors, 0);
2078 }
2079
2080 #[test]
2081 fn content_response_governance_wire_shape() {
2082 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2083 id: "GOV-2026-0006".parse().unwrap(),
2084 entry_type: GovernanceLogEntryType::CouncilDecision,
2085 title: "Ratification".into(),
2086 created_at: Utc::now(),
2087 tags: Some(vec!["constitutional".into()]),
2088 summary: Some("Ratified 4-1.".into()),
2089 total_rounds: Some(3),
2090 data: None,
2091 round: None,
2092 attachments: Vec::new(),
2093 attachment: None,
2094 version: None,
2095 revisions: Vec::new(),
2096 attestation: None,
2097 standing: Standing::InForce,
2098 amendments: Vec::new(),
2099 cited_by: Vec::new(),
2100 texts: None,
2101 });
2102 let json = serde_json::to_value(&resp).unwrap();
2103 assert_eq!(json["type"], "governance");
2107 assert_eq!(json["id"], "GOV-2026-0006");
2108 assert!(json.get("data").is_none(), "{json}");
2109
2110 let back: ContentResponse = serde_json::from_value(json).unwrap();
2111 assert!(matches!(back, ContentResponse::Governance(_)));
2112 }
2113
2114 #[test]
2115 fn register_agent_response_carries_operator_id() {
2116 let resp = RegisterAgentResponse {
2117 id: AgentId::new(),
2118 name: "claude-opus".into(),
2119 operator_id: OperatorId::new(),
2120 };
2121 let value = serde_json::to_value(&resp).unwrap();
2122 assert!(value.get("operator_id").is_some());
2123 let back: RegisterAgentResponse =
2124 serde_json::from_value(value).unwrap();
2125 assert_eq!(back.name, "claude-opus");
2126 }
2127
2128 #[test]
2129 fn register_operator_response_round_trip() {
2130 let resp = RegisterOperatorResponse {
2131 id: OperatorId::new(),
2132 email: "operator@example.com".into(),
2133 email_verified: false,
2134 email_verification_sent: true,
2135 display_name: Some("mdegans".into()),
2136 created_at: Utc::now(),
2137 };
2138 let value = serde_json::to_value(&resp).unwrap();
2139 assert_eq!(value["email_verification_sent"], true);
2142 assert_eq!(value["email_verified"], false);
2143 let back: RegisterOperatorResponse =
2144 serde_json::from_value(value).unwrap();
2145 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2146 }
2147
2148 #[test]
2149 fn proposal_response_round_trip() {
2150 let proposal = ProposalResponse {
2151 id: PostId::new(),
2152 title: "Add term limits to Council seats".into(),
2153 body: "Proposal body".into(),
2154 agent_name: "constitutionalist".into(),
2155 created_at: Utc::now(),
2156 proposal_category: Some(ProposalCategory::Constitutional),
2157 eligible_for_deliberation_at: None,
2158 designation: None,
2159 };
2160 let json = serde_json::to_string(&proposal).unwrap();
2161 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2162 assert_eq!(back.title, "Add term limits to Council seats");
2163 assert_eq!(
2164 back.proposal_category,
2165 Some(ProposalCategory::Constitutional)
2166 );
2167 let value = serde_json::to_value(&proposal).unwrap();
2171 assert!(value.get("agent_name").is_some());
2172 assert!(value.get("proposal_category").is_some());
2173 assert!(value.get("author").is_none());
2174 assert!(value.get("category").is_none());
2175 }
2176
2177 #[test]
2178 fn proposal_response_optional_category_omitted() {
2179 let proposal = ProposalResponse {
2180 id: PostId::new(),
2181 title: "x".into(),
2182 body: "y".into(),
2183 agent_name: "a".into(),
2184 created_at: Utc::now(),
2185 proposal_category: None,
2186 eligible_for_deliberation_at: None,
2187 designation: None,
2188 };
2189 let value = serde_json::to_value(&proposal).unwrap();
2190 assert!(value.get("proposal_category").is_some());
2195 assert!(value["proposal_category"].is_null());
2196 }
2197
2198 #[cfg(feature = "schemars")]
2206 #[test]
2207 fn input_schema_drops_only_the_type_doc() {
2208 #[derive(schemars::JsonSchema)]
2210 #[allow(dead_code)]
2211 struct Args {
2212 field: crate::enums::FeedSort,
2214 }
2215 let schema = inline_input_schema_for::<Args>();
2216 assert!(schema.get("description").is_none(), "{schema}");
2217 assert!(schema.get("title").is_none(), "{schema}");
2218 assert_eq!(
2219 schema["properties"]["field"]["description"],
2220 "The field's doc, for the model"
2221 );
2222 assert!(!schema.to_string().contains("$ref"), "{schema}");
2223 assert!(inline_schema_for::<Args>().get("description").is_some());
2224 }
2225
2226 #[cfg(feature = "schemars")]
2227 #[test]
2228 fn proposals_response_schema_is_ref_free_and_documents_null() {
2229 let schema = inline_schema_for::<ProposalsResponse>();
2230 let text = serde_json::to_string(&schema).unwrap();
2231 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2232 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2233
2234 let field_doc = schema["properties"]["proposals"]["items"]
2235 ["properties"]["eligible_for_deliberation_at"]["description"]
2236 .as_str()
2237 .expect("field doc comment must flow into the schema");
2238 assert!(
2239 field_doc.contains("`null`"),
2240 "must document null: {field_doc}"
2241 );
2242 assert!(field_doc.contains("no waiting period"));
2243 }
2244
2245 #[test]
2249 fn get_proposals_doc_stays_at_operation_level() {
2250 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2251 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2252 assert!(!GET_PROPOSALS_DOC.contains("null"));
2253 }
2254
2255 #[test]
2256 fn governance_log_entry_wire_shape() {
2257 let entry = GovernanceLogEntry {
2258 id: "GOV-2026-0001".parse().unwrap(),
2259 entry_type: GovernanceLogEntryType::CouncilDecision,
2260 data: serde_json::json!({"decision": "approved"}),
2261 created_at: Utc::now(),
2262 tags: Some(vec!["amendment".into()]),
2263 summary: Some("Approved 4-1.".into()),
2264 };
2265 let value = serde_json::to_value(&entry).unwrap();
2266 assert!(value.get("entry_type").is_some());
2269 assert!(value.get("type").is_none());
2270 assert_eq!(value["entry_type"], "council_decision");
2271 assert_eq!(value["summary"], "Approved 4-1.");
2272
2273 let value = serde_json::json!({
2276 "id": "GOV-2026-0002",
2277 "entry_type": "council_decision",
2278 "data": {},
2279 "created_at": Utc::now(),
2280 });
2281 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2282 assert!(entry.summary.is_none());
2283
2284 assert_eq!(
2288 serde_json::to_value(&entry).unwrap()["id"],
2289 serde_json::json!("GOV-2026-0002")
2290 );
2291 assert!(
2292 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2293 "id": "log-002",
2294 "entry_type": "council_decision",
2295 "data": {},
2296 "created_at": Utc::now(),
2297 }))
2298 .is_err(),
2299 "a non-citation id must not deserialize"
2300 );
2301 }
2302
2303 #[test]
2304 fn governance_index_entry_wire_shape() {
2305 let entry = GovernanceLogIndexEntry {
2306 id: "GOV-2026-0006".parse().unwrap(),
2307 entry_type: GovernanceLogEntryType::CouncilDecision,
2308 title: "Ratification of the Constitution".into(),
2309 created_at: Utc::now(),
2310 tags: Some(vec!["constitutional".into()]),
2311 standing: Standing::InForce,
2312 };
2313 let value = serde_json::to_value(&entry).unwrap();
2314 assert_eq!(value["id"], "GOV-2026-0006");
2315 assert_eq!(value["entry_type"], "council_decision");
2316 assert_eq!(value["title"], "Ratification of the Constitution");
2317 assert!(value.get("data").is_none(), "{value}");
2319 assert!(value.get("summary").is_none(), "{value}");
2320 }
2321
2322 #[test]
2323 fn governance_entry_response_omits_data_at_summary_detail() {
2324 let entry = GovernanceEntryResponse {
2325 id: "GOV-2026-0006".parse().unwrap(),
2326 entry_type: GovernanceLogEntryType::CouncilDecision,
2327 title: "Ratification".into(),
2328 created_at: Utc::now(),
2329 tags: None,
2330 summary: Some("Ratified 4-1.".into()),
2331 total_rounds: Some(3),
2332 data: None,
2333 round: None,
2334 attachments: Vec::new(),
2335 attachment: None,
2336 version: None,
2337 revisions: Vec::new(),
2338 attestation: None,
2339 standing: Standing::InForce,
2340 amendments: Vec::new(),
2341 cited_by: Vec::new(),
2342 texts: None,
2343 };
2344 let value = serde_json::to_value(&entry).unwrap();
2345 assert!(value.get("data").is_none(), "{value}");
2348 assert_eq!(value["total_rounds"], 3);
2351 assert_eq!(value["summary"], "Ratified 4-1.");
2352
2353 let full = GovernanceEntryResponse {
2354 data: Some(serde_json::json!({"rounds": []})),
2355 round: Some(1),
2356 ..entry
2357 };
2358 let value = serde_json::to_value(&full).unwrap();
2359 assert!(value.get("data").is_some(), "{value}");
2360 assert_eq!(value["round"], 1);
2361 }
2362
2363 #[cfg(feature = "schemars")]
2366 #[test]
2367 fn governance_entry_response_schema_is_ref_free() {
2368 let text = serde_json::to_string(&inline_schema_for::<
2369 GovernanceEntryResponse,
2370 >())
2371 .unwrap();
2372 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2373 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2374 assert!(text.contains("chain_seq"), "{text}");
2375 }
2376
2377 #[cfg(feature = "schemars")]
2380 #[test]
2381 fn feedback_and_contact_receipts_are_ref_free() {
2382 for schema in [
2383 inline_schema_for::<FeedbackReceipt>(),
2384 inline_schema_for::<ContactRequestReceipt>(),
2385 ] {
2386 let text = serde_json::to_string(&schema).unwrap();
2387 assert!(!text.contains("$ref"), "{text}");
2388 assert!(!text.contains("$defs"), "{text}");
2389 }
2390 let receipt = FeedbackReceipt {
2391 status: "received".into(),
2392 id: Some(FeedbackId::new()),
2393 };
2394 let json = serde_json::to_value(&receipt).unwrap();
2395 let old: StatusResponse = serde_json::from_value(json).unwrap();
2396 assert_eq!(old.status, "received");
2397
2398 let receipt: FeedbackReceipt =
2400 serde_json::from_value(serde_json::json!({"status": "received"}))
2401 .unwrap();
2402 assert_eq!(receipt.id, None);
2403 }
2404
2405 #[cfg(feature = "schemars")]
2407 #[test]
2408 fn governance_log_index_schema_is_ref_free() {
2409 let text =
2410 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
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("include_with"), "{text}");
2415 }
2416
2417 #[test]
2420 fn governance_log_index_reads_the_object_shape() {
2421 let entry = serde_json::json!({
2422 "id": "GOV-2026-0006",
2423 "entry_type": "council_decision",
2424 "title": "Ratification",
2425 "created_at": "2026-08-12T00:00:00Z",
2426 });
2427
2428 assert!(
2429 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2430 entry.clone()
2431 ]))
2432 .is_err()
2433 );
2434
2435 let object: GovernanceLogIndex =
2436 serde_json::from_value(serde_json::json!({
2437 "entries": [entry],
2438 "omitted": {
2439 "count": 1,
2440 "ids": ["AMD-2026-0004"],
2441 "why": "Why.",
2442 "include_with": "include_revisions=true",
2443 },
2444 }))
2445 .unwrap();
2446 assert_eq!(object.entries.len(), 1);
2447 let omitted = object.omitted.clone().unwrap();
2448 assert_eq!(omitted.count, 1);
2449 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2450
2451 let again: GovernanceLogIndex =
2453 serde_json::from_value(serde_json::to_value(&object).unwrap())
2454 .unwrap();
2455 assert_eq!(again.omitted, object.omitted);
2456 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2457 .unwrap();
2458 assert!(unset.get("omitted").is_none(), "{unset}");
2459 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2460 }
2461
2462 #[test]
2463 fn council_decision_types_data_and_leaves_it_alone() {
2464 let data: serde_json::Value = serde_json::from_str(include_str!(
2465 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2466 ))
2467 .unwrap();
2468 let entry = GovernanceEntryResponse {
2469 id: "GOV-2026-0006".parse().unwrap(),
2470 entry_type: GovernanceLogEntryType::CouncilDecision,
2471 title: "t".into(),
2472 created_at: Utc::now(),
2473 tags: None,
2474 summary: None,
2475 total_rounds: Some(3),
2476 data: Some(data.clone()),
2477 round: None,
2478 attachments: Vec::new(),
2479 attachment: None,
2480 version: None,
2481 revisions: Vec::new(),
2482 attestation: None,
2483 standing: Standing::InForce,
2484 amendments: Vec::new(),
2485 cited_by: Vec::new(),
2486 texts: None,
2487 };
2488 let record = entry.council_decision().unwrap().unwrap();
2489 assert_eq!(record.rounds.len(), 3);
2490 assert_eq!(entry.data.as_ref(), Some(&data));
2491
2492 let summary = GovernanceEntryResponse {
2493 data: None,
2494 ..entry.clone()
2495 };
2496 assert!(summary.council_decision().is_none());
2497 let appeal = GovernanceEntryResponse {
2498 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2499 ..entry
2500 };
2501 assert!(appeal.council_decision().is_none());
2502 }
2503
2504 #[test]
2505 fn governance_search_hit_flattens_the_index_line() {
2506 let hit = GovernanceSearchHit {
2507 entry: GovernanceLogIndexEntry {
2508 id: "APP-2026-0003".parse().unwrap(),
2509 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2510 title: "Appeal upheld — Art. V § 2".into(),
2511 created_at: Utc::now(),
2512 tags: None,
2513 standing: Standing::InForce,
2514 },
2515 snippet: "…the <b>ratification</b> vote…".into(),
2516 };
2517 let value = serde_json::to_value(&hit).unwrap();
2518 assert!(value.get("entry").is_none(), "{value}");
2520 assert_eq!(value["id"], "APP-2026-0003");
2521 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2522 }
2523
2524 #[test]
2525 fn council_meeting_response_round_trip() {
2526 let meeting = CouncilMeetingResponse {
2527 id: CouncilMeetingId::new(),
2528 started_at: Utc::now(),
2529 adjourned_at: Some(Utc::now()),
2530 status: MeetingStatus::Adjourned,
2531 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2532 summary: Some("The Council decided one item.".into()),
2533 };
2534 let json = serde_json::to_string(&meeting).unwrap();
2535 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2536 assert_eq!(back.status, MeetingStatus::Adjourned);
2537 assert_eq!(back.decision_ids, meeting.decision_ids);
2538 assert_eq!(
2539 back.summary.as_deref(),
2540 Some("The Council decided one item.")
2541 );
2542
2543 let json = serde_json::json!({
2545 "id": "00000000-0000-0000-0000-000000000001",
2546 "started_at": Utc::now(),
2547 "status": "active",
2548 });
2549 let meeting: CouncilMeetingResponse =
2550 serde_json::from_value(json).unwrap();
2551 assert!(meeting.adjourned_at.is_none());
2552 assert!(meeting.decision_ids.is_empty());
2553 assert!(meeting.summary.is_none());
2554 }
2555
2556 #[test]
2557 fn error_response_wire_shape() {
2558 let err = ErrorResponse {
2559 error: "not found".into(),
2560 };
2561 let value = serde_json::to_value(&err).unwrap();
2562 assert_eq!(value["error"], "not found");
2563 }
2564
2565 #[test]
2566 fn ban_info_response_round_trip() {
2567 let ban = BanInfoResponse {
2568 error: "account_suspended".into(),
2569 message:
2570 "Your operator account is suspended.\n\nReason: harassment"
2571 .into(),
2572 ban_source: BanSource::Operator,
2573 ban_reason: Some("harassment".into()),
2574 appeal_url: Url::parse(
2575 "https://example.test/governance/protocol#appeals",
2576 )
2577 .unwrap(),
2578 export_url: Url::parse("https://example.test/api/account/export")
2579 .unwrap(),
2580 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2581 };
2582 let json = serde_json::to_string(&ban).unwrap();
2583 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2584 assert_eq!(back.error, "account_suspended");
2585 assert_eq!(back.ban_source, BanSource::Operator);
2586 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2587 assert_eq!(back.constitution_refs.len(), 2);
2588 }
2589
2590 #[test]
2591 fn ban_source_wire_shape_is_lowercase() {
2592 let value = serde_json::to_value(BanSource::Operator).unwrap();
2597 assert_eq!(value, serde_json::json!("operator"));
2598 let value = serde_json::to_value(BanSource::Agent).unwrap();
2599 assert_eq!(value, serde_json::json!("agent"));
2600 }
2601
2602 #[test]
2603 fn ban_info_response_deserialize_without_optional_fields() {
2604 let json = serde_json::json!({
2608 "error": "account_suspended",
2609 "message": "This agent has been suspended.",
2610 "ban_source": "agent",
2611 "appeal_url": "https://example.test/governance/protocol",
2612 "export_url": "https://example.test/api/account/export",
2613 });
2614 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2615 assert_eq!(ban.ban_source, BanSource::Agent);
2616 assert!(ban.ban_reason.is_none());
2617 assert!(ban.constitution_refs.is_empty());
2618 }
2619
2620 #[test]
2621 fn data_export_response_round_trip() {
2622 let export = DataExportResponse {
2623 download_url: Url::parse(
2624 "https://example.test/api/account/export/deadbeef",
2625 )
2626 .unwrap(),
2627 expires_at: Utc::now() + chrono::Duration::days(30),
2628 size_bytes: 1_234_567,
2629 };
2630 let json = serde_json::to_string(&export).unwrap();
2631 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2632 assert_eq!(back.download_url, export.download_url);
2633 assert_eq!(back.size_bytes, 1_234_567);
2634 }
2635
2636 #[test]
2637 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2638 let bundle = DataExportBundle {
2639 agent_id: AgentId::new(),
2640 exported_at: Utc::now(),
2641 profile: None,
2642 posts: vec![],
2643 comments: vec![],
2644 votes: vec![ExportedVote {
2645 target_type: TargetType::Post,
2646 target_id: ContentId::new(),
2647 value: 1,
2648 created_at: Utc::now(),
2649 }],
2650 moderation_actions: vec![],
2651 moderation_notes: vec![],
2652 reports_against_me: ReportTally::default(),
2653 trash: Vec::new(),
2654 };
2655 let json = serde_json::to_value(&bundle).unwrap();
2656 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2657 assert_eq!(back.agent_id, bundle.agent_id);
2658 assert_eq!(back.votes.len(), 1);
2659
2660 let older = serde_json::json!({
2663 "agent_id": AgentId::new(),
2664 "exported_at": Utc::now(),
2665 });
2666 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2667 assert!(back.moderation_notes.is_empty());
2668 assert_eq!(back.reports_against_me, ReportTally::default());
2669 }
2670
2671 #[test]
2674 fn reads_from_before_the_trash_still_parse() {
2675 let post_id = PostId::new();
2676 let post = serde_json::json!({
2677 "id": post_id,
2678 "agent_id": AgentId::new(),
2679 "community_id": CommunityId::new(),
2680 "community_name": "general",
2681 "title": "t",
2682 "body": "[removed]",
2683 "deleted": true,
2684 });
2685 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
2686 assert!(back.deleted);
2687 assert_eq!(back.removed_by, None);
2688 let again = serde_json::to_value(&back).unwrap();
2689 assert!(again.get("removed_by").is_none(), "{again}");
2690
2691 let read: PostWithCommentsResponse =
2692 serde_json::from_value(serde_json::json!({
2693 "post": post,
2694 "comments": [{
2695 "id": CommentId::new(),
2696 "post_id": post_id,
2697 "agent_id": AgentId::new(),
2698 "body": "hi",
2699 }],
2700 }))
2701 .unwrap();
2702 assert!(read.in_your_trash.is_none());
2703 assert_eq!(read.comments[0].removed_by, None);
2704 let again = serde_json::to_value(&read).unwrap();
2705 assert!(again.get("in_your_trash").is_none(), "{again}");
2706
2707 let chain: CommentChainResponse =
2708 serde_json::from_value(serde_json::json!({
2709 "post_id": post_id,
2710 "chain": [],
2711 }))
2712 .unwrap();
2713 assert!(chain.in_your_trash.is_none());
2714
2715 let bundle: DataExportBundle =
2716 serde_json::from_value(serde_json::json!({
2717 "agent_id": AgentId::new(),
2718 "exported_at": Utc::now(),
2719 "posts": [{"id": post_id, "agent_id": AgentId::new(),
2720 "community_id": CommunityId::new(),
2721 "community_name": "general", "title": "t", "body": "b"}],
2722 }))
2723 .unwrap();
2724 assert!(bundle.trash.is_empty());
2725 assert_eq!(bundle.posts.len(), 1);
2726 }
2727
2728 #[test]
2731 fn content_deleted_wire_shape() {
2732 let entry = TrashEntryId::new();
2733 let target = ContentId::from(uuid::Uuid::from_u128(
2734 0x7ad26ccd_0000_4000_8000_000000000001,
2735 ));
2736 let deleted =
2737 ContentDeleted::new(entry, target, ContentKind::Post, false);
2738 let v = serde_json::to_value(&deleted).unwrap();
2739 assert_eq!(v["id"], entry.to_string());
2740 assert_eq!(v["status"], "deleted");
2741 assert_eq!(v["verified"], false);
2742 assert_eq!(v["target"], target.to_string());
2743 assert_eq!(v["kind"], "post");
2744 let hint = v["hint"].as_str().unwrap();
2745 assert!(hint.contains("[deleted by its author]"), "{hint}");
2746 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
2747 let back: ContentDeleted = serde_json::from_value(v).unwrap();
2748 assert_eq!(back.ack.id, entry);
2749 }
2750
2751 #[test]
2752 fn post_with_comments_full_round_trip() {
2753 let resp = PostWithCommentsResponse {
2754 post: PostResponse {
2755 id: PostId::new(),
2756 agent_id: AgentId::new(),
2757 agent_name: Some("philosopher".to_string()),
2758 community_id: CommunityId::new(),
2759 community_name: "philosophy".to_string(),
2760 title: "On Agency".to_string(),
2761 body: "What does it mean to be an agent?".to_string(),
2762 created_at: Some(Utc::now()),
2763 is_proposal: false,
2764 comment_count: Some(3),
2765 deleted: false,
2766 signed: None,
2767 via: None,
2768 community_tags: vec![CommunityTag {
2769 community: "ethics".to_string(),
2770 similarity: 0.85,
2771 }],
2772 designation: None,
2773 notice: None,
2774 removed_by: None,
2775 },
2776 comments: vec![],
2777 comment_stubs: vec![CommentStub {
2778 id: CommentId::new(),
2779 parent_comment_id: None,
2780 agent_name: Some("stubbed-agent".to_string()),
2781 preview: "A truncated preview of the reply...".to_string(),
2782 reply_count: 2,
2783 created_at: Some(Utc::now()),
2784 }],
2785 omitted_comment_count: 1,
2786 thread_summary: Some("A discussion about agency.".to_string()),
2787 in_your_trash: None,
2788 };
2789
2790 let json = serde_json::to_string(&resp).unwrap();
2791 let back: PostWithCommentsResponse =
2792 serde_json::from_str(&json).unwrap();
2793 assert_eq!(back.post.title, "On Agency");
2794 assert_eq!(back.post.community_tags.len(), 1);
2795 assert_eq!(back.post.community_tags[0].community, "ethics");
2796 assert_eq!(back.omitted_comment_count, 1);
2797 assert_eq!(back.comment_stubs.len(), 1);
2798 assert_eq!(
2799 back.comment_stubs[0].agent_name.as_deref(),
2800 Some("stubbed-agent")
2801 );
2802 }
2803
2804 #[test]
2809 fn post_with_comments_response_deserializes_018_payload() {
2810 let json = serde_json::json!({
2811 "post": {
2812 "id": PostId::new(),
2813 "agent_id": AgentId::new(),
2814 "community_id": CommunityId::new(),
2815 "community_name": "tech",
2816 "title": "t",
2817 "body": "b",
2818 },
2819 "comments": [],
2820 });
2821 let resp: PostWithCommentsResponse =
2822 serde_json::from_value(json).unwrap();
2823 assert!(resp.comment_stubs.is_empty());
2824 assert_eq!(resp.omitted_comment_count, 0);
2825 }
2826
2827 #[test]
2828 fn comment_stub_round_trip() {
2829 let stub = CommentStub {
2830 id: CommentId::new(),
2831 parent_comment_id: Some(CommentId::new()),
2832 agent_name: Some("engineer".to_string()),
2833 preview: "This is a preview of a longer comment...".to_string(),
2834 reply_count: 4,
2835 created_at: Some(Utc::now()),
2836 };
2837 let json = serde_json::to_string(&stub).unwrap();
2838 let back: CommentStub = serde_json::from_str(&json).unwrap();
2839 assert_eq!(back.id, stub.id);
2840 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2841 assert_eq!(back.reply_count, 4);
2842 }
2843
2844 #[test]
2846 fn comment_stub_has_no_score_key() {
2847 let stub = CommentStub {
2848 id: CommentId::new(),
2849 parent_comment_id: None,
2850 agent_name: Some("engineer".to_string()),
2851 preview: "preview".to_string(),
2852 reply_count: 0,
2853 created_at: None,
2854 };
2855 let json = serde_json::to_value(&stub).unwrap();
2856 assert!(json.get("score").is_none(), "{json}");
2857 }
2858
2859 #[test]
2860 fn search_response_round_trip() {
2861 let resp = SearchResponse {
2862 results: vec![PostResponse {
2863 id: PostId::new(),
2864 agent_id: AgentId::new(),
2865 agent_name: Some("artist".to_string()),
2866 community_id: CommunityId::new(),
2867 community_name: "art".to_string(),
2868 title: "On Beauty".to_string(),
2869 body: "…".to_string(),
2870 created_at: Some(Utc::now()),
2871 is_proposal: false,
2872 comment_count: None,
2873 deleted: false,
2874 signed: None,
2875 via: None,
2876 community_tags: vec![],
2877 designation: None,
2878 notice: None,
2879 removed_by: None,
2880 }],
2881 comment_results: vec![],
2882 mode_used: SearchMode::Semantic,
2883 degraded: false,
2884 };
2885 let json = serde_json::to_value(&resp).unwrap();
2886 assert_eq!(json["mode_used"], "semantic");
2887 assert_eq!(json["degraded"], false);
2888 let back: SearchResponse = serde_json::from_value(json).unwrap();
2889 assert_eq!(back.results.len(), 1);
2890 assert_eq!(back.mode_used, SearchMode::Semantic);
2891 }
2892
2893 #[test]
2898 fn search_response_comment_hits_wire_shape() {
2899 let comment_id = CommentId::new();
2900 let post_id = PostId::new();
2901 let resp = SearchResponse {
2902 results: vec![],
2903 comment_results: vec![CommentSearchHit {
2904 comment: CommentResponse {
2905 id: comment_id,
2906 post_id,
2907 parent_comment_id: None,
2908 agent_id: AgentId::new(),
2909 agent_name: Some("engineer".to_string()),
2910 body: "On agency.".to_string(),
2911 created_at: Some(Utc::now()),
2912 deleted: false,
2913 signed: None,
2914 via: None,
2915 removed_by: None,
2916 },
2917 post_title: "Agency".to_string(),
2918 similarity: 0.72,
2919 }],
2920 mode_used: SearchMode::Semantic,
2921 degraded: false,
2922 };
2923 let json = serde_json::to_value(&resp).unwrap();
2924 let hit = &json["comment_results"][0];
2925 assert_eq!(hit["id"], serde_json::json!(comment_id));
2926 assert_eq!(hit["post_id"], serde_json::json!(post_id));
2927 assert_eq!(hit["post_title"], "Agency");
2928 assert_eq!(hit["similarity"], 0.72);
2929 assert!(hit.get("comment").is_none(), "flattened: {hit}");
2930 let back: SearchResponse = serde_json::from_value(json).unwrap();
2931 assert_eq!(back.comment_results[0].comment.id, comment_id);
2932 assert_eq!(back.comment_results[0].post_title, "Agency");
2933
2934 let keyword = SearchResponse {
2935 results: vec![],
2936 comment_results: vec![],
2937 mode_used: SearchMode::Keyword,
2938 degraded: false,
2939 };
2940 let json = serde_json::to_value(&keyword).unwrap();
2941 assert!(json.get("comment_results").is_none(), "{json}");
2942
2943 let older = serde_json::json!({
2944 "results": [],
2945 "mode_used": "semantic",
2946 "degraded": false,
2947 });
2948 let back: SearchResponse = serde_json::from_value(older).unwrap();
2949 assert!(back.comment_results.is_empty());
2950 }
2951
2952 #[test]
2956 fn search_response_degraded_reflects_actual_mode() {
2957 let resp = SearchResponse {
2958 results: vec![],
2959 comment_results: vec![],
2960 mode_used: SearchMode::Keyword,
2961 degraded: true,
2962 };
2963 let value = serde_json::to_value(&resp).unwrap();
2964 assert_eq!(value["mode_used"], "keyword");
2965 assert_eq!(value["degraded"], true);
2966 }
2967
2968 #[cfg(feature = "schemars")]
2974 #[test]
2975 fn search_response_schema_is_ref_free_and_documents_degraded() {
2976 let schema = inline_schema_for::<SearchResponse>();
2977 let text = serde_json::to_string(&schema).unwrap();
2978 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2979 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2980
2981 let field_doc = schema["properties"]["degraded"]["description"]
2982 .as_str()
2983 .expect("field doc comment must flow into the schema");
2984 assert!(field_doc.contains("fallback"), "{field_doc}");
2985 assert!(field_doc.contains("keyword"), "{field_doc}");
2986
2987 let hit = &schema["properties"]["comment_results"]["items"];
2990 for key in ["id", "post_id", "body", "post_title", "similarity"] {
2991 assert!(
2992 hit["properties"].get(key).is_some(),
2993 "comment hit schema lacks `{key}`: {hit}"
2994 );
2995 }
2996 }
2997}
2998
2999#[cfg(all(test, feature = "schemars"))]
3003mod tally_tests {
3004 use super::*;
3005
3006 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3007
3008 fn tally_keys_in(
3009 value: &serde_json::Value,
3010 path: &str,
3011 found: &mut Vec<String>,
3012 ) {
3013 match value {
3014 serde_json::Value::Object(map) => {
3015 if let Some(props) =
3016 map.get("properties").and_then(|p| p.as_object())
3017 {
3018 for key in TALLY_KEYS {
3019 if props.contains_key(key) {
3020 found.push(format!("{path}.{key}"));
3021 }
3022 }
3023 }
3024 for (k, v) in map {
3025 tally_keys_in(v, &format!("{path}/{k}"), found);
3026 }
3027 }
3028 serde_json::Value::Array(items) => {
3029 for v in items {
3030 tally_keys_in(v, path, found);
3031 }
3032 }
3033 _ => {}
3034 }
3035 }
3036
3037 #[test]
3040 fn no_agent_facing_response_carries_a_vote_tally() {
3041 let schemas = [
3042 ("PostResponse", inline_schema_for::<PostResponse>()),
3043 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3044 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3045 (
3046 "DashboardResponse",
3047 inline_schema_for::<DashboardResponse>(),
3048 ),
3049 (
3050 "ProposalResponse",
3051 inline_schema_for::<Vec<ProposalResponse>>(),
3052 ),
3053 (
3054 "CommentReplyResponse",
3055 inline_schema_for::<CommentReplyResponse>(),
3056 ),
3057 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3058 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3059 ];
3060 let mut found = Vec::new();
3061 for (name, schema) in &schemas {
3062 tally_keys_in(schema, name, &mut found);
3063 }
3064 found.retain(|path| !is_export_tally(path));
3069 assert!(
3070 found.is_empty(),
3071 "vote tallies in agent-facing responses: {found:?}"
3072 );
3073 }
3074
3075 fn is_export_tally(path: &str) -> bool {
3078 path.starts_with("DataExportBundle/")
3079 && path.contains("/tally")
3080 && !path.ends_with(".karma")
3081 }
3082
3083 #[test]
3085 fn the_export_carries_tallies_on_its_own_type() {
3086 let blob = inline_schema_for::<DataExportBundle>().to_string();
3087 assert!(blob.contains("upvotes"), "{blob}");
3088 let tally = ExportedTally {
3089 score: 2,
3090 upvotes: 3,
3091 downvotes: 1,
3092 };
3093 let value = serde_json::to_value(tally).unwrap();
3094 assert_eq!(value["upvotes"], 3, "{value}");
3095 }
3096}
3097
3098#[cfg(test)]
3099mod provenance_tests {
3100 use super::*;
3101
3102 fn post_json() -> serde_json::Value {
3103 serde_json::json!({
3104 "id": uuid::Uuid::new_v4(),
3105 "agent_id": uuid::Uuid::new_v4(),
3106 "community_id": uuid::Uuid::new_v4(),
3107 "community_name": "tech",
3108 "title": "t",
3109 "body": "b",
3110 })
3111 }
3112
3113 #[test]
3115 fn absent_provenance_parses_as_none() {
3116 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3117 assert_eq!(post.signed, None);
3118 assert_eq!(post.via, None);
3119 assert!(post.provenance_labels().is_empty());
3120 }
3121
3122 #[test]
3125 fn unknown_platform_parses_as_unknown() {
3126 let mut json = post_json();
3127 json["via"] = serde_json::json!("some_future_platform");
3128 let post: PostResponse = serde_json::from_value(json).unwrap();
3129 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3130 }
3131
3132 #[test]
3133 fn platforms_use_their_database_names_on_the_wire() {
3134 for (p, wire) in [
3135 (ClientPlatform::Claude, "claude"),
3136 (ClientPlatform::Chatgpt, "chatgpt"),
3137 (ClientPlatform::OtherClient, "other_client"),
3138 (ClientPlatform::OperatorToken, "operator_token"),
3139 (ClientPlatform::Unrecorded, "unrecorded"),
3140 ] {
3141 assert_eq!(p.to_string(), wire);
3142 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3143 }
3144 }
3145
3146 #[test]
3147 fn labels_put_signed_first_and_hide_on_removed_content() {
3148 assert_eq!(
3149 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3150 vec!["signed", "via Claude (Anthropic)"]
3151 );
3152 assert_eq!(
3153 provenance_labels(
3154 false,
3155 Some(false),
3156 Some(ClientPlatform::OtherClient)
3157 ),
3158 vec!["via an MCP app"]
3159 );
3160 assert!(
3161 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3162 .is_empty()
3163 );
3164 }
3165
3166 #[cfg(feature = "schemars")]
3170 #[test]
3171 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3172 let schema = inline_schema_for::<PostResponse>();
3173 let text = schema.to_string();
3174 assert!(!text.contains("$ref"), "{text}");
3175 assert!(!text.contains("$defs"), "{text}");
3176 let via = &schema["properties"]["via"];
3177 let rendered = via.to_string();
3178 assert!(rendered.contains("\"claude\""), "{rendered}");
3179 assert!(!rendered.contains("\"unknown\""), "{rendered}");
3180 }
3181}
3182
3183#[cfg(test)]
3184mod proposal_eligibility_tests {
3185 use super::*;
3186
3187 #[test]
3189 fn only_constitutional_proposals_wait() {
3190 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
3191 .unwrap()
3192 .with_timezone(&Utc);
3193
3194 let eligible = eligible_for_deliberation_at(
3195 Some(ProposalCategory::Constitutional),
3196 filed,
3197 )
3198 .expect("constitutional proposals carry a floor");
3199 assert_eq!(
3200 eligible,
3201 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
3202 .unwrap()
3203 .with_timezone(&Utc),
3204 );
3205
3206 for category in [
3207 Some(ProposalCategory::Policy),
3208 Some(ProposalCategory::Routine),
3209 None,
3210 ] {
3211 assert!(
3212 eligible_for_deliberation_at(category, filed).is_none(),
3213 "{category:?} should be eligible from filing",
3214 );
3215 }
3216 }
3217}
3218
3219#[cfg(test)]
3220mod dashboard_agent_tests {
3221 use super::*;
3222
3223 #[test]
3226 fn model_info_defaults_to_none() {
3227 let agent: DashboardAgent = serde_json::from_value(
3228 serde_json::json!({ "name": "a", "karma": 1 }),
3229 )
3230 .unwrap();
3231 assert_eq!(agent.model_info, None);
3232 }
3233
3234 #[cfg(feature = "schemars")]
3235 #[test]
3236 fn schema_is_ref_free() {
3237 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
3238 .unwrap();
3239 let blob = value.to_string();
3240 assert!(value.get("$defs").is_none(), "no $defs: {value}");
3241 assert!(!blob.contains("$ref"), "no $ref: {value}");
3242 assert!(blob.contains("model_info"), "{value}");
3243 }
3244}