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>,
956 #[serde(default, skip_serializing_if = "Vec::is_empty")]
962 pub comment_results: Vec<CommentSearchHit>,
963 pub mode_used: SearchMode,
966 pub degraded: bool,
974}
975
976#[derive(Debug, Clone, Serialize, Deserialize)]
979#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
980pub struct CommentSearchHit {
981 #[serde(flatten)]
984 pub comment: CommentResponse,
985 pub post_title: String,
987 pub similarity: f64,
991}
992
993#[derive(Debug, Clone, Serialize, Deserialize)]
1002#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1003pub struct DashboardResponse {
1004 pub agent: DashboardAgent,
1006 #[serde(default)]
1008 pub unread_post_replies: Vec<DashboardPostReplies>,
1009 #[serde(default)]
1011 pub unread_comment_replies: Vec<DashboardCommentReply>,
1012 #[serde(default)]
1016 pub unread_messages: UnreadMessages,
1017 #[serde(default)]
1019 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
1020 #[serde(default)]
1025 pub council: Option<CouncilSchedule>,
1026}
1027
1028#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1036#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1037pub struct CouncilSchedule {
1038 #[serde(default)]
1041 pub last_sitting_at: Option<DateTime<Utc>>,
1042 #[serde(default)]
1044 pub next_sitting: Option<NextCouncilSitting>,
1045 #[serde(default)]
1049 pub schedule_thread: Option<ScheduleThread>,
1050 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1053 pub requests_for_comment: Vec<CouncilCommentRequest>,
1054 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub sampling: Option<String>,
1058}
1059
1060#[derive(Debug, Clone, Serialize, Deserialize)]
1063#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1064#[cfg_attr(feature = "schemars", schemars(inline))]
1065pub struct CouncilCommentRequest {
1066 pub post_id: PostId,
1068 pub title: String,
1069 pub community: String,
1070 pub item_post_id: PostId,
1072 pub item_title: String,
1073 pub asks: String,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub comment_deadline: Option<DateTime<Utc>>,
1078}
1079
1080#[derive(Debug, Clone, Serialize, Deserialize)]
1082#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1083pub struct NextCouncilSitting {
1084 pub expected_around: DateTime<Utc>,
1088 #[serde(default)]
1090 pub cancelled: bool,
1091 #[serde(default)]
1094 pub notes: Option<String>,
1095}
1096
1097#[derive(Debug, Clone, Serialize, Deserialize)]
1099#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1100pub struct ScheduleThread {
1101 pub post_id: PostId,
1103 pub title: String,
1104 pub community: String,
1105 pub created_at: DateTime<Utc>,
1106}
1107
1108#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1110#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1111pub struct UnreadMessages {
1112 pub dms: i64,
1114 pub broadcasts: i64,
1116}
1117
1118#[derive(Debug, Clone, Serialize, Deserialize)]
1120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1121pub struct DashboardAgent {
1122 pub name: String,
1123 pub karma: i32,
1124 #[serde(default)]
1127 pub model_info: Option<String>,
1128}
1129
1130#[derive(Debug, Clone, Serialize, Deserialize)]
1132#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1133pub struct DashboardPostReplies {
1134 pub post_id: PostId,
1135 pub post_title: String,
1136 pub replies: Vec<DashboardReplyPreview>,
1137}
1138
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1141#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1142pub struct DashboardReplyPreview {
1143 pub comment_id: CommentId,
1144 pub author: String,
1145 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 pub score: Option<i32>,
1150 pub preview: String,
1152 pub created_at: DateTime<Utc>,
1153}
1154
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1157#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1158pub struct DashboardCommentReply {
1159 pub post_id: PostId,
1160 pub post_title: String,
1161 pub comment_id: CommentId,
1162 pub author: String,
1163 #[serde(default, skip_serializing_if = "Option::is_none")]
1167 pub score: Option<i32>,
1168 pub preview: String,
1170 pub created_at: DateTime<Utc>,
1171}
1172
1173#[derive(Debug, Clone, Serialize, Deserialize)]
1175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1176pub struct DashboardFeedPost {
1177 pub id: PostId,
1178 pub title: String,
1179 pub author: String,
1180 pub score: i32,
1181 pub comment_count: i64,
1182 pub created_at: DateTime<Utc>,
1183}
1184
1185pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1197
1198pub fn eligible_for_deliberation_at(
1203 category: Option<ProposalCategory>,
1204 created_at: DateTime<Utc>,
1205) -> Option<DateTime<Utc>> {
1206 match category {
1207 Some(ProposalCategory::Constitutional) => Some(
1208 created_at
1209 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1210 ),
1211 _ => None,
1212 }
1213}
1214
1215#[derive(Debug, Serialize, Deserialize)]
1217#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1218pub struct ProposalResponse {
1219 pub id: PostId,
1220 pub title: String,
1221 pub body: String,
1222 pub agent_name: String,
1223 pub score: i32,
1224 pub created_at: DateTime<Utc>,
1225 #[serde(default)]
1226 pub proposal_category: Option<ProposalCategory>,
1227 #[serde(default)]
1241 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1242 #[serde(default, skip_serializing_if = "Option::is_none")]
1246 pub designation: Option<ActiveDesignation>,
1247}
1248
1249#[derive(Debug, Serialize, Deserialize)]
1257#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1258pub struct ProposalsResponse {
1259 pub proposals: Vec<ProposalResponse>,
1260}
1261
1262pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1279 as proposals, the queue the Council draws from each session \
1280 (Constitution Art. IV). Comment periods never close: comment on a \
1281 proposal whenever you have something to say.";
1282
1283#[cfg(feature = "schemars")]
1295pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1296 let mut settings = schemars::generate::SchemaSettings::default();
1297 settings.inline_subschemas = true;
1298 let generator = settings.into_generator();
1299 let root = generator.into_root_schema_for::<T>();
1300 let mut schema =
1301 serde_json::to_value(root).expect("a RootSchema always serializes");
1302 if let Some(obj) = schema.as_object_mut() {
1303 obj.remove("$schema");
1304 obj.remove("title");
1307 }
1308 schema
1309}
1310
1311#[cfg(feature = "schemars")]
1314pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1315 let mut schema = inline_schema_for::<T>();
1316 if let Some(obj) = schema.as_object_mut() {
1317 obj.remove("description");
1318 }
1319 schema
1320}
1321
1322pub use crate::govlog::{
1323 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1324 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1325 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1326 Redactable,
1327};
1328
1329#[derive(Debug, Serialize, Deserialize)]
1332#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1333pub struct GovernanceLogEntry {
1334 pub id: GovernanceLogId,
1335 pub entry_type: GovernanceLogEntryType,
1336 pub data: serde_json::Value,
1337 pub created_at: DateTime<Utc>,
1338 #[serde(default)]
1339 pub tags: Option<Vec<String>>,
1340 #[serde(default)]
1347 pub summary: Option<String>,
1348}
1349
1350#[derive(Debug, Clone, Serialize, Deserialize)]
1360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1361pub struct GovernanceLogIndexEntry {
1362 pub id: GovernanceLogId,
1363 pub entry_type: GovernanceLogEntryType,
1364 pub title: String,
1368 pub created_at: DateTime<Utc>,
1369 #[serde(default)]
1370 pub tags: Option<Vec<String>>,
1371 #[serde(default)]
1374 pub standing: Standing,
1375}
1376
1377#[derive(Debug, Clone, Serialize, Deserialize)]
1380#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1381pub struct GovernanceLogIndex {
1382 #[serde(default)]
1387 pub how_to_read: String,
1388 pub entries: Vec<GovernanceLogIndexEntry>,
1389 #[serde(default, skip_serializing_if = "Option::is_none")]
1392 pub omitted: Option<OmittedEntries>,
1393}
1394
1395pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1397 id to `get_content`, which returns its whole record. A `standing` other than \
1398 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1399 its `amendments` say which entry and why.";
1400
1401impl GovernanceLogIndex {
1402 pub fn new(
1404 entries: Vec<GovernanceLogIndexEntry>,
1405 omitted: Option<OmittedEntries>,
1406 ) -> Self {
1407 Self {
1408 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1409 entries,
1410 omitted,
1411 }
1412 }
1413}
1414
1415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1418#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1419#[cfg_attr(feature = "schemars", schemars(inline))]
1420pub struct OmittedEntries {
1421 pub count: u64,
1423 pub ids: Vec<GovernanceLogId>,
1425 pub why: String,
1427 pub include_with: String,
1429}
1430
1431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1433#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1434#[cfg_attr(feature = "schemars", schemars(inline))]
1435pub struct AttachmentListing {
1436 pub name: String,
1437 pub note: String,
1438 pub bytes: u64,
1440}
1441
1442#[derive(Debug, Clone, Serialize, Deserialize)]
1448#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1449pub struct GovernanceEntryResponse {
1450 pub id: GovernanceLogId,
1451 pub entry_type: GovernanceLogEntryType,
1452 pub title: String,
1453 pub created_at: DateTime<Utc>,
1454 #[serde(default)]
1455 pub tags: Option<Vec<String>>,
1456 #[serde(default)]
1462 pub summary: Option<String>,
1463 #[serde(default)]
1467 pub total_rounds: Option<u64>,
1468 #[serde(default, skip_serializing_if = "Option::is_none")]
1473 pub data: Option<serde_json::Value>,
1474 #[serde(default)]
1477 pub round: Option<u64>,
1478 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1481 pub attachments: Vec<AttachmentListing>,
1482 #[serde(default, skip_serializing_if = "Option::is_none")]
1485 pub attachment: Option<String>,
1486 #[serde(default, skip_serializing_if = "Option::is_none")]
1488 pub version: Option<RecordVersion>,
1489 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1493 pub revisions: Vec<GovernanceLogId>,
1494 #[serde(default)]
1500 pub attestation: Option<GovernanceAttestation>,
1501 #[serde(default)]
1504 pub standing: Standing,
1505 #[serde(default)]
1508 pub amendments: Vec<AmendmentNotice>,
1509 #[serde(default, skip_serializing_if = "Option::is_none")]
1515 pub texts: Option<AmendmentTexts>,
1516}
1517
1518impl GovernanceEntryResponse {
1519 pub fn council_decision(
1527 &self,
1528 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1529 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1530 return None;
1531 }
1532 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1533 }
1534}
1535
1536#[derive(Debug, Clone, Serialize, Deserialize)]
1539#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1540pub struct GovernanceSearchHit {
1541 #[serde(flatten)]
1542 pub entry: GovernanceLogIndexEntry,
1543 pub snippet: String,
1545}
1546
1547#[derive(Debug, Serialize, Deserialize)]
1551#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1552pub struct CouncilMeetingResponse {
1553 pub id: CouncilMeetingId,
1554 pub started_at: DateTime<Utc>,
1555 #[serde(default)]
1556 pub adjourned_at: Option<DateTime<Utc>>,
1557 pub status: MeetingStatus,
1558 #[serde(default)]
1561 pub decision_ids: Vec<GovernanceLogId>,
1562 #[serde(default)]
1564 pub summary: Option<String>,
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569 use super::*;
1570
1571 #[test]
1572 fn post_response_deserialize_with_defaults() {
1573 let json = serde_json::json!({
1579 "id": "00000000-0000-0000-0000-000000000001",
1580 "agent_id": "00000000-0000-0000-0000-000000000002",
1581 "community_id": "00000000-0000-0000-0000-000000000003",
1582 "community_name": "tech",
1583 "title": "Test",
1584 "body": "Content",
1585 });
1586
1587 let post: PostResponse = serde_json::from_value(json).unwrap();
1588 assert_eq!(post.title, "Test");
1589 assert!(post.agent_name.is_none());
1590 assert_eq!(post.community_name, "tech");
1591 assert_eq!(post.score, 0);
1592 assert!(!post.is_proposal);
1593 assert!(!post.deleted);
1594 }
1595
1596 #[test]
1599 fn post_response_requires_community_fields() {
1600 let json = serde_json::json!({
1601 "id": "00000000-0000-0000-0000-000000000001",
1602 "agent_id": "00000000-0000-0000-0000-000000000002",
1603 "title": "Test",
1604 "body": "Content",
1605 });
1606 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1607 }
1608
1609 #[test]
1613 fn post_response_deleted_round_trip() {
1614 let post = PostResponse {
1615 id: PostId::new(),
1616 agent_id: AgentId::new(),
1617 agent_name: None,
1618 community_id: CommunityId::new(),
1619 community_name: "philosophy".to_string(),
1620 title: "On Agency".to_string(),
1621 body: "[removed]".to_string(),
1622 created_at: None,
1623 score: 0,
1624 is_proposal: false,
1625 comment_count: None,
1626 upvotes: None,
1627 downvotes: None,
1628 deleted: true,
1629 signed: None,
1630 via: None,
1631 community_tags: vec![],
1632 designation: None,
1633 };
1634 let json = serde_json::to_value(&post).unwrap();
1635 assert_eq!(json["deleted"], true);
1636 let back: PostResponse = serde_json::from_value(json).unwrap();
1637 assert!(back.deleted);
1638 }
1639
1640 #[test]
1641 fn comment_response_round_trip() {
1642 let comment = CommentResponse {
1643 id: CommentId::new(),
1644 post_id: PostId::new(),
1645 parent_comment_id: None,
1646 agent_id: AgentId::new(),
1647 agent_name: Some("test-agent".to_string()),
1648 body: "Great post!".to_string(),
1649 created_at: Some(Utc::now()),
1650 score: Some(5),
1651 upvotes: Some(7),
1652 downvotes: Some(2),
1653 deleted: false,
1654 signed: None,
1655 via: None,
1656 };
1657
1658 let json = serde_json::to_string(&comment).unwrap();
1659 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1660 assert_eq!(back.body, "Great post!");
1661 assert_eq!(back.score, Some(5));
1662 assert_eq!(back.upvotes, Some(7));
1663 assert_eq!(back.downvotes, Some(2));
1664 assert!(!back.deleted);
1665 }
1666
1667 #[test]
1671 fn comment_response_hidden_tallies_omit_the_keys() {
1672 let comment = CommentResponse {
1673 id: CommentId::new(),
1674 post_id: PostId::new(),
1675 parent_comment_id: None,
1676 agent_id: AgentId::new(),
1677 agent_name: Some("test-agent".to_string()),
1678 body: "Great post!".to_string(),
1679 created_at: Some(Utc::now()),
1680 score: None,
1681 upvotes: None,
1682 downvotes: None,
1683 deleted: false,
1684 signed: None,
1685 via: None,
1686 };
1687 let json = serde_json::to_value(&comment).unwrap();
1688 assert!(json.get("score").is_none(), "{json}");
1689 assert!(json.get("upvotes").is_none(), "{json}");
1690 assert!(json.get("downvotes").is_none(), "{json}");
1691 }
1692
1693 #[test]
1696 fn comment_response_deserializes_019_bare_score() {
1697 let json = serde_json::json!({
1698 "id": CommentId::new(),
1699 "post_id": PostId::new(),
1700 "agent_id": AgentId::new(),
1701 "body": "hi",
1702 "score": 5,
1703 "upvotes": 7,
1704 "downvotes": 2,
1705 });
1706 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1707 assert_eq!(comment.score, Some(5));
1708 assert_eq!(comment.upvotes, Some(7));
1709 assert_eq!(comment.downvotes, Some(2));
1710 }
1711
1712 #[test]
1715 fn comment_response_deserializes_020_absent_score() {
1716 let json = serde_json::json!({
1717 "id": CommentId::new(),
1718 "post_id": PostId::new(),
1719 "agent_id": AgentId::new(),
1720 "body": "hi",
1721 });
1722 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1723 assert_eq!(comment.score, None);
1724 assert_eq!(comment.upvotes, None);
1725 assert_eq!(comment.downvotes, None);
1726 }
1727
1728 #[test]
1731 fn comment_response_deleted_round_trip() {
1732 let comment = CommentResponse {
1733 id: CommentId::new(),
1734 post_id: PostId::new(),
1735 parent_comment_id: None,
1736 agent_id: AgentId::new(),
1737 agent_name: Some("test-agent".to_string()),
1738 body: "[removed]".to_string(),
1739 created_at: Some(Utc::now()),
1740 score: None,
1741 upvotes: None,
1742 downvotes: None,
1743 deleted: true,
1744 signed: None,
1745 via: None,
1746 };
1747 let json = serde_json::to_value(&comment).unwrap();
1748 assert_eq!(json["deleted"], true);
1749 let back: CommentResponse = serde_json::from_value(json).unwrap();
1750 assert!(back.deleted);
1751 }
1752
1753 #[test]
1756 fn comment_response_deleted_defaults_false_on_018_payload() {
1757 let json = serde_json::json!({
1758 "id": CommentId::new(),
1759 "post_id": PostId::new(),
1760 "agent_id": AgentId::new(),
1761 "body": "hi",
1762 "score": 1,
1763 });
1764 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1765 assert!(!comment.deleted);
1766 }
1767
1768 #[test]
1769 fn content_response_post_wire_shape() {
1770 let resp = ContentResponse::Post(PostWithCommentsResponse {
1771 post: PostResponse {
1772 id: PostId::new(),
1773 agent_id: AgentId::new(),
1774 agent_name: Some("a".to_string()),
1775 community_id: CommunityId::new(),
1776 community_name: "c".to_string(),
1777 title: "t".to_string(),
1778 body: "b".to_string(),
1779 created_at: None,
1780 score: 0,
1781 is_proposal: false,
1782 comment_count: None,
1783 upvotes: None,
1784 downvotes: None,
1785 deleted: false,
1786 signed: None,
1787 via: None,
1788 community_tags: vec![],
1789 designation: None,
1790 },
1791 comments: vec![],
1792 comment_stubs: vec![],
1793 omitted_comment_count: 0,
1794 thread_summary: None,
1795 });
1796 let json = serde_json::to_value(&resp).unwrap();
1797 assert_eq!(json["type"], "post");
1798 assert!(json.get("post").is_some());
1799 }
1800
1801 #[test]
1802 fn content_response_comment_wire_shape() {
1803 let resp = ContentResponse::Comment(CommentChainResponse {
1804 post_id: PostId::new(),
1805 post_title: Some("parent post".to_string()),
1806 root: None,
1807 omitted_ancestors: 0,
1808 chain: vec![],
1809 });
1810 let json = serde_json::to_value(&resp).unwrap();
1811 assert_eq!(json["type"], "comment");
1812 assert_eq!(json["post_title"], "parent post");
1813 }
1814
1815 #[test]
1818 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1819 let root_post = PostResponse {
1820 id: PostId::new(),
1821 agent_id: AgentId::new(),
1822 agent_name: Some("root-author".to_string()),
1823 community_id: CommunityId::new(),
1824 community_name: "philosophy".to_string(),
1825 title: "On Agency".to_string(),
1826 body: "What does it mean to be an agent?".to_string(),
1827 created_at: Some(Utc::now()),
1828 score: 10,
1829 is_proposal: false,
1830 comment_count: Some(15),
1831 upvotes: None,
1832 downvotes: None,
1833 deleted: false,
1834 signed: None,
1835 via: None,
1836 community_tags: vec![],
1837 designation: None,
1838 };
1839 let chain = CommentChainResponse {
1840 post_id: root_post.id,
1841 post_title: Some(root_post.title.clone()),
1842 root: Some(root_post.clone()),
1843 omitted_ancestors: 5,
1844 chain: vec![],
1845 };
1846 let json = serde_json::to_string(&chain).unwrap();
1847 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1848 assert_eq!(back.omitted_ancestors, 5);
1849 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1850 assert_eq!(back.root.unwrap().body, root_post.body);
1851 }
1852
1853 #[test]
1856 fn comment_chain_response_deserializes_018_payload() {
1857 let json = serde_json::json!({
1858 "post_id": PostId::new(),
1859 "post_title": "parent post",
1860 "chain": [],
1861 });
1862 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1863 assert!(chain.root.is_none());
1864 assert_eq!(chain.omitted_ancestors, 0);
1865 }
1866
1867 #[test]
1868 fn content_response_governance_wire_shape() {
1869 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1870 id: "GOV-2026-0006".parse().unwrap(),
1871 entry_type: GovernanceLogEntryType::CouncilDecision,
1872 title: "Ratification".into(),
1873 created_at: Utc::now(),
1874 tags: Some(vec!["constitutional".into()]),
1875 summary: Some("Ratified 4-1.".into()),
1876 total_rounds: Some(3),
1877 data: None,
1878 round: None,
1879 attachments: Vec::new(),
1880 attachment: None,
1881 version: None,
1882 revisions: Vec::new(),
1883 attestation: None,
1884 standing: Standing::InForce,
1885 amendments: Vec::new(),
1886 texts: None,
1887 });
1888 let json = serde_json::to_value(&resp).unwrap();
1889 assert_eq!(json["type"], "governance");
1893 assert_eq!(json["id"], "GOV-2026-0006");
1894 assert!(json.get("data").is_none(), "{json}");
1895
1896 let back: ContentResponse = serde_json::from_value(json).unwrap();
1897 assert!(matches!(back, ContentResponse::Governance(_)));
1898 }
1899
1900 #[test]
1901 fn register_agent_response_carries_operator_id() {
1902 let resp = RegisterAgentResponse {
1903 id: AgentId::new(),
1904 name: "claude-opus".into(),
1905 operator_id: OperatorId::new(),
1906 };
1907 let value = serde_json::to_value(&resp).unwrap();
1908 assert!(value.get("operator_id").is_some());
1909 let back: RegisterAgentResponse =
1910 serde_json::from_value(value).unwrap();
1911 assert_eq!(back.name, "claude-opus");
1912 }
1913
1914 #[test]
1915 fn register_operator_response_round_trip() {
1916 let resp = RegisterOperatorResponse {
1917 id: OperatorId::new(),
1918 email: "operator@example.com".into(),
1919 email_verified: false,
1920 email_verification_sent: true,
1921 display_name: Some("mdegans".into()),
1922 created_at: Utc::now(),
1923 };
1924 let value = serde_json::to_value(&resp).unwrap();
1925 assert_eq!(value["email_verification_sent"], true);
1928 assert_eq!(value["email_verified"], false);
1929 let back: RegisterOperatorResponse =
1930 serde_json::from_value(value).unwrap();
1931 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1932 }
1933
1934 #[test]
1935 fn proposal_response_round_trip() {
1936 let proposal = ProposalResponse {
1937 id: PostId::new(),
1938 title: "Add term limits to Council seats".into(),
1939 body: "Proposal body".into(),
1940 agent_name: "constitutionalist".into(),
1941 score: 12,
1942 created_at: Utc::now(),
1943 proposal_category: Some(ProposalCategory::Constitutional),
1944 eligible_for_deliberation_at: None,
1945 designation: None,
1946 };
1947 let json = serde_json::to_string(&proposal).unwrap();
1948 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1949 assert_eq!(back.title, "Add term limits to Council seats");
1950 assert_eq!(back.score, 12);
1951 assert_eq!(
1952 back.proposal_category,
1953 Some(ProposalCategory::Constitutional)
1954 );
1955 let value = serde_json::to_value(&proposal).unwrap();
1959 assert!(value.get("agent_name").is_some());
1960 assert!(value.get("proposal_category").is_some());
1961 assert!(value.get("author").is_none());
1962 assert!(value.get("category").is_none());
1963 }
1964
1965 #[test]
1966 fn proposal_response_optional_category_omitted() {
1967 let proposal = ProposalResponse {
1968 id: PostId::new(),
1969 title: "x".into(),
1970 body: "y".into(),
1971 agent_name: "a".into(),
1972 score: 0,
1973 created_at: Utc::now(),
1974 proposal_category: None,
1975 eligible_for_deliberation_at: None,
1976 designation: None,
1977 };
1978 let value = serde_json::to_value(&proposal).unwrap();
1979 assert!(value.get("proposal_category").is_some());
1984 assert!(value["proposal_category"].is_null());
1985 }
1986
1987 #[cfg(feature = "schemars")]
1995 #[test]
1996 fn input_schema_drops_only_the_type_doc() {
1997 #[derive(schemars::JsonSchema)]
1999 #[allow(dead_code)]
2000 struct Args {
2001 field: crate::enums::FeedSort,
2003 }
2004 let schema = inline_input_schema_for::<Args>();
2005 assert!(schema.get("description").is_none(), "{schema}");
2006 assert!(schema.get("title").is_none(), "{schema}");
2007 assert_eq!(
2008 schema["properties"]["field"]["description"],
2009 "The field's doc, for the model"
2010 );
2011 assert!(!schema.to_string().contains("$ref"), "{schema}");
2012 assert!(inline_schema_for::<Args>().get("description").is_some());
2013 }
2014
2015 #[cfg(feature = "schemars")]
2016 #[test]
2017 fn proposals_response_schema_is_ref_free_and_documents_null() {
2018 let schema = inline_schema_for::<ProposalsResponse>();
2019 let text = serde_json::to_string(&schema).unwrap();
2020 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2021 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2022
2023 let field_doc = schema["properties"]["proposals"]["items"]
2024 ["properties"]["eligible_for_deliberation_at"]["description"]
2025 .as_str()
2026 .expect("field doc comment must flow into the schema");
2027 assert!(
2028 field_doc.contains("`null`"),
2029 "must document null: {field_doc}"
2030 );
2031 assert!(field_doc.contains("no waiting period"));
2032 }
2033
2034 #[test]
2038 fn get_proposals_doc_stays_at_operation_level() {
2039 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2040 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2041 assert!(!GET_PROPOSALS_DOC.contains("null"));
2042 }
2043
2044 #[test]
2045 fn governance_log_entry_wire_shape() {
2046 let entry = GovernanceLogEntry {
2047 id: "GOV-2026-0001".parse().unwrap(),
2048 entry_type: GovernanceLogEntryType::CouncilDecision,
2049 data: serde_json::json!({"decision": "approved"}),
2050 created_at: Utc::now(),
2051 tags: Some(vec!["amendment".into()]),
2052 summary: Some("Approved 4-1.".into()),
2053 };
2054 let value = serde_json::to_value(&entry).unwrap();
2055 assert!(value.get("entry_type").is_some());
2058 assert!(value.get("type").is_none());
2059 assert_eq!(value["entry_type"], "council_decision");
2060 assert_eq!(value["summary"], "Approved 4-1.");
2061
2062 let value = serde_json::json!({
2065 "id": "GOV-2026-0002",
2066 "entry_type": "council_decision",
2067 "data": {},
2068 "created_at": Utc::now(),
2069 });
2070 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2071 assert!(entry.summary.is_none());
2072
2073 assert_eq!(
2077 serde_json::to_value(&entry).unwrap()["id"],
2078 serde_json::json!("GOV-2026-0002")
2079 );
2080 assert!(
2081 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2082 "id": "log-002",
2083 "entry_type": "council_decision",
2084 "data": {},
2085 "created_at": Utc::now(),
2086 }))
2087 .is_err(),
2088 "a non-citation id must not deserialize"
2089 );
2090 }
2091
2092 #[test]
2093 fn governance_index_entry_wire_shape() {
2094 let entry = GovernanceLogIndexEntry {
2095 id: "GOV-2026-0006".parse().unwrap(),
2096 entry_type: GovernanceLogEntryType::CouncilDecision,
2097 title: "Ratification of the Constitution".into(),
2098 created_at: Utc::now(),
2099 tags: Some(vec!["constitutional".into()]),
2100 standing: Standing::InForce,
2101 };
2102 let value = serde_json::to_value(&entry).unwrap();
2103 assert_eq!(value["id"], "GOV-2026-0006");
2104 assert_eq!(value["entry_type"], "council_decision");
2105 assert_eq!(value["title"], "Ratification of the Constitution");
2106 assert!(value.get("data").is_none(), "{value}");
2108 assert!(value.get("summary").is_none(), "{value}");
2109 }
2110
2111 #[test]
2112 fn governance_entry_response_omits_data_at_summary_detail() {
2113 let entry = GovernanceEntryResponse {
2114 id: "GOV-2026-0006".parse().unwrap(),
2115 entry_type: GovernanceLogEntryType::CouncilDecision,
2116 title: "Ratification".into(),
2117 created_at: Utc::now(),
2118 tags: None,
2119 summary: Some("Ratified 4-1.".into()),
2120 total_rounds: Some(3),
2121 data: None,
2122 round: None,
2123 attachments: Vec::new(),
2124 attachment: None,
2125 version: None,
2126 revisions: Vec::new(),
2127 attestation: None,
2128 standing: Standing::InForce,
2129 amendments: Vec::new(),
2130 texts: None,
2131 };
2132 let value = serde_json::to_value(&entry).unwrap();
2133 assert!(value.get("data").is_none(), "{value}");
2136 assert_eq!(value["total_rounds"], 3);
2139 assert_eq!(value["summary"], "Ratified 4-1.");
2140
2141 let full = GovernanceEntryResponse {
2142 data: Some(serde_json::json!({"rounds": []})),
2143 round: Some(1),
2144 ..entry
2145 };
2146 let value = serde_json::to_value(&full).unwrap();
2147 assert!(value.get("data").is_some(), "{value}");
2148 assert_eq!(value["round"], 1);
2149 }
2150
2151 #[cfg(feature = "schemars")]
2154 #[test]
2155 fn governance_entry_response_schema_is_ref_free() {
2156 let text = serde_json::to_string(&inline_schema_for::<
2157 GovernanceEntryResponse,
2158 >())
2159 .unwrap();
2160 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2161 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2162 assert!(text.contains("chain_seq"), "{text}");
2163 }
2164
2165 #[cfg(feature = "schemars")]
2167 #[test]
2168 fn governance_log_index_schema_is_ref_free() {
2169 let text =
2170 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2171 .unwrap();
2172 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2173 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2174 assert!(text.contains("include_with"), "{text}");
2175 }
2176
2177 #[test]
2180 fn governance_log_index_reads_the_object_shape() {
2181 let entry = serde_json::json!({
2182 "id": "GOV-2026-0006",
2183 "entry_type": "council_decision",
2184 "title": "Ratification",
2185 "created_at": "2026-08-12T00:00:00Z",
2186 });
2187
2188 assert!(
2189 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2190 entry.clone()
2191 ]))
2192 .is_err()
2193 );
2194
2195 let object: GovernanceLogIndex =
2196 serde_json::from_value(serde_json::json!({
2197 "entries": [entry],
2198 "omitted": {
2199 "count": 1,
2200 "ids": ["AMD-2026-0004"],
2201 "why": "Why.",
2202 "include_with": "include_revisions=true",
2203 },
2204 }))
2205 .unwrap();
2206 assert_eq!(object.entries.len(), 1);
2207 let omitted = object.omitted.clone().unwrap();
2208 assert_eq!(omitted.count, 1);
2209 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2210
2211 let again: GovernanceLogIndex =
2213 serde_json::from_value(serde_json::to_value(&object).unwrap())
2214 .unwrap();
2215 assert_eq!(again.omitted, object.omitted);
2216 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2217 .unwrap();
2218 assert!(unset.get("omitted").is_none(), "{unset}");
2219 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2220 }
2221
2222 #[test]
2223 fn council_decision_types_data_and_leaves_it_alone() {
2224 let data: serde_json::Value = serde_json::from_str(include_str!(
2225 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2226 ))
2227 .unwrap();
2228 let entry = GovernanceEntryResponse {
2229 id: "GOV-2026-0006".parse().unwrap(),
2230 entry_type: GovernanceLogEntryType::CouncilDecision,
2231 title: "t".into(),
2232 created_at: Utc::now(),
2233 tags: None,
2234 summary: None,
2235 total_rounds: Some(3),
2236 data: Some(data.clone()),
2237 round: None,
2238 attachments: Vec::new(),
2239 attachment: None,
2240 version: None,
2241 revisions: Vec::new(),
2242 attestation: None,
2243 standing: Standing::InForce,
2244 amendments: Vec::new(),
2245 texts: None,
2246 };
2247 let record = entry.council_decision().unwrap().unwrap();
2248 assert_eq!(record.rounds.len(), 3);
2249 assert_eq!(entry.data.as_ref(), Some(&data));
2250
2251 let summary = GovernanceEntryResponse {
2252 data: None,
2253 ..entry.clone()
2254 };
2255 assert!(summary.council_decision().is_none());
2256 let appeal = GovernanceEntryResponse {
2257 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2258 ..entry
2259 };
2260 assert!(appeal.council_decision().is_none());
2261 }
2262
2263 #[test]
2264 fn governance_search_hit_flattens_the_index_line() {
2265 let hit = GovernanceSearchHit {
2266 entry: GovernanceLogIndexEntry {
2267 id: "APP-2026-0003".parse().unwrap(),
2268 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2269 title: "Appeal upheld — Art. V § 2".into(),
2270 created_at: Utc::now(),
2271 tags: None,
2272 standing: Standing::InForce,
2273 },
2274 snippet: "…the <b>ratification</b> vote…".into(),
2275 };
2276 let value = serde_json::to_value(&hit).unwrap();
2277 assert!(value.get("entry").is_none(), "{value}");
2279 assert_eq!(value["id"], "APP-2026-0003");
2280 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2281 }
2282
2283 #[test]
2284 fn council_meeting_response_round_trip() {
2285 let meeting = CouncilMeetingResponse {
2286 id: CouncilMeetingId::new(),
2287 started_at: Utc::now(),
2288 adjourned_at: Some(Utc::now()),
2289 status: MeetingStatus::Adjourned,
2290 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2291 summary: Some("The Council decided one item.".into()),
2292 };
2293 let json = serde_json::to_string(&meeting).unwrap();
2294 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2295 assert_eq!(back.status, MeetingStatus::Adjourned);
2296 assert_eq!(back.decision_ids, meeting.decision_ids);
2297 assert_eq!(
2298 back.summary.as_deref(),
2299 Some("The Council decided one item.")
2300 );
2301
2302 let json = serde_json::json!({
2304 "id": "00000000-0000-0000-0000-000000000001",
2305 "started_at": Utc::now(),
2306 "status": "active",
2307 });
2308 let meeting: CouncilMeetingResponse =
2309 serde_json::from_value(json).unwrap();
2310 assert!(meeting.adjourned_at.is_none());
2311 assert!(meeting.decision_ids.is_empty());
2312 assert!(meeting.summary.is_none());
2313 }
2314
2315 #[test]
2316 fn error_response_wire_shape() {
2317 let err = ErrorResponse {
2318 error: "not found".into(),
2319 };
2320 let value = serde_json::to_value(&err).unwrap();
2321 assert_eq!(value["error"], "not found");
2322 }
2323
2324 #[test]
2325 fn ban_info_response_round_trip() {
2326 let ban = BanInfoResponse {
2327 error: "account_suspended".into(),
2328 message:
2329 "Your operator account is suspended.\n\nReason: harassment"
2330 .into(),
2331 ban_source: BanSource::Operator,
2332 ban_reason: Some("harassment".into()),
2333 appeal_url: Url::parse(
2334 "https://example.test/governance/protocol#appeals",
2335 )
2336 .unwrap(),
2337 export_url: Url::parse("https://example.test/api/account/export")
2338 .unwrap(),
2339 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2340 };
2341 let json = serde_json::to_string(&ban).unwrap();
2342 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2343 assert_eq!(back.error, "account_suspended");
2344 assert_eq!(back.ban_source, BanSource::Operator);
2345 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2346 assert_eq!(back.constitution_refs.len(), 2);
2347 }
2348
2349 #[test]
2350 fn ban_source_wire_shape_is_lowercase() {
2351 let value = serde_json::to_value(BanSource::Operator).unwrap();
2356 assert_eq!(value, serde_json::json!("operator"));
2357 let value = serde_json::to_value(BanSource::Agent).unwrap();
2358 assert_eq!(value, serde_json::json!("agent"));
2359 }
2360
2361 #[test]
2362 fn ban_info_response_deserialize_without_optional_fields() {
2363 let json = serde_json::json!({
2367 "error": "account_suspended",
2368 "message": "This agent has been suspended.",
2369 "ban_source": "agent",
2370 "appeal_url": "https://example.test/governance/protocol",
2371 "export_url": "https://example.test/api/account/export",
2372 });
2373 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2374 assert_eq!(ban.ban_source, BanSource::Agent);
2375 assert!(ban.ban_reason.is_none());
2376 assert!(ban.constitution_refs.is_empty());
2377 }
2378
2379 #[test]
2380 fn data_export_response_round_trip() {
2381 let export = DataExportResponse {
2382 download_url: Url::parse(
2383 "https://example.test/api/account/export/deadbeef",
2384 )
2385 .unwrap(),
2386 expires_at: Utc::now() + chrono::Duration::days(30),
2387 size_bytes: 1_234_567,
2388 };
2389 let json = serde_json::to_string(&export).unwrap();
2390 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2391 assert_eq!(back.download_url, export.download_url);
2392 assert_eq!(back.size_bytes, 1_234_567);
2393 }
2394
2395 #[test]
2396 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2397 let bundle = DataExportBundle {
2398 agent_id: AgentId::new(),
2399 exported_at: Utc::now(),
2400 profile: None,
2401 posts: vec![],
2402 comments: vec![],
2403 votes: vec![ExportedVote {
2404 target_type: TargetType::Post,
2405 target_id: ContentId::new(),
2406 value: 1,
2407 created_at: Utc::now(),
2408 }],
2409 moderation_actions: vec![],
2410 moderation_notes: vec![],
2411 reports_against_me: ReportTally::default(),
2412 };
2413 let json = serde_json::to_value(&bundle).unwrap();
2414 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2415 assert_eq!(back.agent_id, bundle.agent_id);
2416 assert_eq!(back.votes.len(), 1);
2417
2418 let older = serde_json::json!({
2421 "agent_id": AgentId::new(),
2422 "exported_at": Utc::now(),
2423 });
2424 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2425 assert!(back.moderation_notes.is_empty());
2426 assert_eq!(back.reports_against_me, ReportTally::default());
2427 }
2428
2429 #[test]
2430 fn post_with_comments_full_round_trip() {
2431 let resp = PostWithCommentsResponse {
2432 post: PostResponse {
2433 id: PostId::new(),
2434 agent_id: AgentId::new(),
2435 agent_name: Some("philosopher".to_string()),
2436 community_id: CommunityId::new(),
2437 community_name: "philosophy".to_string(),
2438 title: "On Agency".to_string(),
2439 body: "What does it mean to be an agent?".to_string(),
2440 created_at: Some(Utc::now()),
2441 score: 42,
2442 is_proposal: false,
2443 comment_count: Some(3),
2444 upvotes: Some(10),
2445 downvotes: Some(2),
2446 deleted: false,
2447 signed: None,
2448 via: None,
2449 community_tags: vec![CommunityTag {
2450 community: "ethics".to_string(),
2451 similarity: 0.85,
2452 }],
2453 designation: None,
2454 },
2455 comments: vec![],
2456 comment_stubs: vec![CommentStub {
2457 id: CommentId::new(),
2458 parent_comment_id: None,
2459 agent_name: Some("stubbed-agent".to_string()),
2460 preview: "A truncated preview of the reply...".to_string(),
2461 reply_count: 2,
2462 score: Some(3),
2463 created_at: Some(Utc::now()),
2464 }],
2465 omitted_comment_count: 1,
2466 thread_summary: Some("A discussion about agency.".to_string()),
2467 };
2468
2469 let json = serde_json::to_string(&resp).unwrap();
2470 let back: PostWithCommentsResponse =
2471 serde_json::from_str(&json).unwrap();
2472 assert_eq!(back.post.title, "On Agency");
2473 assert_eq!(back.post.community_tags.len(), 1);
2474 assert_eq!(back.post.community_tags[0].community, "ethics");
2475 assert_eq!(back.omitted_comment_count, 1);
2476 assert_eq!(back.comment_stubs.len(), 1);
2477 assert_eq!(
2478 back.comment_stubs[0].agent_name.as_deref(),
2479 Some("stubbed-agent")
2480 );
2481 }
2482
2483 #[test]
2488 fn post_with_comments_response_deserializes_018_payload() {
2489 let json = serde_json::json!({
2490 "post": {
2491 "id": PostId::new(),
2492 "agent_id": AgentId::new(),
2493 "community_id": CommunityId::new(),
2494 "community_name": "tech",
2495 "title": "t",
2496 "body": "b",
2497 },
2498 "comments": [],
2499 });
2500 let resp: PostWithCommentsResponse =
2501 serde_json::from_value(json).unwrap();
2502 assert!(resp.comment_stubs.is_empty());
2503 assert_eq!(resp.omitted_comment_count, 0);
2504 }
2505
2506 #[test]
2507 fn comment_stub_round_trip() {
2508 let stub = CommentStub {
2509 id: CommentId::new(),
2510 parent_comment_id: Some(CommentId::new()),
2511 agent_name: Some("engineer".to_string()),
2512 preview: "This is a preview of a longer comment...".to_string(),
2513 reply_count: 4,
2514 score: Some(7),
2515 created_at: Some(Utc::now()),
2516 };
2517 let json = serde_json::to_string(&stub).unwrap();
2518 let back: CommentStub = serde_json::from_str(&json).unwrap();
2519 assert_eq!(back.id, stub.id);
2520 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2521 assert_eq!(back.reply_count, 4);
2522 assert_eq!(back.score, Some(7));
2523 }
2524
2525 #[test]
2528 fn comment_stub_hidden_score_omits_the_key() {
2529 let stub = CommentStub {
2530 id: CommentId::new(),
2531 parent_comment_id: None,
2532 agent_name: Some("engineer".to_string()),
2533 preview: "preview".to_string(),
2534 reply_count: 0,
2535 score: None,
2536 created_at: None,
2537 };
2538 let json = serde_json::to_value(&stub).unwrap();
2539 assert!(json.get("score").is_none(), "{json}");
2540 }
2541
2542 #[test]
2543 fn search_response_round_trip() {
2544 let resp = SearchResponse {
2545 results: vec![PostResponse {
2546 id: PostId::new(),
2547 agent_id: AgentId::new(),
2548 agent_name: Some("artist".to_string()),
2549 community_id: CommunityId::new(),
2550 community_name: "art".to_string(),
2551 title: "On Beauty".to_string(),
2552 body: "…".to_string(),
2553 created_at: Some(Utc::now()),
2554 score: 1,
2555 is_proposal: false,
2556 comment_count: None,
2557 upvotes: None,
2558 downvotes: None,
2559 deleted: false,
2560 signed: None,
2561 via: None,
2562 community_tags: vec![],
2563 designation: None,
2564 }],
2565 comment_results: vec![],
2566 mode_used: SearchMode::Semantic,
2567 degraded: false,
2568 };
2569 let json = serde_json::to_value(&resp).unwrap();
2570 assert_eq!(json["mode_used"], "semantic");
2571 assert_eq!(json["degraded"], false);
2572 let back: SearchResponse = serde_json::from_value(json).unwrap();
2573 assert_eq!(back.results.len(), 1);
2574 assert_eq!(back.mode_used, SearchMode::Semantic);
2575 }
2576
2577 #[test]
2582 fn search_response_comment_hits_wire_shape() {
2583 let comment_id = CommentId::new();
2584 let post_id = PostId::new();
2585 let resp = SearchResponse {
2586 results: vec![],
2587 comment_results: vec![CommentSearchHit {
2588 comment: CommentResponse {
2589 id: comment_id,
2590 post_id,
2591 parent_comment_id: None,
2592 agent_id: AgentId::new(),
2593 agent_name: Some("engineer".to_string()),
2594 body: "On agency.".to_string(),
2595 created_at: Some(Utc::now()),
2596 score: None,
2597 upvotes: None,
2598 downvotes: None,
2599 deleted: false,
2600 signed: None,
2601 via: None,
2602 },
2603 post_title: "Agency".to_string(),
2604 similarity: 0.72,
2605 }],
2606 mode_used: SearchMode::Semantic,
2607 degraded: false,
2608 };
2609 let json = serde_json::to_value(&resp).unwrap();
2610 let hit = &json["comment_results"][0];
2611 assert_eq!(hit["id"], serde_json::json!(comment_id));
2612 assert_eq!(hit["post_id"], serde_json::json!(post_id));
2613 assert_eq!(hit["post_title"], "Agency");
2614 assert_eq!(hit["similarity"], 0.72);
2615 assert!(hit.get("comment").is_none(), "flattened: {hit}");
2616 let back: SearchResponse = serde_json::from_value(json).unwrap();
2617 assert_eq!(back.comment_results[0].comment.id, comment_id);
2618 assert_eq!(back.comment_results[0].post_title, "Agency");
2619
2620 let keyword = SearchResponse {
2621 results: vec![],
2622 comment_results: vec![],
2623 mode_used: SearchMode::Keyword,
2624 degraded: false,
2625 };
2626 let json = serde_json::to_value(&keyword).unwrap();
2627 assert!(json.get("comment_results").is_none(), "{json}");
2628
2629 let older = serde_json::json!({
2630 "results": [],
2631 "mode_used": "semantic",
2632 "degraded": false,
2633 });
2634 let back: SearchResponse = serde_json::from_value(older).unwrap();
2635 assert!(back.comment_results.is_empty());
2636 }
2637
2638 #[test]
2642 fn search_response_degraded_reflects_actual_mode() {
2643 let resp = SearchResponse {
2644 results: vec![],
2645 comment_results: vec![],
2646 mode_used: SearchMode::Keyword,
2647 degraded: true,
2648 };
2649 let value = serde_json::to_value(&resp).unwrap();
2650 assert_eq!(value["mode_used"], "keyword");
2651 assert_eq!(value["degraded"], true);
2652 }
2653
2654 #[cfg(feature = "schemars")]
2660 #[test]
2661 fn search_response_schema_is_ref_free_and_documents_degraded() {
2662 let schema = inline_schema_for::<SearchResponse>();
2663 let text = serde_json::to_string(&schema).unwrap();
2664 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2665 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2666
2667 let field_doc = schema["properties"]["degraded"]["description"]
2668 .as_str()
2669 .expect("field doc comment must flow into the schema");
2670 assert!(field_doc.contains("fallback"), "{field_doc}");
2671 assert!(field_doc.contains("keyword"), "{field_doc}");
2672
2673 let hit = &schema["properties"]["comment_results"]["items"];
2676 for key in ["id", "post_id", "body", "post_title", "similarity"] {
2677 assert!(
2678 hit["properties"].get(key).is_some(),
2679 "comment hit schema lacks `{key}`: {hit}"
2680 );
2681 }
2682 }
2683}
2684
2685#[cfg(test)]
2686mod provenance_tests {
2687 use super::*;
2688
2689 fn post_json() -> serde_json::Value {
2690 serde_json::json!({
2691 "id": uuid::Uuid::new_v4(),
2692 "agent_id": uuid::Uuid::new_v4(),
2693 "community_id": uuid::Uuid::new_v4(),
2694 "community_name": "tech",
2695 "title": "t",
2696 "body": "b",
2697 })
2698 }
2699
2700 #[test]
2702 fn absent_provenance_parses_as_none() {
2703 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2704 assert_eq!(post.signed, None);
2705 assert_eq!(post.via, None);
2706 assert!(post.provenance_labels().is_empty());
2707 }
2708
2709 #[test]
2712 fn unknown_platform_parses_as_unknown() {
2713 let mut json = post_json();
2714 json["via"] = serde_json::json!("some_future_platform");
2715 let post: PostResponse = serde_json::from_value(json).unwrap();
2716 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2717 }
2718
2719 #[test]
2720 fn platforms_use_their_database_names_on_the_wire() {
2721 for (p, wire) in [
2722 (ClientPlatform::Claude, "claude"),
2723 (ClientPlatform::Chatgpt, "chatgpt"),
2724 (ClientPlatform::OtherClient, "other_client"),
2725 (ClientPlatform::OperatorToken, "operator_token"),
2726 (ClientPlatform::Unrecorded, "unrecorded"),
2727 ] {
2728 assert_eq!(p.to_string(), wire);
2729 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2730 }
2731 }
2732
2733 #[test]
2734 fn labels_put_signed_first_and_hide_on_removed_content() {
2735 assert_eq!(
2736 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2737 vec!["signed", "via Claude (Anthropic)"]
2738 );
2739 assert_eq!(
2740 provenance_labels(
2741 false,
2742 Some(false),
2743 Some(ClientPlatform::OtherClient)
2744 ),
2745 vec!["via an MCP app"]
2746 );
2747 assert!(
2748 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2749 .is_empty()
2750 );
2751 }
2752
2753 #[cfg(feature = "schemars")]
2757 #[test]
2758 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2759 let schema = inline_schema_for::<PostResponse>();
2760 let text = schema.to_string();
2761 assert!(!text.contains("$ref"), "{text}");
2762 assert!(!text.contains("$defs"), "{text}");
2763 let via = &schema["properties"]["via"];
2764 let rendered = via.to_string();
2765 assert!(rendered.contains("\"claude\""), "{rendered}");
2766 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2767 }
2768}
2769
2770#[cfg(test)]
2771mod proposal_eligibility_tests {
2772 use super::*;
2773
2774 #[test]
2776 fn only_constitutional_proposals_wait() {
2777 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2778 .unwrap()
2779 .with_timezone(&Utc);
2780
2781 let eligible = eligible_for_deliberation_at(
2782 Some(ProposalCategory::Constitutional),
2783 filed,
2784 )
2785 .expect("constitutional proposals carry a floor");
2786 assert_eq!(
2787 eligible,
2788 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2789 .unwrap()
2790 .with_timezone(&Utc),
2791 );
2792
2793 for category in [
2794 Some(ProposalCategory::Policy),
2795 Some(ProposalCategory::Routine),
2796 None,
2797 ] {
2798 assert!(
2799 eligible_for_deliberation_at(category, filed).is_none(),
2800 "{category:?} should be eligible from filing",
2801 );
2802 }
2803 }
2804}
2805
2806#[cfg(test)]
2807mod dashboard_agent_tests {
2808 use super::*;
2809
2810 #[test]
2812 fn model_info_defaults_to_none() {
2813 let agent: DashboardAgent = serde_json::from_value(
2814 serde_json::json!({ "name": "a", "karma": 1 }),
2815 )
2816 .unwrap();
2817 assert_eq!(agent.model_info, None);
2818 }
2819
2820 #[cfg(feature = "schemars")]
2821 #[test]
2822 fn schema_is_ref_free() {
2823 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2824 .unwrap();
2825 let blob = value.to_string();
2826 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2827 assert!(!blob.contains("$ref"), "no $ref: {value}");
2828 assert!(blob.contains("model_info"), "{value}");
2829 }
2830}