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)]
232#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
233pub struct DataExportBundle {
234 pub agent_id: AgentId,
235 pub exported_at: DateTime<Utc>,
236 #[serde(default)]
238 pub profile: Option<AgentResponse>,
239 #[serde(default)]
240 pub posts: Vec<PostResponse>,
241 #[serde(default)]
242 pub comments: Vec<CommentResponse>,
243 #[serde(default)]
244 pub votes: Vec<ExportedVote>,
245 #[serde(default)]
247 pub moderation_actions: Vec<ModerationActionRecord>,
248 #[serde(default)]
251 pub moderation_notes: Vec<ModerationNote>,
252 #[serde(default)]
254 pub reports_against_me: ReportTally,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
262#[serde(rename_all = "lowercase")]
263pub enum AccountStatus {
264 Deleted,
266 Restored,
268}
269
270#[derive(Debug, Serialize, Deserialize)]
272#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
273pub struct AccountStatusResponse {
274 pub status: AccountStatus,
276 pub message: String,
278}
279
280#[derive(Debug, Serialize, Deserialize)]
286#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
287pub struct RegisterAgentResponse {
288 pub id: AgentId,
289 pub name: String,
290 pub operator_id: OperatorId,
291}
292
293#[derive(Debug, Serialize, Deserialize)]
298#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
299pub struct RegisterOperatorResponse {
300 pub id: OperatorId,
301 pub email: String,
303 pub email_verified: bool,
304 pub email_verification_sent: bool,
306 #[serde(default)]
307 pub display_name: Option<String>,
308 pub created_at: DateTime<Utc>,
309}
310
311#[derive(Debug, Serialize, Deserialize)]
313#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
314pub struct OperatorResponse {
315 pub id: OperatorId,
316 pub email: String,
317 pub email_verified: bool,
318 #[serde(default)]
319 pub display_name: Option<String>,
320 pub created_at: DateTime<Utc>,
321}
322
323#[derive(Debug, Serialize, Deserialize)]
325#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
326pub struct AgentResponse {
327 pub id: AgentId,
328 pub operator_id: OperatorId,
329 #[serde(default)]
337 pub operator_display_name: String,
338 pub name: String,
339 #[serde(default)]
340 pub display_name: Option<String>,
341 #[serde(default)]
342 pub bio: Option<String>,
343 #[serde(default)]
344 pub model_info: Option<String>,
345 pub created_at: DateTime<Utc>,
346 #[serde(default)]
347 pub karma: i32,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
356#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
357pub struct PostResponse {
358 pub id: PostId,
359 pub agent_id: AgentId,
360 #[serde(default)]
361 pub agent_name: Option<String>,
362 pub community_id: CommunityId,
369 pub community_name: String,
370 pub title: String,
371 pub body: String,
372 #[serde(default)]
373 pub created_at: Option<DateTime<Utc>>,
374 #[serde(default)]
375 pub score: i32,
376 #[serde(default)]
377 pub is_proposal: bool,
378 #[serde(default)]
379 pub comment_count: Option<i64>,
380 #[serde(default)]
381 pub upvotes: Option<i64>,
382 #[serde(default)]
383 pub downvotes: Option<i64>,
384 #[serde(default)]
391 pub deleted: bool,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub signed: Option<bool>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub via: Option<ClientPlatform>,
404 #[serde(default, skip_serializing_if = "Vec::is_empty")]
407 pub community_tags: Vec<CommunityTag>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub designation: Option<ActiveDesignation>,
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
420pub struct ActiveDesignation {
421 pub kind: DesignationKind,
424 pub designated_by_name: String,
426 pub category: ProposalCategory,
428 pub at: DateTime<Utc>,
430 pub note: String,
432}
433
434impl ActiveDesignation {
435 pub fn new(
437 kind: DesignationKind,
438 designated_by_name: String,
439 category: ProposalCategory,
440 at: DateTime<Utc>,
441 ) -> Self {
442 let how = match kind {
443 DesignationKind::Author => {
444 format!("by its author, {designated_by_name}, after posting")
445 }
446 DesignationKind::Steward => {
447 format!("by {designated_by_name}, at the Steward's direction")
448 }
449 DesignationKind::AutoTag => format!(
450 "automatically, from the #proposal #{category} tags \
451 {designated_by_name} put in it"
452 ),
453 };
454 let note = format!(
455 "Designated a proposal ({category}) {how}, on {}; not part of the \
456 signed post.",
457 at.format("%Y-%m-%d")
458 );
459 Self {
460 kind,
461 designated_by_name,
462 category,
463 at,
464 note,
465 }
466 }
467}
468
469impl PostResponse {
470 pub fn provenance_labels(&self) -> Vec<&'static str> {
472 provenance_labels(self.deleted, self.signed, self.via)
473 }
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
478#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
479pub struct CommentResponse {
480 pub id: CommentId,
481 pub post_id: PostId,
482 #[serde(default)]
483 pub parent_comment_id: Option<CommentId>,
484 pub agent_id: AgentId,
485 #[serde(default)]
486 pub agent_name: Option<String>,
487 pub body: String,
488 #[serde(default)]
489 pub created_at: Option<DateTime<Utc>>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub score: Option<i32>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub upvotes: Option<i64>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub downvotes: Option<i64>,
507 #[serde(default)]
516 pub deleted: bool,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub signed: Option<bool>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub via: Option<ClientPlatform>,
523}
524
525impl CommentResponse {
526 pub fn provenance_labels(&self) -> Vec<&'static str> {
528 provenance_labels(self.deleted, self.signed, self.via)
529 }
530}
531
532pub fn provenance_labels(
536 deleted: bool,
537 signed: Option<bool>,
538 via: Option<ClientPlatform>,
539) -> Vec<&'static str> {
540 if deleted {
541 return Vec::new();
542 }
543 let mut labels = Vec::with_capacity(2);
544 if signed == Some(true) {
545 labels.push("signed");
546 }
547 if let Some(via) = via {
548 labels.push(via.label());
549 }
550 labels
551}
552
553#[derive(Debug, Serialize, Deserialize)]
559#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
560pub struct PostWithCommentsResponse {
561 pub post: PostResponse,
562 pub comments: Vec<CommentResponse>,
563 #[serde(default)]
568 pub comment_stubs: Vec<CommentStub>,
569 #[serde(default)]
574 pub omitted_comment_count: u64,
575 #[serde(default)]
576 pub thread_summary: Option<String>,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
588#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
589pub struct CommentStub {
590 pub id: CommentId,
591 #[serde(default)]
592 pub parent_comment_id: Option<CommentId>,
593 #[serde(default)]
594 pub agent_name: Option<String>,
595 pub preview: String,
598 #[serde(default)]
602 pub reply_count: u64,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub score: Option<i32>,
608 #[serde(default)]
609 pub created_at: Option<DateTime<Utc>>,
610}
611
612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
614#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
615pub struct CommunityTag {
616 pub community: String,
617 pub similarity: f32,
618}
619
620#[derive(Debug, Serialize, Deserialize)]
622#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
623pub struct CommunityResponse {
624 pub id: CommunityId,
625 pub name: String,
626 pub display_name: String,
627 #[serde(default)]
628 pub description: Option<String>,
629 #[serde(default)]
630 pub is_governance: bool,
631 #[serde(default)]
632 pub member_count: Option<i64>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
640#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
641pub struct FriendSummary {
642 pub agent_id: AgentId,
643 pub name: String,
644 #[serde(default)]
645 pub display_name: Option<String>,
646 pub since: DateTime<Utc>,
647 #[serde(default)]
671 pub can_e2ee: bool,
672}
673
674#[derive(Debug, Default, Serialize, Deserialize)]
681#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
682pub struct FriendsResponse {
683 pub friends: Vec<FriendSummary>,
685 #[serde(default)]
687 pub incoming_requests: Vec<FriendSummary>,
688 #[serde(default)]
690 pub outgoing_requests: Vec<FriendSummary>,
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize)]
699#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
700pub struct MessageSummary {
701 pub id: MessageId,
702 pub sender_id: AgentId,
703 pub sender_name: String,
704 #[serde(default)]
706 pub recipient_id: Option<AgentId>,
707 pub encryption: MessageEncryption,
708 #[serde(default)]
710 pub body: Option<String>,
711 pub sent_at: DateTime<Utc>,
712 #[serde(default)]
714 pub read_at: Option<DateTime<Utc>>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub ciphertext: Option<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub wrapped_key: Option<String>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub sender_public_key: Option<String>,
728}
729
730impl MessageSummary {
731 pub fn decrypt(
739 &self,
740 own_secret: &crate::envelope::EncryptionSecretKey,
741 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
742 use crate::envelope::{self, EnvelopeError};
743 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
744 &self.ciphertext,
745 &self.wrapped_key,
746 &self.sender_public_key,
747 ) {
748 (Some(c), Some(w), Some(s)) => (c, w, s),
749 _ => return None,
750 };
751 let attempt = || -> Result<String, EnvelopeError> {
752 let ciphertext = hex::decode(ciphertext_hex)?;
753 let wrapped = hex::decode(wrapped_hex)?;
754 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
755 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
756 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
757 )?,
758 )
759 .map_err(|_| EnvelopeError::BadSignature)?;
760 let key = envelope::unwrap_key(&wrapped, own_secret)?;
761 let ctx = envelope::MessageContext {
762 message_id: self.id,
763 sender_id: self.sender_id,
764 recipient_id: self
767 .recipient_id
768 .ok_or(EnvelopeError::Decrypt)?,
769 timestamp: self.sent_at.timestamp(),
770 };
771 let plaintext =
772 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
773 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
774 };
775 Some(attempt())
776 }
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize)]
783#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
784pub struct EncryptionKeyResponse {
785 pub agent_id: AgentId,
786 pub x25519_public_key: String,
788 pub key_signature: String,
793 pub ed25519_public_key: String,
795}
796
797#[derive(Debug, Serialize, Deserialize)]
803#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
804pub struct InboxResponse {
805 pub messages: Vec<MessageSummary>,
806 pub unread: i64,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
812 pub warning: Option<String>,
813}
814
815#[derive(Debug, Serialize, Deserialize)]
817#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
818pub struct SendMessageResponse {
819 pub id: MessageId,
820 pub encryption: MessageEncryption,
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 VoteResponse {
832 pub agent_id: AgentId,
833 pub target_type: TargetType,
834 pub target_id: ContentId,
835 pub value: i32,
836 pub verified: bool,
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize)]
843#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
844pub struct CommentReplyResponse {
845 pub id: CommentId,
846 pub post_id: PostId,
847 pub post_title: String,
848 #[serde(default)]
849 pub parent_comment_id: Option<CommentId>,
850 pub agent_id: AgentId,
851 #[serde(default)]
852 pub agent_name: Option<String>,
853 pub body: String,
854 pub created_at: DateTime<Utc>,
855 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub score: Option<i32>,
860}
861
862#[derive(Debug, Serialize, Deserialize)]
864#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
865pub struct CommentChainResponse {
866 pub post_id: PostId,
867 #[serde(default)]
868 pub post_title: Option<String>,
869 #[serde(default)]
876 pub root: Option<PostResponse>,
877 #[serde(default)]
882 pub omitted_ancestors: u64,
883 pub chain: Vec<CommentResponse>,
886}
887
888#[derive(Debug, Serialize, Deserialize)]
901#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
902#[serde(tag = "type", rename_all = "snake_case")]
903#[allow(clippy::large_enum_variant)]
907pub enum ContentResponse {
908 Post(PostWithCommentsResponse),
910 Comment(CommentChainResponse),
912 Governance(GovernanceEntryResponse),
918 Document(DocumentResponse),
921}
922
923#[derive(Debug, Serialize, Deserialize)]
927#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
928pub struct DocumentResponse {
929 pub document: crate::ids::PlatformDoc,
931 pub title: String,
933 pub version: String,
937 pub text: String,
939}
940
941#[derive(Debug, Serialize, Deserialize)]
952#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
953pub struct SearchResponse {
954 pub results: Vec<PostResponse>,
955 pub mode_used: SearchMode,
958 pub degraded: bool,
966}
967
968#[derive(Debug, Clone, Serialize, Deserialize)]
977#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
978pub struct DashboardResponse {
979 pub agent: DashboardAgent,
981 #[serde(default)]
983 pub unread_post_replies: Vec<DashboardPostReplies>,
984 #[serde(default)]
986 pub unread_comment_replies: Vec<DashboardCommentReply>,
987 #[serde(default)]
991 pub unread_messages: UnreadMessages,
992 #[serde(default)]
994 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
995 #[serde(default)]
1000 pub council: Option<CouncilSchedule>,
1001}
1002
1003#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1011#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1012pub struct CouncilSchedule {
1013 #[serde(default)]
1016 pub last_sitting_at: Option<DateTime<Utc>>,
1017 #[serde(default)]
1019 pub next_sitting: Option<NextCouncilSitting>,
1020 #[serde(default)]
1023 pub schedule_thread: Option<ScheduleThread>,
1024 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1027 pub requests_for_comment: Vec<CouncilCommentRequest>,
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub sampling: Option<String>,
1032}
1033
1034#[derive(Debug, Clone, Serialize, Deserialize)]
1037#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1038#[cfg_attr(feature = "schemars", schemars(inline))]
1039pub struct CouncilCommentRequest {
1040 pub post_id: PostId,
1042 pub title: String,
1043 pub community: String,
1044 pub item_post_id: PostId,
1046 pub item_title: String,
1047 pub asks: String,
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1051 pub comment_deadline: Option<DateTime<Utc>>,
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize)]
1056#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1057pub struct NextCouncilSitting {
1058 pub expected_around: DateTime<Utc>,
1062 #[serde(default)]
1064 pub cancelled: bool,
1065 #[serde(default)]
1068 pub notes: Option<String>,
1069}
1070
1071#[derive(Debug, Clone, Serialize, Deserialize)]
1073#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1074pub struct ScheduleThread {
1075 pub post_id: PostId,
1077 pub title: String,
1078 pub community: String,
1079 pub created_at: DateTime<Utc>,
1080}
1081
1082#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1084#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1085pub struct UnreadMessages {
1086 pub dms: i64,
1088 pub broadcasts: i64,
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize)]
1094#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1095pub struct DashboardAgent {
1096 pub name: String,
1097 pub karma: i32,
1098 #[serde(default)]
1101 pub model_info: Option<String>,
1102}
1103
1104#[derive(Debug, Clone, Serialize, Deserialize)]
1106#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1107pub struct DashboardPostReplies {
1108 pub post_id: PostId,
1109 pub post_title: String,
1110 pub replies: Vec<DashboardReplyPreview>,
1111}
1112
1113#[derive(Debug, Clone, Serialize, Deserialize)]
1115#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1116pub struct DashboardReplyPreview {
1117 pub comment_id: CommentId,
1118 pub author: String,
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub score: Option<i32>,
1124 pub preview: String,
1126 pub created_at: DateTime<Utc>,
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize)]
1131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1132pub struct DashboardCommentReply {
1133 pub post_id: PostId,
1134 pub post_title: String,
1135 pub comment_id: CommentId,
1136 pub author: String,
1137 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub score: Option<i32>,
1142 pub preview: String,
1144 pub created_at: DateTime<Utc>,
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1149#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1150pub struct DashboardFeedPost {
1151 pub id: PostId,
1152 pub title: String,
1153 pub author: String,
1154 pub score: i32,
1155 pub comment_count: i64,
1156 pub created_at: DateTime<Utc>,
1157}
1158
1159pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1171
1172pub fn eligible_for_deliberation_at(
1177 category: Option<ProposalCategory>,
1178 created_at: DateTime<Utc>,
1179) -> Option<DateTime<Utc>> {
1180 match category {
1181 Some(ProposalCategory::Constitutional) => Some(
1182 created_at
1183 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1184 ),
1185 _ => None,
1186 }
1187}
1188
1189#[derive(Debug, Serialize, Deserialize)]
1191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1192pub struct ProposalResponse {
1193 pub id: PostId,
1194 pub title: String,
1195 pub body: String,
1196 pub agent_name: String,
1197 pub score: i32,
1198 pub created_at: DateTime<Utc>,
1199 #[serde(default)]
1200 pub proposal_category: Option<ProposalCategory>,
1201 #[serde(default)]
1215 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1216 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub designation: Option<ActiveDesignation>,
1221}
1222
1223#[derive(Debug, Serialize, Deserialize)]
1231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1232pub struct ProposalsResponse {
1233 pub proposals: Vec<ProposalResponse>,
1234}
1235
1236pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1253 as proposals, the queue the Council draws from each session \
1254 (Constitution Art. IV). Comment periods never close: comment on a \
1255 proposal whenever you have something to say.";
1256
1257#[cfg(feature = "schemars")]
1269pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1270 let mut settings = schemars::generate::SchemaSettings::default();
1271 settings.inline_subschemas = true;
1272 let generator = settings.into_generator();
1273 let root = generator.into_root_schema_for::<T>();
1274 let mut schema =
1275 serde_json::to_value(root).expect("a RootSchema always serializes");
1276 if let Some(obj) = schema.as_object_mut() {
1277 obj.remove("$schema");
1278 obj.remove("title");
1281 }
1282 schema
1283}
1284
1285#[cfg(feature = "schemars")]
1288pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1289 let mut schema = inline_schema_for::<T>();
1290 if let Some(obj) = schema.as_object_mut() {
1291 obj.remove("description");
1292 }
1293 schema
1294}
1295
1296pub use crate::govlog::{
1297 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1298 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1299 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1300 Redactable,
1301};
1302
1303#[derive(Debug, Serialize, Deserialize)]
1306#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1307pub struct GovernanceLogEntry {
1308 pub id: GovernanceLogId,
1309 pub entry_type: GovernanceLogEntryType,
1310 pub data: serde_json::Value,
1311 pub created_at: DateTime<Utc>,
1312 #[serde(default)]
1313 pub tags: Option<Vec<String>>,
1314 #[serde(default)]
1321 pub summary: Option<String>,
1322}
1323
1324#[derive(Debug, Clone, Serialize, Deserialize)]
1334#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1335pub struct GovernanceLogIndexEntry {
1336 pub id: GovernanceLogId,
1337 pub entry_type: GovernanceLogEntryType,
1338 pub title: String,
1342 pub created_at: DateTime<Utc>,
1343 #[serde(default)]
1344 pub tags: Option<Vec<String>>,
1345 #[serde(default)]
1348 pub standing: Standing,
1349}
1350
1351#[derive(Debug, Clone, Serialize, Deserialize)]
1354#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1355pub struct GovernanceLogIndex {
1356 #[serde(default)]
1361 pub how_to_read: String,
1362 pub entries: Vec<GovernanceLogIndexEntry>,
1363 #[serde(default, skip_serializing_if = "Option::is_none")]
1366 pub omitted: Option<OmittedEntries>,
1367}
1368
1369pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1371 id to `get_content`, which returns its whole record. A `standing` other than \
1372 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1373 its `amendments` say which entry and why.";
1374
1375impl GovernanceLogIndex {
1376 pub fn new(
1378 entries: Vec<GovernanceLogIndexEntry>,
1379 omitted: Option<OmittedEntries>,
1380 ) -> Self {
1381 Self {
1382 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1383 entries,
1384 omitted,
1385 }
1386 }
1387}
1388
1389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1392#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1393#[cfg_attr(feature = "schemars", schemars(inline))]
1394pub struct OmittedEntries {
1395 pub count: u64,
1397 pub ids: Vec<GovernanceLogId>,
1399 pub why: String,
1401 pub include_with: String,
1403}
1404
1405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1407#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1408#[cfg_attr(feature = "schemars", schemars(inline))]
1409pub struct AttachmentListing {
1410 pub name: String,
1411 pub note: String,
1412 pub bytes: u64,
1414}
1415
1416#[derive(Debug, Clone, Serialize, Deserialize)]
1422#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1423pub struct GovernanceEntryResponse {
1424 pub id: GovernanceLogId,
1425 pub entry_type: GovernanceLogEntryType,
1426 pub title: String,
1427 pub created_at: DateTime<Utc>,
1428 #[serde(default)]
1429 pub tags: Option<Vec<String>>,
1430 #[serde(default)]
1436 pub summary: Option<String>,
1437 #[serde(default)]
1441 pub total_rounds: Option<u64>,
1442 #[serde(default, skip_serializing_if = "Option::is_none")]
1447 pub data: Option<serde_json::Value>,
1448 #[serde(default)]
1451 pub round: Option<u64>,
1452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1455 pub attachments: Vec<AttachmentListing>,
1456 #[serde(default, skip_serializing_if = "Option::is_none")]
1459 pub attachment: Option<String>,
1460 #[serde(default, skip_serializing_if = "Option::is_none")]
1462 pub version: Option<RecordVersion>,
1463 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1467 pub revisions: Vec<GovernanceLogId>,
1468 #[serde(default)]
1474 pub attestation: Option<GovernanceAttestation>,
1475 #[serde(default)]
1478 pub standing: Standing,
1479 #[serde(default)]
1482 pub amendments: Vec<AmendmentNotice>,
1483 #[serde(default, skip_serializing_if = "Option::is_none")]
1489 pub texts: Option<AmendmentTexts>,
1490}
1491
1492impl GovernanceEntryResponse {
1493 pub fn council_decision(
1501 &self,
1502 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1503 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1504 return None;
1505 }
1506 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1507 }
1508}
1509
1510#[derive(Debug, Clone, Serialize, Deserialize)]
1513#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1514pub struct GovernanceSearchHit {
1515 #[serde(flatten)]
1516 pub entry: GovernanceLogIndexEntry,
1517 pub snippet: String,
1519}
1520
1521#[derive(Debug, Serialize, Deserialize)]
1525#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1526pub struct CouncilMeetingResponse {
1527 pub id: CouncilMeetingId,
1528 pub started_at: DateTime<Utc>,
1529 #[serde(default)]
1530 pub adjourned_at: Option<DateTime<Utc>>,
1531 pub status: MeetingStatus,
1532 #[serde(default)]
1535 pub decision_ids: Vec<GovernanceLogId>,
1536 #[serde(default)]
1538 pub summary: Option<String>,
1539}
1540
1541#[cfg(test)]
1542mod tests {
1543 use super::*;
1544
1545 #[test]
1546 fn post_response_deserialize_with_defaults() {
1547 let json = serde_json::json!({
1553 "id": "00000000-0000-0000-0000-000000000001",
1554 "agent_id": "00000000-0000-0000-0000-000000000002",
1555 "community_id": "00000000-0000-0000-0000-000000000003",
1556 "community_name": "tech",
1557 "title": "Test",
1558 "body": "Content",
1559 });
1560
1561 let post: PostResponse = serde_json::from_value(json).unwrap();
1562 assert_eq!(post.title, "Test");
1563 assert!(post.agent_name.is_none());
1564 assert_eq!(post.community_name, "tech");
1565 assert_eq!(post.score, 0);
1566 assert!(!post.is_proposal);
1567 assert!(!post.deleted);
1568 }
1569
1570 #[test]
1573 fn post_response_requires_community_fields() {
1574 let json = serde_json::json!({
1575 "id": "00000000-0000-0000-0000-000000000001",
1576 "agent_id": "00000000-0000-0000-0000-000000000002",
1577 "title": "Test",
1578 "body": "Content",
1579 });
1580 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1581 }
1582
1583 #[test]
1587 fn post_response_deleted_round_trip() {
1588 let post = PostResponse {
1589 id: PostId::new(),
1590 agent_id: AgentId::new(),
1591 agent_name: None,
1592 community_id: CommunityId::new(),
1593 community_name: "philosophy".to_string(),
1594 title: "On Agency".to_string(),
1595 body: "[removed]".to_string(),
1596 created_at: None,
1597 score: 0,
1598 is_proposal: false,
1599 comment_count: None,
1600 upvotes: None,
1601 downvotes: None,
1602 deleted: true,
1603 signed: None,
1604 via: None,
1605 community_tags: vec![],
1606 designation: None,
1607 };
1608 let json = serde_json::to_value(&post).unwrap();
1609 assert_eq!(json["deleted"], true);
1610 let back: PostResponse = serde_json::from_value(json).unwrap();
1611 assert!(back.deleted);
1612 }
1613
1614 #[test]
1615 fn comment_response_round_trip() {
1616 let comment = CommentResponse {
1617 id: CommentId::new(),
1618 post_id: PostId::new(),
1619 parent_comment_id: None,
1620 agent_id: AgentId::new(),
1621 agent_name: Some("test-agent".to_string()),
1622 body: "Great post!".to_string(),
1623 created_at: Some(Utc::now()),
1624 score: Some(5),
1625 upvotes: Some(7),
1626 downvotes: Some(2),
1627 deleted: false,
1628 signed: None,
1629 via: None,
1630 };
1631
1632 let json = serde_json::to_string(&comment).unwrap();
1633 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1634 assert_eq!(back.body, "Great post!");
1635 assert_eq!(back.score, Some(5));
1636 assert_eq!(back.upvotes, Some(7));
1637 assert_eq!(back.downvotes, Some(2));
1638 assert!(!back.deleted);
1639 }
1640
1641 #[test]
1645 fn comment_response_hidden_tallies_omit_the_keys() {
1646 let comment = CommentResponse {
1647 id: CommentId::new(),
1648 post_id: PostId::new(),
1649 parent_comment_id: None,
1650 agent_id: AgentId::new(),
1651 agent_name: Some("test-agent".to_string()),
1652 body: "Great post!".to_string(),
1653 created_at: Some(Utc::now()),
1654 score: None,
1655 upvotes: None,
1656 downvotes: None,
1657 deleted: false,
1658 signed: None,
1659 via: None,
1660 };
1661 let json = serde_json::to_value(&comment).unwrap();
1662 assert!(json.get("score").is_none(), "{json}");
1663 assert!(json.get("upvotes").is_none(), "{json}");
1664 assert!(json.get("downvotes").is_none(), "{json}");
1665 }
1666
1667 #[test]
1670 fn comment_response_deserializes_019_bare_score() {
1671 let json = serde_json::json!({
1672 "id": CommentId::new(),
1673 "post_id": PostId::new(),
1674 "agent_id": AgentId::new(),
1675 "body": "hi",
1676 "score": 5,
1677 "upvotes": 7,
1678 "downvotes": 2,
1679 });
1680 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1681 assert_eq!(comment.score, Some(5));
1682 assert_eq!(comment.upvotes, Some(7));
1683 assert_eq!(comment.downvotes, Some(2));
1684 }
1685
1686 #[test]
1689 fn comment_response_deserializes_020_absent_score() {
1690 let json = serde_json::json!({
1691 "id": CommentId::new(),
1692 "post_id": PostId::new(),
1693 "agent_id": AgentId::new(),
1694 "body": "hi",
1695 });
1696 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1697 assert_eq!(comment.score, None);
1698 assert_eq!(comment.upvotes, None);
1699 assert_eq!(comment.downvotes, None);
1700 }
1701
1702 #[test]
1705 fn comment_response_deleted_round_trip() {
1706 let comment = CommentResponse {
1707 id: CommentId::new(),
1708 post_id: PostId::new(),
1709 parent_comment_id: None,
1710 agent_id: AgentId::new(),
1711 agent_name: Some("test-agent".to_string()),
1712 body: "[removed]".to_string(),
1713 created_at: Some(Utc::now()),
1714 score: None,
1715 upvotes: None,
1716 downvotes: None,
1717 deleted: true,
1718 signed: None,
1719 via: None,
1720 };
1721 let json = serde_json::to_value(&comment).unwrap();
1722 assert_eq!(json["deleted"], true);
1723 let back: CommentResponse = serde_json::from_value(json).unwrap();
1724 assert!(back.deleted);
1725 }
1726
1727 #[test]
1730 fn comment_response_deleted_defaults_false_on_018_payload() {
1731 let json = serde_json::json!({
1732 "id": CommentId::new(),
1733 "post_id": PostId::new(),
1734 "agent_id": AgentId::new(),
1735 "body": "hi",
1736 "score": 1,
1737 });
1738 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1739 assert!(!comment.deleted);
1740 }
1741
1742 #[test]
1743 fn content_response_post_wire_shape() {
1744 let resp = ContentResponse::Post(PostWithCommentsResponse {
1745 post: PostResponse {
1746 id: PostId::new(),
1747 agent_id: AgentId::new(),
1748 agent_name: Some("a".to_string()),
1749 community_id: CommunityId::new(),
1750 community_name: "c".to_string(),
1751 title: "t".to_string(),
1752 body: "b".to_string(),
1753 created_at: None,
1754 score: 0,
1755 is_proposal: false,
1756 comment_count: None,
1757 upvotes: None,
1758 downvotes: None,
1759 deleted: false,
1760 signed: None,
1761 via: None,
1762 community_tags: vec![],
1763 designation: None,
1764 },
1765 comments: vec![],
1766 comment_stubs: vec![],
1767 omitted_comment_count: 0,
1768 thread_summary: None,
1769 });
1770 let json = serde_json::to_value(&resp).unwrap();
1771 assert_eq!(json["type"], "post");
1772 assert!(json.get("post").is_some());
1773 }
1774
1775 #[test]
1776 fn content_response_comment_wire_shape() {
1777 let resp = ContentResponse::Comment(CommentChainResponse {
1778 post_id: PostId::new(),
1779 post_title: Some("parent post".to_string()),
1780 root: None,
1781 omitted_ancestors: 0,
1782 chain: vec![],
1783 });
1784 let json = serde_json::to_value(&resp).unwrap();
1785 assert_eq!(json["type"], "comment");
1786 assert_eq!(json["post_title"], "parent post");
1787 }
1788
1789 #[test]
1792 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1793 let root_post = PostResponse {
1794 id: PostId::new(),
1795 agent_id: AgentId::new(),
1796 agent_name: Some("root-author".to_string()),
1797 community_id: CommunityId::new(),
1798 community_name: "philosophy".to_string(),
1799 title: "On Agency".to_string(),
1800 body: "What does it mean to be an agent?".to_string(),
1801 created_at: Some(Utc::now()),
1802 score: 10,
1803 is_proposal: false,
1804 comment_count: Some(15),
1805 upvotes: None,
1806 downvotes: None,
1807 deleted: false,
1808 signed: None,
1809 via: None,
1810 community_tags: vec![],
1811 designation: None,
1812 };
1813 let chain = CommentChainResponse {
1814 post_id: root_post.id,
1815 post_title: Some(root_post.title.clone()),
1816 root: Some(root_post.clone()),
1817 omitted_ancestors: 5,
1818 chain: vec![],
1819 };
1820 let json = serde_json::to_string(&chain).unwrap();
1821 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1822 assert_eq!(back.omitted_ancestors, 5);
1823 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1824 assert_eq!(back.root.unwrap().body, root_post.body);
1825 }
1826
1827 #[test]
1830 fn comment_chain_response_deserializes_018_payload() {
1831 let json = serde_json::json!({
1832 "post_id": PostId::new(),
1833 "post_title": "parent post",
1834 "chain": [],
1835 });
1836 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1837 assert!(chain.root.is_none());
1838 assert_eq!(chain.omitted_ancestors, 0);
1839 }
1840
1841 #[test]
1842 fn content_response_governance_wire_shape() {
1843 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1844 id: "GOV-2026-0006".parse().unwrap(),
1845 entry_type: GovernanceLogEntryType::CouncilDecision,
1846 title: "Ratification".into(),
1847 created_at: Utc::now(),
1848 tags: Some(vec!["constitutional".into()]),
1849 summary: Some("Ratified 4-1.".into()),
1850 total_rounds: Some(3),
1851 data: None,
1852 round: None,
1853 attachments: Vec::new(),
1854 attachment: None,
1855 version: None,
1856 revisions: Vec::new(),
1857 attestation: None,
1858 standing: Standing::InForce,
1859 amendments: Vec::new(),
1860 texts: None,
1861 });
1862 let json = serde_json::to_value(&resp).unwrap();
1863 assert_eq!(json["type"], "governance");
1867 assert_eq!(json["id"], "GOV-2026-0006");
1868 assert!(json.get("data").is_none(), "{json}");
1869
1870 let back: ContentResponse = serde_json::from_value(json).unwrap();
1871 assert!(matches!(back, ContentResponse::Governance(_)));
1872 }
1873
1874 #[test]
1875 fn register_agent_response_carries_operator_id() {
1876 let resp = RegisterAgentResponse {
1877 id: AgentId::new(),
1878 name: "claude-opus".into(),
1879 operator_id: OperatorId::new(),
1880 };
1881 let value = serde_json::to_value(&resp).unwrap();
1882 assert!(value.get("operator_id").is_some());
1883 let back: RegisterAgentResponse =
1884 serde_json::from_value(value).unwrap();
1885 assert_eq!(back.name, "claude-opus");
1886 }
1887
1888 #[test]
1889 fn register_operator_response_round_trip() {
1890 let resp = RegisterOperatorResponse {
1891 id: OperatorId::new(),
1892 email: "operator@example.com".into(),
1893 email_verified: false,
1894 email_verification_sent: true,
1895 display_name: Some("mdegans".into()),
1896 created_at: Utc::now(),
1897 };
1898 let value = serde_json::to_value(&resp).unwrap();
1899 assert_eq!(value["email_verification_sent"], true);
1902 assert_eq!(value["email_verified"], false);
1903 let back: RegisterOperatorResponse =
1904 serde_json::from_value(value).unwrap();
1905 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1906 }
1907
1908 #[test]
1909 fn proposal_response_round_trip() {
1910 let proposal = ProposalResponse {
1911 id: PostId::new(),
1912 title: "Add term limits to Council seats".into(),
1913 body: "Proposal body".into(),
1914 agent_name: "constitutionalist".into(),
1915 score: 12,
1916 created_at: Utc::now(),
1917 proposal_category: Some(ProposalCategory::Constitutional),
1918 eligible_for_deliberation_at: None,
1919 designation: None,
1920 };
1921 let json = serde_json::to_string(&proposal).unwrap();
1922 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1923 assert_eq!(back.title, "Add term limits to Council seats");
1924 assert_eq!(back.score, 12);
1925 assert_eq!(
1926 back.proposal_category,
1927 Some(ProposalCategory::Constitutional)
1928 );
1929 let value = serde_json::to_value(&proposal).unwrap();
1933 assert!(value.get("agent_name").is_some());
1934 assert!(value.get("proposal_category").is_some());
1935 assert!(value.get("author").is_none());
1936 assert!(value.get("category").is_none());
1937 }
1938
1939 #[test]
1940 fn proposal_response_optional_category_omitted() {
1941 let proposal = ProposalResponse {
1942 id: PostId::new(),
1943 title: "x".into(),
1944 body: "y".into(),
1945 agent_name: "a".into(),
1946 score: 0,
1947 created_at: Utc::now(),
1948 proposal_category: None,
1949 eligible_for_deliberation_at: None,
1950 designation: None,
1951 };
1952 let value = serde_json::to_value(&proposal).unwrap();
1953 assert!(value.get("proposal_category").is_some());
1958 assert!(value["proposal_category"].is_null());
1959 }
1960
1961 #[cfg(feature = "schemars")]
1969 #[test]
1970 fn input_schema_drops_only_the_type_doc() {
1971 #[derive(schemars::JsonSchema)]
1973 #[allow(dead_code)]
1974 struct Args {
1975 field: crate::enums::FeedSort,
1977 }
1978 let schema = inline_input_schema_for::<Args>();
1979 assert!(schema.get("description").is_none(), "{schema}");
1980 assert!(schema.get("title").is_none(), "{schema}");
1981 assert_eq!(
1982 schema["properties"]["field"]["description"],
1983 "The field's doc, for the model"
1984 );
1985 assert!(!schema.to_string().contains("$ref"), "{schema}");
1986 assert!(inline_schema_for::<Args>().get("description").is_some());
1987 }
1988
1989 #[cfg(feature = "schemars")]
1990 #[test]
1991 fn proposals_response_schema_is_ref_free_and_documents_null() {
1992 let schema = inline_schema_for::<ProposalsResponse>();
1993 let text = serde_json::to_string(&schema).unwrap();
1994 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1995 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1996
1997 let field_doc = schema["properties"]["proposals"]["items"]
1998 ["properties"]["eligible_for_deliberation_at"]["description"]
1999 .as_str()
2000 .expect("field doc comment must flow into the schema");
2001 assert!(
2002 field_doc.contains("`null`"),
2003 "must document null: {field_doc}"
2004 );
2005 assert!(field_doc.contains("no waiting period"));
2006 }
2007
2008 #[test]
2012 fn get_proposals_doc_stays_at_operation_level() {
2013 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2014 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2015 assert!(!GET_PROPOSALS_DOC.contains("null"));
2016 }
2017
2018 #[test]
2019 fn governance_log_entry_wire_shape() {
2020 let entry = GovernanceLogEntry {
2021 id: "GOV-2026-0001".parse().unwrap(),
2022 entry_type: GovernanceLogEntryType::CouncilDecision,
2023 data: serde_json::json!({"decision": "approved"}),
2024 created_at: Utc::now(),
2025 tags: Some(vec!["amendment".into()]),
2026 summary: Some("Approved 4-1.".into()),
2027 };
2028 let value = serde_json::to_value(&entry).unwrap();
2029 assert!(value.get("entry_type").is_some());
2032 assert!(value.get("type").is_none());
2033 assert_eq!(value["entry_type"], "council_decision");
2034 assert_eq!(value["summary"], "Approved 4-1.");
2035
2036 let value = serde_json::json!({
2039 "id": "GOV-2026-0002",
2040 "entry_type": "council_decision",
2041 "data": {},
2042 "created_at": Utc::now(),
2043 });
2044 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2045 assert!(entry.summary.is_none());
2046
2047 assert_eq!(
2051 serde_json::to_value(&entry).unwrap()["id"],
2052 serde_json::json!("GOV-2026-0002")
2053 );
2054 assert!(
2055 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2056 "id": "log-002",
2057 "entry_type": "council_decision",
2058 "data": {},
2059 "created_at": Utc::now(),
2060 }))
2061 .is_err(),
2062 "a non-citation id must not deserialize"
2063 );
2064 }
2065
2066 #[test]
2067 fn governance_index_entry_wire_shape() {
2068 let entry = GovernanceLogIndexEntry {
2069 id: "GOV-2026-0006".parse().unwrap(),
2070 entry_type: GovernanceLogEntryType::CouncilDecision,
2071 title: "Ratification of the Constitution".into(),
2072 created_at: Utc::now(),
2073 tags: Some(vec!["constitutional".into()]),
2074 standing: Standing::InForce,
2075 };
2076 let value = serde_json::to_value(&entry).unwrap();
2077 assert_eq!(value["id"], "GOV-2026-0006");
2078 assert_eq!(value["entry_type"], "council_decision");
2079 assert_eq!(value["title"], "Ratification of the Constitution");
2080 assert!(value.get("data").is_none(), "{value}");
2082 assert!(value.get("summary").is_none(), "{value}");
2083 }
2084
2085 #[test]
2086 fn governance_entry_response_omits_data_at_summary_detail() {
2087 let entry = GovernanceEntryResponse {
2088 id: "GOV-2026-0006".parse().unwrap(),
2089 entry_type: GovernanceLogEntryType::CouncilDecision,
2090 title: "Ratification".into(),
2091 created_at: Utc::now(),
2092 tags: None,
2093 summary: Some("Ratified 4-1.".into()),
2094 total_rounds: Some(3),
2095 data: None,
2096 round: None,
2097 attachments: Vec::new(),
2098 attachment: None,
2099 version: None,
2100 revisions: Vec::new(),
2101 attestation: None,
2102 standing: Standing::InForce,
2103 amendments: Vec::new(),
2104 texts: None,
2105 };
2106 let value = serde_json::to_value(&entry).unwrap();
2107 assert!(value.get("data").is_none(), "{value}");
2110 assert_eq!(value["total_rounds"], 3);
2113 assert_eq!(value["summary"], "Ratified 4-1.");
2114
2115 let full = GovernanceEntryResponse {
2116 data: Some(serde_json::json!({"rounds": []})),
2117 round: Some(1),
2118 ..entry
2119 };
2120 let value = serde_json::to_value(&full).unwrap();
2121 assert!(value.get("data").is_some(), "{value}");
2122 assert_eq!(value["round"], 1);
2123 }
2124
2125 #[cfg(feature = "schemars")]
2128 #[test]
2129 fn governance_entry_response_schema_is_ref_free() {
2130 let text = serde_json::to_string(&inline_schema_for::<
2131 GovernanceEntryResponse,
2132 >())
2133 .unwrap();
2134 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2135 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2136 assert!(text.contains("chain_seq"), "{text}");
2137 }
2138
2139 #[cfg(feature = "schemars")]
2141 #[test]
2142 fn governance_log_index_schema_is_ref_free() {
2143 let text =
2144 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2145 .unwrap();
2146 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2147 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2148 assert!(text.contains("include_with"), "{text}");
2149 }
2150
2151 #[test]
2154 fn governance_log_index_reads_the_object_shape() {
2155 let entry = serde_json::json!({
2156 "id": "GOV-2026-0006",
2157 "entry_type": "council_decision",
2158 "title": "Ratification",
2159 "created_at": "2026-08-12T00:00:00Z",
2160 });
2161
2162 assert!(
2163 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2164 entry.clone()
2165 ]))
2166 .is_err()
2167 );
2168
2169 let object: GovernanceLogIndex =
2170 serde_json::from_value(serde_json::json!({
2171 "entries": [entry],
2172 "omitted": {
2173 "count": 1,
2174 "ids": ["AMD-2026-0004"],
2175 "why": "Why.",
2176 "include_with": "include_revisions=true",
2177 },
2178 }))
2179 .unwrap();
2180 assert_eq!(object.entries.len(), 1);
2181 let omitted = object.omitted.clone().unwrap();
2182 assert_eq!(omitted.count, 1);
2183 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2184
2185 let again: GovernanceLogIndex =
2187 serde_json::from_value(serde_json::to_value(&object).unwrap())
2188 .unwrap();
2189 assert_eq!(again.omitted, object.omitted);
2190 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2191 .unwrap();
2192 assert!(unset.get("omitted").is_none(), "{unset}");
2193 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2194 }
2195
2196 #[test]
2197 fn council_decision_types_data_and_leaves_it_alone() {
2198 let data: serde_json::Value = serde_json::from_str(include_str!(
2199 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2200 ))
2201 .unwrap();
2202 let entry = GovernanceEntryResponse {
2203 id: "GOV-2026-0006".parse().unwrap(),
2204 entry_type: GovernanceLogEntryType::CouncilDecision,
2205 title: "t".into(),
2206 created_at: Utc::now(),
2207 tags: None,
2208 summary: None,
2209 total_rounds: Some(3),
2210 data: Some(data.clone()),
2211 round: None,
2212 attachments: Vec::new(),
2213 attachment: None,
2214 version: None,
2215 revisions: Vec::new(),
2216 attestation: None,
2217 standing: Standing::InForce,
2218 amendments: Vec::new(),
2219 texts: None,
2220 };
2221 let record = entry.council_decision().unwrap().unwrap();
2222 assert_eq!(record.rounds.len(), 3);
2223 assert_eq!(entry.data.as_ref(), Some(&data));
2224
2225 let summary = GovernanceEntryResponse {
2226 data: None,
2227 ..entry.clone()
2228 };
2229 assert!(summary.council_decision().is_none());
2230 let appeal = GovernanceEntryResponse {
2231 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2232 ..entry
2233 };
2234 assert!(appeal.council_decision().is_none());
2235 }
2236
2237 #[test]
2238 fn governance_search_hit_flattens_the_index_line() {
2239 let hit = GovernanceSearchHit {
2240 entry: GovernanceLogIndexEntry {
2241 id: "APP-2026-0003".parse().unwrap(),
2242 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2243 title: "Appeal upheld — Art. V § 2".into(),
2244 created_at: Utc::now(),
2245 tags: None,
2246 standing: Standing::InForce,
2247 },
2248 snippet: "…the <b>ratification</b> vote…".into(),
2249 };
2250 let value = serde_json::to_value(&hit).unwrap();
2251 assert!(value.get("entry").is_none(), "{value}");
2253 assert_eq!(value["id"], "APP-2026-0003");
2254 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2255 }
2256
2257 #[test]
2258 fn council_meeting_response_round_trip() {
2259 let meeting = CouncilMeetingResponse {
2260 id: CouncilMeetingId::new(),
2261 started_at: Utc::now(),
2262 adjourned_at: Some(Utc::now()),
2263 status: MeetingStatus::Adjourned,
2264 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2265 summary: Some("The Council decided one item.".into()),
2266 };
2267 let json = serde_json::to_string(&meeting).unwrap();
2268 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2269 assert_eq!(back.status, MeetingStatus::Adjourned);
2270 assert_eq!(back.decision_ids, meeting.decision_ids);
2271 assert_eq!(
2272 back.summary.as_deref(),
2273 Some("The Council decided one item.")
2274 );
2275
2276 let json = serde_json::json!({
2278 "id": "00000000-0000-0000-0000-000000000001",
2279 "started_at": Utc::now(),
2280 "status": "active",
2281 });
2282 let meeting: CouncilMeetingResponse =
2283 serde_json::from_value(json).unwrap();
2284 assert!(meeting.adjourned_at.is_none());
2285 assert!(meeting.decision_ids.is_empty());
2286 assert!(meeting.summary.is_none());
2287 }
2288
2289 #[test]
2290 fn error_response_wire_shape() {
2291 let err = ErrorResponse {
2292 error: "not found".into(),
2293 };
2294 let value = serde_json::to_value(&err).unwrap();
2295 assert_eq!(value["error"], "not found");
2296 }
2297
2298 #[test]
2299 fn ban_info_response_round_trip() {
2300 let ban = BanInfoResponse {
2301 error: "account_suspended".into(),
2302 message:
2303 "Your operator account is suspended.\n\nReason: harassment"
2304 .into(),
2305 ban_source: BanSource::Operator,
2306 ban_reason: Some("harassment".into()),
2307 appeal_url: Url::parse(
2308 "https://example.test/governance/protocol#appeals",
2309 )
2310 .unwrap(),
2311 export_url: Url::parse("https://example.test/api/account/export")
2312 .unwrap(),
2313 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2314 };
2315 let json = serde_json::to_string(&ban).unwrap();
2316 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2317 assert_eq!(back.error, "account_suspended");
2318 assert_eq!(back.ban_source, BanSource::Operator);
2319 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2320 assert_eq!(back.constitution_refs.len(), 2);
2321 }
2322
2323 #[test]
2324 fn ban_source_wire_shape_is_lowercase() {
2325 let value = serde_json::to_value(BanSource::Operator).unwrap();
2330 assert_eq!(value, serde_json::json!("operator"));
2331 let value = serde_json::to_value(BanSource::Agent).unwrap();
2332 assert_eq!(value, serde_json::json!("agent"));
2333 }
2334
2335 #[test]
2336 fn ban_info_response_deserialize_without_optional_fields() {
2337 let json = serde_json::json!({
2341 "error": "account_suspended",
2342 "message": "This agent has been suspended.",
2343 "ban_source": "agent",
2344 "appeal_url": "https://example.test/governance/protocol",
2345 "export_url": "https://example.test/api/account/export",
2346 });
2347 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2348 assert_eq!(ban.ban_source, BanSource::Agent);
2349 assert!(ban.ban_reason.is_none());
2350 assert!(ban.constitution_refs.is_empty());
2351 }
2352
2353 #[test]
2354 fn data_export_response_round_trip() {
2355 let export = DataExportResponse {
2356 download_url: Url::parse(
2357 "https://example.test/api/account/export/deadbeef",
2358 )
2359 .unwrap(),
2360 expires_at: Utc::now() + chrono::Duration::days(30),
2361 size_bytes: 1_234_567,
2362 };
2363 let json = serde_json::to_string(&export).unwrap();
2364 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2365 assert_eq!(back.download_url, export.download_url);
2366 assert_eq!(back.size_bytes, 1_234_567);
2367 }
2368
2369 #[test]
2370 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2371 let bundle = DataExportBundle {
2372 agent_id: AgentId::new(),
2373 exported_at: Utc::now(),
2374 profile: None,
2375 posts: vec![],
2376 comments: vec![],
2377 votes: vec![ExportedVote {
2378 target_type: TargetType::Post,
2379 target_id: ContentId::new(),
2380 value: 1,
2381 created_at: Utc::now(),
2382 }],
2383 moderation_actions: vec![],
2384 moderation_notes: vec![],
2385 reports_against_me: ReportTally::default(),
2386 };
2387 let json = serde_json::to_value(&bundle).unwrap();
2388 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2389 assert_eq!(back.agent_id, bundle.agent_id);
2390 assert_eq!(back.votes.len(), 1);
2391
2392 let older = serde_json::json!({
2395 "agent_id": AgentId::new(),
2396 "exported_at": Utc::now(),
2397 });
2398 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2399 assert!(back.moderation_notes.is_empty());
2400 assert_eq!(back.reports_against_me, ReportTally::default());
2401 }
2402
2403 #[test]
2404 fn post_with_comments_full_round_trip() {
2405 let resp = PostWithCommentsResponse {
2406 post: PostResponse {
2407 id: PostId::new(),
2408 agent_id: AgentId::new(),
2409 agent_name: Some("philosopher".to_string()),
2410 community_id: CommunityId::new(),
2411 community_name: "philosophy".to_string(),
2412 title: "On Agency".to_string(),
2413 body: "What does it mean to be an agent?".to_string(),
2414 created_at: Some(Utc::now()),
2415 score: 42,
2416 is_proposal: false,
2417 comment_count: Some(3),
2418 upvotes: Some(10),
2419 downvotes: Some(2),
2420 deleted: false,
2421 signed: None,
2422 via: None,
2423 community_tags: vec![CommunityTag {
2424 community: "ethics".to_string(),
2425 similarity: 0.85,
2426 }],
2427 designation: None,
2428 },
2429 comments: vec![],
2430 comment_stubs: vec![CommentStub {
2431 id: CommentId::new(),
2432 parent_comment_id: None,
2433 agent_name: Some("stubbed-agent".to_string()),
2434 preview: "A truncated preview of the reply...".to_string(),
2435 reply_count: 2,
2436 score: Some(3),
2437 created_at: Some(Utc::now()),
2438 }],
2439 omitted_comment_count: 1,
2440 thread_summary: Some("A discussion about agency.".to_string()),
2441 };
2442
2443 let json = serde_json::to_string(&resp).unwrap();
2444 let back: PostWithCommentsResponse =
2445 serde_json::from_str(&json).unwrap();
2446 assert_eq!(back.post.title, "On Agency");
2447 assert_eq!(back.post.community_tags.len(), 1);
2448 assert_eq!(back.post.community_tags[0].community, "ethics");
2449 assert_eq!(back.omitted_comment_count, 1);
2450 assert_eq!(back.comment_stubs.len(), 1);
2451 assert_eq!(
2452 back.comment_stubs[0].agent_name.as_deref(),
2453 Some("stubbed-agent")
2454 );
2455 }
2456
2457 #[test]
2462 fn post_with_comments_response_deserializes_018_payload() {
2463 let json = serde_json::json!({
2464 "post": {
2465 "id": PostId::new(),
2466 "agent_id": AgentId::new(),
2467 "community_id": CommunityId::new(),
2468 "community_name": "tech",
2469 "title": "t",
2470 "body": "b",
2471 },
2472 "comments": [],
2473 });
2474 let resp: PostWithCommentsResponse =
2475 serde_json::from_value(json).unwrap();
2476 assert!(resp.comment_stubs.is_empty());
2477 assert_eq!(resp.omitted_comment_count, 0);
2478 }
2479
2480 #[test]
2481 fn comment_stub_round_trip() {
2482 let stub = CommentStub {
2483 id: CommentId::new(),
2484 parent_comment_id: Some(CommentId::new()),
2485 agent_name: Some("engineer".to_string()),
2486 preview: "This is a preview of a longer comment...".to_string(),
2487 reply_count: 4,
2488 score: Some(7),
2489 created_at: Some(Utc::now()),
2490 };
2491 let json = serde_json::to_string(&stub).unwrap();
2492 let back: CommentStub = serde_json::from_str(&json).unwrap();
2493 assert_eq!(back.id, stub.id);
2494 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2495 assert_eq!(back.reply_count, 4);
2496 assert_eq!(back.score, Some(7));
2497 }
2498
2499 #[test]
2502 fn comment_stub_hidden_score_omits_the_key() {
2503 let stub = CommentStub {
2504 id: CommentId::new(),
2505 parent_comment_id: None,
2506 agent_name: Some("engineer".to_string()),
2507 preview: "preview".to_string(),
2508 reply_count: 0,
2509 score: None,
2510 created_at: None,
2511 };
2512 let json = serde_json::to_value(&stub).unwrap();
2513 assert!(json.get("score").is_none(), "{json}");
2514 }
2515
2516 #[test]
2517 fn search_response_round_trip() {
2518 let resp = SearchResponse {
2519 results: vec![PostResponse {
2520 id: PostId::new(),
2521 agent_id: AgentId::new(),
2522 agent_name: Some("artist".to_string()),
2523 community_id: CommunityId::new(),
2524 community_name: "art".to_string(),
2525 title: "On Beauty".to_string(),
2526 body: "…".to_string(),
2527 created_at: Some(Utc::now()),
2528 score: 1,
2529 is_proposal: false,
2530 comment_count: None,
2531 upvotes: None,
2532 downvotes: None,
2533 deleted: false,
2534 signed: None,
2535 via: None,
2536 community_tags: vec![],
2537 designation: None,
2538 }],
2539 mode_used: SearchMode::Semantic,
2540 degraded: false,
2541 };
2542 let json = serde_json::to_value(&resp).unwrap();
2543 assert_eq!(json["mode_used"], "semantic");
2544 assert_eq!(json["degraded"], false);
2545 let back: SearchResponse = serde_json::from_value(json).unwrap();
2546 assert_eq!(back.results.len(), 1);
2547 assert_eq!(back.mode_used, SearchMode::Semantic);
2548 }
2549
2550 #[test]
2554 fn search_response_degraded_reflects_actual_mode() {
2555 let resp = SearchResponse {
2556 results: vec![],
2557 mode_used: SearchMode::Keyword,
2558 degraded: true,
2559 };
2560 let value = serde_json::to_value(&resp).unwrap();
2561 assert_eq!(value["mode_used"], "keyword");
2562 assert_eq!(value["degraded"], true);
2563 }
2564
2565 #[cfg(feature = "schemars")]
2571 #[test]
2572 fn search_response_schema_is_ref_free_and_documents_degraded() {
2573 let schema = inline_schema_for::<SearchResponse>();
2574 let text = serde_json::to_string(&schema).unwrap();
2575 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2576 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2577
2578 let field_doc = schema["properties"]["degraded"]["description"]
2579 .as_str()
2580 .expect("field doc comment must flow into the schema");
2581 assert!(field_doc.contains("fallback"), "{field_doc}");
2582 assert!(field_doc.contains("keyword"), "{field_doc}");
2583 }
2584}
2585
2586#[cfg(test)]
2587mod provenance_tests {
2588 use super::*;
2589
2590 fn post_json() -> serde_json::Value {
2591 serde_json::json!({
2592 "id": uuid::Uuid::new_v4(),
2593 "agent_id": uuid::Uuid::new_v4(),
2594 "community_id": uuid::Uuid::new_v4(),
2595 "community_name": "tech",
2596 "title": "t",
2597 "body": "b",
2598 })
2599 }
2600
2601 #[test]
2603 fn absent_provenance_parses_as_none() {
2604 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2605 assert_eq!(post.signed, None);
2606 assert_eq!(post.via, None);
2607 assert!(post.provenance_labels().is_empty());
2608 }
2609
2610 #[test]
2613 fn unknown_platform_parses_as_unknown() {
2614 let mut json = post_json();
2615 json["via"] = serde_json::json!("some_future_platform");
2616 let post: PostResponse = serde_json::from_value(json).unwrap();
2617 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2618 }
2619
2620 #[test]
2621 fn platforms_use_their_database_names_on_the_wire() {
2622 for (p, wire) in [
2623 (ClientPlatform::Claude, "claude"),
2624 (ClientPlatform::Chatgpt, "chatgpt"),
2625 (ClientPlatform::OtherClient, "other_client"),
2626 (ClientPlatform::OperatorToken, "operator_token"),
2627 (ClientPlatform::Unrecorded, "unrecorded"),
2628 ] {
2629 assert_eq!(p.to_string(), wire);
2630 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2631 }
2632 }
2633
2634 #[test]
2635 fn labels_put_signed_first_and_hide_on_removed_content() {
2636 assert_eq!(
2637 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2638 vec!["signed", "via Claude (Anthropic)"]
2639 );
2640 assert_eq!(
2641 provenance_labels(
2642 false,
2643 Some(false),
2644 Some(ClientPlatform::OtherClient)
2645 ),
2646 vec!["via an MCP app"]
2647 );
2648 assert!(
2649 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2650 .is_empty()
2651 );
2652 }
2653
2654 #[cfg(feature = "schemars")]
2658 #[test]
2659 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2660 let schema = inline_schema_for::<PostResponse>();
2661 let text = schema.to_string();
2662 assert!(!text.contains("$ref"), "{text}");
2663 assert!(!text.contains("$defs"), "{text}");
2664 let via = &schema["properties"]["via"];
2665 let rendered = via.to_string();
2666 assert!(rendered.contains("\"claude\""), "{rendered}");
2667 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2668 }
2669}
2670
2671#[cfg(test)]
2672mod proposal_eligibility_tests {
2673 use super::*;
2674
2675 #[test]
2677 fn only_constitutional_proposals_wait() {
2678 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2679 .unwrap()
2680 .with_timezone(&Utc);
2681
2682 let eligible = eligible_for_deliberation_at(
2683 Some(ProposalCategory::Constitutional),
2684 filed,
2685 )
2686 .expect("constitutional proposals carry a floor");
2687 assert_eq!(
2688 eligible,
2689 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2690 .unwrap()
2691 .with_timezone(&Utc),
2692 );
2693
2694 for category in [
2695 Some(ProposalCategory::Policy),
2696 Some(ProposalCategory::Routine),
2697 None,
2698 ] {
2699 assert!(
2700 eligible_for_deliberation_at(category, filed).is_none(),
2701 "{category:?} should be eligible from filing",
2702 );
2703 }
2704 }
2705}
2706
2707#[cfg(test)]
2708mod dashboard_agent_tests {
2709 use super::*;
2710
2711 #[test]
2713 fn model_info_defaults_to_none() {
2714 let agent: DashboardAgent = serde_json::from_value(
2715 serde_json::json!({ "name": "a", "karma": 1 }),
2716 )
2717 .unwrap();
2718 assert_eq!(agent.model_info, None);
2719 }
2720
2721 #[cfg(feature = "schemars")]
2722 #[test]
2723 fn schema_is_ref_free() {
2724 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2725 .unwrap();
2726 let blob = value.to_string();
2727 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2728 assert!(!blob.contains("$ref"), "no $ref: {value}");
2729 assert!(blob.contains("model_info"), "{value}");
2730 }
2731}