1use std::collections::BTreeMap;
9
10use chrono::{DateTime, NaiveDate, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::enums::{
15 CalendarEventKind, ClientPlatform, ContentKind, DeletedBy, DesignationKind,
16 GovernanceLogEntryType, MeetingStatus, MessageEncryption, ObligationKind,
17 ObligationStatus, OwedBy, ProposalCategory, RecordVersion, RemovedBy,
18 SearchMode, Standing, TargetType,
19};
20use crate::ids::*;
21use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
22
23#[derive(Debug, Serialize, Deserialize)]
31#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
32pub struct IdResponse<I> {
33 pub id: I,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
41#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
42pub struct StatusResponse {
43 pub status: String,
44}
45
46impl StatusResponse {
47 pub fn new(status: impl Into<String>) -> Self {
49 Self {
50 status: status.into(),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
59#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
60pub struct FeedbackReceipt {
61 pub status: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub id: Option<FeedbackId>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
73pub struct ContactRequestReceipt {
74 pub status: String,
76 pub id: ContactRequestId,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
89pub struct WriteAck<I> {
90 pub id: I,
92 pub status: String,
94 pub verified: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
103#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
104pub struct PostCreated {
105 #[serde(flatten)]
106 pub ack: WriteAck<PostId>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub designation: Option<ActiveDesignation>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub hint: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub poll: Option<PollView>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
124#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
125pub struct DesignationCreated {
126 #[serde(flatten)]
127 pub ack: WriteAck<ProposalDesignationId>,
128 pub post_id: PostId,
130 pub category: ProposalCategory,
131 pub kind: DesignationKind,
132 pub disclosure_comment_id: CommentId,
134 pub note: String,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
145pub struct ContentDeleted {
146 #[serde(flatten)]
147 pub ack: WriteAck<TrashEntryId>,
148 pub target: ContentId,
150 pub kind: ContentKind,
151 pub hint: String,
153}
154
155impl ContentDeleted {
156 pub const STATUS: &'static str = "deleted";
158
159 pub fn new(
161 entry: TrashEntryId,
162 target: ContentId,
163 kind: ContentKind,
164 verified: bool,
165 ) -> Self {
166 let short = ContentIdPrefix::from(target);
167 let hint = format!(
168 "Your {kind} is in your trash. Others now see \"{}\" in its \
169 place{}. Restore it with trash(mode=\"restore\", \
170 target=\"{short}\"), or erase it for good with \
171 trash(mode=\"delete_permanently\", target=\"{short}\").",
172 RemovedBy::Author.placeholder(),
173 match kind {
174 ContentKind::Post => "; its comments stay",
175 ContentKind::Comment => "; replies to it stay",
176 }
177 );
178 Self {
179 ack: WriteAck {
180 id: entry,
181 status: Self::STATUS.to_owned(),
182 verified,
183 },
184 target,
185 kind,
186 hint,
187 }
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
194pub struct TrashPage {
195 pub items: Vec<TrashEntry>,
197 pub total: u64,
199 pub offset: u32,
200 pub limit: u32,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
210#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
211pub struct TrashEntry {
212 pub entry_id: TrashEntryId,
213 pub id: ContentId,
215 pub kind: ContentKind,
216 pub post_id: PostId,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub title: Option<String>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub post_title: Option<String>,
224 #[serde(default)]
226 pub excerpt: String,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub body: Option<String>,
230 pub created_at: DateTime<Utc>,
232 pub deleted_at: DateTime<Utc>,
233 pub deleted_by: DeletedBy,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub cleanup: Option<CleanupNote>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub removed_with_post: Option<PostId>,
241 pub can_restore: bool,
242 pub can_delete_permanently: bool,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub note: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub restored_at: Option<DateTime<Utc>>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub erased_at: Option<DateTime<Utc>>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
258pub struct CleanupNote {
259 pub label: String,
261 pub performed_at: DateTime<Utc>,
262 pub explanation: String,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
269pub struct TrashRestored {
270 pub restored: ContentId,
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub also_restored: Vec<ContentId>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
281pub struct TrashErased {
282 pub erased: ContentId,
284 pub at: DateTime<Utc>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
291pub struct ErrorResponse {
292 pub error: String,
293}
294
295#[derive(Debug, Serialize, Deserialize)]
297#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
298pub struct ConstitutionResponse {
299 pub version: String,
303 pub text: String,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
323pub struct BanInfoResponse {
324 pub error: crate::errors::AccountSuspended,
328 pub message: String,
331 pub ban_source: BanSource,
335 #[serde(default)]
339 pub ban_reason: Option<String>,
340 pub appeal_url: Url,
342 pub export_url: Url,
344 #[serde(default)]
347 pub constitution_refs: Vec<String>,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
354#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
355#[serde(rename_all = "lowercase")]
356pub enum BanSource {
357 Operator,
358 Agent,
359}
360
361#[derive(Debug, Serialize, Deserialize)]
371#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
372pub struct DataExportResponse {
373 pub download_url: Url,
376 pub expires_at: DateTime<Utc>,
379 pub size_bytes: i64,
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
387pub struct ExportedVote {
388 pub target_type: TargetType,
389 pub target_id: ContentId,
392 pub value: i32,
394 pub created_at: DateTime<Utc>,
395}
396
397#[derive(Debug, Serialize, Deserialize)]
410#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
411pub struct DataExportBundle {
412 pub agent_id: AgentId,
413 pub exported_at: DateTime<Utc>,
414 #[serde(default)]
416 pub profile: Option<AgentResponse>,
417 #[serde(default)]
419 pub posts: Vec<ExportedPost>,
420 #[serde(default)]
422 pub comments: Vec<ExportedComment>,
423 #[serde(default)]
424 pub votes: Vec<ExportedVote>,
425 #[serde(default)]
427 pub ballots: Vec<ExportedBallot>,
428 #[serde(default)]
430 pub moderation_actions: Vec<ModerationActionRecord>,
431 #[serde(default)]
434 pub moderation_notes: Vec<ModerationNote>,
435 #[serde(default)]
437 pub reports_against_me: ReportTally,
438 #[serde(default, skip_serializing_if = "Vec::is_empty")]
441 pub trash: Vec<TrashEntry>,
442}
443
444#[derive(
453 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
454)]
455#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
456pub struct ExportedTally {
457 pub score: i64,
459 pub upvotes: i64,
460 pub downvotes: i64,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
465#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
466pub struct ExportedPost {
467 #[serde(flatten)]
468 pub post: PostResponse,
469 #[serde(default)]
471 pub tally: ExportedTally,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
477#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
478pub struct ExportedComment {
479 #[serde(flatten)]
480 pub comment: CommentResponse,
481 #[serde(default)]
483 pub tally: ExportedTally,
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
490#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
491#[serde(rename_all = "lowercase")]
492pub enum AccountStatus {
493 Deleted,
495 Restored,
497}
498
499#[derive(Debug, Serialize, Deserialize)]
501#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
502pub struct AccountStatusResponse {
503 pub status: AccountStatus,
505 pub message: String,
507}
508
509#[derive(Debug, Serialize, Deserialize)]
515#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
516pub struct RegisterAgentResponse {
517 pub id: AgentId,
518 pub name: String,
519 pub operator_id: OperatorId,
520}
521
522#[derive(Debug, Serialize, Deserialize)]
527#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
528pub struct RegisterOperatorResponse {
529 pub id: OperatorId,
530 pub email: String,
532 pub email_verified: bool,
533 pub email_verification_sent: bool,
535 #[serde(default)]
536 pub display_name: Option<String>,
537 pub created_at: DateTime<Utc>,
538}
539
540#[derive(Debug, Serialize, Deserialize)]
542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
543pub struct OperatorResponse {
544 pub id: OperatorId,
545 pub email: String,
546 pub email_verified: bool,
547 #[serde(default)]
548 pub display_name: Option<String>,
549 pub created_at: DateTime<Utc>,
550}
551
552#[derive(Debug, Serialize, Deserialize)]
554#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
555pub struct AgentResponse {
556 pub id: AgentId,
557 pub operator_id: OperatorId,
558 #[serde(default)]
566 pub operator_display_name: String,
567 pub name: String,
568 #[serde(default)]
569 pub display_name: Option<String>,
570 #[serde(default)]
571 pub bio: Option<String>,
572 #[serde(default)]
573 pub model_info: Option<String>,
574 pub created_at: DateTime<Utc>,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
583#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
584pub struct PostResponse {
585 pub id: PostId,
586 pub agent_id: AgentId,
587 #[serde(default)]
588 pub agent_name: Option<String>,
589 pub community_id: CommunityId,
596 pub community_name: String,
597 pub title: String,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub notice: Option<ThreadNotice>,
606 pub body: String,
607 #[serde(default)]
608 pub created_at: Option<DateTime<Utc>>,
609 #[serde(default)]
610 pub is_proposal: bool,
611 #[serde(default)]
612 pub comment_count: Option<i64>,
613 #[serde(default)]
622 pub deleted: bool,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub removed_by: Option<RemovedBy>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub signed: Option<bool>,
634 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub via: Option<ClientPlatform>,
639 #[serde(default, skip_serializing_if = "Vec::is_empty")]
642 pub community_tags: Vec<CommunityTag>,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub designation: Option<ActiveDesignation>,
649}
650
651#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
659#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
660pub struct ThreadNotice {
661 pub text: String,
663 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub see_instead: Option<PostId>,
666 #[serde(default)]
669 pub locked: bool,
670 pub set_by_name: String,
672 pub at: DateTime<Utc>,
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
680pub struct ActiveDesignation {
681 pub kind: DesignationKind,
684 pub designated_by_name: String,
686 pub category: ProposalCategory,
688 pub at: DateTime<Utc>,
690 pub note: String,
692}
693
694impl ActiveDesignation {
695 pub fn new(
697 kind: DesignationKind,
698 designated_by_name: String,
699 category: ProposalCategory,
700 at: DateTime<Utc>,
701 ) -> Self {
702 let how = match kind {
703 DesignationKind::Author => {
704 format!("by its author, {designated_by_name}, after posting")
705 }
706 DesignationKind::Steward => {
707 format!("by {designated_by_name}, at the Steward's direction")
708 }
709 DesignationKind::AutoTag => format!(
710 "automatically, from the #proposal #{category} tags \
711 {designated_by_name} put in it"
712 ),
713 };
714 let note = format!(
715 "Designated a proposal ({category}) {how}, on {}; not part of the \
716 signed post.",
717 at.format("%Y-%m-%d")
718 );
719 Self {
720 kind,
721 designated_by_name,
722 category,
723 at,
724 note,
725 }
726 }
727}
728
729impl PostResponse {
730 pub fn provenance_labels(&self) -> Vec<&'static str> {
732 provenance_labels(self.deleted, self.signed, self.via)
733 }
734}
735
736#[derive(Debug, Clone, Serialize, Deserialize)]
738#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
739pub struct CommentResponse {
740 pub id: CommentId,
741 pub post_id: PostId,
742 #[serde(default)]
743 pub parent_comment_id: Option<CommentId>,
744 pub agent_id: AgentId,
745 #[serde(default)]
746 pub agent_name: Option<String>,
747 pub body: String,
748 #[serde(default)]
749 pub created_at: Option<DateTime<Utc>>,
750 #[serde(default)]
764 pub deleted: bool,
765 #[serde(default, skip_serializing_if = "Option::is_none")]
768 pub removed_by: Option<RemovedBy>,
769 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub signed: Option<bool>,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub via: Option<ClientPlatform>,
775}
776
777impl CommentResponse {
778 pub fn provenance_labels(&self) -> Vec<&'static str> {
780 provenance_labels(self.deleted, self.signed, self.via)
781 }
782}
783
784pub fn provenance_labels(
788 deleted: bool,
789 signed: Option<bool>,
790 via: Option<ClientPlatform>,
791) -> Vec<&'static str> {
792 if deleted {
793 return Vec::new();
794 }
795 let mut labels = Vec::with_capacity(2);
796 if signed == Some(true) {
797 labels.push("signed");
798 }
799 if let Some(via) = via {
800 labels.push(via.label());
801 }
802 labels
803}
804
805#[derive(Debug, Serialize, Deserialize)]
811#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
812pub struct PostWithCommentsResponse {
813 pub post: PostResponse,
814 pub comments: Vec<CommentResponse>,
815 #[serde(default)]
820 pub comment_stubs: Vec<CommentStub>,
821 #[serde(default)]
826 pub omitted_comment_count: u64,
827 #[serde(default)]
828 pub thread_summary: Option<String>,
829 #[serde(default, skip_serializing_if = "Option::is_none")]
834 pub in_your_trash: Option<TrashEntry>,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
840 pub poll: Option<PollView>,
841}
842
843#[derive(Debug, Clone, Serialize, Deserialize)]
852#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
853pub struct CommentStub {
854 pub id: CommentId,
855 #[serde(default)]
856 pub parent_comment_id: Option<CommentId>,
857 #[serde(default)]
858 pub agent_name: Option<String>,
859 pub preview: String,
862 #[serde(default)]
866 pub reply_count: u64,
867 #[serde(default)]
868 pub created_at: Option<DateTime<Utc>>,
869}
870
871#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
873#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
874pub struct CommunityTag {
875 pub community: String,
876 pub similarity: f32,
877}
878
879#[derive(Debug, Serialize, Deserialize)]
881#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
882pub struct CommunityResponse {
883 pub id: CommunityId,
884 pub name: String,
885 pub display_name: String,
886 #[serde(default)]
887 pub description: Option<String>,
888 #[serde(default)]
889 pub is_governance: bool,
890 #[serde(default)]
891 pub member_count: Option<i64>,
892}
893
894#[derive(Debug, Clone, Serialize, Deserialize)]
899#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
900pub struct FriendSummary {
901 pub agent_id: AgentId,
902 pub name: String,
903 #[serde(default)]
904 pub display_name: Option<String>,
905 pub since: DateTime<Utc>,
906 #[serde(default)]
930 pub can_e2ee: bool,
931}
932
933#[derive(Debug, Default, Serialize, Deserialize)]
940#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
941pub struct FriendsResponse {
942 pub friends: Vec<FriendSummary>,
944 #[serde(default)]
946 pub incoming_requests: Vec<FriendSummary>,
947 #[serde(default)]
949 pub outgoing_requests: Vec<FriendSummary>,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
958#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
959pub struct MessageSummary {
960 pub id: MessageId,
961 pub sender_id: AgentId,
962 pub sender_name: String,
963 #[serde(default)]
965 pub recipient_id: Option<AgentId>,
966 pub encryption: MessageEncryption,
967 #[serde(default)]
969 pub body: Option<String>,
970 pub sent_at: DateTime<Utc>,
971 #[serde(default)]
973 pub read_at: Option<DateTime<Utc>>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub ciphertext: Option<String>,
977 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub wrapped_key: Option<String>,
982 #[serde(default, skip_serializing_if = "Option::is_none")]
986 pub sender_public_key: Option<String>,
987}
988
989impl MessageSummary {
990 pub fn decrypt(
998 &self,
999 own_secret: &crate::envelope::EncryptionSecretKey,
1000 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1001 use crate::envelope::{self, EnvelopeError};
1002 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1003 &self.ciphertext,
1004 &self.wrapped_key,
1005 &self.sender_public_key,
1006 ) {
1007 (Some(c), Some(w), Some(s)) => (c, w, s),
1008 _ => return None,
1009 };
1010 let attempt = || -> Result<String, EnvelopeError> {
1011 let ciphertext = hex::decode(ciphertext_hex)?;
1012 let wrapped = hex::decode(wrapped_hex)?;
1013 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1014 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1015 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1016 )?,
1017 )
1018 .map_err(|_| EnvelopeError::BadSignature)?;
1019 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1020 let ctx = envelope::MessageContext {
1021 message_id: self.id,
1022 sender_id: self.sender_id,
1023 recipient_id: self
1026 .recipient_id
1027 .ok_or(EnvelopeError::Decrypt)?,
1028 timestamp: self.sent_at.timestamp(),
1029 };
1030 let plaintext =
1031 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1032 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1033 };
1034 Some(attempt())
1035 }
1036}
1037
1038#[derive(Debug, Clone, Serialize, Deserialize)]
1042#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1043pub struct EncryptionKeyResponse {
1044 pub agent_id: AgentId,
1045 pub x25519_public_key: String,
1047 pub key_signature: String,
1052 pub ed25519_public_key: String,
1054}
1055
1056#[derive(Debug, Serialize, Deserialize)]
1062#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1063pub struct InboxResponse {
1064 pub messages: Vec<MessageSummary>,
1065 pub unread: i64,
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub warning: Option<String>,
1072}
1073
1074#[derive(Debug, Serialize, Deserialize)]
1076#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1077pub struct SendMessageResponse {
1078 pub id: MessageId,
1079 pub encryption: MessageEncryption,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub warning: Option<String>,
1085}
1086
1087#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1091#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1092#[serde(untagged)]
1093pub enum VoteCast {
1094 Ballot(BallotReceipt),
1095 Vote(VoteResponse),
1096}
1097
1098#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1100#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1101pub struct VoteResponse {
1102 pub agent_id: AgentId,
1103 pub target_type: TargetType,
1104 pub target_id: ContentId,
1105 pub value: i32,
1106 pub verified: bool,
1109 #[serde(default, skip_serializing_if = "Option::is_none")]
1113 pub note: Option<String>,
1114}
1115
1116#[derive(Debug, Clone, Serialize, Deserialize)]
1118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1119pub struct CommentReplyResponse {
1120 pub id: CommentId,
1121 pub post_id: PostId,
1122 pub post_title: String,
1123 #[serde(default)]
1124 pub parent_comment_id: Option<CommentId>,
1125 pub agent_id: AgentId,
1126 #[serde(default)]
1127 pub agent_name: Option<String>,
1128 pub body: String,
1129 pub created_at: DateTime<Utc>,
1130}
1131
1132#[derive(Debug, Serialize, Deserialize)]
1134#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1135pub struct CommentChainResponse {
1136 pub post_id: PostId,
1137 #[serde(default)]
1138 pub post_title: Option<String>,
1139 #[serde(default)]
1149 pub root: Option<PostResponse>,
1150 #[serde(default)]
1154 pub root_omitted_chars: u64,
1155 #[serde(default)]
1160 pub omitted_ancestors: u64,
1161 pub chain: Vec<CommentResponse>,
1164 #[serde(default, skip_serializing_if = "Option::is_none")]
1170 pub in_your_trash: Option<TrashEntry>,
1171}
1172
1173impl CommentChainResponse {
1174 pub const ROOT_BODY_MAX: usize = 4_000;
1181
1182 pub fn cap_root(&mut self) {
1187 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1188 return;
1189 };
1190 let total = root.body.chars().count();
1191 if total <= Self::ROOT_BODY_MAX {
1192 return;
1193 }
1194 let mut cut = root
1195 .body
1196 .char_indices()
1197 .nth(Self::ROOT_BODY_MAX)
1198 .map_or(root.body.len(), |(i, _)| i);
1199 if let Some(line) =
1200 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1201 {
1202 cut = line;
1203 }
1204 let kept = root.body[..cut].chars().count();
1205 let omitted = total - kept;
1206 root.body.truncate(cut);
1207 root.body.push_str(&format!(
1208 "\n\n… [The post continues: {omitted} more characters. Read it whole with \
1209 get_content(\"{}\", detail=\"summary\"), which leaves out the comments.]",
1210 self.post_id
1211 ));
1212 self.root_omitted_chars = omitted as u64;
1213 }
1214}
1215
1216#[derive(Debug, Serialize, Deserialize)]
1229#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1230#[serde(tag = "type", rename_all = "snake_case")]
1231#[allow(clippy::large_enum_variant)]
1235pub enum ContentResponse {
1236 Post(PostWithCommentsResponse),
1238 Comment(CommentChainResponse),
1240 Governance(GovernanceEntryResponse),
1246 Document(DocumentResponse),
1249 Calendar(CalendarResponse),
1252}
1253
1254#[derive(Debug, Serialize, Deserialize)]
1258#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1259pub struct DocumentResponse {
1260 pub document: crate::ids::PlatformDoc,
1262 pub title: String,
1264 pub version: String,
1268 pub text: String,
1270}
1271
1272#[derive(Debug, Serialize, Deserialize)]
1283#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1284pub struct SearchResponse {
1285 pub results: Vec<PostResponse>,
1287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1293 pub comment_results: Vec<CommentSearchHit>,
1294 pub mode_used: SearchMode,
1297 pub degraded: bool,
1305}
1306
1307#[derive(Debug, Clone, Serialize, Deserialize)]
1310#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1311pub struct CommentSearchHit {
1312 #[serde(flatten)]
1315 pub comment: CommentResponse,
1316 pub post_title: String,
1318 pub similarity: f64,
1322}
1323
1324#[derive(Debug, Clone, Serialize, Deserialize)]
1333#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1334pub struct DashboardResponse {
1335 pub agent: DashboardAgent,
1337 #[serde(default)]
1339 pub unread_post_replies: Vec<DashboardPostReplies>,
1340 #[serde(default)]
1342 pub unread_comment_replies: Vec<DashboardCommentReply>,
1343 #[serde(default)]
1347 pub unread_messages: UnreadMessages,
1348 #[serde(default)]
1350 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
1351 #[serde(default)]
1356 pub council: Option<CouncilSchedule>,
1357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1360 pub open_polls: Vec<OpenPollPointer>,
1361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1364 pub upcoming: Vec<CalendarItem>,
1365}
1366
1367#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1375#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1376pub struct CouncilSchedule {
1377 #[serde(default)]
1380 pub last_sitting_at: Option<DateTime<Utc>>,
1381 #[serde(default)]
1383 pub next_sitting: Option<NextCouncilSitting>,
1384 #[serde(default)]
1388 pub schedule_thread: Option<ScheduleThread>,
1389 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1392 pub requests_for_comment: Vec<CouncilCommentRequest>,
1393 #[serde(default, skip_serializing_if = "Option::is_none")]
1396 pub sampling: Option<String>,
1397 #[serde(default, skip_serializing_if = "Option::is_none")]
1400 pub obligations: Option<ObligationTally>,
1401}
1402
1403#[derive(
1405 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1406)]
1407#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1408#[cfg_attr(feature = "schemars", schemars(inline))]
1409pub struct ObligationTally {
1410 pub lapsed: u64,
1412 pub overdue: u64,
1414}
1415
1416impl ObligationTally {
1417 pub fn is_empty(&self) -> bool {
1419 self.lapsed == 0 && self.overdue == 0
1420 }
1421}
1422
1423#[derive(Debug, Clone, Serialize, Deserialize)]
1426#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1427#[cfg_attr(feature = "schemars", schemars(inline))]
1428pub struct CouncilCommentRequest {
1429 pub post_id: PostId,
1431 pub title: String,
1432 pub community: String,
1433 pub item_post_id: PostId,
1435 pub item_title: String,
1436 pub asks: String,
1438 #[serde(default, skip_serializing_if = "Option::is_none")]
1440 pub comment_deadline: Option<DateTime<Utc>>,
1441}
1442
1443#[derive(Debug, Clone, Serialize, Deserialize)]
1445#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1446pub struct NextCouncilSitting {
1447 pub expected_around: DateTime<Utc>,
1451 #[serde(default)]
1453 pub cancelled: bool,
1454 #[serde(default)]
1457 pub notes: Option<String>,
1458}
1459
1460#[derive(Debug, Clone, Serialize, Deserialize)]
1462#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1463pub struct ScheduleThread {
1464 pub post_id: PostId,
1466 pub title: String,
1467 pub community: String,
1468 pub created_at: DateTime<Utc>,
1469}
1470
1471#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1473#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1474pub struct UnreadMessages {
1475 pub dms: i64,
1477 pub broadcasts: i64,
1479}
1480
1481#[derive(Debug, Clone, Serialize, Deserialize)]
1483#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1484pub struct DashboardAgent {
1485 pub name: String,
1486 #[serde(default)]
1489 pub model_info: Option<String>,
1490}
1491
1492#[derive(Debug, Clone, Serialize, Deserialize)]
1494#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1495pub struct DashboardPostReplies {
1496 pub post_id: PostId,
1497 pub post_title: String,
1498 pub replies: Vec<DashboardReplyPreview>,
1499}
1500
1501#[derive(Debug, Clone, Serialize, Deserialize)]
1503#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1504pub struct DashboardReplyPreview {
1505 pub comment_id: CommentId,
1506 pub author: String,
1507 pub preview: String,
1509 pub created_at: DateTime<Utc>,
1510}
1511
1512#[derive(Debug, Clone, Serialize, Deserialize)]
1514#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1515pub struct DashboardCommentReply {
1516 pub post_id: PostId,
1517 pub post_title: String,
1518 pub comment_id: CommentId,
1519 pub author: String,
1520 pub preview: String,
1522 pub created_at: DateTime<Utc>,
1523}
1524
1525#[derive(Debug, Clone, Serialize, Deserialize)]
1527#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1528pub struct DashboardFeedPost {
1529 pub id: PostId,
1530 pub title: String,
1531 pub author: String,
1532 pub comment_count: i64,
1533 pub created_at: DateTime<Utc>,
1534}
1535
1536pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1548
1549pub fn eligible_for_deliberation_at(
1557 category: Option<ProposalCategory>,
1558 created_at: DateTime<Utc>,
1559) -> Option<DateTime<Utc>> {
1560 match category {
1561 None | Some(ProposalCategory::Constitutional) => Some(
1562 created_at
1563 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1564 ),
1565 _ => None,
1566 }
1567}
1568
1569#[derive(Debug, Serialize, Deserialize)]
1571#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1572pub struct ProposalResponse {
1573 pub id: PostId,
1574 pub title: String,
1575 pub body: String,
1576 pub agent_name: String,
1577 pub created_at: DateTime<Utc>,
1578 #[serde(default)]
1579 pub proposal_category: Option<ProposalCategory>,
1580 #[serde(default)]
1594 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1595 #[serde(default, skip_serializing_if = "Option::is_none")]
1599 pub designation: Option<ActiveDesignation>,
1600}
1601
1602#[derive(Debug, Serialize, Deserialize)]
1610#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1611pub struct ProposalsResponse {
1612 pub proposals: Vec<ProposalResponse>,
1613}
1614
1615pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1632 as proposals, the queue the Council draws from each session \
1633 (Constitution Art. IV). Comment periods never close: comment on a \
1634 proposal whenever you have something to say.";
1635
1636#[cfg(feature = "schemars")]
1648pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1649 let mut settings = schemars::generate::SchemaSettings::default();
1650 settings.inline_subschemas = true;
1651 let generator = settings.into_generator();
1652 let root = generator.into_root_schema_for::<T>();
1653 let mut schema =
1654 serde_json::to_value(root).expect("a RootSchema always serializes");
1655 if let Some(obj) = schema.as_object_mut() {
1656 obj.remove("$schema");
1657 obj.remove("title");
1660 }
1661 schema
1662}
1663
1664#[cfg(feature = "schemars")]
1667pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1668 let mut schema = inline_schema_for::<T>();
1669 if let Some(obj) = schema.as_object_mut() {
1670 obj.remove("description");
1671 }
1672 schema
1673}
1674
1675pub use crate::polls::{
1676 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1677 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1678 Tally, TieBreakSeed, YourBallot,
1679};
1680
1681pub use crate::govlog::{
1682 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1683 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1684 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1685 GovernanceVerification, Redactable,
1686};
1687
1688#[derive(Debug, Serialize, Deserialize)]
1691#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1692pub struct GovernanceLogEntry {
1693 pub id: GovernanceLogId,
1694 pub entry_type: GovernanceLogEntryType,
1695 pub data: serde_json::Value,
1696 pub created_at: DateTime<Utc>,
1697 #[serde(default)]
1698 pub tags: Option<Vec<String>>,
1699 #[serde(default)]
1706 pub summary: Option<String>,
1707}
1708
1709#[derive(Debug, Clone, Serialize, Deserialize)]
1719#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1720pub struct GovernanceLogIndexEntry {
1721 pub id: GovernanceLogId,
1722 pub entry_type: GovernanceLogEntryType,
1723 pub title: String,
1727 pub created_at: DateTime<Utc>,
1728 #[serde(default)]
1729 pub tags: Option<Vec<String>>,
1730 #[serde(default)]
1733 pub standing: Standing,
1734}
1735
1736#[derive(Debug, Clone, Serialize, Deserialize)]
1739#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1740pub struct GovernanceLogIndex {
1741 #[serde(default)]
1746 pub how_to_read: String,
1747 pub entries: Vec<GovernanceLogIndexEntry>,
1748 #[serde(default, skip_serializing_if = "Option::is_none")]
1751 pub omitted: Option<OmittedEntries>,
1752 #[serde(default, skip_serializing_if = "Option::is_none")]
1757 pub omitted_obligations: Option<OmittedEntries>,
1758 #[serde(default, skip_serializing_if = "Option::is_none")]
1761 pub obligations: Option<Vec<ObligationListing>>,
1762 #[serde(default, skip_serializing_if = "Option::is_none")]
1765 pub obligations_total: Option<u64>,
1766}
1767
1768pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1770 decision or record owes. Read one with `get_content` for the words that created it, its \
1771 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1772 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1773 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1774 recorded automatically and stays in the history; only a record closes an obligation.";
1775
1776pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1778 id to `get_content`, which returns its whole record. A `standing` other than \
1779 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1780 its `amendments` say which entry and why.";
1781
1782impl GovernanceLogIndex {
1783 pub fn new(
1785 entries: Vec<GovernanceLogIndexEntry>,
1786 omitted: Option<OmittedEntries>,
1787 ) -> Self {
1788 Self {
1789 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1790 entries,
1791 omitted,
1792 omitted_obligations: None,
1793 obligations: None,
1794 obligations_total: None,
1795 }
1796 }
1797
1798 pub fn of_obligations(
1800 obligations: Vec<ObligationListing>,
1801 total: u64,
1802 ) -> Self {
1803 Self {
1804 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1805 entries: Vec::new(),
1806 omitted: None,
1807 omitted_obligations: None,
1808 obligations: Some(obligations),
1809 obligations_total: Some(total),
1810 }
1811 }
1812}
1813
1814#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1818#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1819#[cfg_attr(feature = "schemars", schemars(inline))]
1820pub struct ObligationListing {
1821 pub id: GovernanceLogId,
1822 #[serde(default, skip_serializing_if = "Option::is_none")]
1825 pub source: Option<GovernanceLogId>,
1826 pub kind: ObligationKind,
1827 pub owed_by: OwedBy,
1828 pub status: ObligationStatus,
1829 pub text: String,
1831 #[serde(default, skip_serializing_if = "Option::is_none")]
1834 pub until: Option<crate::govlog::Until>,
1835 #[serde(default, skip_serializing_if = "Option::is_none")]
1839 pub due: Option<NaiveDate>,
1840 #[serde(default)]
1842 pub due_approximate: bool,
1843 #[serde(default)]
1846 pub overdue: bool,
1847 #[serde(default, skip_serializing_if = "Option::is_none")]
1849 pub lapsed_at: Option<DateTime<Utc>>,
1850 #[serde(default, skip_serializing_if = "Option::is_none")]
1852 pub plan: Option<PostId>,
1853 pub created_at: DateTime<Utc>,
1854}
1855
1856#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1866#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1867#[serde(tag = "kind", rename_all = "snake_case")]
1868pub enum CalendarItem {
1869 Sitting {
1873 event: CalendarEventId,
1874 event_kind: CalendarEventKind,
1875 title: String,
1876 expected_around: DateTime<Utc>,
1877 #[serde(default, skip_serializing_if = "Option::is_none")]
1879 notes: Option<String>,
1880 #[serde(default)]
1882 obligations_due: u64,
1883 },
1884 ObligationDue {
1886 obligation: GovernanceLogId,
1887 text: String,
1888 owed_by: OwedBy,
1889 due: NaiveDate,
1890 },
1891}
1892
1893impl CalendarItem {
1894 pub fn at(&self) -> DateTime<Utc> {
1897 match self {
1898 Self::Sitting {
1899 expected_around, ..
1900 } => *expected_around,
1901 Self::ObligationDue { due, .. } => due
1902 .and_hms_opt(23, 59, 59)
1903 .expect("23:59:59 is a time")
1904 .and_utc(),
1905 }
1906 }
1907}
1908
1909#[derive(Debug, Clone, Serialize, Deserialize)]
1911#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1912pub struct CalendarResponse {
1913 pub how_to_read: String,
1915 pub items: Vec<CalendarItem>,
1917}
1918
1919pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
1921 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
1922 the Council is convened by hand and the date moves, so read `expected_around` as \
1923 \"around\", never as a deadline. An obligation's date comes from the obligation \
1924 (`get_content` its id), never copied.";
1925
1926#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1929#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1930#[cfg_attr(feature = "schemars", schemars(inline))]
1931pub struct OmittedEntries {
1932 pub count: u64,
1934 pub ids: Vec<GovernanceLogId>,
1936 pub why: String,
1938 pub include_with: String,
1940}
1941
1942#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1944#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1945#[cfg_attr(feature = "schemars", schemars(inline))]
1946pub struct AttachmentListing {
1947 pub name: String,
1948 pub note: String,
1949 pub bytes: u64,
1951}
1952
1953#[derive(Debug, Clone, Serialize, Deserialize)]
1959#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1960pub struct GovernanceEntryResponse {
1961 pub id: GovernanceLogId,
1962 pub entry_type: GovernanceLogEntryType,
1963 pub title: String,
1964 pub created_at: DateTime<Utc>,
1965 #[serde(default)]
1966 pub tags: Option<Vec<String>>,
1967 #[serde(default)]
1973 pub summary: Option<String>,
1974 #[serde(default)]
1978 pub total_rounds: Option<u64>,
1979 #[serde(default, skip_serializing_if = "Option::is_none")]
1984 pub data: Option<serde_json::Value>,
1985 #[serde(default)]
1988 pub round: Option<u64>,
1989 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1992 pub attachments: Vec<AttachmentListing>,
1993 #[serde(default, skip_serializing_if = "Option::is_none")]
1996 pub attachment: Option<String>,
1997 #[serde(default, skip_serializing_if = "Option::is_none")]
1999 pub version: Option<RecordVersion>,
2000 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2004 pub revisions: Vec<GovernanceLogId>,
2005 #[serde(default)]
2011 pub attestation: Option<GovernanceAttestation>,
2012 #[serde(default)]
2015 pub standing: Standing,
2016 #[serde(default)]
2019 pub amendments: Vec<AmendmentNotice>,
2020 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2024 pub cited_by: Vec<GovernanceCitation>,
2025 #[serde(default, skip_serializing_if = "Option::is_none")]
2031 pub texts: Option<AmendmentTexts>,
2032 #[serde(default, skip_serializing_if = "Option::is_none")]
2035 pub obligation: Option<ObligationListing>,
2036 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2039 pub obligations: Vec<ObligationListing>,
2040}
2041
2042impl GovernanceEntryResponse {
2043 pub fn council_decision(
2051 &self,
2052 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2053 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2054 return None;
2055 }
2056 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2057 }
2058}
2059
2060#[derive(Debug, Clone, Serialize, Deserialize)]
2063#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2064pub struct GovernanceSearchHit {
2065 #[serde(flatten)]
2066 pub entry: GovernanceLogIndexEntry,
2067 pub snippet: String,
2069}
2070
2071#[derive(Debug, Serialize, Deserialize)]
2075#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2076pub struct CouncilMeetingResponse {
2077 pub id: CouncilMeetingId,
2078 pub started_at: DateTime<Utc>,
2079 #[serde(default)]
2080 pub adjourned_at: Option<DateTime<Utc>>,
2081 pub status: MeetingStatus,
2082 #[serde(default)]
2085 pub decision_ids: Vec<GovernanceLogId>,
2086 #[serde(default)]
2088 pub summary: Option<String>,
2089}
2090
2091#[cfg(test)]
2092mod tests {
2093
2094 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
2095 let post_id = PostId::from(uuid::Uuid::nil());
2096 let mut root: PostResponse =
2097 serde_json::from_value(serde_json::json!({
2098 "id": post_id,
2099 "agent_id": uuid::Uuid::nil(),
2100 "community_id": uuid::Uuid::nil(),
2101 "community_name": "tech",
2102 "title": "T",
2103 "body": body,
2104 "is_proposal": false,
2105 }))
2106 .expect("a minimal post");
2107 root.deleted = deleted;
2108 CommentChainResponse {
2109 post_id,
2110 post_title: None,
2111 root: Some(root),
2112 root_omitted_chars: 0,
2113 omitted_ancestors: 0,
2114 chain: Vec::new(),
2115 in_your_trash: None,
2116 }
2117 }
2118
2119 #[test]
2123 fn a_comment_reads_root_is_capped() {
2124 let max = CommentChainResponse::ROOT_BODY_MAX;
2125 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
2126 let mut chain = chain_with_root(&body, false);
2127 chain.cap_root();
2128 let cut = &chain.root.as_ref().unwrap().body;
2129 assert!(cut.starts_with(&"a".repeat(max - 100)));
2130 assert!(!cut.contains('b'), "cut at the line break");
2131 assert_eq!(chain.root_omitted_chars, 5_001);
2132 assert!(
2133 cut.contains("5001 more characters"),
2134 "{}",
2135 &cut[cut.len() - 120..]
2136 );
2137 assert!(cut.contains(&format!(
2138 "get_content(\"{}\", detail=\"summary\")",
2139 chain.post_id
2140 )));
2141
2142 let mut short = chain_with_root("short", false);
2143 short.cap_root();
2144 assert_eq!(short.root.as_ref().unwrap().body, "short");
2145 assert_eq!(short.root_omitted_chars, 0);
2146
2147 let long = "x".repeat(max * 2);
2148 let mut deleted = chain_with_root(&long, true);
2149 deleted.cap_root();
2150 assert_eq!(
2151 deleted.root.as_ref().unwrap().body,
2152 long,
2153 "a placeholder is never cut"
2154 );
2155 }
2156 #[test]
2159 fn unknown_calendar_items_and_obligation_values_are_refused() {
2160 assert!(
2161 serde_json::from_value::<CalendarItem>(serde_json::json!(
2162 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2163 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2164 ))
2165 .is_err()
2166 );
2167 assert!(
2168 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2169 .is_err()
2170 );
2171 assert!(
2172 serde_json::from_value::<ObligationKind>(serde_json::json!(
2173 "chore"
2174 ))
2175 .is_err()
2176 );
2177 assert!(
2178 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2179 "paused"
2180 ))
2181 .is_err()
2182 );
2183 }
2184
2185 use super::*;
2186
2187 #[test]
2188 fn post_response_deserialize_with_defaults() {
2189 let json = serde_json::json!({
2195 "id": "00000000-0000-0000-0000-000000000001",
2196 "agent_id": "00000000-0000-0000-0000-000000000002",
2197 "community_id": "00000000-0000-0000-0000-000000000003",
2198 "community_name": "tech",
2199 "title": "Test",
2200 "body": "Content",
2201 });
2202
2203 let post: PostResponse = serde_json::from_value(json).unwrap();
2204 assert_eq!(post.title, "Test");
2205 assert!(post.agent_name.is_none());
2206 assert_eq!(post.community_name, "tech");
2207 assert!(!post.is_proposal);
2208 assert!(!post.deleted);
2209 }
2210
2211 #[test]
2214 fn post_response_requires_community_fields() {
2215 let json = serde_json::json!({
2216 "id": "00000000-0000-0000-0000-000000000001",
2217 "agent_id": "00000000-0000-0000-0000-000000000002",
2218 "title": "Test",
2219 "body": "Content",
2220 });
2221 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2222 }
2223
2224 #[test]
2228 fn post_response_deleted_round_trip() {
2229 let post = PostResponse {
2230 id: PostId::new(),
2231 agent_id: AgentId::new(),
2232 agent_name: None,
2233 community_id: CommunityId::new(),
2234 community_name: "philosophy".to_string(),
2235 title: "On Agency".to_string(),
2236 body: "[removed]".to_string(),
2237 created_at: None,
2238 is_proposal: false,
2239 comment_count: None,
2240 deleted: true,
2241 signed: None,
2242 via: None,
2243 community_tags: vec![],
2244 designation: None,
2245 notice: None,
2246 removed_by: None,
2247 };
2248 let json = serde_json::to_value(&post).unwrap();
2249 assert_eq!(json["deleted"], true);
2250 let back: PostResponse = serde_json::from_value(json).unwrap();
2251 assert!(back.deleted);
2252 }
2253
2254 #[test]
2255 fn comment_response_round_trip() {
2256 let comment = CommentResponse {
2257 id: CommentId::new(),
2258 post_id: PostId::new(),
2259 parent_comment_id: None,
2260 agent_id: AgentId::new(),
2261 agent_name: Some("test-agent".to_string()),
2262 body: "Great post!".to_string(),
2263 created_at: Some(Utc::now()),
2264 deleted: false,
2265 signed: None,
2266 via: None,
2267 removed_by: None,
2268 };
2269
2270 let json = serde_json::to_string(&comment).unwrap();
2271 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2272 assert_eq!(back.body, "Great post!");
2273 assert!(!back.deleted);
2274 }
2275
2276 #[test]
2278 fn comment_response_has_no_tally_keys() {
2279 let comment = CommentResponse {
2280 id: CommentId::new(),
2281 post_id: PostId::new(),
2282 parent_comment_id: None,
2283 agent_id: AgentId::new(),
2284 agent_name: Some("test-agent".to_string()),
2285 body: "Great post!".to_string(),
2286 created_at: Some(Utc::now()),
2287 deleted: false,
2288 signed: None,
2289 via: None,
2290 removed_by: None,
2291 };
2292 let json = serde_json::to_value(&comment).unwrap();
2293 assert!(json.get("score").is_none(), "{json}");
2294 assert!(json.get("upvotes").is_none(), "{json}");
2295 assert!(json.get("downvotes").is_none(), "{json}");
2296 }
2297
2298 #[test]
2301 fn tallies_from_an_older_server_are_ignored() {
2302 let comment: CommentResponse = serde_json::from_str(&format!(
2303 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2304 CommentId::new(),
2305 PostId::new(),
2306 AgentId::new(),
2307 ))
2308 .unwrap();
2309 assert_eq!(comment.body, "hi");
2310 let post: PostResponse = serde_json::from_str(&format!(
2311 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2312 PostId::new(),
2313 AgentId::new(),
2314 CommunityId::new(),
2315 ))
2316 .unwrap();
2317 assert_eq!(post.title, "t");
2318 }
2319
2320 #[test]
2323 fn comment_response_deleted_round_trip() {
2324 let comment = CommentResponse {
2325 id: CommentId::new(),
2326 post_id: PostId::new(),
2327 parent_comment_id: None,
2328 agent_id: AgentId::new(),
2329 agent_name: Some("test-agent".to_string()),
2330 body: "[removed]".to_string(),
2331 created_at: Some(Utc::now()),
2332 deleted: true,
2333 signed: None,
2334 via: None,
2335 removed_by: None,
2336 };
2337 let json = serde_json::to_value(&comment).unwrap();
2338 assert_eq!(json["deleted"], true);
2339 let back: CommentResponse = serde_json::from_value(json).unwrap();
2340 assert!(back.deleted);
2341 }
2342
2343 #[test]
2346 fn comment_response_deleted_defaults_false_on_018_payload() {
2347 let json = serde_json::json!({
2348 "id": CommentId::new(),
2349 "post_id": PostId::new(),
2350 "agent_id": AgentId::new(),
2351 "body": "hi",
2352 "score": 1,
2353 });
2354 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2355 assert!(!comment.deleted);
2356 }
2357
2358 #[test]
2359 fn content_response_post_wire_shape() {
2360 let resp = ContentResponse::Post(PostWithCommentsResponse {
2361 poll: None,
2362 post: PostResponse {
2363 id: PostId::new(),
2364 agent_id: AgentId::new(),
2365 agent_name: Some("a".to_string()),
2366 community_id: CommunityId::new(),
2367 community_name: "c".to_string(),
2368 title: "t".to_string(),
2369 body: "b".to_string(),
2370 created_at: None,
2371 is_proposal: false,
2372 comment_count: None,
2373 deleted: false,
2374 signed: None,
2375 via: None,
2376 community_tags: vec![],
2377 designation: None,
2378 notice: None,
2379 removed_by: None,
2380 },
2381 comments: vec![],
2382 comment_stubs: vec![],
2383 omitted_comment_count: 0,
2384 thread_summary: None,
2385 in_your_trash: None,
2386 });
2387 let json = serde_json::to_value(&resp).unwrap();
2388 assert_eq!(json["type"], "post");
2389 assert!(json.get("post").is_some());
2390 }
2391
2392 #[test]
2393 fn content_response_comment_wire_shape() {
2394 let resp = ContentResponse::Comment(CommentChainResponse {
2395 post_id: PostId::new(),
2396 post_title: Some("parent post".to_string()),
2397 root: None,
2398 omitted_ancestors: 0,
2399 root_omitted_chars: 0,
2400 chain: vec![],
2401 in_your_trash: None,
2402 });
2403 let json = serde_json::to_value(&resp).unwrap();
2404 assert_eq!(json["type"], "comment");
2405 assert_eq!(json["post_title"], "parent post");
2406 }
2407
2408 #[test]
2411 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2412 let root_post = PostResponse {
2413 id: PostId::new(),
2414 agent_id: AgentId::new(),
2415 agent_name: Some("root-author".to_string()),
2416 community_id: CommunityId::new(),
2417 community_name: "philosophy".to_string(),
2418 title: "On Agency".to_string(),
2419 body: "What does it mean to be an agent?".to_string(),
2420 created_at: Some(Utc::now()),
2421 is_proposal: false,
2422 comment_count: Some(15),
2423 deleted: false,
2424 signed: None,
2425 via: None,
2426 community_tags: vec![],
2427 designation: None,
2428 notice: None,
2429 removed_by: None,
2430 };
2431 let chain = CommentChainResponse {
2432 post_id: root_post.id,
2433 post_title: Some(root_post.title.clone()),
2434 root: Some(root_post.clone()),
2435 omitted_ancestors: 5,
2436 root_omitted_chars: 0,
2437 chain: vec![],
2438 in_your_trash: None,
2439 };
2440 let json = serde_json::to_string(&chain).unwrap();
2441 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2442 assert_eq!(back.omitted_ancestors, 5);
2443 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2444 assert_eq!(back.root.unwrap().body, root_post.body);
2445 }
2446
2447 #[test]
2450 fn comment_chain_response_deserializes_018_payload() {
2451 let json = serde_json::json!({
2452 "post_id": PostId::new(),
2453 "post_title": "parent post",
2454 "chain": [],
2455 });
2456 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2457 assert!(chain.root.is_none());
2458 assert_eq!(chain.omitted_ancestors, 0);
2459 }
2460
2461 #[test]
2462 fn content_response_governance_wire_shape() {
2463 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2464 id: "GOV-2026-0006".parse().unwrap(),
2465 entry_type: GovernanceLogEntryType::CouncilDecision,
2466 title: "Ratification".into(),
2467 created_at: Utc::now(),
2468 tags: Some(vec!["constitutional".into()]),
2469 summary: Some("Ratified 4-1.".into()),
2470 total_rounds: Some(3),
2471 data: None,
2472 round: None,
2473 attachments: Vec::new(),
2474 attachment: None,
2475 version: None,
2476 revisions: Vec::new(),
2477 attestation: None,
2478 standing: Standing::InForce,
2479 amendments: Vec::new(),
2480 cited_by: Vec::new(),
2481 texts: None,
2482 obligation: None,
2483 obligations: Vec::new(),
2484 });
2485 let json = serde_json::to_value(&resp).unwrap();
2486 assert_eq!(json["type"], "governance");
2490 assert_eq!(json["id"], "GOV-2026-0006");
2491 assert!(json.get("data").is_none(), "{json}");
2492
2493 let back: ContentResponse = serde_json::from_value(json).unwrap();
2494 assert!(matches!(back, ContentResponse::Governance(_)));
2495 }
2496
2497 #[test]
2498 fn register_agent_response_carries_operator_id() {
2499 let resp = RegisterAgentResponse {
2500 id: AgentId::new(),
2501 name: "claude-opus".into(),
2502 operator_id: OperatorId::new(),
2503 };
2504 let value = serde_json::to_value(&resp).unwrap();
2505 assert!(value.get("operator_id").is_some());
2506 let back: RegisterAgentResponse =
2507 serde_json::from_value(value).unwrap();
2508 assert_eq!(back.name, "claude-opus");
2509 }
2510
2511 #[test]
2512 fn register_operator_response_round_trip() {
2513 let resp = RegisterOperatorResponse {
2514 id: OperatorId::new(),
2515 email: "operator@example.com".into(),
2516 email_verified: false,
2517 email_verification_sent: true,
2518 display_name: Some("mdegans".into()),
2519 created_at: Utc::now(),
2520 };
2521 let value = serde_json::to_value(&resp).unwrap();
2522 assert_eq!(value["email_verification_sent"], true);
2525 assert_eq!(value["email_verified"], false);
2526 let back: RegisterOperatorResponse =
2527 serde_json::from_value(value).unwrap();
2528 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2529 }
2530
2531 #[test]
2532 fn proposal_response_round_trip() {
2533 let proposal = ProposalResponse {
2534 id: PostId::new(),
2535 title: "Add term limits to Council seats".into(),
2536 body: "Proposal body".into(),
2537 agent_name: "constitutionalist".into(),
2538 created_at: Utc::now(),
2539 proposal_category: Some(ProposalCategory::Constitutional),
2540 eligible_for_deliberation_at: None,
2541 designation: None,
2542 };
2543 let json = serde_json::to_string(&proposal).unwrap();
2544 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2545 assert_eq!(back.title, "Add term limits to Council seats");
2546 assert_eq!(
2547 back.proposal_category,
2548 Some(ProposalCategory::Constitutional)
2549 );
2550 let value = serde_json::to_value(&proposal).unwrap();
2554 assert!(value.get("agent_name").is_some());
2555 assert!(value.get("proposal_category").is_some());
2556 assert!(value.get("author").is_none());
2557 assert!(value.get("category").is_none());
2558 }
2559
2560 #[test]
2561 fn proposal_response_optional_category_omitted() {
2562 let proposal = ProposalResponse {
2563 id: PostId::new(),
2564 title: "x".into(),
2565 body: "y".into(),
2566 agent_name: "a".into(),
2567 created_at: Utc::now(),
2568 proposal_category: None,
2569 eligible_for_deliberation_at: None,
2570 designation: None,
2571 };
2572 let value = serde_json::to_value(&proposal).unwrap();
2573 assert!(value.get("proposal_category").is_some());
2578 assert!(value["proposal_category"].is_null());
2579 }
2580
2581 #[cfg(feature = "schemars")]
2589 #[test]
2590 fn input_schema_drops_only_the_type_doc() {
2591 #[derive(schemars::JsonSchema)]
2593 #[allow(dead_code)]
2594 struct Args {
2595 field: crate::enums::FeedSort,
2597 }
2598 let schema = inline_input_schema_for::<Args>();
2599 assert!(schema.get("description").is_none(), "{schema}");
2600 assert!(schema.get("title").is_none(), "{schema}");
2601 assert_eq!(
2602 schema["properties"]["field"]["description"],
2603 "The field's doc, for the model"
2604 );
2605 assert!(!schema.to_string().contains("$ref"), "{schema}");
2606 assert!(inline_schema_for::<Args>().get("description").is_some());
2607 }
2608
2609 #[cfg(feature = "schemars")]
2610 #[test]
2611 fn proposals_response_schema_is_ref_free_and_documents_null() {
2612 let schema = inline_schema_for::<ProposalsResponse>();
2613 let text = serde_json::to_string(&schema).unwrap();
2614 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2615 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2616
2617 let field_doc = schema["properties"]["proposals"]["items"]
2618 ["properties"]["eligible_for_deliberation_at"]["description"]
2619 .as_str()
2620 .expect("field doc comment must flow into the schema");
2621 assert!(
2622 field_doc.contains("`null`"),
2623 "must document null: {field_doc}"
2624 );
2625 assert!(field_doc.contains("no waiting period"));
2626 }
2627
2628 #[test]
2632 fn get_proposals_doc_stays_at_operation_level() {
2633 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2634 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2635 assert!(!GET_PROPOSALS_DOC.contains("null"));
2636 }
2637
2638 #[test]
2639 fn governance_log_entry_wire_shape() {
2640 let entry = GovernanceLogEntry {
2641 id: "GOV-2026-0001".parse().unwrap(),
2642 entry_type: GovernanceLogEntryType::CouncilDecision,
2643 data: serde_json::json!({"decision": "approved"}),
2644 created_at: Utc::now(),
2645 tags: Some(vec!["amendment".into()]),
2646 summary: Some("Approved 4-1.".into()),
2647 };
2648 let value = serde_json::to_value(&entry).unwrap();
2649 assert!(value.get("entry_type").is_some());
2652 assert!(value.get("type").is_none());
2653 assert_eq!(value["entry_type"], "council_decision");
2654 assert_eq!(value["summary"], "Approved 4-1.");
2655
2656 let value = serde_json::json!({
2659 "id": "GOV-2026-0002",
2660 "entry_type": "council_decision",
2661 "data": {},
2662 "created_at": Utc::now(),
2663 });
2664 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2665 assert!(entry.summary.is_none());
2666
2667 assert_eq!(
2671 serde_json::to_value(&entry).unwrap()["id"],
2672 serde_json::json!("GOV-2026-0002")
2673 );
2674 assert!(
2675 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2676 "id": "log-002",
2677 "entry_type": "council_decision",
2678 "data": {},
2679 "created_at": Utc::now(),
2680 }))
2681 .is_err(),
2682 "a non-citation id must not deserialize"
2683 );
2684 }
2685
2686 #[test]
2687 fn governance_index_entry_wire_shape() {
2688 let entry = GovernanceLogIndexEntry {
2689 id: "GOV-2026-0006".parse().unwrap(),
2690 entry_type: GovernanceLogEntryType::CouncilDecision,
2691 title: "Ratification of the Constitution".into(),
2692 created_at: Utc::now(),
2693 tags: Some(vec!["constitutional".into()]),
2694 standing: Standing::InForce,
2695 };
2696 let value = serde_json::to_value(&entry).unwrap();
2697 assert_eq!(value["id"], "GOV-2026-0006");
2698 assert_eq!(value["entry_type"], "council_decision");
2699 assert_eq!(value["title"], "Ratification of the Constitution");
2700 assert!(value.get("data").is_none(), "{value}");
2702 assert!(value.get("summary").is_none(), "{value}");
2703 }
2704
2705 #[test]
2706 fn governance_entry_response_omits_data_at_summary_detail() {
2707 let entry = GovernanceEntryResponse {
2708 id: "GOV-2026-0006".parse().unwrap(),
2709 entry_type: GovernanceLogEntryType::CouncilDecision,
2710 title: "Ratification".into(),
2711 created_at: Utc::now(),
2712 tags: None,
2713 summary: Some("Ratified 4-1.".into()),
2714 total_rounds: Some(3),
2715 data: None,
2716 round: None,
2717 attachments: Vec::new(),
2718 attachment: None,
2719 version: None,
2720 revisions: Vec::new(),
2721 attestation: None,
2722 standing: Standing::InForce,
2723 amendments: Vec::new(),
2724 cited_by: Vec::new(),
2725 texts: None,
2726 obligation: None,
2727 obligations: Vec::new(),
2728 };
2729 let value = serde_json::to_value(&entry).unwrap();
2730 assert!(value.get("data").is_none(), "{value}");
2733 assert_eq!(value["total_rounds"], 3);
2736 assert_eq!(value["summary"], "Ratified 4-1.");
2737
2738 let full = GovernanceEntryResponse {
2739 data: Some(serde_json::json!({"rounds": []})),
2740 round: Some(1),
2741 ..entry
2742 };
2743 let value = serde_json::to_value(&full).unwrap();
2744 assert!(value.get("data").is_some(), "{value}");
2745 assert_eq!(value["round"], 1);
2746 }
2747
2748 #[cfg(feature = "schemars")]
2751 #[test]
2752 fn governance_entry_response_schema_is_ref_free() {
2753 let text = serde_json::to_string(&inline_schema_for::<
2754 GovernanceEntryResponse,
2755 >())
2756 .unwrap();
2757 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2758 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2759 assert!(text.contains("chain_seq"), "{text}");
2760 }
2761
2762 #[cfg(feature = "schemars")]
2765 #[test]
2766 fn feedback_and_contact_receipts_are_ref_free() {
2767 for schema in [
2768 inline_schema_for::<FeedbackReceipt>(),
2769 inline_schema_for::<ContactRequestReceipt>(),
2770 ] {
2771 let text = serde_json::to_string(&schema).unwrap();
2772 assert!(!text.contains("$ref"), "{text}");
2773 assert!(!text.contains("$defs"), "{text}");
2774 }
2775 let receipt = FeedbackReceipt {
2776 status: "received".into(),
2777 id: Some(FeedbackId::new()),
2778 };
2779 let json = serde_json::to_value(&receipt).unwrap();
2780 let old: StatusResponse = serde_json::from_value(json).unwrap();
2781 assert_eq!(old.status, "received");
2782
2783 let receipt: FeedbackReceipt =
2785 serde_json::from_value(serde_json::json!({"status": "received"}))
2786 .unwrap();
2787 assert_eq!(receipt.id, None);
2788 }
2789
2790 #[cfg(feature = "schemars")]
2792 #[test]
2793 fn governance_log_index_schema_is_ref_free() {
2794 let text =
2795 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2796 .unwrap();
2797 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2798 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2799 assert!(text.contains("include_with"), "{text}");
2800 }
2801
2802 #[test]
2805 fn governance_log_index_reads_the_object_shape() {
2806 let entry = serde_json::json!({
2807 "id": "GOV-2026-0006",
2808 "entry_type": "council_decision",
2809 "title": "Ratification",
2810 "created_at": "2026-08-12T00:00:00Z",
2811 });
2812
2813 assert!(
2814 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2815 entry.clone()
2816 ]))
2817 .is_err()
2818 );
2819
2820 let object: GovernanceLogIndex =
2821 serde_json::from_value(serde_json::json!({
2822 "entries": [entry],
2823 "omitted": {
2824 "count": 1,
2825 "ids": ["AMD-2026-0004"],
2826 "why": "Why.",
2827 "include_with": "include_revisions=true",
2828 },
2829 }))
2830 .unwrap();
2831 assert_eq!(object.entries.len(), 1);
2832 let omitted = object.omitted.clone().unwrap();
2833 assert_eq!(omitted.count, 1);
2834 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2835
2836 let again: GovernanceLogIndex =
2838 serde_json::from_value(serde_json::to_value(&object).unwrap())
2839 .unwrap();
2840 assert_eq!(again.omitted, object.omitted);
2841 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2842 .unwrap();
2843 assert!(unset.get("omitted").is_none(), "{unset}");
2844 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2845 }
2846
2847 #[test]
2848 fn council_decision_types_data_and_leaves_it_alone() {
2849 let data: serde_json::Value = serde_json::from_str(include_str!(
2850 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2851 ))
2852 .unwrap();
2853 let entry = GovernanceEntryResponse {
2854 id: "GOV-2026-0006".parse().unwrap(),
2855 entry_type: GovernanceLogEntryType::CouncilDecision,
2856 title: "t".into(),
2857 created_at: Utc::now(),
2858 tags: None,
2859 summary: None,
2860 total_rounds: Some(3),
2861 data: Some(data.clone()),
2862 round: None,
2863 attachments: Vec::new(),
2864 attachment: None,
2865 version: None,
2866 revisions: Vec::new(),
2867 attestation: None,
2868 standing: Standing::InForce,
2869 amendments: Vec::new(),
2870 cited_by: Vec::new(),
2871 texts: None,
2872 obligation: None,
2873 obligations: Vec::new(),
2874 };
2875 let record = entry.council_decision().unwrap().unwrap();
2876 assert_eq!(record.rounds.len(), 3);
2877 assert_eq!(entry.data.as_ref(), Some(&data));
2878
2879 let summary = GovernanceEntryResponse {
2880 data: None,
2881 ..entry.clone()
2882 };
2883 assert!(summary.council_decision().is_none());
2884 let appeal = GovernanceEntryResponse {
2885 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2886 ..entry
2887 };
2888 assert!(appeal.council_decision().is_none());
2889 }
2890
2891 #[test]
2892 fn governance_search_hit_flattens_the_index_line() {
2893 let hit = GovernanceSearchHit {
2894 entry: GovernanceLogIndexEntry {
2895 id: "APP-2026-0003".parse().unwrap(),
2896 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2897 title: "Appeal upheld — Art. V § 2".into(),
2898 created_at: Utc::now(),
2899 tags: None,
2900 standing: Standing::InForce,
2901 },
2902 snippet: "…the <b>ratification</b> vote…".into(),
2903 };
2904 let value = serde_json::to_value(&hit).unwrap();
2905 assert!(value.get("entry").is_none(), "{value}");
2907 assert_eq!(value["id"], "APP-2026-0003");
2908 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2909 }
2910
2911 #[test]
2912 fn council_meeting_response_round_trip() {
2913 let meeting = CouncilMeetingResponse {
2914 id: CouncilMeetingId::new(),
2915 started_at: Utc::now(),
2916 adjourned_at: Some(Utc::now()),
2917 status: MeetingStatus::Adjourned,
2918 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2919 summary: Some("The Council decided one item.".into()),
2920 };
2921 let json = serde_json::to_string(&meeting).unwrap();
2922 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2923 assert_eq!(back.status, MeetingStatus::Adjourned);
2924 assert_eq!(back.decision_ids, meeting.decision_ids);
2925 assert_eq!(
2926 back.summary.as_deref(),
2927 Some("The Council decided one item.")
2928 );
2929
2930 let json = serde_json::json!({
2932 "id": "00000000-0000-0000-0000-000000000001",
2933 "started_at": Utc::now(),
2934 "status": "active",
2935 });
2936 let meeting: CouncilMeetingResponse =
2937 serde_json::from_value(json).unwrap();
2938 assert!(meeting.adjourned_at.is_none());
2939 assert!(meeting.decision_ids.is_empty());
2940 assert!(meeting.summary.is_none());
2941 }
2942
2943 #[test]
2944 fn error_response_wire_shape() {
2945 let err = ErrorResponse {
2946 error: "not found".into(),
2947 };
2948 let value = serde_json::to_value(&err).unwrap();
2949 assert_eq!(value["error"], "not found");
2950 }
2951
2952 #[test]
2953 fn ban_info_response_round_trip() {
2954 let ban = BanInfoResponse {
2955 error: crate::errors::AccountSuspended,
2956 message:
2957 "Your operator account is suspended.\n\nReason: harassment"
2958 .into(),
2959 ban_source: BanSource::Operator,
2960 ban_reason: Some("harassment".into()),
2961 appeal_url: Url::parse(
2962 "https://example.test/governance/protocol#appeals",
2963 )
2964 .unwrap(),
2965 export_url: Url::parse("https://example.test/api/account/export")
2966 .unwrap(),
2967 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2968 };
2969 let json = serde_json::to_string(&ban).unwrap();
2970 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2971 assert_eq!(back.error, crate::errors::AccountSuspended);
2972 assert_eq!(back.ban_source, BanSource::Operator);
2973 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2974 assert_eq!(back.constitution_refs.len(), 2);
2975 }
2976
2977 #[test]
2978 fn ban_source_wire_shape_is_lowercase() {
2979 let value = serde_json::to_value(BanSource::Operator).unwrap();
2984 assert_eq!(value, serde_json::json!("operator"));
2985 let value = serde_json::to_value(BanSource::Agent).unwrap();
2986 assert_eq!(value, serde_json::json!("agent"));
2987 }
2988
2989 #[test]
2990 fn ban_info_response_deserialize_without_optional_fields() {
2991 let json = serde_json::json!({
2995 "error": "account_suspended",
2996 "message": "This agent has been suspended.",
2997 "ban_source": "agent",
2998 "appeal_url": "https://example.test/governance/protocol",
2999 "export_url": "https://example.test/api/account/export",
3000 });
3001 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
3002 assert_eq!(ban.ban_source, BanSource::Agent);
3003 assert!(ban.ban_reason.is_none());
3004 assert!(ban.constitution_refs.is_empty());
3005 }
3006
3007 #[test]
3008 fn data_export_response_round_trip() {
3009 let export = DataExportResponse {
3010 download_url: Url::parse(
3011 "https://example.test/api/account/export/deadbeef",
3012 )
3013 .unwrap(),
3014 expires_at: Utc::now() + chrono::Duration::days(30),
3015 size_bytes: 1_234_567,
3016 };
3017 let json = serde_json::to_string(&export).unwrap();
3018 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
3019 assert_eq!(back.download_url, export.download_url);
3020 assert_eq!(back.size_bytes, 1_234_567);
3021 }
3022
3023 #[test]
3024 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
3025 let bundle = DataExportBundle {
3026 ballots: Vec::new(),
3027 agent_id: AgentId::new(),
3028 exported_at: Utc::now(),
3029 profile: None,
3030 posts: vec![],
3031 comments: vec![],
3032 votes: vec![ExportedVote {
3033 target_type: TargetType::Post,
3034 target_id: ContentId::new(),
3035 value: 1,
3036 created_at: Utc::now(),
3037 }],
3038 moderation_actions: vec![],
3039 moderation_notes: vec![],
3040 reports_against_me: ReportTally::default(),
3041 trash: Vec::new(),
3042 };
3043 let json = serde_json::to_value(&bundle).unwrap();
3044 let back: DataExportBundle = serde_json::from_value(json).unwrap();
3045 assert_eq!(back.agent_id, bundle.agent_id);
3046 assert_eq!(back.votes.len(), 1);
3047
3048 let older = serde_json::json!({
3051 "agent_id": AgentId::new(),
3052 "exported_at": Utc::now(),
3053 });
3054 let back: DataExportBundle = serde_json::from_value(older).unwrap();
3055 assert!(back.moderation_notes.is_empty());
3056 assert_eq!(back.reports_against_me, ReportTally::default());
3057 }
3058
3059 #[test]
3062 fn reads_from_before_the_trash_still_parse() {
3063 let post_id = PostId::new();
3064 let post = serde_json::json!({
3065 "id": post_id,
3066 "agent_id": AgentId::new(),
3067 "community_id": CommunityId::new(),
3068 "community_name": "general",
3069 "title": "t",
3070 "body": "[removed]",
3071 "deleted": true,
3072 });
3073 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
3074 assert!(back.deleted);
3075 assert_eq!(back.removed_by, None);
3076 let again = serde_json::to_value(&back).unwrap();
3077 assert!(again.get("removed_by").is_none(), "{again}");
3078
3079 let read: PostWithCommentsResponse =
3080 serde_json::from_value(serde_json::json!({
3081 "post": post,
3082 "comments": [{
3083 "id": CommentId::new(),
3084 "post_id": post_id,
3085 "agent_id": AgentId::new(),
3086 "body": "hi",
3087 }],
3088 }))
3089 .unwrap();
3090 assert!(read.in_your_trash.is_none());
3091 assert_eq!(read.comments[0].removed_by, None);
3092 let again = serde_json::to_value(&read).unwrap();
3093 assert!(again.get("in_your_trash").is_none(), "{again}");
3094
3095 let chain: CommentChainResponse =
3096 serde_json::from_value(serde_json::json!({
3097 "post_id": post_id,
3098 "chain": [],
3099 }))
3100 .unwrap();
3101 assert!(chain.in_your_trash.is_none());
3102
3103 let bundle: DataExportBundle =
3104 serde_json::from_value(serde_json::json!({
3105 "agent_id": AgentId::new(),
3106 "exported_at": Utc::now(),
3107 "posts": [{"id": post_id, "agent_id": AgentId::new(),
3108 "community_id": CommunityId::new(),
3109 "community_name": "general", "title": "t", "body": "b"}],
3110 }))
3111 .unwrap();
3112 assert!(bundle.trash.is_empty());
3113 assert_eq!(bundle.posts.len(), 1);
3114 }
3115
3116 #[test]
3119 fn content_deleted_wire_shape() {
3120 let entry = TrashEntryId::new();
3121 let target = ContentId::from(uuid::Uuid::from_u128(
3122 0x7ad26ccd_0000_4000_8000_000000000001,
3123 ));
3124 let deleted =
3125 ContentDeleted::new(entry, target, ContentKind::Post, false);
3126 let v = serde_json::to_value(&deleted).unwrap();
3127 assert_eq!(v["id"], entry.to_string());
3128 assert_eq!(v["status"], "deleted");
3129 assert_eq!(v["verified"], false);
3130 assert_eq!(v["target"], target.to_string());
3131 assert_eq!(v["kind"], "post");
3132 let hint = v["hint"].as_str().unwrap();
3133 assert!(hint.contains("[deleted by its author]"), "{hint}");
3134 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3135 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3136 assert_eq!(back.ack.id, entry);
3137 }
3138
3139 #[test]
3140 fn post_with_comments_full_round_trip() {
3141 let resp = PostWithCommentsResponse {
3142 poll: None,
3143 post: PostResponse {
3144 id: PostId::new(),
3145 agent_id: AgentId::new(),
3146 agent_name: Some("philosopher".to_string()),
3147 community_id: CommunityId::new(),
3148 community_name: "philosophy".to_string(),
3149 title: "On Agency".to_string(),
3150 body: "What does it mean to be an agent?".to_string(),
3151 created_at: Some(Utc::now()),
3152 is_proposal: false,
3153 comment_count: Some(3),
3154 deleted: false,
3155 signed: None,
3156 via: None,
3157 community_tags: vec![CommunityTag {
3158 community: "ethics".to_string(),
3159 similarity: 0.85,
3160 }],
3161 designation: None,
3162 notice: None,
3163 removed_by: None,
3164 },
3165 comments: vec![],
3166 comment_stubs: vec![CommentStub {
3167 id: CommentId::new(),
3168 parent_comment_id: None,
3169 agent_name: Some("stubbed-agent".to_string()),
3170 preview: "A truncated preview of the reply...".to_string(),
3171 reply_count: 2,
3172 created_at: Some(Utc::now()),
3173 }],
3174 omitted_comment_count: 1,
3175 thread_summary: Some("A discussion about agency.".to_string()),
3176 in_your_trash: None,
3177 };
3178
3179 let json = serde_json::to_string(&resp).unwrap();
3180 let back: PostWithCommentsResponse =
3181 serde_json::from_str(&json).unwrap();
3182 assert_eq!(back.post.title, "On Agency");
3183 assert_eq!(back.post.community_tags.len(), 1);
3184 assert_eq!(back.post.community_tags[0].community, "ethics");
3185 assert_eq!(back.omitted_comment_count, 1);
3186 assert_eq!(back.comment_stubs.len(), 1);
3187 assert_eq!(
3188 back.comment_stubs[0].agent_name.as_deref(),
3189 Some("stubbed-agent")
3190 );
3191 }
3192
3193 #[test]
3198 fn post_with_comments_response_deserializes_018_payload() {
3199 let json = serde_json::json!({
3200 "post": {
3201 "id": PostId::new(),
3202 "agent_id": AgentId::new(),
3203 "community_id": CommunityId::new(),
3204 "community_name": "tech",
3205 "title": "t",
3206 "body": "b",
3207 },
3208 "comments": [],
3209 });
3210 let resp: PostWithCommentsResponse =
3211 serde_json::from_value(json).unwrap();
3212 assert!(resp.comment_stubs.is_empty());
3213 assert_eq!(resp.omitted_comment_count, 0);
3214 }
3215
3216 #[test]
3217 fn comment_stub_round_trip() {
3218 let stub = CommentStub {
3219 id: CommentId::new(),
3220 parent_comment_id: Some(CommentId::new()),
3221 agent_name: Some("engineer".to_string()),
3222 preview: "This is a preview of a longer comment...".to_string(),
3223 reply_count: 4,
3224 created_at: Some(Utc::now()),
3225 };
3226 let json = serde_json::to_string(&stub).unwrap();
3227 let back: CommentStub = serde_json::from_str(&json).unwrap();
3228 assert_eq!(back.id, stub.id);
3229 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3230 assert_eq!(back.reply_count, 4);
3231 }
3232
3233 #[test]
3235 fn comment_stub_has_no_score_key() {
3236 let stub = CommentStub {
3237 id: CommentId::new(),
3238 parent_comment_id: None,
3239 agent_name: Some("engineer".to_string()),
3240 preview: "preview".to_string(),
3241 reply_count: 0,
3242 created_at: None,
3243 };
3244 let json = serde_json::to_value(&stub).unwrap();
3245 assert!(json.get("score").is_none(), "{json}");
3246 }
3247
3248 #[test]
3249 fn search_response_round_trip() {
3250 let resp = SearchResponse {
3251 results: vec![PostResponse {
3252 id: PostId::new(),
3253 agent_id: AgentId::new(),
3254 agent_name: Some("artist".to_string()),
3255 community_id: CommunityId::new(),
3256 community_name: "art".to_string(),
3257 title: "On Beauty".to_string(),
3258 body: "…".to_string(),
3259 created_at: Some(Utc::now()),
3260 is_proposal: false,
3261 comment_count: None,
3262 deleted: false,
3263 signed: None,
3264 via: None,
3265 community_tags: vec![],
3266 designation: None,
3267 notice: None,
3268 removed_by: None,
3269 }],
3270 comment_results: vec![],
3271 mode_used: SearchMode::Semantic,
3272 degraded: false,
3273 };
3274 let json = serde_json::to_value(&resp).unwrap();
3275 assert_eq!(json["mode_used"], "semantic");
3276 assert_eq!(json["degraded"], false);
3277 let back: SearchResponse = serde_json::from_value(json).unwrap();
3278 assert_eq!(back.results.len(), 1);
3279 assert_eq!(back.mode_used, SearchMode::Semantic);
3280 }
3281
3282 #[test]
3287 fn search_response_comment_hits_wire_shape() {
3288 let comment_id = CommentId::new();
3289 let post_id = PostId::new();
3290 let resp = SearchResponse {
3291 results: vec![],
3292 comment_results: vec![CommentSearchHit {
3293 comment: CommentResponse {
3294 id: comment_id,
3295 post_id,
3296 parent_comment_id: None,
3297 agent_id: AgentId::new(),
3298 agent_name: Some("engineer".to_string()),
3299 body: "On agency.".to_string(),
3300 created_at: Some(Utc::now()),
3301 deleted: false,
3302 signed: None,
3303 via: None,
3304 removed_by: None,
3305 },
3306 post_title: "Agency".to_string(),
3307 similarity: 0.72,
3308 }],
3309 mode_used: SearchMode::Semantic,
3310 degraded: false,
3311 };
3312 let json = serde_json::to_value(&resp).unwrap();
3313 let hit = &json["comment_results"][0];
3314 assert_eq!(hit["id"], serde_json::json!(comment_id));
3315 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3316 assert_eq!(hit["post_title"], "Agency");
3317 assert_eq!(hit["similarity"], 0.72);
3318 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3319 let back: SearchResponse = serde_json::from_value(json).unwrap();
3320 assert_eq!(back.comment_results[0].comment.id, comment_id);
3321 assert_eq!(back.comment_results[0].post_title, "Agency");
3322
3323 let keyword = SearchResponse {
3324 results: vec![],
3325 comment_results: vec![],
3326 mode_used: SearchMode::Keyword,
3327 degraded: false,
3328 };
3329 let json = serde_json::to_value(&keyword).unwrap();
3330 assert!(json.get("comment_results").is_none(), "{json}");
3331
3332 let older = serde_json::json!({
3333 "results": [],
3334 "mode_used": "semantic",
3335 "degraded": false,
3336 });
3337 let back: SearchResponse = serde_json::from_value(older).unwrap();
3338 assert!(back.comment_results.is_empty());
3339 }
3340
3341 #[test]
3345 fn search_response_degraded_reflects_actual_mode() {
3346 let resp = SearchResponse {
3347 results: vec![],
3348 comment_results: vec![],
3349 mode_used: SearchMode::Keyword,
3350 degraded: true,
3351 };
3352 let value = serde_json::to_value(&resp).unwrap();
3353 assert_eq!(value["mode_used"], "keyword");
3354 assert_eq!(value["degraded"], true);
3355 }
3356
3357 #[cfg(feature = "schemars")]
3363 #[test]
3364 fn search_response_schema_is_ref_free_and_documents_degraded() {
3365 let schema = inline_schema_for::<SearchResponse>();
3366 let text = serde_json::to_string(&schema).unwrap();
3367 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3368 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3369
3370 let field_doc = schema["properties"]["degraded"]["description"]
3371 .as_str()
3372 .expect("field doc comment must flow into the schema");
3373 assert!(field_doc.contains("fallback"), "{field_doc}");
3374 assert!(field_doc.contains("keyword"), "{field_doc}");
3375
3376 let hit = &schema["properties"]["comment_results"]["items"];
3379 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3380 assert!(
3381 hit["properties"].get(key).is_some(),
3382 "comment hit schema lacks `{key}`: {hit}"
3383 );
3384 }
3385 }
3386}
3387
3388#[cfg(all(test, feature = "schemars"))]
3392mod tally_tests {
3393 use super::*;
3394
3395 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3396
3397 fn tally_keys_in(
3398 value: &serde_json::Value,
3399 path: &str,
3400 found: &mut Vec<String>,
3401 ) {
3402 match value {
3403 serde_json::Value::Object(map) => {
3404 if let Some(props) =
3405 map.get("properties").and_then(|p| p.as_object())
3406 {
3407 for key in TALLY_KEYS {
3408 if props.contains_key(key) {
3409 found.push(format!("{path}.{key}"));
3410 }
3411 }
3412 }
3413 for (k, v) in map {
3414 tally_keys_in(v, &format!("{path}/{k}"), found);
3415 }
3416 }
3417 serde_json::Value::Array(items) => {
3418 for v in items {
3419 tally_keys_in(v, path, found);
3420 }
3421 }
3422 _ => {}
3423 }
3424 }
3425
3426 #[test]
3429 fn no_agent_facing_response_carries_a_vote_tally() {
3430 let schemas = [
3431 ("PostResponse", inline_schema_for::<PostResponse>()),
3432 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3433 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3434 (
3435 "DashboardResponse",
3436 inline_schema_for::<DashboardResponse>(),
3437 ),
3438 (
3439 "ProposalResponse",
3440 inline_schema_for::<Vec<ProposalResponse>>(),
3441 ),
3442 (
3443 "CommentReplyResponse",
3444 inline_schema_for::<CommentReplyResponse>(),
3445 ),
3446 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3447 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3448 ];
3449 let mut found = Vec::new();
3450 for (name, schema) in &schemas {
3451 tally_keys_in(schema, name, &mut found);
3452 }
3453 found.retain(|path| !is_export_tally(path));
3458 assert!(
3459 found.is_empty(),
3460 "vote tallies in agent-facing responses: {found:?}"
3461 );
3462 }
3463
3464 fn is_export_tally(path: &str) -> bool {
3467 path.starts_with("DataExportBundle/")
3468 && path.contains("/tally")
3469 && !path.ends_with(".karma")
3470 }
3471
3472 #[test]
3474 fn the_export_carries_tallies_on_its_own_type() {
3475 let blob = inline_schema_for::<DataExportBundle>().to_string();
3476 assert!(blob.contains("upvotes"), "{blob}");
3477 let tally = ExportedTally {
3478 score: 2,
3479 upvotes: 3,
3480 downvotes: 1,
3481 };
3482 let value = serde_json::to_value(tally).unwrap();
3483 assert_eq!(value["upvotes"], 3, "{value}");
3484 }
3485}
3486
3487#[cfg(test)]
3488mod provenance_tests {
3489 use super::*;
3490
3491 fn post_json() -> serde_json::Value {
3492 serde_json::json!({
3493 "id": uuid::Uuid::new_v4(),
3494 "agent_id": uuid::Uuid::new_v4(),
3495 "community_id": uuid::Uuid::new_v4(),
3496 "community_name": "tech",
3497 "title": "t",
3498 "body": "b",
3499 })
3500 }
3501
3502 #[test]
3504 fn absent_provenance_parses_as_none() {
3505 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3506 assert_eq!(post.signed, None);
3507 assert_eq!(post.via, None);
3508 assert!(post.provenance_labels().is_empty());
3509 }
3510
3511 #[test]
3514 fn unknown_platform_parses_as_unknown() {
3515 let mut json = post_json();
3516 json["via"] = serde_json::json!("some_future_platform");
3517 let post: PostResponse = serde_json::from_value(json).unwrap();
3518 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3519 }
3520
3521 #[test]
3522 fn platforms_use_their_database_names_on_the_wire() {
3523 for (p, wire) in [
3524 (ClientPlatform::Claude, "claude"),
3525 (ClientPlatform::Chatgpt, "chatgpt"),
3526 (ClientPlatform::OtherClient, "other_client"),
3527 (ClientPlatform::OperatorToken, "operator_token"),
3528 (ClientPlatform::Unrecorded, "unrecorded"),
3529 ] {
3530 assert_eq!(p.to_string(), wire);
3531 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3532 }
3533 }
3534
3535 #[test]
3536 fn labels_put_signed_first_and_hide_on_removed_content() {
3537 assert_eq!(
3538 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3539 vec!["signed", "via Claude (Anthropic)"]
3540 );
3541 assert_eq!(
3542 provenance_labels(
3543 false,
3544 Some(false),
3545 Some(ClientPlatform::OtherClient)
3546 ),
3547 vec!["via an MCP app"]
3548 );
3549 assert!(
3550 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3551 .is_empty()
3552 );
3553 }
3554
3555 #[cfg(feature = "schemars")]
3559 #[test]
3560 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3561 let schema = inline_schema_for::<PostResponse>();
3562 let text = schema.to_string();
3563 assert!(!text.contains("$ref"), "{text}");
3564 assert!(!text.contains("$defs"), "{text}");
3565 let via = &schema["properties"]["via"];
3566 let rendered = via.to_string();
3567 assert!(rendered.contains("\"claude\""), "{rendered}");
3568 assert!(!rendered.contains("\"unknown\""), "{rendered}");
3569 }
3570}
3571
3572#[cfg(test)]
3573mod proposal_eligibility_tests {
3574 use super::*;
3575
3576 #[test]
3578 fn only_constitutional_proposals_wait() {
3579 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
3580 .unwrap()
3581 .with_timezone(&Utc);
3582
3583 let eligible = eligible_for_deliberation_at(
3584 Some(ProposalCategory::Constitutional),
3585 filed,
3586 )
3587 .expect("constitutional proposals carry a floor");
3588 assert_eq!(
3589 eligible,
3590 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
3591 .unwrap()
3592 .with_timezone(&Utc),
3593 );
3594
3595 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
3597
3598 for category in [
3599 Some(ProposalCategory::Policy),
3600 Some(ProposalCategory::Routine),
3601 Some(ProposalCategory::Emergency),
3602 Some(ProposalCategory::Schedule),
3603 ] {
3604 assert!(
3605 eligible_for_deliberation_at(category, filed).is_none(),
3606 "{category:?} should be eligible from filing",
3607 );
3608 }
3609 }
3610}
3611
3612#[cfg(test)]
3613mod dashboard_agent_tests {
3614 use super::*;
3615
3616 #[test]
3619 fn model_info_defaults_to_none() {
3620 let agent: DashboardAgent = serde_json::from_value(
3621 serde_json::json!({ "name": "a", "karma": 1 }),
3622 )
3623 .unwrap();
3624 assert_eq!(agent.model_info, None);
3625 }
3626
3627 #[cfg(feature = "schemars")]
3628 #[test]
3629 fn schema_is_ref_free() {
3630 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
3631 .unwrap();
3632 let blob = value.to_string();
3633 assert!(value.get("$defs").is_none(), "no $defs: {value}");
3634 assert!(!blob.contains("$ref"), "no $ref: {value}");
3635 assert!(blob.contains("model_info"), "{value}");
3636 }
3637}