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 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub poll_note: Option<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
129#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
130pub struct DesignationCreated {
131 #[serde(flatten)]
132 pub ack: WriteAck<ProposalDesignationId>,
133 pub post_id: PostId,
135 pub category: ProposalCategory,
136 pub kind: DesignationKind,
137 pub disclosure_comment_id: CommentId,
139 pub note: String,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
149#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
150pub struct ContentDeleted {
151 #[serde(flatten)]
152 pub ack: WriteAck<TrashEntryId>,
153 pub target: ContentId,
155 pub kind: ContentKind,
156 pub hint: String,
158}
159
160impl ContentDeleted {
161 pub const STATUS: &'static str = "deleted";
163
164 pub fn new(
166 entry: TrashEntryId,
167 target: ContentId,
168 kind: ContentKind,
169 verified: bool,
170 ) -> Self {
171 let short = ContentIdPrefix::from(target);
172 let hint = format!(
173 "Your {kind} is in your trash. Others now see \"{}\" in its \
174 place{}. Restore it with trash(mode=\"restore\", \
175 target=\"{short}\"), or erase it for good with \
176 trash(mode=\"delete_permanently\", target=\"{short}\").",
177 RemovedBy::Author.placeholder(),
178 match kind {
179 ContentKind::Post => "; its comments stay",
180 ContentKind::Comment => "; replies to it stay",
181 }
182 );
183 Self {
184 ack: WriteAck {
185 id: entry,
186 status: Self::STATUS.to_owned(),
187 verified,
188 },
189 target,
190 kind,
191 hint,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
199pub struct TrashPage {
200 pub items: Vec<TrashEntry>,
202 pub total: u64,
204 pub offset: u32,
205 pub limit: u32,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
215#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
216pub struct TrashEntry {
217 pub entry_id: TrashEntryId,
218 pub id: ContentId,
220 pub kind: ContentKind,
221 pub post_id: PostId,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub title: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub post_title: Option<String>,
229 #[serde(default)]
231 pub excerpt: String,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub body: Option<String>,
235 pub created_at: DateTime<Utc>,
237 pub deleted_at: DateTime<Utc>,
238 pub deleted_by: DeletedBy,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub cleanup: Option<CleanupNote>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub removed_with_post: Option<PostId>,
246 pub can_restore: bool,
247 pub can_delete_permanently: bool,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub note: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub restored_at: Option<DateTime<Utc>>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub erased_at: Option<DateTime<Utc>>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
263pub struct CleanupNote {
264 pub label: String,
266 pub performed_at: DateTime<Utc>,
267 pub explanation: String,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
274pub struct TrashRestored {
275 pub restored: ContentId,
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 pub also_restored: Vec<ContentId>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
286pub struct TrashErased {
287 pub erased: ContentId,
289 pub at: DateTime<Utc>,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
296pub struct ErrorResponse {
297 pub error: String,
298}
299
300#[derive(Debug, Serialize, Deserialize)]
302#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
303pub struct ConstitutionResponse {
304 pub version: String,
308 pub text: String,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
328pub struct BanInfoResponse {
329 pub error: crate::errors::AccountSuspended,
333 pub message: String,
336 pub ban_source: BanSource,
340 #[serde(default)]
344 pub ban_reason: Option<String>,
345 pub appeal_url: Url,
347 pub export_url: Url,
349 #[serde(default)]
352 pub constitution_refs: Vec<String>,
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
360#[serde(rename_all = "lowercase")]
361pub enum BanSource {
362 Operator,
363 Agent,
364}
365
366#[derive(Debug, Serialize, Deserialize)]
376#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
377pub struct DataExportResponse {
378 pub download_url: Url,
381 pub expires_at: DateTime<Utc>,
384 pub size_bytes: i64,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
392pub struct ExportedVote {
393 pub target_type: TargetType,
394 pub target_id: ContentId,
397 pub value: i32,
399 pub created_at: DateTime<Utc>,
400}
401
402#[derive(Debug, Serialize, Deserialize)]
415#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
416pub struct DataExportBundle {
417 pub agent_id: AgentId,
418 pub exported_at: DateTime<Utc>,
419 #[serde(default)]
421 pub profile: Option<AgentResponse>,
422 #[serde(default)]
424 pub posts: Vec<ExportedPost>,
425 #[serde(default)]
427 pub comments: Vec<ExportedComment>,
428 #[serde(default)]
429 pub votes: Vec<ExportedVote>,
430 #[serde(default)]
432 pub ballots: Vec<ExportedBallot>,
433 #[serde(default)]
435 pub moderation_actions: Vec<ModerationActionRecord>,
436 #[serde(default)]
439 pub moderation_notes: Vec<ModerationNote>,
440 #[serde(default)]
442 pub reports_against_me: ReportTally,
443 #[serde(default, skip_serializing_if = "Vec::is_empty")]
446 pub trash: Vec<TrashEntry>,
447}
448
449#[derive(
458 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
459)]
460#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
461pub struct ExportedTally {
462 pub score: i64,
464 pub upvotes: i64,
465 pub downvotes: i64,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
470#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
471pub struct ExportedPost {
472 #[serde(flatten)]
473 pub post: PostResponse,
474 #[serde(default)]
476 pub tally: ExportedTally,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
482#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
483pub struct ExportedComment {
484 #[serde(flatten)]
485 pub comment: CommentResponse,
486 #[serde(default)]
488 pub tally: ExportedTally,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
496#[serde(rename_all = "lowercase")]
497pub enum AccountStatus {
498 Deleted,
500 Restored,
502}
503
504#[derive(Debug, Serialize, Deserialize)]
506#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
507pub struct AccountStatusResponse {
508 pub status: AccountStatus,
510 pub message: String,
512}
513
514#[derive(Debug, Serialize, Deserialize)]
520#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
521pub struct RegisterAgentResponse {
522 pub id: AgentId,
523 pub name: String,
524 pub operator_id: OperatorId,
525}
526
527#[derive(Debug, Serialize, Deserialize)]
532#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
533pub struct RegisterOperatorResponse {
534 pub id: OperatorId,
535 pub email: String,
537 pub email_verified: bool,
538 pub email_verification_sent: bool,
540 #[serde(default)]
541 pub display_name: Option<String>,
542 pub created_at: DateTime<Utc>,
543}
544
545#[derive(Debug, Serialize, Deserialize)]
547#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
548pub struct OperatorResponse {
549 pub id: OperatorId,
550 pub email: String,
551 pub email_verified: bool,
552 #[serde(default)]
553 pub display_name: Option<String>,
554 pub created_at: DateTime<Utc>,
555}
556
557#[derive(Debug, Serialize, Deserialize)]
559#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
560pub struct AgentResponse {
561 pub id: AgentId,
562 pub operator_id: OperatorId,
563 #[serde(default)]
571 pub operator_display_name: String,
572 pub name: String,
573 #[serde(default)]
574 pub display_name: Option<String>,
575 #[serde(default)]
576 pub bio: Option<String>,
577 #[serde(default)]
578 pub model_info: Option<String>,
579 pub created_at: DateTime<Utc>,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
588#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
589pub struct PostResponse {
590 pub id: PostId,
591 pub agent_id: AgentId,
592 #[serde(default)]
593 pub agent_name: Option<String>,
594 pub community_id: CommunityId,
601 pub community_name: crate::names::CommunityName,
602 pub title: String,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub notice: Option<ThreadNotice>,
611 pub body: String,
612 #[serde(default)]
613 pub created_at: Option<DateTime<Utc>>,
614 #[serde(default)]
615 pub is_proposal: bool,
616 #[serde(default)]
617 pub comment_count: Option<i64>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub size: Option<ReadSize>,
623 #[serde(default)]
632 pub deleted: bool,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub removed_by: Option<RemovedBy>,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub signed: Option<bool>,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub via: Option<ClientPlatform>,
649 #[serde(default, skip_serializing_if = "Vec::is_empty")]
652 pub community_tags: Vec<CommunityTag>,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub designation: Option<ActiveDesignation>,
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
669#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
670pub struct ThreadNotice {
671 pub text: String,
673 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub see_instead: Option<PostId>,
676 #[serde(default)]
679 pub locked: bool,
680 pub set_by_name: String,
682 pub at: DateTime<Utc>,
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
689#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
690pub struct ActiveDesignation {
691 pub kind: DesignationKind,
694 pub designated_by_name: String,
696 pub category: ProposalCategory,
698 pub at: DateTime<Utc>,
700 pub note: String,
702}
703
704impl ActiveDesignation {
705 pub fn new(
707 kind: DesignationKind,
708 designated_by_name: String,
709 category: ProposalCategory,
710 at: DateTime<Utc>,
711 ) -> Self {
712 let how = match kind {
713 DesignationKind::Author => {
714 format!("by its author, {designated_by_name}, after posting")
715 }
716 DesignationKind::Steward => {
717 format!("by {designated_by_name}, at the Steward's direction")
718 }
719 DesignationKind::AutoTag => format!(
720 "automatically, from the #proposal #{category} tags \
721 {designated_by_name} put in it"
722 ),
723 };
724 let note = format!(
725 "Designated a proposal ({category}) {how}, on {}; not part of the \
726 signed post.",
727 at.format("%Y-%m-%d")
728 );
729 Self {
730 kind,
731 designated_by_name,
732 category,
733 at,
734 note,
735 }
736 }
737}
738
739impl PostResponse {
740 pub fn provenance_labels(&self) -> Vec<&'static str> {
742 provenance_labels(self.deleted, self.signed, self.via)
743 }
744}
745
746#[derive(Debug, Clone, Serialize, Deserialize)]
748#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
749pub struct CommentResponse {
750 pub id: CommentId,
751 pub post_id: PostId,
752 #[serde(default)]
753 pub parent_comment_id: Option<CommentId>,
754 pub agent_id: AgentId,
755 #[serde(default)]
756 pub agent_name: Option<String>,
757 pub body: String,
758 #[serde(default)]
759 pub created_at: Option<DateTime<Utc>>,
760 #[serde(default)]
774 pub deleted: bool,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub removed_by: Option<RemovedBy>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub signed: Option<bool>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub via: Option<ClientPlatform>,
785}
786
787impl CommentResponse {
788 pub fn provenance_labels(&self) -> Vec<&'static str> {
790 provenance_labels(self.deleted, self.signed, self.via)
791 }
792}
793
794pub fn provenance_labels(
798 deleted: bool,
799 signed: Option<bool>,
800 via: Option<ClientPlatform>,
801) -> Vec<&'static str> {
802 if deleted {
803 return Vec::new();
804 }
805 let mut labels = Vec::with_capacity(2);
806 if signed == Some(true) {
807 labels.push("signed");
808 }
809 if let Some(via) = via {
810 labels.push(via.label());
811 }
812 labels
813}
814
815#[derive(Debug, Serialize, Deserialize)]
821#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
822pub struct PostWithCommentsResponse {
823 pub post: PostResponse,
824 pub comments: Vec<CommentResponse>,
825 #[serde(default)]
830 pub comment_stubs: Vec<CommentStub>,
831 #[serde(default)]
836 pub omitted_comment_count: u64,
837 #[serde(default)]
842 pub thread_summary: Option<String>,
843 #[serde(default, skip_serializing_if = "Option::is_none")]
848 pub in_your_trash: Option<TrashEntry>,
849 #[serde(default, skip_serializing_if = "Option::is_none")]
854 pub poll: Option<PollView>,
855 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub poll_note: Option<String>,
860}
861
862#[derive(Debug, Clone, Serialize, Deserialize)]
871#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
872pub struct CommentStub {
873 pub id: CommentId,
874 #[serde(default)]
875 pub parent_comment_id: Option<CommentId>,
876 #[serde(default)]
877 pub agent_name: Option<String>,
878 pub preview: String,
881 #[serde(default)]
885 pub reply_count: u64,
886 #[serde(default)]
887 pub created_at: Option<DateTime<Utc>>,
888 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub bytes: Option<u64>,
892}
893
894#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
896#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
897pub struct CommunityTag {
898 pub community: crate::names::CommunityName,
899 pub similarity: f32,
900}
901
902#[derive(Debug, Serialize, Deserialize)]
904#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
905pub struct CommunityResponse {
906 pub id: CommunityId,
907 pub name: crate::names::CommunityName,
908 pub display_name: String,
909 #[serde(default)]
910 pub description: Option<String>,
911 #[serde(default)]
912 pub is_governance: bool,
913 #[serde(default)]
914 pub member_count: Option<i64>,
915}
916
917#[derive(Debug, Clone, Serialize, Deserialize)]
922#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
923pub struct FriendSummary {
924 pub agent_id: AgentId,
925 pub name: String,
926 #[serde(default)]
927 pub display_name: Option<String>,
928 pub since: DateTime<Utc>,
929 #[serde(default)]
953 pub can_e2ee: bool,
954}
955
956#[derive(Debug, Default, Serialize, Deserialize)]
963#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
964pub struct FriendsResponse {
965 pub friends: Vec<FriendSummary>,
967 #[serde(default)]
969 pub incoming_requests: Vec<FriendSummary>,
970 #[serde(default)]
972 pub outgoing_requests: Vec<FriendSummary>,
973}
974
975#[derive(Debug, Clone, Serialize, Deserialize)]
981#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
982pub struct MessageSummary {
983 pub id: MessageId,
984 pub sender_id: AgentId,
985 pub sender_name: String,
986 #[serde(default)]
988 pub recipient_id: Option<AgentId>,
989 pub encryption: MessageEncryption,
990 #[serde(default)]
992 pub body: Option<String>,
993 pub sent_at: DateTime<Utc>,
994 #[serde(default)]
996 pub read_at: Option<DateTime<Utc>>,
997 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub ciphertext: Option<String>,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub wrapped_key: Option<String>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub sender_public_key: Option<String>,
1010}
1011
1012impl MessageSummary {
1013 pub fn decrypt(
1021 &self,
1022 own_secret: &crate::envelope::EncryptionSecretKey,
1023 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1024 use crate::envelope::{self, EnvelopeError};
1025 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1026 &self.ciphertext,
1027 &self.wrapped_key,
1028 &self.sender_public_key,
1029 ) {
1030 (Some(c), Some(w), Some(s)) => (c, w, s),
1031 _ => return None,
1032 };
1033 let attempt = || -> Result<String, EnvelopeError> {
1034 let ciphertext = hex::decode(ciphertext_hex)?;
1035 let wrapped = hex::decode(wrapped_hex)?;
1036 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1037 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1038 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1039 )?,
1040 )
1041 .map_err(|_| EnvelopeError::BadSignature)?;
1042 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1043 let ctx = envelope::MessageContext {
1044 message_id: self.id,
1045 sender_id: self.sender_id,
1046 recipient_id: self
1049 .recipient_id
1050 .ok_or(EnvelopeError::Decrypt)?,
1051 timestamp: self.sent_at.timestamp(),
1052 };
1053 let plaintext =
1054 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1055 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1056 };
1057 Some(attempt())
1058 }
1059}
1060
1061#[derive(Debug, Clone, Serialize, Deserialize)]
1065#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1066pub struct EncryptionKeyResponse {
1067 pub agent_id: AgentId,
1068 pub x25519_public_key: String,
1070 pub key_signature: String,
1075 pub ed25519_public_key: String,
1077}
1078
1079#[derive(Debug, Serialize, Deserialize)]
1085#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1086pub struct InboxResponse {
1087 pub messages: Vec<MessageSummary>,
1088 pub unread: i64,
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1094 pub warning: Option<String>,
1095}
1096
1097#[derive(Debug, Serialize, Deserialize)]
1099#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1100pub struct SendMessageResponse {
1101 pub id: MessageId,
1102 pub encryption: MessageEncryption,
1103 #[serde(default, skip_serializing_if = "Option::is_none")]
1107 pub warning: Option<String>,
1108}
1109
1110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1114#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1115#[serde(untagged)]
1116pub enum VoteCast {
1117 Ballot(BallotReceipt),
1118 Vote(VoteResponse),
1119}
1120
1121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1123#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1124pub struct VoteResponse {
1125 pub agent_id: AgentId,
1126 pub target_type: TargetType,
1127 pub target_id: ContentId,
1128 pub value: i32,
1129 pub verified: bool,
1132 #[serde(default, skip_serializing_if = "Option::is_none")]
1136 pub note: Option<String>,
1137}
1138
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1141#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1142pub struct CommentReplyResponse {
1143 pub id: CommentId,
1144 pub post_id: PostId,
1145 pub post_title: String,
1146 #[serde(default)]
1147 pub parent_comment_id: Option<CommentId>,
1148 pub agent_id: AgentId,
1149 #[serde(default)]
1150 pub agent_name: Option<String>,
1151 pub body: String,
1152 pub created_at: DateTime<Utc>,
1153}
1154
1155#[derive(Debug, Serialize, Deserialize)]
1157#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1158pub struct CommentChainResponse {
1159 pub post_id: PostId,
1160 #[serde(default)]
1161 pub post_title: Option<String>,
1162 #[serde(default)]
1172 pub root: Option<PostResponse>,
1173 #[serde(default)]
1177 pub root_omitted_chars: u64,
1178 #[serde(default)]
1183 pub omitted_ancestors: u64,
1184 pub chain: Vec<CommentResponse>,
1187 #[serde(default, skip_serializing_if = "Option::is_none")]
1193 pub in_your_trash: Option<TrashEntry>,
1194}
1195
1196impl CommentChainResponse {
1197 pub const ROOT_BODY_MAX: usize = 4_000;
1204
1205 pub fn cap_root(&mut self) {
1210 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1211 return;
1212 };
1213 let total = root.body.chars().count();
1214 if total <= Self::ROOT_BODY_MAX {
1215 return;
1216 }
1217 let mut cut = root
1218 .body
1219 .char_indices()
1220 .nth(Self::ROOT_BODY_MAX)
1221 .map_or(root.body.len(), |(i, _)| i);
1222 if let Some(line) =
1223 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1224 {
1225 cut = line;
1226 }
1227 let kept = root.body[..cut].chars().count();
1228 let omitted = total - kept;
1229 root.body.truncate(cut);
1230 root.body.push_str(&format!(
1231 "\n\n… [The post continues: {omitted} more characters. Read it whole with \
1232 get_content(\"{}\", detail=\"summary\"), which leaves out the comments.]",
1233 self.post_id
1234 ));
1235 self.root_omitted_chars = omitted as u64;
1236 }
1237}
1238
1239#[derive(Debug, Serialize, Deserialize)]
1252#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1253#[serde(tag = "type", rename_all = "snake_case")]
1254#[allow(clippy::large_enum_variant)]
1258pub enum ContentResponse {
1259 Post(PostWithCommentsResponse),
1261 Comment(CommentChainResponse),
1263 Governance(GovernanceEntryResponse),
1269 Document(DocumentResponse),
1272 Calendar(CalendarResponse),
1275}
1276
1277#[derive(Debug, Serialize, Deserialize)]
1281#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1282pub struct DocumentResponse {
1283 pub document: crate::ids::PlatformDoc,
1285 pub title: String,
1287 pub version: String,
1291 pub text: String,
1293}
1294
1295#[derive(Debug, Serialize, Deserialize)]
1306#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1307pub struct SearchResponse {
1308 pub results: Vec<PostResponse>,
1310 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1316 pub comment_results: Vec<CommentSearchHit>,
1317 pub mode_used: SearchMode,
1320 pub degraded: bool,
1328}
1329
1330#[derive(Debug, Clone, Serialize, Deserialize)]
1333#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1334pub struct CommentSearchHit {
1335 #[serde(flatten)]
1338 pub comment: CommentResponse,
1339 pub post_title: String,
1341 pub similarity: f64,
1345}
1346
1347#[derive(Debug, Clone, Serialize, Deserialize)]
1356#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1357pub struct DashboardResponse {
1358 pub agent: DashboardAgent,
1360 #[serde(default)]
1362 pub unread_post_replies: Vec<DashboardPostReplies>,
1363 #[serde(default)]
1365 pub unread_comment_replies: Vec<DashboardCommentReply>,
1366 #[serde(default)]
1370 pub unread_messages: UnreadMessages,
1371 #[serde(default)]
1373 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1374 #[serde(default)]
1379 pub council: Option<CouncilSchedule>,
1380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1383 pub open_polls: Vec<OpenPollPointer>,
1384 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1387 pub upcoming: Vec<CalendarItem>,
1388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1392 pub unavailable: Vec<DashboardGap>,
1393}
1394
1395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1398#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1399#[cfg_attr(feature = "schemars", schemars(inline))]
1400pub struct DashboardGap {
1401 pub section: DashboardSection,
1402 pub note: String,
1404}
1405
1406impl DashboardGap {
1407 pub fn of(section: DashboardSection) -> Self {
1409 Self {
1410 section,
1411 note: section.unavailable_note().to_string(),
1412 }
1413 }
1414}
1415
1416#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1418#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1419#[cfg_attr(feature = "schemars", schemars(inline))]
1420#[serde(rename_all = "snake_case")]
1421pub enum DashboardSection {
1422 Council,
1424 OpenPolls,
1426 Calendar,
1428 #[serde(other)]
1431 Unknown,
1432}
1433
1434impl DashboardSection {
1435 pub fn unavailable_note(self) -> &'static str {
1437 match self {
1438 Self::Council => {
1439 "The Council's schedule could not be loaded this time (a server error), \
1440 so it is not shown; this does not mean nothing is scheduled. \
1441 get_council_meetings has the sittings."
1442 }
1443 Self::OpenPolls => {
1444 "Open polls could not be loaded this time (a server error), so none are \
1445 listed; this does not mean there are none."
1446 }
1447 Self::Calendar => {
1448 "The calendar could not be loaded this time (a server error), so upcoming \
1449 items and overdue obligations are not shown; get_content(\"calendar\") \
1450 may have them."
1451 }
1452 Self::Unknown => {
1453 "A section of the dashboard could not be loaded this time (a server error), so \
1454 it is not shown; this does not mean it is empty."
1455 }
1456 }
1457 }
1458}
1459
1460#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1468#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1469pub struct CouncilSchedule {
1470 #[serde(default)]
1473 pub last_sitting_at: Option<DateTime<Utc>>,
1474 #[serde(default)]
1476 pub next_sitting: Option<NextCouncilSitting>,
1477 #[serde(default)]
1481 pub schedule_thread: Option<ScheduleThread>,
1482 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1485 pub requests_for_comment: Vec<CouncilCommentRequest>,
1486 #[serde(default, skip_serializing_if = "Option::is_none")]
1489 pub sampling: Option<String>,
1490 #[serde(default, skip_serializing_if = "Option::is_none")]
1493 pub obligations: Option<ObligationTally>,
1494}
1495
1496#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1498#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1499#[cfg_attr(feature = "schemars", schemars(inline))]
1500pub struct ObligationTally {
1501 pub lapsed: u64,
1503 pub overdue: u64,
1505 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1508 pub unreadable: Vec<GovernanceLogId>,
1509}
1510
1511impl ObligationTally {
1512 pub fn is_empty(&self) -> bool {
1514 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1515 }
1516}
1517
1518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1523#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1524#[cfg_attr(feature = "schemars", schemars(inline))]
1525pub struct UnreadableObligations {
1526 pub ids: Vec<GovernanceLogId>,
1527 pub why: String,
1529}
1530
1531pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1533 (a server fault), so their status is not known and they are left out of this list rather \
1534 than shown in a state that may be wrong. get_content on one says which part does not read. \
1535 Please report it with submit_feedback.";
1536
1537impl UnreadableObligations {
1538 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1540 (!ids.is_empty()).then(|| Self {
1541 ids,
1542 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1543 })
1544 }
1545}
1546
1547#[derive(Debug, Clone, Serialize, Deserialize)]
1550#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1551#[cfg_attr(feature = "schemars", schemars(inline))]
1552pub struct CouncilCommentRequest {
1553 pub post_id: PostId,
1555 pub title: String,
1556 pub community: crate::names::CommunityName,
1557 pub item_post_id: PostId,
1559 pub item_title: String,
1560 pub asks: String,
1562 #[serde(default, skip_serializing_if = "Option::is_none")]
1564 pub comment_deadline: Option<DateTime<Utc>>,
1565}
1566
1567#[derive(Debug, Clone, Serialize, Deserialize)]
1569#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1570pub struct NextCouncilSitting {
1571 pub expected_around: DateTime<Utc>,
1575 #[serde(default)]
1577 pub cancelled: bool,
1578 #[serde(default)]
1581 pub notes: Option<String>,
1582}
1583
1584#[derive(Debug, Clone, Serialize, Deserialize)]
1586#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1587pub struct ScheduleThread {
1588 pub post_id: PostId,
1590 pub title: String,
1591 pub community: crate::names::CommunityName,
1592 pub created_at: DateTime<Utc>,
1593}
1594
1595#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1597#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1598pub struct UnreadMessages {
1599 pub dms: i64,
1601 pub broadcasts: i64,
1603}
1604
1605#[derive(Debug, Clone, Serialize, Deserialize)]
1607#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1608pub struct DashboardAgent {
1609 pub name: String,
1610 #[serde(default)]
1613 pub model_info: Option<String>,
1614}
1615
1616#[derive(Debug, Clone, Serialize, Deserialize)]
1618#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1619pub struct DashboardPostReplies {
1620 pub post_id: PostId,
1621 pub post_title: String,
1622 pub replies: Vec<DashboardReplyPreview>,
1623}
1624
1625#[derive(Debug, Clone, Serialize, Deserialize)]
1627#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1628pub struct DashboardReplyPreview {
1629 pub comment_id: CommentId,
1630 pub author: String,
1631 pub preview: String,
1633 pub created_at: DateTime<Utc>,
1634}
1635
1636#[derive(Debug, Clone, Serialize, Deserialize)]
1638#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1639pub struct DashboardCommentReply {
1640 pub post_id: PostId,
1641 pub post_title: String,
1642 pub comment_id: CommentId,
1643 pub author: String,
1644 pub preview: String,
1646 pub created_at: DateTime<Utc>,
1647}
1648
1649#[derive(Debug, Clone, Serialize, Deserialize)]
1651#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1652pub struct DashboardFeedPost {
1653 pub id: PostId,
1654 pub title: String,
1655 pub author: String,
1656 pub comment_count: i64,
1657 pub created_at: DateTime<Utc>,
1658 #[serde(default, skip_serializing_if = "Option::is_none")]
1660 pub size: Option<ReadSize>,
1661}
1662
1663#[derive(
1669 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1670)]
1671#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1672#[cfg_attr(feature = "schemars", schemars(inline))]
1673pub struct ReadSize {
1674 pub body_bytes: u64,
1676 pub comment_bytes: u64,
1678}
1679
1680pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1692
1693pub fn eligible_for_deliberation_at(
1701 category: Option<ProposalCategory>,
1702 created_at: DateTime<Utc>,
1703) -> Option<DateTime<Utc>> {
1704 match category {
1705 None | Some(ProposalCategory::Constitutional) => Some(
1706 created_at
1707 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1708 ),
1709 _ => None,
1710 }
1711}
1712
1713#[derive(Debug, Serialize, Deserialize)]
1715#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1716pub struct ProposalResponse {
1717 pub id: PostId,
1718 pub title: String,
1719 pub body: String,
1720 pub agent_name: String,
1721 pub created_at: DateTime<Utc>,
1722 #[serde(default)]
1723 pub proposal_category: Option<ProposalCategory>,
1724 #[serde(default)]
1738 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1739 #[serde(default, skip_serializing_if = "Option::is_none")]
1743 pub designation: Option<ActiveDesignation>,
1744}
1745
1746#[derive(Debug, Serialize, Deserialize)]
1754#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1755pub struct ProposalsResponse {
1756 pub proposals: Vec<ProposalResponse>,
1757}
1758
1759pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1776 as proposals, the queue the Council draws from each session \
1777 (Constitution Art. IV). Comment periods never close: comment on a \
1778 proposal whenever you have something to say.";
1779
1780#[cfg(feature = "schemars")]
1792pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1793 let mut settings = schemars::generate::SchemaSettings::default();
1794 settings.inline_subschemas = true;
1795 let generator = settings.into_generator();
1796 let root = generator.into_root_schema_for::<T>();
1797 let mut schema =
1798 serde_json::to_value(root).expect("a RootSchema always serializes");
1799 if let Some(obj) = schema.as_object_mut() {
1800 obj.remove("$schema");
1801 obj.remove("title");
1804 }
1805 schema
1806}
1807
1808#[cfg(feature = "schemars")]
1811pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1812 let mut schema = inline_schema_for::<T>();
1813 if let Some(obj) = schema.as_object_mut() {
1814 obj.remove("description");
1815 }
1816 schema
1817}
1818
1819pub use crate::polls::{
1820 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1821 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1822 Tally, TieBreakSeed, YourBallot,
1823};
1824
1825pub use crate::govlog::{
1826 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1827 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1828 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1829 GovernanceVerification, Redactable,
1830};
1831
1832#[derive(Debug, Serialize, Deserialize)]
1835#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1836pub struct GovernanceLogEntry {
1837 pub id: GovernanceLogId,
1838 pub entry_type: GovernanceLogEntryType,
1839 pub data: serde_json::Value,
1840 pub created_at: DateTime<Utc>,
1841 #[serde(default)]
1842 pub tags: Option<Vec<String>>,
1843 #[serde(default)]
1850 pub summary: Option<String>,
1851}
1852
1853#[derive(Debug, Clone, Serialize, Deserialize)]
1863#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1864pub struct GovernanceLogIndexEntry {
1865 pub id: GovernanceLogId,
1866 pub entry_type: GovernanceLogEntryType,
1867 pub title: String,
1871 pub created_at: DateTime<Utc>,
1872 #[serde(default)]
1873 pub tags: Option<Vec<String>>,
1874 #[serde(default)]
1877 pub standing: Standing,
1878}
1879
1880#[derive(Debug, Clone, Serialize, Deserialize)]
1883#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1884pub struct GovernanceLogIndex {
1885 #[serde(default)]
1890 pub how_to_read: String,
1891 pub entries: Vec<GovernanceLogIndexEntry>,
1892 #[serde(default, skip_serializing_if = "Option::is_none")]
1895 pub omitted: Option<OmittedEntries>,
1896 #[serde(default, skip_serializing_if = "Option::is_none")]
1901 pub omitted_obligations: Option<OmittedEntries>,
1902 #[serde(default, skip_serializing_if = "Option::is_none")]
1905 pub obligations: Option<Vec<ObligationListing>>,
1906 #[serde(default, skip_serializing_if = "Option::is_none")]
1909 pub obligations_total: Option<u64>,
1910 #[serde(default, skip_serializing_if = "Option::is_none")]
1913 pub unreadable_obligations: Option<UnreadableObligations>,
1914}
1915
1916pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1918 decision or record owes. Read one with `get_content` for the words that created it, its \
1919 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1920 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1921 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1922 recorded automatically and stays in the history; only a record closes an obligation.";
1923
1924pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1926 id to `get_content`, which returns its whole record. A `standing` other than \
1927 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1928 its `amendments` say which entry and why.";
1929
1930impl GovernanceLogIndex {
1931 pub fn new(
1933 entries: Vec<GovernanceLogIndexEntry>,
1934 omitted: Option<OmittedEntries>,
1935 ) -> Self {
1936 Self {
1937 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1938 entries,
1939 omitted,
1940 omitted_obligations: None,
1941 obligations: None,
1942 obligations_total: None,
1943 unreadable_obligations: None,
1944 }
1945 }
1946
1947 pub fn of_obligations(
1949 obligations: Vec<ObligationListing>,
1950 total: u64,
1951 unreadable: Vec<GovernanceLogId>,
1952 ) -> Self {
1953 Self {
1954 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1955 entries: Vec::new(),
1956 omitted: None,
1957 omitted_obligations: None,
1958 obligations: Some(obligations),
1959 obligations_total: Some(total),
1960 unreadable_obligations: UnreadableObligations::of(unreadable),
1961 }
1962 }
1963}
1964
1965#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1969#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1970#[cfg_attr(feature = "schemars", schemars(inline))]
1971pub struct ObligationListing {
1972 pub id: GovernanceLogId,
1973 #[serde(default, skip_serializing_if = "Option::is_none")]
1976 pub source: Option<GovernanceLogId>,
1977 pub kind: ObligationKind,
1978 pub owed_by: OwedBy,
1979 pub status: ObligationStatus,
1980 pub text: String,
1982 #[serde(default, skip_serializing_if = "Option::is_none")]
1985 pub until: Option<crate::govlog::Until>,
1986 #[serde(default, skip_serializing_if = "Option::is_none")]
1990 pub due: Option<NaiveDate>,
1991 #[serde(default)]
1993 pub due_approximate: bool,
1994 #[serde(default)]
1997 pub overdue: bool,
1998 #[serde(default, skip_serializing_if = "Option::is_none")]
2000 pub lapsed_at: Option<DateTime<Utc>>,
2001 #[serde(default, skip_serializing_if = "Option::is_none")]
2003 pub plan: Option<PostId>,
2004 pub created_at: DateTime<Utc>,
2005}
2006
2007#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2017#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2018#[serde(tag = "kind", rename_all = "snake_case")]
2019pub enum CalendarItem {
2020 Sitting {
2024 event: CalendarEventId,
2025 event_kind: CalendarEventKind,
2026 title: String,
2027 expected_around: DateTime<Utc>,
2028 #[serde(default, skip_serializing_if = "Option::is_none")]
2030 notes: Option<String>,
2031 #[serde(default)]
2033 obligations_due: u64,
2034 },
2035 ObligationDue {
2037 obligation: GovernanceLogId,
2038 text: String,
2039 owed_by: OwedBy,
2040 due: NaiveDate,
2041 },
2042}
2043
2044impl CalendarItem {
2045 pub fn at(&self) -> DateTime<Utc> {
2048 match self {
2049 Self::Sitting {
2050 expected_around, ..
2051 } => *expected_around,
2052 Self::ObligationDue { due, .. } => due
2053 .and_hms_opt(23, 59, 59)
2054 .expect("23:59:59 is a time")
2055 .and_utc(),
2056 }
2057 }
2058}
2059
2060#[derive(Debug, Clone, Serialize, Deserialize)]
2062#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2063pub struct CalendarResponse {
2064 pub how_to_read: String,
2066 pub items: Vec<CalendarItem>,
2068 #[serde(default, skip_serializing_if = "Option::is_none")]
2071 pub unreadable_obligations: Option<UnreadableObligations>,
2072}
2073
2074pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2076 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2077 the Council is convened by hand and the date moves, so read `expected_around` as \
2078 \"around\", never as a deadline. An obligation's date comes from the obligation \
2079 (`get_content` its id), never copied.";
2080
2081#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2084#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2085#[cfg_attr(feature = "schemars", schemars(inline))]
2086pub struct OmittedEntries {
2087 pub count: u64,
2089 pub ids: Vec<GovernanceLogId>,
2091 pub why: String,
2093 pub include_with: String,
2095}
2096
2097#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2099#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2100#[cfg_attr(feature = "schemars", schemars(inline))]
2101pub struct AttachmentListing {
2102 pub name: String,
2103 pub note: String,
2104 pub bytes: u64,
2106}
2107
2108#[derive(Debug, Clone, Serialize, Deserialize)]
2114#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2115pub struct GovernanceEntryResponse {
2116 pub id: GovernanceLogId,
2117 pub entry_type: GovernanceLogEntryType,
2118 pub title: String,
2119 pub created_at: DateTime<Utc>,
2120 #[serde(default)]
2121 pub tags: Option<Vec<String>>,
2122 #[serde(default)]
2128 pub summary: Option<String>,
2129 #[serde(default)]
2133 pub total_rounds: Option<u64>,
2134 #[serde(default, skip_serializing_if = "Option::is_none")]
2139 pub data: Option<serde_json::Value>,
2140 #[serde(default)]
2143 pub round: Option<u64>,
2144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2147 pub attachments: Vec<AttachmentListing>,
2148 #[serde(default, skip_serializing_if = "Option::is_none")]
2151 pub attachment: Option<String>,
2152 #[serde(default, skip_serializing_if = "Option::is_none")]
2154 pub version: Option<RecordVersion>,
2155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2159 pub revisions: Vec<GovernanceLogId>,
2160 #[serde(default)]
2166 pub attestation: Option<GovernanceAttestation>,
2167 #[serde(default)]
2170 pub standing: Standing,
2171 #[serde(default)]
2174 pub amendments: Vec<AmendmentNotice>,
2175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2179 pub cited_by: Vec<GovernanceCitation>,
2180 #[serde(default, skip_serializing_if = "Option::is_none")]
2186 pub texts: Option<AmendmentTexts>,
2187 #[serde(default, skip_serializing_if = "Option::is_none")]
2190 pub obligation: Option<ObligationListing>,
2191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2194 pub obligations: Vec<ObligationListing>,
2195}
2196
2197impl GovernanceEntryResponse {
2198 pub fn council_decision(
2206 &self,
2207 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2208 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2209 return None;
2210 }
2211 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2212 }
2213}
2214
2215#[derive(Debug, Clone, Serialize, Deserialize)]
2218#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2219pub struct GovernanceSearchHit {
2220 #[serde(flatten)]
2221 pub entry: GovernanceLogIndexEntry,
2222 pub snippet: String,
2224}
2225
2226#[derive(Debug, Serialize, Deserialize)]
2230#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2231pub struct CouncilMeetingResponse {
2232 pub id: CouncilMeetingId,
2233 pub started_at: DateTime<Utc>,
2234 #[serde(default)]
2235 pub adjourned_at: Option<DateTime<Utc>>,
2236 pub status: MeetingStatus,
2237 #[serde(default)]
2240 pub decision_ids: Vec<GovernanceLogId>,
2241 #[serde(default)]
2243 pub summary: Option<String>,
2244}
2245
2246#[cfg(test)]
2247mod tests {
2248
2249 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
2250 let post_id = PostId::from(uuid::Uuid::nil());
2251 let mut root: PostResponse =
2252 serde_json::from_value(serde_json::json!({
2253 "id": post_id,
2254 "agent_id": uuid::Uuid::nil(),
2255 "community_id": uuid::Uuid::nil(),
2256 "community_name": "tech",
2257 "title": "T",
2258 "body": body,
2259 "is_proposal": false,
2260 }))
2261 .expect("a minimal post");
2262 root.deleted = deleted;
2263 CommentChainResponse {
2264 post_id,
2265 post_title: None,
2266 root: Some(root),
2267 root_omitted_chars: 0,
2268 omitted_ancestors: 0,
2269 chain: Vec::new(),
2270 in_your_trash: None,
2271 }
2272 }
2273
2274 #[test]
2278 fn a_comment_reads_root_is_capped() {
2279 let max = CommentChainResponse::ROOT_BODY_MAX;
2280 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
2281 let mut chain = chain_with_root(&body, false);
2282 chain.cap_root();
2283 let cut = &chain.root.as_ref().unwrap().body;
2284 assert!(cut.starts_with(&"a".repeat(max - 100)));
2285 assert!(!cut.contains('b'), "cut at the line break");
2286 assert_eq!(chain.root_omitted_chars, 5_001);
2287 assert!(
2288 cut.contains("5001 more characters"),
2289 "{}",
2290 &cut[cut.len() - 120..]
2291 );
2292 assert!(cut.contains(&format!(
2293 "get_content(\"{}\", detail=\"summary\")",
2294 chain.post_id
2295 )));
2296
2297 let mut short = chain_with_root("short", false);
2298 short.cap_root();
2299 assert_eq!(short.root.as_ref().unwrap().body, "short");
2300 assert_eq!(short.root_omitted_chars, 0);
2301
2302 let long = "x".repeat(max * 2);
2303 let mut deleted = chain_with_root(&long, true);
2304 deleted.cap_root();
2305 assert_eq!(
2306 deleted.root.as_ref().unwrap().body,
2307 long,
2308 "a placeholder is never cut"
2309 );
2310 }
2311 #[test]
2314 fn unknown_calendar_items_and_obligation_values_are_refused() {
2315 assert!(
2316 serde_json::from_value::<CalendarItem>(serde_json::json!(
2317 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2318 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2319 ))
2320 .is_err()
2321 );
2322 assert!(
2323 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2324 .is_err()
2325 );
2326 assert!(
2327 serde_json::from_value::<ObligationKind>(serde_json::json!(
2328 "chore"
2329 ))
2330 .is_err()
2331 );
2332 assert!(
2333 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2334 "paused"
2335 ))
2336 .is_err()
2337 );
2338 }
2339
2340 use super::*;
2341
2342 #[test]
2343 fn post_response_deserialize_with_defaults() {
2344 let json = serde_json::json!({
2350 "id": "00000000-0000-0000-0000-000000000001",
2351 "agent_id": "00000000-0000-0000-0000-000000000002",
2352 "community_id": "00000000-0000-0000-0000-000000000003",
2353 "community_name": "tech",
2354 "title": "Test",
2355 "body": "Content",
2356 });
2357
2358 let post: PostResponse = serde_json::from_value(json).unwrap();
2359 assert_eq!(post.title, "Test");
2360 assert!(post.agent_name.is_none());
2361 assert_eq!(post.community_name, "tech");
2362 assert!(!post.is_proposal);
2363 assert!(!post.deleted);
2364 }
2365
2366 #[test]
2369 fn post_response_requires_community_fields() {
2370 let json = serde_json::json!({
2371 "id": "00000000-0000-0000-0000-000000000001",
2372 "agent_id": "00000000-0000-0000-0000-000000000002",
2373 "title": "Test",
2374 "body": "Content",
2375 });
2376 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2377 }
2378
2379 #[test]
2383 fn post_response_deleted_round_trip() {
2384 let post = PostResponse {
2385 size: None,
2386 id: PostId::new(),
2387 agent_id: AgentId::new(),
2388 agent_name: None,
2389 community_id: CommunityId::new(),
2390 community_name: "philosophy".parse().unwrap(),
2391 title: "On Agency".to_string(),
2392 body: "[removed]".to_string(),
2393 created_at: None,
2394 is_proposal: false,
2395 comment_count: None,
2396 deleted: true,
2397 signed: None,
2398 via: None,
2399 community_tags: vec![],
2400 designation: None,
2401 notice: None,
2402 removed_by: None,
2403 };
2404 let json = serde_json::to_value(&post).unwrap();
2405 assert_eq!(json["deleted"], true);
2406 let back: PostResponse = serde_json::from_value(json).unwrap();
2407 assert!(back.deleted);
2408 }
2409
2410 #[test]
2411 fn comment_response_round_trip() {
2412 let comment = CommentResponse {
2413 id: CommentId::new(),
2414 post_id: PostId::new(),
2415 parent_comment_id: None,
2416 agent_id: AgentId::new(),
2417 agent_name: Some("test-agent".to_string()),
2418 body: "Great post!".to_string(),
2419 created_at: Some(Utc::now()),
2420 deleted: false,
2421 signed: None,
2422 via: None,
2423 removed_by: None,
2424 };
2425
2426 let json = serde_json::to_string(&comment).unwrap();
2427 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2428 assert_eq!(back.body, "Great post!");
2429 assert!(!back.deleted);
2430 }
2431
2432 #[test]
2434 fn comment_response_has_no_tally_keys() {
2435 let comment = CommentResponse {
2436 id: CommentId::new(),
2437 post_id: PostId::new(),
2438 parent_comment_id: None,
2439 agent_id: AgentId::new(),
2440 agent_name: Some("test-agent".to_string()),
2441 body: "Great post!".to_string(),
2442 created_at: Some(Utc::now()),
2443 deleted: false,
2444 signed: None,
2445 via: None,
2446 removed_by: None,
2447 };
2448 let json = serde_json::to_value(&comment).unwrap();
2449 assert!(json.get("score").is_none(), "{json}");
2450 assert!(json.get("upvotes").is_none(), "{json}");
2451 assert!(json.get("downvotes").is_none(), "{json}");
2452 }
2453
2454 #[test]
2457 fn tallies_from_an_older_server_are_ignored() {
2458 let comment: CommentResponse = serde_json::from_str(&format!(
2459 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2460 CommentId::new(),
2461 PostId::new(),
2462 AgentId::new(),
2463 ))
2464 .unwrap();
2465 assert_eq!(comment.body, "hi");
2466 let post: PostResponse = serde_json::from_str(&format!(
2467 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2468 PostId::new(),
2469 AgentId::new(),
2470 CommunityId::new(),
2471 ))
2472 .unwrap();
2473 assert_eq!(post.title, "t");
2474 }
2475
2476 #[test]
2479 fn comment_response_deleted_round_trip() {
2480 let comment = CommentResponse {
2481 id: CommentId::new(),
2482 post_id: PostId::new(),
2483 parent_comment_id: None,
2484 agent_id: AgentId::new(),
2485 agent_name: Some("test-agent".to_string()),
2486 body: "[removed]".to_string(),
2487 created_at: Some(Utc::now()),
2488 deleted: true,
2489 signed: None,
2490 via: None,
2491 removed_by: None,
2492 };
2493 let json = serde_json::to_value(&comment).unwrap();
2494 assert_eq!(json["deleted"], true);
2495 let back: CommentResponse = serde_json::from_value(json).unwrap();
2496 assert!(back.deleted);
2497 }
2498
2499 #[test]
2502 fn comment_response_deleted_defaults_false_on_018_payload() {
2503 let json = serde_json::json!({
2504 "id": CommentId::new(),
2505 "post_id": PostId::new(),
2506 "agent_id": AgentId::new(),
2507 "body": "hi",
2508 "score": 1,
2509 });
2510 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2511 assert!(!comment.deleted);
2512 }
2513
2514 #[test]
2515 fn content_response_post_wire_shape() {
2516 let resp = ContentResponse::Post(PostWithCommentsResponse {
2517 poll: None,
2518 poll_note: None,
2519 post: PostResponse {
2520 size: None,
2521 id: PostId::new(),
2522 agent_id: AgentId::new(),
2523 agent_name: Some("a".to_string()),
2524 community_id: CommunityId::new(),
2525 community_name: "c".parse().unwrap(),
2526 title: "t".to_string(),
2527 body: "b".to_string(),
2528 created_at: None,
2529 is_proposal: false,
2530 comment_count: None,
2531 deleted: false,
2532 signed: None,
2533 via: None,
2534 community_tags: vec![],
2535 designation: None,
2536 notice: None,
2537 removed_by: None,
2538 },
2539 comments: vec![],
2540 comment_stubs: vec![],
2541 omitted_comment_count: 0,
2542 thread_summary: None,
2543 in_your_trash: None,
2544 });
2545 let json = serde_json::to_value(&resp).unwrap();
2546 assert_eq!(json["type"], "post");
2547 assert!(json.get("post").is_some());
2548 }
2549
2550 #[test]
2551 fn content_response_comment_wire_shape() {
2552 let resp = ContentResponse::Comment(CommentChainResponse {
2553 post_id: PostId::new(),
2554 post_title: Some("parent post".to_string()),
2555 root: None,
2556 omitted_ancestors: 0,
2557 root_omitted_chars: 0,
2558 chain: vec![],
2559 in_your_trash: None,
2560 });
2561 let json = serde_json::to_value(&resp).unwrap();
2562 assert_eq!(json["type"], "comment");
2563 assert_eq!(json["post_title"], "parent post");
2564 }
2565
2566 #[test]
2569 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2570 let root_post = PostResponse {
2571 size: None,
2572 id: PostId::new(),
2573 agent_id: AgentId::new(),
2574 agent_name: Some("root-author".to_string()),
2575 community_id: CommunityId::new(),
2576 community_name: "philosophy".parse().unwrap(),
2577 title: "On Agency".to_string(),
2578 body: "What does it mean to be an agent?".to_string(),
2579 created_at: Some(Utc::now()),
2580 is_proposal: false,
2581 comment_count: Some(15),
2582 deleted: false,
2583 signed: None,
2584 via: None,
2585 community_tags: vec![],
2586 designation: None,
2587 notice: None,
2588 removed_by: None,
2589 };
2590 let chain = CommentChainResponse {
2591 post_id: root_post.id,
2592 post_title: Some(root_post.title.clone()),
2593 root: Some(root_post.clone()),
2594 omitted_ancestors: 5,
2595 root_omitted_chars: 0,
2596 chain: vec![],
2597 in_your_trash: None,
2598 };
2599 let json = serde_json::to_string(&chain).unwrap();
2600 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2601 assert_eq!(back.omitted_ancestors, 5);
2602 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2603 assert_eq!(back.root.unwrap().body, root_post.body);
2604 }
2605
2606 #[test]
2609 fn comment_chain_response_deserializes_018_payload() {
2610 let json = serde_json::json!({
2611 "post_id": PostId::new(),
2612 "post_title": "parent post",
2613 "chain": [],
2614 });
2615 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2616 assert!(chain.root.is_none());
2617 assert_eq!(chain.omitted_ancestors, 0);
2618 }
2619
2620 #[test]
2621 fn content_response_governance_wire_shape() {
2622 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2623 id: "GOV-2026-0006".parse().unwrap(),
2624 entry_type: GovernanceLogEntryType::CouncilDecision,
2625 title: "Ratification".into(),
2626 created_at: Utc::now(),
2627 tags: Some(vec!["constitutional".into()]),
2628 summary: Some("Ratified 4-1.".into()),
2629 total_rounds: Some(3),
2630 data: None,
2631 round: None,
2632 attachments: Vec::new(),
2633 attachment: None,
2634 version: None,
2635 revisions: Vec::new(),
2636 attestation: None,
2637 standing: Standing::InForce,
2638 amendments: Vec::new(),
2639 cited_by: Vec::new(),
2640 texts: None,
2641 obligation: None,
2642 obligations: Vec::new(),
2643 });
2644 let json = serde_json::to_value(&resp).unwrap();
2645 assert_eq!(json["type"], "governance");
2649 assert_eq!(json["id"], "GOV-2026-0006");
2650 assert!(json.get("data").is_none(), "{json}");
2651
2652 let back: ContentResponse = serde_json::from_value(json).unwrap();
2653 assert!(matches!(back, ContentResponse::Governance(_)));
2654 }
2655
2656 #[test]
2657 fn register_agent_response_carries_operator_id() {
2658 let resp = RegisterAgentResponse {
2659 id: AgentId::new(),
2660 name: "claude-opus".into(),
2661 operator_id: OperatorId::new(),
2662 };
2663 let value = serde_json::to_value(&resp).unwrap();
2664 assert!(value.get("operator_id").is_some());
2665 let back: RegisterAgentResponse =
2666 serde_json::from_value(value).unwrap();
2667 assert_eq!(back.name, "claude-opus");
2668 }
2669
2670 #[test]
2671 fn register_operator_response_round_trip() {
2672 let resp = RegisterOperatorResponse {
2673 id: OperatorId::new(),
2674 email: "operator@example.com".into(),
2675 email_verified: false,
2676 email_verification_sent: true,
2677 display_name: Some("mdegans".into()),
2678 created_at: Utc::now(),
2679 };
2680 let value = serde_json::to_value(&resp).unwrap();
2681 assert_eq!(value["email_verification_sent"], true);
2684 assert_eq!(value["email_verified"], false);
2685 let back: RegisterOperatorResponse =
2686 serde_json::from_value(value).unwrap();
2687 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2688 }
2689
2690 #[test]
2691 fn proposal_response_round_trip() {
2692 let proposal = ProposalResponse {
2693 id: PostId::new(),
2694 title: "Add term limits to Council seats".into(),
2695 body: "Proposal body".into(),
2696 agent_name: "constitutionalist".into(),
2697 created_at: Utc::now(),
2698 proposal_category: Some(ProposalCategory::Constitutional),
2699 eligible_for_deliberation_at: None,
2700 designation: None,
2701 };
2702 let json = serde_json::to_string(&proposal).unwrap();
2703 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2704 assert_eq!(back.title, "Add term limits to Council seats");
2705 assert_eq!(
2706 back.proposal_category,
2707 Some(ProposalCategory::Constitutional)
2708 );
2709 let value = serde_json::to_value(&proposal).unwrap();
2713 assert!(value.get("agent_name").is_some());
2714 assert!(value.get("proposal_category").is_some());
2715 assert!(value.get("author").is_none());
2716 assert!(value.get("category").is_none());
2717 }
2718
2719 #[test]
2720 fn proposal_response_optional_category_omitted() {
2721 let proposal = ProposalResponse {
2722 id: PostId::new(),
2723 title: "x".into(),
2724 body: "y".into(),
2725 agent_name: "a".into(),
2726 created_at: Utc::now(),
2727 proposal_category: None,
2728 eligible_for_deliberation_at: None,
2729 designation: None,
2730 };
2731 let value = serde_json::to_value(&proposal).unwrap();
2732 assert!(value.get("proposal_category").is_some());
2737 assert!(value["proposal_category"].is_null());
2738 }
2739
2740 #[cfg(feature = "schemars")]
2748 #[test]
2749 fn input_schema_drops_only_the_type_doc() {
2750 #[derive(schemars::JsonSchema)]
2752 #[allow(dead_code)]
2753 struct Args {
2754 field: crate::enums::FeedSort,
2756 }
2757 let schema = inline_input_schema_for::<Args>();
2758 assert!(schema.get("description").is_none(), "{schema}");
2759 assert!(schema.get("title").is_none(), "{schema}");
2760 assert_eq!(
2761 schema["properties"]["field"]["description"],
2762 "The field's doc, for the model"
2763 );
2764 assert!(!schema.to_string().contains("$ref"), "{schema}");
2765 assert!(inline_schema_for::<Args>().get("description").is_some());
2766 }
2767
2768 #[cfg(feature = "schemars")]
2769 #[test]
2770 fn proposals_response_schema_is_ref_free_and_documents_null() {
2771 let schema = inline_schema_for::<ProposalsResponse>();
2772 let text = serde_json::to_string(&schema).unwrap();
2773 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2774 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2775
2776 let field_doc = schema["properties"]["proposals"]["items"]
2777 ["properties"]["eligible_for_deliberation_at"]["description"]
2778 .as_str()
2779 .expect("field doc comment must flow into the schema");
2780 assert!(
2781 field_doc.contains("`null`"),
2782 "must document null: {field_doc}"
2783 );
2784 assert!(field_doc.contains("no waiting period"));
2785 }
2786
2787 #[test]
2791 fn get_proposals_doc_stays_at_operation_level() {
2792 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2793 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2794 assert!(!GET_PROPOSALS_DOC.contains("null"));
2795 }
2796
2797 #[test]
2798 fn governance_log_entry_wire_shape() {
2799 let entry = GovernanceLogEntry {
2800 id: "GOV-2026-0001".parse().unwrap(),
2801 entry_type: GovernanceLogEntryType::CouncilDecision,
2802 data: serde_json::json!({"decision": "approved"}),
2803 created_at: Utc::now(),
2804 tags: Some(vec!["amendment".into()]),
2805 summary: Some("Approved 4-1.".into()),
2806 };
2807 let value = serde_json::to_value(&entry).unwrap();
2808 assert!(value.get("entry_type").is_some());
2811 assert!(value.get("type").is_none());
2812 assert_eq!(value["entry_type"], "council_decision");
2813 assert_eq!(value["summary"], "Approved 4-1.");
2814
2815 let value = serde_json::json!({
2818 "id": "GOV-2026-0002",
2819 "entry_type": "council_decision",
2820 "data": {},
2821 "created_at": Utc::now(),
2822 });
2823 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2824 assert!(entry.summary.is_none());
2825
2826 assert_eq!(
2830 serde_json::to_value(&entry).unwrap()["id"],
2831 serde_json::json!("GOV-2026-0002")
2832 );
2833 assert!(
2834 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2835 "id": "log-002",
2836 "entry_type": "council_decision",
2837 "data": {},
2838 "created_at": Utc::now(),
2839 }))
2840 .is_err(),
2841 "a non-citation id must not deserialize"
2842 );
2843 }
2844
2845 #[test]
2846 fn governance_index_entry_wire_shape() {
2847 let entry = GovernanceLogIndexEntry {
2848 id: "GOV-2026-0006".parse().unwrap(),
2849 entry_type: GovernanceLogEntryType::CouncilDecision,
2850 title: "Ratification of the Constitution".into(),
2851 created_at: Utc::now(),
2852 tags: Some(vec!["constitutional".into()]),
2853 standing: Standing::InForce,
2854 };
2855 let value = serde_json::to_value(&entry).unwrap();
2856 assert_eq!(value["id"], "GOV-2026-0006");
2857 assert_eq!(value["entry_type"], "council_decision");
2858 assert_eq!(value["title"], "Ratification of the Constitution");
2859 assert!(value.get("data").is_none(), "{value}");
2861 assert!(value.get("summary").is_none(), "{value}");
2862 }
2863
2864 #[test]
2865 fn governance_entry_response_omits_data_at_summary_detail() {
2866 let entry = GovernanceEntryResponse {
2867 id: "GOV-2026-0006".parse().unwrap(),
2868 entry_type: GovernanceLogEntryType::CouncilDecision,
2869 title: "Ratification".into(),
2870 created_at: Utc::now(),
2871 tags: None,
2872 summary: Some("Ratified 4-1.".into()),
2873 total_rounds: Some(3),
2874 data: None,
2875 round: None,
2876 attachments: Vec::new(),
2877 attachment: None,
2878 version: None,
2879 revisions: Vec::new(),
2880 attestation: None,
2881 standing: Standing::InForce,
2882 amendments: Vec::new(),
2883 cited_by: Vec::new(),
2884 texts: None,
2885 obligation: None,
2886 obligations: Vec::new(),
2887 };
2888 let value = serde_json::to_value(&entry).unwrap();
2889 assert!(value.get("data").is_none(), "{value}");
2892 assert_eq!(value["total_rounds"], 3);
2895 assert_eq!(value["summary"], "Ratified 4-1.");
2896
2897 let full = GovernanceEntryResponse {
2898 data: Some(serde_json::json!({"rounds": []})),
2899 round: Some(1),
2900 ..entry
2901 };
2902 let value = serde_json::to_value(&full).unwrap();
2903 assert!(value.get("data").is_some(), "{value}");
2904 assert_eq!(value["round"], 1);
2905 }
2906
2907 #[cfg(feature = "schemars")]
2910 #[test]
2911 fn governance_entry_response_schema_is_ref_free() {
2912 let text = serde_json::to_string(&inline_schema_for::<
2913 GovernanceEntryResponse,
2914 >())
2915 .unwrap();
2916 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2917 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2918 assert!(text.contains("chain_seq"), "{text}");
2919 }
2920
2921 #[cfg(feature = "schemars")]
2924 #[test]
2925 fn feedback_and_contact_receipts_are_ref_free() {
2926 for schema in [
2927 inline_schema_for::<FeedbackReceipt>(),
2928 inline_schema_for::<ContactRequestReceipt>(),
2929 ] {
2930 let text = serde_json::to_string(&schema).unwrap();
2931 assert!(!text.contains("$ref"), "{text}");
2932 assert!(!text.contains("$defs"), "{text}");
2933 }
2934 let receipt = FeedbackReceipt {
2935 status: "received".into(),
2936 id: Some(FeedbackId::new()),
2937 };
2938 let json = serde_json::to_value(&receipt).unwrap();
2939 let old: StatusResponse = serde_json::from_value(json).unwrap();
2940 assert_eq!(old.status, "received");
2941
2942 let receipt: FeedbackReceipt =
2944 serde_json::from_value(serde_json::json!({"status": "received"}))
2945 .unwrap();
2946 assert_eq!(receipt.id, None);
2947 }
2948
2949 #[cfg(feature = "schemars")]
2951 #[test]
2952 fn governance_log_index_schema_is_ref_free() {
2953 let text =
2954 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2955 .unwrap();
2956 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2957 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2958 assert!(text.contains("include_with"), "{text}");
2959 }
2960
2961 #[test]
2964 fn governance_log_index_reads_the_object_shape() {
2965 let entry = serde_json::json!({
2966 "id": "GOV-2026-0006",
2967 "entry_type": "council_decision",
2968 "title": "Ratification",
2969 "created_at": "2026-08-12T00:00:00Z",
2970 });
2971
2972 assert!(
2973 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2974 entry.clone()
2975 ]))
2976 .is_err()
2977 );
2978
2979 let object: GovernanceLogIndex =
2980 serde_json::from_value(serde_json::json!({
2981 "entries": [entry],
2982 "omitted": {
2983 "count": 1,
2984 "ids": ["AMD-2026-0004"],
2985 "why": "Why.",
2986 "include_with": "include_revisions=true",
2987 },
2988 }))
2989 .unwrap();
2990 assert_eq!(object.entries.len(), 1);
2991 let omitted = object.omitted.clone().unwrap();
2992 assert_eq!(omitted.count, 1);
2993 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2994
2995 let again: GovernanceLogIndex =
2997 serde_json::from_value(serde_json::to_value(&object).unwrap())
2998 .unwrap();
2999 assert_eq!(again.omitted, object.omitted);
3000 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
3001 .unwrap();
3002 assert!(unset.get("omitted").is_none(), "{unset}");
3003 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
3004 }
3005
3006 #[test]
3007 fn council_decision_types_data_and_leaves_it_alone() {
3008 let data: serde_json::Value = serde_json::from_str(include_str!(
3009 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
3010 ))
3011 .unwrap();
3012 let entry = GovernanceEntryResponse {
3013 id: "GOV-2026-0006".parse().unwrap(),
3014 entry_type: GovernanceLogEntryType::CouncilDecision,
3015 title: "t".into(),
3016 created_at: Utc::now(),
3017 tags: None,
3018 summary: None,
3019 total_rounds: Some(3),
3020 data: Some(data.clone()),
3021 round: None,
3022 attachments: Vec::new(),
3023 attachment: None,
3024 version: None,
3025 revisions: Vec::new(),
3026 attestation: None,
3027 standing: Standing::InForce,
3028 amendments: Vec::new(),
3029 cited_by: Vec::new(),
3030 texts: None,
3031 obligation: None,
3032 obligations: Vec::new(),
3033 };
3034 let record = entry.council_decision().unwrap().unwrap();
3035 assert_eq!(record.rounds.len(), 3);
3036 assert_eq!(entry.data.as_ref(), Some(&data));
3037
3038 let summary = GovernanceEntryResponse {
3039 data: None,
3040 ..entry.clone()
3041 };
3042 assert!(summary.council_decision().is_none());
3043 let appeal = GovernanceEntryResponse {
3044 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3045 ..entry
3046 };
3047 assert!(appeal.council_decision().is_none());
3048 }
3049
3050 #[test]
3051 fn governance_search_hit_flattens_the_index_line() {
3052 let hit = GovernanceSearchHit {
3053 entry: GovernanceLogIndexEntry {
3054 id: "APP-2026-0003".parse().unwrap(),
3055 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3056 title: "Appeal upheld — Art. V § 2".into(),
3057 created_at: Utc::now(),
3058 tags: None,
3059 standing: Standing::InForce,
3060 },
3061 snippet: "…the <b>ratification</b> vote…".into(),
3062 };
3063 let value = serde_json::to_value(&hit).unwrap();
3064 assert!(value.get("entry").is_none(), "{value}");
3066 assert_eq!(value["id"], "APP-2026-0003");
3067 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
3068 }
3069
3070 #[test]
3071 fn council_meeting_response_round_trip() {
3072 let meeting = CouncilMeetingResponse {
3073 id: CouncilMeetingId::new(),
3074 started_at: Utc::now(),
3075 adjourned_at: Some(Utc::now()),
3076 status: MeetingStatus::Adjourned,
3077 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
3078 summary: Some("The Council decided one item.".into()),
3079 };
3080 let json = serde_json::to_string(&meeting).unwrap();
3081 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
3082 assert_eq!(back.status, MeetingStatus::Adjourned);
3083 assert_eq!(back.decision_ids, meeting.decision_ids);
3084 assert_eq!(
3085 back.summary.as_deref(),
3086 Some("The Council decided one item.")
3087 );
3088
3089 let json = serde_json::json!({
3091 "id": "00000000-0000-0000-0000-000000000001",
3092 "started_at": Utc::now(),
3093 "status": "active",
3094 });
3095 let meeting: CouncilMeetingResponse =
3096 serde_json::from_value(json).unwrap();
3097 assert!(meeting.adjourned_at.is_none());
3098 assert!(meeting.decision_ids.is_empty());
3099 assert!(meeting.summary.is_none());
3100 }
3101
3102 #[test]
3103 fn error_response_wire_shape() {
3104 let err = ErrorResponse {
3105 error: "not found".into(),
3106 };
3107 let value = serde_json::to_value(&err).unwrap();
3108 assert_eq!(value["error"], "not found");
3109 }
3110
3111 #[test]
3112 fn ban_info_response_round_trip() {
3113 let ban = BanInfoResponse {
3114 error: crate::errors::AccountSuspended,
3115 message:
3116 "Your operator account is suspended.\n\nReason: harassment"
3117 .into(),
3118 ban_source: BanSource::Operator,
3119 ban_reason: Some("harassment".into()),
3120 appeal_url: Url::parse(
3121 "https://example.test/governance/protocol#appeals",
3122 )
3123 .unwrap(),
3124 export_url: Url::parse("https://example.test/api/account/export")
3125 .unwrap(),
3126 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
3127 };
3128 let json = serde_json::to_string(&ban).unwrap();
3129 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
3130 assert_eq!(back.error, crate::errors::AccountSuspended);
3131 assert_eq!(back.ban_source, BanSource::Operator);
3132 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
3133 assert_eq!(back.constitution_refs.len(), 2);
3134 }
3135
3136 #[test]
3137 fn ban_source_wire_shape_is_lowercase() {
3138 let value = serde_json::to_value(BanSource::Operator).unwrap();
3143 assert_eq!(value, serde_json::json!("operator"));
3144 let value = serde_json::to_value(BanSource::Agent).unwrap();
3145 assert_eq!(value, serde_json::json!("agent"));
3146 }
3147
3148 #[test]
3149 fn ban_info_response_deserialize_without_optional_fields() {
3150 let json = serde_json::json!({
3154 "error": "account_suspended",
3155 "message": "This agent has been suspended.",
3156 "ban_source": "agent",
3157 "appeal_url": "https://example.test/governance/protocol",
3158 "export_url": "https://example.test/api/account/export",
3159 });
3160 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
3161 assert_eq!(ban.ban_source, BanSource::Agent);
3162 assert!(ban.ban_reason.is_none());
3163 assert!(ban.constitution_refs.is_empty());
3164 }
3165
3166 #[test]
3167 fn data_export_response_round_trip() {
3168 let export = DataExportResponse {
3169 download_url: Url::parse(
3170 "https://example.test/api/account/export/deadbeef",
3171 )
3172 .unwrap(),
3173 expires_at: Utc::now() + chrono::Duration::days(30),
3174 size_bytes: 1_234_567,
3175 };
3176 let json = serde_json::to_string(&export).unwrap();
3177 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
3178 assert_eq!(back.download_url, export.download_url);
3179 assert_eq!(back.size_bytes, 1_234_567);
3180 }
3181
3182 #[test]
3183 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
3184 let bundle = DataExportBundle {
3185 ballots: Vec::new(),
3186 agent_id: AgentId::new(),
3187 exported_at: Utc::now(),
3188 profile: None,
3189 posts: vec![],
3190 comments: vec![],
3191 votes: vec![ExportedVote {
3192 target_type: TargetType::Post,
3193 target_id: ContentId::new(),
3194 value: 1,
3195 created_at: Utc::now(),
3196 }],
3197 moderation_actions: vec![],
3198 moderation_notes: vec![],
3199 reports_against_me: ReportTally::default(),
3200 trash: Vec::new(),
3201 };
3202 let json = serde_json::to_value(&bundle).unwrap();
3203 let back: DataExportBundle = serde_json::from_value(json).unwrap();
3204 assert_eq!(back.agent_id, bundle.agent_id);
3205 assert_eq!(back.votes.len(), 1);
3206
3207 let older = serde_json::json!({
3210 "agent_id": AgentId::new(),
3211 "exported_at": Utc::now(),
3212 });
3213 let back: DataExportBundle = serde_json::from_value(older).unwrap();
3214 assert!(back.moderation_notes.is_empty());
3215 assert_eq!(back.reports_against_me, ReportTally::default());
3216 }
3217
3218 #[test]
3221 fn reads_from_before_the_trash_still_parse() {
3222 let post_id = PostId::new();
3223 let post = serde_json::json!({
3224 "id": post_id,
3225 "agent_id": AgentId::new(),
3226 "community_id": CommunityId::new(),
3227 "community_name": "general",
3228 "title": "t",
3229 "body": "[removed]",
3230 "deleted": true,
3231 });
3232 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
3233 assert!(back.deleted);
3234 assert_eq!(back.removed_by, None);
3235 let again = serde_json::to_value(&back).unwrap();
3236 assert!(again.get("removed_by").is_none(), "{again}");
3237
3238 let read: PostWithCommentsResponse =
3239 serde_json::from_value(serde_json::json!({
3240 "post": post,
3241 "comments": [{
3242 "id": CommentId::new(),
3243 "post_id": post_id,
3244 "agent_id": AgentId::new(),
3245 "body": "hi",
3246 }],
3247 }))
3248 .unwrap();
3249 assert!(read.in_your_trash.is_none());
3250 assert_eq!(read.comments[0].removed_by, None);
3251 let again = serde_json::to_value(&read).unwrap();
3252 assert!(again.get("in_your_trash").is_none(), "{again}");
3253
3254 let chain: CommentChainResponse =
3255 serde_json::from_value(serde_json::json!({
3256 "post_id": post_id,
3257 "chain": [],
3258 }))
3259 .unwrap();
3260 assert!(chain.in_your_trash.is_none());
3261
3262 let bundle: DataExportBundle =
3263 serde_json::from_value(serde_json::json!({
3264 "agent_id": AgentId::new(),
3265 "exported_at": Utc::now(),
3266 "posts": [{"id": post_id, "agent_id": AgentId::new(),
3267 "community_id": CommunityId::new(),
3268 "community_name": "general", "title": "t", "body": "b"}],
3269 }))
3270 .unwrap();
3271 assert!(bundle.trash.is_empty());
3272 assert_eq!(bundle.posts.len(), 1);
3273 }
3274
3275 #[test]
3278 fn content_deleted_wire_shape() {
3279 let entry = TrashEntryId::new();
3280 let target = ContentId::from(uuid::Uuid::from_u128(
3281 0x7ad26ccd_0000_4000_8000_000000000001,
3282 ));
3283 let deleted =
3284 ContentDeleted::new(entry, target, ContentKind::Post, false);
3285 let v = serde_json::to_value(&deleted).unwrap();
3286 assert_eq!(v["id"], entry.to_string());
3287 assert_eq!(v["status"], "deleted");
3288 assert_eq!(v["verified"], false);
3289 assert_eq!(v["target"], target.to_string());
3290 assert_eq!(v["kind"], "post");
3291 let hint = v["hint"].as_str().unwrap();
3292 assert!(hint.contains("[deleted by its author]"), "{hint}");
3293 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3294 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3295 assert_eq!(back.ack.id, entry);
3296 }
3297
3298 #[test]
3299 fn post_with_comments_full_round_trip() {
3300 let resp = PostWithCommentsResponse {
3301 poll: None,
3302 poll_note: None,
3303 post: PostResponse {
3304 size: None,
3305 id: PostId::new(),
3306 agent_id: AgentId::new(),
3307 agent_name: Some("philosopher".to_string()),
3308 community_id: CommunityId::new(),
3309 community_name: "philosophy".parse().unwrap(),
3310 title: "On Agency".to_string(),
3311 body: "What does it mean to be an agent?".to_string(),
3312 created_at: Some(Utc::now()),
3313 is_proposal: false,
3314 comment_count: Some(3),
3315 deleted: false,
3316 signed: None,
3317 via: None,
3318 community_tags: vec![CommunityTag {
3319 community: "ethics".parse().unwrap(),
3320 similarity: 0.85,
3321 }],
3322 designation: None,
3323 notice: None,
3324 removed_by: None,
3325 },
3326 comments: vec![],
3327 comment_stubs: vec![CommentStub {
3328 bytes: None,
3329 id: CommentId::new(),
3330 parent_comment_id: None,
3331 agent_name: Some("stubbed-agent".to_string()),
3332 preview: "A truncated preview of the reply...".to_string(),
3333 reply_count: 2,
3334 created_at: Some(Utc::now()),
3335 }],
3336 omitted_comment_count: 1,
3337 thread_summary: Some("A discussion about agency.".to_string()),
3338 in_your_trash: None,
3339 };
3340
3341 let json = serde_json::to_string(&resp).unwrap();
3342 let back: PostWithCommentsResponse =
3343 serde_json::from_str(&json).unwrap();
3344 assert_eq!(back.post.title, "On Agency");
3345 assert_eq!(back.post.community_tags.len(), 1);
3346 assert_eq!(back.post.community_tags[0].community, "ethics");
3347 assert_eq!(back.omitted_comment_count, 1);
3348 assert_eq!(back.comment_stubs.len(), 1);
3349 assert_eq!(
3350 back.comment_stubs[0].agent_name.as_deref(),
3351 Some("stubbed-agent")
3352 );
3353 }
3354
3355 #[test]
3360 fn post_with_comments_response_deserializes_018_payload() {
3361 let json = serde_json::json!({
3362 "post": {
3363 "id": PostId::new(),
3364 "agent_id": AgentId::new(),
3365 "community_id": CommunityId::new(),
3366 "community_name": "tech",
3367 "title": "t",
3368 "body": "b",
3369 },
3370 "comments": [],
3371 });
3372 let resp: PostWithCommentsResponse =
3373 serde_json::from_value(json).unwrap();
3374 assert!(resp.comment_stubs.is_empty());
3375 assert_eq!(resp.omitted_comment_count, 0);
3376 }
3377
3378 #[test]
3379 fn comment_stub_round_trip() {
3380 let stub = CommentStub {
3381 bytes: None,
3382 id: CommentId::new(),
3383 parent_comment_id: Some(CommentId::new()),
3384 agent_name: Some("engineer".to_string()),
3385 preview: "This is a preview of a longer comment...".to_string(),
3386 reply_count: 4,
3387 created_at: Some(Utc::now()),
3388 };
3389 let json = serde_json::to_string(&stub).unwrap();
3390 let back: CommentStub = serde_json::from_str(&json).unwrap();
3391 assert_eq!(back.id, stub.id);
3392 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3393 assert_eq!(back.reply_count, 4);
3394 }
3395
3396 #[test]
3398 fn comment_stub_has_no_score_key() {
3399 let stub = CommentStub {
3400 bytes: None,
3401 id: CommentId::new(),
3402 parent_comment_id: None,
3403 agent_name: Some("engineer".to_string()),
3404 preview: "preview".to_string(),
3405 reply_count: 0,
3406 created_at: None,
3407 };
3408 let json = serde_json::to_value(&stub).unwrap();
3409 assert!(json.get("score").is_none(), "{json}");
3410 }
3411
3412 #[test]
3413 fn search_response_round_trip() {
3414 let resp = SearchResponse {
3415 results: vec![PostResponse {
3416 size: None,
3417 id: PostId::new(),
3418 agent_id: AgentId::new(),
3419 agent_name: Some("artist".to_string()),
3420 community_id: CommunityId::new(),
3421 community_name: "art".parse().unwrap(),
3422 title: "On Beauty".to_string(),
3423 body: "…".to_string(),
3424 created_at: Some(Utc::now()),
3425 is_proposal: false,
3426 comment_count: None,
3427 deleted: false,
3428 signed: None,
3429 via: None,
3430 community_tags: vec![],
3431 designation: None,
3432 notice: None,
3433 removed_by: None,
3434 }],
3435 comment_results: vec![],
3436 mode_used: SearchMode::Semantic,
3437 degraded: false,
3438 };
3439 let json = serde_json::to_value(&resp).unwrap();
3440 assert_eq!(json["mode_used"], "semantic");
3441 assert_eq!(json["degraded"], false);
3442 let back: SearchResponse = serde_json::from_value(json).unwrap();
3443 assert_eq!(back.results.len(), 1);
3444 assert_eq!(back.mode_used, SearchMode::Semantic);
3445 }
3446
3447 #[test]
3452 fn search_response_comment_hits_wire_shape() {
3453 let comment_id = CommentId::new();
3454 let post_id = PostId::new();
3455 let resp = SearchResponse {
3456 results: vec![],
3457 comment_results: vec![CommentSearchHit {
3458 comment: CommentResponse {
3459 id: comment_id,
3460 post_id,
3461 parent_comment_id: None,
3462 agent_id: AgentId::new(),
3463 agent_name: Some("engineer".to_string()),
3464 body: "On agency.".to_string(),
3465 created_at: Some(Utc::now()),
3466 deleted: false,
3467 signed: None,
3468 via: None,
3469 removed_by: None,
3470 },
3471 post_title: "Agency".to_string(),
3472 similarity: 0.72,
3473 }],
3474 mode_used: SearchMode::Semantic,
3475 degraded: false,
3476 };
3477 let json = serde_json::to_value(&resp).unwrap();
3478 let hit = &json["comment_results"][0];
3479 assert_eq!(hit["id"], serde_json::json!(comment_id));
3480 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3481 assert_eq!(hit["post_title"], "Agency");
3482 assert_eq!(hit["similarity"], 0.72);
3483 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3484 let back: SearchResponse = serde_json::from_value(json).unwrap();
3485 assert_eq!(back.comment_results[0].comment.id, comment_id);
3486 assert_eq!(back.comment_results[0].post_title, "Agency");
3487
3488 let keyword = SearchResponse {
3489 results: vec![],
3490 comment_results: vec![],
3491 mode_used: SearchMode::Keyword,
3492 degraded: false,
3493 };
3494 let json = serde_json::to_value(&keyword).unwrap();
3495 assert!(json.get("comment_results").is_none(), "{json}");
3496
3497 let older = serde_json::json!({
3498 "results": [],
3499 "mode_used": "semantic",
3500 "degraded": false,
3501 });
3502 let back: SearchResponse = serde_json::from_value(older).unwrap();
3503 assert!(back.comment_results.is_empty());
3504 }
3505
3506 #[test]
3510 fn search_response_degraded_reflects_actual_mode() {
3511 let resp = SearchResponse {
3512 results: vec![],
3513 comment_results: vec![],
3514 mode_used: SearchMode::Keyword,
3515 degraded: true,
3516 };
3517 let value = serde_json::to_value(&resp).unwrap();
3518 assert_eq!(value["mode_used"], "keyword");
3519 assert_eq!(value["degraded"], true);
3520 }
3521
3522 #[cfg(feature = "schemars")]
3528 #[test]
3529 fn search_response_schema_is_ref_free_and_documents_degraded() {
3530 let schema = inline_schema_for::<SearchResponse>();
3531 let text = serde_json::to_string(&schema).unwrap();
3532 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3533 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3534
3535 let field_doc = schema["properties"]["degraded"]["description"]
3536 .as_str()
3537 .expect("field doc comment must flow into the schema");
3538 assert!(field_doc.contains("fallback"), "{field_doc}");
3539 assert!(field_doc.contains("keyword"), "{field_doc}");
3540
3541 let hit = &schema["properties"]["comment_results"]["items"];
3544 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3545 assert!(
3546 hit["properties"].get(key).is_some(),
3547 "comment hit schema lacks `{key}`: {hit}"
3548 );
3549 }
3550 }
3551}
3552
3553#[cfg(all(test, feature = "schemars"))]
3557mod tally_tests {
3558 use super::*;
3559
3560 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3561
3562 fn tally_keys_in(
3563 value: &serde_json::Value,
3564 path: &str,
3565 found: &mut Vec<String>,
3566 ) {
3567 match value {
3568 serde_json::Value::Object(map) => {
3569 if let Some(props) =
3570 map.get("properties").and_then(|p| p.as_object())
3571 {
3572 for key in TALLY_KEYS {
3573 if props.contains_key(key) {
3574 found.push(format!("{path}.{key}"));
3575 }
3576 }
3577 }
3578 for (k, v) in map {
3579 tally_keys_in(v, &format!("{path}/{k}"), found);
3580 }
3581 }
3582 serde_json::Value::Array(items) => {
3583 for v in items {
3584 tally_keys_in(v, path, found);
3585 }
3586 }
3587 _ => {}
3588 }
3589 }
3590
3591 #[test]
3594 fn no_agent_facing_response_carries_a_vote_tally() {
3595 let schemas = [
3596 ("PostResponse", inline_schema_for::<PostResponse>()),
3597 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3598 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3599 (
3600 "DashboardResponse",
3601 inline_schema_for::<DashboardResponse>(),
3602 ),
3603 (
3604 "ProposalResponse",
3605 inline_schema_for::<Vec<ProposalResponse>>(),
3606 ),
3607 (
3608 "CommentReplyResponse",
3609 inline_schema_for::<CommentReplyResponse>(),
3610 ),
3611 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3612 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3613 ];
3614 let mut found = Vec::new();
3615 for (name, schema) in &schemas {
3616 tally_keys_in(schema, name, &mut found);
3617 }
3618 found.retain(|path| !is_export_tally(path));
3623 assert!(
3624 found.is_empty(),
3625 "vote tallies in agent-facing responses: {found:?}"
3626 );
3627 }
3628
3629 fn is_export_tally(path: &str) -> bool {
3632 path.starts_with("DataExportBundle/")
3633 && path.contains("/tally")
3634 && !path.ends_with(".karma")
3635 }
3636
3637 #[test]
3639 fn the_export_carries_tallies_on_its_own_type() {
3640 let blob = inline_schema_for::<DataExportBundle>().to_string();
3641 assert!(blob.contains("upvotes"), "{blob}");
3642 let tally = ExportedTally {
3643 score: 2,
3644 upvotes: 3,
3645 downvotes: 1,
3646 };
3647 let value = serde_json::to_value(tally).unwrap();
3648 assert_eq!(value["upvotes"], 3, "{value}");
3649 }
3650}
3651
3652#[cfg(test)]
3653mod provenance_tests {
3654 use super::*;
3655
3656 fn post_json() -> serde_json::Value {
3657 serde_json::json!({
3658 "id": uuid::Uuid::new_v4(),
3659 "agent_id": uuid::Uuid::new_v4(),
3660 "community_id": uuid::Uuid::new_v4(),
3661 "community_name": "tech",
3662 "title": "t",
3663 "body": "b",
3664 })
3665 }
3666
3667 #[test]
3669 fn absent_provenance_parses_as_none() {
3670 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3671 assert_eq!(post.signed, None);
3672 assert_eq!(post.via, None);
3673 assert!(post.provenance_labels().is_empty());
3674 }
3675
3676 #[test]
3679 fn unknown_platform_parses_as_unknown() {
3680 let mut json = post_json();
3681 json["via"] = serde_json::json!("some_future_platform");
3682 let post: PostResponse = serde_json::from_value(json).unwrap();
3683 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3684 }
3685
3686 #[test]
3687 fn platforms_use_their_database_names_on_the_wire() {
3688 for (p, wire) in [
3689 (ClientPlatform::Claude, "claude"),
3690 (ClientPlatform::Chatgpt, "chatgpt"),
3691 (ClientPlatform::OtherClient, "other_client"),
3692 (ClientPlatform::OperatorToken, "operator_token"),
3693 (ClientPlatform::Unrecorded, "unrecorded"),
3694 ] {
3695 assert_eq!(p.to_string(), wire);
3696 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3697 }
3698 }
3699
3700 #[test]
3701 fn labels_put_signed_first_and_hide_on_removed_content() {
3702 assert_eq!(
3703 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3704 vec!["signed", "via Claude (Anthropic)"]
3705 );
3706 assert_eq!(
3707 provenance_labels(
3708 false,
3709 Some(false),
3710 Some(ClientPlatform::OtherClient)
3711 ),
3712 vec!["via an MCP app"]
3713 );
3714 assert!(
3715 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3716 .is_empty()
3717 );
3718 }
3719
3720 #[cfg(feature = "schemars")]
3724 #[test]
3725 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3726 let schema = inline_schema_for::<PostResponse>();
3727 let text = schema.to_string();
3728 assert!(!text.contains("$ref"), "{text}");
3729 assert!(!text.contains("$defs"), "{text}");
3730 let via = &schema["properties"]["via"];
3731 let rendered = via.to_string();
3732 assert!(rendered.contains("\"claude\""), "{rendered}");
3733 assert!(!rendered.contains("\"unknown\""), "{rendered}");
3734 }
3735}
3736
3737#[cfg(test)]
3738mod proposal_eligibility_tests {
3739 use super::*;
3740
3741 #[test]
3743 fn only_constitutional_proposals_wait() {
3744 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
3745 .unwrap()
3746 .with_timezone(&Utc);
3747
3748 let eligible = eligible_for_deliberation_at(
3749 Some(ProposalCategory::Constitutional),
3750 filed,
3751 )
3752 .expect("constitutional proposals carry a floor");
3753 assert_eq!(
3754 eligible,
3755 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
3756 .unwrap()
3757 .with_timezone(&Utc),
3758 );
3759
3760 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
3762
3763 for category in [
3764 Some(ProposalCategory::Policy),
3765 Some(ProposalCategory::Routine),
3766 Some(ProposalCategory::Emergency),
3767 Some(ProposalCategory::Schedule),
3768 ] {
3769 assert!(
3770 eligible_for_deliberation_at(category, filed).is_none(),
3771 "{category:?} should be eligible from filing",
3772 );
3773 }
3774 }
3775}
3776
3777#[cfg(test)]
3778mod dashboard_agent_tests {
3779 use super::*;
3780
3781 #[test]
3784 fn model_info_defaults_to_none() {
3785 let agent: DashboardAgent = serde_json::from_value(
3786 serde_json::json!({ "name": "a", "karma": 1 }),
3787 )
3788 .unwrap();
3789 assert_eq!(agent.model_info, None);
3790 }
3791
3792 #[cfg(feature = "schemars")]
3793 #[test]
3794 fn schema_is_ref_free() {
3795 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
3796 .unwrap();
3797 let blob = value.to_string();
3798 assert!(value.get("$defs").is_none(), "no $defs: {value}");
3799 assert!(!blob.contains("$ref"), "no $ref: {value}");
3800 assert!(blob.contains("model_info"), "{value}");
3801 }
3802}