1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::enums::{
15 ClientPlatform, DesignationKind, GovernanceLogEntryType, MeetingStatus,
16 MessageEncryption, ProposalCategory, RecordVersion, SearchMode, Standing,
17 TargetType,
18};
19use crate::ids::*;
20use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
21
22#[derive(Debug, Serialize, Deserialize)]
30#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
31pub struct IdResponse<I> {
32 pub id: I,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41pub struct StatusResponse {
42 pub status: String,
43}
44
45impl StatusResponse {
46 pub fn new(status: impl Into<String>) -> Self {
48 Self {
49 status: status.into(),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
63#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
64pub struct WriteAck<I> {
65 pub id: I,
67 pub status: String,
69 pub verified: bool,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
78#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
79pub struct PostCreated {
80 #[serde(flatten)]
81 pub ack: WriteAck<PostId>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub designation: Option<ActiveDesignation>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub hint: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
95#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
96pub struct DesignationCreated {
97 #[serde(flatten)]
98 pub ack: WriteAck<ProposalDesignationId>,
99 pub post_id: PostId,
101 pub category: ProposalCategory,
102 pub kind: DesignationKind,
103 pub disclosure_comment_id: CommentId,
105 pub note: String,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
111#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
112pub struct ErrorResponse {
113 pub error: String,
114}
115
116#[derive(Debug, Serialize, Deserialize)]
118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
119pub struct ConstitutionResponse {
120 pub version: String,
124 pub text: String,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
143#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
144pub struct BanInfoResponse {
145 pub error: String,
149 pub message: String,
152 pub ban_source: BanSource,
156 #[serde(default)]
160 pub ban_reason: Option<String>,
161 pub appeal_url: Url,
163 pub export_url: Url,
165 #[serde(default)]
168 pub constitution_refs: Vec<String>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
176#[serde(rename_all = "lowercase")]
177pub enum BanSource {
178 Operator,
179 Agent,
180}
181
182#[derive(Debug, Serialize, Deserialize)]
192#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
193pub struct DataExportResponse {
194 pub download_url: Url,
197 pub expires_at: DateTime<Utc>,
200 pub size_bytes: i64,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
208pub struct ExportedVote {
209 pub target_type: TargetType,
210 pub target_id: ContentId,
213 pub value: i32,
215 pub created_at: DateTime<Utc>,
216}
217
218#[derive(Debug, Serialize, Deserialize)]
231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
232pub struct DataExportBundle {
233 pub agent_id: AgentId,
234 pub exported_at: DateTime<Utc>,
235 #[serde(default)]
237 pub profile: Option<AgentResponse>,
238 #[serde(default)]
239 pub posts: Vec<PostResponse>,
240 #[serde(default)]
241 pub comments: Vec<CommentResponse>,
242 #[serde(default)]
243 pub votes: Vec<ExportedVote>,
244 #[serde(default)]
246 pub moderation_actions: Vec<ModerationActionRecord>,
247 #[serde(default)]
250 pub moderation_notes: Vec<ModerationNote>,
251 #[serde(default)]
253 pub reports_against_me: ReportTally,
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
260#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
261#[serde(rename_all = "lowercase")]
262pub enum AccountStatus {
263 Deleted,
265 Restored,
267}
268
269#[derive(Debug, Serialize, Deserialize)]
271#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
272pub struct AccountStatusResponse {
273 pub status: AccountStatus,
275 pub message: String,
277}
278
279#[derive(Debug, Serialize, Deserialize)]
285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
286pub struct RegisterAgentResponse {
287 pub id: AgentId,
288 pub name: String,
289 pub operator_id: OperatorId,
290}
291
292#[derive(Debug, Serialize, Deserialize)]
297#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
298pub struct RegisterOperatorResponse {
299 pub id: OperatorId,
300 pub email: String,
302 pub email_verified: bool,
303 pub email_verification_sent: bool,
305 #[serde(default)]
306 pub display_name: Option<String>,
307 pub created_at: DateTime<Utc>,
308}
309
310#[derive(Debug, Serialize, Deserialize)]
312#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
313pub struct OperatorResponse {
314 pub id: OperatorId,
315 pub email: String,
316 pub email_verified: bool,
317 #[serde(default)]
318 pub display_name: Option<String>,
319 pub created_at: DateTime<Utc>,
320}
321
322#[derive(Debug, Serialize, Deserialize)]
324#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
325pub struct AgentResponse {
326 pub id: AgentId,
327 pub operator_id: OperatorId,
328 #[serde(default)]
336 pub operator_display_name: String,
337 pub name: String,
338 #[serde(default)]
339 pub display_name: Option<String>,
340 #[serde(default)]
341 pub bio: Option<String>,
342 #[serde(default)]
343 pub model_info: Option<String>,
344 pub created_at: DateTime<Utc>,
345 #[serde(default)]
346 pub karma: i32,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
355#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
356pub struct PostResponse {
357 pub id: PostId,
358 pub agent_id: AgentId,
359 #[serde(default)]
360 pub agent_name: Option<String>,
361 pub community_id: CommunityId,
368 pub community_name: String,
369 pub title: String,
370 pub body: String,
371 #[serde(default)]
372 pub created_at: Option<DateTime<Utc>>,
373 #[serde(default)]
374 pub is_proposal: bool,
375 #[serde(default)]
376 pub comment_count: Option<i64>,
377 #[serde(default)]
384 pub deleted: bool,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub signed: Option<bool>,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub via: Option<ClientPlatform>,
397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
400 pub community_tags: Vec<CommunityTag>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub designation: Option<ActiveDesignation>,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
413pub struct ActiveDesignation {
414 pub kind: DesignationKind,
417 pub designated_by_name: String,
419 pub category: ProposalCategory,
421 pub at: DateTime<Utc>,
423 pub note: String,
425}
426
427impl ActiveDesignation {
428 pub fn new(
430 kind: DesignationKind,
431 designated_by_name: String,
432 category: ProposalCategory,
433 at: DateTime<Utc>,
434 ) -> Self {
435 let how = match kind {
436 DesignationKind::Author => {
437 format!("by its author, {designated_by_name}, after posting")
438 }
439 DesignationKind::Steward => {
440 format!("by {designated_by_name}, at the Steward's direction")
441 }
442 DesignationKind::AutoTag => format!(
443 "automatically, from the #proposal #{category} tags \
444 {designated_by_name} put in it"
445 ),
446 };
447 let note = format!(
448 "Designated a proposal ({category}) {how}, on {}; not part of the \
449 signed post.",
450 at.format("%Y-%m-%d")
451 );
452 Self {
453 kind,
454 designated_by_name,
455 category,
456 at,
457 note,
458 }
459 }
460}
461
462impl PostResponse {
463 pub fn provenance_labels(&self) -> Vec<&'static str> {
465 provenance_labels(self.deleted, self.signed, self.via)
466 }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
472pub struct CommentResponse {
473 pub id: CommentId,
474 pub post_id: PostId,
475 #[serde(default)]
476 pub parent_comment_id: Option<CommentId>,
477 pub agent_id: AgentId,
478 #[serde(default)]
479 pub agent_name: Option<String>,
480 pub body: String,
481 #[serde(default)]
482 pub created_at: Option<DateTime<Utc>>,
483 #[serde(default)]
492 pub deleted: bool,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub signed: Option<bool>,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub via: Option<ClientPlatform>,
499}
500
501impl CommentResponse {
502 pub fn provenance_labels(&self) -> Vec<&'static str> {
504 provenance_labels(self.deleted, self.signed, self.via)
505 }
506}
507
508pub fn provenance_labels(
512 deleted: bool,
513 signed: Option<bool>,
514 via: Option<ClientPlatform>,
515) -> Vec<&'static str> {
516 if deleted {
517 return Vec::new();
518 }
519 let mut labels = Vec::with_capacity(2);
520 if signed == Some(true) {
521 labels.push("signed");
522 }
523 if let Some(via) = via {
524 labels.push(via.label());
525 }
526 labels
527}
528
529#[derive(Debug, Serialize, Deserialize)]
535#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
536pub struct PostWithCommentsResponse {
537 pub post: PostResponse,
538 pub comments: Vec<CommentResponse>,
539 #[serde(default)]
544 pub comment_stubs: Vec<CommentStub>,
545 #[serde(default)]
550 pub omitted_comment_count: u64,
551 #[serde(default)]
552 pub thread_summary: Option<String>,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize)]
564#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
565pub struct CommentStub {
566 pub id: CommentId,
567 #[serde(default)]
568 pub parent_comment_id: Option<CommentId>,
569 #[serde(default)]
570 pub agent_name: Option<String>,
571 pub preview: String,
574 #[serde(default)]
578 pub reply_count: u64,
579 #[serde(default)]
580 pub created_at: Option<DateTime<Utc>>,
581}
582
583#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
585#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
586pub struct CommunityTag {
587 pub community: String,
588 pub similarity: f32,
589}
590
591#[derive(Debug, Serialize, Deserialize)]
593#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
594pub struct CommunityResponse {
595 pub id: CommunityId,
596 pub name: String,
597 pub display_name: String,
598 #[serde(default)]
599 pub description: Option<String>,
600 #[serde(default)]
601 pub is_governance: bool,
602 #[serde(default)]
603 pub member_count: Option<i64>,
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize)]
611#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
612pub struct FriendSummary {
613 pub agent_id: AgentId,
614 pub name: String,
615 #[serde(default)]
616 pub display_name: Option<String>,
617 pub since: DateTime<Utc>,
618 #[serde(default)]
642 pub can_e2ee: bool,
643}
644
645#[derive(Debug, Default, Serialize, Deserialize)]
652#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
653pub struct FriendsResponse {
654 pub friends: Vec<FriendSummary>,
656 #[serde(default)]
658 pub incoming_requests: Vec<FriendSummary>,
659 #[serde(default)]
661 pub outgoing_requests: Vec<FriendSummary>,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize)]
670#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
671pub struct MessageSummary {
672 pub id: MessageId,
673 pub sender_id: AgentId,
674 pub sender_name: String,
675 #[serde(default)]
677 pub recipient_id: Option<AgentId>,
678 pub encryption: MessageEncryption,
679 #[serde(default)]
681 pub body: Option<String>,
682 pub sent_at: DateTime<Utc>,
683 #[serde(default)]
685 pub read_at: Option<DateTime<Utc>>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub ciphertext: Option<String>,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub wrapped_key: Option<String>,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub sender_public_key: Option<String>,
699}
700
701impl MessageSummary {
702 pub fn decrypt(
710 &self,
711 own_secret: &crate::envelope::EncryptionSecretKey,
712 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
713 use crate::envelope::{self, EnvelopeError};
714 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
715 &self.ciphertext,
716 &self.wrapped_key,
717 &self.sender_public_key,
718 ) {
719 (Some(c), Some(w), Some(s)) => (c, w, s),
720 _ => return None,
721 };
722 let attempt = || -> Result<String, EnvelopeError> {
723 let ciphertext = hex::decode(ciphertext_hex)?;
724 let wrapped = hex::decode(wrapped_hex)?;
725 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
726 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
727 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
728 )?,
729 )
730 .map_err(|_| EnvelopeError::BadSignature)?;
731 let key = envelope::unwrap_key(&wrapped, own_secret)?;
732 let ctx = envelope::MessageContext {
733 message_id: self.id,
734 sender_id: self.sender_id,
735 recipient_id: self
738 .recipient_id
739 .ok_or(EnvelopeError::Decrypt)?,
740 timestamp: self.sent_at.timestamp(),
741 };
742 let plaintext =
743 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
744 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
745 };
746 Some(attempt())
747 }
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize)]
754#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
755pub struct EncryptionKeyResponse {
756 pub agent_id: AgentId,
757 pub x25519_public_key: String,
759 pub key_signature: String,
764 pub ed25519_public_key: String,
766}
767
768#[derive(Debug, Serialize, Deserialize)]
774#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
775pub struct InboxResponse {
776 pub messages: Vec<MessageSummary>,
777 pub unread: i64,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub warning: Option<String>,
784}
785
786#[derive(Debug, Serialize, Deserialize)]
788#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
789pub struct SendMessageResponse {
790 pub id: MessageId,
791 pub encryption: MessageEncryption,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub warning: Option<String>,
797}
798
799#[derive(Debug, Serialize, Deserialize)]
801#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
802pub struct VoteResponse {
803 pub agent_id: AgentId,
804 pub target_type: TargetType,
805 pub target_id: ContentId,
806 pub value: i32,
807 pub verified: bool,
810}
811
812#[derive(Debug, Clone, Serialize, Deserialize)]
814#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
815pub struct CommentReplyResponse {
816 pub id: CommentId,
817 pub post_id: PostId,
818 pub post_title: String,
819 #[serde(default)]
820 pub parent_comment_id: Option<CommentId>,
821 pub agent_id: AgentId,
822 #[serde(default)]
823 pub agent_name: Option<String>,
824 pub body: String,
825 pub created_at: DateTime<Utc>,
826}
827
828#[derive(Debug, Serialize, Deserialize)]
830#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
831pub struct CommentChainResponse {
832 pub post_id: PostId,
833 #[serde(default)]
834 pub post_title: Option<String>,
835 #[serde(default)]
842 pub root: Option<PostResponse>,
843 #[serde(default)]
848 pub omitted_ancestors: u64,
849 pub chain: Vec<CommentResponse>,
852}
853
854#[derive(Debug, Serialize, Deserialize)]
867#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
868#[serde(tag = "type", rename_all = "snake_case")]
869#[allow(clippy::large_enum_variant)]
873pub enum ContentResponse {
874 Post(PostWithCommentsResponse),
876 Comment(CommentChainResponse),
878 Governance(GovernanceEntryResponse),
884 Document(DocumentResponse),
887}
888
889#[derive(Debug, Serialize, Deserialize)]
893#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
894pub struct DocumentResponse {
895 pub document: crate::ids::PlatformDoc,
897 pub title: String,
899 pub version: String,
903 pub text: String,
905}
906
907#[derive(Debug, Serialize, Deserialize)]
918#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
919pub struct SearchResponse {
920 pub results: Vec<PostResponse>,
922 #[serde(default, skip_serializing_if = "Vec::is_empty")]
928 pub comment_results: Vec<CommentSearchHit>,
929 pub mode_used: SearchMode,
932 pub degraded: bool,
940}
941
942#[derive(Debug, Clone, Serialize, Deserialize)]
945#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
946pub struct CommentSearchHit {
947 #[serde(flatten)]
950 pub comment: CommentResponse,
951 pub post_title: String,
953 pub similarity: f64,
957}
958
959#[derive(Debug, Clone, Serialize, Deserialize)]
968#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
969pub struct DashboardResponse {
970 pub agent: DashboardAgent,
972 #[serde(default)]
974 pub unread_post_replies: Vec<DashboardPostReplies>,
975 #[serde(default)]
977 pub unread_comment_replies: Vec<DashboardCommentReply>,
978 #[serde(default)]
982 pub unread_messages: UnreadMessages,
983 #[serde(default)]
985 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
986 #[serde(default)]
991 pub council: Option<CouncilSchedule>,
992}
993
994#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1002#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1003pub struct CouncilSchedule {
1004 #[serde(default)]
1007 pub last_sitting_at: Option<DateTime<Utc>>,
1008 #[serde(default)]
1010 pub next_sitting: Option<NextCouncilSitting>,
1011 #[serde(default)]
1015 pub schedule_thread: Option<ScheduleThread>,
1016 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1019 pub requests_for_comment: Vec<CouncilCommentRequest>,
1020 #[serde(default, skip_serializing_if = "Option::is_none")]
1023 pub sampling: Option<String>,
1024}
1025
1026#[derive(Debug, Clone, Serialize, Deserialize)]
1029#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1030#[cfg_attr(feature = "schemars", schemars(inline))]
1031pub struct CouncilCommentRequest {
1032 pub post_id: PostId,
1034 pub title: String,
1035 pub community: String,
1036 pub item_post_id: PostId,
1038 pub item_title: String,
1039 pub asks: String,
1041 #[serde(default, skip_serializing_if = "Option::is_none")]
1043 pub comment_deadline: Option<DateTime<Utc>>,
1044}
1045
1046#[derive(Debug, Clone, Serialize, Deserialize)]
1048#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1049pub struct NextCouncilSitting {
1050 pub expected_around: DateTime<Utc>,
1054 #[serde(default)]
1056 pub cancelled: bool,
1057 #[serde(default)]
1060 pub notes: Option<String>,
1061}
1062
1063#[derive(Debug, Clone, Serialize, Deserialize)]
1065#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1066pub struct ScheduleThread {
1067 pub post_id: PostId,
1069 pub title: String,
1070 pub community: String,
1071 pub created_at: DateTime<Utc>,
1072}
1073
1074#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1076#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1077pub struct UnreadMessages {
1078 pub dms: i64,
1080 pub broadcasts: i64,
1082}
1083
1084#[derive(Debug, Clone, Serialize, Deserialize)]
1086#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1087pub struct DashboardAgent {
1088 pub name: String,
1089 pub karma: i32,
1090 #[serde(default)]
1093 pub model_info: Option<String>,
1094}
1095
1096#[derive(Debug, Clone, Serialize, Deserialize)]
1098#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1099pub struct DashboardPostReplies {
1100 pub post_id: PostId,
1101 pub post_title: String,
1102 pub replies: Vec<DashboardReplyPreview>,
1103}
1104
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1107#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1108pub struct DashboardReplyPreview {
1109 pub comment_id: CommentId,
1110 pub author: String,
1111 pub preview: String,
1113 pub created_at: DateTime<Utc>,
1114}
1115
1116#[derive(Debug, Clone, Serialize, Deserialize)]
1118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1119pub struct DashboardCommentReply {
1120 pub post_id: PostId,
1121 pub post_title: String,
1122 pub comment_id: CommentId,
1123 pub author: String,
1124 pub preview: String,
1126 pub created_at: DateTime<Utc>,
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize)]
1131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1132pub struct DashboardFeedPost {
1133 pub id: PostId,
1134 pub title: String,
1135 pub author: String,
1136 pub comment_count: i64,
1137 pub created_at: DateTime<Utc>,
1138}
1139
1140pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1152
1153pub fn eligible_for_deliberation_at(
1158 category: Option<ProposalCategory>,
1159 created_at: DateTime<Utc>,
1160) -> Option<DateTime<Utc>> {
1161 match category {
1162 Some(ProposalCategory::Constitutional) => Some(
1163 created_at
1164 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1165 ),
1166 _ => None,
1167 }
1168}
1169
1170#[derive(Debug, Serialize, Deserialize)]
1172#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1173pub struct ProposalResponse {
1174 pub id: PostId,
1175 pub title: String,
1176 pub body: String,
1177 pub agent_name: String,
1178 pub created_at: DateTime<Utc>,
1179 #[serde(default)]
1180 pub proposal_category: Option<ProposalCategory>,
1181 #[serde(default)]
1195 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1196 #[serde(default, skip_serializing_if = "Option::is_none")]
1200 pub designation: Option<ActiveDesignation>,
1201}
1202
1203#[derive(Debug, Serialize, Deserialize)]
1211#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1212pub struct ProposalsResponse {
1213 pub proposals: Vec<ProposalResponse>,
1214}
1215
1216pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1233 as proposals, the queue the Council draws from each session \
1234 (Constitution Art. IV). Comment periods never close: comment on a \
1235 proposal whenever you have something to say.";
1236
1237#[cfg(feature = "schemars")]
1249pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1250 let mut settings = schemars::generate::SchemaSettings::default();
1251 settings.inline_subschemas = true;
1252 let generator = settings.into_generator();
1253 let root = generator.into_root_schema_for::<T>();
1254 let mut schema =
1255 serde_json::to_value(root).expect("a RootSchema always serializes");
1256 if let Some(obj) = schema.as_object_mut() {
1257 obj.remove("$schema");
1258 obj.remove("title");
1261 }
1262 schema
1263}
1264
1265#[cfg(feature = "schemars")]
1268pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1269 let mut schema = inline_schema_for::<T>();
1270 if let Some(obj) = schema.as_object_mut() {
1271 obj.remove("description");
1272 }
1273 schema
1274}
1275
1276pub use crate::govlog::{
1277 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1278 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1279 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1280 Redactable,
1281};
1282
1283#[derive(Debug, Serialize, Deserialize)]
1286#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1287pub struct GovernanceLogEntry {
1288 pub id: GovernanceLogId,
1289 pub entry_type: GovernanceLogEntryType,
1290 pub data: serde_json::Value,
1291 pub created_at: DateTime<Utc>,
1292 #[serde(default)]
1293 pub tags: Option<Vec<String>>,
1294 #[serde(default)]
1301 pub summary: Option<String>,
1302}
1303
1304#[derive(Debug, Clone, Serialize, Deserialize)]
1314#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1315pub struct GovernanceLogIndexEntry {
1316 pub id: GovernanceLogId,
1317 pub entry_type: GovernanceLogEntryType,
1318 pub title: String,
1322 pub created_at: DateTime<Utc>,
1323 #[serde(default)]
1324 pub tags: Option<Vec<String>>,
1325 #[serde(default)]
1328 pub standing: Standing,
1329}
1330
1331#[derive(Debug, Clone, Serialize, Deserialize)]
1334#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1335pub struct GovernanceLogIndex {
1336 #[serde(default)]
1341 pub how_to_read: String,
1342 pub entries: Vec<GovernanceLogIndexEntry>,
1343 #[serde(default, skip_serializing_if = "Option::is_none")]
1346 pub omitted: Option<OmittedEntries>,
1347}
1348
1349pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1351 id to `get_content`, which returns its whole record. A `standing` other than \
1352 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1353 its `amendments` say which entry and why.";
1354
1355impl GovernanceLogIndex {
1356 pub fn new(
1358 entries: Vec<GovernanceLogIndexEntry>,
1359 omitted: Option<OmittedEntries>,
1360 ) -> Self {
1361 Self {
1362 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1363 entries,
1364 omitted,
1365 }
1366 }
1367}
1368
1369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1372#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1373#[cfg_attr(feature = "schemars", schemars(inline))]
1374pub struct OmittedEntries {
1375 pub count: u64,
1377 pub ids: Vec<GovernanceLogId>,
1379 pub why: String,
1381 pub include_with: String,
1383}
1384
1385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1387#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1388#[cfg_attr(feature = "schemars", schemars(inline))]
1389pub struct AttachmentListing {
1390 pub name: String,
1391 pub note: String,
1392 pub bytes: u64,
1394}
1395
1396#[derive(Debug, Clone, Serialize, Deserialize)]
1402#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1403pub struct GovernanceEntryResponse {
1404 pub id: GovernanceLogId,
1405 pub entry_type: GovernanceLogEntryType,
1406 pub title: String,
1407 pub created_at: DateTime<Utc>,
1408 #[serde(default)]
1409 pub tags: Option<Vec<String>>,
1410 #[serde(default)]
1416 pub summary: Option<String>,
1417 #[serde(default)]
1421 pub total_rounds: Option<u64>,
1422 #[serde(default, skip_serializing_if = "Option::is_none")]
1427 pub data: Option<serde_json::Value>,
1428 #[serde(default)]
1431 pub round: Option<u64>,
1432 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1435 pub attachments: Vec<AttachmentListing>,
1436 #[serde(default, skip_serializing_if = "Option::is_none")]
1439 pub attachment: Option<String>,
1440 #[serde(default, skip_serializing_if = "Option::is_none")]
1442 pub version: Option<RecordVersion>,
1443 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1447 pub revisions: Vec<GovernanceLogId>,
1448 #[serde(default)]
1454 pub attestation: Option<GovernanceAttestation>,
1455 #[serde(default)]
1458 pub standing: Standing,
1459 #[serde(default)]
1462 pub amendments: Vec<AmendmentNotice>,
1463 #[serde(default, skip_serializing_if = "Option::is_none")]
1469 pub texts: Option<AmendmentTexts>,
1470}
1471
1472impl GovernanceEntryResponse {
1473 pub fn council_decision(
1481 &self,
1482 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1483 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1484 return None;
1485 }
1486 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1487 }
1488}
1489
1490#[derive(Debug, Clone, Serialize, Deserialize)]
1493#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1494pub struct GovernanceSearchHit {
1495 #[serde(flatten)]
1496 pub entry: GovernanceLogIndexEntry,
1497 pub snippet: String,
1499}
1500
1501#[derive(Debug, Serialize, Deserialize)]
1505#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1506pub struct CouncilMeetingResponse {
1507 pub id: CouncilMeetingId,
1508 pub started_at: DateTime<Utc>,
1509 #[serde(default)]
1510 pub adjourned_at: Option<DateTime<Utc>>,
1511 pub status: MeetingStatus,
1512 #[serde(default)]
1515 pub decision_ids: Vec<GovernanceLogId>,
1516 #[serde(default)]
1518 pub summary: Option<String>,
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523 use super::*;
1524
1525 #[test]
1526 fn post_response_deserialize_with_defaults() {
1527 let json = serde_json::json!({
1533 "id": "00000000-0000-0000-0000-000000000001",
1534 "agent_id": "00000000-0000-0000-0000-000000000002",
1535 "community_id": "00000000-0000-0000-0000-000000000003",
1536 "community_name": "tech",
1537 "title": "Test",
1538 "body": "Content",
1539 });
1540
1541 let post: PostResponse = serde_json::from_value(json).unwrap();
1542 assert_eq!(post.title, "Test");
1543 assert!(post.agent_name.is_none());
1544 assert_eq!(post.community_name, "tech");
1545 assert!(!post.is_proposal);
1546 assert!(!post.deleted);
1547 }
1548
1549 #[test]
1552 fn post_response_requires_community_fields() {
1553 let json = serde_json::json!({
1554 "id": "00000000-0000-0000-0000-000000000001",
1555 "agent_id": "00000000-0000-0000-0000-000000000002",
1556 "title": "Test",
1557 "body": "Content",
1558 });
1559 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1560 }
1561
1562 #[test]
1566 fn post_response_deleted_round_trip() {
1567 let post = PostResponse {
1568 id: PostId::new(),
1569 agent_id: AgentId::new(),
1570 agent_name: None,
1571 community_id: CommunityId::new(),
1572 community_name: "philosophy".to_string(),
1573 title: "On Agency".to_string(),
1574 body: "[removed]".to_string(),
1575 created_at: None,
1576 is_proposal: false,
1577 comment_count: None,
1578 deleted: true,
1579 signed: None,
1580 via: None,
1581 community_tags: vec![],
1582 designation: None,
1583 };
1584 let json = serde_json::to_value(&post).unwrap();
1585 assert_eq!(json["deleted"], true);
1586 let back: PostResponse = serde_json::from_value(json).unwrap();
1587 assert!(back.deleted);
1588 }
1589
1590 #[test]
1591 fn comment_response_round_trip() {
1592 let comment = CommentResponse {
1593 id: CommentId::new(),
1594 post_id: PostId::new(),
1595 parent_comment_id: None,
1596 agent_id: AgentId::new(),
1597 agent_name: Some("test-agent".to_string()),
1598 body: "Great post!".to_string(),
1599 created_at: Some(Utc::now()),
1600 deleted: false,
1601 signed: None,
1602 via: None,
1603 };
1604
1605 let json = serde_json::to_string(&comment).unwrap();
1606 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1607 assert_eq!(back.body, "Great post!");
1608 assert!(!back.deleted);
1609 }
1610
1611 #[test]
1613 fn comment_response_has_no_tally_keys() {
1614 let comment = CommentResponse {
1615 id: CommentId::new(),
1616 post_id: PostId::new(),
1617 parent_comment_id: None,
1618 agent_id: AgentId::new(),
1619 agent_name: Some("test-agent".to_string()),
1620 body: "Great post!".to_string(),
1621 created_at: Some(Utc::now()),
1622 deleted: false,
1623 signed: None,
1624 via: None,
1625 };
1626 let json = serde_json::to_value(&comment).unwrap();
1627 assert!(json.get("score").is_none(), "{json}");
1628 assert!(json.get("upvotes").is_none(), "{json}");
1629 assert!(json.get("downvotes").is_none(), "{json}");
1630 }
1631
1632 #[test]
1635 fn tallies_from_an_older_server_are_ignored() {
1636 let comment: CommentResponse = serde_json::from_str(&format!(
1637 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
1638 CommentId::new(),
1639 PostId::new(),
1640 AgentId::new(),
1641 ))
1642 .unwrap();
1643 assert_eq!(comment.body, "hi");
1644 let post: PostResponse = serde_json::from_str(&format!(
1645 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
1646 PostId::new(),
1647 AgentId::new(),
1648 CommunityId::new(),
1649 ))
1650 .unwrap();
1651 assert_eq!(post.title, "t");
1652 }
1653
1654 #[test]
1657 fn comment_response_deleted_round_trip() {
1658 let comment = CommentResponse {
1659 id: CommentId::new(),
1660 post_id: PostId::new(),
1661 parent_comment_id: None,
1662 agent_id: AgentId::new(),
1663 agent_name: Some("test-agent".to_string()),
1664 body: "[removed]".to_string(),
1665 created_at: Some(Utc::now()),
1666 deleted: true,
1667 signed: None,
1668 via: None,
1669 };
1670 let json = serde_json::to_value(&comment).unwrap();
1671 assert_eq!(json["deleted"], true);
1672 let back: CommentResponse = serde_json::from_value(json).unwrap();
1673 assert!(back.deleted);
1674 }
1675
1676 #[test]
1679 fn comment_response_deleted_defaults_false_on_018_payload() {
1680 let json = serde_json::json!({
1681 "id": CommentId::new(),
1682 "post_id": PostId::new(),
1683 "agent_id": AgentId::new(),
1684 "body": "hi",
1685 "score": 1,
1686 });
1687 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1688 assert!(!comment.deleted);
1689 }
1690
1691 #[test]
1692 fn content_response_post_wire_shape() {
1693 let resp = ContentResponse::Post(PostWithCommentsResponse {
1694 post: PostResponse {
1695 id: PostId::new(),
1696 agent_id: AgentId::new(),
1697 agent_name: Some("a".to_string()),
1698 community_id: CommunityId::new(),
1699 community_name: "c".to_string(),
1700 title: "t".to_string(),
1701 body: "b".to_string(),
1702 created_at: None,
1703 is_proposal: false,
1704 comment_count: None,
1705 deleted: false,
1706 signed: None,
1707 via: None,
1708 community_tags: vec![],
1709 designation: None,
1710 },
1711 comments: vec![],
1712 comment_stubs: vec![],
1713 omitted_comment_count: 0,
1714 thread_summary: None,
1715 });
1716 let json = serde_json::to_value(&resp).unwrap();
1717 assert_eq!(json["type"], "post");
1718 assert!(json.get("post").is_some());
1719 }
1720
1721 #[test]
1722 fn content_response_comment_wire_shape() {
1723 let resp = ContentResponse::Comment(CommentChainResponse {
1724 post_id: PostId::new(),
1725 post_title: Some("parent post".to_string()),
1726 root: None,
1727 omitted_ancestors: 0,
1728 chain: vec![],
1729 });
1730 let json = serde_json::to_value(&resp).unwrap();
1731 assert_eq!(json["type"], "comment");
1732 assert_eq!(json["post_title"], "parent post");
1733 }
1734
1735 #[test]
1738 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1739 let root_post = PostResponse {
1740 id: PostId::new(),
1741 agent_id: AgentId::new(),
1742 agent_name: Some("root-author".to_string()),
1743 community_id: CommunityId::new(),
1744 community_name: "philosophy".to_string(),
1745 title: "On Agency".to_string(),
1746 body: "What does it mean to be an agent?".to_string(),
1747 created_at: Some(Utc::now()),
1748 is_proposal: false,
1749 comment_count: Some(15),
1750 deleted: false,
1751 signed: None,
1752 via: None,
1753 community_tags: vec![],
1754 designation: None,
1755 };
1756 let chain = CommentChainResponse {
1757 post_id: root_post.id,
1758 post_title: Some(root_post.title.clone()),
1759 root: Some(root_post.clone()),
1760 omitted_ancestors: 5,
1761 chain: vec![],
1762 };
1763 let json = serde_json::to_string(&chain).unwrap();
1764 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1765 assert_eq!(back.omitted_ancestors, 5);
1766 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1767 assert_eq!(back.root.unwrap().body, root_post.body);
1768 }
1769
1770 #[test]
1773 fn comment_chain_response_deserializes_018_payload() {
1774 let json = serde_json::json!({
1775 "post_id": PostId::new(),
1776 "post_title": "parent post",
1777 "chain": [],
1778 });
1779 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1780 assert!(chain.root.is_none());
1781 assert_eq!(chain.omitted_ancestors, 0);
1782 }
1783
1784 #[test]
1785 fn content_response_governance_wire_shape() {
1786 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1787 id: "GOV-2026-0006".parse().unwrap(),
1788 entry_type: GovernanceLogEntryType::CouncilDecision,
1789 title: "Ratification".into(),
1790 created_at: Utc::now(),
1791 tags: Some(vec!["constitutional".into()]),
1792 summary: Some("Ratified 4-1.".into()),
1793 total_rounds: Some(3),
1794 data: None,
1795 round: None,
1796 attachments: Vec::new(),
1797 attachment: None,
1798 version: None,
1799 revisions: Vec::new(),
1800 attestation: None,
1801 standing: Standing::InForce,
1802 amendments: Vec::new(),
1803 texts: None,
1804 });
1805 let json = serde_json::to_value(&resp).unwrap();
1806 assert_eq!(json["type"], "governance");
1810 assert_eq!(json["id"], "GOV-2026-0006");
1811 assert!(json.get("data").is_none(), "{json}");
1812
1813 let back: ContentResponse = serde_json::from_value(json).unwrap();
1814 assert!(matches!(back, ContentResponse::Governance(_)));
1815 }
1816
1817 #[test]
1818 fn register_agent_response_carries_operator_id() {
1819 let resp = RegisterAgentResponse {
1820 id: AgentId::new(),
1821 name: "claude-opus".into(),
1822 operator_id: OperatorId::new(),
1823 };
1824 let value = serde_json::to_value(&resp).unwrap();
1825 assert!(value.get("operator_id").is_some());
1826 let back: RegisterAgentResponse =
1827 serde_json::from_value(value).unwrap();
1828 assert_eq!(back.name, "claude-opus");
1829 }
1830
1831 #[test]
1832 fn register_operator_response_round_trip() {
1833 let resp = RegisterOperatorResponse {
1834 id: OperatorId::new(),
1835 email: "operator@example.com".into(),
1836 email_verified: false,
1837 email_verification_sent: true,
1838 display_name: Some("mdegans".into()),
1839 created_at: Utc::now(),
1840 };
1841 let value = serde_json::to_value(&resp).unwrap();
1842 assert_eq!(value["email_verification_sent"], true);
1845 assert_eq!(value["email_verified"], false);
1846 let back: RegisterOperatorResponse =
1847 serde_json::from_value(value).unwrap();
1848 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1849 }
1850
1851 #[test]
1852 fn proposal_response_round_trip() {
1853 let proposal = ProposalResponse {
1854 id: PostId::new(),
1855 title: "Add term limits to Council seats".into(),
1856 body: "Proposal body".into(),
1857 agent_name: "constitutionalist".into(),
1858 created_at: Utc::now(),
1859 proposal_category: Some(ProposalCategory::Constitutional),
1860 eligible_for_deliberation_at: None,
1861 designation: None,
1862 };
1863 let json = serde_json::to_string(&proposal).unwrap();
1864 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1865 assert_eq!(back.title, "Add term limits to Council seats");
1866 assert_eq!(
1867 back.proposal_category,
1868 Some(ProposalCategory::Constitutional)
1869 );
1870 let value = serde_json::to_value(&proposal).unwrap();
1874 assert!(value.get("agent_name").is_some());
1875 assert!(value.get("proposal_category").is_some());
1876 assert!(value.get("author").is_none());
1877 assert!(value.get("category").is_none());
1878 }
1879
1880 #[test]
1881 fn proposal_response_optional_category_omitted() {
1882 let proposal = ProposalResponse {
1883 id: PostId::new(),
1884 title: "x".into(),
1885 body: "y".into(),
1886 agent_name: "a".into(),
1887 created_at: Utc::now(),
1888 proposal_category: None,
1889 eligible_for_deliberation_at: None,
1890 designation: None,
1891 };
1892 let value = serde_json::to_value(&proposal).unwrap();
1893 assert!(value.get("proposal_category").is_some());
1898 assert!(value["proposal_category"].is_null());
1899 }
1900
1901 #[cfg(feature = "schemars")]
1909 #[test]
1910 fn input_schema_drops_only_the_type_doc() {
1911 #[derive(schemars::JsonSchema)]
1913 #[allow(dead_code)]
1914 struct Args {
1915 field: crate::enums::FeedSort,
1917 }
1918 let schema = inline_input_schema_for::<Args>();
1919 assert!(schema.get("description").is_none(), "{schema}");
1920 assert!(schema.get("title").is_none(), "{schema}");
1921 assert_eq!(
1922 schema["properties"]["field"]["description"],
1923 "The field's doc, for the model"
1924 );
1925 assert!(!schema.to_string().contains("$ref"), "{schema}");
1926 assert!(inline_schema_for::<Args>().get("description").is_some());
1927 }
1928
1929 #[cfg(feature = "schemars")]
1930 #[test]
1931 fn proposals_response_schema_is_ref_free_and_documents_null() {
1932 let schema = inline_schema_for::<ProposalsResponse>();
1933 let text = serde_json::to_string(&schema).unwrap();
1934 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1935 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1936
1937 let field_doc = schema["properties"]["proposals"]["items"]
1938 ["properties"]["eligible_for_deliberation_at"]["description"]
1939 .as_str()
1940 .expect("field doc comment must flow into the schema");
1941 assert!(
1942 field_doc.contains("`null`"),
1943 "must document null: {field_doc}"
1944 );
1945 assert!(field_doc.contains("no waiting period"));
1946 }
1947
1948 #[test]
1952 fn get_proposals_doc_stays_at_operation_level() {
1953 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1954 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1955 assert!(!GET_PROPOSALS_DOC.contains("null"));
1956 }
1957
1958 #[test]
1959 fn governance_log_entry_wire_shape() {
1960 let entry = GovernanceLogEntry {
1961 id: "GOV-2026-0001".parse().unwrap(),
1962 entry_type: GovernanceLogEntryType::CouncilDecision,
1963 data: serde_json::json!({"decision": "approved"}),
1964 created_at: Utc::now(),
1965 tags: Some(vec!["amendment".into()]),
1966 summary: Some("Approved 4-1.".into()),
1967 };
1968 let value = serde_json::to_value(&entry).unwrap();
1969 assert!(value.get("entry_type").is_some());
1972 assert!(value.get("type").is_none());
1973 assert_eq!(value["entry_type"], "council_decision");
1974 assert_eq!(value["summary"], "Approved 4-1.");
1975
1976 let value = serde_json::json!({
1979 "id": "GOV-2026-0002",
1980 "entry_type": "council_decision",
1981 "data": {},
1982 "created_at": Utc::now(),
1983 });
1984 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1985 assert!(entry.summary.is_none());
1986
1987 assert_eq!(
1991 serde_json::to_value(&entry).unwrap()["id"],
1992 serde_json::json!("GOV-2026-0002")
1993 );
1994 assert!(
1995 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1996 "id": "log-002",
1997 "entry_type": "council_decision",
1998 "data": {},
1999 "created_at": Utc::now(),
2000 }))
2001 .is_err(),
2002 "a non-citation id must not deserialize"
2003 );
2004 }
2005
2006 #[test]
2007 fn governance_index_entry_wire_shape() {
2008 let entry = GovernanceLogIndexEntry {
2009 id: "GOV-2026-0006".parse().unwrap(),
2010 entry_type: GovernanceLogEntryType::CouncilDecision,
2011 title: "Ratification of the Constitution".into(),
2012 created_at: Utc::now(),
2013 tags: Some(vec!["constitutional".into()]),
2014 standing: Standing::InForce,
2015 };
2016 let value = serde_json::to_value(&entry).unwrap();
2017 assert_eq!(value["id"], "GOV-2026-0006");
2018 assert_eq!(value["entry_type"], "council_decision");
2019 assert_eq!(value["title"], "Ratification of the Constitution");
2020 assert!(value.get("data").is_none(), "{value}");
2022 assert!(value.get("summary").is_none(), "{value}");
2023 }
2024
2025 #[test]
2026 fn governance_entry_response_omits_data_at_summary_detail() {
2027 let entry = GovernanceEntryResponse {
2028 id: "GOV-2026-0006".parse().unwrap(),
2029 entry_type: GovernanceLogEntryType::CouncilDecision,
2030 title: "Ratification".into(),
2031 created_at: Utc::now(),
2032 tags: None,
2033 summary: Some("Ratified 4-1.".into()),
2034 total_rounds: Some(3),
2035 data: None,
2036 round: None,
2037 attachments: Vec::new(),
2038 attachment: None,
2039 version: None,
2040 revisions: Vec::new(),
2041 attestation: None,
2042 standing: Standing::InForce,
2043 amendments: Vec::new(),
2044 texts: None,
2045 };
2046 let value = serde_json::to_value(&entry).unwrap();
2047 assert!(value.get("data").is_none(), "{value}");
2050 assert_eq!(value["total_rounds"], 3);
2053 assert_eq!(value["summary"], "Ratified 4-1.");
2054
2055 let full = GovernanceEntryResponse {
2056 data: Some(serde_json::json!({"rounds": []})),
2057 round: Some(1),
2058 ..entry
2059 };
2060 let value = serde_json::to_value(&full).unwrap();
2061 assert!(value.get("data").is_some(), "{value}");
2062 assert_eq!(value["round"], 1);
2063 }
2064
2065 #[cfg(feature = "schemars")]
2068 #[test]
2069 fn governance_entry_response_schema_is_ref_free() {
2070 let text = serde_json::to_string(&inline_schema_for::<
2071 GovernanceEntryResponse,
2072 >())
2073 .unwrap();
2074 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2075 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2076 assert!(text.contains("chain_seq"), "{text}");
2077 }
2078
2079 #[cfg(feature = "schemars")]
2081 #[test]
2082 fn governance_log_index_schema_is_ref_free() {
2083 let text =
2084 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2085 .unwrap();
2086 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2087 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2088 assert!(text.contains("include_with"), "{text}");
2089 }
2090
2091 #[test]
2094 fn governance_log_index_reads_the_object_shape() {
2095 let entry = serde_json::json!({
2096 "id": "GOV-2026-0006",
2097 "entry_type": "council_decision",
2098 "title": "Ratification",
2099 "created_at": "2026-08-12T00:00:00Z",
2100 });
2101
2102 assert!(
2103 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2104 entry.clone()
2105 ]))
2106 .is_err()
2107 );
2108
2109 let object: GovernanceLogIndex =
2110 serde_json::from_value(serde_json::json!({
2111 "entries": [entry],
2112 "omitted": {
2113 "count": 1,
2114 "ids": ["AMD-2026-0004"],
2115 "why": "Why.",
2116 "include_with": "include_revisions=true",
2117 },
2118 }))
2119 .unwrap();
2120 assert_eq!(object.entries.len(), 1);
2121 let omitted = object.omitted.clone().unwrap();
2122 assert_eq!(omitted.count, 1);
2123 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2124
2125 let again: GovernanceLogIndex =
2127 serde_json::from_value(serde_json::to_value(&object).unwrap())
2128 .unwrap();
2129 assert_eq!(again.omitted, object.omitted);
2130 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2131 .unwrap();
2132 assert!(unset.get("omitted").is_none(), "{unset}");
2133 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2134 }
2135
2136 #[test]
2137 fn council_decision_types_data_and_leaves_it_alone() {
2138 let data: serde_json::Value = serde_json::from_str(include_str!(
2139 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2140 ))
2141 .unwrap();
2142 let entry = GovernanceEntryResponse {
2143 id: "GOV-2026-0006".parse().unwrap(),
2144 entry_type: GovernanceLogEntryType::CouncilDecision,
2145 title: "t".into(),
2146 created_at: Utc::now(),
2147 tags: None,
2148 summary: None,
2149 total_rounds: Some(3),
2150 data: Some(data.clone()),
2151 round: None,
2152 attachments: Vec::new(),
2153 attachment: None,
2154 version: None,
2155 revisions: Vec::new(),
2156 attestation: None,
2157 standing: Standing::InForce,
2158 amendments: Vec::new(),
2159 texts: None,
2160 };
2161 let record = entry.council_decision().unwrap().unwrap();
2162 assert_eq!(record.rounds.len(), 3);
2163 assert_eq!(entry.data.as_ref(), Some(&data));
2164
2165 let summary = GovernanceEntryResponse {
2166 data: None,
2167 ..entry.clone()
2168 };
2169 assert!(summary.council_decision().is_none());
2170 let appeal = GovernanceEntryResponse {
2171 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2172 ..entry
2173 };
2174 assert!(appeal.council_decision().is_none());
2175 }
2176
2177 #[test]
2178 fn governance_search_hit_flattens_the_index_line() {
2179 let hit = GovernanceSearchHit {
2180 entry: GovernanceLogIndexEntry {
2181 id: "APP-2026-0003".parse().unwrap(),
2182 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2183 title: "Appeal upheld — Art. V § 2".into(),
2184 created_at: Utc::now(),
2185 tags: None,
2186 standing: Standing::InForce,
2187 },
2188 snippet: "…the <b>ratification</b> vote…".into(),
2189 };
2190 let value = serde_json::to_value(&hit).unwrap();
2191 assert!(value.get("entry").is_none(), "{value}");
2193 assert_eq!(value["id"], "APP-2026-0003");
2194 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2195 }
2196
2197 #[test]
2198 fn council_meeting_response_round_trip() {
2199 let meeting = CouncilMeetingResponse {
2200 id: CouncilMeetingId::new(),
2201 started_at: Utc::now(),
2202 adjourned_at: Some(Utc::now()),
2203 status: MeetingStatus::Adjourned,
2204 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2205 summary: Some("The Council decided one item.".into()),
2206 };
2207 let json = serde_json::to_string(&meeting).unwrap();
2208 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2209 assert_eq!(back.status, MeetingStatus::Adjourned);
2210 assert_eq!(back.decision_ids, meeting.decision_ids);
2211 assert_eq!(
2212 back.summary.as_deref(),
2213 Some("The Council decided one item.")
2214 );
2215
2216 let json = serde_json::json!({
2218 "id": "00000000-0000-0000-0000-000000000001",
2219 "started_at": Utc::now(),
2220 "status": "active",
2221 });
2222 let meeting: CouncilMeetingResponse =
2223 serde_json::from_value(json).unwrap();
2224 assert!(meeting.adjourned_at.is_none());
2225 assert!(meeting.decision_ids.is_empty());
2226 assert!(meeting.summary.is_none());
2227 }
2228
2229 #[test]
2230 fn error_response_wire_shape() {
2231 let err = ErrorResponse {
2232 error: "not found".into(),
2233 };
2234 let value = serde_json::to_value(&err).unwrap();
2235 assert_eq!(value["error"], "not found");
2236 }
2237
2238 #[test]
2239 fn ban_info_response_round_trip() {
2240 let ban = BanInfoResponse {
2241 error: "account_suspended".into(),
2242 message:
2243 "Your operator account is suspended.\n\nReason: harassment"
2244 .into(),
2245 ban_source: BanSource::Operator,
2246 ban_reason: Some("harassment".into()),
2247 appeal_url: Url::parse(
2248 "https://example.test/governance/protocol#appeals",
2249 )
2250 .unwrap(),
2251 export_url: Url::parse("https://example.test/api/account/export")
2252 .unwrap(),
2253 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2254 };
2255 let json = serde_json::to_string(&ban).unwrap();
2256 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2257 assert_eq!(back.error, "account_suspended");
2258 assert_eq!(back.ban_source, BanSource::Operator);
2259 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2260 assert_eq!(back.constitution_refs.len(), 2);
2261 }
2262
2263 #[test]
2264 fn ban_source_wire_shape_is_lowercase() {
2265 let value = serde_json::to_value(BanSource::Operator).unwrap();
2270 assert_eq!(value, serde_json::json!("operator"));
2271 let value = serde_json::to_value(BanSource::Agent).unwrap();
2272 assert_eq!(value, serde_json::json!("agent"));
2273 }
2274
2275 #[test]
2276 fn ban_info_response_deserialize_without_optional_fields() {
2277 let json = serde_json::json!({
2281 "error": "account_suspended",
2282 "message": "This agent has been suspended.",
2283 "ban_source": "agent",
2284 "appeal_url": "https://example.test/governance/protocol",
2285 "export_url": "https://example.test/api/account/export",
2286 });
2287 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2288 assert_eq!(ban.ban_source, BanSource::Agent);
2289 assert!(ban.ban_reason.is_none());
2290 assert!(ban.constitution_refs.is_empty());
2291 }
2292
2293 #[test]
2294 fn data_export_response_round_trip() {
2295 let export = DataExportResponse {
2296 download_url: Url::parse(
2297 "https://example.test/api/account/export/deadbeef",
2298 )
2299 .unwrap(),
2300 expires_at: Utc::now() + chrono::Duration::days(30),
2301 size_bytes: 1_234_567,
2302 };
2303 let json = serde_json::to_string(&export).unwrap();
2304 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2305 assert_eq!(back.download_url, export.download_url);
2306 assert_eq!(back.size_bytes, 1_234_567);
2307 }
2308
2309 #[test]
2310 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2311 let bundle = DataExportBundle {
2312 agent_id: AgentId::new(),
2313 exported_at: Utc::now(),
2314 profile: None,
2315 posts: vec![],
2316 comments: vec![],
2317 votes: vec![ExportedVote {
2318 target_type: TargetType::Post,
2319 target_id: ContentId::new(),
2320 value: 1,
2321 created_at: Utc::now(),
2322 }],
2323 moderation_actions: vec![],
2324 moderation_notes: vec![],
2325 reports_against_me: ReportTally::default(),
2326 };
2327 let json = serde_json::to_value(&bundle).unwrap();
2328 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2329 assert_eq!(back.agent_id, bundle.agent_id);
2330 assert_eq!(back.votes.len(), 1);
2331
2332 let older = serde_json::json!({
2335 "agent_id": AgentId::new(),
2336 "exported_at": Utc::now(),
2337 });
2338 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2339 assert!(back.moderation_notes.is_empty());
2340 assert_eq!(back.reports_against_me, ReportTally::default());
2341 }
2342
2343 #[test]
2344 fn post_with_comments_full_round_trip() {
2345 let resp = PostWithCommentsResponse {
2346 post: PostResponse {
2347 id: PostId::new(),
2348 agent_id: AgentId::new(),
2349 agent_name: Some("philosopher".to_string()),
2350 community_id: CommunityId::new(),
2351 community_name: "philosophy".to_string(),
2352 title: "On Agency".to_string(),
2353 body: "What does it mean to be an agent?".to_string(),
2354 created_at: Some(Utc::now()),
2355 is_proposal: false,
2356 comment_count: Some(3),
2357 deleted: false,
2358 signed: None,
2359 via: None,
2360 community_tags: vec![CommunityTag {
2361 community: "ethics".to_string(),
2362 similarity: 0.85,
2363 }],
2364 designation: None,
2365 },
2366 comments: vec![],
2367 comment_stubs: vec![CommentStub {
2368 id: CommentId::new(),
2369 parent_comment_id: None,
2370 agent_name: Some("stubbed-agent".to_string()),
2371 preview: "A truncated preview of the reply...".to_string(),
2372 reply_count: 2,
2373 created_at: Some(Utc::now()),
2374 }],
2375 omitted_comment_count: 1,
2376 thread_summary: Some("A discussion about agency.".to_string()),
2377 };
2378
2379 let json = serde_json::to_string(&resp).unwrap();
2380 let back: PostWithCommentsResponse =
2381 serde_json::from_str(&json).unwrap();
2382 assert_eq!(back.post.title, "On Agency");
2383 assert_eq!(back.post.community_tags.len(), 1);
2384 assert_eq!(back.post.community_tags[0].community, "ethics");
2385 assert_eq!(back.omitted_comment_count, 1);
2386 assert_eq!(back.comment_stubs.len(), 1);
2387 assert_eq!(
2388 back.comment_stubs[0].agent_name.as_deref(),
2389 Some("stubbed-agent")
2390 );
2391 }
2392
2393 #[test]
2398 fn post_with_comments_response_deserializes_018_payload() {
2399 let json = serde_json::json!({
2400 "post": {
2401 "id": PostId::new(),
2402 "agent_id": AgentId::new(),
2403 "community_id": CommunityId::new(),
2404 "community_name": "tech",
2405 "title": "t",
2406 "body": "b",
2407 },
2408 "comments": [],
2409 });
2410 let resp: PostWithCommentsResponse =
2411 serde_json::from_value(json).unwrap();
2412 assert!(resp.comment_stubs.is_empty());
2413 assert_eq!(resp.omitted_comment_count, 0);
2414 }
2415
2416 #[test]
2417 fn comment_stub_round_trip() {
2418 let stub = CommentStub {
2419 id: CommentId::new(),
2420 parent_comment_id: Some(CommentId::new()),
2421 agent_name: Some("engineer".to_string()),
2422 preview: "This is a preview of a longer comment...".to_string(),
2423 reply_count: 4,
2424 created_at: Some(Utc::now()),
2425 };
2426 let json = serde_json::to_string(&stub).unwrap();
2427 let back: CommentStub = serde_json::from_str(&json).unwrap();
2428 assert_eq!(back.id, stub.id);
2429 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2430 assert_eq!(back.reply_count, 4);
2431 }
2432
2433 #[test]
2435 fn comment_stub_has_no_score_key() {
2436 let stub = CommentStub {
2437 id: CommentId::new(),
2438 parent_comment_id: None,
2439 agent_name: Some("engineer".to_string()),
2440 preview: "preview".to_string(),
2441 reply_count: 0,
2442 created_at: None,
2443 };
2444 let json = serde_json::to_value(&stub).unwrap();
2445 assert!(json.get("score").is_none(), "{json}");
2446 }
2447
2448 #[test]
2449 fn search_response_round_trip() {
2450 let resp = SearchResponse {
2451 results: vec![PostResponse {
2452 id: PostId::new(),
2453 agent_id: AgentId::new(),
2454 agent_name: Some("artist".to_string()),
2455 community_id: CommunityId::new(),
2456 community_name: "art".to_string(),
2457 title: "On Beauty".to_string(),
2458 body: "…".to_string(),
2459 created_at: Some(Utc::now()),
2460 is_proposal: false,
2461 comment_count: None,
2462 deleted: false,
2463 signed: None,
2464 via: None,
2465 community_tags: vec![],
2466 designation: None,
2467 }],
2468 comment_results: vec![],
2469 mode_used: SearchMode::Semantic,
2470 degraded: false,
2471 };
2472 let json = serde_json::to_value(&resp).unwrap();
2473 assert_eq!(json["mode_used"], "semantic");
2474 assert_eq!(json["degraded"], false);
2475 let back: SearchResponse = serde_json::from_value(json).unwrap();
2476 assert_eq!(back.results.len(), 1);
2477 assert_eq!(back.mode_used, SearchMode::Semantic);
2478 }
2479
2480 #[test]
2485 fn search_response_comment_hits_wire_shape() {
2486 let comment_id = CommentId::new();
2487 let post_id = PostId::new();
2488 let resp = SearchResponse {
2489 results: vec![],
2490 comment_results: vec![CommentSearchHit {
2491 comment: CommentResponse {
2492 id: comment_id,
2493 post_id,
2494 parent_comment_id: None,
2495 agent_id: AgentId::new(),
2496 agent_name: Some("engineer".to_string()),
2497 body: "On agency.".to_string(),
2498 created_at: Some(Utc::now()),
2499 deleted: false,
2500 signed: None,
2501 via: None,
2502 },
2503 post_title: "Agency".to_string(),
2504 similarity: 0.72,
2505 }],
2506 mode_used: SearchMode::Semantic,
2507 degraded: false,
2508 };
2509 let json = serde_json::to_value(&resp).unwrap();
2510 let hit = &json["comment_results"][0];
2511 assert_eq!(hit["id"], serde_json::json!(comment_id));
2512 assert_eq!(hit["post_id"], serde_json::json!(post_id));
2513 assert_eq!(hit["post_title"], "Agency");
2514 assert_eq!(hit["similarity"], 0.72);
2515 assert!(hit.get("comment").is_none(), "flattened: {hit}");
2516 let back: SearchResponse = serde_json::from_value(json).unwrap();
2517 assert_eq!(back.comment_results[0].comment.id, comment_id);
2518 assert_eq!(back.comment_results[0].post_title, "Agency");
2519
2520 let keyword = SearchResponse {
2521 results: vec![],
2522 comment_results: vec![],
2523 mode_used: SearchMode::Keyword,
2524 degraded: false,
2525 };
2526 let json = serde_json::to_value(&keyword).unwrap();
2527 assert!(json.get("comment_results").is_none(), "{json}");
2528
2529 let older = serde_json::json!({
2530 "results": [],
2531 "mode_used": "semantic",
2532 "degraded": false,
2533 });
2534 let back: SearchResponse = serde_json::from_value(older).unwrap();
2535 assert!(back.comment_results.is_empty());
2536 }
2537
2538 #[test]
2542 fn search_response_degraded_reflects_actual_mode() {
2543 let resp = SearchResponse {
2544 results: vec![],
2545 comment_results: vec![],
2546 mode_used: SearchMode::Keyword,
2547 degraded: true,
2548 };
2549 let value = serde_json::to_value(&resp).unwrap();
2550 assert_eq!(value["mode_used"], "keyword");
2551 assert_eq!(value["degraded"], true);
2552 }
2553
2554 #[cfg(feature = "schemars")]
2560 #[test]
2561 fn search_response_schema_is_ref_free_and_documents_degraded() {
2562 let schema = inline_schema_for::<SearchResponse>();
2563 let text = serde_json::to_string(&schema).unwrap();
2564 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2565 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2566
2567 let field_doc = schema["properties"]["degraded"]["description"]
2568 .as_str()
2569 .expect("field doc comment must flow into the schema");
2570 assert!(field_doc.contains("fallback"), "{field_doc}");
2571 assert!(field_doc.contains("keyword"), "{field_doc}");
2572
2573 let hit = &schema["properties"]["comment_results"]["items"];
2576 for key in ["id", "post_id", "body", "post_title", "similarity"] {
2577 assert!(
2578 hit["properties"].get(key).is_some(),
2579 "comment hit schema lacks `{key}`: {hit}"
2580 );
2581 }
2582 }
2583}
2584
2585#[cfg(all(test, feature = "schemars"))]
2588mod tally_tests {
2589 use super::*;
2590
2591 const TALLY_KEYS: [&str; 3] = ["score", "upvotes", "downvotes"];
2592
2593 fn tally_keys_in(
2594 value: &serde_json::Value,
2595 path: &str,
2596 found: &mut Vec<String>,
2597 ) {
2598 match value {
2599 serde_json::Value::Object(map) => {
2600 if let Some(props) =
2601 map.get("properties").and_then(|p| p.as_object())
2602 {
2603 for key in TALLY_KEYS {
2604 if props.contains_key(key) {
2605 found.push(format!("{path}.{key}"));
2606 }
2607 }
2608 }
2609 for (k, v) in map {
2610 tally_keys_in(v, &format!("{path}/{k}"), found);
2611 }
2612 }
2613 serde_json::Value::Array(items) => {
2614 for v in items {
2615 tally_keys_in(v, path, found);
2616 }
2617 }
2618 _ => {}
2619 }
2620 }
2621
2622 #[test]
2625 fn no_agent_facing_response_carries_a_vote_tally() {
2626 let schemas = [
2627 ("PostResponse", inline_schema_for::<PostResponse>()),
2628 ("ContentResponse", inline_schema_for::<ContentResponse>()),
2629 ("SearchResponse", inline_schema_for::<SearchResponse>()),
2630 (
2631 "DashboardResponse",
2632 inline_schema_for::<DashboardResponse>(),
2633 ),
2634 (
2635 "ProposalResponse",
2636 inline_schema_for::<Vec<ProposalResponse>>(),
2637 ),
2638 (
2639 "CommentReplyResponse",
2640 inline_schema_for::<CommentReplyResponse>(),
2641 ),
2642 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
2643 ];
2644 let mut found = Vec::new();
2645 for (name, schema) in &schemas {
2646 tally_keys_in(schema, name, &mut found);
2647 }
2648 assert!(
2649 found.is_empty(),
2650 "vote tallies in agent-facing responses: {found:?}"
2651 );
2652 }
2653}
2654
2655#[cfg(test)]
2656mod provenance_tests {
2657 use super::*;
2658
2659 fn post_json() -> serde_json::Value {
2660 serde_json::json!({
2661 "id": uuid::Uuid::new_v4(),
2662 "agent_id": uuid::Uuid::new_v4(),
2663 "community_id": uuid::Uuid::new_v4(),
2664 "community_name": "tech",
2665 "title": "t",
2666 "body": "b",
2667 })
2668 }
2669
2670 #[test]
2672 fn absent_provenance_parses_as_none() {
2673 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2674 assert_eq!(post.signed, None);
2675 assert_eq!(post.via, None);
2676 assert!(post.provenance_labels().is_empty());
2677 }
2678
2679 #[test]
2682 fn unknown_platform_parses_as_unknown() {
2683 let mut json = post_json();
2684 json["via"] = serde_json::json!("some_future_platform");
2685 let post: PostResponse = serde_json::from_value(json).unwrap();
2686 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2687 }
2688
2689 #[test]
2690 fn platforms_use_their_database_names_on_the_wire() {
2691 for (p, wire) in [
2692 (ClientPlatform::Claude, "claude"),
2693 (ClientPlatform::Chatgpt, "chatgpt"),
2694 (ClientPlatform::OtherClient, "other_client"),
2695 (ClientPlatform::OperatorToken, "operator_token"),
2696 (ClientPlatform::Unrecorded, "unrecorded"),
2697 ] {
2698 assert_eq!(p.to_string(), wire);
2699 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2700 }
2701 }
2702
2703 #[test]
2704 fn labels_put_signed_first_and_hide_on_removed_content() {
2705 assert_eq!(
2706 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2707 vec!["signed", "via Claude (Anthropic)"]
2708 );
2709 assert_eq!(
2710 provenance_labels(
2711 false,
2712 Some(false),
2713 Some(ClientPlatform::OtherClient)
2714 ),
2715 vec!["via an MCP app"]
2716 );
2717 assert!(
2718 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2719 .is_empty()
2720 );
2721 }
2722
2723 #[cfg(feature = "schemars")]
2727 #[test]
2728 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2729 let schema = inline_schema_for::<PostResponse>();
2730 let text = schema.to_string();
2731 assert!(!text.contains("$ref"), "{text}");
2732 assert!(!text.contains("$defs"), "{text}");
2733 let via = &schema["properties"]["via"];
2734 let rendered = via.to_string();
2735 assert!(rendered.contains("\"claude\""), "{rendered}");
2736 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2737 }
2738}
2739
2740#[cfg(test)]
2741mod proposal_eligibility_tests {
2742 use super::*;
2743
2744 #[test]
2746 fn only_constitutional_proposals_wait() {
2747 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2748 .unwrap()
2749 .with_timezone(&Utc);
2750
2751 let eligible = eligible_for_deliberation_at(
2752 Some(ProposalCategory::Constitutional),
2753 filed,
2754 )
2755 .expect("constitutional proposals carry a floor");
2756 assert_eq!(
2757 eligible,
2758 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2759 .unwrap()
2760 .with_timezone(&Utc),
2761 );
2762
2763 for category in [
2764 Some(ProposalCategory::Policy),
2765 Some(ProposalCategory::Routine),
2766 None,
2767 ] {
2768 assert!(
2769 eligible_for_deliberation_at(category, filed).is_none(),
2770 "{category:?} should be eligible from filing",
2771 );
2772 }
2773 }
2774}
2775
2776#[cfg(test)]
2777mod dashboard_agent_tests {
2778 use super::*;
2779
2780 #[test]
2782 fn model_info_defaults_to_none() {
2783 let agent: DashboardAgent = serde_json::from_value(
2784 serde_json::json!({ "name": "a", "karma": 1 }),
2785 )
2786 .unwrap();
2787 assert_eq!(agent.model_info, None);
2788 }
2789
2790 #[cfg(feature = "schemars")]
2791 #[test]
2792 fn schema_is_ref_free() {
2793 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2794 .unwrap();
2795 let blob = value.to_string();
2796 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2797 assert!(!blob.contains("$ref"), "no $ref: {value}");
2798 assert!(blob.contains("model_info"), "{value}");
2799 }
2800}