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