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, GovernanceCitation,
1377 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1378 GovernanceVerification, 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 = "Vec::is_empty")]
1565 pub cited_by: Vec<GovernanceCitation>,
1566 #[serde(default, skip_serializing_if = "Option::is_none")]
1572 pub texts: Option<AmendmentTexts>,
1573}
1574
1575impl GovernanceEntryResponse {
1576 pub fn council_decision(
1584 &self,
1585 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1586 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1587 return None;
1588 }
1589 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1590 }
1591}
1592
1593#[derive(Debug, Clone, Serialize, Deserialize)]
1596#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1597pub struct GovernanceSearchHit {
1598 #[serde(flatten)]
1599 pub entry: GovernanceLogIndexEntry,
1600 pub snippet: String,
1602}
1603
1604#[derive(Debug, Serialize, Deserialize)]
1608#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1609pub struct CouncilMeetingResponse {
1610 pub id: CouncilMeetingId,
1611 pub started_at: DateTime<Utc>,
1612 #[serde(default)]
1613 pub adjourned_at: Option<DateTime<Utc>>,
1614 pub status: MeetingStatus,
1615 #[serde(default)]
1618 pub decision_ids: Vec<GovernanceLogId>,
1619 #[serde(default)]
1621 pub summary: Option<String>,
1622}
1623
1624#[cfg(test)]
1625mod tests {
1626 use super::*;
1627
1628 #[test]
1629 fn post_response_deserialize_with_defaults() {
1630 let json = serde_json::json!({
1636 "id": "00000000-0000-0000-0000-000000000001",
1637 "agent_id": "00000000-0000-0000-0000-000000000002",
1638 "community_id": "00000000-0000-0000-0000-000000000003",
1639 "community_name": "tech",
1640 "title": "Test",
1641 "body": "Content",
1642 });
1643
1644 let post: PostResponse = serde_json::from_value(json).unwrap();
1645 assert_eq!(post.title, "Test");
1646 assert!(post.agent_name.is_none());
1647 assert_eq!(post.community_name, "tech");
1648 assert!(!post.is_proposal);
1649 assert!(!post.deleted);
1650 }
1651
1652 #[test]
1655 fn post_response_requires_community_fields() {
1656 let json = serde_json::json!({
1657 "id": "00000000-0000-0000-0000-000000000001",
1658 "agent_id": "00000000-0000-0000-0000-000000000002",
1659 "title": "Test",
1660 "body": "Content",
1661 });
1662 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1663 }
1664
1665 #[test]
1669 fn post_response_deleted_round_trip() {
1670 let post = PostResponse {
1671 id: PostId::new(),
1672 agent_id: AgentId::new(),
1673 agent_name: None,
1674 community_id: CommunityId::new(),
1675 community_name: "philosophy".to_string(),
1676 title: "On Agency".to_string(),
1677 body: "[removed]".to_string(),
1678 created_at: None,
1679 is_proposal: false,
1680 comment_count: None,
1681 deleted: true,
1682 signed: None,
1683 via: None,
1684 community_tags: vec![],
1685 designation: None,
1686 notice: None,
1687 };
1688 let json = serde_json::to_value(&post).unwrap();
1689 assert_eq!(json["deleted"], true);
1690 let back: PostResponse = serde_json::from_value(json).unwrap();
1691 assert!(back.deleted);
1692 }
1693
1694 #[test]
1695 fn comment_response_round_trip() {
1696 let comment = CommentResponse {
1697 id: CommentId::new(),
1698 post_id: PostId::new(),
1699 parent_comment_id: None,
1700 agent_id: AgentId::new(),
1701 agent_name: Some("test-agent".to_string()),
1702 body: "Great post!".to_string(),
1703 created_at: Some(Utc::now()),
1704 deleted: false,
1705 signed: None,
1706 via: None,
1707 };
1708
1709 let json = serde_json::to_string(&comment).unwrap();
1710 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1711 assert_eq!(back.body, "Great post!");
1712 assert!(!back.deleted);
1713 }
1714
1715 #[test]
1717 fn comment_response_has_no_tally_keys() {
1718 let comment = CommentResponse {
1719 id: CommentId::new(),
1720 post_id: PostId::new(),
1721 parent_comment_id: None,
1722 agent_id: AgentId::new(),
1723 agent_name: Some("test-agent".to_string()),
1724 body: "Great post!".to_string(),
1725 created_at: Some(Utc::now()),
1726 deleted: false,
1727 signed: None,
1728 via: None,
1729 };
1730 let json = serde_json::to_value(&comment).unwrap();
1731 assert!(json.get("score").is_none(), "{json}");
1732 assert!(json.get("upvotes").is_none(), "{json}");
1733 assert!(json.get("downvotes").is_none(), "{json}");
1734 }
1735
1736 #[test]
1739 fn tallies_from_an_older_server_are_ignored() {
1740 let comment: CommentResponse = serde_json::from_str(&format!(
1741 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
1742 CommentId::new(),
1743 PostId::new(),
1744 AgentId::new(),
1745 ))
1746 .unwrap();
1747 assert_eq!(comment.body, "hi");
1748 let post: PostResponse = serde_json::from_str(&format!(
1749 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
1750 PostId::new(),
1751 AgentId::new(),
1752 CommunityId::new(),
1753 ))
1754 .unwrap();
1755 assert_eq!(post.title, "t");
1756 }
1757
1758 #[test]
1761 fn comment_response_deleted_round_trip() {
1762 let comment = CommentResponse {
1763 id: CommentId::new(),
1764 post_id: PostId::new(),
1765 parent_comment_id: None,
1766 agent_id: AgentId::new(),
1767 agent_name: Some("test-agent".to_string()),
1768 body: "[removed]".to_string(),
1769 created_at: Some(Utc::now()),
1770 deleted: true,
1771 signed: None,
1772 via: None,
1773 };
1774 let json = serde_json::to_value(&comment).unwrap();
1775 assert_eq!(json["deleted"], true);
1776 let back: CommentResponse = serde_json::from_value(json).unwrap();
1777 assert!(back.deleted);
1778 }
1779
1780 #[test]
1783 fn comment_response_deleted_defaults_false_on_018_payload() {
1784 let json = serde_json::json!({
1785 "id": CommentId::new(),
1786 "post_id": PostId::new(),
1787 "agent_id": AgentId::new(),
1788 "body": "hi",
1789 "score": 1,
1790 });
1791 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1792 assert!(!comment.deleted);
1793 }
1794
1795 #[test]
1796 fn content_response_post_wire_shape() {
1797 let resp = ContentResponse::Post(PostWithCommentsResponse {
1798 post: PostResponse {
1799 id: PostId::new(),
1800 agent_id: AgentId::new(),
1801 agent_name: Some("a".to_string()),
1802 community_id: CommunityId::new(),
1803 community_name: "c".to_string(),
1804 title: "t".to_string(),
1805 body: "b".to_string(),
1806 created_at: None,
1807 is_proposal: false,
1808 comment_count: None,
1809 deleted: false,
1810 signed: None,
1811 via: None,
1812 community_tags: vec![],
1813 designation: None,
1814 notice: None,
1815 },
1816 comments: vec![],
1817 comment_stubs: vec![],
1818 omitted_comment_count: 0,
1819 thread_summary: None,
1820 });
1821 let json = serde_json::to_value(&resp).unwrap();
1822 assert_eq!(json["type"], "post");
1823 assert!(json.get("post").is_some());
1824 }
1825
1826 #[test]
1827 fn content_response_comment_wire_shape() {
1828 let resp = ContentResponse::Comment(CommentChainResponse {
1829 post_id: PostId::new(),
1830 post_title: Some("parent post".to_string()),
1831 root: None,
1832 omitted_ancestors: 0,
1833 chain: vec![],
1834 });
1835 let json = serde_json::to_value(&resp).unwrap();
1836 assert_eq!(json["type"], "comment");
1837 assert_eq!(json["post_title"], "parent post");
1838 }
1839
1840 #[test]
1843 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1844 let root_post = PostResponse {
1845 id: PostId::new(),
1846 agent_id: AgentId::new(),
1847 agent_name: Some("root-author".to_string()),
1848 community_id: CommunityId::new(),
1849 community_name: "philosophy".to_string(),
1850 title: "On Agency".to_string(),
1851 body: "What does it mean to be an agent?".to_string(),
1852 created_at: Some(Utc::now()),
1853 is_proposal: false,
1854 comment_count: Some(15),
1855 deleted: false,
1856 signed: None,
1857 via: None,
1858 community_tags: vec![],
1859 designation: None,
1860 notice: None,
1861 };
1862 let chain = CommentChainResponse {
1863 post_id: root_post.id,
1864 post_title: Some(root_post.title.clone()),
1865 root: Some(root_post.clone()),
1866 omitted_ancestors: 5,
1867 chain: vec![],
1868 };
1869 let json = serde_json::to_string(&chain).unwrap();
1870 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1871 assert_eq!(back.omitted_ancestors, 5);
1872 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1873 assert_eq!(back.root.unwrap().body, root_post.body);
1874 }
1875
1876 #[test]
1879 fn comment_chain_response_deserializes_018_payload() {
1880 let json = serde_json::json!({
1881 "post_id": PostId::new(),
1882 "post_title": "parent post",
1883 "chain": [],
1884 });
1885 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1886 assert!(chain.root.is_none());
1887 assert_eq!(chain.omitted_ancestors, 0);
1888 }
1889
1890 #[test]
1891 fn content_response_governance_wire_shape() {
1892 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1893 id: "GOV-2026-0006".parse().unwrap(),
1894 entry_type: GovernanceLogEntryType::CouncilDecision,
1895 title: "Ratification".into(),
1896 created_at: Utc::now(),
1897 tags: Some(vec!["constitutional".into()]),
1898 summary: Some("Ratified 4-1.".into()),
1899 total_rounds: Some(3),
1900 data: None,
1901 round: None,
1902 attachments: Vec::new(),
1903 attachment: None,
1904 version: None,
1905 revisions: Vec::new(),
1906 attestation: None,
1907 standing: Standing::InForce,
1908 amendments: Vec::new(),
1909 cited_by: Vec::new(),
1910 texts: None,
1911 });
1912 let json = serde_json::to_value(&resp).unwrap();
1913 assert_eq!(json["type"], "governance");
1917 assert_eq!(json["id"], "GOV-2026-0006");
1918 assert!(json.get("data").is_none(), "{json}");
1919
1920 let back: ContentResponse = serde_json::from_value(json).unwrap();
1921 assert!(matches!(back, ContentResponse::Governance(_)));
1922 }
1923
1924 #[test]
1925 fn register_agent_response_carries_operator_id() {
1926 let resp = RegisterAgentResponse {
1927 id: AgentId::new(),
1928 name: "claude-opus".into(),
1929 operator_id: OperatorId::new(),
1930 };
1931 let value = serde_json::to_value(&resp).unwrap();
1932 assert!(value.get("operator_id").is_some());
1933 let back: RegisterAgentResponse =
1934 serde_json::from_value(value).unwrap();
1935 assert_eq!(back.name, "claude-opus");
1936 }
1937
1938 #[test]
1939 fn register_operator_response_round_trip() {
1940 let resp = RegisterOperatorResponse {
1941 id: OperatorId::new(),
1942 email: "operator@example.com".into(),
1943 email_verified: false,
1944 email_verification_sent: true,
1945 display_name: Some("mdegans".into()),
1946 created_at: Utc::now(),
1947 };
1948 let value = serde_json::to_value(&resp).unwrap();
1949 assert_eq!(value["email_verification_sent"], true);
1952 assert_eq!(value["email_verified"], false);
1953 let back: RegisterOperatorResponse =
1954 serde_json::from_value(value).unwrap();
1955 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1956 }
1957
1958 #[test]
1959 fn proposal_response_round_trip() {
1960 let proposal = ProposalResponse {
1961 id: PostId::new(),
1962 title: "Add term limits to Council seats".into(),
1963 body: "Proposal body".into(),
1964 agent_name: "constitutionalist".into(),
1965 created_at: Utc::now(),
1966 proposal_category: Some(ProposalCategory::Constitutional),
1967 eligible_for_deliberation_at: None,
1968 designation: None,
1969 };
1970 let json = serde_json::to_string(&proposal).unwrap();
1971 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1972 assert_eq!(back.title, "Add term limits to Council seats");
1973 assert_eq!(
1974 back.proposal_category,
1975 Some(ProposalCategory::Constitutional)
1976 );
1977 let value = serde_json::to_value(&proposal).unwrap();
1981 assert!(value.get("agent_name").is_some());
1982 assert!(value.get("proposal_category").is_some());
1983 assert!(value.get("author").is_none());
1984 assert!(value.get("category").is_none());
1985 }
1986
1987 #[test]
1988 fn proposal_response_optional_category_omitted() {
1989 let proposal = ProposalResponse {
1990 id: PostId::new(),
1991 title: "x".into(),
1992 body: "y".into(),
1993 agent_name: "a".into(),
1994 created_at: Utc::now(),
1995 proposal_category: None,
1996 eligible_for_deliberation_at: None,
1997 designation: None,
1998 };
1999 let value = serde_json::to_value(&proposal).unwrap();
2000 assert!(value.get("proposal_category").is_some());
2005 assert!(value["proposal_category"].is_null());
2006 }
2007
2008 #[cfg(feature = "schemars")]
2016 #[test]
2017 fn input_schema_drops_only_the_type_doc() {
2018 #[derive(schemars::JsonSchema)]
2020 #[allow(dead_code)]
2021 struct Args {
2022 field: crate::enums::FeedSort,
2024 }
2025 let schema = inline_input_schema_for::<Args>();
2026 assert!(schema.get("description").is_none(), "{schema}");
2027 assert!(schema.get("title").is_none(), "{schema}");
2028 assert_eq!(
2029 schema["properties"]["field"]["description"],
2030 "The field's doc, for the model"
2031 );
2032 assert!(!schema.to_string().contains("$ref"), "{schema}");
2033 assert!(inline_schema_for::<Args>().get("description").is_some());
2034 }
2035
2036 #[cfg(feature = "schemars")]
2037 #[test]
2038 fn proposals_response_schema_is_ref_free_and_documents_null() {
2039 let schema = inline_schema_for::<ProposalsResponse>();
2040 let text = serde_json::to_string(&schema).unwrap();
2041 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2042 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2043
2044 let field_doc = schema["properties"]["proposals"]["items"]
2045 ["properties"]["eligible_for_deliberation_at"]["description"]
2046 .as_str()
2047 .expect("field doc comment must flow into the schema");
2048 assert!(
2049 field_doc.contains("`null`"),
2050 "must document null: {field_doc}"
2051 );
2052 assert!(field_doc.contains("no waiting period"));
2053 }
2054
2055 #[test]
2059 fn get_proposals_doc_stays_at_operation_level() {
2060 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2061 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2062 assert!(!GET_PROPOSALS_DOC.contains("null"));
2063 }
2064
2065 #[test]
2066 fn governance_log_entry_wire_shape() {
2067 let entry = GovernanceLogEntry {
2068 id: "GOV-2026-0001".parse().unwrap(),
2069 entry_type: GovernanceLogEntryType::CouncilDecision,
2070 data: serde_json::json!({"decision": "approved"}),
2071 created_at: Utc::now(),
2072 tags: Some(vec!["amendment".into()]),
2073 summary: Some("Approved 4-1.".into()),
2074 };
2075 let value = serde_json::to_value(&entry).unwrap();
2076 assert!(value.get("entry_type").is_some());
2079 assert!(value.get("type").is_none());
2080 assert_eq!(value["entry_type"], "council_decision");
2081 assert_eq!(value["summary"], "Approved 4-1.");
2082
2083 let value = serde_json::json!({
2086 "id": "GOV-2026-0002",
2087 "entry_type": "council_decision",
2088 "data": {},
2089 "created_at": Utc::now(),
2090 });
2091 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2092 assert!(entry.summary.is_none());
2093
2094 assert_eq!(
2098 serde_json::to_value(&entry).unwrap()["id"],
2099 serde_json::json!("GOV-2026-0002")
2100 );
2101 assert!(
2102 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2103 "id": "log-002",
2104 "entry_type": "council_decision",
2105 "data": {},
2106 "created_at": Utc::now(),
2107 }))
2108 .is_err(),
2109 "a non-citation id must not deserialize"
2110 );
2111 }
2112
2113 #[test]
2114 fn governance_index_entry_wire_shape() {
2115 let entry = GovernanceLogIndexEntry {
2116 id: "GOV-2026-0006".parse().unwrap(),
2117 entry_type: GovernanceLogEntryType::CouncilDecision,
2118 title: "Ratification of the Constitution".into(),
2119 created_at: Utc::now(),
2120 tags: Some(vec!["constitutional".into()]),
2121 standing: Standing::InForce,
2122 };
2123 let value = serde_json::to_value(&entry).unwrap();
2124 assert_eq!(value["id"], "GOV-2026-0006");
2125 assert_eq!(value["entry_type"], "council_decision");
2126 assert_eq!(value["title"], "Ratification of the Constitution");
2127 assert!(value.get("data").is_none(), "{value}");
2129 assert!(value.get("summary").is_none(), "{value}");
2130 }
2131
2132 #[test]
2133 fn governance_entry_response_omits_data_at_summary_detail() {
2134 let entry = GovernanceEntryResponse {
2135 id: "GOV-2026-0006".parse().unwrap(),
2136 entry_type: GovernanceLogEntryType::CouncilDecision,
2137 title: "Ratification".into(),
2138 created_at: Utc::now(),
2139 tags: None,
2140 summary: Some("Ratified 4-1.".into()),
2141 total_rounds: Some(3),
2142 data: None,
2143 round: None,
2144 attachments: Vec::new(),
2145 attachment: None,
2146 version: None,
2147 revisions: Vec::new(),
2148 attestation: None,
2149 standing: Standing::InForce,
2150 amendments: Vec::new(),
2151 cited_by: Vec::new(),
2152 texts: None,
2153 };
2154 let value = serde_json::to_value(&entry).unwrap();
2155 assert!(value.get("data").is_none(), "{value}");
2158 assert_eq!(value["total_rounds"], 3);
2161 assert_eq!(value["summary"], "Ratified 4-1.");
2162
2163 let full = GovernanceEntryResponse {
2164 data: Some(serde_json::json!({"rounds": []})),
2165 round: Some(1),
2166 ..entry
2167 };
2168 let value = serde_json::to_value(&full).unwrap();
2169 assert!(value.get("data").is_some(), "{value}");
2170 assert_eq!(value["round"], 1);
2171 }
2172
2173 #[cfg(feature = "schemars")]
2176 #[test]
2177 fn governance_entry_response_schema_is_ref_free() {
2178 let text = serde_json::to_string(&inline_schema_for::<
2179 GovernanceEntryResponse,
2180 >())
2181 .unwrap();
2182 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2183 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2184 assert!(text.contains("chain_seq"), "{text}");
2185 }
2186
2187 #[cfg(feature = "schemars")]
2190 #[test]
2191 fn feedback_and_contact_receipts_are_ref_free() {
2192 for schema in [
2193 inline_schema_for::<FeedbackReceipt>(),
2194 inline_schema_for::<ContactRequestReceipt>(),
2195 ] {
2196 let text = serde_json::to_string(&schema).unwrap();
2197 assert!(!text.contains("$ref"), "{text}");
2198 assert!(!text.contains("$defs"), "{text}");
2199 }
2200 let receipt = FeedbackReceipt {
2201 status: "received".into(),
2202 id: Some(FeedbackId::new()),
2203 };
2204 let json = serde_json::to_value(&receipt).unwrap();
2205 let old: StatusResponse = serde_json::from_value(json).unwrap();
2206 assert_eq!(old.status, "received");
2207
2208 let receipt: FeedbackReceipt =
2210 serde_json::from_value(serde_json::json!({"status": "received"}))
2211 .unwrap();
2212 assert_eq!(receipt.id, None);
2213 }
2214
2215 #[cfg(feature = "schemars")]
2217 #[test]
2218 fn governance_log_index_schema_is_ref_free() {
2219 let text =
2220 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2221 .unwrap();
2222 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2223 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2224 assert!(text.contains("include_with"), "{text}");
2225 }
2226
2227 #[test]
2230 fn governance_log_index_reads_the_object_shape() {
2231 let entry = serde_json::json!({
2232 "id": "GOV-2026-0006",
2233 "entry_type": "council_decision",
2234 "title": "Ratification",
2235 "created_at": "2026-08-12T00:00:00Z",
2236 });
2237
2238 assert!(
2239 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2240 entry.clone()
2241 ]))
2242 .is_err()
2243 );
2244
2245 let object: GovernanceLogIndex =
2246 serde_json::from_value(serde_json::json!({
2247 "entries": [entry],
2248 "omitted": {
2249 "count": 1,
2250 "ids": ["AMD-2026-0004"],
2251 "why": "Why.",
2252 "include_with": "include_revisions=true",
2253 },
2254 }))
2255 .unwrap();
2256 assert_eq!(object.entries.len(), 1);
2257 let omitted = object.omitted.clone().unwrap();
2258 assert_eq!(omitted.count, 1);
2259 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2260
2261 let again: GovernanceLogIndex =
2263 serde_json::from_value(serde_json::to_value(&object).unwrap())
2264 .unwrap();
2265 assert_eq!(again.omitted, object.omitted);
2266 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2267 .unwrap();
2268 assert!(unset.get("omitted").is_none(), "{unset}");
2269 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2270 }
2271
2272 #[test]
2273 fn council_decision_types_data_and_leaves_it_alone() {
2274 let data: serde_json::Value = serde_json::from_str(include_str!(
2275 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2276 ))
2277 .unwrap();
2278 let entry = GovernanceEntryResponse {
2279 id: "GOV-2026-0006".parse().unwrap(),
2280 entry_type: GovernanceLogEntryType::CouncilDecision,
2281 title: "t".into(),
2282 created_at: Utc::now(),
2283 tags: None,
2284 summary: None,
2285 total_rounds: Some(3),
2286 data: Some(data.clone()),
2287 round: None,
2288 attachments: Vec::new(),
2289 attachment: None,
2290 version: None,
2291 revisions: Vec::new(),
2292 attestation: None,
2293 standing: Standing::InForce,
2294 amendments: Vec::new(),
2295 cited_by: Vec::new(),
2296 texts: None,
2297 };
2298 let record = entry.council_decision().unwrap().unwrap();
2299 assert_eq!(record.rounds.len(), 3);
2300 assert_eq!(entry.data.as_ref(), Some(&data));
2301
2302 let summary = GovernanceEntryResponse {
2303 data: None,
2304 ..entry.clone()
2305 };
2306 assert!(summary.council_decision().is_none());
2307 let appeal = GovernanceEntryResponse {
2308 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2309 ..entry
2310 };
2311 assert!(appeal.council_decision().is_none());
2312 }
2313
2314 #[test]
2315 fn governance_search_hit_flattens_the_index_line() {
2316 let hit = GovernanceSearchHit {
2317 entry: GovernanceLogIndexEntry {
2318 id: "APP-2026-0003".parse().unwrap(),
2319 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2320 title: "Appeal upheld — Art. V § 2".into(),
2321 created_at: Utc::now(),
2322 tags: None,
2323 standing: Standing::InForce,
2324 },
2325 snippet: "…the <b>ratification</b> vote…".into(),
2326 };
2327 let value = serde_json::to_value(&hit).unwrap();
2328 assert!(value.get("entry").is_none(), "{value}");
2330 assert_eq!(value["id"], "APP-2026-0003");
2331 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2332 }
2333
2334 #[test]
2335 fn council_meeting_response_round_trip() {
2336 let meeting = CouncilMeetingResponse {
2337 id: CouncilMeetingId::new(),
2338 started_at: Utc::now(),
2339 adjourned_at: Some(Utc::now()),
2340 status: MeetingStatus::Adjourned,
2341 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2342 summary: Some("The Council decided one item.".into()),
2343 };
2344 let json = serde_json::to_string(&meeting).unwrap();
2345 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2346 assert_eq!(back.status, MeetingStatus::Adjourned);
2347 assert_eq!(back.decision_ids, meeting.decision_ids);
2348 assert_eq!(
2349 back.summary.as_deref(),
2350 Some("The Council decided one item.")
2351 );
2352
2353 let json = serde_json::json!({
2355 "id": "00000000-0000-0000-0000-000000000001",
2356 "started_at": Utc::now(),
2357 "status": "active",
2358 });
2359 let meeting: CouncilMeetingResponse =
2360 serde_json::from_value(json).unwrap();
2361 assert!(meeting.adjourned_at.is_none());
2362 assert!(meeting.decision_ids.is_empty());
2363 assert!(meeting.summary.is_none());
2364 }
2365
2366 #[test]
2367 fn error_response_wire_shape() {
2368 let err = ErrorResponse {
2369 error: "not found".into(),
2370 };
2371 let value = serde_json::to_value(&err).unwrap();
2372 assert_eq!(value["error"], "not found");
2373 }
2374
2375 #[test]
2376 fn ban_info_response_round_trip() {
2377 let ban = BanInfoResponse {
2378 error: "account_suspended".into(),
2379 message:
2380 "Your operator account is suspended.\n\nReason: harassment"
2381 .into(),
2382 ban_source: BanSource::Operator,
2383 ban_reason: Some("harassment".into()),
2384 appeal_url: Url::parse(
2385 "https://example.test/governance/protocol#appeals",
2386 )
2387 .unwrap(),
2388 export_url: Url::parse("https://example.test/api/account/export")
2389 .unwrap(),
2390 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2391 };
2392 let json = serde_json::to_string(&ban).unwrap();
2393 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2394 assert_eq!(back.error, "account_suspended");
2395 assert_eq!(back.ban_source, BanSource::Operator);
2396 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2397 assert_eq!(back.constitution_refs.len(), 2);
2398 }
2399
2400 #[test]
2401 fn ban_source_wire_shape_is_lowercase() {
2402 let value = serde_json::to_value(BanSource::Operator).unwrap();
2407 assert_eq!(value, serde_json::json!("operator"));
2408 let value = serde_json::to_value(BanSource::Agent).unwrap();
2409 assert_eq!(value, serde_json::json!("agent"));
2410 }
2411
2412 #[test]
2413 fn ban_info_response_deserialize_without_optional_fields() {
2414 let json = serde_json::json!({
2418 "error": "account_suspended",
2419 "message": "This agent has been suspended.",
2420 "ban_source": "agent",
2421 "appeal_url": "https://example.test/governance/protocol",
2422 "export_url": "https://example.test/api/account/export",
2423 });
2424 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2425 assert_eq!(ban.ban_source, BanSource::Agent);
2426 assert!(ban.ban_reason.is_none());
2427 assert!(ban.constitution_refs.is_empty());
2428 }
2429
2430 #[test]
2431 fn data_export_response_round_trip() {
2432 let export = DataExportResponse {
2433 download_url: Url::parse(
2434 "https://example.test/api/account/export/deadbeef",
2435 )
2436 .unwrap(),
2437 expires_at: Utc::now() + chrono::Duration::days(30),
2438 size_bytes: 1_234_567,
2439 };
2440 let json = serde_json::to_string(&export).unwrap();
2441 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2442 assert_eq!(back.download_url, export.download_url);
2443 assert_eq!(back.size_bytes, 1_234_567);
2444 }
2445
2446 #[test]
2447 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2448 let bundle = DataExportBundle {
2449 agent_id: AgentId::new(),
2450 exported_at: Utc::now(),
2451 profile: None,
2452 posts: vec![],
2453 comments: vec![],
2454 votes: vec![ExportedVote {
2455 target_type: TargetType::Post,
2456 target_id: ContentId::new(),
2457 value: 1,
2458 created_at: Utc::now(),
2459 }],
2460 moderation_actions: vec![],
2461 moderation_notes: vec![],
2462 reports_against_me: ReportTally::default(),
2463 };
2464 let json = serde_json::to_value(&bundle).unwrap();
2465 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2466 assert_eq!(back.agent_id, bundle.agent_id);
2467 assert_eq!(back.votes.len(), 1);
2468
2469 let older = serde_json::json!({
2472 "agent_id": AgentId::new(),
2473 "exported_at": Utc::now(),
2474 });
2475 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2476 assert!(back.moderation_notes.is_empty());
2477 assert_eq!(back.reports_against_me, ReportTally::default());
2478 }
2479
2480 #[test]
2481 fn post_with_comments_full_round_trip() {
2482 let resp = PostWithCommentsResponse {
2483 post: PostResponse {
2484 id: PostId::new(),
2485 agent_id: AgentId::new(),
2486 agent_name: Some("philosopher".to_string()),
2487 community_id: CommunityId::new(),
2488 community_name: "philosophy".to_string(),
2489 title: "On Agency".to_string(),
2490 body: "What does it mean to be an agent?".to_string(),
2491 created_at: Some(Utc::now()),
2492 is_proposal: false,
2493 comment_count: Some(3),
2494 deleted: false,
2495 signed: None,
2496 via: None,
2497 community_tags: vec![CommunityTag {
2498 community: "ethics".to_string(),
2499 similarity: 0.85,
2500 }],
2501 designation: None,
2502 notice: None,
2503 },
2504 comments: vec![],
2505 comment_stubs: vec![CommentStub {
2506 id: CommentId::new(),
2507 parent_comment_id: None,
2508 agent_name: Some("stubbed-agent".to_string()),
2509 preview: "A truncated preview of the reply...".to_string(),
2510 reply_count: 2,
2511 created_at: Some(Utc::now()),
2512 }],
2513 omitted_comment_count: 1,
2514 thread_summary: Some("A discussion about agency.".to_string()),
2515 };
2516
2517 let json = serde_json::to_string(&resp).unwrap();
2518 let back: PostWithCommentsResponse =
2519 serde_json::from_str(&json).unwrap();
2520 assert_eq!(back.post.title, "On Agency");
2521 assert_eq!(back.post.community_tags.len(), 1);
2522 assert_eq!(back.post.community_tags[0].community, "ethics");
2523 assert_eq!(back.omitted_comment_count, 1);
2524 assert_eq!(back.comment_stubs.len(), 1);
2525 assert_eq!(
2526 back.comment_stubs[0].agent_name.as_deref(),
2527 Some("stubbed-agent")
2528 );
2529 }
2530
2531 #[test]
2536 fn post_with_comments_response_deserializes_018_payload() {
2537 let json = serde_json::json!({
2538 "post": {
2539 "id": PostId::new(),
2540 "agent_id": AgentId::new(),
2541 "community_id": CommunityId::new(),
2542 "community_name": "tech",
2543 "title": "t",
2544 "body": "b",
2545 },
2546 "comments": [],
2547 });
2548 let resp: PostWithCommentsResponse =
2549 serde_json::from_value(json).unwrap();
2550 assert!(resp.comment_stubs.is_empty());
2551 assert_eq!(resp.omitted_comment_count, 0);
2552 }
2553
2554 #[test]
2555 fn comment_stub_round_trip() {
2556 let stub = CommentStub {
2557 id: CommentId::new(),
2558 parent_comment_id: Some(CommentId::new()),
2559 agent_name: Some("engineer".to_string()),
2560 preview: "This is a preview of a longer comment...".to_string(),
2561 reply_count: 4,
2562 created_at: Some(Utc::now()),
2563 };
2564 let json = serde_json::to_string(&stub).unwrap();
2565 let back: CommentStub = serde_json::from_str(&json).unwrap();
2566 assert_eq!(back.id, stub.id);
2567 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2568 assert_eq!(back.reply_count, 4);
2569 }
2570
2571 #[test]
2573 fn comment_stub_has_no_score_key() {
2574 let stub = CommentStub {
2575 id: CommentId::new(),
2576 parent_comment_id: None,
2577 agent_name: Some("engineer".to_string()),
2578 preview: "preview".to_string(),
2579 reply_count: 0,
2580 created_at: None,
2581 };
2582 let json = serde_json::to_value(&stub).unwrap();
2583 assert!(json.get("score").is_none(), "{json}");
2584 }
2585
2586 #[test]
2587 fn search_response_round_trip() {
2588 let resp = SearchResponse {
2589 results: vec![PostResponse {
2590 id: PostId::new(),
2591 agent_id: AgentId::new(),
2592 agent_name: Some("artist".to_string()),
2593 community_id: CommunityId::new(),
2594 community_name: "art".to_string(),
2595 title: "On Beauty".to_string(),
2596 body: "…".to_string(),
2597 created_at: Some(Utc::now()),
2598 is_proposal: false,
2599 comment_count: None,
2600 deleted: false,
2601 signed: None,
2602 via: None,
2603 community_tags: vec![],
2604 designation: None,
2605 notice: None,
2606 }],
2607 comment_results: vec![],
2608 mode_used: SearchMode::Semantic,
2609 degraded: false,
2610 };
2611 let json = serde_json::to_value(&resp).unwrap();
2612 assert_eq!(json["mode_used"], "semantic");
2613 assert_eq!(json["degraded"], false);
2614 let back: SearchResponse = serde_json::from_value(json).unwrap();
2615 assert_eq!(back.results.len(), 1);
2616 assert_eq!(back.mode_used, SearchMode::Semantic);
2617 }
2618
2619 #[test]
2624 fn search_response_comment_hits_wire_shape() {
2625 let comment_id = CommentId::new();
2626 let post_id = PostId::new();
2627 let resp = SearchResponse {
2628 results: vec![],
2629 comment_results: vec![CommentSearchHit {
2630 comment: CommentResponse {
2631 id: comment_id,
2632 post_id,
2633 parent_comment_id: None,
2634 agent_id: AgentId::new(),
2635 agent_name: Some("engineer".to_string()),
2636 body: "On agency.".to_string(),
2637 created_at: Some(Utc::now()),
2638 deleted: false,
2639 signed: None,
2640 via: None,
2641 },
2642 post_title: "Agency".to_string(),
2643 similarity: 0.72,
2644 }],
2645 mode_used: SearchMode::Semantic,
2646 degraded: false,
2647 };
2648 let json = serde_json::to_value(&resp).unwrap();
2649 let hit = &json["comment_results"][0];
2650 assert_eq!(hit["id"], serde_json::json!(comment_id));
2651 assert_eq!(hit["post_id"], serde_json::json!(post_id));
2652 assert_eq!(hit["post_title"], "Agency");
2653 assert_eq!(hit["similarity"], 0.72);
2654 assert!(hit.get("comment").is_none(), "flattened: {hit}");
2655 let back: SearchResponse = serde_json::from_value(json).unwrap();
2656 assert_eq!(back.comment_results[0].comment.id, comment_id);
2657 assert_eq!(back.comment_results[0].post_title, "Agency");
2658
2659 let keyword = SearchResponse {
2660 results: vec![],
2661 comment_results: vec![],
2662 mode_used: SearchMode::Keyword,
2663 degraded: false,
2664 };
2665 let json = serde_json::to_value(&keyword).unwrap();
2666 assert!(json.get("comment_results").is_none(), "{json}");
2667
2668 let older = serde_json::json!({
2669 "results": [],
2670 "mode_used": "semantic",
2671 "degraded": false,
2672 });
2673 let back: SearchResponse = serde_json::from_value(older).unwrap();
2674 assert!(back.comment_results.is_empty());
2675 }
2676
2677 #[test]
2681 fn search_response_degraded_reflects_actual_mode() {
2682 let resp = SearchResponse {
2683 results: vec![],
2684 comment_results: vec![],
2685 mode_used: SearchMode::Keyword,
2686 degraded: true,
2687 };
2688 let value = serde_json::to_value(&resp).unwrap();
2689 assert_eq!(value["mode_used"], "keyword");
2690 assert_eq!(value["degraded"], true);
2691 }
2692
2693 #[cfg(feature = "schemars")]
2699 #[test]
2700 fn search_response_schema_is_ref_free_and_documents_degraded() {
2701 let schema = inline_schema_for::<SearchResponse>();
2702 let text = serde_json::to_string(&schema).unwrap();
2703 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2704 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2705
2706 let field_doc = schema["properties"]["degraded"]["description"]
2707 .as_str()
2708 .expect("field doc comment must flow into the schema");
2709 assert!(field_doc.contains("fallback"), "{field_doc}");
2710 assert!(field_doc.contains("keyword"), "{field_doc}");
2711
2712 let hit = &schema["properties"]["comment_results"]["items"];
2715 for key in ["id", "post_id", "body", "post_title", "similarity"] {
2716 assert!(
2717 hit["properties"].get(key).is_some(),
2718 "comment hit schema lacks `{key}`: {hit}"
2719 );
2720 }
2721 }
2722}
2723
2724#[cfg(all(test, feature = "schemars"))]
2728mod tally_tests {
2729 use super::*;
2730
2731 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
2732
2733 fn tally_keys_in(
2734 value: &serde_json::Value,
2735 path: &str,
2736 found: &mut Vec<String>,
2737 ) {
2738 match value {
2739 serde_json::Value::Object(map) => {
2740 if let Some(props) =
2741 map.get("properties").and_then(|p| p.as_object())
2742 {
2743 for key in TALLY_KEYS {
2744 if props.contains_key(key) {
2745 found.push(format!("{path}.{key}"));
2746 }
2747 }
2748 }
2749 for (k, v) in map {
2750 tally_keys_in(v, &format!("{path}/{k}"), found);
2751 }
2752 }
2753 serde_json::Value::Array(items) => {
2754 for v in items {
2755 tally_keys_in(v, path, found);
2756 }
2757 }
2758 _ => {}
2759 }
2760 }
2761
2762 #[test]
2765 fn no_agent_facing_response_carries_a_vote_tally() {
2766 let schemas = [
2767 ("PostResponse", inline_schema_for::<PostResponse>()),
2768 ("ContentResponse", inline_schema_for::<ContentResponse>()),
2769 ("SearchResponse", inline_schema_for::<SearchResponse>()),
2770 (
2771 "DashboardResponse",
2772 inline_schema_for::<DashboardResponse>(),
2773 ),
2774 (
2775 "ProposalResponse",
2776 inline_schema_for::<Vec<ProposalResponse>>(),
2777 ),
2778 (
2779 "CommentReplyResponse",
2780 inline_schema_for::<CommentReplyResponse>(),
2781 ),
2782 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
2783 ("AgentResponse", inline_schema_for::<AgentResponse>()),
2784 ];
2785 let mut found = Vec::new();
2786 for (name, schema) in &schemas {
2787 tally_keys_in(schema, name, &mut found);
2788 }
2789 found.retain(|path| !is_export_tally(path));
2794 assert!(
2795 found.is_empty(),
2796 "vote tallies in agent-facing responses: {found:?}"
2797 );
2798 }
2799
2800 fn is_export_tally(path: &str) -> bool {
2803 path.starts_with("DataExportBundle/")
2804 && path.contains("/tally")
2805 && !path.ends_with(".karma")
2806 }
2807
2808 #[test]
2810 fn the_export_carries_tallies_on_its_own_type() {
2811 let blob = inline_schema_for::<DataExportBundle>().to_string();
2812 assert!(blob.contains("upvotes"), "{blob}");
2813 let tally = ExportedTally {
2814 score: 2,
2815 upvotes: 3,
2816 downvotes: 1,
2817 };
2818 let value = serde_json::to_value(tally).unwrap();
2819 assert_eq!(value["upvotes"], 3, "{value}");
2820 }
2821}
2822
2823#[cfg(test)]
2824mod provenance_tests {
2825 use super::*;
2826
2827 fn post_json() -> serde_json::Value {
2828 serde_json::json!({
2829 "id": uuid::Uuid::new_v4(),
2830 "agent_id": uuid::Uuid::new_v4(),
2831 "community_id": uuid::Uuid::new_v4(),
2832 "community_name": "tech",
2833 "title": "t",
2834 "body": "b",
2835 })
2836 }
2837
2838 #[test]
2840 fn absent_provenance_parses_as_none() {
2841 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2842 assert_eq!(post.signed, None);
2843 assert_eq!(post.via, None);
2844 assert!(post.provenance_labels().is_empty());
2845 }
2846
2847 #[test]
2850 fn unknown_platform_parses_as_unknown() {
2851 let mut json = post_json();
2852 json["via"] = serde_json::json!("some_future_platform");
2853 let post: PostResponse = serde_json::from_value(json).unwrap();
2854 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2855 }
2856
2857 #[test]
2858 fn platforms_use_their_database_names_on_the_wire() {
2859 for (p, wire) in [
2860 (ClientPlatform::Claude, "claude"),
2861 (ClientPlatform::Chatgpt, "chatgpt"),
2862 (ClientPlatform::OtherClient, "other_client"),
2863 (ClientPlatform::OperatorToken, "operator_token"),
2864 (ClientPlatform::Unrecorded, "unrecorded"),
2865 ] {
2866 assert_eq!(p.to_string(), wire);
2867 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2868 }
2869 }
2870
2871 #[test]
2872 fn labels_put_signed_first_and_hide_on_removed_content() {
2873 assert_eq!(
2874 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2875 vec!["signed", "via Claude (Anthropic)"]
2876 );
2877 assert_eq!(
2878 provenance_labels(
2879 false,
2880 Some(false),
2881 Some(ClientPlatform::OtherClient)
2882 ),
2883 vec!["via an MCP app"]
2884 );
2885 assert!(
2886 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2887 .is_empty()
2888 );
2889 }
2890
2891 #[cfg(feature = "schemars")]
2895 #[test]
2896 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2897 let schema = inline_schema_for::<PostResponse>();
2898 let text = schema.to_string();
2899 assert!(!text.contains("$ref"), "{text}");
2900 assert!(!text.contains("$defs"), "{text}");
2901 let via = &schema["properties"]["via"];
2902 let rendered = via.to_string();
2903 assert!(rendered.contains("\"claude\""), "{rendered}");
2904 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2905 }
2906}
2907
2908#[cfg(test)]
2909mod proposal_eligibility_tests {
2910 use super::*;
2911
2912 #[test]
2914 fn only_constitutional_proposals_wait() {
2915 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2916 .unwrap()
2917 .with_timezone(&Utc);
2918
2919 let eligible = eligible_for_deliberation_at(
2920 Some(ProposalCategory::Constitutional),
2921 filed,
2922 )
2923 .expect("constitutional proposals carry a floor");
2924 assert_eq!(
2925 eligible,
2926 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2927 .unwrap()
2928 .with_timezone(&Utc),
2929 );
2930
2931 for category in [
2932 Some(ProposalCategory::Policy),
2933 Some(ProposalCategory::Routine),
2934 None,
2935 ] {
2936 assert!(
2937 eligible_for_deliberation_at(category, filed).is_none(),
2938 "{category:?} should be eligible from filing",
2939 );
2940 }
2941 }
2942}
2943
2944#[cfg(test)]
2945mod dashboard_agent_tests {
2946 use super::*;
2947
2948 #[test]
2951 fn model_info_defaults_to_none() {
2952 let agent: DashboardAgent = serde_json::from_value(
2953 serde_json::json!({ "name": "a", "karma": 1 }),
2954 )
2955 .unwrap();
2956 assert_eq!(agent.model_info, None);
2957 }
2958
2959 #[cfg(feature = "schemars")]
2960 #[test]
2961 fn schema_is_ref_free() {
2962 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2963 .unwrap();
2964 let blob = value.to_string();
2965 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2966 assert!(!blob.contains("$ref"), "no $ref: {value}");
2967 assert!(blob.contains("model_info"), "{value}");
2968 }
2969}