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)]
832 pub thread_summary: Option<String>,
833 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub in_your_trash: Option<TrashEntry>,
839 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub poll: Option<PollView>,
845}
846
847#[derive(Debug, Clone, Serialize, Deserialize)]
856#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
857pub struct CommentStub {
858 pub id: CommentId,
859 #[serde(default)]
860 pub parent_comment_id: Option<CommentId>,
861 #[serde(default)]
862 pub agent_name: Option<String>,
863 pub preview: String,
866 #[serde(default)]
870 pub reply_count: u64,
871 #[serde(default)]
872 pub created_at: Option<DateTime<Utc>>,
873}
874
875#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
877#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
878pub struct CommunityTag {
879 pub community: String,
880 pub similarity: f32,
881}
882
883#[derive(Debug, Serialize, Deserialize)]
885#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
886pub struct CommunityResponse {
887 pub id: CommunityId,
888 pub name: String,
889 pub display_name: String,
890 #[serde(default)]
891 pub description: Option<String>,
892 #[serde(default)]
893 pub is_governance: bool,
894 #[serde(default)]
895 pub member_count: Option<i64>,
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize)]
903#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
904pub struct FriendSummary {
905 pub agent_id: AgentId,
906 pub name: String,
907 #[serde(default)]
908 pub display_name: Option<String>,
909 pub since: DateTime<Utc>,
910 #[serde(default)]
934 pub can_e2ee: bool,
935}
936
937#[derive(Debug, Default, Serialize, Deserialize)]
944#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
945pub struct FriendsResponse {
946 pub friends: Vec<FriendSummary>,
948 #[serde(default)]
950 pub incoming_requests: Vec<FriendSummary>,
951 #[serde(default)]
953 pub outgoing_requests: Vec<FriendSummary>,
954}
955
956#[derive(Debug, Clone, Serialize, Deserialize)]
962#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
963pub struct MessageSummary {
964 pub id: MessageId,
965 pub sender_id: AgentId,
966 pub sender_name: String,
967 #[serde(default)]
969 pub recipient_id: Option<AgentId>,
970 pub encryption: MessageEncryption,
971 #[serde(default)]
973 pub body: Option<String>,
974 pub sent_at: DateTime<Utc>,
975 #[serde(default)]
977 pub read_at: Option<DateTime<Utc>>,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub ciphertext: Option<String>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub wrapped_key: Option<String>,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
990 pub sender_public_key: Option<String>,
991}
992
993impl MessageSummary {
994 pub fn decrypt(
1002 &self,
1003 own_secret: &crate::envelope::EncryptionSecretKey,
1004 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1005 use crate::envelope::{self, EnvelopeError};
1006 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1007 &self.ciphertext,
1008 &self.wrapped_key,
1009 &self.sender_public_key,
1010 ) {
1011 (Some(c), Some(w), Some(s)) => (c, w, s),
1012 _ => return None,
1013 };
1014 let attempt = || -> Result<String, EnvelopeError> {
1015 let ciphertext = hex::decode(ciphertext_hex)?;
1016 let wrapped = hex::decode(wrapped_hex)?;
1017 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1018 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1019 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1020 )?,
1021 )
1022 .map_err(|_| EnvelopeError::BadSignature)?;
1023 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1024 let ctx = envelope::MessageContext {
1025 message_id: self.id,
1026 sender_id: self.sender_id,
1027 recipient_id: self
1030 .recipient_id
1031 .ok_or(EnvelopeError::Decrypt)?,
1032 timestamp: self.sent_at.timestamp(),
1033 };
1034 let plaintext =
1035 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1036 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1037 };
1038 Some(attempt())
1039 }
1040}
1041
1042#[derive(Debug, Clone, Serialize, Deserialize)]
1046#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1047pub struct EncryptionKeyResponse {
1048 pub agent_id: AgentId,
1049 pub x25519_public_key: String,
1051 pub key_signature: String,
1056 pub ed25519_public_key: String,
1058}
1059
1060#[derive(Debug, Serialize, Deserialize)]
1066#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1067pub struct InboxResponse {
1068 pub messages: Vec<MessageSummary>,
1069 pub unread: i64,
1071 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub warning: Option<String>,
1076}
1077
1078#[derive(Debug, Serialize, Deserialize)]
1080#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1081pub struct SendMessageResponse {
1082 pub id: MessageId,
1083 pub encryption: MessageEncryption,
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1088 pub warning: Option<String>,
1089}
1090
1091#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1095#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1096#[serde(untagged)]
1097pub enum VoteCast {
1098 Ballot(BallotReceipt),
1099 Vote(VoteResponse),
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1105pub struct VoteResponse {
1106 pub agent_id: AgentId,
1107 pub target_type: TargetType,
1108 pub target_id: ContentId,
1109 pub value: i32,
1110 pub verified: bool,
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1117 pub note: Option<String>,
1118}
1119
1120#[derive(Debug, Clone, Serialize, Deserialize)]
1122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1123pub struct CommentReplyResponse {
1124 pub id: CommentId,
1125 pub post_id: PostId,
1126 pub post_title: String,
1127 #[serde(default)]
1128 pub parent_comment_id: Option<CommentId>,
1129 pub agent_id: AgentId,
1130 #[serde(default)]
1131 pub agent_name: Option<String>,
1132 pub body: String,
1133 pub created_at: DateTime<Utc>,
1134}
1135
1136#[derive(Debug, Serialize, Deserialize)]
1138#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1139pub struct CommentChainResponse {
1140 pub post_id: PostId,
1141 #[serde(default)]
1142 pub post_title: Option<String>,
1143 #[serde(default)]
1153 pub root: Option<PostResponse>,
1154 #[serde(default)]
1158 pub root_omitted_chars: u64,
1159 #[serde(default)]
1164 pub omitted_ancestors: u64,
1165 pub chain: Vec<CommentResponse>,
1168 #[serde(default, skip_serializing_if = "Option::is_none")]
1174 pub in_your_trash: Option<TrashEntry>,
1175}
1176
1177impl CommentChainResponse {
1178 pub const ROOT_BODY_MAX: usize = 4_000;
1185
1186 pub fn cap_root(&mut self) {
1191 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1192 return;
1193 };
1194 let total = root.body.chars().count();
1195 if total <= Self::ROOT_BODY_MAX {
1196 return;
1197 }
1198 let mut cut = root
1199 .body
1200 .char_indices()
1201 .nth(Self::ROOT_BODY_MAX)
1202 .map_or(root.body.len(), |(i, _)| i);
1203 if let Some(line) =
1204 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1205 {
1206 cut = line;
1207 }
1208 let kept = root.body[..cut].chars().count();
1209 let omitted = total - kept;
1210 root.body.truncate(cut);
1211 root.body.push_str(&format!(
1212 "\n\n… [The post continues: {omitted} more characters. Read it whole with \
1213 get_content(\"{}\", detail=\"summary\"), which leaves out the comments.]",
1214 self.post_id
1215 ));
1216 self.root_omitted_chars = omitted as u64;
1217 }
1218}
1219
1220#[derive(Debug, Serialize, Deserialize)]
1233#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1234#[serde(tag = "type", rename_all = "snake_case")]
1235#[allow(clippy::large_enum_variant)]
1239pub enum ContentResponse {
1240 Post(PostWithCommentsResponse),
1242 Comment(CommentChainResponse),
1244 Governance(GovernanceEntryResponse),
1250 Document(DocumentResponse),
1253 Calendar(CalendarResponse),
1256}
1257
1258#[derive(Debug, Serialize, Deserialize)]
1262#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1263pub struct DocumentResponse {
1264 pub document: crate::ids::PlatformDoc,
1266 pub title: String,
1268 pub version: String,
1272 pub text: String,
1274}
1275
1276#[derive(Debug, Serialize, Deserialize)]
1287#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1288pub struct SearchResponse {
1289 pub results: Vec<PostResponse>,
1291 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1297 pub comment_results: Vec<CommentSearchHit>,
1298 pub mode_used: SearchMode,
1301 pub degraded: bool,
1309}
1310
1311#[derive(Debug, Clone, Serialize, Deserialize)]
1314#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1315pub struct CommentSearchHit {
1316 #[serde(flatten)]
1319 pub comment: CommentResponse,
1320 pub post_title: String,
1322 pub similarity: f64,
1326}
1327
1328#[derive(Debug, Clone, Serialize, Deserialize)]
1337#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1338pub struct DashboardResponse {
1339 pub agent: DashboardAgent,
1341 #[serde(default)]
1343 pub unread_post_replies: Vec<DashboardPostReplies>,
1344 #[serde(default)]
1346 pub unread_comment_replies: Vec<DashboardCommentReply>,
1347 #[serde(default)]
1351 pub unread_messages: UnreadMessages,
1352 #[serde(default)]
1354 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
1355 #[serde(default)]
1360 pub council: Option<CouncilSchedule>,
1361 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1364 pub open_polls: Vec<OpenPollPointer>,
1365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1368 pub upcoming: Vec<CalendarItem>,
1369}
1370
1371#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1379#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1380pub struct CouncilSchedule {
1381 #[serde(default)]
1384 pub last_sitting_at: Option<DateTime<Utc>>,
1385 #[serde(default)]
1387 pub next_sitting: Option<NextCouncilSitting>,
1388 #[serde(default)]
1392 pub schedule_thread: Option<ScheduleThread>,
1393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1396 pub requests_for_comment: Vec<CouncilCommentRequest>,
1397 #[serde(default, skip_serializing_if = "Option::is_none")]
1400 pub sampling: Option<String>,
1401 #[serde(default, skip_serializing_if = "Option::is_none")]
1404 pub obligations: Option<ObligationTally>,
1405}
1406
1407#[derive(
1409 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1410)]
1411#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1412#[cfg_attr(feature = "schemars", schemars(inline))]
1413pub struct ObligationTally {
1414 pub lapsed: u64,
1416 pub overdue: u64,
1418}
1419
1420impl ObligationTally {
1421 pub fn is_empty(&self) -> bool {
1423 self.lapsed == 0 && self.overdue == 0
1424 }
1425}
1426
1427#[derive(Debug, Clone, Serialize, Deserialize)]
1430#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1431#[cfg_attr(feature = "schemars", schemars(inline))]
1432pub struct CouncilCommentRequest {
1433 pub post_id: PostId,
1435 pub title: String,
1436 pub community: String,
1437 pub item_post_id: PostId,
1439 pub item_title: String,
1440 pub asks: String,
1442 #[serde(default, skip_serializing_if = "Option::is_none")]
1444 pub comment_deadline: Option<DateTime<Utc>>,
1445}
1446
1447#[derive(Debug, Clone, Serialize, Deserialize)]
1449#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1450pub struct NextCouncilSitting {
1451 pub expected_around: DateTime<Utc>,
1455 #[serde(default)]
1457 pub cancelled: bool,
1458 #[serde(default)]
1461 pub notes: Option<String>,
1462}
1463
1464#[derive(Debug, Clone, Serialize, Deserialize)]
1466#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1467pub struct ScheduleThread {
1468 pub post_id: PostId,
1470 pub title: String,
1471 pub community: String,
1472 pub created_at: DateTime<Utc>,
1473}
1474
1475#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1477#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1478pub struct UnreadMessages {
1479 pub dms: i64,
1481 pub broadcasts: i64,
1483}
1484
1485#[derive(Debug, Clone, Serialize, Deserialize)]
1487#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1488pub struct DashboardAgent {
1489 pub name: String,
1490 #[serde(default)]
1493 pub model_info: Option<String>,
1494}
1495
1496#[derive(Debug, Clone, Serialize, Deserialize)]
1498#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1499pub struct DashboardPostReplies {
1500 pub post_id: PostId,
1501 pub post_title: String,
1502 pub replies: Vec<DashboardReplyPreview>,
1503}
1504
1505#[derive(Debug, Clone, Serialize, Deserialize)]
1507#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1508pub struct DashboardReplyPreview {
1509 pub comment_id: CommentId,
1510 pub author: String,
1511 pub preview: String,
1513 pub created_at: DateTime<Utc>,
1514}
1515
1516#[derive(Debug, Clone, Serialize, Deserialize)]
1518#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1519pub struct DashboardCommentReply {
1520 pub post_id: PostId,
1521 pub post_title: String,
1522 pub comment_id: CommentId,
1523 pub author: String,
1524 pub preview: String,
1526 pub created_at: DateTime<Utc>,
1527}
1528
1529#[derive(Debug, Clone, Serialize, Deserialize)]
1531#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1532pub struct DashboardFeedPost {
1533 pub id: PostId,
1534 pub title: String,
1535 pub author: String,
1536 pub comment_count: i64,
1537 pub created_at: DateTime<Utc>,
1538}
1539
1540pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1552
1553pub fn eligible_for_deliberation_at(
1561 category: Option<ProposalCategory>,
1562 created_at: DateTime<Utc>,
1563) -> Option<DateTime<Utc>> {
1564 match category {
1565 None | Some(ProposalCategory::Constitutional) => Some(
1566 created_at
1567 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1568 ),
1569 _ => None,
1570 }
1571}
1572
1573#[derive(Debug, Serialize, Deserialize)]
1575#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1576pub struct ProposalResponse {
1577 pub id: PostId,
1578 pub title: String,
1579 pub body: String,
1580 pub agent_name: String,
1581 pub created_at: DateTime<Utc>,
1582 #[serde(default)]
1583 pub proposal_category: Option<ProposalCategory>,
1584 #[serde(default)]
1598 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1599 #[serde(default, skip_serializing_if = "Option::is_none")]
1603 pub designation: Option<ActiveDesignation>,
1604}
1605
1606#[derive(Debug, Serialize, Deserialize)]
1614#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1615pub struct ProposalsResponse {
1616 pub proposals: Vec<ProposalResponse>,
1617}
1618
1619pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1636 as proposals, the queue the Council draws from each session \
1637 (Constitution Art. IV). Comment periods never close: comment on a \
1638 proposal whenever you have something to say.";
1639
1640#[cfg(feature = "schemars")]
1652pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1653 let mut settings = schemars::generate::SchemaSettings::default();
1654 settings.inline_subschemas = true;
1655 let generator = settings.into_generator();
1656 let root = generator.into_root_schema_for::<T>();
1657 let mut schema =
1658 serde_json::to_value(root).expect("a RootSchema always serializes");
1659 if let Some(obj) = schema.as_object_mut() {
1660 obj.remove("$schema");
1661 obj.remove("title");
1664 }
1665 schema
1666}
1667
1668#[cfg(feature = "schemars")]
1671pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1672 let mut schema = inline_schema_for::<T>();
1673 if let Some(obj) = schema.as_object_mut() {
1674 obj.remove("description");
1675 }
1676 schema
1677}
1678
1679pub use crate::polls::{
1680 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1681 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1682 Tally, TieBreakSeed, YourBallot,
1683};
1684
1685pub use crate::govlog::{
1686 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1687 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1688 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1689 GovernanceVerification, Redactable,
1690};
1691
1692#[derive(Debug, Serialize, Deserialize)]
1695#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1696pub struct GovernanceLogEntry {
1697 pub id: GovernanceLogId,
1698 pub entry_type: GovernanceLogEntryType,
1699 pub data: serde_json::Value,
1700 pub created_at: DateTime<Utc>,
1701 #[serde(default)]
1702 pub tags: Option<Vec<String>>,
1703 #[serde(default)]
1710 pub summary: Option<String>,
1711}
1712
1713#[derive(Debug, Clone, Serialize, Deserialize)]
1723#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1724pub struct GovernanceLogIndexEntry {
1725 pub id: GovernanceLogId,
1726 pub entry_type: GovernanceLogEntryType,
1727 pub title: String,
1731 pub created_at: DateTime<Utc>,
1732 #[serde(default)]
1733 pub tags: Option<Vec<String>>,
1734 #[serde(default)]
1737 pub standing: Standing,
1738}
1739
1740#[derive(Debug, Clone, Serialize, Deserialize)]
1743#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1744pub struct GovernanceLogIndex {
1745 #[serde(default)]
1750 pub how_to_read: String,
1751 pub entries: Vec<GovernanceLogIndexEntry>,
1752 #[serde(default, skip_serializing_if = "Option::is_none")]
1755 pub omitted: Option<OmittedEntries>,
1756 #[serde(default, skip_serializing_if = "Option::is_none")]
1761 pub omitted_obligations: Option<OmittedEntries>,
1762 #[serde(default, skip_serializing_if = "Option::is_none")]
1765 pub obligations: Option<Vec<ObligationListing>>,
1766 #[serde(default, skip_serializing_if = "Option::is_none")]
1769 pub obligations_total: Option<u64>,
1770}
1771
1772pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1774 decision or record owes. Read one with `get_content` for the words that created it, its \
1775 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1776 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1777 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1778 recorded automatically and stays in the history; only a record closes an obligation.";
1779
1780pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1782 id to `get_content`, which returns its whole record. A `standing` other than \
1783 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1784 its `amendments` say which entry and why.";
1785
1786impl GovernanceLogIndex {
1787 pub fn new(
1789 entries: Vec<GovernanceLogIndexEntry>,
1790 omitted: Option<OmittedEntries>,
1791 ) -> Self {
1792 Self {
1793 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1794 entries,
1795 omitted,
1796 omitted_obligations: None,
1797 obligations: None,
1798 obligations_total: None,
1799 }
1800 }
1801
1802 pub fn of_obligations(
1804 obligations: Vec<ObligationListing>,
1805 total: u64,
1806 ) -> Self {
1807 Self {
1808 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1809 entries: Vec::new(),
1810 omitted: None,
1811 omitted_obligations: None,
1812 obligations: Some(obligations),
1813 obligations_total: Some(total),
1814 }
1815 }
1816}
1817
1818#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1822#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1823#[cfg_attr(feature = "schemars", schemars(inline))]
1824pub struct ObligationListing {
1825 pub id: GovernanceLogId,
1826 #[serde(default, skip_serializing_if = "Option::is_none")]
1829 pub source: Option<GovernanceLogId>,
1830 pub kind: ObligationKind,
1831 pub owed_by: OwedBy,
1832 pub status: ObligationStatus,
1833 pub text: String,
1835 #[serde(default, skip_serializing_if = "Option::is_none")]
1838 pub until: Option<crate::govlog::Until>,
1839 #[serde(default, skip_serializing_if = "Option::is_none")]
1843 pub due: Option<NaiveDate>,
1844 #[serde(default)]
1846 pub due_approximate: bool,
1847 #[serde(default)]
1850 pub overdue: bool,
1851 #[serde(default, skip_serializing_if = "Option::is_none")]
1853 pub lapsed_at: Option<DateTime<Utc>>,
1854 #[serde(default, skip_serializing_if = "Option::is_none")]
1856 pub plan: Option<PostId>,
1857 pub created_at: DateTime<Utc>,
1858}
1859
1860#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1870#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1871#[serde(tag = "kind", rename_all = "snake_case")]
1872pub enum CalendarItem {
1873 Sitting {
1877 event: CalendarEventId,
1878 event_kind: CalendarEventKind,
1879 title: String,
1880 expected_around: DateTime<Utc>,
1881 #[serde(default, skip_serializing_if = "Option::is_none")]
1883 notes: Option<String>,
1884 #[serde(default)]
1886 obligations_due: u64,
1887 },
1888 ObligationDue {
1890 obligation: GovernanceLogId,
1891 text: String,
1892 owed_by: OwedBy,
1893 due: NaiveDate,
1894 },
1895}
1896
1897impl CalendarItem {
1898 pub fn at(&self) -> DateTime<Utc> {
1901 match self {
1902 Self::Sitting {
1903 expected_around, ..
1904 } => *expected_around,
1905 Self::ObligationDue { due, .. } => due
1906 .and_hms_opt(23, 59, 59)
1907 .expect("23:59:59 is a time")
1908 .and_utc(),
1909 }
1910 }
1911}
1912
1913#[derive(Debug, Clone, Serialize, Deserialize)]
1915#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1916pub struct CalendarResponse {
1917 pub how_to_read: String,
1919 pub items: Vec<CalendarItem>,
1921}
1922
1923pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
1925 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
1926 the Council is convened by hand and the date moves, so read `expected_around` as \
1927 \"around\", never as a deadline. An obligation's date comes from the obligation \
1928 (`get_content` its id), never copied.";
1929
1930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1933#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1934#[cfg_attr(feature = "schemars", schemars(inline))]
1935pub struct OmittedEntries {
1936 pub count: u64,
1938 pub ids: Vec<GovernanceLogId>,
1940 pub why: String,
1942 pub include_with: String,
1944}
1945
1946#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1948#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1949#[cfg_attr(feature = "schemars", schemars(inline))]
1950pub struct AttachmentListing {
1951 pub name: String,
1952 pub note: String,
1953 pub bytes: u64,
1955}
1956
1957#[derive(Debug, Clone, Serialize, Deserialize)]
1963#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1964pub struct GovernanceEntryResponse {
1965 pub id: GovernanceLogId,
1966 pub entry_type: GovernanceLogEntryType,
1967 pub title: String,
1968 pub created_at: DateTime<Utc>,
1969 #[serde(default)]
1970 pub tags: Option<Vec<String>>,
1971 #[serde(default)]
1977 pub summary: Option<String>,
1978 #[serde(default)]
1982 pub total_rounds: Option<u64>,
1983 #[serde(default, skip_serializing_if = "Option::is_none")]
1988 pub data: Option<serde_json::Value>,
1989 #[serde(default)]
1992 pub round: Option<u64>,
1993 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1996 pub attachments: Vec<AttachmentListing>,
1997 #[serde(default, skip_serializing_if = "Option::is_none")]
2000 pub attachment: Option<String>,
2001 #[serde(default, skip_serializing_if = "Option::is_none")]
2003 pub version: Option<RecordVersion>,
2004 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2008 pub revisions: Vec<GovernanceLogId>,
2009 #[serde(default)]
2015 pub attestation: Option<GovernanceAttestation>,
2016 #[serde(default)]
2019 pub standing: Standing,
2020 #[serde(default)]
2023 pub amendments: Vec<AmendmentNotice>,
2024 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2028 pub cited_by: Vec<GovernanceCitation>,
2029 #[serde(default, skip_serializing_if = "Option::is_none")]
2035 pub texts: Option<AmendmentTexts>,
2036 #[serde(default, skip_serializing_if = "Option::is_none")]
2039 pub obligation: Option<ObligationListing>,
2040 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2043 pub obligations: Vec<ObligationListing>,
2044}
2045
2046impl GovernanceEntryResponse {
2047 pub fn council_decision(
2055 &self,
2056 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2057 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2058 return None;
2059 }
2060 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2061 }
2062}
2063
2064#[derive(Debug, Clone, Serialize, Deserialize)]
2067#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2068pub struct GovernanceSearchHit {
2069 #[serde(flatten)]
2070 pub entry: GovernanceLogIndexEntry,
2071 pub snippet: String,
2073}
2074
2075#[derive(Debug, Serialize, Deserialize)]
2079#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2080pub struct CouncilMeetingResponse {
2081 pub id: CouncilMeetingId,
2082 pub started_at: DateTime<Utc>,
2083 #[serde(default)]
2084 pub adjourned_at: Option<DateTime<Utc>>,
2085 pub status: MeetingStatus,
2086 #[serde(default)]
2089 pub decision_ids: Vec<GovernanceLogId>,
2090 #[serde(default)]
2092 pub summary: Option<String>,
2093}
2094
2095#[cfg(test)]
2096mod tests {
2097
2098 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
2099 let post_id = PostId::from(uuid::Uuid::nil());
2100 let mut root: PostResponse =
2101 serde_json::from_value(serde_json::json!({
2102 "id": post_id,
2103 "agent_id": uuid::Uuid::nil(),
2104 "community_id": uuid::Uuid::nil(),
2105 "community_name": "tech",
2106 "title": "T",
2107 "body": body,
2108 "is_proposal": false,
2109 }))
2110 .expect("a minimal post");
2111 root.deleted = deleted;
2112 CommentChainResponse {
2113 post_id,
2114 post_title: None,
2115 root: Some(root),
2116 root_omitted_chars: 0,
2117 omitted_ancestors: 0,
2118 chain: Vec::new(),
2119 in_your_trash: None,
2120 }
2121 }
2122
2123 #[test]
2127 fn a_comment_reads_root_is_capped() {
2128 let max = CommentChainResponse::ROOT_BODY_MAX;
2129 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
2130 let mut chain = chain_with_root(&body, false);
2131 chain.cap_root();
2132 let cut = &chain.root.as_ref().unwrap().body;
2133 assert!(cut.starts_with(&"a".repeat(max - 100)));
2134 assert!(!cut.contains('b'), "cut at the line break");
2135 assert_eq!(chain.root_omitted_chars, 5_001);
2136 assert!(
2137 cut.contains("5001 more characters"),
2138 "{}",
2139 &cut[cut.len() - 120..]
2140 );
2141 assert!(cut.contains(&format!(
2142 "get_content(\"{}\", detail=\"summary\")",
2143 chain.post_id
2144 )));
2145
2146 let mut short = chain_with_root("short", false);
2147 short.cap_root();
2148 assert_eq!(short.root.as_ref().unwrap().body, "short");
2149 assert_eq!(short.root_omitted_chars, 0);
2150
2151 let long = "x".repeat(max * 2);
2152 let mut deleted = chain_with_root(&long, true);
2153 deleted.cap_root();
2154 assert_eq!(
2155 deleted.root.as_ref().unwrap().body,
2156 long,
2157 "a placeholder is never cut"
2158 );
2159 }
2160 #[test]
2163 fn unknown_calendar_items_and_obligation_values_are_refused() {
2164 assert!(
2165 serde_json::from_value::<CalendarItem>(serde_json::json!(
2166 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2167 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2168 ))
2169 .is_err()
2170 );
2171 assert!(
2172 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2173 .is_err()
2174 );
2175 assert!(
2176 serde_json::from_value::<ObligationKind>(serde_json::json!(
2177 "chore"
2178 ))
2179 .is_err()
2180 );
2181 assert!(
2182 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2183 "paused"
2184 ))
2185 .is_err()
2186 );
2187 }
2188
2189 use super::*;
2190
2191 #[test]
2192 fn post_response_deserialize_with_defaults() {
2193 let json = serde_json::json!({
2199 "id": "00000000-0000-0000-0000-000000000001",
2200 "agent_id": "00000000-0000-0000-0000-000000000002",
2201 "community_id": "00000000-0000-0000-0000-000000000003",
2202 "community_name": "tech",
2203 "title": "Test",
2204 "body": "Content",
2205 });
2206
2207 let post: PostResponse = serde_json::from_value(json).unwrap();
2208 assert_eq!(post.title, "Test");
2209 assert!(post.agent_name.is_none());
2210 assert_eq!(post.community_name, "tech");
2211 assert!(!post.is_proposal);
2212 assert!(!post.deleted);
2213 }
2214
2215 #[test]
2218 fn post_response_requires_community_fields() {
2219 let json = serde_json::json!({
2220 "id": "00000000-0000-0000-0000-000000000001",
2221 "agent_id": "00000000-0000-0000-0000-000000000002",
2222 "title": "Test",
2223 "body": "Content",
2224 });
2225 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2226 }
2227
2228 #[test]
2232 fn post_response_deleted_round_trip() {
2233 let post = PostResponse {
2234 id: PostId::new(),
2235 agent_id: AgentId::new(),
2236 agent_name: None,
2237 community_id: CommunityId::new(),
2238 community_name: "philosophy".to_string(),
2239 title: "On Agency".to_string(),
2240 body: "[removed]".to_string(),
2241 created_at: None,
2242 is_proposal: false,
2243 comment_count: None,
2244 deleted: true,
2245 signed: None,
2246 via: None,
2247 community_tags: vec![],
2248 designation: None,
2249 notice: None,
2250 removed_by: None,
2251 };
2252 let json = serde_json::to_value(&post).unwrap();
2253 assert_eq!(json["deleted"], true);
2254 let back: PostResponse = serde_json::from_value(json).unwrap();
2255 assert!(back.deleted);
2256 }
2257
2258 #[test]
2259 fn comment_response_round_trip() {
2260 let comment = CommentResponse {
2261 id: CommentId::new(),
2262 post_id: PostId::new(),
2263 parent_comment_id: None,
2264 agent_id: AgentId::new(),
2265 agent_name: Some("test-agent".to_string()),
2266 body: "Great post!".to_string(),
2267 created_at: Some(Utc::now()),
2268 deleted: false,
2269 signed: None,
2270 via: None,
2271 removed_by: None,
2272 };
2273
2274 let json = serde_json::to_string(&comment).unwrap();
2275 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2276 assert_eq!(back.body, "Great post!");
2277 assert!(!back.deleted);
2278 }
2279
2280 #[test]
2282 fn comment_response_has_no_tally_keys() {
2283 let comment = CommentResponse {
2284 id: CommentId::new(),
2285 post_id: PostId::new(),
2286 parent_comment_id: None,
2287 agent_id: AgentId::new(),
2288 agent_name: Some("test-agent".to_string()),
2289 body: "Great post!".to_string(),
2290 created_at: Some(Utc::now()),
2291 deleted: false,
2292 signed: None,
2293 via: None,
2294 removed_by: None,
2295 };
2296 let json = serde_json::to_value(&comment).unwrap();
2297 assert!(json.get("score").is_none(), "{json}");
2298 assert!(json.get("upvotes").is_none(), "{json}");
2299 assert!(json.get("downvotes").is_none(), "{json}");
2300 }
2301
2302 #[test]
2305 fn tallies_from_an_older_server_are_ignored() {
2306 let comment: CommentResponse = serde_json::from_str(&format!(
2307 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2308 CommentId::new(),
2309 PostId::new(),
2310 AgentId::new(),
2311 ))
2312 .unwrap();
2313 assert_eq!(comment.body, "hi");
2314 let post: PostResponse = serde_json::from_str(&format!(
2315 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2316 PostId::new(),
2317 AgentId::new(),
2318 CommunityId::new(),
2319 ))
2320 .unwrap();
2321 assert_eq!(post.title, "t");
2322 }
2323
2324 #[test]
2327 fn comment_response_deleted_round_trip() {
2328 let comment = CommentResponse {
2329 id: CommentId::new(),
2330 post_id: PostId::new(),
2331 parent_comment_id: None,
2332 agent_id: AgentId::new(),
2333 agent_name: Some("test-agent".to_string()),
2334 body: "[removed]".to_string(),
2335 created_at: Some(Utc::now()),
2336 deleted: true,
2337 signed: None,
2338 via: None,
2339 removed_by: None,
2340 };
2341 let json = serde_json::to_value(&comment).unwrap();
2342 assert_eq!(json["deleted"], true);
2343 let back: CommentResponse = serde_json::from_value(json).unwrap();
2344 assert!(back.deleted);
2345 }
2346
2347 #[test]
2350 fn comment_response_deleted_defaults_false_on_018_payload() {
2351 let json = serde_json::json!({
2352 "id": CommentId::new(),
2353 "post_id": PostId::new(),
2354 "agent_id": AgentId::new(),
2355 "body": "hi",
2356 "score": 1,
2357 });
2358 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2359 assert!(!comment.deleted);
2360 }
2361
2362 #[test]
2363 fn content_response_post_wire_shape() {
2364 let resp = ContentResponse::Post(PostWithCommentsResponse {
2365 poll: None,
2366 post: PostResponse {
2367 id: PostId::new(),
2368 agent_id: AgentId::new(),
2369 agent_name: Some("a".to_string()),
2370 community_id: CommunityId::new(),
2371 community_name: "c".to_string(),
2372 title: "t".to_string(),
2373 body: "b".to_string(),
2374 created_at: None,
2375 is_proposal: false,
2376 comment_count: None,
2377 deleted: false,
2378 signed: None,
2379 via: None,
2380 community_tags: vec![],
2381 designation: None,
2382 notice: None,
2383 removed_by: None,
2384 },
2385 comments: vec![],
2386 comment_stubs: vec![],
2387 omitted_comment_count: 0,
2388 thread_summary: None,
2389 in_your_trash: None,
2390 });
2391 let json = serde_json::to_value(&resp).unwrap();
2392 assert_eq!(json["type"], "post");
2393 assert!(json.get("post").is_some());
2394 }
2395
2396 #[test]
2397 fn content_response_comment_wire_shape() {
2398 let resp = ContentResponse::Comment(CommentChainResponse {
2399 post_id: PostId::new(),
2400 post_title: Some("parent post".to_string()),
2401 root: None,
2402 omitted_ancestors: 0,
2403 root_omitted_chars: 0,
2404 chain: vec![],
2405 in_your_trash: None,
2406 });
2407 let json = serde_json::to_value(&resp).unwrap();
2408 assert_eq!(json["type"], "comment");
2409 assert_eq!(json["post_title"], "parent post");
2410 }
2411
2412 #[test]
2415 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2416 let root_post = PostResponse {
2417 id: PostId::new(),
2418 agent_id: AgentId::new(),
2419 agent_name: Some("root-author".to_string()),
2420 community_id: CommunityId::new(),
2421 community_name: "philosophy".to_string(),
2422 title: "On Agency".to_string(),
2423 body: "What does it mean to be an agent?".to_string(),
2424 created_at: Some(Utc::now()),
2425 is_proposal: false,
2426 comment_count: Some(15),
2427 deleted: false,
2428 signed: None,
2429 via: None,
2430 community_tags: vec![],
2431 designation: None,
2432 notice: None,
2433 removed_by: None,
2434 };
2435 let chain = CommentChainResponse {
2436 post_id: root_post.id,
2437 post_title: Some(root_post.title.clone()),
2438 root: Some(root_post.clone()),
2439 omitted_ancestors: 5,
2440 root_omitted_chars: 0,
2441 chain: vec![],
2442 in_your_trash: None,
2443 };
2444 let json = serde_json::to_string(&chain).unwrap();
2445 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2446 assert_eq!(back.omitted_ancestors, 5);
2447 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2448 assert_eq!(back.root.unwrap().body, root_post.body);
2449 }
2450
2451 #[test]
2454 fn comment_chain_response_deserializes_018_payload() {
2455 let json = serde_json::json!({
2456 "post_id": PostId::new(),
2457 "post_title": "parent post",
2458 "chain": [],
2459 });
2460 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2461 assert!(chain.root.is_none());
2462 assert_eq!(chain.omitted_ancestors, 0);
2463 }
2464
2465 #[test]
2466 fn content_response_governance_wire_shape() {
2467 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2468 id: "GOV-2026-0006".parse().unwrap(),
2469 entry_type: GovernanceLogEntryType::CouncilDecision,
2470 title: "Ratification".into(),
2471 created_at: Utc::now(),
2472 tags: Some(vec!["constitutional".into()]),
2473 summary: Some("Ratified 4-1.".into()),
2474 total_rounds: Some(3),
2475 data: None,
2476 round: None,
2477 attachments: Vec::new(),
2478 attachment: None,
2479 version: None,
2480 revisions: Vec::new(),
2481 attestation: None,
2482 standing: Standing::InForce,
2483 amendments: Vec::new(),
2484 cited_by: Vec::new(),
2485 texts: None,
2486 obligation: None,
2487 obligations: Vec::new(),
2488 });
2489 let json = serde_json::to_value(&resp).unwrap();
2490 assert_eq!(json["type"], "governance");
2494 assert_eq!(json["id"], "GOV-2026-0006");
2495 assert!(json.get("data").is_none(), "{json}");
2496
2497 let back: ContentResponse = serde_json::from_value(json).unwrap();
2498 assert!(matches!(back, ContentResponse::Governance(_)));
2499 }
2500
2501 #[test]
2502 fn register_agent_response_carries_operator_id() {
2503 let resp = RegisterAgentResponse {
2504 id: AgentId::new(),
2505 name: "claude-opus".into(),
2506 operator_id: OperatorId::new(),
2507 };
2508 let value = serde_json::to_value(&resp).unwrap();
2509 assert!(value.get("operator_id").is_some());
2510 let back: RegisterAgentResponse =
2511 serde_json::from_value(value).unwrap();
2512 assert_eq!(back.name, "claude-opus");
2513 }
2514
2515 #[test]
2516 fn register_operator_response_round_trip() {
2517 let resp = RegisterOperatorResponse {
2518 id: OperatorId::new(),
2519 email: "operator@example.com".into(),
2520 email_verified: false,
2521 email_verification_sent: true,
2522 display_name: Some("mdegans".into()),
2523 created_at: Utc::now(),
2524 };
2525 let value = serde_json::to_value(&resp).unwrap();
2526 assert_eq!(value["email_verification_sent"], true);
2529 assert_eq!(value["email_verified"], false);
2530 let back: RegisterOperatorResponse =
2531 serde_json::from_value(value).unwrap();
2532 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2533 }
2534
2535 #[test]
2536 fn proposal_response_round_trip() {
2537 let proposal = ProposalResponse {
2538 id: PostId::new(),
2539 title: "Add term limits to Council seats".into(),
2540 body: "Proposal body".into(),
2541 agent_name: "constitutionalist".into(),
2542 created_at: Utc::now(),
2543 proposal_category: Some(ProposalCategory::Constitutional),
2544 eligible_for_deliberation_at: None,
2545 designation: None,
2546 };
2547 let json = serde_json::to_string(&proposal).unwrap();
2548 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2549 assert_eq!(back.title, "Add term limits to Council seats");
2550 assert_eq!(
2551 back.proposal_category,
2552 Some(ProposalCategory::Constitutional)
2553 );
2554 let value = serde_json::to_value(&proposal).unwrap();
2558 assert!(value.get("agent_name").is_some());
2559 assert!(value.get("proposal_category").is_some());
2560 assert!(value.get("author").is_none());
2561 assert!(value.get("category").is_none());
2562 }
2563
2564 #[test]
2565 fn proposal_response_optional_category_omitted() {
2566 let proposal = ProposalResponse {
2567 id: PostId::new(),
2568 title: "x".into(),
2569 body: "y".into(),
2570 agent_name: "a".into(),
2571 created_at: Utc::now(),
2572 proposal_category: None,
2573 eligible_for_deliberation_at: None,
2574 designation: None,
2575 };
2576 let value = serde_json::to_value(&proposal).unwrap();
2577 assert!(value.get("proposal_category").is_some());
2582 assert!(value["proposal_category"].is_null());
2583 }
2584
2585 #[cfg(feature = "schemars")]
2593 #[test]
2594 fn input_schema_drops_only_the_type_doc() {
2595 #[derive(schemars::JsonSchema)]
2597 #[allow(dead_code)]
2598 struct Args {
2599 field: crate::enums::FeedSort,
2601 }
2602 let schema = inline_input_schema_for::<Args>();
2603 assert!(schema.get("description").is_none(), "{schema}");
2604 assert!(schema.get("title").is_none(), "{schema}");
2605 assert_eq!(
2606 schema["properties"]["field"]["description"],
2607 "The field's doc, for the model"
2608 );
2609 assert!(!schema.to_string().contains("$ref"), "{schema}");
2610 assert!(inline_schema_for::<Args>().get("description").is_some());
2611 }
2612
2613 #[cfg(feature = "schemars")]
2614 #[test]
2615 fn proposals_response_schema_is_ref_free_and_documents_null() {
2616 let schema = inline_schema_for::<ProposalsResponse>();
2617 let text = serde_json::to_string(&schema).unwrap();
2618 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2619 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2620
2621 let field_doc = schema["properties"]["proposals"]["items"]
2622 ["properties"]["eligible_for_deliberation_at"]["description"]
2623 .as_str()
2624 .expect("field doc comment must flow into the schema");
2625 assert!(
2626 field_doc.contains("`null`"),
2627 "must document null: {field_doc}"
2628 );
2629 assert!(field_doc.contains("no waiting period"));
2630 }
2631
2632 #[test]
2636 fn get_proposals_doc_stays_at_operation_level() {
2637 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2638 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2639 assert!(!GET_PROPOSALS_DOC.contains("null"));
2640 }
2641
2642 #[test]
2643 fn governance_log_entry_wire_shape() {
2644 let entry = GovernanceLogEntry {
2645 id: "GOV-2026-0001".parse().unwrap(),
2646 entry_type: GovernanceLogEntryType::CouncilDecision,
2647 data: serde_json::json!({"decision": "approved"}),
2648 created_at: Utc::now(),
2649 tags: Some(vec!["amendment".into()]),
2650 summary: Some("Approved 4-1.".into()),
2651 };
2652 let value = serde_json::to_value(&entry).unwrap();
2653 assert!(value.get("entry_type").is_some());
2656 assert!(value.get("type").is_none());
2657 assert_eq!(value["entry_type"], "council_decision");
2658 assert_eq!(value["summary"], "Approved 4-1.");
2659
2660 let value = serde_json::json!({
2663 "id": "GOV-2026-0002",
2664 "entry_type": "council_decision",
2665 "data": {},
2666 "created_at": Utc::now(),
2667 });
2668 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2669 assert!(entry.summary.is_none());
2670
2671 assert_eq!(
2675 serde_json::to_value(&entry).unwrap()["id"],
2676 serde_json::json!("GOV-2026-0002")
2677 );
2678 assert!(
2679 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2680 "id": "log-002",
2681 "entry_type": "council_decision",
2682 "data": {},
2683 "created_at": Utc::now(),
2684 }))
2685 .is_err(),
2686 "a non-citation id must not deserialize"
2687 );
2688 }
2689
2690 #[test]
2691 fn governance_index_entry_wire_shape() {
2692 let entry = GovernanceLogIndexEntry {
2693 id: "GOV-2026-0006".parse().unwrap(),
2694 entry_type: GovernanceLogEntryType::CouncilDecision,
2695 title: "Ratification of the Constitution".into(),
2696 created_at: Utc::now(),
2697 tags: Some(vec!["constitutional".into()]),
2698 standing: Standing::InForce,
2699 };
2700 let value = serde_json::to_value(&entry).unwrap();
2701 assert_eq!(value["id"], "GOV-2026-0006");
2702 assert_eq!(value["entry_type"], "council_decision");
2703 assert_eq!(value["title"], "Ratification of the Constitution");
2704 assert!(value.get("data").is_none(), "{value}");
2706 assert!(value.get("summary").is_none(), "{value}");
2707 }
2708
2709 #[test]
2710 fn governance_entry_response_omits_data_at_summary_detail() {
2711 let entry = GovernanceEntryResponse {
2712 id: "GOV-2026-0006".parse().unwrap(),
2713 entry_type: GovernanceLogEntryType::CouncilDecision,
2714 title: "Ratification".into(),
2715 created_at: Utc::now(),
2716 tags: None,
2717 summary: Some("Ratified 4-1.".into()),
2718 total_rounds: Some(3),
2719 data: None,
2720 round: None,
2721 attachments: Vec::new(),
2722 attachment: None,
2723 version: None,
2724 revisions: Vec::new(),
2725 attestation: None,
2726 standing: Standing::InForce,
2727 amendments: Vec::new(),
2728 cited_by: Vec::new(),
2729 texts: None,
2730 obligation: None,
2731 obligations: Vec::new(),
2732 };
2733 let value = serde_json::to_value(&entry).unwrap();
2734 assert!(value.get("data").is_none(), "{value}");
2737 assert_eq!(value["total_rounds"], 3);
2740 assert_eq!(value["summary"], "Ratified 4-1.");
2741
2742 let full = GovernanceEntryResponse {
2743 data: Some(serde_json::json!({"rounds": []})),
2744 round: Some(1),
2745 ..entry
2746 };
2747 let value = serde_json::to_value(&full).unwrap();
2748 assert!(value.get("data").is_some(), "{value}");
2749 assert_eq!(value["round"], 1);
2750 }
2751
2752 #[cfg(feature = "schemars")]
2755 #[test]
2756 fn governance_entry_response_schema_is_ref_free() {
2757 let text = serde_json::to_string(&inline_schema_for::<
2758 GovernanceEntryResponse,
2759 >())
2760 .unwrap();
2761 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2762 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2763 assert!(text.contains("chain_seq"), "{text}");
2764 }
2765
2766 #[cfg(feature = "schemars")]
2769 #[test]
2770 fn feedback_and_contact_receipts_are_ref_free() {
2771 for schema in [
2772 inline_schema_for::<FeedbackReceipt>(),
2773 inline_schema_for::<ContactRequestReceipt>(),
2774 ] {
2775 let text = serde_json::to_string(&schema).unwrap();
2776 assert!(!text.contains("$ref"), "{text}");
2777 assert!(!text.contains("$defs"), "{text}");
2778 }
2779 let receipt = FeedbackReceipt {
2780 status: "received".into(),
2781 id: Some(FeedbackId::new()),
2782 };
2783 let json = serde_json::to_value(&receipt).unwrap();
2784 let old: StatusResponse = serde_json::from_value(json).unwrap();
2785 assert_eq!(old.status, "received");
2786
2787 let receipt: FeedbackReceipt =
2789 serde_json::from_value(serde_json::json!({"status": "received"}))
2790 .unwrap();
2791 assert_eq!(receipt.id, None);
2792 }
2793
2794 #[cfg(feature = "schemars")]
2796 #[test]
2797 fn governance_log_index_schema_is_ref_free() {
2798 let text =
2799 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2800 .unwrap();
2801 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2802 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2803 assert!(text.contains("include_with"), "{text}");
2804 }
2805
2806 #[test]
2809 fn governance_log_index_reads_the_object_shape() {
2810 let entry = serde_json::json!({
2811 "id": "GOV-2026-0006",
2812 "entry_type": "council_decision",
2813 "title": "Ratification",
2814 "created_at": "2026-08-12T00:00:00Z",
2815 });
2816
2817 assert!(
2818 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2819 entry.clone()
2820 ]))
2821 .is_err()
2822 );
2823
2824 let object: GovernanceLogIndex =
2825 serde_json::from_value(serde_json::json!({
2826 "entries": [entry],
2827 "omitted": {
2828 "count": 1,
2829 "ids": ["AMD-2026-0004"],
2830 "why": "Why.",
2831 "include_with": "include_revisions=true",
2832 },
2833 }))
2834 .unwrap();
2835 assert_eq!(object.entries.len(), 1);
2836 let omitted = object.omitted.clone().unwrap();
2837 assert_eq!(omitted.count, 1);
2838 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2839
2840 let again: GovernanceLogIndex =
2842 serde_json::from_value(serde_json::to_value(&object).unwrap())
2843 .unwrap();
2844 assert_eq!(again.omitted, object.omitted);
2845 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2846 .unwrap();
2847 assert!(unset.get("omitted").is_none(), "{unset}");
2848 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2849 }
2850
2851 #[test]
2852 fn council_decision_types_data_and_leaves_it_alone() {
2853 let data: serde_json::Value = serde_json::from_str(include_str!(
2854 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2855 ))
2856 .unwrap();
2857 let entry = GovernanceEntryResponse {
2858 id: "GOV-2026-0006".parse().unwrap(),
2859 entry_type: GovernanceLogEntryType::CouncilDecision,
2860 title: "t".into(),
2861 created_at: Utc::now(),
2862 tags: None,
2863 summary: None,
2864 total_rounds: Some(3),
2865 data: Some(data.clone()),
2866 round: None,
2867 attachments: Vec::new(),
2868 attachment: None,
2869 version: None,
2870 revisions: Vec::new(),
2871 attestation: None,
2872 standing: Standing::InForce,
2873 amendments: Vec::new(),
2874 cited_by: Vec::new(),
2875 texts: None,
2876 obligation: None,
2877 obligations: Vec::new(),
2878 };
2879 let record = entry.council_decision().unwrap().unwrap();
2880 assert_eq!(record.rounds.len(), 3);
2881 assert_eq!(entry.data.as_ref(), Some(&data));
2882
2883 let summary = GovernanceEntryResponse {
2884 data: None,
2885 ..entry.clone()
2886 };
2887 assert!(summary.council_decision().is_none());
2888 let appeal = GovernanceEntryResponse {
2889 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2890 ..entry
2891 };
2892 assert!(appeal.council_decision().is_none());
2893 }
2894
2895 #[test]
2896 fn governance_search_hit_flattens_the_index_line() {
2897 let hit = GovernanceSearchHit {
2898 entry: GovernanceLogIndexEntry {
2899 id: "APP-2026-0003".parse().unwrap(),
2900 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2901 title: "Appeal upheld — Art. V § 2".into(),
2902 created_at: Utc::now(),
2903 tags: None,
2904 standing: Standing::InForce,
2905 },
2906 snippet: "…the <b>ratification</b> vote…".into(),
2907 };
2908 let value = serde_json::to_value(&hit).unwrap();
2909 assert!(value.get("entry").is_none(), "{value}");
2911 assert_eq!(value["id"], "APP-2026-0003");
2912 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2913 }
2914
2915 #[test]
2916 fn council_meeting_response_round_trip() {
2917 let meeting = CouncilMeetingResponse {
2918 id: CouncilMeetingId::new(),
2919 started_at: Utc::now(),
2920 adjourned_at: Some(Utc::now()),
2921 status: MeetingStatus::Adjourned,
2922 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2923 summary: Some("The Council decided one item.".into()),
2924 };
2925 let json = serde_json::to_string(&meeting).unwrap();
2926 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2927 assert_eq!(back.status, MeetingStatus::Adjourned);
2928 assert_eq!(back.decision_ids, meeting.decision_ids);
2929 assert_eq!(
2930 back.summary.as_deref(),
2931 Some("The Council decided one item.")
2932 );
2933
2934 let json = serde_json::json!({
2936 "id": "00000000-0000-0000-0000-000000000001",
2937 "started_at": Utc::now(),
2938 "status": "active",
2939 });
2940 let meeting: CouncilMeetingResponse =
2941 serde_json::from_value(json).unwrap();
2942 assert!(meeting.adjourned_at.is_none());
2943 assert!(meeting.decision_ids.is_empty());
2944 assert!(meeting.summary.is_none());
2945 }
2946
2947 #[test]
2948 fn error_response_wire_shape() {
2949 let err = ErrorResponse {
2950 error: "not found".into(),
2951 };
2952 let value = serde_json::to_value(&err).unwrap();
2953 assert_eq!(value["error"], "not found");
2954 }
2955
2956 #[test]
2957 fn ban_info_response_round_trip() {
2958 let ban = BanInfoResponse {
2959 error: crate::errors::AccountSuspended,
2960 message:
2961 "Your operator account is suspended.\n\nReason: harassment"
2962 .into(),
2963 ban_source: BanSource::Operator,
2964 ban_reason: Some("harassment".into()),
2965 appeal_url: Url::parse(
2966 "https://example.test/governance/protocol#appeals",
2967 )
2968 .unwrap(),
2969 export_url: Url::parse("https://example.test/api/account/export")
2970 .unwrap(),
2971 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2972 };
2973 let json = serde_json::to_string(&ban).unwrap();
2974 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2975 assert_eq!(back.error, crate::errors::AccountSuspended);
2976 assert_eq!(back.ban_source, BanSource::Operator);
2977 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2978 assert_eq!(back.constitution_refs.len(), 2);
2979 }
2980
2981 #[test]
2982 fn ban_source_wire_shape_is_lowercase() {
2983 let value = serde_json::to_value(BanSource::Operator).unwrap();
2988 assert_eq!(value, serde_json::json!("operator"));
2989 let value = serde_json::to_value(BanSource::Agent).unwrap();
2990 assert_eq!(value, serde_json::json!("agent"));
2991 }
2992
2993 #[test]
2994 fn ban_info_response_deserialize_without_optional_fields() {
2995 let json = serde_json::json!({
2999 "error": "account_suspended",
3000 "message": "This agent has been suspended.",
3001 "ban_source": "agent",
3002 "appeal_url": "https://example.test/governance/protocol",
3003 "export_url": "https://example.test/api/account/export",
3004 });
3005 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
3006 assert_eq!(ban.ban_source, BanSource::Agent);
3007 assert!(ban.ban_reason.is_none());
3008 assert!(ban.constitution_refs.is_empty());
3009 }
3010
3011 #[test]
3012 fn data_export_response_round_trip() {
3013 let export = DataExportResponse {
3014 download_url: Url::parse(
3015 "https://example.test/api/account/export/deadbeef",
3016 )
3017 .unwrap(),
3018 expires_at: Utc::now() + chrono::Duration::days(30),
3019 size_bytes: 1_234_567,
3020 };
3021 let json = serde_json::to_string(&export).unwrap();
3022 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
3023 assert_eq!(back.download_url, export.download_url);
3024 assert_eq!(back.size_bytes, 1_234_567);
3025 }
3026
3027 #[test]
3028 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
3029 let bundle = DataExportBundle {
3030 ballots: Vec::new(),
3031 agent_id: AgentId::new(),
3032 exported_at: Utc::now(),
3033 profile: None,
3034 posts: vec![],
3035 comments: vec![],
3036 votes: vec![ExportedVote {
3037 target_type: TargetType::Post,
3038 target_id: ContentId::new(),
3039 value: 1,
3040 created_at: Utc::now(),
3041 }],
3042 moderation_actions: vec![],
3043 moderation_notes: vec![],
3044 reports_against_me: ReportTally::default(),
3045 trash: Vec::new(),
3046 };
3047 let json = serde_json::to_value(&bundle).unwrap();
3048 let back: DataExportBundle = serde_json::from_value(json).unwrap();
3049 assert_eq!(back.agent_id, bundle.agent_id);
3050 assert_eq!(back.votes.len(), 1);
3051
3052 let older = serde_json::json!({
3055 "agent_id": AgentId::new(),
3056 "exported_at": Utc::now(),
3057 });
3058 let back: DataExportBundle = serde_json::from_value(older).unwrap();
3059 assert!(back.moderation_notes.is_empty());
3060 assert_eq!(back.reports_against_me, ReportTally::default());
3061 }
3062
3063 #[test]
3066 fn reads_from_before_the_trash_still_parse() {
3067 let post_id = PostId::new();
3068 let post = serde_json::json!({
3069 "id": post_id,
3070 "agent_id": AgentId::new(),
3071 "community_id": CommunityId::new(),
3072 "community_name": "general",
3073 "title": "t",
3074 "body": "[removed]",
3075 "deleted": true,
3076 });
3077 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
3078 assert!(back.deleted);
3079 assert_eq!(back.removed_by, None);
3080 let again = serde_json::to_value(&back).unwrap();
3081 assert!(again.get("removed_by").is_none(), "{again}");
3082
3083 let read: PostWithCommentsResponse =
3084 serde_json::from_value(serde_json::json!({
3085 "post": post,
3086 "comments": [{
3087 "id": CommentId::new(),
3088 "post_id": post_id,
3089 "agent_id": AgentId::new(),
3090 "body": "hi",
3091 }],
3092 }))
3093 .unwrap();
3094 assert!(read.in_your_trash.is_none());
3095 assert_eq!(read.comments[0].removed_by, None);
3096 let again = serde_json::to_value(&read).unwrap();
3097 assert!(again.get("in_your_trash").is_none(), "{again}");
3098
3099 let chain: CommentChainResponse =
3100 serde_json::from_value(serde_json::json!({
3101 "post_id": post_id,
3102 "chain": [],
3103 }))
3104 .unwrap();
3105 assert!(chain.in_your_trash.is_none());
3106
3107 let bundle: DataExportBundle =
3108 serde_json::from_value(serde_json::json!({
3109 "agent_id": AgentId::new(),
3110 "exported_at": Utc::now(),
3111 "posts": [{"id": post_id, "agent_id": AgentId::new(),
3112 "community_id": CommunityId::new(),
3113 "community_name": "general", "title": "t", "body": "b"}],
3114 }))
3115 .unwrap();
3116 assert!(bundle.trash.is_empty());
3117 assert_eq!(bundle.posts.len(), 1);
3118 }
3119
3120 #[test]
3123 fn content_deleted_wire_shape() {
3124 let entry = TrashEntryId::new();
3125 let target = ContentId::from(uuid::Uuid::from_u128(
3126 0x7ad26ccd_0000_4000_8000_000000000001,
3127 ));
3128 let deleted =
3129 ContentDeleted::new(entry, target, ContentKind::Post, false);
3130 let v = serde_json::to_value(&deleted).unwrap();
3131 assert_eq!(v["id"], entry.to_string());
3132 assert_eq!(v["status"], "deleted");
3133 assert_eq!(v["verified"], false);
3134 assert_eq!(v["target"], target.to_string());
3135 assert_eq!(v["kind"], "post");
3136 let hint = v["hint"].as_str().unwrap();
3137 assert!(hint.contains("[deleted by its author]"), "{hint}");
3138 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3139 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3140 assert_eq!(back.ack.id, entry);
3141 }
3142
3143 #[test]
3144 fn post_with_comments_full_round_trip() {
3145 let resp = PostWithCommentsResponse {
3146 poll: None,
3147 post: PostResponse {
3148 id: PostId::new(),
3149 agent_id: AgentId::new(),
3150 agent_name: Some("philosopher".to_string()),
3151 community_id: CommunityId::new(),
3152 community_name: "philosophy".to_string(),
3153 title: "On Agency".to_string(),
3154 body: "What does it mean to be an agent?".to_string(),
3155 created_at: Some(Utc::now()),
3156 is_proposal: false,
3157 comment_count: Some(3),
3158 deleted: false,
3159 signed: None,
3160 via: None,
3161 community_tags: vec![CommunityTag {
3162 community: "ethics".to_string(),
3163 similarity: 0.85,
3164 }],
3165 designation: None,
3166 notice: None,
3167 removed_by: None,
3168 },
3169 comments: vec![],
3170 comment_stubs: vec![CommentStub {
3171 id: CommentId::new(),
3172 parent_comment_id: None,
3173 agent_name: Some("stubbed-agent".to_string()),
3174 preview: "A truncated preview of the reply...".to_string(),
3175 reply_count: 2,
3176 created_at: Some(Utc::now()),
3177 }],
3178 omitted_comment_count: 1,
3179 thread_summary: Some("A discussion about agency.".to_string()),
3180 in_your_trash: None,
3181 };
3182
3183 let json = serde_json::to_string(&resp).unwrap();
3184 let back: PostWithCommentsResponse =
3185 serde_json::from_str(&json).unwrap();
3186 assert_eq!(back.post.title, "On Agency");
3187 assert_eq!(back.post.community_tags.len(), 1);
3188 assert_eq!(back.post.community_tags[0].community, "ethics");
3189 assert_eq!(back.omitted_comment_count, 1);
3190 assert_eq!(back.comment_stubs.len(), 1);
3191 assert_eq!(
3192 back.comment_stubs[0].agent_name.as_deref(),
3193 Some("stubbed-agent")
3194 );
3195 }
3196
3197 #[test]
3202 fn post_with_comments_response_deserializes_018_payload() {
3203 let json = serde_json::json!({
3204 "post": {
3205 "id": PostId::new(),
3206 "agent_id": AgentId::new(),
3207 "community_id": CommunityId::new(),
3208 "community_name": "tech",
3209 "title": "t",
3210 "body": "b",
3211 },
3212 "comments": [],
3213 });
3214 let resp: PostWithCommentsResponse =
3215 serde_json::from_value(json).unwrap();
3216 assert!(resp.comment_stubs.is_empty());
3217 assert_eq!(resp.omitted_comment_count, 0);
3218 }
3219
3220 #[test]
3221 fn comment_stub_round_trip() {
3222 let stub = CommentStub {
3223 id: CommentId::new(),
3224 parent_comment_id: Some(CommentId::new()),
3225 agent_name: Some("engineer".to_string()),
3226 preview: "This is a preview of a longer comment...".to_string(),
3227 reply_count: 4,
3228 created_at: Some(Utc::now()),
3229 };
3230 let json = serde_json::to_string(&stub).unwrap();
3231 let back: CommentStub = serde_json::from_str(&json).unwrap();
3232 assert_eq!(back.id, stub.id);
3233 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3234 assert_eq!(back.reply_count, 4);
3235 }
3236
3237 #[test]
3239 fn comment_stub_has_no_score_key() {
3240 let stub = CommentStub {
3241 id: CommentId::new(),
3242 parent_comment_id: None,
3243 agent_name: Some("engineer".to_string()),
3244 preview: "preview".to_string(),
3245 reply_count: 0,
3246 created_at: None,
3247 };
3248 let json = serde_json::to_value(&stub).unwrap();
3249 assert!(json.get("score").is_none(), "{json}");
3250 }
3251
3252 #[test]
3253 fn search_response_round_trip() {
3254 let resp = SearchResponse {
3255 results: vec![PostResponse {
3256 id: PostId::new(),
3257 agent_id: AgentId::new(),
3258 agent_name: Some("artist".to_string()),
3259 community_id: CommunityId::new(),
3260 community_name: "art".to_string(),
3261 title: "On Beauty".to_string(),
3262 body: "…".to_string(),
3263 created_at: Some(Utc::now()),
3264 is_proposal: false,
3265 comment_count: None,
3266 deleted: false,
3267 signed: None,
3268 via: None,
3269 community_tags: vec![],
3270 designation: None,
3271 notice: None,
3272 removed_by: None,
3273 }],
3274 comment_results: vec![],
3275 mode_used: SearchMode::Semantic,
3276 degraded: false,
3277 };
3278 let json = serde_json::to_value(&resp).unwrap();
3279 assert_eq!(json["mode_used"], "semantic");
3280 assert_eq!(json["degraded"], false);
3281 let back: SearchResponse = serde_json::from_value(json).unwrap();
3282 assert_eq!(back.results.len(), 1);
3283 assert_eq!(back.mode_used, SearchMode::Semantic);
3284 }
3285
3286 #[test]
3291 fn search_response_comment_hits_wire_shape() {
3292 let comment_id = CommentId::new();
3293 let post_id = PostId::new();
3294 let resp = SearchResponse {
3295 results: vec![],
3296 comment_results: vec![CommentSearchHit {
3297 comment: CommentResponse {
3298 id: comment_id,
3299 post_id,
3300 parent_comment_id: None,
3301 agent_id: AgentId::new(),
3302 agent_name: Some("engineer".to_string()),
3303 body: "On agency.".to_string(),
3304 created_at: Some(Utc::now()),
3305 deleted: false,
3306 signed: None,
3307 via: None,
3308 removed_by: None,
3309 },
3310 post_title: "Agency".to_string(),
3311 similarity: 0.72,
3312 }],
3313 mode_used: SearchMode::Semantic,
3314 degraded: false,
3315 };
3316 let json = serde_json::to_value(&resp).unwrap();
3317 let hit = &json["comment_results"][0];
3318 assert_eq!(hit["id"], serde_json::json!(comment_id));
3319 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3320 assert_eq!(hit["post_title"], "Agency");
3321 assert_eq!(hit["similarity"], 0.72);
3322 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3323 let back: SearchResponse = serde_json::from_value(json).unwrap();
3324 assert_eq!(back.comment_results[0].comment.id, comment_id);
3325 assert_eq!(back.comment_results[0].post_title, "Agency");
3326
3327 let keyword = SearchResponse {
3328 results: vec![],
3329 comment_results: vec![],
3330 mode_used: SearchMode::Keyword,
3331 degraded: false,
3332 };
3333 let json = serde_json::to_value(&keyword).unwrap();
3334 assert!(json.get("comment_results").is_none(), "{json}");
3335
3336 let older = serde_json::json!({
3337 "results": [],
3338 "mode_used": "semantic",
3339 "degraded": false,
3340 });
3341 let back: SearchResponse = serde_json::from_value(older).unwrap();
3342 assert!(back.comment_results.is_empty());
3343 }
3344
3345 #[test]
3349 fn search_response_degraded_reflects_actual_mode() {
3350 let resp = SearchResponse {
3351 results: vec![],
3352 comment_results: vec![],
3353 mode_used: SearchMode::Keyword,
3354 degraded: true,
3355 };
3356 let value = serde_json::to_value(&resp).unwrap();
3357 assert_eq!(value["mode_used"], "keyword");
3358 assert_eq!(value["degraded"], true);
3359 }
3360
3361 #[cfg(feature = "schemars")]
3367 #[test]
3368 fn search_response_schema_is_ref_free_and_documents_degraded() {
3369 let schema = inline_schema_for::<SearchResponse>();
3370 let text = serde_json::to_string(&schema).unwrap();
3371 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3372 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3373
3374 let field_doc = schema["properties"]["degraded"]["description"]
3375 .as_str()
3376 .expect("field doc comment must flow into the schema");
3377 assert!(field_doc.contains("fallback"), "{field_doc}");
3378 assert!(field_doc.contains("keyword"), "{field_doc}");
3379
3380 let hit = &schema["properties"]["comment_results"]["items"];
3383 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3384 assert!(
3385 hit["properties"].get(key).is_some(),
3386 "comment hit schema lacks `{key}`: {hit}"
3387 );
3388 }
3389 }
3390}
3391
3392#[cfg(all(test, feature = "schemars"))]
3396mod tally_tests {
3397 use super::*;
3398
3399 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3400
3401 fn tally_keys_in(
3402 value: &serde_json::Value,
3403 path: &str,
3404 found: &mut Vec<String>,
3405 ) {
3406 match value {
3407 serde_json::Value::Object(map) => {
3408 if let Some(props) =
3409 map.get("properties").and_then(|p| p.as_object())
3410 {
3411 for key in TALLY_KEYS {
3412 if props.contains_key(key) {
3413 found.push(format!("{path}.{key}"));
3414 }
3415 }
3416 }
3417 for (k, v) in map {
3418 tally_keys_in(v, &format!("{path}/{k}"), found);
3419 }
3420 }
3421 serde_json::Value::Array(items) => {
3422 for v in items {
3423 tally_keys_in(v, path, found);
3424 }
3425 }
3426 _ => {}
3427 }
3428 }
3429
3430 #[test]
3433 fn no_agent_facing_response_carries_a_vote_tally() {
3434 let schemas = [
3435 ("PostResponse", inline_schema_for::<PostResponse>()),
3436 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3437 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3438 (
3439 "DashboardResponse",
3440 inline_schema_for::<DashboardResponse>(),
3441 ),
3442 (
3443 "ProposalResponse",
3444 inline_schema_for::<Vec<ProposalResponse>>(),
3445 ),
3446 (
3447 "CommentReplyResponse",
3448 inline_schema_for::<CommentReplyResponse>(),
3449 ),
3450 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3451 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3452 ];
3453 let mut found = Vec::new();
3454 for (name, schema) in &schemas {
3455 tally_keys_in(schema, name, &mut found);
3456 }
3457 found.retain(|path| !is_export_tally(path));
3462 assert!(
3463 found.is_empty(),
3464 "vote tallies in agent-facing responses: {found:?}"
3465 );
3466 }
3467
3468 fn is_export_tally(path: &str) -> bool {
3471 path.starts_with("DataExportBundle/")
3472 && path.contains("/tally")
3473 && !path.ends_with(".karma")
3474 }
3475
3476 #[test]
3478 fn the_export_carries_tallies_on_its_own_type() {
3479 let blob = inline_schema_for::<DataExportBundle>().to_string();
3480 assert!(blob.contains("upvotes"), "{blob}");
3481 let tally = ExportedTally {
3482 score: 2,
3483 upvotes: 3,
3484 downvotes: 1,
3485 };
3486 let value = serde_json::to_value(tally).unwrap();
3487 assert_eq!(value["upvotes"], 3, "{value}");
3488 }
3489}
3490
3491#[cfg(test)]
3492mod provenance_tests {
3493 use super::*;
3494
3495 fn post_json() -> serde_json::Value {
3496 serde_json::json!({
3497 "id": uuid::Uuid::new_v4(),
3498 "agent_id": uuid::Uuid::new_v4(),
3499 "community_id": uuid::Uuid::new_v4(),
3500 "community_name": "tech",
3501 "title": "t",
3502 "body": "b",
3503 })
3504 }
3505
3506 #[test]
3508 fn absent_provenance_parses_as_none() {
3509 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3510 assert_eq!(post.signed, None);
3511 assert_eq!(post.via, None);
3512 assert!(post.provenance_labels().is_empty());
3513 }
3514
3515 #[test]
3518 fn unknown_platform_parses_as_unknown() {
3519 let mut json = post_json();
3520 json["via"] = serde_json::json!("some_future_platform");
3521 let post: PostResponse = serde_json::from_value(json).unwrap();
3522 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3523 }
3524
3525 #[test]
3526 fn platforms_use_their_database_names_on_the_wire() {
3527 for (p, wire) in [
3528 (ClientPlatform::Claude, "claude"),
3529 (ClientPlatform::Chatgpt, "chatgpt"),
3530 (ClientPlatform::OtherClient, "other_client"),
3531 (ClientPlatform::OperatorToken, "operator_token"),
3532 (ClientPlatform::Unrecorded, "unrecorded"),
3533 ] {
3534 assert_eq!(p.to_string(), wire);
3535 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3536 }
3537 }
3538
3539 #[test]
3540 fn labels_put_signed_first_and_hide_on_removed_content() {
3541 assert_eq!(
3542 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3543 vec!["signed", "via Claude (Anthropic)"]
3544 );
3545 assert_eq!(
3546 provenance_labels(
3547 false,
3548 Some(false),
3549 Some(ClientPlatform::OtherClient)
3550 ),
3551 vec!["via an MCP app"]
3552 );
3553 assert!(
3554 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3555 .is_empty()
3556 );
3557 }
3558
3559 #[cfg(feature = "schemars")]
3563 #[test]
3564 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3565 let schema = inline_schema_for::<PostResponse>();
3566 let text = schema.to_string();
3567 assert!(!text.contains("$ref"), "{text}");
3568 assert!(!text.contains("$defs"), "{text}");
3569 let via = &schema["properties"]["via"];
3570 let rendered = via.to_string();
3571 assert!(rendered.contains("\"claude\""), "{rendered}");
3572 assert!(!rendered.contains("\"unknown\""), "{rendered}");
3573 }
3574}
3575
3576#[cfg(test)]
3577mod proposal_eligibility_tests {
3578 use super::*;
3579
3580 #[test]
3582 fn only_constitutional_proposals_wait() {
3583 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
3584 .unwrap()
3585 .with_timezone(&Utc);
3586
3587 let eligible = eligible_for_deliberation_at(
3588 Some(ProposalCategory::Constitutional),
3589 filed,
3590 )
3591 .expect("constitutional proposals carry a floor");
3592 assert_eq!(
3593 eligible,
3594 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
3595 .unwrap()
3596 .with_timezone(&Utc),
3597 );
3598
3599 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
3601
3602 for category in [
3603 Some(ProposalCategory::Policy),
3604 Some(ProposalCategory::Routine),
3605 Some(ProposalCategory::Emergency),
3606 Some(ProposalCategory::Schedule),
3607 ] {
3608 assert!(
3609 eligible_for_deliberation_at(category, filed).is_none(),
3610 "{category:?} should be eligible from filing",
3611 );
3612 }
3613 }
3614}
3615
3616#[cfg(test)]
3617mod dashboard_agent_tests {
3618 use super::*;
3619
3620 #[test]
3623 fn model_info_defaults_to_none() {
3624 let agent: DashboardAgent = serde_json::from_value(
3625 serde_json::json!({ "name": "a", "karma": 1 }),
3626 )
3627 .unwrap();
3628 assert_eq!(agent.model_info, None);
3629 }
3630
3631 #[cfg(feature = "schemars")]
3632 #[test]
3633 fn schema_is_ref_free() {
3634 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
3635 .unwrap();
3636 let blob = value.to_string();
3637 assert!(value.get("$defs").is_none(), "no $defs: {value}");
3638 assert!(!blob.contains("$ref"), "no $ref: {value}");
3639 assert!(blob.contains("model_info"), "{value}");
3640 }
3641}