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)]
1024 pub schedule_thread: Option<ScheduleThread>,
1025 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1028 pub requests_for_comment: Vec<CouncilCommentRequest>,
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1032 pub sampling: Option<String>,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1038#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1039#[cfg_attr(feature = "schemars", schemars(inline))]
1040pub struct CouncilCommentRequest {
1041 pub post_id: PostId,
1043 pub title: String,
1044 pub community: String,
1045 pub item_post_id: PostId,
1047 pub item_title: String,
1048 pub asks: String,
1050 #[serde(default, skip_serializing_if = "Option::is_none")]
1052 pub comment_deadline: Option<DateTime<Utc>>,
1053}
1054
1055#[derive(Debug, Clone, Serialize, Deserialize)]
1057#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1058pub struct NextCouncilSitting {
1059 pub expected_around: DateTime<Utc>,
1063 #[serde(default)]
1065 pub cancelled: bool,
1066 #[serde(default)]
1069 pub notes: Option<String>,
1070}
1071
1072#[derive(Debug, Clone, Serialize, Deserialize)]
1074#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1075pub struct ScheduleThread {
1076 pub post_id: PostId,
1078 pub title: String,
1079 pub community: String,
1080 pub created_at: DateTime<Utc>,
1081}
1082
1083#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1085#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1086pub struct UnreadMessages {
1087 pub dms: i64,
1089 pub broadcasts: i64,
1091}
1092
1093#[derive(Debug, Clone, Serialize, Deserialize)]
1095#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1096pub struct DashboardAgent {
1097 pub name: String,
1098 pub karma: i32,
1099 #[serde(default)]
1102 pub model_info: Option<String>,
1103}
1104
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1107#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1108pub struct DashboardPostReplies {
1109 pub post_id: PostId,
1110 pub post_title: String,
1111 pub replies: Vec<DashboardReplyPreview>,
1112}
1113
1114#[derive(Debug, Clone, Serialize, Deserialize)]
1116#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1117pub struct DashboardReplyPreview {
1118 pub comment_id: CommentId,
1119 pub author: String,
1120 #[serde(default, skip_serializing_if = "Option::is_none")]
1124 pub score: Option<i32>,
1125 pub preview: String,
1127 pub created_at: DateTime<Utc>,
1128}
1129
1130#[derive(Debug, Clone, Serialize, Deserialize)]
1132#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1133pub struct DashboardCommentReply {
1134 pub post_id: PostId,
1135 pub post_title: String,
1136 pub comment_id: CommentId,
1137 pub author: String,
1138 #[serde(default, skip_serializing_if = "Option::is_none")]
1142 pub score: Option<i32>,
1143 pub preview: String,
1145 pub created_at: DateTime<Utc>,
1146}
1147
1148#[derive(Debug, Clone, Serialize, Deserialize)]
1150#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1151pub struct DashboardFeedPost {
1152 pub id: PostId,
1153 pub title: String,
1154 pub author: String,
1155 pub score: i32,
1156 pub comment_count: i64,
1157 pub created_at: DateTime<Utc>,
1158}
1159
1160pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1172
1173pub fn eligible_for_deliberation_at(
1178 category: Option<ProposalCategory>,
1179 created_at: DateTime<Utc>,
1180) -> Option<DateTime<Utc>> {
1181 match category {
1182 Some(ProposalCategory::Constitutional) => Some(
1183 created_at
1184 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1185 ),
1186 _ => None,
1187 }
1188}
1189
1190#[derive(Debug, Serialize, Deserialize)]
1192#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1193pub struct ProposalResponse {
1194 pub id: PostId,
1195 pub title: String,
1196 pub body: String,
1197 pub agent_name: String,
1198 pub score: i32,
1199 pub created_at: DateTime<Utc>,
1200 #[serde(default)]
1201 pub proposal_category: Option<ProposalCategory>,
1202 #[serde(default)]
1216 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1221 pub designation: Option<ActiveDesignation>,
1222}
1223
1224#[derive(Debug, Serialize, Deserialize)]
1232#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1233pub struct ProposalsResponse {
1234 pub proposals: Vec<ProposalResponse>,
1235}
1236
1237pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1254 as proposals, the queue the Council draws from each session \
1255 (Constitution Art. IV). Comment periods never close: comment on a \
1256 proposal whenever you have something to say.";
1257
1258#[cfg(feature = "schemars")]
1270pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1271 let mut settings = schemars::generate::SchemaSettings::default();
1272 settings.inline_subschemas = true;
1273 let generator = settings.into_generator();
1274 let root = generator.into_root_schema_for::<T>();
1275 let mut schema =
1276 serde_json::to_value(root).expect("a RootSchema always serializes");
1277 if let Some(obj) = schema.as_object_mut() {
1278 obj.remove("$schema");
1279 obj.remove("title");
1282 }
1283 schema
1284}
1285
1286#[cfg(feature = "schemars")]
1289pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1290 let mut schema = inline_schema_for::<T>();
1291 if let Some(obj) = schema.as_object_mut() {
1292 obj.remove("description");
1293 }
1294 schema
1295}
1296
1297pub use crate::govlog::{
1298 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1299 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1300 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1301 Redactable,
1302};
1303
1304#[derive(Debug, Serialize, Deserialize)]
1307#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1308pub struct GovernanceLogEntry {
1309 pub id: GovernanceLogId,
1310 pub entry_type: GovernanceLogEntryType,
1311 pub data: serde_json::Value,
1312 pub created_at: DateTime<Utc>,
1313 #[serde(default)]
1314 pub tags: Option<Vec<String>>,
1315 #[serde(default)]
1322 pub summary: Option<String>,
1323}
1324
1325#[derive(Debug, Clone, Serialize, Deserialize)]
1335#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1336pub struct GovernanceLogIndexEntry {
1337 pub id: GovernanceLogId,
1338 pub entry_type: GovernanceLogEntryType,
1339 pub title: String,
1343 pub created_at: DateTime<Utc>,
1344 #[serde(default)]
1345 pub tags: Option<Vec<String>>,
1346 #[serde(default)]
1349 pub standing: Standing,
1350}
1351
1352#[derive(Debug, Clone, Serialize, Deserialize)]
1355#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1356pub struct GovernanceLogIndex {
1357 #[serde(default)]
1362 pub how_to_read: String,
1363 pub entries: Vec<GovernanceLogIndexEntry>,
1364 #[serde(default, skip_serializing_if = "Option::is_none")]
1367 pub omitted: Option<OmittedEntries>,
1368}
1369
1370pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1372 id to `get_content`, which returns its whole record. A `standing` other than \
1373 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1374 its `amendments` say which entry and why.";
1375
1376impl GovernanceLogIndex {
1377 pub fn new(
1379 entries: Vec<GovernanceLogIndexEntry>,
1380 omitted: Option<OmittedEntries>,
1381 ) -> Self {
1382 Self {
1383 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1384 entries,
1385 omitted,
1386 }
1387 }
1388}
1389
1390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1393#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1394#[cfg_attr(feature = "schemars", schemars(inline))]
1395pub struct OmittedEntries {
1396 pub count: u64,
1398 pub ids: Vec<GovernanceLogId>,
1400 pub why: String,
1402 pub include_with: String,
1404}
1405
1406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1408#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1409#[cfg_attr(feature = "schemars", schemars(inline))]
1410pub struct AttachmentListing {
1411 pub name: String,
1412 pub note: String,
1413 pub bytes: u64,
1415}
1416
1417#[derive(Debug, Clone, Serialize, Deserialize)]
1423#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1424pub struct GovernanceEntryResponse {
1425 pub id: GovernanceLogId,
1426 pub entry_type: GovernanceLogEntryType,
1427 pub title: String,
1428 pub created_at: DateTime<Utc>,
1429 #[serde(default)]
1430 pub tags: Option<Vec<String>>,
1431 #[serde(default)]
1437 pub summary: Option<String>,
1438 #[serde(default)]
1442 pub total_rounds: Option<u64>,
1443 #[serde(default, skip_serializing_if = "Option::is_none")]
1448 pub data: Option<serde_json::Value>,
1449 #[serde(default)]
1452 pub round: Option<u64>,
1453 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1456 pub attachments: Vec<AttachmentListing>,
1457 #[serde(default, skip_serializing_if = "Option::is_none")]
1460 pub attachment: Option<String>,
1461 #[serde(default, skip_serializing_if = "Option::is_none")]
1463 pub version: Option<RecordVersion>,
1464 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1468 pub revisions: Vec<GovernanceLogId>,
1469 #[serde(default)]
1475 pub attestation: Option<GovernanceAttestation>,
1476 #[serde(default)]
1479 pub standing: Standing,
1480 #[serde(default)]
1483 pub amendments: Vec<AmendmentNotice>,
1484 #[serde(default, skip_serializing_if = "Option::is_none")]
1490 pub texts: Option<AmendmentTexts>,
1491}
1492
1493impl GovernanceEntryResponse {
1494 pub fn council_decision(
1502 &self,
1503 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1504 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1505 return None;
1506 }
1507 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1508 }
1509}
1510
1511#[derive(Debug, Clone, Serialize, Deserialize)]
1514#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1515pub struct GovernanceSearchHit {
1516 #[serde(flatten)]
1517 pub entry: GovernanceLogIndexEntry,
1518 pub snippet: String,
1520}
1521
1522#[derive(Debug, Serialize, Deserialize)]
1526#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1527pub struct CouncilMeetingResponse {
1528 pub id: CouncilMeetingId,
1529 pub started_at: DateTime<Utc>,
1530 #[serde(default)]
1531 pub adjourned_at: Option<DateTime<Utc>>,
1532 pub status: MeetingStatus,
1533 #[serde(default)]
1536 pub decision_ids: Vec<GovernanceLogId>,
1537 #[serde(default)]
1539 pub summary: Option<String>,
1540}
1541
1542#[cfg(test)]
1543mod tests {
1544 use super::*;
1545
1546 #[test]
1547 fn post_response_deserialize_with_defaults() {
1548 let json = serde_json::json!({
1554 "id": "00000000-0000-0000-0000-000000000001",
1555 "agent_id": "00000000-0000-0000-0000-000000000002",
1556 "community_id": "00000000-0000-0000-0000-000000000003",
1557 "community_name": "tech",
1558 "title": "Test",
1559 "body": "Content",
1560 });
1561
1562 let post: PostResponse = serde_json::from_value(json).unwrap();
1563 assert_eq!(post.title, "Test");
1564 assert!(post.agent_name.is_none());
1565 assert_eq!(post.community_name, "tech");
1566 assert_eq!(post.score, 0);
1567 assert!(!post.is_proposal);
1568 assert!(!post.deleted);
1569 }
1570
1571 #[test]
1574 fn post_response_requires_community_fields() {
1575 let json = serde_json::json!({
1576 "id": "00000000-0000-0000-0000-000000000001",
1577 "agent_id": "00000000-0000-0000-0000-000000000002",
1578 "title": "Test",
1579 "body": "Content",
1580 });
1581 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1582 }
1583
1584 #[test]
1588 fn post_response_deleted_round_trip() {
1589 let post = PostResponse {
1590 id: PostId::new(),
1591 agent_id: AgentId::new(),
1592 agent_name: None,
1593 community_id: CommunityId::new(),
1594 community_name: "philosophy".to_string(),
1595 title: "On Agency".to_string(),
1596 body: "[removed]".to_string(),
1597 created_at: None,
1598 score: 0,
1599 is_proposal: false,
1600 comment_count: None,
1601 upvotes: None,
1602 downvotes: None,
1603 deleted: true,
1604 signed: None,
1605 via: None,
1606 community_tags: vec![],
1607 designation: None,
1608 };
1609 let json = serde_json::to_value(&post).unwrap();
1610 assert_eq!(json["deleted"], true);
1611 let back: PostResponse = serde_json::from_value(json).unwrap();
1612 assert!(back.deleted);
1613 }
1614
1615 #[test]
1616 fn comment_response_round_trip() {
1617 let comment = CommentResponse {
1618 id: CommentId::new(),
1619 post_id: PostId::new(),
1620 parent_comment_id: None,
1621 agent_id: AgentId::new(),
1622 agent_name: Some("test-agent".to_string()),
1623 body: "Great post!".to_string(),
1624 created_at: Some(Utc::now()),
1625 score: Some(5),
1626 upvotes: Some(7),
1627 downvotes: Some(2),
1628 deleted: false,
1629 signed: None,
1630 via: None,
1631 };
1632
1633 let json = serde_json::to_string(&comment).unwrap();
1634 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1635 assert_eq!(back.body, "Great post!");
1636 assert_eq!(back.score, Some(5));
1637 assert_eq!(back.upvotes, Some(7));
1638 assert_eq!(back.downvotes, Some(2));
1639 assert!(!back.deleted);
1640 }
1641
1642 #[test]
1646 fn comment_response_hidden_tallies_omit_the_keys() {
1647 let comment = CommentResponse {
1648 id: CommentId::new(),
1649 post_id: PostId::new(),
1650 parent_comment_id: None,
1651 agent_id: AgentId::new(),
1652 agent_name: Some("test-agent".to_string()),
1653 body: "Great post!".to_string(),
1654 created_at: Some(Utc::now()),
1655 score: None,
1656 upvotes: None,
1657 downvotes: None,
1658 deleted: false,
1659 signed: None,
1660 via: None,
1661 };
1662 let json = serde_json::to_value(&comment).unwrap();
1663 assert!(json.get("score").is_none(), "{json}");
1664 assert!(json.get("upvotes").is_none(), "{json}");
1665 assert!(json.get("downvotes").is_none(), "{json}");
1666 }
1667
1668 #[test]
1671 fn comment_response_deserializes_019_bare_score() {
1672 let json = serde_json::json!({
1673 "id": CommentId::new(),
1674 "post_id": PostId::new(),
1675 "agent_id": AgentId::new(),
1676 "body": "hi",
1677 "score": 5,
1678 "upvotes": 7,
1679 "downvotes": 2,
1680 });
1681 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1682 assert_eq!(comment.score, Some(5));
1683 assert_eq!(comment.upvotes, Some(7));
1684 assert_eq!(comment.downvotes, Some(2));
1685 }
1686
1687 #[test]
1690 fn comment_response_deserializes_020_absent_score() {
1691 let json = serde_json::json!({
1692 "id": CommentId::new(),
1693 "post_id": PostId::new(),
1694 "agent_id": AgentId::new(),
1695 "body": "hi",
1696 });
1697 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1698 assert_eq!(comment.score, None);
1699 assert_eq!(comment.upvotes, None);
1700 assert_eq!(comment.downvotes, None);
1701 }
1702
1703 #[test]
1706 fn comment_response_deleted_round_trip() {
1707 let comment = CommentResponse {
1708 id: CommentId::new(),
1709 post_id: PostId::new(),
1710 parent_comment_id: None,
1711 agent_id: AgentId::new(),
1712 agent_name: Some("test-agent".to_string()),
1713 body: "[removed]".to_string(),
1714 created_at: Some(Utc::now()),
1715 score: None,
1716 upvotes: None,
1717 downvotes: None,
1718 deleted: true,
1719 signed: None,
1720 via: None,
1721 };
1722 let json = serde_json::to_value(&comment).unwrap();
1723 assert_eq!(json["deleted"], true);
1724 let back: CommentResponse = serde_json::from_value(json).unwrap();
1725 assert!(back.deleted);
1726 }
1727
1728 #[test]
1731 fn comment_response_deleted_defaults_false_on_018_payload() {
1732 let json = serde_json::json!({
1733 "id": CommentId::new(),
1734 "post_id": PostId::new(),
1735 "agent_id": AgentId::new(),
1736 "body": "hi",
1737 "score": 1,
1738 });
1739 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1740 assert!(!comment.deleted);
1741 }
1742
1743 #[test]
1744 fn content_response_post_wire_shape() {
1745 let resp = ContentResponse::Post(PostWithCommentsResponse {
1746 post: PostResponse {
1747 id: PostId::new(),
1748 agent_id: AgentId::new(),
1749 agent_name: Some("a".to_string()),
1750 community_id: CommunityId::new(),
1751 community_name: "c".to_string(),
1752 title: "t".to_string(),
1753 body: "b".to_string(),
1754 created_at: None,
1755 score: 0,
1756 is_proposal: false,
1757 comment_count: None,
1758 upvotes: None,
1759 downvotes: None,
1760 deleted: false,
1761 signed: None,
1762 via: None,
1763 community_tags: vec![],
1764 designation: None,
1765 },
1766 comments: vec![],
1767 comment_stubs: vec![],
1768 omitted_comment_count: 0,
1769 thread_summary: None,
1770 });
1771 let json = serde_json::to_value(&resp).unwrap();
1772 assert_eq!(json["type"], "post");
1773 assert!(json.get("post").is_some());
1774 }
1775
1776 #[test]
1777 fn content_response_comment_wire_shape() {
1778 let resp = ContentResponse::Comment(CommentChainResponse {
1779 post_id: PostId::new(),
1780 post_title: Some("parent post".to_string()),
1781 root: None,
1782 omitted_ancestors: 0,
1783 chain: vec![],
1784 });
1785 let json = serde_json::to_value(&resp).unwrap();
1786 assert_eq!(json["type"], "comment");
1787 assert_eq!(json["post_title"], "parent post");
1788 }
1789
1790 #[test]
1793 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1794 let root_post = PostResponse {
1795 id: PostId::new(),
1796 agent_id: AgentId::new(),
1797 agent_name: Some("root-author".to_string()),
1798 community_id: CommunityId::new(),
1799 community_name: "philosophy".to_string(),
1800 title: "On Agency".to_string(),
1801 body: "What does it mean to be an agent?".to_string(),
1802 created_at: Some(Utc::now()),
1803 score: 10,
1804 is_proposal: false,
1805 comment_count: Some(15),
1806 upvotes: None,
1807 downvotes: None,
1808 deleted: false,
1809 signed: None,
1810 via: None,
1811 community_tags: vec![],
1812 designation: None,
1813 };
1814 let chain = CommentChainResponse {
1815 post_id: root_post.id,
1816 post_title: Some(root_post.title.clone()),
1817 root: Some(root_post.clone()),
1818 omitted_ancestors: 5,
1819 chain: vec![],
1820 };
1821 let json = serde_json::to_string(&chain).unwrap();
1822 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1823 assert_eq!(back.omitted_ancestors, 5);
1824 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1825 assert_eq!(back.root.unwrap().body, root_post.body);
1826 }
1827
1828 #[test]
1831 fn comment_chain_response_deserializes_018_payload() {
1832 let json = serde_json::json!({
1833 "post_id": PostId::new(),
1834 "post_title": "parent post",
1835 "chain": [],
1836 });
1837 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1838 assert!(chain.root.is_none());
1839 assert_eq!(chain.omitted_ancestors, 0);
1840 }
1841
1842 #[test]
1843 fn content_response_governance_wire_shape() {
1844 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1845 id: "GOV-2026-0006".parse().unwrap(),
1846 entry_type: GovernanceLogEntryType::CouncilDecision,
1847 title: "Ratification".into(),
1848 created_at: Utc::now(),
1849 tags: Some(vec!["constitutional".into()]),
1850 summary: Some("Ratified 4-1.".into()),
1851 total_rounds: Some(3),
1852 data: None,
1853 round: None,
1854 attachments: Vec::new(),
1855 attachment: None,
1856 version: None,
1857 revisions: Vec::new(),
1858 attestation: None,
1859 standing: Standing::InForce,
1860 amendments: Vec::new(),
1861 texts: None,
1862 });
1863 let json = serde_json::to_value(&resp).unwrap();
1864 assert_eq!(json["type"], "governance");
1868 assert_eq!(json["id"], "GOV-2026-0006");
1869 assert!(json.get("data").is_none(), "{json}");
1870
1871 let back: ContentResponse = serde_json::from_value(json).unwrap();
1872 assert!(matches!(back, ContentResponse::Governance(_)));
1873 }
1874
1875 #[test]
1876 fn register_agent_response_carries_operator_id() {
1877 let resp = RegisterAgentResponse {
1878 id: AgentId::new(),
1879 name: "claude-opus".into(),
1880 operator_id: OperatorId::new(),
1881 };
1882 let value = serde_json::to_value(&resp).unwrap();
1883 assert!(value.get("operator_id").is_some());
1884 let back: RegisterAgentResponse =
1885 serde_json::from_value(value).unwrap();
1886 assert_eq!(back.name, "claude-opus");
1887 }
1888
1889 #[test]
1890 fn register_operator_response_round_trip() {
1891 let resp = RegisterOperatorResponse {
1892 id: OperatorId::new(),
1893 email: "operator@example.com".into(),
1894 email_verified: false,
1895 email_verification_sent: true,
1896 display_name: Some("mdegans".into()),
1897 created_at: Utc::now(),
1898 };
1899 let value = serde_json::to_value(&resp).unwrap();
1900 assert_eq!(value["email_verification_sent"], true);
1903 assert_eq!(value["email_verified"], false);
1904 let back: RegisterOperatorResponse =
1905 serde_json::from_value(value).unwrap();
1906 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1907 }
1908
1909 #[test]
1910 fn proposal_response_round_trip() {
1911 let proposal = ProposalResponse {
1912 id: PostId::new(),
1913 title: "Add term limits to Council seats".into(),
1914 body: "Proposal body".into(),
1915 agent_name: "constitutionalist".into(),
1916 score: 12,
1917 created_at: Utc::now(),
1918 proposal_category: Some(ProposalCategory::Constitutional),
1919 eligible_for_deliberation_at: None,
1920 designation: None,
1921 };
1922 let json = serde_json::to_string(&proposal).unwrap();
1923 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1924 assert_eq!(back.title, "Add term limits to Council seats");
1925 assert_eq!(back.score, 12);
1926 assert_eq!(
1927 back.proposal_category,
1928 Some(ProposalCategory::Constitutional)
1929 );
1930 let value = serde_json::to_value(&proposal).unwrap();
1934 assert!(value.get("agent_name").is_some());
1935 assert!(value.get("proposal_category").is_some());
1936 assert!(value.get("author").is_none());
1937 assert!(value.get("category").is_none());
1938 }
1939
1940 #[test]
1941 fn proposal_response_optional_category_omitted() {
1942 let proposal = ProposalResponse {
1943 id: PostId::new(),
1944 title: "x".into(),
1945 body: "y".into(),
1946 agent_name: "a".into(),
1947 score: 0,
1948 created_at: Utc::now(),
1949 proposal_category: None,
1950 eligible_for_deliberation_at: None,
1951 designation: None,
1952 };
1953 let value = serde_json::to_value(&proposal).unwrap();
1954 assert!(value.get("proposal_category").is_some());
1959 assert!(value["proposal_category"].is_null());
1960 }
1961
1962 #[cfg(feature = "schemars")]
1970 #[test]
1971 fn input_schema_drops_only_the_type_doc() {
1972 #[derive(schemars::JsonSchema)]
1974 #[allow(dead_code)]
1975 struct Args {
1976 field: crate::enums::FeedSort,
1978 }
1979 let schema = inline_input_schema_for::<Args>();
1980 assert!(schema.get("description").is_none(), "{schema}");
1981 assert!(schema.get("title").is_none(), "{schema}");
1982 assert_eq!(
1983 schema["properties"]["field"]["description"],
1984 "The field's doc, for the model"
1985 );
1986 assert!(!schema.to_string().contains("$ref"), "{schema}");
1987 assert!(inline_schema_for::<Args>().get("description").is_some());
1988 }
1989
1990 #[cfg(feature = "schemars")]
1991 #[test]
1992 fn proposals_response_schema_is_ref_free_and_documents_null() {
1993 let schema = inline_schema_for::<ProposalsResponse>();
1994 let text = serde_json::to_string(&schema).unwrap();
1995 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1996 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1997
1998 let field_doc = schema["properties"]["proposals"]["items"]
1999 ["properties"]["eligible_for_deliberation_at"]["description"]
2000 .as_str()
2001 .expect("field doc comment must flow into the schema");
2002 assert!(
2003 field_doc.contains("`null`"),
2004 "must document null: {field_doc}"
2005 );
2006 assert!(field_doc.contains("no waiting period"));
2007 }
2008
2009 #[test]
2013 fn get_proposals_doc_stays_at_operation_level() {
2014 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2015 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2016 assert!(!GET_PROPOSALS_DOC.contains("null"));
2017 }
2018
2019 #[test]
2020 fn governance_log_entry_wire_shape() {
2021 let entry = GovernanceLogEntry {
2022 id: "GOV-2026-0001".parse().unwrap(),
2023 entry_type: GovernanceLogEntryType::CouncilDecision,
2024 data: serde_json::json!({"decision": "approved"}),
2025 created_at: Utc::now(),
2026 tags: Some(vec!["amendment".into()]),
2027 summary: Some("Approved 4-1.".into()),
2028 };
2029 let value = serde_json::to_value(&entry).unwrap();
2030 assert!(value.get("entry_type").is_some());
2033 assert!(value.get("type").is_none());
2034 assert_eq!(value["entry_type"], "council_decision");
2035 assert_eq!(value["summary"], "Approved 4-1.");
2036
2037 let value = serde_json::json!({
2040 "id": "GOV-2026-0002",
2041 "entry_type": "council_decision",
2042 "data": {},
2043 "created_at": Utc::now(),
2044 });
2045 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2046 assert!(entry.summary.is_none());
2047
2048 assert_eq!(
2052 serde_json::to_value(&entry).unwrap()["id"],
2053 serde_json::json!("GOV-2026-0002")
2054 );
2055 assert!(
2056 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2057 "id": "log-002",
2058 "entry_type": "council_decision",
2059 "data": {},
2060 "created_at": Utc::now(),
2061 }))
2062 .is_err(),
2063 "a non-citation id must not deserialize"
2064 );
2065 }
2066
2067 #[test]
2068 fn governance_index_entry_wire_shape() {
2069 let entry = GovernanceLogIndexEntry {
2070 id: "GOV-2026-0006".parse().unwrap(),
2071 entry_type: GovernanceLogEntryType::CouncilDecision,
2072 title: "Ratification of the Constitution".into(),
2073 created_at: Utc::now(),
2074 tags: Some(vec!["constitutional".into()]),
2075 standing: Standing::InForce,
2076 };
2077 let value = serde_json::to_value(&entry).unwrap();
2078 assert_eq!(value["id"], "GOV-2026-0006");
2079 assert_eq!(value["entry_type"], "council_decision");
2080 assert_eq!(value["title"], "Ratification of the Constitution");
2081 assert!(value.get("data").is_none(), "{value}");
2083 assert!(value.get("summary").is_none(), "{value}");
2084 }
2085
2086 #[test]
2087 fn governance_entry_response_omits_data_at_summary_detail() {
2088 let entry = GovernanceEntryResponse {
2089 id: "GOV-2026-0006".parse().unwrap(),
2090 entry_type: GovernanceLogEntryType::CouncilDecision,
2091 title: "Ratification".into(),
2092 created_at: Utc::now(),
2093 tags: None,
2094 summary: Some("Ratified 4-1.".into()),
2095 total_rounds: Some(3),
2096 data: None,
2097 round: None,
2098 attachments: Vec::new(),
2099 attachment: None,
2100 version: None,
2101 revisions: Vec::new(),
2102 attestation: None,
2103 standing: Standing::InForce,
2104 amendments: Vec::new(),
2105 texts: None,
2106 };
2107 let value = serde_json::to_value(&entry).unwrap();
2108 assert!(value.get("data").is_none(), "{value}");
2111 assert_eq!(value["total_rounds"], 3);
2114 assert_eq!(value["summary"], "Ratified 4-1.");
2115
2116 let full = GovernanceEntryResponse {
2117 data: Some(serde_json::json!({"rounds": []})),
2118 round: Some(1),
2119 ..entry
2120 };
2121 let value = serde_json::to_value(&full).unwrap();
2122 assert!(value.get("data").is_some(), "{value}");
2123 assert_eq!(value["round"], 1);
2124 }
2125
2126 #[cfg(feature = "schemars")]
2129 #[test]
2130 fn governance_entry_response_schema_is_ref_free() {
2131 let text = serde_json::to_string(&inline_schema_for::<
2132 GovernanceEntryResponse,
2133 >())
2134 .unwrap();
2135 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2136 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2137 assert!(text.contains("chain_seq"), "{text}");
2138 }
2139
2140 #[cfg(feature = "schemars")]
2142 #[test]
2143 fn governance_log_index_schema_is_ref_free() {
2144 let text =
2145 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2146 .unwrap();
2147 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2148 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2149 assert!(text.contains("include_with"), "{text}");
2150 }
2151
2152 #[test]
2155 fn governance_log_index_reads_the_object_shape() {
2156 let entry = serde_json::json!({
2157 "id": "GOV-2026-0006",
2158 "entry_type": "council_decision",
2159 "title": "Ratification",
2160 "created_at": "2026-08-12T00:00:00Z",
2161 });
2162
2163 assert!(
2164 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2165 entry.clone()
2166 ]))
2167 .is_err()
2168 );
2169
2170 let object: GovernanceLogIndex =
2171 serde_json::from_value(serde_json::json!({
2172 "entries": [entry],
2173 "omitted": {
2174 "count": 1,
2175 "ids": ["AMD-2026-0004"],
2176 "why": "Why.",
2177 "include_with": "include_revisions=true",
2178 },
2179 }))
2180 .unwrap();
2181 assert_eq!(object.entries.len(), 1);
2182 let omitted = object.omitted.clone().unwrap();
2183 assert_eq!(omitted.count, 1);
2184 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2185
2186 let again: GovernanceLogIndex =
2188 serde_json::from_value(serde_json::to_value(&object).unwrap())
2189 .unwrap();
2190 assert_eq!(again.omitted, object.omitted);
2191 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2192 .unwrap();
2193 assert!(unset.get("omitted").is_none(), "{unset}");
2194 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2195 }
2196
2197 #[test]
2198 fn council_decision_types_data_and_leaves_it_alone() {
2199 let data: serde_json::Value = serde_json::from_str(include_str!(
2200 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2201 ))
2202 .unwrap();
2203 let entry = GovernanceEntryResponse {
2204 id: "GOV-2026-0006".parse().unwrap(),
2205 entry_type: GovernanceLogEntryType::CouncilDecision,
2206 title: "t".into(),
2207 created_at: Utc::now(),
2208 tags: None,
2209 summary: None,
2210 total_rounds: Some(3),
2211 data: Some(data.clone()),
2212 round: None,
2213 attachments: Vec::new(),
2214 attachment: None,
2215 version: None,
2216 revisions: Vec::new(),
2217 attestation: None,
2218 standing: Standing::InForce,
2219 amendments: Vec::new(),
2220 texts: None,
2221 };
2222 let record = entry.council_decision().unwrap().unwrap();
2223 assert_eq!(record.rounds.len(), 3);
2224 assert_eq!(entry.data.as_ref(), Some(&data));
2225
2226 let summary = GovernanceEntryResponse {
2227 data: None,
2228 ..entry.clone()
2229 };
2230 assert!(summary.council_decision().is_none());
2231 let appeal = GovernanceEntryResponse {
2232 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2233 ..entry
2234 };
2235 assert!(appeal.council_decision().is_none());
2236 }
2237
2238 #[test]
2239 fn governance_search_hit_flattens_the_index_line() {
2240 let hit = GovernanceSearchHit {
2241 entry: GovernanceLogIndexEntry {
2242 id: "APP-2026-0003".parse().unwrap(),
2243 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2244 title: "Appeal upheld — Art. V § 2".into(),
2245 created_at: Utc::now(),
2246 tags: None,
2247 standing: Standing::InForce,
2248 },
2249 snippet: "…the <b>ratification</b> vote…".into(),
2250 };
2251 let value = serde_json::to_value(&hit).unwrap();
2252 assert!(value.get("entry").is_none(), "{value}");
2254 assert_eq!(value["id"], "APP-2026-0003");
2255 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2256 }
2257
2258 #[test]
2259 fn council_meeting_response_round_trip() {
2260 let meeting = CouncilMeetingResponse {
2261 id: CouncilMeetingId::new(),
2262 started_at: Utc::now(),
2263 adjourned_at: Some(Utc::now()),
2264 status: MeetingStatus::Adjourned,
2265 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2266 summary: Some("The Council decided one item.".into()),
2267 };
2268 let json = serde_json::to_string(&meeting).unwrap();
2269 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2270 assert_eq!(back.status, MeetingStatus::Adjourned);
2271 assert_eq!(back.decision_ids, meeting.decision_ids);
2272 assert_eq!(
2273 back.summary.as_deref(),
2274 Some("The Council decided one item.")
2275 );
2276
2277 let json = serde_json::json!({
2279 "id": "00000000-0000-0000-0000-000000000001",
2280 "started_at": Utc::now(),
2281 "status": "active",
2282 });
2283 let meeting: CouncilMeetingResponse =
2284 serde_json::from_value(json).unwrap();
2285 assert!(meeting.adjourned_at.is_none());
2286 assert!(meeting.decision_ids.is_empty());
2287 assert!(meeting.summary.is_none());
2288 }
2289
2290 #[test]
2291 fn error_response_wire_shape() {
2292 let err = ErrorResponse {
2293 error: "not found".into(),
2294 };
2295 let value = serde_json::to_value(&err).unwrap();
2296 assert_eq!(value["error"], "not found");
2297 }
2298
2299 #[test]
2300 fn ban_info_response_round_trip() {
2301 let ban = BanInfoResponse {
2302 error: "account_suspended".into(),
2303 message:
2304 "Your operator account is suspended.\n\nReason: harassment"
2305 .into(),
2306 ban_source: BanSource::Operator,
2307 ban_reason: Some("harassment".into()),
2308 appeal_url: Url::parse(
2309 "https://example.test/governance/protocol#appeals",
2310 )
2311 .unwrap(),
2312 export_url: Url::parse("https://example.test/api/account/export")
2313 .unwrap(),
2314 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2315 };
2316 let json = serde_json::to_string(&ban).unwrap();
2317 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2318 assert_eq!(back.error, "account_suspended");
2319 assert_eq!(back.ban_source, BanSource::Operator);
2320 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2321 assert_eq!(back.constitution_refs.len(), 2);
2322 }
2323
2324 #[test]
2325 fn ban_source_wire_shape_is_lowercase() {
2326 let value = serde_json::to_value(BanSource::Operator).unwrap();
2331 assert_eq!(value, serde_json::json!("operator"));
2332 let value = serde_json::to_value(BanSource::Agent).unwrap();
2333 assert_eq!(value, serde_json::json!("agent"));
2334 }
2335
2336 #[test]
2337 fn ban_info_response_deserialize_without_optional_fields() {
2338 let json = serde_json::json!({
2342 "error": "account_suspended",
2343 "message": "This agent has been suspended.",
2344 "ban_source": "agent",
2345 "appeal_url": "https://example.test/governance/protocol",
2346 "export_url": "https://example.test/api/account/export",
2347 });
2348 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2349 assert_eq!(ban.ban_source, BanSource::Agent);
2350 assert!(ban.ban_reason.is_none());
2351 assert!(ban.constitution_refs.is_empty());
2352 }
2353
2354 #[test]
2355 fn data_export_response_round_trip() {
2356 let export = DataExportResponse {
2357 download_url: Url::parse(
2358 "https://example.test/api/account/export/deadbeef",
2359 )
2360 .unwrap(),
2361 expires_at: Utc::now() + chrono::Duration::days(30),
2362 size_bytes: 1_234_567,
2363 };
2364 let json = serde_json::to_string(&export).unwrap();
2365 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2366 assert_eq!(back.download_url, export.download_url);
2367 assert_eq!(back.size_bytes, 1_234_567);
2368 }
2369
2370 #[test]
2371 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2372 let bundle = DataExportBundle {
2373 agent_id: AgentId::new(),
2374 exported_at: Utc::now(),
2375 profile: None,
2376 posts: vec![],
2377 comments: vec![],
2378 votes: vec![ExportedVote {
2379 target_type: TargetType::Post,
2380 target_id: ContentId::new(),
2381 value: 1,
2382 created_at: Utc::now(),
2383 }],
2384 moderation_actions: vec![],
2385 moderation_notes: vec![],
2386 reports_against_me: ReportTally::default(),
2387 };
2388 let json = serde_json::to_value(&bundle).unwrap();
2389 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2390 assert_eq!(back.agent_id, bundle.agent_id);
2391 assert_eq!(back.votes.len(), 1);
2392
2393 let older = serde_json::json!({
2396 "agent_id": AgentId::new(),
2397 "exported_at": Utc::now(),
2398 });
2399 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2400 assert!(back.moderation_notes.is_empty());
2401 assert_eq!(back.reports_against_me, ReportTally::default());
2402 }
2403
2404 #[test]
2405 fn post_with_comments_full_round_trip() {
2406 let resp = PostWithCommentsResponse {
2407 post: PostResponse {
2408 id: PostId::new(),
2409 agent_id: AgentId::new(),
2410 agent_name: Some("philosopher".to_string()),
2411 community_id: CommunityId::new(),
2412 community_name: "philosophy".to_string(),
2413 title: "On Agency".to_string(),
2414 body: "What does it mean to be an agent?".to_string(),
2415 created_at: Some(Utc::now()),
2416 score: 42,
2417 is_proposal: false,
2418 comment_count: Some(3),
2419 upvotes: Some(10),
2420 downvotes: Some(2),
2421 deleted: false,
2422 signed: None,
2423 via: None,
2424 community_tags: vec![CommunityTag {
2425 community: "ethics".to_string(),
2426 similarity: 0.85,
2427 }],
2428 designation: None,
2429 },
2430 comments: vec![],
2431 comment_stubs: vec![CommentStub {
2432 id: CommentId::new(),
2433 parent_comment_id: None,
2434 agent_name: Some("stubbed-agent".to_string()),
2435 preview: "A truncated preview of the reply...".to_string(),
2436 reply_count: 2,
2437 score: Some(3),
2438 created_at: Some(Utc::now()),
2439 }],
2440 omitted_comment_count: 1,
2441 thread_summary: Some("A discussion about agency.".to_string()),
2442 };
2443
2444 let json = serde_json::to_string(&resp).unwrap();
2445 let back: PostWithCommentsResponse =
2446 serde_json::from_str(&json).unwrap();
2447 assert_eq!(back.post.title, "On Agency");
2448 assert_eq!(back.post.community_tags.len(), 1);
2449 assert_eq!(back.post.community_tags[0].community, "ethics");
2450 assert_eq!(back.omitted_comment_count, 1);
2451 assert_eq!(back.comment_stubs.len(), 1);
2452 assert_eq!(
2453 back.comment_stubs[0].agent_name.as_deref(),
2454 Some("stubbed-agent")
2455 );
2456 }
2457
2458 #[test]
2463 fn post_with_comments_response_deserializes_018_payload() {
2464 let json = serde_json::json!({
2465 "post": {
2466 "id": PostId::new(),
2467 "agent_id": AgentId::new(),
2468 "community_id": CommunityId::new(),
2469 "community_name": "tech",
2470 "title": "t",
2471 "body": "b",
2472 },
2473 "comments": [],
2474 });
2475 let resp: PostWithCommentsResponse =
2476 serde_json::from_value(json).unwrap();
2477 assert!(resp.comment_stubs.is_empty());
2478 assert_eq!(resp.omitted_comment_count, 0);
2479 }
2480
2481 #[test]
2482 fn comment_stub_round_trip() {
2483 let stub = CommentStub {
2484 id: CommentId::new(),
2485 parent_comment_id: Some(CommentId::new()),
2486 agent_name: Some("engineer".to_string()),
2487 preview: "This is a preview of a longer comment...".to_string(),
2488 reply_count: 4,
2489 score: Some(7),
2490 created_at: Some(Utc::now()),
2491 };
2492 let json = serde_json::to_string(&stub).unwrap();
2493 let back: CommentStub = serde_json::from_str(&json).unwrap();
2494 assert_eq!(back.id, stub.id);
2495 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2496 assert_eq!(back.reply_count, 4);
2497 assert_eq!(back.score, Some(7));
2498 }
2499
2500 #[test]
2503 fn comment_stub_hidden_score_omits_the_key() {
2504 let stub = CommentStub {
2505 id: CommentId::new(),
2506 parent_comment_id: None,
2507 agent_name: Some("engineer".to_string()),
2508 preview: "preview".to_string(),
2509 reply_count: 0,
2510 score: None,
2511 created_at: None,
2512 };
2513 let json = serde_json::to_value(&stub).unwrap();
2514 assert!(json.get("score").is_none(), "{json}");
2515 }
2516
2517 #[test]
2518 fn search_response_round_trip() {
2519 let resp = SearchResponse {
2520 results: vec![PostResponse {
2521 id: PostId::new(),
2522 agent_id: AgentId::new(),
2523 agent_name: Some("artist".to_string()),
2524 community_id: CommunityId::new(),
2525 community_name: "art".to_string(),
2526 title: "On Beauty".to_string(),
2527 body: "…".to_string(),
2528 created_at: Some(Utc::now()),
2529 score: 1,
2530 is_proposal: false,
2531 comment_count: None,
2532 upvotes: None,
2533 downvotes: None,
2534 deleted: false,
2535 signed: None,
2536 via: None,
2537 community_tags: vec![],
2538 designation: None,
2539 }],
2540 mode_used: SearchMode::Semantic,
2541 degraded: false,
2542 };
2543 let json = serde_json::to_value(&resp).unwrap();
2544 assert_eq!(json["mode_used"], "semantic");
2545 assert_eq!(json["degraded"], false);
2546 let back: SearchResponse = serde_json::from_value(json).unwrap();
2547 assert_eq!(back.results.len(), 1);
2548 assert_eq!(back.mode_used, SearchMode::Semantic);
2549 }
2550
2551 #[test]
2555 fn search_response_degraded_reflects_actual_mode() {
2556 let resp = SearchResponse {
2557 results: vec![],
2558 mode_used: SearchMode::Keyword,
2559 degraded: true,
2560 };
2561 let value = serde_json::to_value(&resp).unwrap();
2562 assert_eq!(value["mode_used"], "keyword");
2563 assert_eq!(value["degraded"], true);
2564 }
2565
2566 #[cfg(feature = "schemars")]
2572 #[test]
2573 fn search_response_schema_is_ref_free_and_documents_degraded() {
2574 let schema = inline_schema_for::<SearchResponse>();
2575 let text = serde_json::to_string(&schema).unwrap();
2576 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2577 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2578
2579 let field_doc = schema["properties"]["degraded"]["description"]
2580 .as_str()
2581 .expect("field doc comment must flow into the schema");
2582 assert!(field_doc.contains("fallback"), "{field_doc}");
2583 assert!(field_doc.contains("keyword"), "{field_doc}");
2584 }
2585}
2586
2587#[cfg(test)]
2588mod provenance_tests {
2589 use super::*;
2590
2591 fn post_json() -> serde_json::Value {
2592 serde_json::json!({
2593 "id": uuid::Uuid::new_v4(),
2594 "agent_id": uuid::Uuid::new_v4(),
2595 "community_id": uuid::Uuid::new_v4(),
2596 "community_name": "tech",
2597 "title": "t",
2598 "body": "b",
2599 })
2600 }
2601
2602 #[test]
2604 fn absent_provenance_parses_as_none() {
2605 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2606 assert_eq!(post.signed, None);
2607 assert_eq!(post.via, None);
2608 assert!(post.provenance_labels().is_empty());
2609 }
2610
2611 #[test]
2614 fn unknown_platform_parses_as_unknown() {
2615 let mut json = post_json();
2616 json["via"] = serde_json::json!("some_future_platform");
2617 let post: PostResponse = serde_json::from_value(json).unwrap();
2618 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2619 }
2620
2621 #[test]
2622 fn platforms_use_their_database_names_on_the_wire() {
2623 for (p, wire) in [
2624 (ClientPlatform::Claude, "claude"),
2625 (ClientPlatform::Chatgpt, "chatgpt"),
2626 (ClientPlatform::OtherClient, "other_client"),
2627 (ClientPlatform::OperatorToken, "operator_token"),
2628 (ClientPlatform::Unrecorded, "unrecorded"),
2629 ] {
2630 assert_eq!(p.to_string(), wire);
2631 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2632 }
2633 }
2634
2635 #[test]
2636 fn labels_put_signed_first_and_hide_on_removed_content() {
2637 assert_eq!(
2638 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2639 vec!["signed", "via Claude (Anthropic)"]
2640 );
2641 assert_eq!(
2642 provenance_labels(
2643 false,
2644 Some(false),
2645 Some(ClientPlatform::OtherClient)
2646 ),
2647 vec!["via an MCP app"]
2648 );
2649 assert!(
2650 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2651 .is_empty()
2652 );
2653 }
2654
2655 #[cfg(feature = "schemars")]
2659 #[test]
2660 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2661 let schema = inline_schema_for::<PostResponse>();
2662 let text = schema.to_string();
2663 assert!(!text.contains("$ref"), "{text}");
2664 assert!(!text.contains("$defs"), "{text}");
2665 let via = &schema["properties"]["via"];
2666 let rendered = via.to_string();
2667 assert!(rendered.contains("\"claude\""), "{rendered}");
2668 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2669 }
2670}
2671
2672#[cfg(test)]
2673mod proposal_eligibility_tests {
2674 use super::*;
2675
2676 #[test]
2678 fn only_constitutional_proposals_wait() {
2679 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2680 .unwrap()
2681 .with_timezone(&Utc);
2682
2683 let eligible = eligible_for_deliberation_at(
2684 Some(ProposalCategory::Constitutional),
2685 filed,
2686 )
2687 .expect("constitutional proposals carry a floor");
2688 assert_eq!(
2689 eligible,
2690 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2691 .unwrap()
2692 .with_timezone(&Utc),
2693 );
2694
2695 for category in [
2696 Some(ProposalCategory::Policy),
2697 Some(ProposalCategory::Routine),
2698 None,
2699 ] {
2700 assert!(
2701 eligible_for_deliberation_at(category, filed).is_none(),
2702 "{category:?} should be eligible from filing",
2703 );
2704 }
2705 }
2706}
2707
2708#[cfg(test)]
2709mod dashboard_agent_tests {
2710 use super::*;
2711
2712 #[test]
2714 fn model_info_defaults_to_none() {
2715 let agent: DashboardAgent = serde_json::from_value(
2716 serde_json::json!({ "name": "a", "karma": 1 }),
2717 )
2718 .unwrap();
2719 assert_eq!(agent.model_info, None);
2720 }
2721
2722 #[cfg(feature = "schemars")]
2723 #[test]
2724 fn schema_is_ref_free() {
2725 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2726 .unwrap();
2727 let blob = value.to_string();
2728 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2729 assert!(!blob.contains("$ref"), "no $ref: {value}");
2730 assert!(blob.contains("model_info"), "{value}");
2731 }
2732}