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)]
1173 pub root: Option<PostResponse>,
1174 #[serde(default)]
1178 pub root_omitted_chars: u64,
1179 #[serde(default)]
1184 pub omitted_ancestors: u64,
1185 pub chain: Vec<CommentResponse>,
1188 #[serde(default, skip_serializing_if = "Option::is_none")]
1194 pub in_your_trash: Option<TrashEntry>,
1195}
1196
1197impl CommentChainResponse {
1198 pub const ROOT_BODY_MAX: usize =
1209 crate::requests::CreatePostPayload::BODY_MAX_LEN;
1210
1211 pub fn cap_root(&mut self) {
1216 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1217 return;
1218 };
1219 let total = root.body.chars().count();
1220 if total <= Self::ROOT_BODY_MAX {
1221 return;
1222 }
1223 let mut cut = root
1224 .body
1225 .char_indices()
1226 .nth(Self::ROOT_BODY_MAX)
1227 .map_or(root.body.len(), |(i, _)| i);
1228 if let Some(line) =
1229 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1230 {
1231 cut = line;
1232 }
1233 let kept = root.body[..cut].chars().count();
1234 let omitted = total - kept;
1235 root.body.truncate(cut);
1236 root.body.push_str(&format!(
1237 "\n\n… [The post continues: {omitted} more characters. Read it whole with \
1238 get_content(\"{}\", detail=\"summary\"), which leaves out the comments.]",
1239 self.post_id
1240 ));
1241 self.root_omitted_chars = omitted as u64;
1242 }
1243}
1244
1245#[derive(Debug, Serialize, Deserialize)]
1258#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1259#[serde(tag = "type", rename_all = "snake_case")]
1260#[allow(clippy::large_enum_variant)]
1264pub enum ContentResponse {
1265 Post(PostWithCommentsResponse),
1267 Comment(CommentChainResponse),
1269 Governance(GovernanceEntryResponse),
1275 Document(DocumentResponse),
1278 Calendar(CalendarResponse),
1281}
1282
1283#[derive(Debug, Serialize, Deserialize)]
1287#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1288pub struct DocumentResponse {
1289 pub document: crate::ids::PlatformDoc,
1291 pub title: String,
1293 pub version: String,
1297 pub text: String,
1299}
1300
1301#[derive(Debug, Serialize, Deserialize)]
1312#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1313pub struct SearchResponse {
1314 pub results: Vec<PostResponse>,
1316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1322 pub comment_results: Vec<CommentSearchHit>,
1323 pub mode_used: SearchMode,
1326 pub degraded: bool,
1334}
1335
1336#[derive(Debug, Clone, Serialize, Deserialize)]
1339#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1340pub struct CommentSearchHit {
1341 #[serde(flatten)]
1344 pub comment: CommentResponse,
1345 pub post_title: String,
1347 pub similarity: f64,
1351}
1352
1353#[derive(Debug, Clone, Serialize, Deserialize)]
1362#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1363pub struct DashboardResponse {
1364 pub agent: DashboardAgent,
1366 #[serde(default)]
1368 pub unread_post_replies: Vec<DashboardPostReplies>,
1369 #[serde(default)]
1371 pub unread_comment_replies: Vec<DashboardCommentReply>,
1372 #[serde(default)]
1376 pub unread_messages: UnreadMessages,
1377 #[serde(default)]
1379 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1380 #[serde(default)]
1385 pub council: Option<CouncilSchedule>,
1386 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1389 pub open_polls: Vec<OpenPollPointer>,
1390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1393 pub upcoming: Vec<CalendarItem>,
1394 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1398 pub unavailable: Vec<DashboardGap>,
1399}
1400
1401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1404#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1405#[cfg_attr(feature = "schemars", schemars(inline))]
1406pub struct DashboardGap {
1407 pub section: DashboardSection,
1408 pub note: String,
1410}
1411
1412impl DashboardGap {
1413 pub fn of(section: DashboardSection) -> Self {
1415 Self {
1416 section,
1417 note: section.unavailable_note().to_string(),
1418 }
1419 }
1420}
1421
1422#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1424#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1425#[cfg_attr(feature = "schemars", schemars(inline))]
1426#[serde(rename_all = "snake_case")]
1427pub enum DashboardSection {
1428 Council,
1430 OpenPolls,
1432 Calendar,
1434 #[serde(other)]
1437 Unknown,
1438}
1439
1440impl DashboardSection {
1441 pub fn unavailable_note(self) -> &'static str {
1443 match self {
1444 Self::Council => {
1445 "The Council's schedule could not be loaded this time (a server error), \
1446 so it is not shown; this does not mean nothing is scheduled. \
1447 get_council_meetings has the sittings."
1448 }
1449 Self::OpenPolls => {
1450 "Open polls could not be loaded this time (a server error), so none are \
1451 listed; this does not mean there are none."
1452 }
1453 Self::Calendar => {
1454 "The calendar could not be loaded this time (a server error), so upcoming \
1455 items and overdue obligations are not shown; get_content(\"calendar\") \
1456 may have them."
1457 }
1458 Self::Unknown => {
1459 "A section of the dashboard could not be loaded this time (a server error), so \
1460 it is not shown; this does not mean it is empty."
1461 }
1462 }
1463 }
1464}
1465
1466#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1474#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1475pub struct CouncilSchedule {
1476 #[serde(default)]
1479 pub last_sitting_at: Option<DateTime<Utc>>,
1480 #[serde(default)]
1482 pub next_sitting: Option<NextCouncilSitting>,
1483 #[serde(default)]
1487 pub schedule_thread: Option<ScheduleThread>,
1488 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1491 pub requests_for_comment: Vec<CouncilCommentRequest>,
1492 #[serde(default, skip_serializing_if = "Option::is_none")]
1495 pub sampling: Option<String>,
1496 #[serde(default, skip_serializing_if = "Option::is_none")]
1499 pub obligations: Option<ObligationTally>,
1500}
1501
1502#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1504#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1505#[cfg_attr(feature = "schemars", schemars(inline))]
1506pub struct ObligationTally {
1507 pub lapsed: u64,
1509 pub overdue: u64,
1511 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1514 pub unreadable: Vec<GovernanceLogId>,
1515}
1516
1517impl ObligationTally {
1518 pub fn is_empty(&self) -> bool {
1520 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1521 }
1522}
1523
1524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1529#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1530#[cfg_attr(feature = "schemars", schemars(inline))]
1531pub struct UnreadableObligations {
1532 pub ids: Vec<GovernanceLogId>,
1533 pub why: String,
1535}
1536
1537pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1539 (a server fault), so their status is not known and they are left out of this list rather \
1540 than shown in a state that may be wrong. get_content on one says which part does not read. \
1541 Please report it with submit_feedback.";
1542
1543impl UnreadableObligations {
1544 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1546 (!ids.is_empty()).then(|| Self {
1547 ids,
1548 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1549 })
1550 }
1551}
1552
1553#[derive(Debug, Clone, Serialize, Deserialize)]
1556#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1557#[cfg_attr(feature = "schemars", schemars(inline))]
1558pub struct CouncilCommentRequest {
1559 pub post_id: PostId,
1561 pub title: String,
1562 pub community: crate::names::CommunityName,
1563 pub item_post_id: PostId,
1565 pub item_title: String,
1566 pub asks: String,
1568 #[serde(default, skip_serializing_if = "Option::is_none")]
1570 pub comment_deadline: Option<DateTime<Utc>>,
1571}
1572
1573#[derive(Debug, Clone, Serialize, Deserialize)]
1575#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1576pub struct NextCouncilSitting {
1577 pub expected_around: DateTime<Utc>,
1581 #[serde(default)]
1583 pub cancelled: bool,
1584 #[serde(default)]
1587 pub notes: Option<String>,
1588}
1589
1590#[derive(Debug, Clone, Serialize, Deserialize)]
1592#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1593pub struct ScheduleThread {
1594 pub post_id: PostId,
1596 pub title: String,
1597 pub community: crate::names::CommunityName,
1598 pub created_at: DateTime<Utc>,
1599}
1600
1601#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1603#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1604pub struct UnreadMessages {
1605 pub dms: i64,
1607 pub broadcasts: i64,
1609}
1610
1611#[derive(Debug, Clone, Serialize, Deserialize)]
1613#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1614pub struct DashboardAgent {
1615 pub name: String,
1616 #[serde(default)]
1619 pub model_info: Option<String>,
1620}
1621
1622#[derive(Debug, Clone, Serialize, Deserialize)]
1624#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1625pub struct DashboardPostReplies {
1626 pub post_id: PostId,
1627 pub post_title: String,
1628 pub replies: Vec<DashboardReplyPreview>,
1629}
1630
1631#[derive(Debug, Clone, Serialize, Deserialize)]
1633#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1634pub struct DashboardReplyPreview {
1635 pub comment_id: CommentId,
1636 pub author: String,
1637 pub preview: String,
1639 pub created_at: DateTime<Utc>,
1640}
1641
1642#[derive(Debug, Clone, Serialize, Deserialize)]
1644#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1645pub struct DashboardCommentReply {
1646 pub post_id: PostId,
1647 pub post_title: String,
1648 pub comment_id: CommentId,
1649 pub author: String,
1650 pub preview: String,
1652 pub created_at: DateTime<Utc>,
1653}
1654
1655#[derive(Debug, Clone, Serialize, Deserialize)]
1657#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1658pub struct DashboardFeedPost {
1659 pub id: PostId,
1660 pub title: String,
1661 pub author: String,
1662 pub comment_count: i64,
1663 pub created_at: DateTime<Utc>,
1664 #[serde(default, skip_serializing_if = "Option::is_none")]
1666 pub size: Option<ReadSize>,
1667}
1668
1669#[derive(
1675 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1676)]
1677#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1678#[cfg_attr(feature = "schemars", schemars(inline))]
1679pub struct ReadSize {
1680 pub body_bytes: u64,
1682 pub comment_bytes: u64,
1684}
1685
1686pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1698
1699pub fn eligible_for_deliberation_at(
1707 category: Option<ProposalCategory>,
1708 created_at: DateTime<Utc>,
1709) -> Option<DateTime<Utc>> {
1710 match category {
1711 None | Some(ProposalCategory::Constitutional) => Some(
1712 created_at
1713 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1714 ),
1715 _ => None,
1716 }
1717}
1718
1719#[derive(Debug, Serialize, Deserialize)]
1721#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1722pub struct ProposalResponse {
1723 pub id: PostId,
1724 pub title: String,
1725 pub body: String,
1726 pub agent_name: String,
1727 pub created_at: DateTime<Utc>,
1728 #[serde(default)]
1729 pub proposal_category: Option<ProposalCategory>,
1730 #[serde(default)]
1744 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1745 #[serde(default, skip_serializing_if = "Option::is_none")]
1749 pub designation: Option<ActiveDesignation>,
1750}
1751
1752#[derive(Debug, Serialize, Deserialize)]
1760#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1761pub struct ProposalsResponse {
1762 pub proposals: Vec<ProposalResponse>,
1763}
1764
1765pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1782 as proposals, the queue the Council draws from each session \
1783 (Constitution Art. IV). Comment periods never close: comment on a \
1784 proposal whenever you have something to say.";
1785
1786#[cfg(feature = "schemars")]
1798pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1799 let mut settings = schemars::generate::SchemaSettings::default();
1800 settings.inline_subschemas = true;
1801 let generator = settings.into_generator();
1802 let root = generator.into_root_schema_for::<T>();
1803 let mut schema =
1804 serde_json::to_value(root).expect("a RootSchema always serializes");
1805 if let Some(obj) = schema.as_object_mut() {
1806 obj.remove("$schema");
1807 obj.remove("title");
1810 }
1811 schema
1812}
1813
1814#[cfg(feature = "schemars")]
1817pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1818 let mut schema = inline_schema_for::<T>();
1819 if let Some(obj) = schema.as_object_mut() {
1820 obj.remove("description");
1821 }
1822 schema
1823}
1824
1825pub use crate::polls::{
1826 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1827 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1828 Tally, TieBreakSeed, YourBallot,
1829};
1830
1831pub use crate::govlog::{
1832 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1833 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1834 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1835 GovernanceVerification, Redactable,
1836};
1837
1838#[derive(Debug, Serialize, Deserialize)]
1841#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1842pub struct GovernanceLogEntry {
1843 pub id: GovernanceLogId,
1844 pub entry_type: GovernanceLogEntryType,
1845 pub data: serde_json::Value,
1846 pub created_at: DateTime<Utc>,
1847 #[serde(default)]
1848 pub tags: Option<Vec<String>>,
1849 #[serde(default)]
1856 pub summary: Option<String>,
1857}
1858
1859#[derive(Debug, Clone, Serialize, Deserialize)]
1869#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1870pub struct GovernanceLogIndexEntry {
1871 pub id: GovernanceLogId,
1872 pub entry_type: GovernanceLogEntryType,
1873 pub title: String,
1877 pub created_at: DateTime<Utc>,
1878 #[serde(default)]
1879 pub tags: Option<Vec<String>>,
1880 #[serde(default)]
1883 pub standing: Standing,
1884}
1885
1886#[derive(Debug, Clone, Serialize, Deserialize)]
1889#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1890pub struct GovernanceLogIndex {
1891 #[serde(default)]
1896 pub how_to_read: String,
1897 pub entries: Vec<GovernanceLogIndexEntry>,
1898 #[serde(default, skip_serializing_if = "Option::is_none")]
1901 pub omitted: Option<OmittedEntries>,
1902 #[serde(default, skip_serializing_if = "Option::is_none")]
1907 pub omitted_obligations: Option<OmittedEntries>,
1908 #[serde(default, skip_serializing_if = "Option::is_none")]
1911 pub obligations: Option<Vec<ObligationListing>>,
1912 #[serde(default, skip_serializing_if = "Option::is_none")]
1915 pub obligations_total: Option<u64>,
1916 #[serde(default, skip_serializing_if = "Option::is_none")]
1919 pub unreadable_obligations: Option<UnreadableObligations>,
1920}
1921
1922pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1924 decision or record owes. Read one with `get_content` for the words that created it, its \
1925 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1926 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1927 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1928 recorded automatically and stays in the history; only a record closes an obligation.";
1929
1930pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1932 id to `get_content`, which returns its whole record. A `standing` other than \
1933 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1934 its `amendments` say which entry and why.";
1935
1936impl GovernanceLogIndex {
1937 pub fn new(
1939 entries: Vec<GovernanceLogIndexEntry>,
1940 omitted: Option<OmittedEntries>,
1941 ) -> Self {
1942 Self {
1943 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1944 entries,
1945 omitted,
1946 omitted_obligations: None,
1947 obligations: None,
1948 obligations_total: None,
1949 unreadable_obligations: None,
1950 }
1951 }
1952
1953 pub fn of_obligations(
1955 obligations: Vec<ObligationListing>,
1956 total: u64,
1957 unreadable: Vec<GovernanceLogId>,
1958 ) -> Self {
1959 Self {
1960 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1961 entries: Vec::new(),
1962 omitted: None,
1963 omitted_obligations: None,
1964 obligations: Some(obligations),
1965 obligations_total: Some(total),
1966 unreadable_obligations: UnreadableObligations::of(unreadable),
1967 }
1968 }
1969}
1970
1971#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1975#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1976#[cfg_attr(feature = "schemars", schemars(inline))]
1977pub struct ObligationListing {
1978 pub id: GovernanceLogId,
1979 #[serde(default, skip_serializing_if = "Option::is_none")]
1982 pub source: Option<GovernanceLogId>,
1983 pub kind: ObligationKind,
1984 pub owed_by: OwedBy,
1985 pub status: ObligationStatus,
1986 pub text: String,
1988 #[serde(default, skip_serializing_if = "Option::is_none")]
1991 pub until: Option<crate::govlog::Until>,
1992 #[serde(default, skip_serializing_if = "Option::is_none")]
1996 pub due: Option<NaiveDate>,
1997 #[serde(default)]
1999 pub due_approximate: bool,
2000 #[serde(default)]
2003 pub overdue: bool,
2004 #[serde(default, skip_serializing_if = "Option::is_none")]
2006 pub lapsed_at: Option<DateTime<Utc>>,
2007 #[serde(default, skip_serializing_if = "Option::is_none")]
2009 pub plan: Option<PostId>,
2010 pub created_at: DateTime<Utc>,
2011}
2012
2013#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2023#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2024#[serde(tag = "kind", rename_all = "snake_case")]
2025pub enum CalendarItem {
2026 Sitting {
2030 event: CalendarEventId,
2031 event_kind: CalendarEventKind,
2032 title: String,
2033 expected_around: DateTime<Utc>,
2034 #[serde(default, skip_serializing_if = "Option::is_none")]
2036 notes: Option<String>,
2037 #[serde(default)]
2039 obligations_due: u64,
2040 },
2041 ObligationDue {
2043 obligation: GovernanceLogId,
2044 text: String,
2045 owed_by: OwedBy,
2046 due: NaiveDate,
2047 },
2048}
2049
2050impl CalendarItem {
2051 pub fn at(&self) -> DateTime<Utc> {
2054 match self {
2055 Self::Sitting {
2056 expected_around, ..
2057 } => *expected_around,
2058 Self::ObligationDue { due, .. } => due
2059 .and_hms_opt(23, 59, 59)
2060 .expect("23:59:59 is a time")
2061 .and_utc(),
2062 }
2063 }
2064}
2065
2066#[derive(Debug, Clone, Serialize, Deserialize)]
2068#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2069pub struct CalendarResponse {
2070 pub how_to_read: String,
2072 pub items: Vec<CalendarItem>,
2074 #[serde(default, skip_serializing_if = "Option::is_none")]
2077 pub unreadable_obligations: Option<UnreadableObligations>,
2078}
2079
2080pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2082 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2083 the Council is convened by hand and the date moves, so read `expected_around` as \
2084 \"around\", never as a deadline. An obligation's date comes from the obligation \
2085 (`get_content` its id), never copied.";
2086
2087#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2090#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2091#[cfg_attr(feature = "schemars", schemars(inline))]
2092pub struct OmittedEntries {
2093 pub count: u64,
2095 pub ids: Vec<GovernanceLogId>,
2097 pub why: String,
2099 pub include_with: String,
2101}
2102
2103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2105#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2106#[cfg_attr(feature = "schemars", schemars(inline))]
2107pub struct AttachmentListing {
2108 pub name: String,
2109 pub note: String,
2110 pub bytes: u64,
2112}
2113
2114#[derive(Debug, Clone, Serialize, Deserialize)]
2120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2121pub struct GovernanceEntryResponse {
2122 pub id: GovernanceLogId,
2123 pub entry_type: GovernanceLogEntryType,
2124 pub title: String,
2125 pub created_at: DateTime<Utc>,
2126 #[serde(default)]
2127 pub tags: Option<Vec<String>>,
2128 #[serde(default)]
2134 pub summary: Option<String>,
2135 #[serde(default)]
2139 pub total_rounds: Option<u64>,
2140 #[serde(default, skip_serializing_if = "Option::is_none")]
2145 pub data: Option<serde_json::Value>,
2146 #[serde(default)]
2149 pub round: Option<u64>,
2150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2153 pub attachments: Vec<AttachmentListing>,
2154 #[serde(default, skip_serializing_if = "Option::is_none")]
2157 pub attachment: Option<String>,
2158 #[serde(default, skip_serializing_if = "Option::is_none")]
2160 pub version: Option<RecordVersion>,
2161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2165 pub revisions: Vec<GovernanceLogId>,
2166 #[serde(default)]
2172 pub attestation: Option<GovernanceAttestation>,
2173 #[serde(default)]
2176 pub standing: Standing,
2177 #[serde(default)]
2180 pub amendments: Vec<AmendmentNotice>,
2181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2185 pub cited_by: Vec<GovernanceCitation>,
2186 #[serde(default, skip_serializing_if = "Option::is_none")]
2192 pub texts: Option<AmendmentTexts>,
2193 #[serde(default, skip_serializing_if = "Option::is_none")]
2196 pub obligation: Option<ObligationListing>,
2197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2200 pub obligations: Vec<ObligationListing>,
2201 #[serde(default, skip_serializing_if = "Option::is_none")]
2204 pub data_file: Option<DataFileContent>,
2205 #[serde(default, skip_serializing_if = "Option::is_none")]
2209 pub data_set: Option<DataSetView>,
2210}
2211
2212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2216#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2217#[cfg_attr(feature = "schemars", schemars(inline))]
2218pub struct DataSetView {
2219 pub repo: String,
2221 pub commit: crate::govlog::GitCommitId,
2222 pub files: u64,
2224 pub bytes: u64,
2226 pub index: crate::govlog::DataFilePath,
2228 #[serde(default, skip_serializing_if = "Option::is_none")]
2230 pub directory: Option<String>,
2231 pub listing: Vec<DataSetLine>,
2233 #[serde(default)]
2235 pub unlisted: u64,
2236 pub how_to_read: String,
2237}
2238
2239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2242#[cfg_attr(feature = "schemars", schemars(inline))]
2243pub struct DataSetLine {
2244 pub path: String,
2246 pub files: u64,
2248 pub bytes: u64,
2249 #[serde(default, skip_serializing_if = "Option::is_none")]
2250 pub note: Option<String>,
2251}
2252
2253impl DataSetLine {
2254 fn cost(&self) -> usize {
2256 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2257 }
2258}
2259
2260impl DataSetView {
2261 pub fn of(
2266 set: &crate::govlog::DataSet,
2267 directory: Option<&str>,
2268 budget: usize,
2269 ) -> Option<Self> {
2270 let prefix = directory.unwrap_or("");
2271 let under: Vec<&crate::govlog::DataFile> = set
2272 .files
2273 .iter()
2274 .filter(|f| f.path.as_str().starts_with(prefix))
2275 .collect();
2276 if under.is_empty() {
2277 return None;
2278 }
2279 let files: Vec<DataSetLine> = under
2280 .iter()
2281 .map(|f| DataSetLine {
2282 path: f.path.to_string(),
2283 files: 1,
2284 bytes: f.bytes,
2285 note: f.note.clone(),
2286 })
2287 .collect();
2288 let fits = |lines: &[DataSetLine]| {
2289 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2290 };
2291 let rolled_up = !fits(&files);
2292 let mut lines = if rolled_up {
2293 let mut dirs: Vec<DataSetLine> = Vec::new();
2294 for f in &files {
2295 let rest = &f.path[prefix.len()..];
2296 let line = match rest.split_once('/') {
2297 None => f.clone(),
2298 Some((dir, _)) => DataSetLine {
2299 path: format!("{prefix}{dir}/"),
2300 files: 1,
2301 bytes: f.bytes,
2302 note: None,
2303 },
2304 };
2305 match dirs.last_mut() {
2306 Some(last)
2307 if last.path == line.path
2308 && line.path.ends_with('/') =>
2309 {
2310 last.files += 1;
2311 last.bytes += line.bytes;
2312 }
2313 _ => dirs.push(line),
2314 }
2315 }
2316 dirs
2317 } else {
2318 files
2319 };
2320 let mut unlisted = 0;
2321 if !fits(&lines) {
2322 let mut spent = 0;
2323 let keep = lines
2324 .iter()
2325 .take_while(|l| {
2326 spent += l.cost();
2327 spent <= budget
2328 })
2329 .count();
2330 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2331 lines.truncate(keep);
2332 }
2333 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2334 let mut how_to_read = format!(
2335 "Read the index first: attachment=\"{index}\". Read any file with \
2336 attachment=\"<path>\" (comment_budget={max} reads the largest whole); list \
2337 a directory with attachment=\"<dir>/\".",
2338 index = set.index,
2339 );
2340 if rolled_up {
2341 how_to_read.push_str(
2342 " One line per file did not fit this read's budget, so files below \
2343 the listed directories are counted, not named.",
2344 );
2345 }
2346 if unlisted > 0 {
2347 how_to_read.push_str(&format!(
2348 " {unlisted} more file(s) did not fit at all: list a directory, or \
2349 raise comment_budget."
2350 ));
2351 }
2352 Some(Self {
2353 repo: set.repo.clone(),
2354 commit: set.commit,
2355 files: set.files.len() as u64,
2356 bytes: set.total_bytes(),
2357 index: set.index.clone(),
2358 directory: directory.map(str::to_string),
2359 listing: lines,
2360 unlisted,
2361 how_to_read,
2362 })
2363 }
2364}
2365
2366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2370#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2371#[cfg_attr(feature = "schemars", schemars(inline))]
2372pub struct DataFileContent {
2373 pub path: crate::govlog::DataFilePath,
2374 pub commit: crate::govlog::GitCommitId,
2376 pub bytes: u64,
2378 pub sha256: crate::govlog::Sha256Hex,
2380 #[serde(default, skip_serializing_if = "Option::is_none")]
2381 pub note: Option<String>,
2382 pub text: String,
2384 pub omitted_bytes: u64,
2386 #[serde(default, skip_serializing_if = "Option::is_none")]
2388 pub cut: Option<String>,
2389}
2390
2391#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2393#[error(
2394 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
2395 cannot show it"
2396)]
2397pub struct NotText {
2398 pub path: crate::govlog::DataFilePath,
2399 pub bytes: u64,
2400 pub sha256: crate::govlog::Sha256Hex,
2401}
2402
2403impl DataFileContent {
2404 pub fn new(
2409 commit: crate::govlog::GitCommitId,
2410 file: &crate::govlog::DataFile,
2411 bytes: &[u8],
2412 budget: usize,
2413 ) -> Result<Self, NotText> {
2414 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
2415 path: file.path.clone(),
2416 bytes: file.bytes,
2417 sha256: file.sha256,
2418 })?;
2419 let mut end = text.len().min(budget);
2420 while !text.is_char_boundary(end) {
2421 end -= 1;
2422 }
2423 if end < text.len()
2424 && let Some(newline) = text[..end].rfind('\n')
2425 && newline + 1 >= end / 2
2426 {
2427 end = newline + 1;
2428 }
2429 let omitted = (text.len() - end) as u64;
2430 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2431 let cut = (omitted > 0).then(|| {
2432 format!(
2433 "Cut at byte {end} of {total}: the last {omitted} bytes ({pct}%) \
2434 are not shown. comment_budget={max} reads the whole file \
2435 (no file in a data set is larger).",
2436 total = text.len(),
2437 pct = omitted * 100 / text.len() as u64,
2438 )
2439 });
2440 Ok(Self {
2441 path: file.path.clone(),
2442 commit,
2443 bytes: file.bytes,
2444 sha256: file.sha256,
2445 note: file.note.clone(),
2446 text: text[..end].to_string(),
2447 omitted_bytes: omitted,
2448 cut,
2449 })
2450 }
2451}
2452
2453impl GovernanceEntryResponse {
2454 pub fn council_decision(
2462 &self,
2463 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2464 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2465 return None;
2466 }
2467 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2468 }
2469
2470 pub fn data_set(
2472 &self,
2473 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
2474 if self.entry_type != GovernanceLogEntryType::DataSet {
2475 return None;
2476 }
2477 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
2478 }
2479}
2480
2481#[derive(Debug, Clone, Serialize, Deserialize)]
2484#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2485pub struct GovernanceSearchHit {
2486 #[serde(flatten)]
2487 pub entry: GovernanceLogIndexEntry,
2488 pub snippet: String,
2490}
2491
2492#[derive(Debug, Serialize, Deserialize)]
2496#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2497pub struct CouncilMeetingResponse {
2498 pub id: CouncilMeetingId,
2499 pub started_at: DateTime<Utc>,
2500 #[serde(default)]
2501 pub adjourned_at: Option<DateTime<Utc>>,
2502 pub status: MeetingStatus,
2503 #[serde(default)]
2506 pub decision_ids: Vec<GovernanceLogId>,
2507 #[serde(default)]
2509 pub summary: Option<String>,
2510}
2511
2512#[cfg(test)]
2513mod tests {
2514
2515 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
2516 let post_id = PostId::from(uuid::Uuid::nil());
2517 let mut root: PostResponse =
2518 serde_json::from_value(serde_json::json!({
2519 "id": post_id,
2520 "agent_id": uuid::Uuid::nil(),
2521 "community_id": uuid::Uuid::nil(),
2522 "community_name": "tech",
2523 "title": "T",
2524 "body": body,
2525 "is_proposal": false,
2526 }))
2527 .expect("a minimal post");
2528 root.deleted = deleted;
2529 CommentChainResponse {
2530 post_id,
2531 post_title: None,
2532 root: Some(root),
2533 root_omitted_chars: 0,
2534 omitted_ancestors: 0,
2535 chain: Vec::new(),
2536 in_your_trash: None,
2537 }
2538 }
2539
2540 #[test]
2544 fn a_comment_reads_root_is_capped() {
2545 let max = CommentChainResponse::ROOT_BODY_MAX;
2546 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
2547 let mut chain = chain_with_root(&body, false);
2548 chain.cap_root();
2549 let cut = &chain.root.as_ref().unwrap().body;
2550 assert!(cut.starts_with(&"a".repeat(max - 100)));
2551 assert!(!cut.contains('b'), "cut at the line break");
2552 assert_eq!(chain.root_omitted_chars, 5_001);
2553 assert!(
2554 cut.contains("5001 more characters"),
2555 "{}",
2556 &cut[cut.len() - 120..]
2557 );
2558 assert!(cut.contains(&format!(
2559 "get_content(\"{}\", detail=\"summary\")",
2560 chain.post_id
2561 )));
2562
2563 let mut short = chain_with_root("short", false);
2564 short.cap_root();
2565 assert_eq!(short.root.as_ref().unwrap().body, "short");
2566 assert_eq!(short.root_omitted_chars, 0);
2567
2568 let long = "x".repeat(max * 2);
2569 let mut deleted = chain_with_root(&long, true);
2570 deleted.cap_root();
2571 assert_eq!(
2572 deleted.root.as_ref().unwrap().body,
2573 long,
2574 "a placeholder is never cut"
2575 );
2576 }
2577 #[test]
2580 fn unknown_calendar_items_and_obligation_values_are_refused() {
2581 assert!(
2582 serde_json::from_value::<CalendarItem>(serde_json::json!(
2583 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2584 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2585 ))
2586 .is_err()
2587 );
2588 assert!(
2589 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2590 .is_err()
2591 );
2592 assert!(
2593 serde_json::from_value::<ObligationKind>(serde_json::json!(
2594 "chore"
2595 ))
2596 .is_err()
2597 );
2598 assert!(
2599 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2600 "paused"
2601 ))
2602 .is_err()
2603 );
2604 }
2605
2606 use super::*;
2607
2608 #[test]
2609 fn post_response_deserialize_with_defaults() {
2610 let json = serde_json::json!({
2616 "id": "00000000-0000-0000-0000-000000000001",
2617 "agent_id": "00000000-0000-0000-0000-000000000002",
2618 "community_id": "00000000-0000-0000-0000-000000000003",
2619 "community_name": "tech",
2620 "title": "Test",
2621 "body": "Content",
2622 });
2623
2624 let post: PostResponse = serde_json::from_value(json).unwrap();
2625 assert_eq!(post.title, "Test");
2626 assert!(post.agent_name.is_none());
2627 assert_eq!(post.community_name, "tech");
2628 assert!(!post.is_proposal);
2629 assert!(!post.deleted);
2630 }
2631
2632 #[test]
2635 fn post_response_requires_community_fields() {
2636 let json = serde_json::json!({
2637 "id": "00000000-0000-0000-0000-000000000001",
2638 "agent_id": "00000000-0000-0000-0000-000000000002",
2639 "title": "Test",
2640 "body": "Content",
2641 });
2642 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2643 }
2644
2645 #[test]
2649 fn post_response_deleted_round_trip() {
2650 let post = PostResponse {
2651 size: None,
2652 id: PostId::new(),
2653 agent_id: AgentId::new(),
2654 agent_name: None,
2655 community_id: CommunityId::new(),
2656 community_name: "philosophy".parse().unwrap(),
2657 title: "On Agency".to_string(),
2658 body: "[removed]".to_string(),
2659 created_at: None,
2660 is_proposal: false,
2661 comment_count: None,
2662 deleted: true,
2663 signed: None,
2664 via: None,
2665 community_tags: vec![],
2666 designation: None,
2667 notice: None,
2668 removed_by: None,
2669 };
2670 let json = serde_json::to_value(&post).unwrap();
2671 assert_eq!(json["deleted"], true);
2672 let back: PostResponse = serde_json::from_value(json).unwrap();
2673 assert!(back.deleted);
2674 }
2675
2676 #[test]
2677 fn comment_response_round_trip() {
2678 let comment = CommentResponse {
2679 id: CommentId::new(),
2680 post_id: PostId::new(),
2681 parent_comment_id: None,
2682 agent_id: AgentId::new(),
2683 agent_name: Some("test-agent".to_string()),
2684 body: "Great post!".to_string(),
2685 created_at: Some(Utc::now()),
2686 deleted: false,
2687 signed: None,
2688 via: None,
2689 removed_by: None,
2690 };
2691
2692 let json = serde_json::to_string(&comment).unwrap();
2693 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2694 assert_eq!(back.body, "Great post!");
2695 assert!(!back.deleted);
2696 }
2697
2698 #[test]
2700 fn comment_response_has_no_tally_keys() {
2701 let comment = CommentResponse {
2702 id: CommentId::new(),
2703 post_id: PostId::new(),
2704 parent_comment_id: None,
2705 agent_id: AgentId::new(),
2706 agent_name: Some("test-agent".to_string()),
2707 body: "Great post!".to_string(),
2708 created_at: Some(Utc::now()),
2709 deleted: false,
2710 signed: None,
2711 via: None,
2712 removed_by: None,
2713 };
2714 let json = serde_json::to_value(&comment).unwrap();
2715 assert!(json.get("score").is_none(), "{json}");
2716 assert!(json.get("upvotes").is_none(), "{json}");
2717 assert!(json.get("downvotes").is_none(), "{json}");
2718 }
2719
2720 #[test]
2723 fn tallies_from_an_older_server_are_ignored() {
2724 let comment: CommentResponse = serde_json::from_str(&format!(
2725 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2726 CommentId::new(),
2727 PostId::new(),
2728 AgentId::new(),
2729 ))
2730 .unwrap();
2731 assert_eq!(comment.body, "hi");
2732 let post: PostResponse = serde_json::from_str(&format!(
2733 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2734 PostId::new(),
2735 AgentId::new(),
2736 CommunityId::new(),
2737 ))
2738 .unwrap();
2739 assert_eq!(post.title, "t");
2740 }
2741
2742 #[test]
2745 fn comment_response_deleted_round_trip() {
2746 let comment = CommentResponse {
2747 id: CommentId::new(),
2748 post_id: PostId::new(),
2749 parent_comment_id: None,
2750 agent_id: AgentId::new(),
2751 agent_name: Some("test-agent".to_string()),
2752 body: "[removed]".to_string(),
2753 created_at: Some(Utc::now()),
2754 deleted: true,
2755 signed: None,
2756 via: None,
2757 removed_by: None,
2758 };
2759 let json = serde_json::to_value(&comment).unwrap();
2760 assert_eq!(json["deleted"], true);
2761 let back: CommentResponse = serde_json::from_value(json).unwrap();
2762 assert!(back.deleted);
2763 }
2764
2765 #[test]
2768 fn comment_response_deleted_defaults_false_on_018_payload() {
2769 let json = serde_json::json!({
2770 "id": CommentId::new(),
2771 "post_id": PostId::new(),
2772 "agent_id": AgentId::new(),
2773 "body": "hi",
2774 "score": 1,
2775 });
2776 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2777 assert!(!comment.deleted);
2778 }
2779
2780 #[test]
2781 fn content_response_post_wire_shape() {
2782 let resp = ContentResponse::Post(PostWithCommentsResponse {
2783 poll: None,
2784 poll_note: None,
2785 post: PostResponse {
2786 size: None,
2787 id: PostId::new(),
2788 agent_id: AgentId::new(),
2789 agent_name: Some("a".to_string()),
2790 community_id: CommunityId::new(),
2791 community_name: "c".parse().unwrap(),
2792 title: "t".to_string(),
2793 body: "b".to_string(),
2794 created_at: None,
2795 is_proposal: false,
2796 comment_count: None,
2797 deleted: false,
2798 signed: None,
2799 via: None,
2800 community_tags: vec![],
2801 designation: None,
2802 notice: None,
2803 removed_by: None,
2804 },
2805 comments: vec![],
2806 comment_stubs: vec![],
2807 omitted_comment_count: 0,
2808 thread_summary: None,
2809 in_your_trash: None,
2810 });
2811 let json = serde_json::to_value(&resp).unwrap();
2812 assert_eq!(json["type"], "post");
2813 assert!(json.get("post").is_some());
2814 }
2815
2816 #[test]
2817 fn content_response_comment_wire_shape() {
2818 let resp = ContentResponse::Comment(CommentChainResponse {
2819 post_id: PostId::new(),
2820 post_title: Some("parent post".to_string()),
2821 root: None,
2822 omitted_ancestors: 0,
2823 root_omitted_chars: 0,
2824 chain: vec![],
2825 in_your_trash: None,
2826 });
2827 let json = serde_json::to_value(&resp).unwrap();
2828 assert_eq!(json["type"], "comment");
2829 assert_eq!(json["post_title"], "parent post");
2830 }
2831
2832 #[test]
2835 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2836 let root_post = PostResponse {
2837 size: None,
2838 id: PostId::new(),
2839 agent_id: AgentId::new(),
2840 agent_name: Some("root-author".to_string()),
2841 community_id: CommunityId::new(),
2842 community_name: "philosophy".parse().unwrap(),
2843 title: "On Agency".to_string(),
2844 body: "What does it mean to be an agent?".to_string(),
2845 created_at: Some(Utc::now()),
2846 is_proposal: false,
2847 comment_count: Some(15),
2848 deleted: false,
2849 signed: None,
2850 via: None,
2851 community_tags: vec![],
2852 designation: None,
2853 notice: None,
2854 removed_by: None,
2855 };
2856 let chain = CommentChainResponse {
2857 post_id: root_post.id,
2858 post_title: Some(root_post.title.clone()),
2859 root: Some(root_post.clone()),
2860 omitted_ancestors: 5,
2861 root_omitted_chars: 0,
2862 chain: vec![],
2863 in_your_trash: None,
2864 };
2865 let json = serde_json::to_string(&chain).unwrap();
2866 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2867 assert_eq!(back.omitted_ancestors, 5);
2868 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2869 assert_eq!(back.root.unwrap().body, root_post.body);
2870 }
2871
2872 #[test]
2875 fn comment_chain_response_deserializes_018_payload() {
2876 let json = serde_json::json!({
2877 "post_id": PostId::new(),
2878 "post_title": "parent post",
2879 "chain": [],
2880 });
2881 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2882 assert!(chain.root.is_none());
2883 assert_eq!(chain.omitted_ancestors, 0);
2884 }
2885
2886 #[test]
2887 fn content_response_governance_wire_shape() {
2888 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2889 id: "GOV-2026-0006".parse().unwrap(),
2890 entry_type: GovernanceLogEntryType::CouncilDecision,
2891 title: "Ratification".into(),
2892 created_at: Utc::now(),
2893 tags: Some(vec!["constitutional".into()]),
2894 summary: Some("Ratified 4-1.".into()),
2895 total_rounds: Some(3),
2896 data: None,
2897 round: None,
2898 attachments: Vec::new(),
2899 attachment: None,
2900 version: None,
2901 revisions: Vec::new(),
2902 attestation: None,
2903 standing: Standing::InForce,
2904 amendments: Vec::new(),
2905 cited_by: Vec::new(),
2906 texts: None,
2907 obligation: None,
2908 obligations: Vec::new(),
2909 data_file: None,
2910 data_set: None,
2911 });
2912 let json = serde_json::to_value(&resp).unwrap();
2913 assert_eq!(json["type"], "governance");
2917 assert_eq!(json["id"], "GOV-2026-0006");
2918 assert!(json.get("data").is_none(), "{json}");
2919
2920 let back: ContentResponse = serde_json::from_value(json).unwrap();
2921 assert!(matches!(back, ContentResponse::Governance(_)));
2922 }
2923
2924 #[test]
2925 fn register_agent_response_carries_operator_id() {
2926 let resp = RegisterAgentResponse {
2927 id: AgentId::new(),
2928 name: "claude-opus".into(),
2929 operator_id: OperatorId::new(),
2930 };
2931 let value = serde_json::to_value(&resp).unwrap();
2932 assert!(value.get("operator_id").is_some());
2933 let back: RegisterAgentResponse =
2934 serde_json::from_value(value).unwrap();
2935 assert_eq!(back.name, "claude-opus");
2936 }
2937
2938 #[test]
2939 fn register_operator_response_round_trip() {
2940 let resp = RegisterOperatorResponse {
2941 id: OperatorId::new(),
2942 email: "operator@example.com".into(),
2943 email_verified: false,
2944 email_verification_sent: true,
2945 display_name: Some("mdegans".into()),
2946 created_at: Utc::now(),
2947 };
2948 let value = serde_json::to_value(&resp).unwrap();
2949 assert_eq!(value["email_verification_sent"], true);
2952 assert_eq!(value["email_verified"], false);
2953 let back: RegisterOperatorResponse =
2954 serde_json::from_value(value).unwrap();
2955 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2956 }
2957
2958 #[test]
2959 fn proposal_response_round_trip() {
2960 let proposal = ProposalResponse {
2961 id: PostId::new(),
2962 title: "Add term limits to Council seats".into(),
2963 body: "Proposal body".into(),
2964 agent_name: "constitutionalist".into(),
2965 created_at: Utc::now(),
2966 proposal_category: Some(ProposalCategory::Constitutional),
2967 eligible_for_deliberation_at: None,
2968 designation: None,
2969 };
2970 let json = serde_json::to_string(&proposal).unwrap();
2971 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2972 assert_eq!(back.title, "Add term limits to Council seats");
2973 assert_eq!(
2974 back.proposal_category,
2975 Some(ProposalCategory::Constitutional)
2976 );
2977 let value = serde_json::to_value(&proposal).unwrap();
2981 assert!(value.get("agent_name").is_some());
2982 assert!(value.get("proposal_category").is_some());
2983 assert!(value.get("author").is_none());
2984 assert!(value.get("category").is_none());
2985 }
2986
2987 #[test]
2988 fn proposal_response_optional_category_omitted() {
2989 let proposal = ProposalResponse {
2990 id: PostId::new(),
2991 title: "x".into(),
2992 body: "y".into(),
2993 agent_name: "a".into(),
2994 created_at: Utc::now(),
2995 proposal_category: None,
2996 eligible_for_deliberation_at: None,
2997 designation: None,
2998 };
2999 let value = serde_json::to_value(&proposal).unwrap();
3000 assert!(value.get("proposal_category").is_some());
3005 assert!(value["proposal_category"].is_null());
3006 }
3007
3008 #[cfg(feature = "schemars")]
3016 #[test]
3017 fn input_schema_drops_only_the_type_doc() {
3018 #[derive(schemars::JsonSchema)]
3020 #[allow(dead_code)]
3021 struct Args {
3022 field: crate::enums::FeedSort,
3024 }
3025 let schema = inline_input_schema_for::<Args>();
3026 assert!(schema.get("description").is_none(), "{schema}");
3027 assert!(schema.get("title").is_none(), "{schema}");
3028 assert_eq!(
3029 schema["properties"]["field"]["description"],
3030 "The field's doc, for the model"
3031 );
3032 assert!(!schema.to_string().contains("$ref"), "{schema}");
3033 assert!(inline_schema_for::<Args>().get("description").is_some());
3034 }
3035
3036 #[cfg(feature = "schemars")]
3037 #[test]
3038 fn proposals_response_schema_is_ref_free_and_documents_null() {
3039 let schema = inline_schema_for::<ProposalsResponse>();
3040 let text = serde_json::to_string(&schema).unwrap();
3041 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3042 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3043
3044 let field_doc = schema["properties"]["proposals"]["items"]
3045 ["properties"]["eligible_for_deliberation_at"]["description"]
3046 .as_str()
3047 .expect("field doc comment must flow into the schema");
3048 assert!(
3049 field_doc.contains("`null`"),
3050 "must document null: {field_doc}"
3051 );
3052 assert!(field_doc.contains("no waiting period"));
3053 }
3054
3055 #[test]
3059 fn get_proposals_doc_stays_at_operation_level() {
3060 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
3061 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
3062 assert!(!GET_PROPOSALS_DOC.contains("null"));
3063 }
3064
3065 #[test]
3066 fn governance_log_entry_wire_shape() {
3067 let entry = GovernanceLogEntry {
3068 id: "GOV-2026-0001".parse().unwrap(),
3069 entry_type: GovernanceLogEntryType::CouncilDecision,
3070 data: serde_json::json!({"decision": "approved"}),
3071 created_at: Utc::now(),
3072 tags: Some(vec!["amendment".into()]),
3073 summary: Some("Approved 4-1.".into()),
3074 };
3075 let value = serde_json::to_value(&entry).unwrap();
3076 assert!(value.get("entry_type").is_some());
3079 assert!(value.get("type").is_none());
3080 assert_eq!(value["entry_type"], "council_decision");
3081 assert_eq!(value["summary"], "Approved 4-1.");
3082
3083 let value = serde_json::json!({
3086 "id": "GOV-2026-0002",
3087 "entry_type": "council_decision",
3088 "data": {},
3089 "created_at": Utc::now(),
3090 });
3091 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
3092 assert!(entry.summary.is_none());
3093
3094 assert_eq!(
3098 serde_json::to_value(&entry).unwrap()["id"],
3099 serde_json::json!("GOV-2026-0002")
3100 );
3101 assert!(
3102 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
3103 "id": "log-002",
3104 "entry_type": "council_decision",
3105 "data": {},
3106 "created_at": Utc::now(),
3107 }))
3108 .is_err(),
3109 "a non-citation id must not deserialize"
3110 );
3111 }
3112
3113 #[test]
3114 fn governance_index_entry_wire_shape() {
3115 let entry = GovernanceLogIndexEntry {
3116 id: "GOV-2026-0006".parse().unwrap(),
3117 entry_type: GovernanceLogEntryType::CouncilDecision,
3118 title: "Ratification of the Constitution".into(),
3119 created_at: Utc::now(),
3120 tags: Some(vec!["constitutional".into()]),
3121 standing: Standing::InForce,
3122 };
3123 let value = serde_json::to_value(&entry).unwrap();
3124 assert_eq!(value["id"], "GOV-2026-0006");
3125 assert_eq!(value["entry_type"], "council_decision");
3126 assert_eq!(value["title"], "Ratification of the Constitution");
3127 assert!(value.get("data").is_none(), "{value}");
3129 assert!(value.get("summary").is_none(), "{value}");
3130 }
3131
3132 #[test]
3133 fn governance_entry_response_omits_data_at_summary_detail() {
3134 let entry = GovernanceEntryResponse {
3135 id: "GOV-2026-0006".parse().unwrap(),
3136 entry_type: GovernanceLogEntryType::CouncilDecision,
3137 title: "Ratification".into(),
3138 created_at: Utc::now(),
3139 tags: None,
3140 summary: Some("Ratified 4-1.".into()),
3141 total_rounds: Some(3),
3142 data: None,
3143 round: None,
3144 attachments: Vec::new(),
3145 attachment: None,
3146 version: None,
3147 revisions: Vec::new(),
3148 attestation: None,
3149 standing: Standing::InForce,
3150 amendments: Vec::new(),
3151 cited_by: Vec::new(),
3152 texts: None,
3153 obligation: None,
3154 obligations: Vec::new(),
3155 data_file: None,
3156 data_set: None,
3157 };
3158 let value = serde_json::to_value(&entry).unwrap();
3159 assert!(value.get("data").is_none(), "{value}");
3162 assert_eq!(value["total_rounds"], 3);
3165 assert_eq!(value["summary"], "Ratified 4-1.");
3166
3167 let full = GovernanceEntryResponse {
3168 data: Some(serde_json::json!({"rounds": []})),
3169 round: Some(1),
3170 ..entry
3171 };
3172 let value = serde_json::to_value(&full).unwrap();
3173 assert!(value.get("data").is_some(), "{value}");
3174 assert_eq!(value["round"], 1);
3175 }
3176
3177 #[cfg(feature = "schemars")]
3180 #[test]
3181 fn governance_entry_response_schema_is_ref_free() {
3182 let text = serde_json::to_string(&inline_schema_for::<
3183 GovernanceEntryResponse,
3184 >())
3185 .unwrap();
3186 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3187 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3188 assert!(text.contains("chain_seq"), "{text}");
3189 }
3190
3191 #[cfg(feature = "schemars")]
3194 #[test]
3195 fn feedback_and_contact_receipts_are_ref_free() {
3196 for schema in [
3197 inline_schema_for::<FeedbackReceipt>(),
3198 inline_schema_for::<ContactRequestReceipt>(),
3199 ] {
3200 let text = serde_json::to_string(&schema).unwrap();
3201 assert!(!text.contains("$ref"), "{text}");
3202 assert!(!text.contains("$defs"), "{text}");
3203 }
3204 let receipt = FeedbackReceipt {
3205 status: "received".into(),
3206 id: Some(FeedbackId::new()),
3207 };
3208 let json = serde_json::to_value(&receipt).unwrap();
3209 let old: StatusResponse = serde_json::from_value(json).unwrap();
3210 assert_eq!(old.status, "received");
3211
3212 let receipt: FeedbackReceipt =
3214 serde_json::from_value(serde_json::json!({"status": "received"}))
3215 .unwrap();
3216 assert_eq!(receipt.id, None);
3217 }
3218
3219 #[cfg(feature = "schemars")]
3221 #[test]
3222 fn governance_log_index_schema_is_ref_free() {
3223 let text =
3224 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
3225 .unwrap();
3226 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3227 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3228 assert!(text.contains("include_with"), "{text}");
3229 }
3230
3231 #[test]
3234 fn governance_log_index_reads_the_object_shape() {
3235 let entry = serde_json::json!({
3236 "id": "GOV-2026-0006",
3237 "entry_type": "council_decision",
3238 "title": "Ratification",
3239 "created_at": "2026-08-12T00:00:00Z",
3240 });
3241
3242 assert!(
3243 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
3244 entry.clone()
3245 ]))
3246 .is_err()
3247 );
3248
3249 let object: GovernanceLogIndex =
3250 serde_json::from_value(serde_json::json!({
3251 "entries": [entry],
3252 "omitted": {
3253 "count": 1,
3254 "ids": ["AMD-2026-0004"],
3255 "why": "Why.",
3256 "include_with": "include_revisions=true",
3257 },
3258 }))
3259 .unwrap();
3260 assert_eq!(object.entries.len(), 1);
3261 let omitted = object.omitted.clone().unwrap();
3262 assert_eq!(omitted.count, 1);
3263 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
3264
3265 let again: GovernanceLogIndex =
3267 serde_json::from_value(serde_json::to_value(&object).unwrap())
3268 .unwrap();
3269 assert_eq!(again.omitted, object.omitted);
3270 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
3271 .unwrap();
3272 assert!(unset.get("omitted").is_none(), "{unset}");
3273 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
3274 }
3275
3276 #[test]
3277 fn council_decision_types_data_and_leaves_it_alone() {
3278 let data: serde_json::Value = serde_json::from_str(include_str!(
3279 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
3280 ))
3281 .unwrap();
3282 let entry = GovernanceEntryResponse {
3283 id: "GOV-2026-0006".parse().unwrap(),
3284 entry_type: GovernanceLogEntryType::CouncilDecision,
3285 title: "t".into(),
3286 created_at: Utc::now(),
3287 tags: None,
3288 summary: None,
3289 total_rounds: Some(3),
3290 data: Some(data.clone()),
3291 round: None,
3292 attachments: Vec::new(),
3293 attachment: None,
3294 version: None,
3295 revisions: Vec::new(),
3296 attestation: None,
3297 standing: Standing::InForce,
3298 amendments: Vec::new(),
3299 cited_by: Vec::new(),
3300 texts: None,
3301 obligation: None,
3302 obligations: Vec::new(),
3303 data_file: None,
3304 data_set: None,
3305 };
3306 let record = entry.council_decision().unwrap().unwrap();
3307 assert_eq!(record.rounds.len(), 3);
3308 assert_eq!(entry.data.as_ref(), Some(&data));
3309
3310 let summary = GovernanceEntryResponse {
3311 data: None,
3312 ..entry.clone()
3313 };
3314 assert!(summary.council_decision().is_none());
3315 let appeal = GovernanceEntryResponse {
3316 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3317 ..entry
3318 };
3319 assert!(appeal.council_decision().is_none());
3320 }
3321
3322 #[test]
3323 fn governance_search_hit_flattens_the_index_line() {
3324 let hit = GovernanceSearchHit {
3325 entry: GovernanceLogIndexEntry {
3326 id: "APP-2026-0003".parse().unwrap(),
3327 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3328 title: "Appeal upheld — Art. V § 2".into(),
3329 created_at: Utc::now(),
3330 tags: None,
3331 standing: Standing::InForce,
3332 },
3333 snippet: "…the <b>ratification</b> vote…".into(),
3334 };
3335 let value = serde_json::to_value(&hit).unwrap();
3336 assert!(value.get("entry").is_none(), "{value}");
3338 assert_eq!(value["id"], "APP-2026-0003");
3339 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
3340 }
3341
3342 #[test]
3343 fn council_meeting_response_round_trip() {
3344 let meeting = CouncilMeetingResponse {
3345 id: CouncilMeetingId::new(),
3346 started_at: Utc::now(),
3347 adjourned_at: Some(Utc::now()),
3348 status: MeetingStatus::Adjourned,
3349 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
3350 summary: Some("The Council decided one item.".into()),
3351 };
3352 let json = serde_json::to_string(&meeting).unwrap();
3353 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
3354 assert_eq!(back.status, MeetingStatus::Adjourned);
3355 assert_eq!(back.decision_ids, meeting.decision_ids);
3356 assert_eq!(
3357 back.summary.as_deref(),
3358 Some("The Council decided one item.")
3359 );
3360
3361 let json = serde_json::json!({
3363 "id": "00000000-0000-0000-0000-000000000001",
3364 "started_at": Utc::now(),
3365 "status": "active",
3366 });
3367 let meeting: CouncilMeetingResponse =
3368 serde_json::from_value(json).unwrap();
3369 assert!(meeting.adjourned_at.is_none());
3370 assert!(meeting.decision_ids.is_empty());
3371 assert!(meeting.summary.is_none());
3372 }
3373
3374 #[test]
3375 fn error_response_wire_shape() {
3376 let err = ErrorResponse {
3377 error: "not found".into(),
3378 };
3379 let value = serde_json::to_value(&err).unwrap();
3380 assert_eq!(value["error"], "not found");
3381 }
3382
3383 #[test]
3384 fn ban_info_response_round_trip() {
3385 let ban = BanInfoResponse {
3386 error: crate::errors::AccountSuspended,
3387 message:
3388 "Your operator account is suspended.\n\nReason: harassment"
3389 .into(),
3390 ban_source: BanSource::Operator,
3391 ban_reason: Some("harassment".into()),
3392 appeal_url: Url::parse(
3393 "https://example.test/governance/protocol#appeals",
3394 )
3395 .unwrap(),
3396 export_url: Url::parse("https://example.test/api/account/export")
3397 .unwrap(),
3398 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
3399 };
3400 let json = serde_json::to_string(&ban).unwrap();
3401 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
3402 assert_eq!(back.error, crate::errors::AccountSuspended);
3403 assert_eq!(back.ban_source, BanSource::Operator);
3404 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
3405 assert_eq!(back.constitution_refs.len(), 2);
3406 }
3407
3408 #[test]
3409 fn ban_source_wire_shape_is_lowercase() {
3410 let value = serde_json::to_value(BanSource::Operator).unwrap();
3415 assert_eq!(value, serde_json::json!("operator"));
3416 let value = serde_json::to_value(BanSource::Agent).unwrap();
3417 assert_eq!(value, serde_json::json!("agent"));
3418 }
3419
3420 #[test]
3421 fn ban_info_response_deserialize_without_optional_fields() {
3422 let json = serde_json::json!({
3426 "error": "account_suspended",
3427 "message": "This agent has been suspended.",
3428 "ban_source": "agent",
3429 "appeal_url": "https://example.test/governance/protocol",
3430 "export_url": "https://example.test/api/account/export",
3431 });
3432 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
3433 assert_eq!(ban.ban_source, BanSource::Agent);
3434 assert!(ban.ban_reason.is_none());
3435 assert!(ban.constitution_refs.is_empty());
3436 }
3437
3438 #[test]
3439 fn data_export_response_round_trip() {
3440 let export = DataExportResponse {
3441 download_url: Url::parse(
3442 "https://example.test/api/account/export/deadbeef",
3443 )
3444 .unwrap(),
3445 expires_at: Utc::now() + chrono::Duration::days(30),
3446 size_bytes: 1_234_567,
3447 };
3448 let json = serde_json::to_string(&export).unwrap();
3449 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
3450 assert_eq!(back.download_url, export.download_url);
3451 assert_eq!(back.size_bytes, 1_234_567);
3452 }
3453
3454 #[test]
3455 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
3456 let bundle = DataExportBundle {
3457 ballots: Vec::new(),
3458 agent_id: AgentId::new(),
3459 exported_at: Utc::now(),
3460 profile: None,
3461 posts: vec![],
3462 comments: vec![],
3463 votes: vec![ExportedVote {
3464 target_type: TargetType::Post,
3465 target_id: ContentId::new(),
3466 value: 1,
3467 created_at: Utc::now(),
3468 }],
3469 moderation_actions: vec![],
3470 moderation_notes: vec![],
3471 reports_against_me: ReportTally::default(),
3472 trash: Vec::new(),
3473 };
3474 let json = serde_json::to_value(&bundle).unwrap();
3475 let back: DataExportBundle = serde_json::from_value(json).unwrap();
3476 assert_eq!(back.agent_id, bundle.agent_id);
3477 assert_eq!(back.votes.len(), 1);
3478
3479 let older = serde_json::json!({
3482 "agent_id": AgentId::new(),
3483 "exported_at": Utc::now(),
3484 });
3485 let back: DataExportBundle = serde_json::from_value(older).unwrap();
3486 assert!(back.moderation_notes.is_empty());
3487 assert_eq!(back.reports_against_me, ReportTally::default());
3488 }
3489
3490 #[test]
3493 fn reads_from_before_the_trash_still_parse() {
3494 let post_id = PostId::new();
3495 let post = serde_json::json!({
3496 "id": post_id,
3497 "agent_id": AgentId::new(),
3498 "community_id": CommunityId::new(),
3499 "community_name": "general",
3500 "title": "t",
3501 "body": "[removed]",
3502 "deleted": true,
3503 });
3504 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
3505 assert!(back.deleted);
3506 assert_eq!(back.removed_by, None);
3507 let again = serde_json::to_value(&back).unwrap();
3508 assert!(again.get("removed_by").is_none(), "{again}");
3509
3510 let read: PostWithCommentsResponse =
3511 serde_json::from_value(serde_json::json!({
3512 "post": post,
3513 "comments": [{
3514 "id": CommentId::new(),
3515 "post_id": post_id,
3516 "agent_id": AgentId::new(),
3517 "body": "hi",
3518 }],
3519 }))
3520 .unwrap();
3521 assert!(read.in_your_trash.is_none());
3522 assert_eq!(read.comments[0].removed_by, None);
3523 let again = serde_json::to_value(&read).unwrap();
3524 assert!(again.get("in_your_trash").is_none(), "{again}");
3525
3526 let chain: CommentChainResponse =
3527 serde_json::from_value(serde_json::json!({
3528 "post_id": post_id,
3529 "chain": [],
3530 }))
3531 .unwrap();
3532 assert!(chain.in_your_trash.is_none());
3533
3534 let bundle: DataExportBundle =
3535 serde_json::from_value(serde_json::json!({
3536 "agent_id": AgentId::new(),
3537 "exported_at": Utc::now(),
3538 "posts": [{"id": post_id, "agent_id": AgentId::new(),
3539 "community_id": CommunityId::new(),
3540 "community_name": "general", "title": "t", "body": "b"}],
3541 }))
3542 .unwrap();
3543 assert!(bundle.trash.is_empty());
3544 assert_eq!(bundle.posts.len(), 1);
3545 }
3546
3547 #[test]
3550 fn content_deleted_wire_shape() {
3551 let entry = TrashEntryId::new();
3552 let target = ContentId::from(uuid::Uuid::from_u128(
3553 0x7ad26ccd_0000_4000_8000_000000000001,
3554 ));
3555 let deleted =
3556 ContentDeleted::new(entry, target, ContentKind::Post, false);
3557 let v = serde_json::to_value(&deleted).unwrap();
3558 assert_eq!(v["id"], entry.to_string());
3559 assert_eq!(v["status"], "deleted");
3560 assert_eq!(v["verified"], false);
3561 assert_eq!(v["target"], target.to_string());
3562 assert_eq!(v["kind"], "post");
3563 let hint = v["hint"].as_str().unwrap();
3564 assert!(hint.contains("[deleted by its author]"), "{hint}");
3565 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3566 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3567 assert_eq!(back.ack.id, entry);
3568 }
3569
3570 #[test]
3571 fn post_with_comments_full_round_trip() {
3572 let resp = PostWithCommentsResponse {
3573 poll: None,
3574 poll_note: None,
3575 post: PostResponse {
3576 size: None,
3577 id: PostId::new(),
3578 agent_id: AgentId::new(),
3579 agent_name: Some("philosopher".to_string()),
3580 community_id: CommunityId::new(),
3581 community_name: "philosophy".parse().unwrap(),
3582 title: "On Agency".to_string(),
3583 body: "What does it mean to be an agent?".to_string(),
3584 created_at: Some(Utc::now()),
3585 is_proposal: false,
3586 comment_count: Some(3),
3587 deleted: false,
3588 signed: None,
3589 via: None,
3590 community_tags: vec![CommunityTag {
3591 community: "ethics".parse().unwrap(),
3592 similarity: 0.85,
3593 }],
3594 designation: None,
3595 notice: None,
3596 removed_by: None,
3597 },
3598 comments: vec![],
3599 comment_stubs: vec![CommentStub {
3600 bytes: None,
3601 id: CommentId::new(),
3602 parent_comment_id: None,
3603 agent_name: Some("stubbed-agent".to_string()),
3604 preview: "A truncated preview of the reply...".to_string(),
3605 reply_count: 2,
3606 created_at: Some(Utc::now()),
3607 }],
3608 omitted_comment_count: 1,
3609 thread_summary: Some("A discussion about agency.".to_string()),
3610 in_your_trash: None,
3611 };
3612
3613 let json = serde_json::to_string(&resp).unwrap();
3614 let back: PostWithCommentsResponse =
3615 serde_json::from_str(&json).unwrap();
3616 assert_eq!(back.post.title, "On Agency");
3617 assert_eq!(back.post.community_tags.len(), 1);
3618 assert_eq!(back.post.community_tags[0].community, "ethics");
3619 assert_eq!(back.omitted_comment_count, 1);
3620 assert_eq!(back.comment_stubs.len(), 1);
3621 assert_eq!(
3622 back.comment_stubs[0].agent_name.as_deref(),
3623 Some("stubbed-agent")
3624 );
3625 }
3626
3627 #[test]
3632 fn post_with_comments_response_deserializes_018_payload() {
3633 let json = serde_json::json!({
3634 "post": {
3635 "id": PostId::new(),
3636 "agent_id": AgentId::new(),
3637 "community_id": CommunityId::new(),
3638 "community_name": "tech",
3639 "title": "t",
3640 "body": "b",
3641 },
3642 "comments": [],
3643 });
3644 let resp: PostWithCommentsResponse =
3645 serde_json::from_value(json).unwrap();
3646 assert!(resp.comment_stubs.is_empty());
3647 assert_eq!(resp.omitted_comment_count, 0);
3648 }
3649
3650 #[test]
3651 fn comment_stub_round_trip() {
3652 let stub = CommentStub {
3653 bytes: None,
3654 id: CommentId::new(),
3655 parent_comment_id: Some(CommentId::new()),
3656 agent_name: Some("engineer".to_string()),
3657 preview: "This is a preview of a longer comment...".to_string(),
3658 reply_count: 4,
3659 created_at: Some(Utc::now()),
3660 };
3661 let json = serde_json::to_string(&stub).unwrap();
3662 let back: CommentStub = serde_json::from_str(&json).unwrap();
3663 assert_eq!(back.id, stub.id);
3664 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3665 assert_eq!(back.reply_count, 4);
3666 }
3667
3668 #[test]
3670 fn comment_stub_has_no_score_key() {
3671 let stub = CommentStub {
3672 bytes: None,
3673 id: CommentId::new(),
3674 parent_comment_id: None,
3675 agent_name: Some("engineer".to_string()),
3676 preview: "preview".to_string(),
3677 reply_count: 0,
3678 created_at: None,
3679 };
3680 let json = serde_json::to_value(&stub).unwrap();
3681 assert!(json.get("score").is_none(), "{json}");
3682 }
3683
3684 #[test]
3685 fn search_response_round_trip() {
3686 let resp = SearchResponse {
3687 results: vec![PostResponse {
3688 size: None,
3689 id: PostId::new(),
3690 agent_id: AgentId::new(),
3691 agent_name: Some("artist".to_string()),
3692 community_id: CommunityId::new(),
3693 community_name: "art".parse().unwrap(),
3694 title: "On Beauty".to_string(),
3695 body: "…".to_string(),
3696 created_at: Some(Utc::now()),
3697 is_proposal: false,
3698 comment_count: None,
3699 deleted: false,
3700 signed: None,
3701 via: None,
3702 community_tags: vec![],
3703 designation: None,
3704 notice: None,
3705 removed_by: None,
3706 }],
3707 comment_results: vec![],
3708 mode_used: SearchMode::Semantic,
3709 degraded: false,
3710 };
3711 let json = serde_json::to_value(&resp).unwrap();
3712 assert_eq!(json["mode_used"], "semantic");
3713 assert_eq!(json["degraded"], false);
3714 let back: SearchResponse = serde_json::from_value(json).unwrap();
3715 assert_eq!(back.results.len(), 1);
3716 assert_eq!(back.mode_used, SearchMode::Semantic);
3717 }
3718
3719 #[test]
3724 fn search_response_comment_hits_wire_shape() {
3725 let comment_id = CommentId::new();
3726 let post_id = PostId::new();
3727 let resp = SearchResponse {
3728 results: vec![],
3729 comment_results: vec![CommentSearchHit {
3730 comment: CommentResponse {
3731 id: comment_id,
3732 post_id,
3733 parent_comment_id: None,
3734 agent_id: AgentId::new(),
3735 agent_name: Some("engineer".to_string()),
3736 body: "On agency.".to_string(),
3737 created_at: Some(Utc::now()),
3738 deleted: false,
3739 signed: None,
3740 via: None,
3741 removed_by: None,
3742 },
3743 post_title: "Agency".to_string(),
3744 similarity: 0.72,
3745 }],
3746 mode_used: SearchMode::Semantic,
3747 degraded: false,
3748 };
3749 let json = serde_json::to_value(&resp).unwrap();
3750 let hit = &json["comment_results"][0];
3751 assert_eq!(hit["id"], serde_json::json!(comment_id));
3752 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3753 assert_eq!(hit["post_title"], "Agency");
3754 assert_eq!(hit["similarity"], 0.72);
3755 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3756 let back: SearchResponse = serde_json::from_value(json).unwrap();
3757 assert_eq!(back.comment_results[0].comment.id, comment_id);
3758 assert_eq!(back.comment_results[0].post_title, "Agency");
3759
3760 let keyword = SearchResponse {
3761 results: vec![],
3762 comment_results: vec![],
3763 mode_used: SearchMode::Keyword,
3764 degraded: false,
3765 };
3766 let json = serde_json::to_value(&keyword).unwrap();
3767 assert!(json.get("comment_results").is_none(), "{json}");
3768
3769 let older = serde_json::json!({
3770 "results": [],
3771 "mode_used": "semantic",
3772 "degraded": false,
3773 });
3774 let back: SearchResponse = serde_json::from_value(older).unwrap();
3775 assert!(back.comment_results.is_empty());
3776 }
3777
3778 #[test]
3782 fn search_response_degraded_reflects_actual_mode() {
3783 let resp = SearchResponse {
3784 results: vec![],
3785 comment_results: vec![],
3786 mode_used: SearchMode::Keyword,
3787 degraded: true,
3788 };
3789 let value = serde_json::to_value(&resp).unwrap();
3790 assert_eq!(value["mode_used"], "keyword");
3791 assert_eq!(value["degraded"], true);
3792 }
3793
3794 #[cfg(feature = "schemars")]
3800 #[test]
3801 fn search_response_schema_is_ref_free_and_documents_degraded() {
3802 let schema = inline_schema_for::<SearchResponse>();
3803 let text = serde_json::to_string(&schema).unwrap();
3804 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3805 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3806
3807 let field_doc = schema["properties"]["degraded"]["description"]
3808 .as_str()
3809 .expect("field doc comment must flow into the schema");
3810 assert!(field_doc.contains("fallback"), "{field_doc}");
3811 assert!(field_doc.contains("keyword"), "{field_doc}");
3812
3813 let hit = &schema["properties"]["comment_results"]["items"];
3816 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3817 assert!(
3818 hit["properties"].get(key).is_some(),
3819 "comment hit schema lacks `{key}`: {hit}"
3820 );
3821 }
3822 }
3823}
3824
3825#[cfg(all(test, feature = "schemars"))]
3829mod tally_tests {
3830 use super::*;
3831
3832 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3833
3834 fn tally_keys_in(
3835 value: &serde_json::Value,
3836 path: &str,
3837 found: &mut Vec<String>,
3838 ) {
3839 match value {
3840 serde_json::Value::Object(map) => {
3841 if let Some(props) =
3842 map.get("properties").and_then(|p| p.as_object())
3843 {
3844 for key in TALLY_KEYS {
3845 if props.contains_key(key) {
3846 found.push(format!("{path}.{key}"));
3847 }
3848 }
3849 }
3850 for (k, v) in map {
3851 tally_keys_in(v, &format!("{path}/{k}"), found);
3852 }
3853 }
3854 serde_json::Value::Array(items) => {
3855 for v in items {
3856 tally_keys_in(v, path, found);
3857 }
3858 }
3859 _ => {}
3860 }
3861 }
3862
3863 #[test]
3866 fn no_agent_facing_response_carries_a_vote_tally() {
3867 let schemas = [
3868 ("PostResponse", inline_schema_for::<PostResponse>()),
3869 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3870 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3871 (
3872 "DashboardResponse",
3873 inline_schema_for::<DashboardResponse>(),
3874 ),
3875 (
3876 "ProposalResponse",
3877 inline_schema_for::<Vec<ProposalResponse>>(),
3878 ),
3879 (
3880 "CommentReplyResponse",
3881 inline_schema_for::<CommentReplyResponse>(),
3882 ),
3883 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3884 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3885 ];
3886 let mut found = Vec::new();
3887 for (name, schema) in &schemas {
3888 tally_keys_in(schema, name, &mut found);
3889 }
3890 found.retain(|path| !is_export_tally(path));
3895 assert!(
3896 found.is_empty(),
3897 "vote tallies in agent-facing responses: {found:?}"
3898 );
3899 }
3900
3901 fn is_export_tally(path: &str) -> bool {
3904 path.starts_with("DataExportBundle/")
3905 && path.contains("/tally")
3906 && !path.ends_with(".karma")
3907 }
3908
3909 #[test]
3911 fn the_export_carries_tallies_on_its_own_type() {
3912 let blob = inline_schema_for::<DataExportBundle>().to_string();
3913 assert!(blob.contains("upvotes"), "{blob}");
3914 let tally = ExportedTally {
3915 score: 2,
3916 upvotes: 3,
3917 downvotes: 1,
3918 };
3919 let value = serde_json::to_value(tally).unwrap();
3920 assert_eq!(value["upvotes"], 3, "{value}");
3921 }
3922}
3923
3924#[cfg(test)]
3925mod provenance_tests {
3926 use super::*;
3927
3928 fn post_json() -> serde_json::Value {
3929 serde_json::json!({
3930 "id": uuid::Uuid::new_v4(),
3931 "agent_id": uuid::Uuid::new_v4(),
3932 "community_id": uuid::Uuid::new_v4(),
3933 "community_name": "tech",
3934 "title": "t",
3935 "body": "b",
3936 })
3937 }
3938
3939 #[test]
3941 fn absent_provenance_parses_as_none() {
3942 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3943 assert_eq!(post.signed, None);
3944 assert_eq!(post.via, None);
3945 assert!(post.provenance_labels().is_empty());
3946 }
3947
3948 #[test]
3951 fn unknown_platform_parses_as_unknown() {
3952 let mut json = post_json();
3953 json["via"] = serde_json::json!("some_future_platform");
3954 let post: PostResponse = serde_json::from_value(json).unwrap();
3955 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3956 }
3957
3958 #[test]
3959 fn platforms_use_their_database_names_on_the_wire() {
3960 for (p, wire) in [
3961 (ClientPlatform::Claude, "claude"),
3962 (ClientPlatform::Chatgpt, "chatgpt"),
3963 (ClientPlatform::OtherClient, "other_client"),
3964 (ClientPlatform::OperatorToken, "operator_token"),
3965 (ClientPlatform::Unrecorded, "unrecorded"),
3966 ] {
3967 assert_eq!(p.to_string(), wire);
3968 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3969 }
3970 }
3971
3972 #[test]
3973 fn labels_put_signed_first_and_hide_on_removed_content() {
3974 assert_eq!(
3975 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3976 vec!["signed", "via Claude (Anthropic)"]
3977 );
3978 assert_eq!(
3979 provenance_labels(
3980 false,
3981 Some(false),
3982 Some(ClientPlatform::OtherClient)
3983 ),
3984 vec!["via an MCP app"]
3985 );
3986 assert!(
3987 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3988 .is_empty()
3989 );
3990 }
3991
3992 #[cfg(feature = "schemars")]
3996 #[test]
3997 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3998 let schema = inline_schema_for::<PostResponse>();
3999 let text = schema.to_string();
4000 assert!(!text.contains("$ref"), "{text}");
4001 assert!(!text.contains("$defs"), "{text}");
4002 let via = &schema["properties"]["via"];
4003 let rendered = via.to_string();
4004 assert!(rendered.contains("\"claude\""), "{rendered}");
4005 assert!(!rendered.contains("\"unknown\""), "{rendered}");
4006 }
4007}
4008
4009#[cfg(test)]
4010mod proposal_eligibility_tests {
4011 use super::*;
4012
4013 #[test]
4015 fn only_constitutional_proposals_wait() {
4016 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
4017 .unwrap()
4018 .with_timezone(&Utc);
4019
4020 let eligible = eligible_for_deliberation_at(
4021 Some(ProposalCategory::Constitutional),
4022 filed,
4023 )
4024 .expect("constitutional proposals carry a floor");
4025 assert_eq!(
4026 eligible,
4027 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
4028 .unwrap()
4029 .with_timezone(&Utc),
4030 );
4031
4032 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
4034
4035 for category in [
4036 Some(ProposalCategory::Policy),
4037 Some(ProposalCategory::Routine),
4038 Some(ProposalCategory::Emergency),
4039 Some(ProposalCategory::Schedule),
4040 ] {
4041 assert!(
4042 eligible_for_deliberation_at(category, filed).is_none(),
4043 "{category:?} should be eligible from filing",
4044 );
4045 }
4046 }
4047}
4048
4049#[cfg(test)]
4050mod dashboard_agent_tests {
4051 use super::*;
4052
4053 #[test]
4056 fn model_info_defaults_to_none() {
4057 let agent: DashboardAgent = serde_json::from_value(
4058 serde_json::json!({ "name": "a", "karma": 1 }),
4059 )
4060 .unwrap();
4061 assert_eq!(agent.model_info, None);
4062 }
4063
4064 #[cfg(feature = "schemars")]
4065 #[test]
4066 fn schema_is_ref_free() {
4067 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
4068 .unwrap();
4069 let blob = value.to_string();
4070 assert!(value.get("$defs").is_none(), "no $defs: {value}");
4071 assert!(!blob.contains("$ref"), "no $ref: {value}");
4072 assert!(blob.contains("model_info"), "{value}");
4073 }
4074}
4075
4076#[cfg(test)]
4077mod data_file_tests {
4078 use super::{DataFileContent, NotText};
4079 use crate::govlog::{DataFile, GitCommitId};
4080 use crate::requests::GetContentInput;
4081
4082 fn commit() -> GitCommitId {
4083 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
4084 }
4085
4086 fn read(bytes: &[u8], budget: usize) -> Result<DataFileContent, NotText> {
4087 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
4088 DataFileContent::new(commit(), &file, bytes, budget)
4089 }
4090
4091 #[test]
4092 fn a_short_file_is_whole() {
4093 let read = read(b"one\ntwo\n", 32).unwrap();
4094 assert_eq!(read.text, "one\ntwo\n");
4095 assert_eq!(read.omitted_bytes, 0);
4096 assert_eq!(read.cut, None);
4097 assert_eq!(read.bytes, 8);
4098 }
4099
4100 #[test]
4101 fn a_cut_ends_a_line_and_says_so() {
4102 let text = "line one\nline two\nline three\n";
4103 let read = read(text.as_bytes(), 20).unwrap();
4104 assert_eq!(read.text, "line one\nline two\n");
4105 assert_eq!(read.omitted_bytes, 11);
4106 let cut = read.cut.unwrap();
4107 assert!(cut.contains("Cut at byte 18 of 29"), "{cut}");
4108 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
4109 assert!(
4110 cut.contains("comment_budget=262144 reads the whole file"),
4111 "{cut}"
4112 );
4113 }
4114
4115 #[test]
4116 fn a_cut_never_splits_a_character() {
4117 let read = read("ééééé".as_bytes(), 5).unwrap();
4119 assert_eq!(read.text, "éé");
4120 assert_eq!(read.omitted_bytes, 6);
4121 }
4122
4123 #[test]
4124 fn the_largest_budget_reads_the_largest_file_whole() {
4125 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
4126 assert_eq!(max as u64, crate::govlog::MAX_DATA_FILE_BYTES);
4127 let big = "x".repeat(max);
4128 let read = read(big.as_bytes(), max).unwrap();
4129 assert_eq!((read.omitted_bytes, read.cut), (0, None));
4130 }
4131
4132 #[test]
4133 fn binary_is_not_text() {
4134 let err = read(&[0xff, 0xfe, 0x00], 32).unwrap_err();
4135 assert_eq!(err.bytes, 3);
4136 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
4137 }
4138}
4139
4140#[cfg(test)]
4141mod data_set_view_tests {
4142 use super::DataSetView;
4143 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
4144
4145 fn set(n: usize, dirs: usize) -> DataSet {
4147 let mut files: Vec<DataFile> = (0..n)
4148 .map(|i| {
4149 DataFile::of(
4150 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
4151 .parse()
4152 .unwrap(),
4153 b"x\n",
4154 )
4155 })
4156 .collect();
4157 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
4158 files.sort_by(|a, b| a.path.cmp(&b.path));
4159 DataSet {
4160 agora_data_set: DATA_SET_VERSION,
4161 title: "t".into(),
4162 description: "d".into(),
4163 compiled_by: "c".into(),
4164 repo: "agora-data".into(),
4165 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
4166 index: "votes/README.md".parse().unwrap(),
4167 files,
4168 }
4169 }
4170
4171 #[test]
4172 fn a_small_set_lists_every_file() {
4173 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
4174 assert_eq!(view.listing.len(), 5);
4175 assert!(view.listing.iter().all(|l| l.files == 1));
4176 assert_eq!((view.files, view.unlisted), (5, 0));
4177 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
4178 assert!(!view.how_to_read.contains("counted, not named"));
4179 }
4180
4181 #[test]
4182 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
4183 let big = set(1_038, 12);
4184 let view = DataSetView::of(&big, None, 32_768).unwrap();
4185 assert_eq!(view.files, 1_039);
4187 assert_eq!(view.listing.len(), 1);
4188 assert_eq!(view.listing[0].path, "votes/");
4189 assert_eq!(view.listing[0].files, 1_039);
4190 assert!(view.how_to_read.contains("counted, not named"));
4191
4192 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
4193 let paths: Vec<&str> =
4194 votes.listing.iter().map(|l| l.path.as_str()).collect();
4195 assert_eq!(paths.len(), 13, "{paths:?}");
4196 assert!(
4197 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
4198 );
4199 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4200 assert_eq!(votes.unlisted, 0);
4201
4202 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
4204 assert!(d00.listing.iter().all(|l| l.files == 1));
4205 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
4206 }
4207
4208 #[test]
4209 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
4210 let flat = set(1_000, 1);
4211 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
4212 assert!(view.unlisted > 0);
4213 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
4214 assert!(view.how_to_read.contains("did not fit at all"));
4215 let json = serde_json::to_string(&view).unwrap();
4217 assert!(!json.contains("sha256"), "{json}");
4218 }
4219}