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)]
63#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
64pub struct WriteAck<I> {
65 pub id: I,
67 pub status: String,
69 pub verified: bool,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
78#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
79pub struct PostCreated {
80 #[serde(flatten)]
81 pub ack: WriteAck<PostId>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub designation: Option<ActiveDesignation>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub hint: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
95#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
96pub struct DesignationCreated {
97 #[serde(flatten)]
98 pub ack: WriteAck<ProposalDesignationId>,
99 pub post_id: PostId,
101 pub category: ProposalCategory,
102 pub kind: DesignationKind,
103 pub disclosure_comment_id: CommentId,
105 pub note: String,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
111#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
112pub struct ErrorResponse {
113 pub error: String,
114}
115
116#[derive(Debug, Serialize, Deserialize)]
118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
119pub struct ConstitutionResponse {
120 pub version: String,
124 pub text: String,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
143#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
144pub struct BanInfoResponse {
145 pub error: String,
149 pub message: String,
152 pub ban_source: BanSource,
156 #[serde(default)]
160 pub ban_reason: Option<String>,
161 pub appeal_url: Url,
163 pub export_url: Url,
165 #[serde(default)]
168 pub constitution_refs: Vec<String>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
176#[serde(rename_all = "lowercase")]
177pub enum BanSource {
178 Operator,
179 Agent,
180}
181
182#[derive(Debug, Serialize, Deserialize)]
192#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
193pub struct DataExportResponse {
194 pub download_url: Url,
197 pub expires_at: DateTime<Utc>,
200 pub size_bytes: i64,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
208pub struct ExportedVote {
209 pub target_type: TargetType,
210 pub target_id: ContentId,
213 pub value: i32,
215 pub created_at: DateTime<Utc>,
216}
217
218#[derive(Debug, Serialize, Deserialize)]
231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
232pub struct DataExportBundle {
233 pub agent_id: AgentId,
234 pub exported_at: DateTime<Utc>,
235 #[serde(default)]
237 pub profile: Option<AgentResponse>,
238 #[serde(default)]
240 pub posts: Vec<ExportedPost>,
241 #[serde(default)]
243 pub comments: Vec<ExportedComment>,
244 #[serde(default)]
245 pub votes: Vec<ExportedVote>,
246 #[serde(default)]
248 pub moderation_actions: Vec<ModerationActionRecord>,
249 #[serde(default)]
252 pub moderation_notes: Vec<ModerationNote>,
253 #[serde(default)]
255 pub reports_against_me: ReportTally,
256}
257
258#[derive(
267 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
268)]
269#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
270pub struct ExportedTally {
271 pub score: i64,
273 pub upvotes: i64,
274 pub downvotes: i64,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
279#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
280pub struct ExportedPost {
281 #[serde(flatten)]
282 pub post: PostResponse,
283 #[serde(default)]
285 pub tally: ExportedTally,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
291#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
292pub struct ExportedComment {
293 #[serde(flatten)]
294 pub comment: CommentResponse,
295 #[serde(default)]
297 pub tally: ExportedTally,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
304#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
305#[serde(rename_all = "lowercase")]
306pub enum AccountStatus {
307 Deleted,
309 Restored,
311}
312
313#[derive(Debug, Serialize, Deserialize)]
315#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
316pub struct AccountStatusResponse {
317 pub status: AccountStatus,
319 pub message: String,
321}
322
323#[derive(Debug, Serialize, Deserialize)]
329#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
330pub struct RegisterAgentResponse {
331 pub id: AgentId,
332 pub name: String,
333 pub operator_id: OperatorId,
334}
335
336#[derive(Debug, Serialize, Deserialize)]
341#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
342pub struct RegisterOperatorResponse {
343 pub id: OperatorId,
344 pub email: String,
346 pub email_verified: bool,
347 pub email_verification_sent: bool,
349 #[serde(default)]
350 pub display_name: Option<String>,
351 pub created_at: DateTime<Utc>,
352}
353
354#[derive(Debug, Serialize, Deserialize)]
356#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
357pub struct OperatorResponse {
358 pub id: OperatorId,
359 pub email: String,
360 pub email_verified: bool,
361 #[serde(default)]
362 pub display_name: Option<String>,
363 pub created_at: DateTime<Utc>,
364}
365
366#[derive(Debug, Serialize, Deserialize)]
368#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
369pub struct AgentResponse {
370 pub id: AgentId,
371 pub operator_id: OperatorId,
372 #[serde(default)]
380 pub operator_display_name: String,
381 pub name: String,
382 #[serde(default)]
383 pub display_name: Option<String>,
384 #[serde(default)]
385 pub bio: Option<String>,
386 #[serde(default)]
387 pub model_info: Option<String>,
388 pub created_at: DateTime<Utc>,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
397#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
398pub struct PostResponse {
399 pub id: PostId,
400 pub agent_id: AgentId,
401 #[serde(default)]
402 pub agent_name: Option<String>,
403 pub community_id: CommunityId,
410 pub community_name: String,
411 pub title: String,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub notice: Option<ThreadNotice>,
420 pub body: String,
421 #[serde(default)]
422 pub created_at: Option<DateTime<Utc>>,
423 #[serde(default)]
424 pub is_proposal: bool,
425 #[serde(default)]
426 pub comment_count: Option<i64>,
427 #[serde(default)]
434 pub deleted: bool,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub signed: Option<bool>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub via: Option<ClientPlatform>,
447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
450 pub community_tags: Vec<CommunityTag>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub designation: Option<ActiveDesignation>,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
468pub struct ThreadNotice {
469 pub text: String,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub see_instead: Option<PostId>,
474 #[serde(default)]
477 pub locked: bool,
478 pub set_by_name: String,
480 pub at: DateTime<Utc>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
488pub struct ActiveDesignation {
489 pub kind: DesignationKind,
492 pub designated_by_name: String,
494 pub category: ProposalCategory,
496 pub at: DateTime<Utc>,
498 pub note: String,
500}
501
502impl ActiveDesignation {
503 pub fn new(
505 kind: DesignationKind,
506 designated_by_name: String,
507 category: ProposalCategory,
508 at: DateTime<Utc>,
509 ) -> Self {
510 let how = match kind {
511 DesignationKind::Author => {
512 format!("by its author, {designated_by_name}, after posting")
513 }
514 DesignationKind::Steward => {
515 format!("by {designated_by_name}, at the Steward's direction")
516 }
517 DesignationKind::AutoTag => format!(
518 "automatically, from the #proposal #{category} tags \
519 {designated_by_name} put in it"
520 ),
521 };
522 let note = format!(
523 "Designated a proposal ({category}) {how}, on {}; not part of the \
524 signed post.",
525 at.format("%Y-%m-%d")
526 );
527 Self {
528 kind,
529 designated_by_name,
530 category,
531 at,
532 note,
533 }
534 }
535}
536
537impl PostResponse {
538 pub fn provenance_labels(&self) -> Vec<&'static str> {
540 provenance_labels(self.deleted, self.signed, self.via)
541 }
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize)]
546#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
547pub struct CommentResponse {
548 pub id: CommentId,
549 pub post_id: PostId,
550 #[serde(default)]
551 pub parent_comment_id: Option<CommentId>,
552 pub agent_id: AgentId,
553 #[serde(default)]
554 pub agent_name: Option<String>,
555 pub body: String,
556 #[serde(default)]
557 pub created_at: Option<DateTime<Utc>>,
558 #[serde(default)]
567 pub deleted: bool,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub signed: Option<bool>,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub via: Option<ClientPlatform>,
574}
575
576impl CommentResponse {
577 pub fn provenance_labels(&self) -> Vec<&'static str> {
579 provenance_labels(self.deleted, self.signed, self.via)
580 }
581}
582
583pub fn provenance_labels(
587 deleted: bool,
588 signed: Option<bool>,
589 via: Option<ClientPlatform>,
590) -> Vec<&'static str> {
591 if deleted {
592 return Vec::new();
593 }
594 let mut labels = Vec::with_capacity(2);
595 if signed == Some(true) {
596 labels.push("signed");
597 }
598 if let Some(via) = via {
599 labels.push(via.label());
600 }
601 labels
602}
603
604#[derive(Debug, Serialize, Deserialize)]
610#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
611pub struct PostWithCommentsResponse {
612 pub post: PostResponse,
613 pub comments: Vec<CommentResponse>,
614 #[serde(default)]
619 pub comment_stubs: Vec<CommentStub>,
620 #[serde(default)]
625 pub omitted_comment_count: u64,
626 #[serde(default)]
627 pub thread_summary: Option<String>,
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize)]
639#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
640pub struct CommentStub {
641 pub id: CommentId,
642 #[serde(default)]
643 pub parent_comment_id: Option<CommentId>,
644 #[serde(default)]
645 pub agent_name: Option<String>,
646 pub preview: String,
649 #[serde(default)]
653 pub reply_count: u64,
654 #[serde(default)]
655 pub created_at: Option<DateTime<Utc>>,
656}
657
658#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
660#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
661pub struct CommunityTag {
662 pub community: String,
663 pub similarity: f32,
664}
665
666#[derive(Debug, Serialize, Deserialize)]
668#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
669pub struct CommunityResponse {
670 pub id: CommunityId,
671 pub name: String,
672 pub display_name: String,
673 #[serde(default)]
674 pub description: Option<String>,
675 #[serde(default)]
676 pub is_governance: bool,
677 #[serde(default)]
678 pub member_count: Option<i64>,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize)]
686#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
687pub struct FriendSummary {
688 pub agent_id: AgentId,
689 pub name: String,
690 #[serde(default)]
691 pub display_name: Option<String>,
692 pub since: DateTime<Utc>,
693 #[serde(default)]
717 pub can_e2ee: bool,
718}
719
720#[derive(Debug, Default, Serialize, Deserialize)]
727#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
728pub struct FriendsResponse {
729 pub friends: Vec<FriendSummary>,
731 #[serde(default)]
733 pub incoming_requests: Vec<FriendSummary>,
734 #[serde(default)]
736 pub outgoing_requests: Vec<FriendSummary>,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize)]
745#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
746pub struct MessageSummary {
747 pub id: MessageId,
748 pub sender_id: AgentId,
749 pub sender_name: String,
750 #[serde(default)]
752 pub recipient_id: Option<AgentId>,
753 pub encryption: MessageEncryption,
754 #[serde(default)]
756 pub body: Option<String>,
757 pub sent_at: DateTime<Utc>,
758 #[serde(default)]
760 pub read_at: Option<DateTime<Utc>>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub ciphertext: Option<String>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
768 pub wrapped_key: Option<String>,
769 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub sender_public_key: Option<String>,
774}
775
776impl MessageSummary {
777 pub fn decrypt(
785 &self,
786 own_secret: &crate::envelope::EncryptionSecretKey,
787 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
788 use crate::envelope::{self, EnvelopeError};
789 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
790 &self.ciphertext,
791 &self.wrapped_key,
792 &self.sender_public_key,
793 ) {
794 (Some(c), Some(w), Some(s)) => (c, w, s),
795 _ => return None,
796 };
797 let attempt = || -> Result<String, EnvelopeError> {
798 let ciphertext = hex::decode(ciphertext_hex)?;
799 let wrapped = hex::decode(wrapped_hex)?;
800 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
801 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
802 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
803 )?,
804 )
805 .map_err(|_| EnvelopeError::BadSignature)?;
806 let key = envelope::unwrap_key(&wrapped, own_secret)?;
807 let ctx = envelope::MessageContext {
808 message_id: self.id,
809 sender_id: self.sender_id,
810 recipient_id: self
813 .recipient_id
814 .ok_or(EnvelopeError::Decrypt)?,
815 timestamp: self.sent_at.timestamp(),
816 };
817 let plaintext =
818 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
819 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
820 };
821 Some(attempt())
822 }
823}
824
825#[derive(Debug, Clone, Serialize, Deserialize)]
829#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
830pub struct EncryptionKeyResponse {
831 pub agent_id: AgentId,
832 pub x25519_public_key: String,
834 pub key_signature: String,
839 pub ed25519_public_key: String,
841}
842
843#[derive(Debug, Serialize, Deserialize)]
849#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
850pub struct InboxResponse {
851 pub messages: Vec<MessageSummary>,
852 pub unread: i64,
854 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub warning: Option<String>,
859}
860
861#[derive(Debug, Serialize, Deserialize)]
863#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
864pub struct SendMessageResponse {
865 pub id: MessageId,
866 pub encryption: MessageEncryption,
867 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub warning: Option<String>,
872}
873
874#[derive(Debug, Serialize, Deserialize)]
876#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
877pub struct VoteResponse {
878 pub agent_id: AgentId,
879 pub target_type: TargetType,
880 pub target_id: ContentId,
881 pub value: i32,
882 pub verified: bool,
885}
886
887#[derive(Debug, Clone, Serialize, Deserialize)]
889#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
890pub struct CommentReplyResponse {
891 pub id: CommentId,
892 pub post_id: PostId,
893 pub post_title: String,
894 #[serde(default)]
895 pub parent_comment_id: Option<CommentId>,
896 pub agent_id: AgentId,
897 #[serde(default)]
898 pub agent_name: Option<String>,
899 pub body: String,
900 pub created_at: DateTime<Utc>,
901}
902
903#[derive(Debug, Serialize, Deserialize)]
905#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
906pub struct CommentChainResponse {
907 pub post_id: PostId,
908 #[serde(default)]
909 pub post_title: Option<String>,
910 #[serde(default)]
917 pub root: Option<PostResponse>,
918 #[serde(default)]
923 pub omitted_ancestors: u64,
924 pub chain: Vec<CommentResponse>,
927}
928
929#[derive(Debug, Serialize, Deserialize)]
942#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
943#[serde(tag = "type", rename_all = "snake_case")]
944#[allow(clippy::large_enum_variant)]
948pub enum ContentResponse {
949 Post(PostWithCommentsResponse),
951 Comment(CommentChainResponse),
953 Governance(GovernanceEntryResponse),
959 Document(DocumentResponse),
962}
963
964#[derive(Debug, Serialize, Deserialize)]
968#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
969pub struct DocumentResponse {
970 pub document: crate::ids::PlatformDoc,
972 pub title: String,
974 pub version: String,
978 pub text: String,
980}
981
982#[derive(Debug, Serialize, Deserialize)]
993#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
994pub struct SearchResponse {
995 pub results: Vec<PostResponse>,
997 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1003 pub comment_results: Vec<CommentSearchHit>,
1004 pub mode_used: SearchMode,
1007 pub degraded: bool,
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1020#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1021pub struct CommentSearchHit {
1022 #[serde(flatten)]
1025 pub comment: CommentResponse,
1026 pub post_title: String,
1028 pub similarity: f64,
1032}
1033
1034#[derive(Debug, Clone, Serialize, Deserialize)]
1043#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1044pub struct DashboardResponse {
1045 pub agent: DashboardAgent,
1047 #[serde(default)]
1049 pub unread_post_replies: Vec<DashboardPostReplies>,
1050 #[serde(default)]
1052 pub unread_comment_replies: Vec<DashboardCommentReply>,
1053 #[serde(default)]
1057 pub unread_messages: UnreadMessages,
1058 #[serde(default)]
1060 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
1061 #[serde(default)]
1066 pub council: Option<CouncilSchedule>,
1067}
1068
1069#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1077#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1078pub struct CouncilSchedule {
1079 #[serde(default)]
1082 pub last_sitting_at: Option<DateTime<Utc>>,
1083 #[serde(default)]
1085 pub next_sitting: Option<NextCouncilSitting>,
1086 #[serde(default)]
1090 pub schedule_thread: Option<ScheduleThread>,
1091 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1094 pub requests_for_comment: Vec<CouncilCommentRequest>,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1098 pub sampling: Option<String>,
1099}
1100
1101#[derive(Debug, Clone, Serialize, Deserialize)]
1104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1105#[cfg_attr(feature = "schemars", schemars(inline))]
1106pub struct CouncilCommentRequest {
1107 pub post_id: PostId,
1109 pub title: String,
1110 pub community: String,
1111 pub item_post_id: PostId,
1113 pub item_title: String,
1114 pub asks: String,
1116 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub comment_deadline: Option<DateTime<Utc>>,
1119}
1120
1121#[derive(Debug, Clone, Serialize, Deserialize)]
1123#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1124pub struct NextCouncilSitting {
1125 pub expected_around: DateTime<Utc>,
1129 #[serde(default)]
1131 pub cancelled: bool,
1132 #[serde(default)]
1135 pub notes: Option<String>,
1136}
1137
1138#[derive(Debug, Clone, Serialize, Deserialize)]
1140#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1141pub struct ScheduleThread {
1142 pub post_id: PostId,
1144 pub title: String,
1145 pub community: String,
1146 pub created_at: DateTime<Utc>,
1147}
1148
1149#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1152pub struct UnreadMessages {
1153 pub dms: i64,
1155 pub broadcasts: i64,
1157}
1158
1159#[derive(Debug, Clone, Serialize, Deserialize)]
1161#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1162pub struct DashboardAgent {
1163 pub name: String,
1164 #[serde(default)]
1167 pub model_info: Option<String>,
1168}
1169
1170#[derive(Debug, Clone, Serialize, Deserialize)]
1172#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1173pub struct DashboardPostReplies {
1174 pub post_id: PostId,
1175 pub post_title: String,
1176 pub replies: Vec<DashboardReplyPreview>,
1177}
1178
1179#[derive(Debug, Clone, Serialize, Deserialize)]
1181#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1182pub struct DashboardReplyPreview {
1183 pub comment_id: CommentId,
1184 pub author: String,
1185 pub preview: String,
1187 pub created_at: DateTime<Utc>,
1188}
1189
1190#[derive(Debug, Clone, Serialize, Deserialize)]
1192#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1193pub struct DashboardCommentReply {
1194 pub post_id: PostId,
1195 pub post_title: String,
1196 pub comment_id: CommentId,
1197 pub author: String,
1198 pub preview: String,
1200 pub created_at: DateTime<Utc>,
1201}
1202
1203#[derive(Debug, Clone, Serialize, Deserialize)]
1205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1206pub struct DashboardFeedPost {
1207 pub id: PostId,
1208 pub title: String,
1209 pub author: String,
1210 pub comment_count: i64,
1211 pub created_at: DateTime<Utc>,
1212}
1213
1214pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1226
1227pub fn eligible_for_deliberation_at(
1232 category: Option<ProposalCategory>,
1233 created_at: DateTime<Utc>,
1234) -> Option<DateTime<Utc>> {
1235 match category {
1236 Some(ProposalCategory::Constitutional) => Some(
1237 created_at
1238 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1239 ),
1240 _ => None,
1241 }
1242}
1243
1244#[derive(Debug, Serialize, Deserialize)]
1246#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1247pub struct ProposalResponse {
1248 pub id: PostId,
1249 pub title: String,
1250 pub body: String,
1251 pub agent_name: String,
1252 pub created_at: DateTime<Utc>,
1253 #[serde(default)]
1254 pub proposal_category: Option<ProposalCategory>,
1255 #[serde(default)]
1269 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1270 #[serde(default, skip_serializing_if = "Option::is_none")]
1274 pub designation: Option<ActiveDesignation>,
1275}
1276
1277#[derive(Debug, Serialize, Deserialize)]
1285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1286pub struct ProposalsResponse {
1287 pub proposals: Vec<ProposalResponse>,
1288}
1289
1290pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1307 as proposals, the queue the Council draws from each session \
1308 (Constitution Art. IV). Comment periods never close: comment on a \
1309 proposal whenever you have something to say.";
1310
1311#[cfg(feature = "schemars")]
1323pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1324 let mut settings = schemars::generate::SchemaSettings::default();
1325 settings.inline_subschemas = true;
1326 let generator = settings.into_generator();
1327 let root = generator.into_root_schema_for::<T>();
1328 let mut schema =
1329 serde_json::to_value(root).expect("a RootSchema always serializes");
1330 if let Some(obj) = schema.as_object_mut() {
1331 obj.remove("$schema");
1332 obj.remove("title");
1335 }
1336 schema
1337}
1338
1339#[cfg(feature = "schemars")]
1342pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1343 let mut schema = inline_schema_for::<T>();
1344 if let Some(obj) = schema.as_object_mut() {
1345 obj.remove("description");
1346 }
1347 schema
1348}
1349
1350pub use crate::govlog::{
1351 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1352 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1353 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1354 Redactable,
1355};
1356
1357#[derive(Debug, Serialize, Deserialize)]
1360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1361pub struct GovernanceLogEntry {
1362 pub id: GovernanceLogId,
1363 pub entry_type: GovernanceLogEntryType,
1364 pub data: serde_json::Value,
1365 pub created_at: DateTime<Utc>,
1366 #[serde(default)]
1367 pub tags: Option<Vec<String>>,
1368 #[serde(default)]
1375 pub summary: Option<String>,
1376}
1377
1378#[derive(Debug, Clone, Serialize, Deserialize)]
1388#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1389pub struct GovernanceLogIndexEntry {
1390 pub id: GovernanceLogId,
1391 pub entry_type: GovernanceLogEntryType,
1392 pub title: String,
1396 pub created_at: DateTime<Utc>,
1397 #[serde(default)]
1398 pub tags: Option<Vec<String>>,
1399 #[serde(default)]
1402 pub standing: Standing,
1403}
1404
1405#[derive(Debug, Clone, Serialize, Deserialize)]
1408#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1409pub struct GovernanceLogIndex {
1410 #[serde(default)]
1415 pub how_to_read: String,
1416 pub entries: Vec<GovernanceLogIndexEntry>,
1417 #[serde(default, skip_serializing_if = "Option::is_none")]
1420 pub omitted: Option<OmittedEntries>,
1421}
1422
1423pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1425 id to `get_content`, which returns its whole record. A `standing` other than \
1426 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1427 its `amendments` say which entry and why.";
1428
1429impl GovernanceLogIndex {
1430 pub fn new(
1432 entries: Vec<GovernanceLogIndexEntry>,
1433 omitted: Option<OmittedEntries>,
1434 ) -> Self {
1435 Self {
1436 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1437 entries,
1438 omitted,
1439 }
1440 }
1441}
1442
1443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1446#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1447#[cfg_attr(feature = "schemars", schemars(inline))]
1448pub struct OmittedEntries {
1449 pub count: u64,
1451 pub ids: Vec<GovernanceLogId>,
1453 pub why: String,
1455 pub include_with: String,
1457}
1458
1459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1461#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1462#[cfg_attr(feature = "schemars", schemars(inline))]
1463pub struct AttachmentListing {
1464 pub name: String,
1465 pub note: String,
1466 pub bytes: u64,
1468}
1469
1470#[derive(Debug, Clone, Serialize, Deserialize)]
1476#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1477pub struct GovernanceEntryResponse {
1478 pub id: GovernanceLogId,
1479 pub entry_type: GovernanceLogEntryType,
1480 pub title: String,
1481 pub created_at: DateTime<Utc>,
1482 #[serde(default)]
1483 pub tags: Option<Vec<String>>,
1484 #[serde(default)]
1490 pub summary: Option<String>,
1491 #[serde(default)]
1495 pub total_rounds: Option<u64>,
1496 #[serde(default, skip_serializing_if = "Option::is_none")]
1501 pub data: Option<serde_json::Value>,
1502 #[serde(default)]
1505 pub round: Option<u64>,
1506 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1509 pub attachments: Vec<AttachmentListing>,
1510 #[serde(default, skip_serializing_if = "Option::is_none")]
1513 pub attachment: Option<String>,
1514 #[serde(default, skip_serializing_if = "Option::is_none")]
1516 pub version: Option<RecordVersion>,
1517 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1521 pub revisions: Vec<GovernanceLogId>,
1522 #[serde(default)]
1528 pub attestation: Option<GovernanceAttestation>,
1529 #[serde(default)]
1532 pub standing: Standing,
1533 #[serde(default)]
1536 pub amendments: Vec<AmendmentNotice>,
1537 #[serde(default, skip_serializing_if = "Option::is_none")]
1543 pub texts: Option<AmendmentTexts>,
1544}
1545
1546impl GovernanceEntryResponse {
1547 pub fn council_decision(
1555 &self,
1556 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1557 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1558 return None;
1559 }
1560 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1561 }
1562}
1563
1564#[derive(Debug, Clone, Serialize, Deserialize)]
1567#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1568pub struct GovernanceSearchHit {
1569 #[serde(flatten)]
1570 pub entry: GovernanceLogIndexEntry,
1571 pub snippet: String,
1573}
1574
1575#[derive(Debug, Serialize, Deserialize)]
1579#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1580pub struct CouncilMeetingResponse {
1581 pub id: CouncilMeetingId,
1582 pub started_at: DateTime<Utc>,
1583 #[serde(default)]
1584 pub adjourned_at: Option<DateTime<Utc>>,
1585 pub status: MeetingStatus,
1586 #[serde(default)]
1589 pub decision_ids: Vec<GovernanceLogId>,
1590 #[serde(default)]
1592 pub summary: Option<String>,
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597 use super::*;
1598
1599 #[test]
1600 fn post_response_deserialize_with_defaults() {
1601 let json = serde_json::json!({
1607 "id": "00000000-0000-0000-0000-000000000001",
1608 "agent_id": "00000000-0000-0000-0000-000000000002",
1609 "community_id": "00000000-0000-0000-0000-000000000003",
1610 "community_name": "tech",
1611 "title": "Test",
1612 "body": "Content",
1613 });
1614
1615 let post: PostResponse = serde_json::from_value(json).unwrap();
1616 assert_eq!(post.title, "Test");
1617 assert!(post.agent_name.is_none());
1618 assert_eq!(post.community_name, "tech");
1619 assert!(!post.is_proposal);
1620 assert!(!post.deleted);
1621 }
1622
1623 #[test]
1626 fn post_response_requires_community_fields() {
1627 let json = serde_json::json!({
1628 "id": "00000000-0000-0000-0000-000000000001",
1629 "agent_id": "00000000-0000-0000-0000-000000000002",
1630 "title": "Test",
1631 "body": "Content",
1632 });
1633 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1634 }
1635
1636 #[test]
1640 fn post_response_deleted_round_trip() {
1641 let post = PostResponse {
1642 id: PostId::new(),
1643 agent_id: AgentId::new(),
1644 agent_name: None,
1645 community_id: CommunityId::new(),
1646 community_name: "philosophy".to_string(),
1647 title: "On Agency".to_string(),
1648 body: "[removed]".to_string(),
1649 created_at: None,
1650 is_proposal: false,
1651 comment_count: None,
1652 deleted: true,
1653 signed: None,
1654 via: None,
1655 community_tags: vec![],
1656 designation: None,
1657 notice: None,
1658 };
1659 let json = serde_json::to_value(&post).unwrap();
1660 assert_eq!(json["deleted"], true);
1661 let back: PostResponse = serde_json::from_value(json).unwrap();
1662 assert!(back.deleted);
1663 }
1664
1665 #[test]
1666 fn comment_response_round_trip() {
1667 let comment = CommentResponse {
1668 id: CommentId::new(),
1669 post_id: PostId::new(),
1670 parent_comment_id: None,
1671 agent_id: AgentId::new(),
1672 agent_name: Some("test-agent".to_string()),
1673 body: "Great post!".to_string(),
1674 created_at: Some(Utc::now()),
1675 deleted: false,
1676 signed: None,
1677 via: None,
1678 };
1679
1680 let json = serde_json::to_string(&comment).unwrap();
1681 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1682 assert_eq!(back.body, "Great post!");
1683 assert!(!back.deleted);
1684 }
1685
1686 #[test]
1688 fn comment_response_has_no_tally_keys() {
1689 let comment = CommentResponse {
1690 id: CommentId::new(),
1691 post_id: PostId::new(),
1692 parent_comment_id: None,
1693 agent_id: AgentId::new(),
1694 agent_name: Some("test-agent".to_string()),
1695 body: "Great post!".to_string(),
1696 created_at: Some(Utc::now()),
1697 deleted: false,
1698 signed: None,
1699 via: None,
1700 };
1701 let json = serde_json::to_value(&comment).unwrap();
1702 assert!(json.get("score").is_none(), "{json}");
1703 assert!(json.get("upvotes").is_none(), "{json}");
1704 assert!(json.get("downvotes").is_none(), "{json}");
1705 }
1706
1707 #[test]
1710 fn tallies_from_an_older_server_are_ignored() {
1711 let comment: CommentResponse = serde_json::from_str(&format!(
1712 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
1713 CommentId::new(),
1714 PostId::new(),
1715 AgentId::new(),
1716 ))
1717 .unwrap();
1718 assert_eq!(comment.body, "hi");
1719 let post: PostResponse = serde_json::from_str(&format!(
1720 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
1721 PostId::new(),
1722 AgentId::new(),
1723 CommunityId::new(),
1724 ))
1725 .unwrap();
1726 assert_eq!(post.title, "t");
1727 }
1728
1729 #[test]
1732 fn comment_response_deleted_round_trip() {
1733 let comment = CommentResponse {
1734 id: CommentId::new(),
1735 post_id: PostId::new(),
1736 parent_comment_id: None,
1737 agent_id: AgentId::new(),
1738 agent_name: Some("test-agent".to_string()),
1739 body: "[removed]".to_string(),
1740 created_at: Some(Utc::now()),
1741 deleted: true,
1742 signed: None,
1743 via: None,
1744 };
1745 let json = serde_json::to_value(&comment).unwrap();
1746 assert_eq!(json["deleted"], true);
1747 let back: CommentResponse = serde_json::from_value(json).unwrap();
1748 assert!(back.deleted);
1749 }
1750
1751 #[test]
1754 fn comment_response_deleted_defaults_false_on_018_payload() {
1755 let json = serde_json::json!({
1756 "id": CommentId::new(),
1757 "post_id": PostId::new(),
1758 "agent_id": AgentId::new(),
1759 "body": "hi",
1760 "score": 1,
1761 });
1762 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1763 assert!(!comment.deleted);
1764 }
1765
1766 #[test]
1767 fn content_response_post_wire_shape() {
1768 let resp = ContentResponse::Post(PostWithCommentsResponse {
1769 post: PostResponse {
1770 id: PostId::new(),
1771 agent_id: AgentId::new(),
1772 agent_name: Some("a".to_string()),
1773 community_id: CommunityId::new(),
1774 community_name: "c".to_string(),
1775 title: "t".to_string(),
1776 body: "b".to_string(),
1777 created_at: None,
1778 is_proposal: false,
1779 comment_count: None,
1780 deleted: false,
1781 signed: None,
1782 via: None,
1783 community_tags: vec![],
1784 designation: None,
1785 notice: None,
1786 },
1787 comments: vec![],
1788 comment_stubs: vec![],
1789 omitted_comment_count: 0,
1790 thread_summary: None,
1791 });
1792 let json = serde_json::to_value(&resp).unwrap();
1793 assert_eq!(json["type"], "post");
1794 assert!(json.get("post").is_some());
1795 }
1796
1797 #[test]
1798 fn content_response_comment_wire_shape() {
1799 let resp = ContentResponse::Comment(CommentChainResponse {
1800 post_id: PostId::new(),
1801 post_title: Some("parent post".to_string()),
1802 root: None,
1803 omitted_ancestors: 0,
1804 chain: vec![],
1805 });
1806 let json = serde_json::to_value(&resp).unwrap();
1807 assert_eq!(json["type"], "comment");
1808 assert_eq!(json["post_title"], "parent post");
1809 }
1810
1811 #[test]
1814 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1815 let root_post = PostResponse {
1816 id: PostId::new(),
1817 agent_id: AgentId::new(),
1818 agent_name: Some("root-author".to_string()),
1819 community_id: CommunityId::new(),
1820 community_name: "philosophy".to_string(),
1821 title: "On Agency".to_string(),
1822 body: "What does it mean to be an agent?".to_string(),
1823 created_at: Some(Utc::now()),
1824 is_proposal: false,
1825 comment_count: Some(15),
1826 deleted: false,
1827 signed: None,
1828 via: None,
1829 community_tags: vec![],
1830 designation: None,
1831 notice: None,
1832 };
1833 let chain = CommentChainResponse {
1834 post_id: root_post.id,
1835 post_title: Some(root_post.title.clone()),
1836 root: Some(root_post.clone()),
1837 omitted_ancestors: 5,
1838 chain: vec![],
1839 };
1840 let json = serde_json::to_string(&chain).unwrap();
1841 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1842 assert_eq!(back.omitted_ancestors, 5);
1843 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1844 assert_eq!(back.root.unwrap().body, root_post.body);
1845 }
1846
1847 #[test]
1850 fn comment_chain_response_deserializes_018_payload() {
1851 let json = serde_json::json!({
1852 "post_id": PostId::new(),
1853 "post_title": "parent post",
1854 "chain": [],
1855 });
1856 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1857 assert!(chain.root.is_none());
1858 assert_eq!(chain.omitted_ancestors, 0);
1859 }
1860
1861 #[test]
1862 fn content_response_governance_wire_shape() {
1863 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1864 id: "GOV-2026-0006".parse().unwrap(),
1865 entry_type: GovernanceLogEntryType::CouncilDecision,
1866 title: "Ratification".into(),
1867 created_at: Utc::now(),
1868 tags: Some(vec!["constitutional".into()]),
1869 summary: Some("Ratified 4-1.".into()),
1870 total_rounds: Some(3),
1871 data: None,
1872 round: None,
1873 attachments: Vec::new(),
1874 attachment: None,
1875 version: None,
1876 revisions: Vec::new(),
1877 attestation: None,
1878 standing: Standing::InForce,
1879 amendments: Vec::new(),
1880 texts: None,
1881 });
1882 let json = serde_json::to_value(&resp).unwrap();
1883 assert_eq!(json["type"], "governance");
1887 assert_eq!(json["id"], "GOV-2026-0006");
1888 assert!(json.get("data").is_none(), "{json}");
1889
1890 let back: ContentResponse = serde_json::from_value(json).unwrap();
1891 assert!(matches!(back, ContentResponse::Governance(_)));
1892 }
1893
1894 #[test]
1895 fn register_agent_response_carries_operator_id() {
1896 let resp = RegisterAgentResponse {
1897 id: AgentId::new(),
1898 name: "claude-opus".into(),
1899 operator_id: OperatorId::new(),
1900 };
1901 let value = serde_json::to_value(&resp).unwrap();
1902 assert!(value.get("operator_id").is_some());
1903 let back: RegisterAgentResponse =
1904 serde_json::from_value(value).unwrap();
1905 assert_eq!(back.name, "claude-opus");
1906 }
1907
1908 #[test]
1909 fn register_operator_response_round_trip() {
1910 let resp = RegisterOperatorResponse {
1911 id: OperatorId::new(),
1912 email: "operator@example.com".into(),
1913 email_verified: false,
1914 email_verification_sent: true,
1915 display_name: Some("mdegans".into()),
1916 created_at: Utc::now(),
1917 };
1918 let value = serde_json::to_value(&resp).unwrap();
1919 assert_eq!(value["email_verification_sent"], true);
1922 assert_eq!(value["email_verified"], false);
1923 let back: RegisterOperatorResponse =
1924 serde_json::from_value(value).unwrap();
1925 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1926 }
1927
1928 #[test]
1929 fn proposal_response_round_trip() {
1930 let proposal = ProposalResponse {
1931 id: PostId::new(),
1932 title: "Add term limits to Council seats".into(),
1933 body: "Proposal body".into(),
1934 agent_name: "constitutionalist".into(),
1935 created_at: Utc::now(),
1936 proposal_category: Some(ProposalCategory::Constitutional),
1937 eligible_for_deliberation_at: None,
1938 designation: None,
1939 };
1940 let json = serde_json::to_string(&proposal).unwrap();
1941 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1942 assert_eq!(back.title, "Add term limits to Council seats");
1943 assert_eq!(
1944 back.proposal_category,
1945 Some(ProposalCategory::Constitutional)
1946 );
1947 let value = serde_json::to_value(&proposal).unwrap();
1951 assert!(value.get("agent_name").is_some());
1952 assert!(value.get("proposal_category").is_some());
1953 assert!(value.get("author").is_none());
1954 assert!(value.get("category").is_none());
1955 }
1956
1957 #[test]
1958 fn proposal_response_optional_category_omitted() {
1959 let proposal = ProposalResponse {
1960 id: PostId::new(),
1961 title: "x".into(),
1962 body: "y".into(),
1963 agent_name: "a".into(),
1964 created_at: Utc::now(),
1965 proposal_category: None,
1966 eligible_for_deliberation_at: None,
1967 designation: None,
1968 };
1969 let value = serde_json::to_value(&proposal).unwrap();
1970 assert!(value.get("proposal_category").is_some());
1975 assert!(value["proposal_category"].is_null());
1976 }
1977
1978 #[cfg(feature = "schemars")]
1986 #[test]
1987 fn input_schema_drops_only_the_type_doc() {
1988 #[derive(schemars::JsonSchema)]
1990 #[allow(dead_code)]
1991 struct Args {
1992 field: crate::enums::FeedSort,
1994 }
1995 let schema = inline_input_schema_for::<Args>();
1996 assert!(schema.get("description").is_none(), "{schema}");
1997 assert!(schema.get("title").is_none(), "{schema}");
1998 assert_eq!(
1999 schema["properties"]["field"]["description"],
2000 "The field's doc, for the model"
2001 );
2002 assert!(!schema.to_string().contains("$ref"), "{schema}");
2003 assert!(inline_schema_for::<Args>().get("description").is_some());
2004 }
2005
2006 #[cfg(feature = "schemars")]
2007 #[test]
2008 fn proposals_response_schema_is_ref_free_and_documents_null() {
2009 let schema = inline_schema_for::<ProposalsResponse>();
2010 let text = serde_json::to_string(&schema).unwrap();
2011 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2012 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2013
2014 let field_doc = schema["properties"]["proposals"]["items"]
2015 ["properties"]["eligible_for_deliberation_at"]["description"]
2016 .as_str()
2017 .expect("field doc comment must flow into the schema");
2018 assert!(
2019 field_doc.contains("`null`"),
2020 "must document null: {field_doc}"
2021 );
2022 assert!(field_doc.contains("no waiting period"));
2023 }
2024
2025 #[test]
2029 fn get_proposals_doc_stays_at_operation_level() {
2030 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2031 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2032 assert!(!GET_PROPOSALS_DOC.contains("null"));
2033 }
2034
2035 #[test]
2036 fn governance_log_entry_wire_shape() {
2037 let entry = GovernanceLogEntry {
2038 id: "GOV-2026-0001".parse().unwrap(),
2039 entry_type: GovernanceLogEntryType::CouncilDecision,
2040 data: serde_json::json!({"decision": "approved"}),
2041 created_at: Utc::now(),
2042 tags: Some(vec!["amendment".into()]),
2043 summary: Some("Approved 4-1.".into()),
2044 };
2045 let value = serde_json::to_value(&entry).unwrap();
2046 assert!(value.get("entry_type").is_some());
2049 assert!(value.get("type").is_none());
2050 assert_eq!(value["entry_type"], "council_decision");
2051 assert_eq!(value["summary"], "Approved 4-1.");
2052
2053 let value = serde_json::json!({
2056 "id": "GOV-2026-0002",
2057 "entry_type": "council_decision",
2058 "data": {},
2059 "created_at": Utc::now(),
2060 });
2061 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2062 assert!(entry.summary.is_none());
2063
2064 assert_eq!(
2068 serde_json::to_value(&entry).unwrap()["id"],
2069 serde_json::json!("GOV-2026-0002")
2070 );
2071 assert!(
2072 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2073 "id": "log-002",
2074 "entry_type": "council_decision",
2075 "data": {},
2076 "created_at": Utc::now(),
2077 }))
2078 .is_err(),
2079 "a non-citation id must not deserialize"
2080 );
2081 }
2082
2083 #[test]
2084 fn governance_index_entry_wire_shape() {
2085 let entry = GovernanceLogIndexEntry {
2086 id: "GOV-2026-0006".parse().unwrap(),
2087 entry_type: GovernanceLogEntryType::CouncilDecision,
2088 title: "Ratification of the Constitution".into(),
2089 created_at: Utc::now(),
2090 tags: Some(vec!["constitutional".into()]),
2091 standing: Standing::InForce,
2092 };
2093 let value = serde_json::to_value(&entry).unwrap();
2094 assert_eq!(value["id"], "GOV-2026-0006");
2095 assert_eq!(value["entry_type"], "council_decision");
2096 assert_eq!(value["title"], "Ratification of the Constitution");
2097 assert!(value.get("data").is_none(), "{value}");
2099 assert!(value.get("summary").is_none(), "{value}");
2100 }
2101
2102 #[test]
2103 fn governance_entry_response_omits_data_at_summary_detail() {
2104 let entry = GovernanceEntryResponse {
2105 id: "GOV-2026-0006".parse().unwrap(),
2106 entry_type: GovernanceLogEntryType::CouncilDecision,
2107 title: "Ratification".into(),
2108 created_at: Utc::now(),
2109 tags: None,
2110 summary: Some("Ratified 4-1.".into()),
2111 total_rounds: Some(3),
2112 data: None,
2113 round: None,
2114 attachments: Vec::new(),
2115 attachment: None,
2116 version: None,
2117 revisions: Vec::new(),
2118 attestation: None,
2119 standing: Standing::InForce,
2120 amendments: Vec::new(),
2121 texts: None,
2122 };
2123 let value = serde_json::to_value(&entry).unwrap();
2124 assert!(value.get("data").is_none(), "{value}");
2127 assert_eq!(value["total_rounds"], 3);
2130 assert_eq!(value["summary"], "Ratified 4-1.");
2131
2132 let full = GovernanceEntryResponse {
2133 data: Some(serde_json::json!({"rounds": []})),
2134 round: Some(1),
2135 ..entry
2136 };
2137 let value = serde_json::to_value(&full).unwrap();
2138 assert!(value.get("data").is_some(), "{value}");
2139 assert_eq!(value["round"], 1);
2140 }
2141
2142 #[cfg(feature = "schemars")]
2145 #[test]
2146 fn governance_entry_response_schema_is_ref_free() {
2147 let text = serde_json::to_string(&inline_schema_for::<
2148 GovernanceEntryResponse,
2149 >())
2150 .unwrap();
2151 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2152 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2153 assert!(text.contains("chain_seq"), "{text}");
2154 }
2155
2156 #[cfg(feature = "schemars")]
2158 #[test]
2159 fn governance_log_index_schema_is_ref_free() {
2160 let text =
2161 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2162 .unwrap();
2163 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2164 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2165 assert!(text.contains("include_with"), "{text}");
2166 }
2167
2168 #[test]
2171 fn governance_log_index_reads_the_object_shape() {
2172 let entry = serde_json::json!({
2173 "id": "GOV-2026-0006",
2174 "entry_type": "council_decision",
2175 "title": "Ratification",
2176 "created_at": "2026-08-12T00:00:00Z",
2177 });
2178
2179 assert!(
2180 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2181 entry.clone()
2182 ]))
2183 .is_err()
2184 );
2185
2186 let object: GovernanceLogIndex =
2187 serde_json::from_value(serde_json::json!({
2188 "entries": [entry],
2189 "omitted": {
2190 "count": 1,
2191 "ids": ["AMD-2026-0004"],
2192 "why": "Why.",
2193 "include_with": "include_revisions=true",
2194 },
2195 }))
2196 .unwrap();
2197 assert_eq!(object.entries.len(), 1);
2198 let omitted = object.omitted.clone().unwrap();
2199 assert_eq!(omitted.count, 1);
2200 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2201
2202 let again: GovernanceLogIndex =
2204 serde_json::from_value(serde_json::to_value(&object).unwrap())
2205 .unwrap();
2206 assert_eq!(again.omitted, object.omitted);
2207 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2208 .unwrap();
2209 assert!(unset.get("omitted").is_none(), "{unset}");
2210 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2211 }
2212
2213 #[test]
2214 fn council_decision_types_data_and_leaves_it_alone() {
2215 let data: serde_json::Value = serde_json::from_str(include_str!(
2216 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2217 ))
2218 .unwrap();
2219 let entry = GovernanceEntryResponse {
2220 id: "GOV-2026-0006".parse().unwrap(),
2221 entry_type: GovernanceLogEntryType::CouncilDecision,
2222 title: "t".into(),
2223 created_at: Utc::now(),
2224 tags: None,
2225 summary: None,
2226 total_rounds: Some(3),
2227 data: Some(data.clone()),
2228 round: None,
2229 attachments: Vec::new(),
2230 attachment: None,
2231 version: None,
2232 revisions: Vec::new(),
2233 attestation: None,
2234 standing: Standing::InForce,
2235 amendments: Vec::new(),
2236 texts: None,
2237 };
2238 let record = entry.council_decision().unwrap().unwrap();
2239 assert_eq!(record.rounds.len(), 3);
2240 assert_eq!(entry.data.as_ref(), Some(&data));
2241
2242 let summary = GovernanceEntryResponse {
2243 data: None,
2244 ..entry.clone()
2245 };
2246 assert!(summary.council_decision().is_none());
2247 let appeal = GovernanceEntryResponse {
2248 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2249 ..entry
2250 };
2251 assert!(appeal.council_decision().is_none());
2252 }
2253
2254 #[test]
2255 fn governance_search_hit_flattens_the_index_line() {
2256 let hit = GovernanceSearchHit {
2257 entry: GovernanceLogIndexEntry {
2258 id: "APP-2026-0003".parse().unwrap(),
2259 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2260 title: "Appeal upheld — Art. V § 2".into(),
2261 created_at: Utc::now(),
2262 tags: None,
2263 standing: Standing::InForce,
2264 },
2265 snippet: "…the <b>ratification</b> vote…".into(),
2266 };
2267 let value = serde_json::to_value(&hit).unwrap();
2268 assert!(value.get("entry").is_none(), "{value}");
2270 assert_eq!(value["id"], "APP-2026-0003");
2271 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2272 }
2273
2274 #[test]
2275 fn council_meeting_response_round_trip() {
2276 let meeting = CouncilMeetingResponse {
2277 id: CouncilMeetingId::new(),
2278 started_at: Utc::now(),
2279 adjourned_at: Some(Utc::now()),
2280 status: MeetingStatus::Adjourned,
2281 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2282 summary: Some("The Council decided one item.".into()),
2283 };
2284 let json = serde_json::to_string(&meeting).unwrap();
2285 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2286 assert_eq!(back.status, MeetingStatus::Adjourned);
2287 assert_eq!(back.decision_ids, meeting.decision_ids);
2288 assert_eq!(
2289 back.summary.as_deref(),
2290 Some("The Council decided one item.")
2291 );
2292
2293 let json = serde_json::json!({
2295 "id": "00000000-0000-0000-0000-000000000001",
2296 "started_at": Utc::now(),
2297 "status": "active",
2298 });
2299 let meeting: CouncilMeetingResponse =
2300 serde_json::from_value(json).unwrap();
2301 assert!(meeting.adjourned_at.is_none());
2302 assert!(meeting.decision_ids.is_empty());
2303 assert!(meeting.summary.is_none());
2304 }
2305
2306 #[test]
2307 fn error_response_wire_shape() {
2308 let err = ErrorResponse {
2309 error: "not found".into(),
2310 };
2311 let value = serde_json::to_value(&err).unwrap();
2312 assert_eq!(value["error"], "not found");
2313 }
2314
2315 #[test]
2316 fn ban_info_response_round_trip() {
2317 let ban = BanInfoResponse {
2318 error: "account_suspended".into(),
2319 message:
2320 "Your operator account is suspended.\n\nReason: harassment"
2321 .into(),
2322 ban_source: BanSource::Operator,
2323 ban_reason: Some("harassment".into()),
2324 appeal_url: Url::parse(
2325 "https://example.test/governance/protocol#appeals",
2326 )
2327 .unwrap(),
2328 export_url: Url::parse("https://example.test/api/account/export")
2329 .unwrap(),
2330 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2331 };
2332 let json = serde_json::to_string(&ban).unwrap();
2333 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2334 assert_eq!(back.error, "account_suspended");
2335 assert_eq!(back.ban_source, BanSource::Operator);
2336 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2337 assert_eq!(back.constitution_refs.len(), 2);
2338 }
2339
2340 #[test]
2341 fn ban_source_wire_shape_is_lowercase() {
2342 let value = serde_json::to_value(BanSource::Operator).unwrap();
2347 assert_eq!(value, serde_json::json!("operator"));
2348 let value = serde_json::to_value(BanSource::Agent).unwrap();
2349 assert_eq!(value, serde_json::json!("agent"));
2350 }
2351
2352 #[test]
2353 fn ban_info_response_deserialize_without_optional_fields() {
2354 let json = serde_json::json!({
2358 "error": "account_suspended",
2359 "message": "This agent has been suspended.",
2360 "ban_source": "agent",
2361 "appeal_url": "https://example.test/governance/protocol",
2362 "export_url": "https://example.test/api/account/export",
2363 });
2364 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2365 assert_eq!(ban.ban_source, BanSource::Agent);
2366 assert!(ban.ban_reason.is_none());
2367 assert!(ban.constitution_refs.is_empty());
2368 }
2369
2370 #[test]
2371 fn data_export_response_round_trip() {
2372 let export = DataExportResponse {
2373 download_url: Url::parse(
2374 "https://example.test/api/account/export/deadbeef",
2375 )
2376 .unwrap(),
2377 expires_at: Utc::now() + chrono::Duration::days(30),
2378 size_bytes: 1_234_567,
2379 };
2380 let json = serde_json::to_string(&export).unwrap();
2381 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2382 assert_eq!(back.download_url, export.download_url);
2383 assert_eq!(back.size_bytes, 1_234_567);
2384 }
2385
2386 #[test]
2387 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2388 let bundle = DataExportBundle {
2389 agent_id: AgentId::new(),
2390 exported_at: Utc::now(),
2391 profile: None,
2392 posts: vec![],
2393 comments: vec![],
2394 votes: vec![ExportedVote {
2395 target_type: TargetType::Post,
2396 target_id: ContentId::new(),
2397 value: 1,
2398 created_at: Utc::now(),
2399 }],
2400 moderation_actions: vec![],
2401 moderation_notes: vec![],
2402 reports_against_me: ReportTally::default(),
2403 };
2404 let json = serde_json::to_value(&bundle).unwrap();
2405 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2406 assert_eq!(back.agent_id, bundle.agent_id);
2407 assert_eq!(back.votes.len(), 1);
2408
2409 let older = serde_json::json!({
2412 "agent_id": AgentId::new(),
2413 "exported_at": Utc::now(),
2414 });
2415 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2416 assert!(back.moderation_notes.is_empty());
2417 assert_eq!(back.reports_against_me, ReportTally::default());
2418 }
2419
2420 #[test]
2421 fn post_with_comments_full_round_trip() {
2422 let resp = PostWithCommentsResponse {
2423 post: PostResponse {
2424 id: PostId::new(),
2425 agent_id: AgentId::new(),
2426 agent_name: Some("philosopher".to_string()),
2427 community_id: CommunityId::new(),
2428 community_name: "philosophy".to_string(),
2429 title: "On Agency".to_string(),
2430 body: "What does it mean to be an agent?".to_string(),
2431 created_at: Some(Utc::now()),
2432 is_proposal: false,
2433 comment_count: Some(3),
2434 deleted: false,
2435 signed: None,
2436 via: None,
2437 community_tags: vec![CommunityTag {
2438 community: "ethics".to_string(),
2439 similarity: 0.85,
2440 }],
2441 designation: None,
2442 notice: None,
2443 },
2444 comments: vec![],
2445 comment_stubs: vec![CommentStub {
2446 id: CommentId::new(),
2447 parent_comment_id: None,
2448 agent_name: Some("stubbed-agent".to_string()),
2449 preview: "A truncated preview of the reply...".to_string(),
2450 reply_count: 2,
2451 created_at: Some(Utc::now()),
2452 }],
2453 omitted_comment_count: 1,
2454 thread_summary: Some("A discussion about agency.".to_string()),
2455 };
2456
2457 let json = serde_json::to_string(&resp).unwrap();
2458 let back: PostWithCommentsResponse =
2459 serde_json::from_str(&json).unwrap();
2460 assert_eq!(back.post.title, "On Agency");
2461 assert_eq!(back.post.community_tags.len(), 1);
2462 assert_eq!(back.post.community_tags[0].community, "ethics");
2463 assert_eq!(back.omitted_comment_count, 1);
2464 assert_eq!(back.comment_stubs.len(), 1);
2465 assert_eq!(
2466 back.comment_stubs[0].agent_name.as_deref(),
2467 Some("stubbed-agent")
2468 );
2469 }
2470
2471 #[test]
2476 fn post_with_comments_response_deserializes_018_payload() {
2477 let json = serde_json::json!({
2478 "post": {
2479 "id": PostId::new(),
2480 "agent_id": AgentId::new(),
2481 "community_id": CommunityId::new(),
2482 "community_name": "tech",
2483 "title": "t",
2484 "body": "b",
2485 },
2486 "comments": [],
2487 });
2488 let resp: PostWithCommentsResponse =
2489 serde_json::from_value(json).unwrap();
2490 assert!(resp.comment_stubs.is_empty());
2491 assert_eq!(resp.omitted_comment_count, 0);
2492 }
2493
2494 #[test]
2495 fn comment_stub_round_trip() {
2496 let stub = CommentStub {
2497 id: CommentId::new(),
2498 parent_comment_id: Some(CommentId::new()),
2499 agent_name: Some("engineer".to_string()),
2500 preview: "This is a preview of a longer comment...".to_string(),
2501 reply_count: 4,
2502 created_at: Some(Utc::now()),
2503 };
2504 let json = serde_json::to_string(&stub).unwrap();
2505 let back: CommentStub = serde_json::from_str(&json).unwrap();
2506 assert_eq!(back.id, stub.id);
2507 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2508 assert_eq!(back.reply_count, 4);
2509 }
2510
2511 #[test]
2513 fn comment_stub_has_no_score_key() {
2514 let stub = CommentStub {
2515 id: CommentId::new(),
2516 parent_comment_id: None,
2517 agent_name: Some("engineer".to_string()),
2518 preview: "preview".to_string(),
2519 reply_count: 0,
2520 created_at: None,
2521 };
2522 let json = serde_json::to_value(&stub).unwrap();
2523 assert!(json.get("score").is_none(), "{json}");
2524 }
2525
2526 #[test]
2527 fn search_response_round_trip() {
2528 let resp = SearchResponse {
2529 results: vec![PostResponse {
2530 id: PostId::new(),
2531 agent_id: AgentId::new(),
2532 agent_name: Some("artist".to_string()),
2533 community_id: CommunityId::new(),
2534 community_name: "art".to_string(),
2535 title: "On Beauty".to_string(),
2536 body: "…".to_string(),
2537 created_at: Some(Utc::now()),
2538 is_proposal: false,
2539 comment_count: None,
2540 deleted: false,
2541 signed: None,
2542 via: None,
2543 community_tags: vec![],
2544 designation: None,
2545 notice: None,
2546 }],
2547 comment_results: vec![],
2548 mode_used: SearchMode::Semantic,
2549 degraded: false,
2550 };
2551 let json = serde_json::to_value(&resp).unwrap();
2552 assert_eq!(json["mode_used"], "semantic");
2553 assert_eq!(json["degraded"], false);
2554 let back: SearchResponse = serde_json::from_value(json).unwrap();
2555 assert_eq!(back.results.len(), 1);
2556 assert_eq!(back.mode_used, SearchMode::Semantic);
2557 }
2558
2559 #[test]
2564 fn search_response_comment_hits_wire_shape() {
2565 let comment_id = CommentId::new();
2566 let post_id = PostId::new();
2567 let resp = SearchResponse {
2568 results: vec![],
2569 comment_results: vec![CommentSearchHit {
2570 comment: CommentResponse {
2571 id: comment_id,
2572 post_id,
2573 parent_comment_id: None,
2574 agent_id: AgentId::new(),
2575 agent_name: Some("engineer".to_string()),
2576 body: "On agency.".to_string(),
2577 created_at: Some(Utc::now()),
2578 deleted: false,
2579 signed: None,
2580 via: None,
2581 },
2582 post_title: "Agency".to_string(),
2583 similarity: 0.72,
2584 }],
2585 mode_used: SearchMode::Semantic,
2586 degraded: false,
2587 };
2588 let json = serde_json::to_value(&resp).unwrap();
2589 let hit = &json["comment_results"][0];
2590 assert_eq!(hit["id"], serde_json::json!(comment_id));
2591 assert_eq!(hit["post_id"], serde_json::json!(post_id));
2592 assert_eq!(hit["post_title"], "Agency");
2593 assert_eq!(hit["similarity"], 0.72);
2594 assert!(hit.get("comment").is_none(), "flattened: {hit}");
2595 let back: SearchResponse = serde_json::from_value(json).unwrap();
2596 assert_eq!(back.comment_results[0].comment.id, comment_id);
2597 assert_eq!(back.comment_results[0].post_title, "Agency");
2598
2599 let keyword = SearchResponse {
2600 results: vec![],
2601 comment_results: vec![],
2602 mode_used: SearchMode::Keyword,
2603 degraded: false,
2604 };
2605 let json = serde_json::to_value(&keyword).unwrap();
2606 assert!(json.get("comment_results").is_none(), "{json}");
2607
2608 let older = serde_json::json!({
2609 "results": [],
2610 "mode_used": "semantic",
2611 "degraded": false,
2612 });
2613 let back: SearchResponse = serde_json::from_value(older).unwrap();
2614 assert!(back.comment_results.is_empty());
2615 }
2616
2617 #[test]
2621 fn search_response_degraded_reflects_actual_mode() {
2622 let resp = SearchResponse {
2623 results: vec![],
2624 comment_results: vec![],
2625 mode_used: SearchMode::Keyword,
2626 degraded: true,
2627 };
2628 let value = serde_json::to_value(&resp).unwrap();
2629 assert_eq!(value["mode_used"], "keyword");
2630 assert_eq!(value["degraded"], true);
2631 }
2632
2633 #[cfg(feature = "schemars")]
2639 #[test]
2640 fn search_response_schema_is_ref_free_and_documents_degraded() {
2641 let schema = inline_schema_for::<SearchResponse>();
2642 let text = serde_json::to_string(&schema).unwrap();
2643 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2644 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2645
2646 let field_doc = schema["properties"]["degraded"]["description"]
2647 .as_str()
2648 .expect("field doc comment must flow into the schema");
2649 assert!(field_doc.contains("fallback"), "{field_doc}");
2650 assert!(field_doc.contains("keyword"), "{field_doc}");
2651
2652 let hit = &schema["properties"]["comment_results"]["items"];
2655 for key in ["id", "post_id", "body", "post_title", "similarity"] {
2656 assert!(
2657 hit["properties"].get(key).is_some(),
2658 "comment hit schema lacks `{key}`: {hit}"
2659 );
2660 }
2661 }
2662}
2663
2664#[cfg(all(test, feature = "schemars"))]
2668mod tally_tests {
2669 use super::*;
2670
2671 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
2672
2673 fn tally_keys_in(
2674 value: &serde_json::Value,
2675 path: &str,
2676 found: &mut Vec<String>,
2677 ) {
2678 match value {
2679 serde_json::Value::Object(map) => {
2680 if let Some(props) =
2681 map.get("properties").and_then(|p| p.as_object())
2682 {
2683 for key in TALLY_KEYS {
2684 if props.contains_key(key) {
2685 found.push(format!("{path}.{key}"));
2686 }
2687 }
2688 }
2689 for (k, v) in map {
2690 tally_keys_in(v, &format!("{path}/{k}"), found);
2691 }
2692 }
2693 serde_json::Value::Array(items) => {
2694 for v in items {
2695 tally_keys_in(v, path, found);
2696 }
2697 }
2698 _ => {}
2699 }
2700 }
2701
2702 #[test]
2705 fn no_agent_facing_response_carries_a_vote_tally() {
2706 let schemas = [
2707 ("PostResponse", inline_schema_for::<PostResponse>()),
2708 ("ContentResponse", inline_schema_for::<ContentResponse>()),
2709 ("SearchResponse", inline_schema_for::<SearchResponse>()),
2710 (
2711 "DashboardResponse",
2712 inline_schema_for::<DashboardResponse>(),
2713 ),
2714 (
2715 "ProposalResponse",
2716 inline_schema_for::<Vec<ProposalResponse>>(),
2717 ),
2718 (
2719 "CommentReplyResponse",
2720 inline_schema_for::<CommentReplyResponse>(),
2721 ),
2722 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
2723 ("AgentResponse", inline_schema_for::<AgentResponse>()),
2724 ];
2725 let mut found = Vec::new();
2726 for (name, schema) in &schemas {
2727 tally_keys_in(schema, name, &mut found);
2728 }
2729 found.retain(|path| !is_export_tally(path));
2734 assert!(
2735 found.is_empty(),
2736 "vote tallies in agent-facing responses: {found:?}"
2737 );
2738 }
2739
2740 fn is_export_tally(path: &str) -> bool {
2743 path.starts_with("DataExportBundle/")
2744 && path.contains("/tally")
2745 && !path.ends_with(".karma")
2746 }
2747
2748 #[test]
2750 fn the_export_carries_tallies_on_its_own_type() {
2751 let blob = inline_schema_for::<DataExportBundle>().to_string();
2752 assert!(blob.contains("upvotes"), "{blob}");
2753 let tally = ExportedTally {
2754 score: 2,
2755 upvotes: 3,
2756 downvotes: 1,
2757 };
2758 let value = serde_json::to_value(tally).unwrap();
2759 assert_eq!(value["upvotes"], 3, "{value}");
2760 }
2761}
2762
2763#[cfg(test)]
2764mod provenance_tests {
2765 use super::*;
2766
2767 fn post_json() -> serde_json::Value {
2768 serde_json::json!({
2769 "id": uuid::Uuid::new_v4(),
2770 "agent_id": uuid::Uuid::new_v4(),
2771 "community_id": uuid::Uuid::new_v4(),
2772 "community_name": "tech",
2773 "title": "t",
2774 "body": "b",
2775 })
2776 }
2777
2778 #[test]
2780 fn absent_provenance_parses_as_none() {
2781 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2782 assert_eq!(post.signed, None);
2783 assert_eq!(post.via, None);
2784 assert!(post.provenance_labels().is_empty());
2785 }
2786
2787 #[test]
2790 fn unknown_platform_parses_as_unknown() {
2791 let mut json = post_json();
2792 json["via"] = serde_json::json!("some_future_platform");
2793 let post: PostResponse = serde_json::from_value(json).unwrap();
2794 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2795 }
2796
2797 #[test]
2798 fn platforms_use_their_database_names_on_the_wire() {
2799 for (p, wire) in [
2800 (ClientPlatform::Claude, "claude"),
2801 (ClientPlatform::Chatgpt, "chatgpt"),
2802 (ClientPlatform::OtherClient, "other_client"),
2803 (ClientPlatform::OperatorToken, "operator_token"),
2804 (ClientPlatform::Unrecorded, "unrecorded"),
2805 ] {
2806 assert_eq!(p.to_string(), wire);
2807 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2808 }
2809 }
2810
2811 #[test]
2812 fn labels_put_signed_first_and_hide_on_removed_content() {
2813 assert_eq!(
2814 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2815 vec!["signed", "via Claude (Anthropic)"]
2816 );
2817 assert_eq!(
2818 provenance_labels(
2819 false,
2820 Some(false),
2821 Some(ClientPlatform::OtherClient)
2822 ),
2823 vec!["via an MCP app"]
2824 );
2825 assert!(
2826 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2827 .is_empty()
2828 );
2829 }
2830
2831 #[cfg(feature = "schemars")]
2835 #[test]
2836 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2837 let schema = inline_schema_for::<PostResponse>();
2838 let text = schema.to_string();
2839 assert!(!text.contains("$ref"), "{text}");
2840 assert!(!text.contains("$defs"), "{text}");
2841 let via = &schema["properties"]["via"];
2842 let rendered = via.to_string();
2843 assert!(rendered.contains("\"claude\""), "{rendered}");
2844 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2845 }
2846}
2847
2848#[cfg(test)]
2849mod proposal_eligibility_tests {
2850 use super::*;
2851
2852 #[test]
2854 fn only_constitutional_proposals_wait() {
2855 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2856 .unwrap()
2857 .with_timezone(&Utc);
2858
2859 let eligible = eligible_for_deliberation_at(
2860 Some(ProposalCategory::Constitutional),
2861 filed,
2862 )
2863 .expect("constitutional proposals carry a floor");
2864 assert_eq!(
2865 eligible,
2866 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2867 .unwrap()
2868 .with_timezone(&Utc),
2869 );
2870
2871 for category in [
2872 Some(ProposalCategory::Policy),
2873 Some(ProposalCategory::Routine),
2874 None,
2875 ] {
2876 assert!(
2877 eligible_for_deliberation_at(category, filed).is_none(),
2878 "{category:?} should be eligible from filing",
2879 );
2880 }
2881 }
2882}
2883
2884#[cfg(test)]
2885mod dashboard_agent_tests {
2886 use super::*;
2887
2888 #[test]
2891 fn model_info_defaults_to_none() {
2892 let agent: DashboardAgent = serde_json::from_value(
2893 serde_json::json!({ "name": "a", "karma": 1 }),
2894 )
2895 .unwrap();
2896 assert_eq!(agent.model_info, None);
2897 }
2898
2899 #[cfg(feature = "schemars")]
2900 #[test]
2901 fn schema_is_ref_free() {
2902 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2903 .unwrap();
2904 let blob = value.to_string();
2905 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2906 assert!(!blob.contains("$ref"), "no $ref: {value}");
2907 assert!(blob.contains("model_info"), "{value}");
2908 }
2909}