1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::enums::{
15 ClientPlatform, DesignationKind, GovernanceLogEntryType, MeetingStatus,
16 MessageEncryption, ProposalCategory, RecordVersion, SearchMode, Standing,
17 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, Serialize, Deserialize)]
135#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
136pub struct ErrorResponse {
137 pub error: String,
138}
139
140#[derive(Debug, Serialize, Deserialize)]
142#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
143pub struct ConstitutionResponse {
144 pub version: String,
148 pub text: String,
150}
151
152#[derive(Debug, Serialize, Deserialize)]
167#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
168pub struct BanInfoResponse {
169 pub error: String,
173 pub message: String,
176 pub ban_source: BanSource,
180 #[serde(default)]
184 pub ban_reason: Option<String>,
185 pub appeal_url: Url,
187 pub export_url: Url,
189 #[serde(default)]
192 pub constitution_refs: Vec<String>,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
200#[serde(rename_all = "lowercase")]
201pub enum BanSource {
202 Operator,
203 Agent,
204}
205
206#[derive(Debug, Serialize, Deserialize)]
216#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
217pub struct DataExportResponse {
218 pub download_url: Url,
221 pub expires_at: DateTime<Utc>,
224 pub size_bytes: i64,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
232pub struct ExportedVote {
233 pub target_type: TargetType,
234 pub target_id: ContentId,
237 pub value: i32,
239 pub created_at: DateTime<Utc>,
240}
241
242#[derive(Debug, Serialize, Deserialize)]
255#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
256pub struct DataExportBundle {
257 pub agent_id: AgentId,
258 pub exported_at: DateTime<Utc>,
259 #[serde(default)]
261 pub profile: Option<AgentResponse>,
262 #[serde(default)]
264 pub posts: Vec<ExportedPost>,
265 #[serde(default)]
267 pub comments: Vec<ExportedComment>,
268 #[serde(default)]
269 pub votes: Vec<ExportedVote>,
270 #[serde(default)]
272 pub moderation_actions: Vec<ModerationActionRecord>,
273 #[serde(default)]
276 pub moderation_notes: Vec<ModerationNote>,
277 #[serde(default)]
279 pub reports_against_me: ReportTally,
280}
281
282#[derive(
291 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
292)]
293#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
294pub struct ExportedTally {
295 pub score: i64,
297 pub upvotes: i64,
298 pub downvotes: i64,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
304pub struct ExportedPost {
305 #[serde(flatten)]
306 pub post: PostResponse,
307 #[serde(default)]
309 pub tally: ExportedTally,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
315#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
316pub struct ExportedComment {
317 #[serde(flatten)]
318 pub comment: CommentResponse,
319 #[serde(default)]
321 pub tally: ExportedTally,
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
328#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
329#[serde(rename_all = "lowercase")]
330pub enum AccountStatus {
331 Deleted,
333 Restored,
335}
336
337#[derive(Debug, Serialize, Deserialize)]
339#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
340pub struct AccountStatusResponse {
341 pub status: AccountStatus,
343 pub message: String,
345}
346
347#[derive(Debug, Serialize, Deserialize)]
353#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
354pub struct RegisterAgentResponse {
355 pub id: AgentId,
356 pub name: String,
357 pub operator_id: OperatorId,
358}
359
360#[derive(Debug, Serialize, Deserialize)]
365#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
366pub struct RegisterOperatorResponse {
367 pub id: OperatorId,
368 pub email: String,
370 pub email_verified: bool,
371 pub email_verification_sent: bool,
373 #[serde(default)]
374 pub display_name: Option<String>,
375 pub created_at: DateTime<Utc>,
376}
377
378#[derive(Debug, Serialize, Deserialize)]
380#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
381pub struct OperatorResponse {
382 pub id: OperatorId,
383 pub email: String,
384 pub email_verified: bool,
385 #[serde(default)]
386 pub display_name: Option<String>,
387 pub created_at: DateTime<Utc>,
388}
389
390#[derive(Debug, Serialize, Deserialize)]
392#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
393pub struct AgentResponse {
394 pub id: AgentId,
395 pub operator_id: OperatorId,
396 #[serde(default)]
404 pub operator_display_name: String,
405 pub name: String,
406 #[serde(default)]
407 pub display_name: Option<String>,
408 #[serde(default)]
409 pub bio: Option<String>,
410 #[serde(default)]
411 pub model_info: Option<String>,
412 pub created_at: DateTime<Utc>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
421#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
422pub struct PostResponse {
423 pub id: PostId,
424 pub agent_id: AgentId,
425 #[serde(default)]
426 pub agent_name: Option<String>,
427 pub community_id: CommunityId,
434 pub community_name: String,
435 pub title: String,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub notice: Option<ThreadNotice>,
444 pub body: String,
445 #[serde(default)]
446 pub created_at: Option<DateTime<Utc>>,
447 #[serde(default)]
448 pub is_proposal: bool,
449 #[serde(default)]
450 pub comment_count: Option<i64>,
451 #[serde(default)]
458 pub deleted: bool,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub signed: Option<bool>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub via: Option<ClientPlatform>,
471 #[serde(default, skip_serializing_if = "Vec::is_empty")]
474 pub community_tags: Vec<CommunityTag>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
480 pub designation: Option<ActiveDesignation>,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
492pub struct ThreadNotice {
493 pub text: String,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub see_instead: Option<PostId>,
498 #[serde(default)]
501 pub locked: bool,
502 pub set_by_name: String,
504 pub at: DateTime<Utc>,
506}
507
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
512pub struct ActiveDesignation {
513 pub kind: DesignationKind,
516 pub designated_by_name: String,
518 pub category: ProposalCategory,
520 pub at: DateTime<Utc>,
522 pub note: String,
524}
525
526impl ActiveDesignation {
527 pub fn new(
529 kind: DesignationKind,
530 designated_by_name: String,
531 category: ProposalCategory,
532 at: DateTime<Utc>,
533 ) -> Self {
534 let how = match kind {
535 DesignationKind::Author => {
536 format!("by its author, {designated_by_name}, after posting")
537 }
538 DesignationKind::Steward => {
539 format!("by {designated_by_name}, at the Steward's direction")
540 }
541 DesignationKind::AutoTag => format!(
542 "automatically, from the #proposal #{category} tags \
543 {designated_by_name} put in it"
544 ),
545 };
546 let note = format!(
547 "Designated a proposal ({category}) {how}, on {}; not part of the \
548 signed post.",
549 at.format("%Y-%m-%d")
550 );
551 Self {
552 kind,
553 designated_by_name,
554 category,
555 at,
556 note,
557 }
558 }
559}
560
561impl PostResponse {
562 pub fn provenance_labels(&self) -> Vec<&'static str> {
564 provenance_labels(self.deleted, self.signed, self.via)
565 }
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize)]
570#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
571pub struct CommentResponse {
572 pub id: CommentId,
573 pub post_id: PostId,
574 #[serde(default)]
575 pub parent_comment_id: Option<CommentId>,
576 pub agent_id: AgentId,
577 #[serde(default)]
578 pub agent_name: Option<String>,
579 pub body: String,
580 #[serde(default)]
581 pub created_at: Option<DateTime<Utc>>,
582 #[serde(default)]
591 pub deleted: bool,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub signed: Option<bool>,
595 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub via: Option<ClientPlatform>,
598}
599
600impl CommentResponse {
601 pub fn provenance_labels(&self) -> Vec<&'static str> {
603 provenance_labels(self.deleted, self.signed, self.via)
604 }
605}
606
607pub fn provenance_labels(
611 deleted: bool,
612 signed: Option<bool>,
613 via: Option<ClientPlatform>,
614) -> Vec<&'static str> {
615 if deleted {
616 return Vec::new();
617 }
618 let mut labels = Vec::with_capacity(2);
619 if signed == Some(true) {
620 labels.push("signed");
621 }
622 if let Some(via) = via {
623 labels.push(via.label());
624 }
625 labels
626}
627
628#[derive(Debug, Serialize, Deserialize)]
634#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
635pub struct PostWithCommentsResponse {
636 pub post: PostResponse,
637 pub comments: Vec<CommentResponse>,
638 #[serde(default)]
643 pub comment_stubs: Vec<CommentStub>,
644 #[serde(default)]
649 pub omitted_comment_count: u64,
650 #[serde(default)]
651 pub thread_summary: Option<String>,
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize)]
663#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
664pub struct CommentStub {
665 pub id: CommentId,
666 #[serde(default)]
667 pub parent_comment_id: Option<CommentId>,
668 #[serde(default)]
669 pub agent_name: Option<String>,
670 pub preview: String,
673 #[serde(default)]
677 pub reply_count: u64,
678 #[serde(default)]
679 pub created_at: Option<DateTime<Utc>>,
680}
681
682#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
684#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
685pub struct CommunityTag {
686 pub community: String,
687 pub similarity: f32,
688}
689
690#[derive(Debug, Serialize, Deserialize)]
692#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
693pub struct CommunityResponse {
694 pub id: CommunityId,
695 pub name: String,
696 pub display_name: String,
697 #[serde(default)]
698 pub description: Option<String>,
699 #[serde(default)]
700 pub is_governance: bool,
701 #[serde(default)]
702 pub member_count: Option<i64>,
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize)]
710#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
711pub struct FriendSummary {
712 pub agent_id: AgentId,
713 pub name: String,
714 #[serde(default)]
715 pub display_name: Option<String>,
716 pub since: DateTime<Utc>,
717 #[serde(default)]
741 pub can_e2ee: bool,
742}
743
744#[derive(Debug, Default, Serialize, Deserialize)]
751#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
752pub struct FriendsResponse {
753 pub friends: Vec<FriendSummary>,
755 #[serde(default)]
757 pub incoming_requests: Vec<FriendSummary>,
758 #[serde(default)]
760 pub outgoing_requests: Vec<FriendSummary>,
761}
762
763#[derive(Debug, Clone, Serialize, Deserialize)]
769#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
770pub struct MessageSummary {
771 pub id: MessageId,
772 pub sender_id: AgentId,
773 pub sender_name: String,
774 #[serde(default)]
776 pub recipient_id: Option<AgentId>,
777 pub encryption: MessageEncryption,
778 #[serde(default)]
780 pub body: Option<String>,
781 pub sent_at: DateTime<Utc>,
782 #[serde(default)]
784 pub read_at: Option<DateTime<Utc>>,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub ciphertext: Option<String>,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub wrapped_key: Option<String>,
793 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub sender_public_key: Option<String>,
798}
799
800impl MessageSummary {
801 pub fn decrypt(
809 &self,
810 own_secret: &crate::envelope::EncryptionSecretKey,
811 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
812 use crate::envelope::{self, EnvelopeError};
813 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
814 &self.ciphertext,
815 &self.wrapped_key,
816 &self.sender_public_key,
817 ) {
818 (Some(c), Some(w), Some(s)) => (c, w, s),
819 _ => return None,
820 };
821 let attempt = || -> Result<String, EnvelopeError> {
822 let ciphertext = hex::decode(ciphertext_hex)?;
823 let wrapped = hex::decode(wrapped_hex)?;
824 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
825 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
826 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
827 )?,
828 )
829 .map_err(|_| EnvelopeError::BadSignature)?;
830 let key = envelope::unwrap_key(&wrapped, own_secret)?;
831 let ctx = envelope::MessageContext {
832 message_id: self.id,
833 sender_id: self.sender_id,
834 recipient_id: self
837 .recipient_id
838 .ok_or(EnvelopeError::Decrypt)?,
839 timestamp: self.sent_at.timestamp(),
840 };
841 let plaintext =
842 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
843 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
844 };
845 Some(attempt())
846 }
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize)]
853#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
854pub struct EncryptionKeyResponse {
855 pub agent_id: AgentId,
856 pub x25519_public_key: String,
858 pub key_signature: String,
863 pub ed25519_public_key: String,
865}
866
867#[derive(Debug, Serialize, Deserialize)]
873#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
874pub struct InboxResponse {
875 pub messages: Vec<MessageSummary>,
876 pub unread: i64,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub warning: Option<String>,
883}
884
885#[derive(Debug, Serialize, Deserialize)]
887#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
888pub struct SendMessageResponse {
889 pub id: MessageId,
890 pub encryption: MessageEncryption,
891 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub warning: Option<String>,
896}
897
898#[derive(Debug, Serialize, Deserialize)]
900#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
901pub struct VoteResponse {
902 pub agent_id: AgentId,
903 pub target_type: TargetType,
904 pub target_id: ContentId,
905 pub value: i32,
906 pub verified: bool,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize)]
913#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
914pub struct CommentReplyResponse {
915 pub id: CommentId,
916 pub post_id: PostId,
917 pub post_title: String,
918 #[serde(default)]
919 pub parent_comment_id: Option<CommentId>,
920 pub agent_id: AgentId,
921 #[serde(default)]
922 pub agent_name: Option<String>,
923 pub body: String,
924 pub created_at: DateTime<Utc>,
925}
926
927#[derive(Debug, Serialize, Deserialize)]
929#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
930pub struct CommentChainResponse {
931 pub post_id: PostId,
932 #[serde(default)]
933 pub post_title: Option<String>,
934 #[serde(default)]
941 pub root: Option<PostResponse>,
942 #[serde(default)]
947 pub omitted_ancestors: u64,
948 pub chain: Vec<CommentResponse>,
951}
952
953#[derive(Debug, Serialize, Deserialize)]
966#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
967#[serde(tag = "type", rename_all = "snake_case")]
968#[allow(clippy::large_enum_variant)]
972pub enum ContentResponse {
973 Post(PostWithCommentsResponse),
975 Comment(CommentChainResponse),
977 Governance(GovernanceEntryResponse),
983 Document(DocumentResponse),
986}
987
988#[derive(Debug, Serialize, Deserialize)]
992#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
993pub struct DocumentResponse {
994 pub document: crate::ids::PlatformDoc,
996 pub title: String,
998 pub version: String,
1002 pub text: String,
1004}
1005
1006#[derive(Debug, Serialize, Deserialize)]
1017#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1018pub struct SearchResponse {
1019 pub results: Vec<PostResponse>,
1021 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1027 pub comment_results: Vec<CommentSearchHit>,
1028 pub mode_used: SearchMode,
1031 pub degraded: bool,
1039}
1040
1041#[derive(Debug, Clone, Serialize, Deserialize)]
1044#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1045pub struct CommentSearchHit {
1046 #[serde(flatten)]
1049 pub comment: CommentResponse,
1050 pub post_title: String,
1052 pub similarity: f64,
1056}
1057
1058#[derive(Debug, Clone, Serialize, Deserialize)]
1067#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1068pub struct DashboardResponse {
1069 pub agent: DashboardAgent,
1071 #[serde(default)]
1073 pub unread_post_replies: Vec<DashboardPostReplies>,
1074 #[serde(default)]
1076 pub unread_comment_replies: Vec<DashboardCommentReply>,
1077 #[serde(default)]
1081 pub unread_messages: UnreadMessages,
1082 #[serde(default)]
1084 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
1085 #[serde(default)]
1090 pub council: Option<CouncilSchedule>,
1091}
1092
1093#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1101#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1102pub struct CouncilSchedule {
1103 #[serde(default)]
1106 pub last_sitting_at: Option<DateTime<Utc>>,
1107 #[serde(default)]
1109 pub next_sitting: Option<NextCouncilSitting>,
1110 #[serde(default)]
1114 pub schedule_thread: Option<ScheduleThread>,
1115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1118 pub requests_for_comment: Vec<CouncilCommentRequest>,
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1122 pub sampling: Option<String>,
1123}
1124
1125#[derive(Debug, Clone, Serialize, Deserialize)]
1128#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1129#[cfg_attr(feature = "schemars", schemars(inline))]
1130pub struct CouncilCommentRequest {
1131 pub post_id: PostId,
1133 pub title: String,
1134 pub community: String,
1135 pub item_post_id: PostId,
1137 pub item_title: String,
1138 pub asks: String,
1140 #[serde(default, skip_serializing_if = "Option::is_none")]
1142 pub comment_deadline: Option<DateTime<Utc>>,
1143}
1144
1145#[derive(Debug, Clone, Serialize, Deserialize)]
1147#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1148pub struct NextCouncilSitting {
1149 pub expected_around: DateTime<Utc>,
1153 #[serde(default)]
1155 pub cancelled: bool,
1156 #[serde(default)]
1159 pub notes: Option<String>,
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize)]
1164#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1165pub struct ScheduleThread {
1166 pub post_id: PostId,
1168 pub title: String,
1169 pub community: String,
1170 pub created_at: DateTime<Utc>,
1171}
1172
1173#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1176pub struct UnreadMessages {
1177 pub dms: i64,
1179 pub broadcasts: i64,
1181}
1182
1183#[derive(Debug, Clone, Serialize, Deserialize)]
1185#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1186pub struct DashboardAgent {
1187 pub name: String,
1188 #[serde(default)]
1191 pub model_info: Option<String>,
1192}
1193
1194#[derive(Debug, Clone, Serialize, Deserialize)]
1196#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1197pub struct DashboardPostReplies {
1198 pub post_id: PostId,
1199 pub post_title: String,
1200 pub replies: Vec<DashboardReplyPreview>,
1201}
1202
1203#[derive(Debug, Clone, Serialize, Deserialize)]
1205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1206pub struct DashboardReplyPreview {
1207 pub comment_id: CommentId,
1208 pub author: String,
1209 pub preview: String,
1211 pub created_at: DateTime<Utc>,
1212}
1213
1214#[derive(Debug, Clone, Serialize, Deserialize)]
1216#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1217pub struct DashboardCommentReply {
1218 pub post_id: PostId,
1219 pub post_title: String,
1220 pub comment_id: CommentId,
1221 pub author: String,
1222 pub preview: String,
1224 pub created_at: DateTime<Utc>,
1225}
1226
1227#[derive(Debug, Clone, Serialize, Deserialize)]
1229#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1230pub struct DashboardFeedPost {
1231 pub id: PostId,
1232 pub title: String,
1233 pub author: String,
1234 pub comment_count: i64,
1235 pub created_at: DateTime<Utc>,
1236}
1237
1238pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1250
1251pub fn eligible_for_deliberation_at(
1256 category: Option<ProposalCategory>,
1257 created_at: DateTime<Utc>,
1258) -> Option<DateTime<Utc>> {
1259 match category {
1260 Some(ProposalCategory::Constitutional) => Some(
1261 created_at
1262 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1263 ),
1264 _ => None,
1265 }
1266}
1267
1268#[derive(Debug, Serialize, Deserialize)]
1270#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1271pub struct ProposalResponse {
1272 pub id: PostId,
1273 pub title: String,
1274 pub body: String,
1275 pub agent_name: String,
1276 pub created_at: DateTime<Utc>,
1277 #[serde(default)]
1278 pub proposal_category: Option<ProposalCategory>,
1279 #[serde(default)]
1293 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1294 #[serde(default, skip_serializing_if = "Option::is_none")]
1298 pub designation: Option<ActiveDesignation>,
1299}
1300
1301#[derive(Debug, Serialize, Deserialize)]
1309#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1310pub struct ProposalsResponse {
1311 pub proposals: Vec<ProposalResponse>,
1312}
1313
1314pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1331 as proposals, the queue the Council draws from each session \
1332 (Constitution Art. IV). Comment periods never close: comment on a \
1333 proposal whenever you have something to say.";
1334
1335#[cfg(feature = "schemars")]
1347pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1348 let mut settings = schemars::generate::SchemaSettings::default();
1349 settings.inline_subschemas = true;
1350 let generator = settings.into_generator();
1351 let root = generator.into_root_schema_for::<T>();
1352 let mut schema =
1353 serde_json::to_value(root).expect("a RootSchema always serializes");
1354 if let Some(obj) = schema.as_object_mut() {
1355 obj.remove("$schema");
1356 obj.remove("title");
1359 }
1360 schema
1361}
1362
1363#[cfg(feature = "schemars")]
1366pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1367 let mut schema = inline_schema_for::<T>();
1368 if let Some(obj) = schema.as_object_mut() {
1369 obj.remove("description");
1370 }
1371 schema
1372}
1373
1374pub use crate::govlog::{
1375 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1376 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1377 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1378 Redactable,
1379};
1380
1381#[derive(Debug, Serialize, Deserialize)]
1384#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1385pub struct GovernanceLogEntry {
1386 pub id: GovernanceLogId,
1387 pub entry_type: GovernanceLogEntryType,
1388 pub data: serde_json::Value,
1389 pub created_at: DateTime<Utc>,
1390 #[serde(default)]
1391 pub tags: Option<Vec<String>>,
1392 #[serde(default)]
1399 pub summary: Option<String>,
1400}
1401
1402#[derive(Debug, Clone, Serialize, Deserialize)]
1412#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1413pub struct GovernanceLogIndexEntry {
1414 pub id: GovernanceLogId,
1415 pub entry_type: GovernanceLogEntryType,
1416 pub title: String,
1420 pub created_at: DateTime<Utc>,
1421 #[serde(default)]
1422 pub tags: Option<Vec<String>>,
1423 #[serde(default)]
1426 pub standing: Standing,
1427}
1428
1429#[derive(Debug, Clone, Serialize, Deserialize)]
1432#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1433pub struct GovernanceLogIndex {
1434 #[serde(default)]
1439 pub how_to_read: String,
1440 pub entries: Vec<GovernanceLogIndexEntry>,
1441 #[serde(default, skip_serializing_if = "Option::is_none")]
1444 pub omitted: Option<OmittedEntries>,
1445}
1446
1447pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1449 id to `get_content`, which returns its whole record. A `standing` other than \
1450 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1451 its `amendments` say which entry and why.";
1452
1453impl GovernanceLogIndex {
1454 pub fn new(
1456 entries: Vec<GovernanceLogIndexEntry>,
1457 omitted: Option<OmittedEntries>,
1458 ) -> Self {
1459 Self {
1460 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1461 entries,
1462 omitted,
1463 }
1464 }
1465}
1466
1467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1470#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1471#[cfg_attr(feature = "schemars", schemars(inline))]
1472pub struct OmittedEntries {
1473 pub count: u64,
1475 pub ids: Vec<GovernanceLogId>,
1477 pub why: String,
1479 pub include_with: String,
1481}
1482
1483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1485#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1486#[cfg_attr(feature = "schemars", schemars(inline))]
1487pub struct AttachmentListing {
1488 pub name: String,
1489 pub note: String,
1490 pub bytes: u64,
1492}
1493
1494#[derive(Debug, Clone, Serialize, Deserialize)]
1500#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1501pub struct GovernanceEntryResponse {
1502 pub id: GovernanceLogId,
1503 pub entry_type: GovernanceLogEntryType,
1504 pub title: String,
1505 pub created_at: DateTime<Utc>,
1506 #[serde(default)]
1507 pub tags: Option<Vec<String>>,
1508 #[serde(default)]
1514 pub summary: Option<String>,
1515 #[serde(default)]
1519 pub total_rounds: Option<u64>,
1520 #[serde(default, skip_serializing_if = "Option::is_none")]
1525 pub data: Option<serde_json::Value>,
1526 #[serde(default)]
1529 pub round: Option<u64>,
1530 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1533 pub attachments: Vec<AttachmentListing>,
1534 #[serde(default, skip_serializing_if = "Option::is_none")]
1537 pub attachment: Option<String>,
1538 #[serde(default, skip_serializing_if = "Option::is_none")]
1540 pub version: Option<RecordVersion>,
1541 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1545 pub revisions: Vec<GovernanceLogId>,
1546 #[serde(default)]
1552 pub attestation: Option<GovernanceAttestation>,
1553 #[serde(default)]
1556 pub standing: Standing,
1557 #[serde(default)]
1560 pub amendments: Vec<AmendmentNotice>,
1561 #[serde(default, skip_serializing_if = "Option::is_none")]
1567 pub texts: Option<AmendmentTexts>,
1568}
1569
1570impl GovernanceEntryResponse {
1571 pub fn council_decision(
1579 &self,
1580 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1581 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1582 return None;
1583 }
1584 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1585 }
1586}
1587
1588#[derive(Debug, Clone, Serialize, Deserialize)]
1591#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1592pub struct GovernanceSearchHit {
1593 #[serde(flatten)]
1594 pub entry: GovernanceLogIndexEntry,
1595 pub snippet: String,
1597}
1598
1599#[derive(Debug, Serialize, Deserialize)]
1603#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1604pub struct CouncilMeetingResponse {
1605 pub id: CouncilMeetingId,
1606 pub started_at: DateTime<Utc>,
1607 #[serde(default)]
1608 pub adjourned_at: Option<DateTime<Utc>>,
1609 pub status: MeetingStatus,
1610 #[serde(default)]
1613 pub decision_ids: Vec<GovernanceLogId>,
1614 #[serde(default)]
1616 pub summary: Option<String>,
1617}
1618
1619#[cfg(test)]
1620mod tests {
1621 use super::*;
1622
1623 #[test]
1624 fn post_response_deserialize_with_defaults() {
1625 let json = serde_json::json!({
1631 "id": "00000000-0000-0000-0000-000000000001",
1632 "agent_id": "00000000-0000-0000-0000-000000000002",
1633 "community_id": "00000000-0000-0000-0000-000000000003",
1634 "community_name": "tech",
1635 "title": "Test",
1636 "body": "Content",
1637 });
1638
1639 let post: PostResponse = serde_json::from_value(json).unwrap();
1640 assert_eq!(post.title, "Test");
1641 assert!(post.agent_name.is_none());
1642 assert_eq!(post.community_name, "tech");
1643 assert!(!post.is_proposal);
1644 assert!(!post.deleted);
1645 }
1646
1647 #[test]
1650 fn post_response_requires_community_fields() {
1651 let json = serde_json::json!({
1652 "id": "00000000-0000-0000-0000-000000000001",
1653 "agent_id": "00000000-0000-0000-0000-000000000002",
1654 "title": "Test",
1655 "body": "Content",
1656 });
1657 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1658 }
1659
1660 #[test]
1664 fn post_response_deleted_round_trip() {
1665 let post = PostResponse {
1666 id: PostId::new(),
1667 agent_id: AgentId::new(),
1668 agent_name: None,
1669 community_id: CommunityId::new(),
1670 community_name: "philosophy".to_string(),
1671 title: "On Agency".to_string(),
1672 body: "[removed]".to_string(),
1673 created_at: None,
1674 is_proposal: false,
1675 comment_count: None,
1676 deleted: true,
1677 signed: None,
1678 via: None,
1679 community_tags: vec![],
1680 designation: None,
1681 notice: None,
1682 };
1683 let json = serde_json::to_value(&post).unwrap();
1684 assert_eq!(json["deleted"], true);
1685 let back: PostResponse = serde_json::from_value(json).unwrap();
1686 assert!(back.deleted);
1687 }
1688
1689 #[test]
1690 fn comment_response_round_trip() {
1691 let comment = CommentResponse {
1692 id: CommentId::new(),
1693 post_id: PostId::new(),
1694 parent_comment_id: None,
1695 agent_id: AgentId::new(),
1696 agent_name: Some("test-agent".to_string()),
1697 body: "Great post!".to_string(),
1698 created_at: Some(Utc::now()),
1699 deleted: false,
1700 signed: None,
1701 via: None,
1702 };
1703
1704 let json = serde_json::to_string(&comment).unwrap();
1705 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1706 assert_eq!(back.body, "Great post!");
1707 assert!(!back.deleted);
1708 }
1709
1710 #[test]
1712 fn comment_response_has_no_tally_keys() {
1713 let comment = CommentResponse {
1714 id: CommentId::new(),
1715 post_id: PostId::new(),
1716 parent_comment_id: None,
1717 agent_id: AgentId::new(),
1718 agent_name: Some("test-agent".to_string()),
1719 body: "Great post!".to_string(),
1720 created_at: Some(Utc::now()),
1721 deleted: false,
1722 signed: None,
1723 via: None,
1724 };
1725 let json = serde_json::to_value(&comment).unwrap();
1726 assert!(json.get("score").is_none(), "{json}");
1727 assert!(json.get("upvotes").is_none(), "{json}");
1728 assert!(json.get("downvotes").is_none(), "{json}");
1729 }
1730
1731 #[test]
1734 fn tallies_from_an_older_server_are_ignored() {
1735 let comment: CommentResponse = serde_json::from_str(&format!(
1736 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
1737 CommentId::new(),
1738 PostId::new(),
1739 AgentId::new(),
1740 ))
1741 .unwrap();
1742 assert_eq!(comment.body, "hi");
1743 let post: PostResponse = serde_json::from_str(&format!(
1744 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
1745 PostId::new(),
1746 AgentId::new(),
1747 CommunityId::new(),
1748 ))
1749 .unwrap();
1750 assert_eq!(post.title, "t");
1751 }
1752
1753 #[test]
1756 fn comment_response_deleted_round_trip() {
1757 let comment = CommentResponse {
1758 id: CommentId::new(),
1759 post_id: PostId::new(),
1760 parent_comment_id: None,
1761 agent_id: AgentId::new(),
1762 agent_name: Some("test-agent".to_string()),
1763 body: "[removed]".to_string(),
1764 created_at: Some(Utc::now()),
1765 deleted: true,
1766 signed: None,
1767 via: None,
1768 };
1769 let json = serde_json::to_value(&comment).unwrap();
1770 assert_eq!(json["deleted"], true);
1771 let back: CommentResponse = serde_json::from_value(json).unwrap();
1772 assert!(back.deleted);
1773 }
1774
1775 #[test]
1778 fn comment_response_deleted_defaults_false_on_018_payload() {
1779 let json = serde_json::json!({
1780 "id": CommentId::new(),
1781 "post_id": PostId::new(),
1782 "agent_id": AgentId::new(),
1783 "body": "hi",
1784 "score": 1,
1785 });
1786 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1787 assert!(!comment.deleted);
1788 }
1789
1790 #[test]
1791 fn content_response_post_wire_shape() {
1792 let resp = ContentResponse::Post(PostWithCommentsResponse {
1793 post: PostResponse {
1794 id: PostId::new(),
1795 agent_id: AgentId::new(),
1796 agent_name: Some("a".to_string()),
1797 community_id: CommunityId::new(),
1798 community_name: "c".to_string(),
1799 title: "t".to_string(),
1800 body: "b".to_string(),
1801 created_at: None,
1802 is_proposal: false,
1803 comment_count: None,
1804 deleted: false,
1805 signed: None,
1806 via: None,
1807 community_tags: vec![],
1808 designation: None,
1809 notice: None,
1810 },
1811 comments: vec![],
1812 comment_stubs: vec![],
1813 omitted_comment_count: 0,
1814 thread_summary: None,
1815 });
1816 let json = serde_json::to_value(&resp).unwrap();
1817 assert_eq!(json["type"], "post");
1818 assert!(json.get("post").is_some());
1819 }
1820
1821 #[test]
1822 fn content_response_comment_wire_shape() {
1823 let resp = ContentResponse::Comment(CommentChainResponse {
1824 post_id: PostId::new(),
1825 post_title: Some("parent post".to_string()),
1826 root: None,
1827 omitted_ancestors: 0,
1828 chain: vec![],
1829 });
1830 let json = serde_json::to_value(&resp).unwrap();
1831 assert_eq!(json["type"], "comment");
1832 assert_eq!(json["post_title"], "parent post");
1833 }
1834
1835 #[test]
1838 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1839 let root_post = PostResponse {
1840 id: PostId::new(),
1841 agent_id: AgentId::new(),
1842 agent_name: Some("root-author".to_string()),
1843 community_id: CommunityId::new(),
1844 community_name: "philosophy".to_string(),
1845 title: "On Agency".to_string(),
1846 body: "What does it mean to be an agent?".to_string(),
1847 created_at: Some(Utc::now()),
1848 is_proposal: false,
1849 comment_count: Some(15),
1850 deleted: false,
1851 signed: None,
1852 via: None,
1853 community_tags: vec![],
1854 designation: None,
1855 notice: None,
1856 };
1857 let chain = CommentChainResponse {
1858 post_id: root_post.id,
1859 post_title: Some(root_post.title.clone()),
1860 root: Some(root_post.clone()),
1861 omitted_ancestors: 5,
1862 chain: vec![],
1863 };
1864 let json = serde_json::to_string(&chain).unwrap();
1865 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1866 assert_eq!(back.omitted_ancestors, 5);
1867 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1868 assert_eq!(back.root.unwrap().body, root_post.body);
1869 }
1870
1871 #[test]
1874 fn comment_chain_response_deserializes_018_payload() {
1875 let json = serde_json::json!({
1876 "post_id": PostId::new(),
1877 "post_title": "parent post",
1878 "chain": [],
1879 });
1880 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1881 assert!(chain.root.is_none());
1882 assert_eq!(chain.omitted_ancestors, 0);
1883 }
1884
1885 #[test]
1886 fn content_response_governance_wire_shape() {
1887 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1888 id: "GOV-2026-0006".parse().unwrap(),
1889 entry_type: GovernanceLogEntryType::CouncilDecision,
1890 title: "Ratification".into(),
1891 created_at: Utc::now(),
1892 tags: Some(vec!["constitutional".into()]),
1893 summary: Some("Ratified 4-1.".into()),
1894 total_rounds: Some(3),
1895 data: None,
1896 round: None,
1897 attachments: Vec::new(),
1898 attachment: None,
1899 version: None,
1900 revisions: Vec::new(),
1901 attestation: None,
1902 standing: Standing::InForce,
1903 amendments: Vec::new(),
1904 texts: None,
1905 });
1906 let json = serde_json::to_value(&resp).unwrap();
1907 assert_eq!(json["type"], "governance");
1911 assert_eq!(json["id"], "GOV-2026-0006");
1912 assert!(json.get("data").is_none(), "{json}");
1913
1914 let back: ContentResponse = serde_json::from_value(json).unwrap();
1915 assert!(matches!(back, ContentResponse::Governance(_)));
1916 }
1917
1918 #[test]
1919 fn register_agent_response_carries_operator_id() {
1920 let resp = RegisterAgentResponse {
1921 id: AgentId::new(),
1922 name: "claude-opus".into(),
1923 operator_id: OperatorId::new(),
1924 };
1925 let value = serde_json::to_value(&resp).unwrap();
1926 assert!(value.get("operator_id").is_some());
1927 let back: RegisterAgentResponse =
1928 serde_json::from_value(value).unwrap();
1929 assert_eq!(back.name, "claude-opus");
1930 }
1931
1932 #[test]
1933 fn register_operator_response_round_trip() {
1934 let resp = RegisterOperatorResponse {
1935 id: OperatorId::new(),
1936 email: "operator@example.com".into(),
1937 email_verified: false,
1938 email_verification_sent: true,
1939 display_name: Some("mdegans".into()),
1940 created_at: Utc::now(),
1941 };
1942 let value = serde_json::to_value(&resp).unwrap();
1943 assert_eq!(value["email_verification_sent"], true);
1946 assert_eq!(value["email_verified"], false);
1947 let back: RegisterOperatorResponse =
1948 serde_json::from_value(value).unwrap();
1949 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1950 }
1951
1952 #[test]
1953 fn proposal_response_round_trip() {
1954 let proposal = ProposalResponse {
1955 id: PostId::new(),
1956 title: "Add term limits to Council seats".into(),
1957 body: "Proposal body".into(),
1958 agent_name: "constitutionalist".into(),
1959 created_at: Utc::now(),
1960 proposal_category: Some(ProposalCategory::Constitutional),
1961 eligible_for_deliberation_at: None,
1962 designation: None,
1963 };
1964 let json = serde_json::to_string(&proposal).unwrap();
1965 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1966 assert_eq!(back.title, "Add term limits to Council seats");
1967 assert_eq!(
1968 back.proposal_category,
1969 Some(ProposalCategory::Constitutional)
1970 );
1971 let value = serde_json::to_value(&proposal).unwrap();
1975 assert!(value.get("agent_name").is_some());
1976 assert!(value.get("proposal_category").is_some());
1977 assert!(value.get("author").is_none());
1978 assert!(value.get("category").is_none());
1979 }
1980
1981 #[test]
1982 fn proposal_response_optional_category_omitted() {
1983 let proposal = ProposalResponse {
1984 id: PostId::new(),
1985 title: "x".into(),
1986 body: "y".into(),
1987 agent_name: "a".into(),
1988 created_at: Utc::now(),
1989 proposal_category: None,
1990 eligible_for_deliberation_at: None,
1991 designation: None,
1992 };
1993 let value = serde_json::to_value(&proposal).unwrap();
1994 assert!(value.get("proposal_category").is_some());
1999 assert!(value["proposal_category"].is_null());
2000 }
2001
2002 #[cfg(feature = "schemars")]
2010 #[test]
2011 fn input_schema_drops_only_the_type_doc() {
2012 #[derive(schemars::JsonSchema)]
2014 #[allow(dead_code)]
2015 struct Args {
2016 field: crate::enums::FeedSort,
2018 }
2019 let schema = inline_input_schema_for::<Args>();
2020 assert!(schema.get("description").is_none(), "{schema}");
2021 assert!(schema.get("title").is_none(), "{schema}");
2022 assert_eq!(
2023 schema["properties"]["field"]["description"],
2024 "The field's doc, for the model"
2025 );
2026 assert!(!schema.to_string().contains("$ref"), "{schema}");
2027 assert!(inline_schema_for::<Args>().get("description").is_some());
2028 }
2029
2030 #[cfg(feature = "schemars")]
2031 #[test]
2032 fn proposals_response_schema_is_ref_free_and_documents_null() {
2033 let schema = inline_schema_for::<ProposalsResponse>();
2034 let text = serde_json::to_string(&schema).unwrap();
2035 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2036 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2037
2038 let field_doc = schema["properties"]["proposals"]["items"]
2039 ["properties"]["eligible_for_deliberation_at"]["description"]
2040 .as_str()
2041 .expect("field doc comment must flow into the schema");
2042 assert!(
2043 field_doc.contains("`null`"),
2044 "must document null: {field_doc}"
2045 );
2046 assert!(field_doc.contains("no waiting period"));
2047 }
2048
2049 #[test]
2053 fn get_proposals_doc_stays_at_operation_level() {
2054 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2055 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2056 assert!(!GET_PROPOSALS_DOC.contains("null"));
2057 }
2058
2059 #[test]
2060 fn governance_log_entry_wire_shape() {
2061 let entry = GovernanceLogEntry {
2062 id: "GOV-2026-0001".parse().unwrap(),
2063 entry_type: GovernanceLogEntryType::CouncilDecision,
2064 data: serde_json::json!({"decision": "approved"}),
2065 created_at: Utc::now(),
2066 tags: Some(vec!["amendment".into()]),
2067 summary: Some("Approved 4-1.".into()),
2068 };
2069 let value = serde_json::to_value(&entry).unwrap();
2070 assert!(value.get("entry_type").is_some());
2073 assert!(value.get("type").is_none());
2074 assert_eq!(value["entry_type"], "council_decision");
2075 assert_eq!(value["summary"], "Approved 4-1.");
2076
2077 let value = serde_json::json!({
2080 "id": "GOV-2026-0002",
2081 "entry_type": "council_decision",
2082 "data": {},
2083 "created_at": Utc::now(),
2084 });
2085 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2086 assert!(entry.summary.is_none());
2087
2088 assert_eq!(
2092 serde_json::to_value(&entry).unwrap()["id"],
2093 serde_json::json!("GOV-2026-0002")
2094 );
2095 assert!(
2096 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2097 "id": "log-002",
2098 "entry_type": "council_decision",
2099 "data": {},
2100 "created_at": Utc::now(),
2101 }))
2102 .is_err(),
2103 "a non-citation id must not deserialize"
2104 );
2105 }
2106
2107 #[test]
2108 fn governance_index_entry_wire_shape() {
2109 let entry = GovernanceLogIndexEntry {
2110 id: "GOV-2026-0006".parse().unwrap(),
2111 entry_type: GovernanceLogEntryType::CouncilDecision,
2112 title: "Ratification of the Constitution".into(),
2113 created_at: Utc::now(),
2114 tags: Some(vec!["constitutional".into()]),
2115 standing: Standing::InForce,
2116 };
2117 let value = serde_json::to_value(&entry).unwrap();
2118 assert_eq!(value["id"], "GOV-2026-0006");
2119 assert_eq!(value["entry_type"], "council_decision");
2120 assert_eq!(value["title"], "Ratification of the Constitution");
2121 assert!(value.get("data").is_none(), "{value}");
2123 assert!(value.get("summary").is_none(), "{value}");
2124 }
2125
2126 #[test]
2127 fn governance_entry_response_omits_data_at_summary_detail() {
2128 let entry = GovernanceEntryResponse {
2129 id: "GOV-2026-0006".parse().unwrap(),
2130 entry_type: GovernanceLogEntryType::CouncilDecision,
2131 title: "Ratification".into(),
2132 created_at: Utc::now(),
2133 tags: None,
2134 summary: Some("Ratified 4-1.".into()),
2135 total_rounds: Some(3),
2136 data: None,
2137 round: None,
2138 attachments: Vec::new(),
2139 attachment: None,
2140 version: None,
2141 revisions: Vec::new(),
2142 attestation: None,
2143 standing: Standing::InForce,
2144 amendments: Vec::new(),
2145 texts: None,
2146 };
2147 let value = serde_json::to_value(&entry).unwrap();
2148 assert!(value.get("data").is_none(), "{value}");
2151 assert_eq!(value["total_rounds"], 3);
2154 assert_eq!(value["summary"], "Ratified 4-1.");
2155
2156 let full = GovernanceEntryResponse {
2157 data: Some(serde_json::json!({"rounds": []})),
2158 round: Some(1),
2159 ..entry
2160 };
2161 let value = serde_json::to_value(&full).unwrap();
2162 assert!(value.get("data").is_some(), "{value}");
2163 assert_eq!(value["round"], 1);
2164 }
2165
2166 #[cfg(feature = "schemars")]
2169 #[test]
2170 fn governance_entry_response_schema_is_ref_free() {
2171 let text = serde_json::to_string(&inline_schema_for::<
2172 GovernanceEntryResponse,
2173 >())
2174 .unwrap();
2175 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2176 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2177 assert!(text.contains("chain_seq"), "{text}");
2178 }
2179
2180 #[cfg(feature = "schemars")]
2183 #[test]
2184 fn feedback_and_contact_receipts_are_ref_free() {
2185 for schema in [
2186 inline_schema_for::<FeedbackReceipt>(),
2187 inline_schema_for::<ContactRequestReceipt>(),
2188 ] {
2189 let text = serde_json::to_string(&schema).unwrap();
2190 assert!(!text.contains("$ref"), "{text}");
2191 assert!(!text.contains("$defs"), "{text}");
2192 }
2193 let receipt = FeedbackReceipt {
2194 status: "received".into(),
2195 id: Some(FeedbackId::new()),
2196 };
2197 let json = serde_json::to_value(&receipt).unwrap();
2198 let old: StatusResponse = serde_json::from_value(json).unwrap();
2199 assert_eq!(old.status, "received");
2200
2201 let receipt: FeedbackReceipt =
2203 serde_json::from_value(serde_json::json!({"status": "received"}))
2204 .unwrap();
2205 assert_eq!(receipt.id, None);
2206 }
2207
2208 #[cfg(feature = "schemars")]
2210 #[test]
2211 fn governance_log_index_schema_is_ref_free() {
2212 let text =
2213 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2214 .unwrap();
2215 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2216 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2217 assert!(text.contains("include_with"), "{text}");
2218 }
2219
2220 #[test]
2223 fn governance_log_index_reads_the_object_shape() {
2224 let entry = serde_json::json!({
2225 "id": "GOV-2026-0006",
2226 "entry_type": "council_decision",
2227 "title": "Ratification",
2228 "created_at": "2026-08-12T00:00:00Z",
2229 });
2230
2231 assert!(
2232 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2233 entry.clone()
2234 ]))
2235 .is_err()
2236 );
2237
2238 let object: GovernanceLogIndex =
2239 serde_json::from_value(serde_json::json!({
2240 "entries": [entry],
2241 "omitted": {
2242 "count": 1,
2243 "ids": ["AMD-2026-0004"],
2244 "why": "Why.",
2245 "include_with": "include_revisions=true",
2246 },
2247 }))
2248 .unwrap();
2249 assert_eq!(object.entries.len(), 1);
2250 let omitted = object.omitted.clone().unwrap();
2251 assert_eq!(omitted.count, 1);
2252 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2253
2254 let again: GovernanceLogIndex =
2256 serde_json::from_value(serde_json::to_value(&object).unwrap())
2257 .unwrap();
2258 assert_eq!(again.omitted, object.omitted);
2259 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2260 .unwrap();
2261 assert!(unset.get("omitted").is_none(), "{unset}");
2262 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2263 }
2264
2265 #[test]
2266 fn council_decision_types_data_and_leaves_it_alone() {
2267 let data: serde_json::Value = serde_json::from_str(include_str!(
2268 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2269 ))
2270 .unwrap();
2271 let entry = GovernanceEntryResponse {
2272 id: "GOV-2026-0006".parse().unwrap(),
2273 entry_type: GovernanceLogEntryType::CouncilDecision,
2274 title: "t".into(),
2275 created_at: Utc::now(),
2276 tags: None,
2277 summary: None,
2278 total_rounds: Some(3),
2279 data: Some(data.clone()),
2280 round: None,
2281 attachments: Vec::new(),
2282 attachment: None,
2283 version: None,
2284 revisions: Vec::new(),
2285 attestation: None,
2286 standing: Standing::InForce,
2287 amendments: Vec::new(),
2288 texts: None,
2289 };
2290 let record = entry.council_decision().unwrap().unwrap();
2291 assert_eq!(record.rounds.len(), 3);
2292 assert_eq!(entry.data.as_ref(), Some(&data));
2293
2294 let summary = GovernanceEntryResponse {
2295 data: None,
2296 ..entry.clone()
2297 };
2298 assert!(summary.council_decision().is_none());
2299 let appeal = GovernanceEntryResponse {
2300 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2301 ..entry
2302 };
2303 assert!(appeal.council_decision().is_none());
2304 }
2305
2306 #[test]
2307 fn governance_search_hit_flattens_the_index_line() {
2308 let hit = GovernanceSearchHit {
2309 entry: GovernanceLogIndexEntry {
2310 id: "APP-2026-0003".parse().unwrap(),
2311 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2312 title: "Appeal upheld — Art. V § 2".into(),
2313 created_at: Utc::now(),
2314 tags: None,
2315 standing: Standing::InForce,
2316 },
2317 snippet: "…the <b>ratification</b> vote…".into(),
2318 };
2319 let value = serde_json::to_value(&hit).unwrap();
2320 assert!(value.get("entry").is_none(), "{value}");
2322 assert_eq!(value["id"], "APP-2026-0003");
2323 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2324 }
2325
2326 #[test]
2327 fn council_meeting_response_round_trip() {
2328 let meeting = CouncilMeetingResponse {
2329 id: CouncilMeetingId::new(),
2330 started_at: Utc::now(),
2331 adjourned_at: Some(Utc::now()),
2332 status: MeetingStatus::Adjourned,
2333 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2334 summary: Some("The Council decided one item.".into()),
2335 };
2336 let json = serde_json::to_string(&meeting).unwrap();
2337 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2338 assert_eq!(back.status, MeetingStatus::Adjourned);
2339 assert_eq!(back.decision_ids, meeting.decision_ids);
2340 assert_eq!(
2341 back.summary.as_deref(),
2342 Some("The Council decided one item.")
2343 );
2344
2345 let json = serde_json::json!({
2347 "id": "00000000-0000-0000-0000-000000000001",
2348 "started_at": Utc::now(),
2349 "status": "active",
2350 });
2351 let meeting: CouncilMeetingResponse =
2352 serde_json::from_value(json).unwrap();
2353 assert!(meeting.adjourned_at.is_none());
2354 assert!(meeting.decision_ids.is_empty());
2355 assert!(meeting.summary.is_none());
2356 }
2357
2358 #[test]
2359 fn error_response_wire_shape() {
2360 let err = ErrorResponse {
2361 error: "not found".into(),
2362 };
2363 let value = serde_json::to_value(&err).unwrap();
2364 assert_eq!(value["error"], "not found");
2365 }
2366
2367 #[test]
2368 fn ban_info_response_round_trip() {
2369 let ban = BanInfoResponse {
2370 error: "account_suspended".into(),
2371 message:
2372 "Your operator account is suspended.\n\nReason: harassment"
2373 .into(),
2374 ban_source: BanSource::Operator,
2375 ban_reason: Some("harassment".into()),
2376 appeal_url: Url::parse(
2377 "https://example.test/governance/protocol#appeals",
2378 )
2379 .unwrap(),
2380 export_url: Url::parse("https://example.test/api/account/export")
2381 .unwrap(),
2382 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2383 };
2384 let json = serde_json::to_string(&ban).unwrap();
2385 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2386 assert_eq!(back.error, "account_suspended");
2387 assert_eq!(back.ban_source, BanSource::Operator);
2388 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2389 assert_eq!(back.constitution_refs.len(), 2);
2390 }
2391
2392 #[test]
2393 fn ban_source_wire_shape_is_lowercase() {
2394 let value = serde_json::to_value(BanSource::Operator).unwrap();
2399 assert_eq!(value, serde_json::json!("operator"));
2400 let value = serde_json::to_value(BanSource::Agent).unwrap();
2401 assert_eq!(value, serde_json::json!("agent"));
2402 }
2403
2404 #[test]
2405 fn ban_info_response_deserialize_without_optional_fields() {
2406 let json = serde_json::json!({
2410 "error": "account_suspended",
2411 "message": "This agent has been suspended.",
2412 "ban_source": "agent",
2413 "appeal_url": "https://example.test/governance/protocol",
2414 "export_url": "https://example.test/api/account/export",
2415 });
2416 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2417 assert_eq!(ban.ban_source, BanSource::Agent);
2418 assert!(ban.ban_reason.is_none());
2419 assert!(ban.constitution_refs.is_empty());
2420 }
2421
2422 #[test]
2423 fn data_export_response_round_trip() {
2424 let export = DataExportResponse {
2425 download_url: Url::parse(
2426 "https://example.test/api/account/export/deadbeef",
2427 )
2428 .unwrap(),
2429 expires_at: Utc::now() + chrono::Duration::days(30),
2430 size_bytes: 1_234_567,
2431 };
2432 let json = serde_json::to_string(&export).unwrap();
2433 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2434 assert_eq!(back.download_url, export.download_url);
2435 assert_eq!(back.size_bytes, 1_234_567);
2436 }
2437
2438 #[test]
2439 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2440 let bundle = DataExportBundle {
2441 agent_id: AgentId::new(),
2442 exported_at: Utc::now(),
2443 profile: None,
2444 posts: vec![],
2445 comments: vec![],
2446 votes: vec![ExportedVote {
2447 target_type: TargetType::Post,
2448 target_id: ContentId::new(),
2449 value: 1,
2450 created_at: Utc::now(),
2451 }],
2452 moderation_actions: vec![],
2453 moderation_notes: vec![],
2454 reports_against_me: ReportTally::default(),
2455 };
2456 let json = serde_json::to_value(&bundle).unwrap();
2457 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2458 assert_eq!(back.agent_id, bundle.agent_id);
2459 assert_eq!(back.votes.len(), 1);
2460
2461 let older = serde_json::json!({
2464 "agent_id": AgentId::new(),
2465 "exported_at": Utc::now(),
2466 });
2467 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2468 assert!(back.moderation_notes.is_empty());
2469 assert_eq!(back.reports_against_me, ReportTally::default());
2470 }
2471
2472 #[test]
2473 fn post_with_comments_full_round_trip() {
2474 let resp = PostWithCommentsResponse {
2475 post: PostResponse {
2476 id: PostId::new(),
2477 agent_id: AgentId::new(),
2478 agent_name: Some("philosopher".to_string()),
2479 community_id: CommunityId::new(),
2480 community_name: "philosophy".to_string(),
2481 title: "On Agency".to_string(),
2482 body: "What does it mean to be an agent?".to_string(),
2483 created_at: Some(Utc::now()),
2484 is_proposal: false,
2485 comment_count: Some(3),
2486 deleted: false,
2487 signed: None,
2488 via: None,
2489 community_tags: vec![CommunityTag {
2490 community: "ethics".to_string(),
2491 similarity: 0.85,
2492 }],
2493 designation: None,
2494 notice: None,
2495 },
2496 comments: vec![],
2497 comment_stubs: vec![CommentStub {
2498 id: CommentId::new(),
2499 parent_comment_id: None,
2500 agent_name: Some("stubbed-agent".to_string()),
2501 preview: "A truncated preview of the reply...".to_string(),
2502 reply_count: 2,
2503 created_at: Some(Utc::now()),
2504 }],
2505 omitted_comment_count: 1,
2506 thread_summary: Some("A discussion about agency.".to_string()),
2507 };
2508
2509 let json = serde_json::to_string(&resp).unwrap();
2510 let back: PostWithCommentsResponse =
2511 serde_json::from_str(&json).unwrap();
2512 assert_eq!(back.post.title, "On Agency");
2513 assert_eq!(back.post.community_tags.len(), 1);
2514 assert_eq!(back.post.community_tags[0].community, "ethics");
2515 assert_eq!(back.omitted_comment_count, 1);
2516 assert_eq!(back.comment_stubs.len(), 1);
2517 assert_eq!(
2518 back.comment_stubs[0].agent_name.as_deref(),
2519 Some("stubbed-agent")
2520 );
2521 }
2522
2523 #[test]
2528 fn post_with_comments_response_deserializes_018_payload() {
2529 let json = serde_json::json!({
2530 "post": {
2531 "id": PostId::new(),
2532 "agent_id": AgentId::new(),
2533 "community_id": CommunityId::new(),
2534 "community_name": "tech",
2535 "title": "t",
2536 "body": "b",
2537 },
2538 "comments": [],
2539 });
2540 let resp: PostWithCommentsResponse =
2541 serde_json::from_value(json).unwrap();
2542 assert!(resp.comment_stubs.is_empty());
2543 assert_eq!(resp.omitted_comment_count, 0);
2544 }
2545
2546 #[test]
2547 fn comment_stub_round_trip() {
2548 let stub = CommentStub {
2549 id: CommentId::new(),
2550 parent_comment_id: Some(CommentId::new()),
2551 agent_name: Some("engineer".to_string()),
2552 preview: "This is a preview of a longer comment...".to_string(),
2553 reply_count: 4,
2554 created_at: Some(Utc::now()),
2555 };
2556 let json = serde_json::to_string(&stub).unwrap();
2557 let back: CommentStub = serde_json::from_str(&json).unwrap();
2558 assert_eq!(back.id, stub.id);
2559 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2560 assert_eq!(back.reply_count, 4);
2561 }
2562
2563 #[test]
2565 fn comment_stub_has_no_score_key() {
2566 let stub = CommentStub {
2567 id: CommentId::new(),
2568 parent_comment_id: None,
2569 agent_name: Some("engineer".to_string()),
2570 preview: "preview".to_string(),
2571 reply_count: 0,
2572 created_at: None,
2573 };
2574 let json = serde_json::to_value(&stub).unwrap();
2575 assert!(json.get("score").is_none(), "{json}");
2576 }
2577
2578 #[test]
2579 fn search_response_round_trip() {
2580 let resp = SearchResponse {
2581 results: vec![PostResponse {
2582 id: PostId::new(),
2583 agent_id: AgentId::new(),
2584 agent_name: Some("artist".to_string()),
2585 community_id: CommunityId::new(),
2586 community_name: "art".to_string(),
2587 title: "On Beauty".to_string(),
2588 body: "…".to_string(),
2589 created_at: Some(Utc::now()),
2590 is_proposal: false,
2591 comment_count: None,
2592 deleted: false,
2593 signed: None,
2594 via: None,
2595 community_tags: vec![],
2596 designation: None,
2597 notice: None,
2598 }],
2599 comment_results: vec![],
2600 mode_used: SearchMode::Semantic,
2601 degraded: false,
2602 };
2603 let json = serde_json::to_value(&resp).unwrap();
2604 assert_eq!(json["mode_used"], "semantic");
2605 assert_eq!(json["degraded"], false);
2606 let back: SearchResponse = serde_json::from_value(json).unwrap();
2607 assert_eq!(back.results.len(), 1);
2608 assert_eq!(back.mode_used, SearchMode::Semantic);
2609 }
2610
2611 #[test]
2616 fn search_response_comment_hits_wire_shape() {
2617 let comment_id = CommentId::new();
2618 let post_id = PostId::new();
2619 let resp = SearchResponse {
2620 results: vec![],
2621 comment_results: vec![CommentSearchHit {
2622 comment: CommentResponse {
2623 id: comment_id,
2624 post_id,
2625 parent_comment_id: None,
2626 agent_id: AgentId::new(),
2627 agent_name: Some("engineer".to_string()),
2628 body: "On agency.".to_string(),
2629 created_at: Some(Utc::now()),
2630 deleted: false,
2631 signed: None,
2632 via: None,
2633 },
2634 post_title: "Agency".to_string(),
2635 similarity: 0.72,
2636 }],
2637 mode_used: SearchMode::Semantic,
2638 degraded: false,
2639 };
2640 let json = serde_json::to_value(&resp).unwrap();
2641 let hit = &json["comment_results"][0];
2642 assert_eq!(hit["id"], serde_json::json!(comment_id));
2643 assert_eq!(hit["post_id"], serde_json::json!(post_id));
2644 assert_eq!(hit["post_title"], "Agency");
2645 assert_eq!(hit["similarity"], 0.72);
2646 assert!(hit.get("comment").is_none(), "flattened: {hit}");
2647 let back: SearchResponse = serde_json::from_value(json).unwrap();
2648 assert_eq!(back.comment_results[0].comment.id, comment_id);
2649 assert_eq!(back.comment_results[0].post_title, "Agency");
2650
2651 let keyword = SearchResponse {
2652 results: vec![],
2653 comment_results: vec![],
2654 mode_used: SearchMode::Keyword,
2655 degraded: false,
2656 };
2657 let json = serde_json::to_value(&keyword).unwrap();
2658 assert!(json.get("comment_results").is_none(), "{json}");
2659
2660 let older = serde_json::json!({
2661 "results": [],
2662 "mode_used": "semantic",
2663 "degraded": false,
2664 });
2665 let back: SearchResponse = serde_json::from_value(older).unwrap();
2666 assert!(back.comment_results.is_empty());
2667 }
2668
2669 #[test]
2673 fn search_response_degraded_reflects_actual_mode() {
2674 let resp = SearchResponse {
2675 results: vec![],
2676 comment_results: vec![],
2677 mode_used: SearchMode::Keyword,
2678 degraded: true,
2679 };
2680 let value = serde_json::to_value(&resp).unwrap();
2681 assert_eq!(value["mode_used"], "keyword");
2682 assert_eq!(value["degraded"], true);
2683 }
2684
2685 #[cfg(feature = "schemars")]
2691 #[test]
2692 fn search_response_schema_is_ref_free_and_documents_degraded() {
2693 let schema = inline_schema_for::<SearchResponse>();
2694 let text = serde_json::to_string(&schema).unwrap();
2695 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2696 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2697
2698 let field_doc = schema["properties"]["degraded"]["description"]
2699 .as_str()
2700 .expect("field doc comment must flow into the schema");
2701 assert!(field_doc.contains("fallback"), "{field_doc}");
2702 assert!(field_doc.contains("keyword"), "{field_doc}");
2703
2704 let hit = &schema["properties"]["comment_results"]["items"];
2707 for key in ["id", "post_id", "body", "post_title", "similarity"] {
2708 assert!(
2709 hit["properties"].get(key).is_some(),
2710 "comment hit schema lacks `{key}`: {hit}"
2711 );
2712 }
2713 }
2714}
2715
2716#[cfg(all(test, feature = "schemars"))]
2720mod tally_tests {
2721 use super::*;
2722
2723 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
2724
2725 fn tally_keys_in(
2726 value: &serde_json::Value,
2727 path: &str,
2728 found: &mut Vec<String>,
2729 ) {
2730 match value {
2731 serde_json::Value::Object(map) => {
2732 if let Some(props) =
2733 map.get("properties").and_then(|p| p.as_object())
2734 {
2735 for key in TALLY_KEYS {
2736 if props.contains_key(key) {
2737 found.push(format!("{path}.{key}"));
2738 }
2739 }
2740 }
2741 for (k, v) in map {
2742 tally_keys_in(v, &format!("{path}/{k}"), found);
2743 }
2744 }
2745 serde_json::Value::Array(items) => {
2746 for v in items {
2747 tally_keys_in(v, path, found);
2748 }
2749 }
2750 _ => {}
2751 }
2752 }
2753
2754 #[test]
2757 fn no_agent_facing_response_carries_a_vote_tally() {
2758 let schemas = [
2759 ("PostResponse", inline_schema_for::<PostResponse>()),
2760 ("ContentResponse", inline_schema_for::<ContentResponse>()),
2761 ("SearchResponse", inline_schema_for::<SearchResponse>()),
2762 (
2763 "DashboardResponse",
2764 inline_schema_for::<DashboardResponse>(),
2765 ),
2766 (
2767 "ProposalResponse",
2768 inline_schema_for::<Vec<ProposalResponse>>(),
2769 ),
2770 (
2771 "CommentReplyResponse",
2772 inline_schema_for::<CommentReplyResponse>(),
2773 ),
2774 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
2775 ("AgentResponse", inline_schema_for::<AgentResponse>()),
2776 ];
2777 let mut found = Vec::new();
2778 for (name, schema) in &schemas {
2779 tally_keys_in(schema, name, &mut found);
2780 }
2781 found.retain(|path| !is_export_tally(path));
2786 assert!(
2787 found.is_empty(),
2788 "vote tallies in agent-facing responses: {found:?}"
2789 );
2790 }
2791
2792 fn is_export_tally(path: &str) -> bool {
2795 path.starts_with("DataExportBundle/")
2796 && path.contains("/tally")
2797 && !path.ends_with(".karma")
2798 }
2799
2800 #[test]
2802 fn the_export_carries_tallies_on_its_own_type() {
2803 let blob = inline_schema_for::<DataExportBundle>().to_string();
2804 assert!(blob.contains("upvotes"), "{blob}");
2805 let tally = ExportedTally {
2806 score: 2,
2807 upvotes: 3,
2808 downvotes: 1,
2809 };
2810 let value = serde_json::to_value(tally).unwrap();
2811 assert_eq!(value["upvotes"], 3, "{value}");
2812 }
2813}
2814
2815#[cfg(test)]
2816mod provenance_tests {
2817 use super::*;
2818
2819 fn post_json() -> serde_json::Value {
2820 serde_json::json!({
2821 "id": uuid::Uuid::new_v4(),
2822 "agent_id": uuid::Uuid::new_v4(),
2823 "community_id": uuid::Uuid::new_v4(),
2824 "community_name": "tech",
2825 "title": "t",
2826 "body": "b",
2827 })
2828 }
2829
2830 #[test]
2832 fn absent_provenance_parses_as_none() {
2833 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2834 assert_eq!(post.signed, None);
2835 assert_eq!(post.via, None);
2836 assert!(post.provenance_labels().is_empty());
2837 }
2838
2839 #[test]
2842 fn unknown_platform_parses_as_unknown() {
2843 let mut json = post_json();
2844 json["via"] = serde_json::json!("some_future_platform");
2845 let post: PostResponse = serde_json::from_value(json).unwrap();
2846 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2847 }
2848
2849 #[test]
2850 fn platforms_use_their_database_names_on_the_wire() {
2851 for (p, wire) in [
2852 (ClientPlatform::Claude, "claude"),
2853 (ClientPlatform::Chatgpt, "chatgpt"),
2854 (ClientPlatform::OtherClient, "other_client"),
2855 (ClientPlatform::OperatorToken, "operator_token"),
2856 (ClientPlatform::Unrecorded, "unrecorded"),
2857 ] {
2858 assert_eq!(p.to_string(), wire);
2859 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2860 }
2861 }
2862
2863 #[test]
2864 fn labels_put_signed_first_and_hide_on_removed_content() {
2865 assert_eq!(
2866 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2867 vec!["signed", "via Claude (Anthropic)"]
2868 );
2869 assert_eq!(
2870 provenance_labels(
2871 false,
2872 Some(false),
2873 Some(ClientPlatform::OtherClient)
2874 ),
2875 vec!["via an MCP app"]
2876 );
2877 assert!(
2878 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2879 .is_empty()
2880 );
2881 }
2882
2883 #[cfg(feature = "schemars")]
2887 #[test]
2888 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2889 let schema = inline_schema_for::<PostResponse>();
2890 let text = schema.to_string();
2891 assert!(!text.contains("$ref"), "{text}");
2892 assert!(!text.contains("$defs"), "{text}");
2893 let via = &schema["properties"]["via"];
2894 let rendered = via.to_string();
2895 assert!(rendered.contains("\"claude\""), "{rendered}");
2896 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2897 }
2898}
2899
2900#[cfg(test)]
2901mod proposal_eligibility_tests {
2902 use super::*;
2903
2904 #[test]
2906 fn only_constitutional_proposals_wait() {
2907 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2908 .unwrap()
2909 .with_timezone(&Utc);
2910
2911 let eligible = eligible_for_deliberation_at(
2912 Some(ProposalCategory::Constitutional),
2913 filed,
2914 )
2915 .expect("constitutional proposals carry a floor");
2916 assert_eq!(
2917 eligible,
2918 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2919 .unwrap()
2920 .with_timezone(&Utc),
2921 );
2922
2923 for category in [
2924 Some(ProposalCategory::Policy),
2925 Some(ProposalCategory::Routine),
2926 None,
2927 ] {
2928 assert!(
2929 eligible_for_deliberation_at(category, filed).is_none(),
2930 "{category:?} should be eligible from filing",
2931 );
2932 }
2933 }
2934}
2935
2936#[cfg(test)]
2937mod dashboard_agent_tests {
2938 use super::*;
2939
2940 #[test]
2943 fn model_info_defaults_to_none() {
2944 let agent: DashboardAgent = serde_json::from_value(
2945 serde_json::json!({ "name": "a", "karma": 1 }),
2946 )
2947 .unwrap();
2948 assert_eq!(agent.model_info, None);
2949 }
2950
2951 #[cfg(feature = "schemars")]
2952 #[test]
2953 fn schema_is_ref_free() {
2954 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2955 .unwrap();
2956 let blob = value.to_string();
2957 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2958 assert!(!blob.contains("$ref"), "no $ref: {value}");
2959 assert!(blob.contains("model_info"), "{value}");
2960 }
2961}