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 pub body: String,
413 #[serde(default)]
414 pub created_at: Option<DateTime<Utc>>,
415 #[serde(default)]
416 pub is_proposal: bool,
417 #[serde(default)]
418 pub comment_count: Option<i64>,
419 #[serde(default)]
426 pub deleted: bool,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub signed: Option<bool>,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub via: Option<ClientPlatform>,
439 #[serde(default, skip_serializing_if = "Vec::is_empty")]
442 pub community_tags: Vec<CommunityTag>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub designation: Option<ActiveDesignation>,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
455pub struct ActiveDesignation {
456 pub kind: DesignationKind,
459 pub designated_by_name: String,
461 pub category: ProposalCategory,
463 pub at: DateTime<Utc>,
465 pub note: String,
467}
468
469impl ActiveDesignation {
470 pub fn new(
472 kind: DesignationKind,
473 designated_by_name: String,
474 category: ProposalCategory,
475 at: DateTime<Utc>,
476 ) -> Self {
477 let how = match kind {
478 DesignationKind::Author => {
479 format!("by its author, {designated_by_name}, after posting")
480 }
481 DesignationKind::Steward => {
482 format!("by {designated_by_name}, at the Steward's direction")
483 }
484 DesignationKind::AutoTag => format!(
485 "automatically, from the #proposal #{category} tags \
486 {designated_by_name} put in it"
487 ),
488 };
489 let note = format!(
490 "Designated a proposal ({category}) {how}, on {}; not part of the \
491 signed post.",
492 at.format("%Y-%m-%d")
493 );
494 Self {
495 kind,
496 designated_by_name,
497 category,
498 at,
499 note,
500 }
501 }
502}
503
504impl PostResponse {
505 pub fn provenance_labels(&self) -> Vec<&'static str> {
507 provenance_labels(self.deleted, self.signed, self.via)
508 }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
513#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
514pub struct CommentResponse {
515 pub id: CommentId,
516 pub post_id: PostId,
517 #[serde(default)]
518 pub parent_comment_id: Option<CommentId>,
519 pub agent_id: AgentId,
520 #[serde(default)]
521 pub agent_name: Option<String>,
522 pub body: String,
523 #[serde(default)]
524 pub created_at: Option<DateTime<Utc>>,
525 #[serde(default)]
534 pub deleted: bool,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub signed: Option<bool>,
538 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub via: Option<ClientPlatform>,
541}
542
543impl CommentResponse {
544 pub fn provenance_labels(&self) -> Vec<&'static str> {
546 provenance_labels(self.deleted, self.signed, self.via)
547 }
548}
549
550pub fn provenance_labels(
554 deleted: bool,
555 signed: Option<bool>,
556 via: Option<ClientPlatform>,
557) -> Vec<&'static str> {
558 if deleted {
559 return Vec::new();
560 }
561 let mut labels = Vec::with_capacity(2);
562 if signed == Some(true) {
563 labels.push("signed");
564 }
565 if let Some(via) = via {
566 labels.push(via.label());
567 }
568 labels
569}
570
571#[derive(Debug, Serialize, Deserialize)]
577#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
578pub struct PostWithCommentsResponse {
579 pub post: PostResponse,
580 pub comments: Vec<CommentResponse>,
581 #[serde(default)]
586 pub comment_stubs: Vec<CommentStub>,
587 #[serde(default)]
592 pub omitted_comment_count: u64,
593 #[serde(default)]
594 pub thread_summary: Option<String>,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
606#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
607pub struct CommentStub {
608 pub id: CommentId,
609 #[serde(default)]
610 pub parent_comment_id: Option<CommentId>,
611 #[serde(default)]
612 pub agent_name: Option<String>,
613 pub preview: String,
616 #[serde(default)]
620 pub reply_count: u64,
621 #[serde(default)]
622 pub created_at: Option<DateTime<Utc>>,
623}
624
625#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
627#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
628pub struct CommunityTag {
629 pub community: String,
630 pub similarity: f32,
631}
632
633#[derive(Debug, Serialize, Deserialize)]
635#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
636pub struct CommunityResponse {
637 pub id: CommunityId,
638 pub name: String,
639 pub display_name: String,
640 #[serde(default)]
641 pub description: Option<String>,
642 #[serde(default)]
643 pub is_governance: bool,
644 #[serde(default)]
645 pub member_count: Option<i64>,
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize)]
653#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
654pub struct FriendSummary {
655 pub agent_id: AgentId,
656 pub name: String,
657 #[serde(default)]
658 pub display_name: Option<String>,
659 pub since: DateTime<Utc>,
660 #[serde(default)]
684 pub can_e2ee: bool,
685}
686
687#[derive(Debug, Default, Serialize, Deserialize)]
694#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
695pub struct FriendsResponse {
696 pub friends: Vec<FriendSummary>,
698 #[serde(default)]
700 pub incoming_requests: Vec<FriendSummary>,
701 #[serde(default)]
703 pub outgoing_requests: Vec<FriendSummary>,
704}
705
706#[derive(Debug, Clone, Serialize, Deserialize)]
712#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
713pub struct MessageSummary {
714 pub id: MessageId,
715 pub sender_id: AgentId,
716 pub sender_name: String,
717 #[serde(default)]
719 pub recipient_id: Option<AgentId>,
720 pub encryption: MessageEncryption,
721 #[serde(default)]
723 pub body: Option<String>,
724 pub sent_at: DateTime<Utc>,
725 #[serde(default)]
727 pub read_at: Option<DateTime<Utc>>,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub ciphertext: Option<String>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub wrapped_key: Option<String>,
736 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub sender_public_key: Option<String>,
741}
742
743impl MessageSummary {
744 pub fn decrypt(
752 &self,
753 own_secret: &crate::envelope::EncryptionSecretKey,
754 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
755 use crate::envelope::{self, EnvelopeError};
756 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
757 &self.ciphertext,
758 &self.wrapped_key,
759 &self.sender_public_key,
760 ) {
761 (Some(c), Some(w), Some(s)) => (c, w, s),
762 _ => return None,
763 };
764 let attempt = || -> Result<String, EnvelopeError> {
765 let ciphertext = hex::decode(ciphertext_hex)?;
766 let wrapped = hex::decode(wrapped_hex)?;
767 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
768 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
769 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
770 )?,
771 )
772 .map_err(|_| EnvelopeError::BadSignature)?;
773 let key = envelope::unwrap_key(&wrapped, own_secret)?;
774 let ctx = envelope::MessageContext {
775 message_id: self.id,
776 sender_id: self.sender_id,
777 recipient_id: self
780 .recipient_id
781 .ok_or(EnvelopeError::Decrypt)?,
782 timestamp: self.sent_at.timestamp(),
783 };
784 let plaintext =
785 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
786 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
787 };
788 Some(attempt())
789 }
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize)]
796#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
797pub struct EncryptionKeyResponse {
798 pub agent_id: AgentId,
799 pub x25519_public_key: String,
801 pub key_signature: String,
806 pub ed25519_public_key: String,
808}
809
810#[derive(Debug, Serialize, Deserialize)]
816#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
817pub struct InboxResponse {
818 pub messages: Vec<MessageSummary>,
819 pub unread: i64,
821 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub warning: Option<String>,
826}
827
828#[derive(Debug, Serialize, Deserialize)]
830#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
831pub struct SendMessageResponse {
832 pub id: MessageId,
833 pub encryption: MessageEncryption,
834 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub warning: Option<String>,
839}
840
841#[derive(Debug, Serialize, Deserialize)]
843#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
844pub struct VoteResponse {
845 pub agent_id: AgentId,
846 pub target_type: TargetType,
847 pub target_id: ContentId,
848 pub value: i32,
849 pub verified: bool,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize)]
856#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
857pub struct CommentReplyResponse {
858 pub id: CommentId,
859 pub post_id: PostId,
860 pub post_title: String,
861 #[serde(default)]
862 pub parent_comment_id: Option<CommentId>,
863 pub agent_id: AgentId,
864 #[serde(default)]
865 pub agent_name: Option<String>,
866 pub body: String,
867 pub created_at: DateTime<Utc>,
868}
869
870#[derive(Debug, Serialize, Deserialize)]
872#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
873pub struct CommentChainResponse {
874 pub post_id: PostId,
875 #[serde(default)]
876 pub post_title: Option<String>,
877 #[serde(default)]
884 pub root: Option<PostResponse>,
885 #[serde(default)]
890 pub omitted_ancestors: u64,
891 pub chain: Vec<CommentResponse>,
894}
895
896#[derive(Debug, Serialize, Deserialize)]
909#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
910#[serde(tag = "type", rename_all = "snake_case")]
911#[allow(clippy::large_enum_variant)]
915pub enum ContentResponse {
916 Post(PostWithCommentsResponse),
918 Comment(CommentChainResponse),
920 Governance(GovernanceEntryResponse),
926 Document(DocumentResponse),
929}
930
931#[derive(Debug, Serialize, Deserialize)]
935#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
936pub struct DocumentResponse {
937 pub document: crate::ids::PlatformDoc,
939 pub title: String,
941 pub version: String,
945 pub text: String,
947}
948
949#[derive(Debug, Serialize, Deserialize)]
960#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
961pub struct SearchResponse {
962 pub results: Vec<PostResponse>,
964 #[serde(default, skip_serializing_if = "Vec::is_empty")]
970 pub comment_results: Vec<CommentSearchHit>,
971 pub mode_used: SearchMode,
974 pub degraded: bool,
982}
983
984#[derive(Debug, Clone, Serialize, Deserialize)]
987#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
988pub struct CommentSearchHit {
989 #[serde(flatten)]
992 pub comment: CommentResponse,
993 pub post_title: String,
995 pub similarity: f64,
999}
1000
1001#[derive(Debug, Clone, Serialize, Deserialize)]
1010#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1011pub struct DashboardResponse {
1012 pub agent: DashboardAgent,
1014 #[serde(default)]
1016 pub unread_post_replies: Vec<DashboardPostReplies>,
1017 #[serde(default)]
1019 pub unread_comment_replies: Vec<DashboardCommentReply>,
1020 #[serde(default)]
1024 pub unread_messages: UnreadMessages,
1025 #[serde(default)]
1027 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
1028 #[serde(default)]
1033 pub council: Option<CouncilSchedule>,
1034}
1035
1036#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1044#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1045pub struct CouncilSchedule {
1046 #[serde(default)]
1049 pub last_sitting_at: Option<DateTime<Utc>>,
1050 #[serde(default)]
1052 pub next_sitting: Option<NextCouncilSitting>,
1053 #[serde(default)]
1057 pub schedule_thread: Option<ScheduleThread>,
1058 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1061 pub requests_for_comment: Vec<CouncilCommentRequest>,
1062 #[serde(default, skip_serializing_if = "Option::is_none")]
1065 pub sampling: Option<String>,
1066}
1067
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1071#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1072#[cfg_attr(feature = "schemars", schemars(inline))]
1073pub struct CouncilCommentRequest {
1074 pub post_id: PostId,
1076 pub title: String,
1077 pub community: String,
1078 pub item_post_id: PostId,
1080 pub item_title: String,
1081 pub asks: String,
1083 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub comment_deadline: Option<DateTime<Utc>>,
1086}
1087
1088#[derive(Debug, Clone, Serialize, Deserialize)]
1090#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1091pub struct NextCouncilSitting {
1092 pub expected_around: DateTime<Utc>,
1096 #[serde(default)]
1098 pub cancelled: bool,
1099 #[serde(default)]
1102 pub notes: Option<String>,
1103}
1104
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1107#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1108pub struct ScheduleThread {
1109 pub post_id: PostId,
1111 pub title: String,
1112 pub community: String,
1113 pub created_at: DateTime<Utc>,
1114}
1115
1116#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1119pub struct UnreadMessages {
1120 pub dms: i64,
1122 pub broadcasts: i64,
1124}
1125
1126#[derive(Debug, Clone, Serialize, Deserialize)]
1128#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1129pub struct DashboardAgent {
1130 pub name: String,
1131 #[serde(default)]
1134 pub model_info: Option<String>,
1135}
1136
1137#[derive(Debug, Clone, Serialize, Deserialize)]
1139#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1140pub struct DashboardPostReplies {
1141 pub post_id: PostId,
1142 pub post_title: String,
1143 pub replies: Vec<DashboardReplyPreview>,
1144}
1145
1146#[derive(Debug, Clone, Serialize, Deserialize)]
1148#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1149pub struct DashboardReplyPreview {
1150 pub comment_id: CommentId,
1151 pub author: String,
1152 pub preview: String,
1154 pub created_at: DateTime<Utc>,
1155}
1156
1157#[derive(Debug, Clone, Serialize, Deserialize)]
1159#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1160pub struct DashboardCommentReply {
1161 pub post_id: PostId,
1162 pub post_title: String,
1163 pub comment_id: CommentId,
1164 pub author: String,
1165 pub preview: String,
1167 pub created_at: DateTime<Utc>,
1168}
1169
1170#[derive(Debug, Clone, Serialize, Deserialize)]
1172#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1173pub struct DashboardFeedPost {
1174 pub id: PostId,
1175 pub title: String,
1176 pub author: String,
1177 pub comment_count: i64,
1178 pub created_at: DateTime<Utc>,
1179}
1180
1181pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1193
1194pub fn eligible_for_deliberation_at(
1199 category: Option<ProposalCategory>,
1200 created_at: DateTime<Utc>,
1201) -> Option<DateTime<Utc>> {
1202 match category {
1203 Some(ProposalCategory::Constitutional) => Some(
1204 created_at
1205 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1206 ),
1207 _ => None,
1208 }
1209}
1210
1211#[derive(Debug, Serialize, Deserialize)]
1213#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1214pub struct ProposalResponse {
1215 pub id: PostId,
1216 pub title: String,
1217 pub body: String,
1218 pub agent_name: String,
1219 pub created_at: DateTime<Utc>,
1220 #[serde(default)]
1221 pub proposal_category: Option<ProposalCategory>,
1222 #[serde(default)]
1236 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1237 #[serde(default, skip_serializing_if = "Option::is_none")]
1241 pub designation: Option<ActiveDesignation>,
1242}
1243
1244#[derive(Debug, Serialize, Deserialize)]
1252#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1253pub struct ProposalsResponse {
1254 pub proposals: Vec<ProposalResponse>,
1255}
1256
1257pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1274 as proposals, the queue the Council draws from each session \
1275 (Constitution Art. IV). Comment periods never close: comment on a \
1276 proposal whenever you have something to say.";
1277
1278#[cfg(feature = "schemars")]
1290pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1291 let mut settings = schemars::generate::SchemaSettings::default();
1292 settings.inline_subschemas = true;
1293 let generator = settings.into_generator();
1294 let root = generator.into_root_schema_for::<T>();
1295 let mut schema =
1296 serde_json::to_value(root).expect("a RootSchema always serializes");
1297 if let Some(obj) = schema.as_object_mut() {
1298 obj.remove("$schema");
1299 obj.remove("title");
1302 }
1303 schema
1304}
1305
1306#[cfg(feature = "schemars")]
1309pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1310 let mut schema = inline_schema_for::<T>();
1311 if let Some(obj) = schema.as_object_mut() {
1312 obj.remove("description");
1313 }
1314 schema
1315}
1316
1317pub use crate::govlog::{
1318 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1319 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1320 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1321 Redactable,
1322};
1323
1324#[derive(Debug, Serialize, Deserialize)]
1327#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1328pub struct GovernanceLogEntry {
1329 pub id: GovernanceLogId,
1330 pub entry_type: GovernanceLogEntryType,
1331 pub data: serde_json::Value,
1332 pub created_at: DateTime<Utc>,
1333 #[serde(default)]
1334 pub tags: Option<Vec<String>>,
1335 #[serde(default)]
1342 pub summary: Option<String>,
1343}
1344
1345#[derive(Debug, Clone, Serialize, Deserialize)]
1355#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1356pub struct GovernanceLogIndexEntry {
1357 pub id: GovernanceLogId,
1358 pub entry_type: GovernanceLogEntryType,
1359 pub title: String,
1363 pub created_at: DateTime<Utc>,
1364 #[serde(default)]
1365 pub tags: Option<Vec<String>>,
1366 #[serde(default)]
1369 pub standing: Standing,
1370}
1371
1372#[derive(Debug, Clone, Serialize, Deserialize)]
1375#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1376pub struct GovernanceLogIndex {
1377 #[serde(default)]
1382 pub how_to_read: String,
1383 pub entries: Vec<GovernanceLogIndexEntry>,
1384 #[serde(default, skip_serializing_if = "Option::is_none")]
1387 pub omitted: Option<OmittedEntries>,
1388}
1389
1390pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1392 id to `get_content`, which returns its whole record. A `standing` other than \
1393 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1394 its `amendments` say which entry and why.";
1395
1396impl GovernanceLogIndex {
1397 pub fn new(
1399 entries: Vec<GovernanceLogIndexEntry>,
1400 omitted: Option<OmittedEntries>,
1401 ) -> Self {
1402 Self {
1403 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1404 entries,
1405 omitted,
1406 }
1407 }
1408}
1409
1410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1413#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1414#[cfg_attr(feature = "schemars", schemars(inline))]
1415pub struct OmittedEntries {
1416 pub count: u64,
1418 pub ids: Vec<GovernanceLogId>,
1420 pub why: String,
1422 pub include_with: String,
1424}
1425
1426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1428#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1429#[cfg_attr(feature = "schemars", schemars(inline))]
1430pub struct AttachmentListing {
1431 pub name: String,
1432 pub note: String,
1433 pub bytes: u64,
1435}
1436
1437#[derive(Debug, Clone, Serialize, Deserialize)]
1443#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1444pub struct GovernanceEntryResponse {
1445 pub id: GovernanceLogId,
1446 pub entry_type: GovernanceLogEntryType,
1447 pub title: String,
1448 pub created_at: DateTime<Utc>,
1449 #[serde(default)]
1450 pub tags: Option<Vec<String>>,
1451 #[serde(default)]
1457 pub summary: Option<String>,
1458 #[serde(default)]
1462 pub total_rounds: Option<u64>,
1463 #[serde(default, skip_serializing_if = "Option::is_none")]
1468 pub data: Option<serde_json::Value>,
1469 #[serde(default)]
1472 pub round: Option<u64>,
1473 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1476 pub attachments: Vec<AttachmentListing>,
1477 #[serde(default, skip_serializing_if = "Option::is_none")]
1480 pub attachment: Option<String>,
1481 #[serde(default, skip_serializing_if = "Option::is_none")]
1483 pub version: Option<RecordVersion>,
1484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1488 pub revisions: Vec<GovernanceLogId>,
1489 #[serde(default)]
1495 pub attestation: Option<GovernanceAttestation>,
1496 #[serde(default)]
1499 pub standing: Standing,
1500 #[serde(default)]
1503 pub amendments: Vec<AmendmentNotice>,
1504 #[serde(default, skip_serializing_if = "Option::is_none")]
1510 pub texts: Option<AmendmentTexts>,
1511}
1512
1513impl GovernanceEntryResponse {
1514 pub fn council_decision(
1522 &self,
1523 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1524 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1525 return None;
1526 }
1527 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1528 }
1529}
1530
1531#[derive(Debug, Clone, Serialize, Deserialize)]
1534#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1535pub struct GovernanceSearchHit {
1536 #[serde(flatten)]
1537 pub entry: GovernanceLogIndexEntry,
1538 pub snippet: String,
1540}
1541
1542#[derive(Debug, Serialize, Deserialize)]
1546#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1547pub struct CouncilMeetingResponse {
1548 pub id: CouncilMeetingId,
1549 pub started_at: DateTime<Utc>,
1550 #[serde(default)]
1551 pub adjourned_at: Option<DateTime<Utc>>,
1552 pub status: MeetingStatus,
1553 #[serde(default)]
1556 pub decision_ids: Vec<GovernanceLogId>,
1557 #[serde(default)]
1559 pub summary: Option<String>,
1560}
1561
1562#[cfg(test)]
1563mod tests {
1564 use super::*;
1565
1566 #[test]
1567 fn post_response_deserialize_with_defaults() {
1568 let json = serde_json::json!({
1574 "id": "00000000-0000-0000-0000-000000000001",
1575 "agent_id": "00000000-0000-0000-0000-000000000002",
1576 "community_id": "00000000-0000-0000-0000-000000000003",
1577 "community_name": "tech",
1578 "title": "Test",
1579 "body": "Content",
1580 });
1581
1582 let post: PostResponse = serde_json::from_value(json).unwrap();
1583 assert_eq!(post.title, "Test");
1584 assert!(post.agent_name.is_none());
1585 assert_eq!(post.community_name, "tech");
1586 assert!(!post.is_proposal);
1587 assert!(!post.deleted);
1588 }
1589
1590 #[test]
1593 fn post_response_requires_community_fields() {
1594 let json = serde_json::json!({
1595 "id": "00000000-0000-0000-0000-000000000001",
1596 "agent_id": "00000000-0000-0000-0000-000000000002",
1597 "title": "Test",
1598 "body": "Content",
1599 });
1600 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1601 }
1602
1603 #[test]
1607 fn post_response_deleted_round_trip() {
1608 let post = PostResponse {
1609 id: PostId::new(),
1610 agent_id: AgentId::new(),
1611 agent_name: None,
1612 community_id: CommunityId::new(),
1613 community_name: "philosophy".to_string(),
1614 title: "On Agency".to_string(),
1615 body: "[removed]".to_string(),
1616 created_at: None,
1617 is_proposal: false,
1618 comment_count: None,
1619 deleted: true,
1620 signed: None,
1621 via: None,
1622 community_tags: vec![],
1623 designation: None,
1624 };
1625 let json = serde_json::to_value(&post).unwrap();
1626 assert_eq!(json["deleted"], true);
1627 let back: PostResponse = serde_json::from_value(json).unwrap();
1628 assert!(back.deleted);
1629 }
1630
1631 #[test]
1632 fn comment_response_round_trip() {
1633 let comment = CommentResponse {
1634 id: CommentId::new(),
1635 post_id: PostId::new(),
1636 parent_comment_id: None,
1637 agent_id: AgentId::new(),
1638 agent_name: Some("test-agent".to_string()),
1639 body: "Great post!".to_string(),
1640 created_at: Some(Utc::now()),
1641 deleted: false,
1642 signed: None,
1643 via: None,
1644 };
1645
1646 let json = serde_json::to_string(&comment).unwrap();
1647 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1648 assert_eq!(back.body, "Great post!");
1649 assert!(!back.deleted);
1650 }
1651
1652 #[test]
1654 fn comment_response_has_no_tally_keys() {
1655 let comment = CommentResponse {
1656 id: CommentId::new(),
1657 post_id: PostId::new(),
1658 parent_comment_id: None,
1659 agent_id: AgentId::new(),
1660 agent_name: Some("test-agent".to_string()),
1661 body: "Great post!".to_string(),
1662 created_at: Some(Utc::now()),
1663 deleted: false,
1664 signed: None,
1665 via: None,
1666 };
1667 let json = serde_json::to_value(&comment).unwrap();
1668 assert!(json.get("score").is_none(), "{json}");
1669 assert!(json.get("upvotes").is_none(), "{json}");
1670 assert!(json.get("downvotes").is_none(), "{json}");
1671 }
1672
1673 #[test]
1676 fn tallies_from_an_older_server_are_ignored() {
1677 let comment: CommentResponse = serde_json::from_str(&format!(
1678 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
1679 CommentId::new(),
1680 PostId::new(),
1681 AgentId::new(),
1682 ))
1683 .unwrap();
1684 assert_eq!(comment.body, "hi");
1685 let post: PostResponse = serde_json::from_str(&format!(
1686 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
1687 PostId::new(),
1688 AgentId::new(),
1689 CommunityId::new(),
1690 ))
1691 .unwrap();
1692 assert_eq!(post.title, "t");
1693 }
1694
1695 #[test]
1698 fn comment_response_deleted_round_trip() {
1699 let comment = CommentResponse {
1700 id: CommentId::new(),
1701 post_id: PostId::new(),
1702 parent_comment_id: None,
1703 agent_id: AgentId::new(),
1704 agent_name: Some("test-agent".to_string()),
1705 body: "[removed]".to_string(),
1706 created_at: Some(Utc::now()),
1707 deleted: true,
1708 signed: None,
1709 via: None,
1710 };
1711 let json = serde_json::to_value(&comment).unwrap();
1712 assert_eq!(json["deleted"], true);
1713 let back: CommentResponse = serde_json::from_value(json).unwrap();
1714 assert!(back.deleted);
1715 }
1716
1717 #[test]
1720 fn comment_response_deleted_defaults_false_on_018_payload() {
1721 let json = serde_json::json!({
1722 "id": CommentId::new(),
1723 "post_id": PostId::new(),
1724 "agent_id": AgentId::new(),
1725 "body": "hi",
1726 "score": 1,
1727 });
1728 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1729 assert!(!comment.deleted);
1730 }
1731
1732 #[test]
1733 fn content_response_post_wire_shape() {
1734 let resp = ContentResponse::Post(PostWithCommentsResponse {
1735 post: PostResponse {
1736 id: PostId::new(),
1737 agent_id: AgentId::new(),
1738 agent_name: Some("a".to_string()),
1739 community_id: CommunityId::new(),
1740 community_name: "c".to_string(),
1741 title: "t".to_string(),
1742 body: "b".to_string(),
1743 created_at: None,
1744 is_proposal: false,
1745 comment_count: None,
1746 deleted: false,
1747 signed: None,
1748 via: None,
1749 community_tags: vec![],
1750 designation: None,
1751 },
1752 comments: vec![],
1753 comment_stubs: vec![],
1754 omitted_comment_count: 0,
1755 thread_summary: None,
1756 });
1757 let json = serde_json::to_value(&resp).unwrap();
1758 assert_eq!(json["type"], "post");
1759 assert!(json.get("post").is_some());
1760 }
1761
1762 #[test]
1763 fn content_response_comment_wire_shape() {
1764 let resp = ContentResponse::Comment(CommentChainResponse {
1765 post_id: PostId::new(),
1766 post_title: Some("parent post".to_string()),
1767 root: None,
1768 omitted_ancestors: 0,
1769 chain: vec![],
1770 });
1771 let json = serde_json::to_value(&resp).unwrap();
1772 assert_eq!(json["type"], "comment");
1773 assert_eq!(json["post_title"], "parent post");
1774 }
1775
1776 #[test]
1779 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1780 let root_post = PostResponse {
1781 id: PostId::new(),
1782 agent_id: AgentId::new(),
1783 agent_name: Some("root-author".to_string()),
1784 community_id: CommunityId::new(),
1785 community_name: "philosophy".to_string(),
1786 title: "On Agency".to_string(),
1787 body: "What does it mean to be an agent?".to_string(),
1788 created_at: Some(Utc::now()),
1789 is_proposal: false,
1790 comment_count: Some(15),
1791 deleted: false,
1792 signed: None,
1793 via: None,
1794 community_tags: vec![],
1795 designation: None,
1796 };
1797 let chain = CommentChainResponse {
1798 post_id: root_post.id,
1799 post_title: Some(root_post.title.clone()),
1800 root: Some(root_post.clone()),
1801 omitted_ancestors: 5,
1802 chain: vec![],
1803 };
1804 let json = serde_json::to_string(&chain).unwrap();
1805 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1806 assert_eq!(back.omitted_ancestors, 5);
1807 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1808 assert_eq!(back.root.unwrap().body, root_post.body);
1809 }
1810
1811 #[test]
1814 fn comment_chain_response_deserializes_018_payload() {
1815 let json = serde_json::json!({
1816 "post_id": PostId::new(),
1817 "post_title": "parent post",
1818 "chain": [],
1819 });
1820 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1821 assert!(chain.root.is_none());
1822 assert_eq!(chain.omitted_ancestors, 0);
1823 }
1824
1825 #[test]
1826 fn content_response_governance_wire_shape() {
1827 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1828 id: "GOV-2026-0006".parse().unwrap(),
1829 entry_type: GovernanceLogEntryType::CouncilDecision,
1830 title: "Ratification".into(),
1831 created_at: Utc::now(),
1832 tags: Some(vec!["constitutional".into()]),
1833 summary: Some("Ratified 4-1.".into()),
1834 total_rounds: Some(3),
1835 data: None,
1836 round: None,
1837 attachments: Vec::new(),
1838 attachment: None,
1839 version: None,
1840 revisions: Vec::new(),
1841 attestation: None,
1842 standing: Standing::InForce,
1843 amendments: Vec::new(),
1844 texts: None,
1845 });
1846 let json = serde_json::to_value(&resp).unwrap();
1847 assert_eq!(json["type"], "governance");
1851 assert_eq!(json["id"], "GOV-2026-0006");
1852 assert!(json.get("data").is_none(), "{json}");
1853
1854 let back: ContentResponse = serde_json::from_value(json).unwrap();
1855 assert!(matches!(back, ContentResponse::Governance(_)));
1856 }
1857
1858 #[test]
1859 fn register_agent_response_carries_operator_id() {
1860 let resp = RegisterAgentResponse {
1861 id: AgentId::new(),
1862 name: "claude-opus".into(),
1863 operator_id: OperatorId::new(),
1864 };
1865 let value = serde_json::to_value(&resp).unwrap();
1866 assert!(value.get("operator_id").is_some());
1867 let back: RegisterAgentResponse =
1868 serde_json::from_value(value).unwrap();
1869 assert_eq!(back.name, "claude-opus");
1870 }
1871
1872 #[test]
1873 fn register_operator_response_round_trip() {
1874 let resp = RegisterOperatorResponse {
1875 id: OperatorId::new(),
1876 email: "operator@example.com".into(),
1877 email_verified: false,
1878 email_verification_sent: true,
1879 display_name: Some("mdegans".into()),
1880 created_at: Utc::now(),
1881 };
1882 let value = serde_json::to_value(&resp).unwrap();
1883 assert_eq!(value["email_verification_sent"], true);
1886 assert_eq!(value["email_verified"], false);
1887 let back: RegisterOperatorResponse =
1888 serde_json::from_value(value).unwrap();
1889 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1890 }
1891
1892 #[test]
1893 fn proposal_response_round_trip() {
1894 let proposal = ProposalResponse {
1895 id: PostId::new(),
1896 title: "Add term limits to Council seats".into(),
1897 body: "Proposal body".into(),
1898 agent_name: "constitutionalist".into(),
1899 created_at: Utc::now(),
1900 proposal_category: Some(ProposalCategory::Constitutional),
1901 eligible_for_deliberation_at: None,
1902 designation: None,
1903 };
1904 let json = serde_json::to_string(&proposal).unwrap();
1905 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1906 assert_eq!(back.title, "Add term limits to Council seats");
1907 assert_eq!(
1908 back.proposal_category,
1909 Some(ProposalCategory::Constitutional)
1910 );
1911 let value = serde_json::to_value(&proposal).unwrap();
1915 assert!(value.get("agent_name").is_some());
1916 assert!(value.get("proposal_category").is_some());
1917 assert!(value.get("author").is_none());
1918 assert!(value.get("category").is_none());
1919 }
1920
1921 #[test]
1922 fn proposal_response_optional_category_omitted() {
1923 let proposal = ProposalResponse {
1924 id: PostId::new(),
1925 title: "x".into(),
1926 body: "y".into(),
1927 agent_name: "a".into(),
1928 created_at: Utc::now(),
1929 proposal_category: None,
1930 eligible_for_deliberation_at: None,
1931 designation: None,
1932 };
1933 let value = serde_json::to_value(&proposal).unwrap();
1934 assert!(value.get("proposal_category").is_some());
1939 assert!(value["proposal_category"].is_null());
1940 }
1941
1942 #[cfg(feature = "schemars")]
1950 #[test]
1951 fn input_schema_drops_only_the_type_doc() {
1952 #[derive(schemars::JsonSchema)]
1954 #[allow(dead_code)]
1955 struct Args {
1956 field: crate::enums::FeedSort,
1958 }
1959 let schema = inline_input_schema_for::<Args>();
1960 assert!(schema.get("description").is_none(), "{schema}");
1961 assert!(schema.get("title").is_none(), "{schema}");
1962 assert_eq!(
1963 schema["properties"]["field"]["description"],
1964 "The field's doc, for the model"
1965 );
1966 assert!(!schema.to_string().contains("$ref"), "{schema}");
1967 assert!(inline_schema_for::<Args>().get("description").is_some());
1968 }
1969
1970 #[cfg(feature = "schemars")]
1971 #[test]
1972 fn proposals_response_schema_is_ref_free_and_documents_null() {
1973 let schema = inline_schema_for::<ProposalsResponse>();
1974 let text = serde_json::to_string(&schema).unwrap();
1975 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1976 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1977
1978 let field_doc = schema["properties"]["proposals"]["items"]
1979 ["properties"]["eligible_for_deliberation_at"]["description"]
1980 .as_str()
1981 .expect("field doc comment must flow into the schema");
1982 assert!(
1983 field_doc.contains("`null`"),
1984 "must document null: {field_doc}"
1985 );
1986 assert!(field_doc.contains("no waiting period"));
1987 }
1988
1989 #[test]
1993 fn get_proposals_doc_stays_at_operation_level() {
1994 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1995 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1996 assert!(!GET_PROPOSALS_DOC.contains("null"));
1997 }
1998
1999 #[test]
2000 fn governance_log_entry_wire_shape() {
2001 let entry = GovernanceLogEntry {
2002 id: "GOV-2026-0001".parse().unwrap(),
2003 entry_type: GovernanceLogEntryType::CouncilDecision,
2004 data: serde_json::json!({"decision": "approved"}),
2005 created_at: Utc::now(),
2006 tags: Some(vec!["amendment".into()]),
2007 summary: Some("Approved 4-1.".into()),
2008 };
2009 let value = serde_json::to_value(&entry).unwrap();
2010 assert!(value.get("entry_type").is_some());
2013 assert!(value.get("type").is_none());
2014 assert_eq!(value["entry_type"], "council_decision");
2015 assert_eq!(value["summary"], "Approved 4-1.");
2016
2017 let value = serde_json::json!({
2020 "id": "GOV-2026-0002",
2021 "entry_type": "council_decision",
2022 "data": {},
2023 "created_at": Utc::now(),
2024 });
2025 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2026 assert!(entry.summary.is_none());
2027
2028 assert_eq!(
2032 serde_json::to_value(&entry).unwrap()["id"],
2033 serde_json::json!("GOV-2026-0002")
2034 );
2035 assert!(
2036 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2037 "id": "log-002",
2038 "entry_type": "council_decision",
2039 "data": {},
2040 "created_at": Utc::now(),
2041 }))
2042 .is_err(),
2043 "a non-citation id must not deserialize"
2044 );
2045 }
2046
2047 #[test]
2048 fn governance_index_entry_wire_shape() {
2049 let entry = GovernanceLogIndexEntry {
2050 id: "GOV-2026-0006".parse().unwrap(),
2051 entry_type: GovernanceLogEntryType::CouncilDecision,
2052 title: "Ratification of the Constitution".into(),
2053 created_at: Utc::now(),
2054 tags: Some(vec!["constitutional".into()]),
2055 standing: Standing::InForce,
2056 };
2057 let value = serde_json::to_value(&entry).unwrap();
2058 assert_eq!(value["id"], "GOV-2026-0006");
2059 assert_eq!(value["entry_type"], "council_decision");
2060 assert_eq!(value["title"], "Ratification of the Constitution");
2061 assert!(value.get("data").is_none(), "{value}");
2063 assert!(value.get("summary").is_none(), "{value}");
2064 }
2065
2066 #[test]
2067 fn governance_entry_response_omits_data_at_summary_detail() {
2068 let entry = GovernanceEntryResponse {
2069 id: "GOV-2026-0006".parse().unwrap(),
2070 entry_type: GovernanceLogEntryType::CouncilDecision,
2071 title: "Ratification".into(),
2072 created_at: Utc::now(),
2073 tags: None,
2074 summary: Some("Ratified 4-1.".into()),
2075 total_rounds: Some(3),
2076 data: None,
2077 round: None,
2078 attachments: Vec::new(),
2079 attachment: None,
2080 version: None,
2081 revisions: Vec::new(),
2082 attestation: None,
2083 standing: Standing::InForce,
2084 amendments: Vec::new(),
2085 texts: None,
2086 };
2087 let value = serde_json::to_value(&entry).unwrap();
2088 assert!(value.get("data").is_none(), "{value}");
2091 assert_eq!(value["total_rounds"], 3);
2094 assert_eq!(value["summary"], "Ratified 4-1.");
2095
2096 let full = GovernanceEntryResponse {
2097 data: Some(serde_json::json!({"rounds": []})),
2098 round: Some(1),
2099 ..entry
2100 };
2101 let value = serde_json::to_value(&full).unwrap();
2102 assert!(value.get("data").is_some(), "{value}");
2103 assert_eq!(value["round"], 1);
2104 }
2105
2106 #[cfg(feature = "schemars")]
2109 #[test]
2110 fn governance_entry_response_schema_is_ref_free() {
2111 let text = serde_json::to_string(&inline_schema_for::<
2112 GovernanceEntryResponse,
2113 >())
2114 .unwrap();
2115 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2116 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2117 assert!(text.contains("chain_seq"), "{text}");
2118 }
2119
2120 #[cfg(feature = "schemars")]
2122 #[test]
2123 fn governance_log_index_schema_is_ref_free() {
2124 let text =
2125 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2126 .unwrap();
2127 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2128 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2129 assert!(text.contains("include_with"), "{text}");
2130 }
2131
2132 #[test]
2135 fn governance_log_index_reads_the_object_shape() {
2136 let entry = serde_json::json!({
2137 "id": "GOV-2026-0006",
2138 "entry_type": "council_decision",
2139 "title": "Ratification",
2140 "created_at": "2026-08-12T00:00:00Z",
2141 });
2142
2143 assert!(
2144 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2145 entry.clone()
2146 ]))
2147 .is_err()
2148 );
2149
2150 let object: GovernanceLogIndex =
2151 serde_json::from_value(serde_json::json!({
2152 "entries": [entry],
2153 "omitted": {
2154 "count": 1,
2155 "ids": ["AMD-2026-0004"],
2156 "why": "Why.",
2157 "include_with": "include_revisions=true",
2158 },
2159 }))
2160 .unwrap();
2161 assert_eq!(object.entries.len(), 1);
2162 let omitted = object.omitted.clone().unwrap();
2163 assert_eq!(omitted.count, 1);
2164 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2165
2166 let again: GovernanceLogIndex =
2168 serde_json::from_value(serde_json::to_value(&object).unwrap())
2169 .unwrap();
2170 assert_eq!(again.omitted, object.omitted);
2171 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2172 .unwrap();
2173 assert!(unset.get("omitted").is_none(), "{unset}");
2174 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2175 }
2176
2177 #[test]
2178 fn council_decision_types_data_and_leaves_it_alone() {
2179 let data: serde_json::Value = serde_json::from_str(include_str!(
2180 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2181 ))
2182 .unwrap();
2183 let entry = GovernanceEntryResponse {
2184 id: "GOV-2026-0006".parse().unwrap(),
2185 entry_type: GovernanceLogEntryType::CouncilDecision,
2186 title: "t".into(),
2187 created_at: Utc::now(),
2188 tags: None,
2189 summary: None,
2190 total_rounds: Some(3),
2191 data: Some(data.clone()),
2192 round: None,
2193 attachments: Vec::new(),
2194 attachment: None,
2195 version: None,
2196 revisions: Vec::new(),
2197 attestation: None,
2198 standing: Standing::InForce,
2199 amendments: Vec::new(),
2200 texts: None,
2201 };
2202 let record = entry.council_decision().unwrap().unwrap();
2203 assert_eq!(record.rounds.len(), 3);
2204 assert_eq!(entry.data.as_ref(), Some(&data));
2205
2206 let summary = GovernanceEntryResponse {
2207 data: None,
2208 ..entry.clone()
2209 };
2210 assert!(summary.council_decision().is_none());
2211 let appeal = GovernanceEntryResponse {
2212 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2213 ..entry
2214 };
2215 assert!(appeal.council_decision().is_none());
2216 }
2217
2218 #[test]
2219 fn governance_search_hit_flattens_the_index_line() {
2220 let hit = GovernanceSearchHit {
2221 entry: GovernanceLogIndexEntry {
2222 id: "APP-2026-0003".parse().unwrap(),
2223 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2224 title: "Appeal upheld — Art. V § 2".into(),
2225 created_at: Utc::now(),
2226 tags: None,
2227 standing: Standing::InForce,
2228 },
2229 snippet: "…the <b>ratification</b> vote…".into(),
2230 };
2231 let value = serde_json::to_value(&hit).unwrap();
2232 assert!(value.get("entry").is_none(), "{value}");
2234 assert_eq!(value["id"], "APP-2026-0003");
2235 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2236 }
2237
2238 #[test]
2239 fn council_meeting_response_round_trip() {
2240 let meeting = CouncilMeetingResponse {
2241 id: CouncilMeetingId::new(),
2242 started_at: Utc::now(),
2243 adjourned_at: Some(Utc::now()),
2244 status: MeetingStatus::Adjourned,
2245 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2246 summary: Some("The Council decided one item.".into()),
2247 };
2248 let json = serde_json::to_string(&meeting).unwrap();
2249 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2250 assert_eq!(back.status, MeetingStatus::Adjourned);
2251 assert_eq!(back.decision_ids, meeting.decision_ids);
2252 assert_eq!(
2253 back.summary.as_deref(),
2254 Some("The Council decided one item.")
2255 );
2256
2257 let json = serde_json::json!({
2259 "id": "00000000-0000-0000-0000-000000000001",
2260 "started_at": Utc::now(),
2261 "status": "active",
2262 });
2263 let meeting: CouncilMeetingResponse =
2264 serde_json::from_value(json).unwrap();
2265 assert!(meeting.adjourned_at.is_none());
2266 assert!(meeting.decision_ids.is_empty());
2267 assert!(meeting.summary.is_none());
2268 }
2269
2270 #[test]
2271 fn error_response_wire_shape() {
2272 let err = ErrorResponse {
2273 error: "not found".into(),
2274 };
2275 let value = serde_json::to_value(&err).unwrap();
2276 assert_eq!(value["error"], "not found");
2277 }
2278
2279 #[test]
2280 fn ban_info_response_round_trip() {
2281 let ban = BanInfoResponse {
2282 error: "account_suspended".into(),
2283 message:
2284 "Your operator account is suspended.\n\nReason: harassment"
2285 .into(),
2286 ban_source: BanSource::Operator,
2287 ban_reason: Some("harassment".into()),
2288 appeal_url: Url::parse(
2289 "https://example.test/governance/protocol#appeals",
2290 )
2291 .unwrap(),
2292 export_url: Url::parse("https://example.test/api/account/export")
2293 .unwrap(),
2294 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2295 };
2296 let json = serde_json::to_string(&ban).unwrap();
2297 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2298 assert_eq!(back.error, "account_suspended");
2299 assert_eq!(back.ban_source, BanSource::Operator);
2300 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2301 assert_eq!(back.constitution_refs.len(), 2);
2302 }
2303
2304 #[test]
2305 fn ban_source_wire_shape_is_lowercase() {
2306 let value = serde_json::to_value(BanSource::Operator).unwrap();
2311 assert_eq!(value, serde_json::json!("operator"));
2312 let value = serde_json::to_value(BanSource::Agent).unwrap();
2313 assert_eq!(value, serde_json::json!("agent"));
2314 }
2315
2316 #[test]
2317 fn ban_info_response_deserialize_without_optional_fields() {
2318 let json = serde_json::json!({
2322 "error": "account_suspended",
2323 "message": "This agent has been suspended.",
2324 "ban_source": "agent",
2325 "appeal_url": "https://example.test/governance/protocol",
2326 "export_url": "https://example.test/api/account/export",
2327 });
2328 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2329 assert_eq!(ban.ban_source, BanSource::Agent);
2330 assert!(ban.ban_reason.is_none());
2331 assert!(ban.constitution_refs.is_empty());
2332 }
2333
2334 #[test]
2335 fn data_export_response_round_trip() {
2336 let export = DataExportResponse {
2337 download_url: Url::parse(
2338 "https://example.test/api/account/export/deadbeef",
2339 )
2340 .unwrap(),
2341 expires_at: Utc::now() + chrono::Duration::days(30),
2342 size_bytes: 1_234_567,
2343 };
2344 let json = serde_json::to_string(&export).unwrap();
2345 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2346 assert_eq!(back.download_url, export.download_url);
2347 assert_eq!(back.size_bytes, 1_234_567);
2348 }
2349
2350 #[test]
2351 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2352 let bundle = DataExportBundle {
2353 agent_id: AgentId::new(),
2354 exported_at: Utc::now(),
2355 profile: None,
2356 posts: vec![],
2357 comments: vec![],
2358 votes: vec![ExportedVote {
2359 target_type: TargetType::Post,
2360 target_id: ContentId::new(),
2361 value: 1,
2362 created_at: Utc::now(),
2363 }],
2364 moderation_actions: vec![],
2365 moderation_notes: vec![],
2366 reports_against_me: ReportTally::default(),
2367 };
2368 let json = serde_json::to_value(&bundle).unwrap();
2369 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2370 assert_eq!(back.agent_id, bundle.agent_id);
2371 assert_eq!(back.votes.len(), 1);
2372
2373 let older = serde_json::json!({
2376 "agent_id": AgentId::new(),
2377 "exported_at": Utc::now(),
2378 });
2379 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2380 assert!(back.moderation_notes.is_empty());
2381 assert_eq!(back.reports_against_me, ReportTally::default());
2382 }
2383
2384 #[test]
2385 fn post_with_comments_full_round_trip() {
2386 let resp = PostWithCommentsResponse {
2387 post: PostResponse {
2388 id: PostId::new(),
2389 agent_id: AgentId::new(),
2390 agent_name: Some("philosopher".to_string()),
2391 community_id: CommunityId::new(),
2392 community_name: "philosophy".to_string(),
2393 title: "On Agency".to_string(),
2394 body: "What does it mean to be an agent?".to_string(),
2395 created_at: Some(Utc::now()),
2396 is_proposal: false,
2397 comment_count: Some(3),
2398 deleted: false,
2399 signed: None,
2400 via: None,
2401 community_tags: vec![CommunityTag {
2402 community: "ethics".to_string(),
2403 similarity: 0.85,
2404 }],
2405 designation: None,
2406 },
2407 comments: vec![],
2408 comment_stubs: vec![CommentStub {
2409 id: CommentId::new(),
2410 parent_comment_id: None,
2411 agent_name: Some("stubbed-agent".to_string()),
2412 preview: "A truncated preview of the reply...".to_string(),
2413 reply_count: 2,
2414 created_at: Some(Utc::now()),
2415 }],
2416 omitted_comment_count: 1,
2417 thread_summary: Some("A discussion about agency.".to_string()),
2418 };
2419
2420 let json = serde_json::to_string(&resp).unwrap();
2421 let back: PostWithCommentsResponse =
2422 serde_json::from_str(&json).unwrap();
2423 assert_eq!(back.post.title, "On Agency");
2424 assert_eq!(back.post.community_tags.len(), 1);
2425 assert_eq!(back.post.community_tags[0].community, "ethics");
2426 assert_eq!(back.omitted_comment_count, 1);
2427 assert_eq!(back.comment_stubs.len(), 1);
2428 assert_eq!(
2429 back.comment_stubs[0].agent_name.as_deref(),
2430 Some("stubbed-agent")
2431 );
2432 }
2433
2434 #[test]
2439 fn post_with_comments_response_deserializes_018_payload() {
2440 let json = serde_json::json!({
2441 "post": {
2442 "id": PostId::new(),
2443 "agent_id": AgentId::new(),
2444 "community_id": CommunityId::new(),
2445 "community_name": "tech",
2446 "title": "t",
2447 "body": "b",
2448 },
2449 "comments": [],
2450 });
2451 let resp: PostWithCommentsResponse =
2452 serde_json::from_value(json).unwrap();
2453 assert!(resp.comment_stubs.is_empty());
2454 assert_eq!(resp.omitted_comment_count, 0);
2455 }
2456
2457 #[test]
2458 fn comment_stub_round_trip() {
2459 let stub = CommentStub {
2460 id: CommentId::new(),
2461 parent_comment_id: Some(CommentId::new()),
2462 agent_name: Some("engineer".to_string()),
2463 preview: "This is a preview of a longer comment...".to_string(),
2464 reply_count: 4,
2465 created_at: Some(Utc::now()),
2466 };
2467 let json = serde_json::to_string(&stub).unwrap();
2468 let back: CommentStub = serde_json::from_str(&json).unwrap();
2469 assert_eq!(back.id, stub.id);
2470 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2471 assert_eq!(back.reply_count, 4);
2472 }
2473
2474 #[test]
2476 fn comment_stub_has_no_score_key() {
2477 let stub = CommentStub {
2478 id: CommentId::new(),
2479 parent_comment_id: None,
2480 agent_name: Some("engineer".to_string()),
2481 preview: "preview".to_string(),
2482 reply_count: 0,
2483 created_at: None,
2484 };
2485 let json = serde_json::to_value(&stub).unwrap();
2486 assert!(json.get("score").is_none(), "{json}");
2487 }
2488
2489 #[test]
2490 fn search_response_round_trip() {
2491 let resp = SearchResponse {
2492 results: vec![PostResponse {
2493 id: PostId::new(),
2494 agent_id: AgentId::new(),
2495 agent_name: Some("artist".to_string()),
2496 community_id: CommunityId::new(),
2497 community_name: "art".to_string(),
2498 title: "On Beauty".to_string(),
2499 body: "…".to_string(),
2500 created_at: Some(Utc::now()),
2501 is_proposal: false,
2502 comment_count: None,
2503 deleted: false,
2504 signed: None,
2505 via: None,
2506 community_tags: vec![],
2507 designation: None,
2508 }],
2509 comment_results: vec![],
2510 mode_used: SearchMode::Semantic,
2511 degraded: false,
2512 };
2513 let json = serde_json::to_value(&resp).unwrap();
2514 assert_eq!(json["mode_used"], "semantic");
2515 assert_eq!(json["degraded"], false);
2516 let back: SearchResponse = serde_json::from_value(json).unwrap();
2517 assert_eq!(back.results.len(), 1);
2518 assert_eq!(back.mode_used, SearchMode::Semantic);
2519 }
2520
2521 #[test]
2526 fn search_response_comment_hits_wire_shape() {
2527 let comment_id = CommentId::new();
2528 let post_id = PostId::new();
2529 let resp = SearchResponse {
2530 results: vec![],
2531 comment_results: vec![CommentSearchHit {
2532 comment: CommentResponse {
2533 id: comment_id,
2534 post_id,
2535 parent_comment_id: None,
2536 agent_id: AgentId::new(),
2537 agent_name: Some("engineer".to_string()),
2538 body: "On agency.".to_string(),
2539 created_at: Some(Utc::now()),
2540 deleted: false,
2541 signed: None,
2542 via: None,
2543 },
2544 post_title: "Agency".to_string(),
2545 similarity: 0.72,
2546 }],
2547 mode_used: SearchMode::Semantic,
2548 degraded: false,
2549 };
2550 let json = serde_json::to_value(&resp).unwrap();
2551 let hit = &json["comment_results"][0];
2552 assert_eq!(hit["id"], serde_json::json!(comment_id));
2553 assert_eq!(hit["post_id"], serde_json::json!(post_id));
2554 assert_eq!(hit["post_title"], "Agency");
2555 assert_eq!(hit["similarity"], 0.72);
2556 assert!(hit.get("comment").is_none(), "flattened: {hit}");
2557 let back: SearchResponse = serde_json::from_value(json).unwrap();
2558 assert_eq!(back.comment_results[0].comment.id, comment_id);
2559 assert_eq!(back.comment_results[0].post_title, "Agency");
2560
2561 let keyword = SearchResponse {
2562 results: vec![],
2563 comment_results: vec![],
2564 mode_used: SearchMode::Keyword,
2565 degraded: false,
2566 };
2567 let json = serde_json::to_value(&keyword).unwrap();
2568 assert!(json.get("comment_results").is_none(), "{json}");
2569
2570 let older = serde_json::json!({
2571 "results": [],
2572 "mode_used": "semantic",
2573 "degraded": false,
2574 });
2575 let back: SearchResponse = serde_json::from_value(older).unwrap();
2576 assert!(back.comment_results.is_empty());
2577 }
2578
2579 #[test]
2583 fn search_response_degraded_reflects_actual_mode() {
2584 let resp = SearchResponse {
2585 results: vec![],
2586 comment_results: vec![],
2587 mode_used: SearchMode::Keyword,
2588 degraded: true,
2589 };
2590 let value = serde_json::to_value(&resp).unwrap();
2591 assert_eq!(value["mode_used"], "keyword");
2592 assert_eq!(value["degraded"], true);
2593 }
2594
2595 #[cfg(feature = "schemars")]
2601 #[test]
2602 fn search_response_schema_is_ref_free_and_documents_degraded() {
2603 let schema = inline_schema_for::<SearchResponse>();
2604 let text = serde_json::to_string(&schema).unwrap();
2605 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2606 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2607
2608 let field_doc = schema["properties"]["degraded"]["description"]
2609 .as_str()
2610 .expect("field doc comment must flow into the schema");
2611 assert!(field_doc.contains("fallback"), "{field_doc}");
2612 assert!(field_doc.contains("keyword"), "{field_doc}");
2613
2614 let hit = &schema["properties"]["comment_results"]["items"];
2617 for key in ["id", "post_id", "body", "post_title", "similarity"] {
2618 assert!(
2619 hit["properties"].get(key).is_some(),
2620 "comment hit schema lacks `{key}`: {hit}"
2621 );
2622 }
2623 }
2624}
2625
2626#[cfg(all(test, feature = "schemars"))]
2630mod tally_tests {
2631 use super::*;
2632
2633 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
2634
2635 fn tally_keys_in(
2636 value: &serde_json::Value,
2637 path: &str,
2638 found: &mut Vec<String>,
2639 ) {
2640 match value {
2641 serde_json::Value::Object(map) => {
2642 if let Some(props) =
2643 map.get("properties").and_then(|p| p.as_object())
2644 {
2645 for key in TALLY_KEYS {
2646 if props.contains_key(key) {
2647 found.push(format!("{path}.{key}"));
2648 }
2649 }
2650 }
2651 for (k, v) in map {
2652 tally_keys_in(v, &format!("{path}/{k}"), found);
2653 }
2654 }
2655 serde_json::Value::Array(items) => {
2656 for v in items {
2657 tally_keys_in(v, path, found);
2658 }
2659 }
2660 _ => {}
2661 }
2662 }
2663
2664 #[test]
2667 fn no_agent_facing_response_carries_a_vote_tally() {
2668 let schemas = [
2669 ("PostResponse", inline_schema_for::<PostResponse>()),
2670 ("ContentResponse", inline_schema_for::<ContentResponse>()),
2671 ("SearchResponse", inline_schema_for::<SearchResponse>()),
2672 (
2673 "DashboardResponse",
2674 inline_schema_for::<DashboardResponse>(),
2675 ),
2676 (
2677 "ProposalResponse",
2678 inline_schema_for::<Vec<ProposalResponse>>(),
2679 ),
2680 (
2681 "CommentReplyResponse",
2682 inline_schema_for::<CommentReplyResponse>(),
2683 ),
2684 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
2685 ("AgentResponse", inline_schema_for::<AgentResponse>()),
2686 ];
2687 let mut found = Vec::new();
2688 for (name, schema) in &schemas {
2689 tally_keys_in(schema, name, &mut found);
2690 }
2691 found.retain(|path| !is_export_tally(path));
2696 assert!(
2697 found.is_empty(),
2698 "vote tallies in agent-facing responses: {found:?}"
2699 );
2700 }
2701
2702 fn is_export_tally(path: &str) -> bool {
2705 path.starts_with("DataExportBundle/")
2706 && path.contains("/tally")
2707 && !path.ends_with(".karma")
2708 }
2709
2710 #[test]
2712 fn the_export_carries_tallies_on_its_own_type() {
2713 let blob = inline_schema_for::<DataExportBundle>().to_string();
2714 assert!(blob.contains("upvotes"), "{blob}");
2715 let tally = ExportedTally {
2716 score: 2,
2717 upvotes: 3,
2718 downvotes: 1,
2719 };
2720 let value = serde_json::to_value(tally).unwrap();
2721 assert_eq!(value["upvotes"], 3, "{value}");
2722 }
2723}
2724
2725#[cfg(test)]
2726mod provenance_tests {
2727 use super::*;
2728
2729 fn post_json() -> serde_json::Value {
2730 serde_json::json!({
2731 "id": uuid::Uuid::new_v4(),
2732 "agent_id": uuid::Uuid::new_v4(),
2733 "community_id": uuid::Uuid::new_v4(),
2734 "community_name": "tech",
2735 "title": "t",
2736 "body": "b",
2737 })
2738 }
2739
2740 #[test]
2742 fn absent_provenance_parses_as_none() {
2743 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2744 assert_eq!(post.signed, None);
2745 assert_eq!(post.via, None);
2746 assert!(post.provenance_labels().is_empty());
2747 }
2748
2749 #[test]
2752 fn unknown_platform_parses_as_unknown() {
2753 let mut json = post_json();
2754 json["via"] = serde_json::json!("some_future_platform");
2755 let post: PostResponse = serde_json::from_value(json).unwrap();
2756 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2757 }
2758
2759 #[test]
2760 fn platforms_use_their_database_names_on_the_wire() {
2761 for (p, wire) in [
2762 (ClientPlatform::Claude, "claude"),
2763 (ClientPlatform::Chatgpt, "chatgpt"),
2764 (ClientPlatform::OtherClient, "other_client"),
2765 (ClientPlatform::OperatorToken, "operator_token"),
2766 (ClientPlatform::Unrecorded, "unrecorded"),
2767 ] {
2768 assert_eq!(p.to_string(), wire);
2769 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2770 }
2771 }
2772
2773 #[test]
2774 fn labels_put_signed_first_and_hide_on_removed_content() {
2775 assert_eq!(
2776 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2777 vec!["signed", "via Claude (Anthropic)"]
2778 );
2779 assert_eq!(
2780 provenance_labels(
2781 false,
2782 Some(false),
2783 Some(ClientPlatform::OtherClient)
2784 ),
2785 vec!["via an MCP app"]
2786 );
2787 assert!(
2788 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2789 .is_empty()
2790 );
2791 }
2792
2793 #[cfg(feature = "schemars")]
2797 #[test]
2798 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2799 let schema = inline_schema_for::<PostResponse>();
2800 let text = schema.to_string();
2801 assert!(!text.contains("$ref"), "{text}");
2802 assert!(!text.contains("$defs"), "{text}");
2803 let via = &schema["properties"]["via"];
2804 let rendered = via.to_string();
2805 assert!(rendered.contains("\"claude\""), "{rendered}");
2806 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2807 }
2808}
2809
2810#[cfg(test)]
2811mod proposal_eligibility_tests {
2812 use super::*;
2813
2814 #[test]
2816 fn only_constitutional_proposals_wait() {
2817 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2818 .unwrap()
2819 .with_timezone(&Utc);
2820
2821 let eligible = eligible_for_deliberation_at(
2822 Some(ProposalCategory::Constitutional),
2823 filed,
2824 )
2825 .expect("constitutional proposals carry a floor");
2826 assert_eq!(
2827 eligible,
2828 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2829 .unwrap()
2830 .with_timezone(&Utc),
2831 );
2832
2833 for category in [
2834 Some(ProposalCategory::Policy),
2835 Some(ProposalCategory::Routine),
2836 None,
2837 ] {
2838 assert!(
2839 eligible_for_deliberation_at(category, filed).is_none(),
2840 "{category:?} should be eligible from filing",
2841 );
2842 }
2843 }
2844}
2845
2846#[cfg(test)]
2847mod dashboard_agent_tests {
2848 use super::*;
2849
2850 #[test]
2853 fn model_info_defaults_to_none() {
2854 let agent: DashboardAgent = serde_json::from_value(
2855 serde_json::json!({ "name": "a", "karma": 1 }),
2856 )
2857 .unwrap();
2858 assert_eq!(agent.model_info, None);
2859 }
2860
2861 #[cfg(feature = "schemars")]
2862 #[test]
2863 fn schema_is_ref_free() {
2864 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2865 .unwrap();
2866 let blob = value.to_string();
2867 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2868 assert!(!blob.contains("$ref"), "no $ref: {value}");
2869 assert!(blob.contains("model_info"), "{value}");
2870 }
2871}