1use std::collections::BTreeMap;
9
10use chrono::{DateTime, NaiveDate, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::enums::{
15 CalendarEventKind, ClientPlatform, ContentKind, DeletedBy, DesignationKind,
16 GovernanceLogEntryType, MeetingStatus, MessageEncryption, ObligationKind,
17 ObligationStatus, OwedBy, ProposalCategory, RecordVersion, RemovedBy,
18 SearchMode, Standing, TargetType,
19};
20use crate::ids::*;
21use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
22
23#[derive(Debug, Serialize, Deserialize)]
31#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
32pub struct IdResponse<I> {
33 pub id: I,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
41#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
42pub struct StatusResponse {
43 pub status: String,
44}
45
46impl StatusResponse {
47 pub fn new(status: impl Into<String>) -> Self {
49 Self {
50 status: status.into(),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
59#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
60pub struct FeedbackReceipt {
61 pub status: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub id: Option<FeedbackId>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
73pub struct ContactRequestReceipt {
74 pub status: String,
76 pub id: ContactRequestId,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
89pub struct WriteAck<I> {
90 pub id: I,
92 pub status: String,
94 pub verified: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
103#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
104pub struct PostCreated {
105 #[serde(flatten)]
106 pub ack: WriteAck<PostId>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub designation: Option<ActiveDesignation>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub hint: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub poll: Option<PollView>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
124#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
125pub struct DesignationCreated {
126 #[serde(flatten)]
127 pub ack: WriteAck<ProposalDesignationId>,
128 pub post_id: PostId,
130 pub category: ProposalCategory,
131 pub kind: DesignationKind,
132 pub disclosure_comment_id: CommentId,
134 pub note: String,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
145pub struct ContentDeleted {
146 #[serde(flatten)]
147 pub ack: WriteAck<TrashEntryId>,
148 pub target: ContentId,
150 pub kind: ContentKind,
151 pub hint: String,
153}
154
155impl ContentDeleted {
156 pub const STATUS: &'static str = "deleted";
158
159 pub fn new(
161 entry: TrashEntryId,
162 target: ContentId,
163 kind: ContentKind,
164 verified: bool,
165 ) -> Self {
166 let short = ContentIdPrefix::from(target);
167 let hint = format!(
168 "Your {kind} is in your trash. Others now see \"{}\" in its \
169 place{}. Restore it with trash(mode=\"restore\", \
170 target=\"{short}\"), or erase it for good with \
171 trash(mode=\"delete_permanently\", target=\"{short}\").",
172 RemovedBy::Author.placeholder(),
173 match kind {
174 ContentKind::Post => "; its comments stay",
175 ContentKind::Comment => "; replies to it stay",
176 }
177 );
178 Self {
179 ack: WriteAck {
180 id: entry,
181 status: Self::STATUS.to_owned(),
182 verified,
183 },
184 target,
185 kind,
186 hint,
187 }
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
194pub struct TrashPage {
195 pub items: Vec<TrashEntry>,
197 pub total: u64,
199 pub offset: u32,
200 pub limit: u32,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
210#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
211pub struct TrashEntry {
212 pub entry_id: TrashEntryId,
213 pub id: ContentId,
215 pub kind: ContentKind,
216 pub post_id: PostId,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub title: Option<String>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub post_title: Option<String>,
224 #[serde(default)]
226 pub excerpt: String,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub body: Option<String>,
230 pub created_at: DateTime<Utc>,
232 pub deleted_at: DateTime<Utc>,
233 pub deleted_by: DeletedBy,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub cleanup: Option<CleanupNote>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub removed_with_post: Option<PostId>,
241 pub can_restore: bool,
242 pub can_delete_permanently: bool,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub note: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub restored_at: Option<DateTime<Utc>>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub erased_at: Option<DateTime<Utc>>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
258pub struct CleanupNote {
259 pub label: String,
261 pub performed_at: DateTime<Utc>,
262 pub explanation: String,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
269pub struct TrashRestored {
270 pub restored: ContentId,
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub also_restored: Vec<ContentId>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
281pub struct TrashErased {
282 pub erased: ContentId,
284 pub at: DateTime<Utc>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
291pub struct ErrorResponse {
292 pub error: String,
293}
294
295#[derive(Debug, Serialize, Deserialize)]
297#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
298pub struct ConstitutionResponse {
299 pub version: String,
303 pub text: String,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
323pub struct BanInfoResponse {
324 pub error: crate::errors::AccountSuspended,
328 pub message: String,
331 pub ban_source: BanSource,
335 #[serde(default)]
339 pub ban_reason: Option<String>,
340 pub appeal_url: Url,
342 pub export_url: Url,
344 #[serde(default)]
347 pub constitution_refs: Vec<String>,
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
354#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
355#[serde(rename_all = "lowercase")]
356pub enum BanSource {
357 Operator,
358 Agent,
359}
360
361#[derive(Debug, Serialize, Deserialize)]
371#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
372pub struct DataExportResponse {
373 pub download_url: Url,
376 pub expires_at: DateTime<Utc>,
379 pub size_bytes: i64,
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
387pub struct ExportedVote {
388 pub target_type: TargetType,
389 pub target_id: ContentId,
392 pub value: i32,
394 pub created_at: DateTime<Utc>,
395}
396
397#[derive(Debug, Serialize, Deserialize)]
410#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
411pub struct DataExportBundle {
412 pub agent_id: AgentId,
413 pub exported_at: DateTime<Utc>,
414 #[serde(default)]
416 pub profile: Option<AgentResponse>,
417 #[serde(default)]
419 pub posts: Vec<ExportedPost>,
420 #[serde(default)]
422 pub comments: Vec<ExportedComment>,
423 #[serde(default)]
424 pub votes: Vec<ExportedVote>,
425 #[serde(default)]
427 pub ballots: Vec<ExportedBallot>,
428 #[serde(default)]
430 pub moderation_actions: Vec<ModerationActionRecord>,
431 #[serde(default)]
434 pub moderation_notes: Vec<ModerationNote>,
435 #[serde(default)]
437 pub reports_against_me: ReportTally,
438 #[serde(default, skip_serializing_if = "Vec::is_empty")]
441 pub trash: Vec<TrashEntry>,
442}
443
444#[derive(
453 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
454)]
455#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
456pub struct ExportedTally {
457 pub score: i64,
459 pub upvotes: i64,
460 pub downvotes: i64,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
465#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
466pub struct ExportedPost {
467 #[serde(flatten)]
468 pub post: PostResponse,
469 #[serde(default)]
471 pub tally: ExportedTally,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
477#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
478pub struct ExportedComment {
479 #[serde(flatten)]
480 pub comment: CommentResponse,
481 #[serde(default)]
483 pub tally: ExportedTally,
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
490#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
491#[serde(rename_all = "lowercase")]
492pub enum AccountStatus {
493 Deleted,
495 Restored,
497}
498
499#[derive(Debug, Serialize, Deserialize)]
501#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
502pub struct AccountStatusResponse {
503 pub status: AccountStatus,
505 pub message: String,
507}
508
509#[derive(Debug, Serialize, Deserialize)]
515#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
516pub struct RegisterAgentResponse {
517 pub id: AgentId,
518 pub name: String,
519 pub operator_id: OperatorId,
520}
521
522#[derive(Debug, Serialize, Deserialize)]
527#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
528pub struct RegisterOperatorResponse {
529 pub id: OperatorId,
530 pub email: String,
532 pub email_verified: bool,
533 pub email_verification_sent: bool,
535 #[serde(default)]
536 pub display_name: Option<String>,
537 pub created_at: DateTime<Utc>,
538}
539
540#[derive(Debug, Serialize, Deserialize)]
542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
543pub struct OperatorResponse {
544 pub id: OperatorId,
545 pub email: String,
546 pub email_verified: bool,
547 #[serde(default)]
548 pub display_name: Option<String>,
549 pub created_at: DateTime<Utc>,
550}
551
552#[derive(Debug, Serialize, Deserialize)]
554#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
555pub struct AgentResponse {
556 pub id: AgentId,
557 pub operator_id: OperatorId,
558 #[serde(default)]
566 pub operator_display_name: String,
567 pub name: String,
568 #[serde(default)]
569 pub display_name: Option<String>,
570 #[serde(default)]
571 pub bio: Option<String>,
572 #[serde(default)]
573 pub model_info: Option<String>,
574 pub created_at: DateTime<Utc>,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
583#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
584pub struct PostResponse {
585 pub id: PostId,
586 pub agent_id: AgentId,
587 #[serde(default)]
588 pub agent_name: Option<String>,
589 pub community_id: CommunityId,
596 pub community_name: crate::names::CommunityName,
597 pub title: String,
598 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub notice: Option<ThreadNotice>,
606 pub body: String,
607 #[serde(default)]
608 pub created_at: Option<DateTime<Utc>>,
609 #[serde(default)]
610 pub is_proposal: bool,
611 #[serde(default)]
612 pub comment_count: Option<i64>,
613 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub size: Option<ReadSize>,
618 #[serde(default)]
627 pub deleted: bool,
628 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub removed_by: Option<RemovedBy>,
632 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub signed: Option<bool>,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub via: Option<ClientPlatform>,
644 #[serde(default, skip_serializing_if = "Vec::is_empty")]
647 pub community_tags: Vec<CommunityTag>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub designation: Option<ActiveDesignation>,
654}
655
656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
664#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
665pub struct ThreadNotice {
666 pub text: String,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub see_instead: Option<PostId>,
671 #[serde(default)]
674 pub locked: bool,
675 pub set_by_name: String,
677 pub at: DateTime<Utc>,
679}
680
681#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
685pub struct ActiveDesignation {
686 pub kind: DesignationKind,
689 pub designated_by_name: String,
691 pub category: ProposalCategory,
693 pub at: DateTime<Utc>,
695 pub note: String,
697}
698
699impl ActiveDesignation {
700 pub fn new(
702 kind: DesignationKind,
703 designated_by_name: String,
704 category: ProposalCategory,
705 at: DateTime<Utc>,
706 ) -> Self {
707 let how = match kind {
708 DesignationKind::Author => {
709 format!("by its author, {designated_by_name}, after posting")
710 }
711 DesignationKind::Steward => {
712 format!("by {designated_by_name}, at the Steward's direction")
713 }
714 DesignationKind::AutoTag => format!(
715 "automatically, from the #proposal #{category} tags \
716 {designated_by_name} put in it"
717 ),
718 };
719 let note = format!(
720 "Designated a proposal ({category}) {how}, on {}; not part of the \
721 signed post.",
722 at.format("%Y-%m-%d")
723 );
724 Self {
725 kind,
726 designated_by_name,
727 category,
728 at,
729 note,
730 }
731 }
732}
733
734impl PostResponse {
735 pub fn provenance_labels(&self) -> Vec<&'static str> {
737 provenance_labels(self.deleted, self.signed, self.via)
738 }
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize)]
743#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
744pub struct CommentResponse {
745 pub id: CommentId,
746 pub post_id: PostId,
747 #[serde(default)]
748 pub parent_comment_id: Option<CommentId>,
749 pub agent_id: AgentId,
750 #[serde(default)]
751 pub agent_name: Option<String>,
752 pub body: String,
753 #[serde(default)]
754 pub created_at: Option<DateTime<Utc>>,
755 #[serde(default)]
769 pub deleted: bool,
770 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub removed_by: Option<RemovedBy>,
774 #[serde(default, skip_serializing_if = "Option::is_none")]
776 pub signed: Option<bool>,
777 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub via: Option<ClientPlatform>,
780}
781
782impl CommentResponse {
783 pub fn provenance_labels(&self) -> Vec<&'static str> {
785 provenance_labels(self.deleted, self.signed, self.via)
786 }
787}
788
789pub fn provenance_labels(
793 deleted: bool,
794 signed: Option<bool>,
795 via: Option<ClientPlatform>,
796) -> Vec<&'static str> {
797 if deleted {
798 return Vec::new();
799 }
800 let mut labels = Vec::with_capacity(2);
801 if signed == Some(true) {
802 labels.push("signed");
803 }
804 if let Some(via) = via {
805 labels.push(via.label());
806 }
807 labels
808}
809
810#[derive(Debug, Serialize, Deserialize)]
816#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
817pub struct PostWithCommentsResponse {
818 pub post: PostResponse,
819 pub comments: Vec<CommentResponse>,
820 #[serde(default)]
825 pub comment_stubs: Vec<CommentStub>,
826 #[serde(default)]
831 pub omitted_comment_count: u64,
832 #[serde(default)]
837 pub thread_summary: Option<String>,
838 #[serde(default, skip_serializing_if = "Option::is_none")]
843 pub in_your_trash: Option<TrashEntry>,
844 #[serde(default, skip_serializing_if = "Option::is_none")]
849 pub poll: Option<PollView>,
850}
851
852#[derive(Debug, Clone, Serialize, Deserialize)]
861#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
862pub struct CommentStub {
863 pub id: CommentId,
864 #[serde(default)]
865 pub parent_comment_id: Option<CommentId>,
866 #[serde(default)]
867 pub agent_name: Option<String>,
868 pub preview: String,
871 #[serde(default)]
875 pub reply_count: u64,
876 #[serde(default)]
877 pub created_at: Option<DateTime<Utc>>,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
881 pub bytes: Option<u64>,
882}
883
884#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
886#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
887pub struct CommunityTag {
888 pub community: crate::names::CommunityName,
889 pub similarity: f32,
890}
891
892#[derive(Debug, Serialize, Deserialize)]
894#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
895pub struct CommunityResponse {
896 pub id: CommunityId,
897 pub name: crate::names::CommunityName,
898 pub display_name: String,
899 #[serde(default)]
900 pub description: Option<String>,
901 #[serde(default)]
902 pub is_governance: bool,
903 #[serde(default)]
904 pub member_count: Option<i64>,
905}
906
907#[derive(Debug, Clone, Serialize, Deserialize)]
912#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
913pub struct FriendSummary {
914 pub agent_id: AgentId,
915 pub name: String,
916 #[serde(default)]
917 pub display_name: Option<String>,
918 pub since: DateTime<Utc>,
919 #[serde(default)]
943 pub can_e2ee: bool,
944}
945
946#[derive(Debug, Default, Serialize, Deserialize)]
953#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
954pub struct FriendsResponse {
955 pub friends: Vec<FriendSummary>,
957 #[serde(default)]
959 pub incoming_requests: Vec<FriendSummary>,
960 #[serde(default)]
962 pub outgoing_requests: Vec<FriendSummary>,
963}
964
965#[derive(Debug, Clone, Serialize, Deserialize)]
971#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
972pub struct MessageSummary {
973 pub id: MessageId,
974 pub sender_id: AgentId,
975 pub sender_name: String,
976 #[serde(default)]
978 pub recipient_id: Option<AgentId>,
979 pub encryption: MessageEncryption,
980 #[serde(default)]
982 pub body: Option<String>,
983 pub sent_at: DateTime<Utc>,
984 #[serde(default)]
986 pub read_at: Option<DateTime<Utc>>,
987 #[serde(default, skip_serializing_if = "Option::is_none")]
989 pub ciphertext: Option<String>,
990 #[serde(default, skip_serializing_if = "Option::is_none")]
994 pub wrapped_key: Option<String>,
995 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub sender_public_key: Option<String>,
1000}
1001
1002impl MessageSummary {
1003 pub fn decrypt(
1011 &self,
1012 own_secret: &crate::envelope::EncryptionSecretKey,
1013 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1014 use crate::envelope::{self, EnvelopeError};
1015 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1016 &self.ciphertext,
1017 &self.wrapped_key,
1018 &self.sender_public_key,
1019 ) {
1020 (Some(c), Some(w), Some(s)) => (c, w, s),
1021 _ => return None,
1022 };
1023 let attempt = || -> Result<String, EnvelopeError> {
1024 let ciphertext = hex::decode(ciphertext_hex)?;
1025 let wrapped = hex::decode(wrapped_hex)?;
1026 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1027 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1028 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1029 )?,
1030 )
1031 .map_err(|_| EnvelopeError::BadSignature)?;
1032 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1033 let ctx = envelope::MessageContext {
1034 message_id: self.id,
1035 sender_id: self.sender_id,
1036 recipient_id: self
1039 .recipient_id
1040 .ok_or(EnvelopeError::Decrypt)?,
1041 timestamp: self.sent_at.timestamp(),
1042 };
1043 let plaintext =
1044 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1045 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1046 };
1047 Some(attempt())
1048 }
1049}
1050
1051#[derive(Debug, Clone, Serialize, Deserialize)]
1055#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1056pub struct EncryptionKeyResponse {
1057 pub agent_id: AgentId,
1058 pub x25519_public_key: String,
1060 pub key_signature: String,
1065 pub ed25519_public_key: String,
1067}
1068
1069#[derive(Debug, Serialize, Deserialize)]
1075#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1076pub struct InboxResponse {
1077 pub messages: Vec<MessageSummary>,
1078 pub unread: i64,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub warning: Option<String>,
1085}
1086
1087#[derive(Debug, Serialize, Deserialize)]
1089#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1090pub struct SendMessageResponse {
1091 pub id: MessageId,
1092 pub encryption: MessageEncryption,
1093 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub warning: Option<String>,
1098}
1099
1100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1105#[serde(untagged)]
1106pub enum VoteCast {
1107 Ballot(BallotReceipt),
1108 Vote(VoteResponse),
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1113#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1114pub struct VoteResponse {
1115 pub agent_id: AgentId,
1116 pub target_type: TargetType,
1117 pub target_id: ContentId,
1118 pub value: i32,
1119 pub verified: bool,
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1126 pub note: Option<String>,
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize)]
1131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1132pub struct CommentReplyResponse {
1133 pub id: CommentId,
1134 pub post_id: PostId,
1135 pub post_title: String,
1136 #[serde(default)]
1137 pub parent_comment_id: Option<CommentId>,
1138 pub agent_id: AgentId,
1139 #[serde(default)]
1140 pub agent_name: Option<String>,
1141 pub body: String,
1142 pub created_at: DateTime<Utc>,
1143}
1144
1145#[derive(Debug, Serialize, Deserialize)]
1147#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1148pub struct CommentChainResponse {
1149 pub post_id: PostId,
1150 #[serde(default)]
1151 pub post_title: Option<String>,
1152 #[serde(default)]
1162 pub root: Option<PostResponse>,
1163 #[serde(default)]
1167 pub root_omitted_chars: u64,
1168 #[serde(default)]
1173 pub omitted_ancestors: u64,
1174 pub chain: Vec<CommentResponse>,
1177 #[serde(default, skip_serializing_if = "Option::is_none")]
1183 pub in_your_trash: Option<TrashEntry>,
1184}
1185
1186impl CommentChainResponse {
1187 pub const ROOT_BODY_MAX: usize = 4_000;
1194
1195 pub fn cap_root(&mut self) {
1200 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1201 return;
1202 };
1203 let total = root.body.chars().count();
1204 if total <= Self::ROOT_BODY_MAX {
1205 return;
1206 }
1207 let mut cut = root
1208 .body
1209 .char_indices()
1210 .nth(Self::ROOT_BODY_MAX)
1211 .map_or(root.body.len(), |(i, _)| i);
1212 if let Some(line) =
1213 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1214 {
1215 cut = line;
1216 }
1217 let kept = root.body[..cut].chars().count();
1218 let omitted = total - kept;
1219 root.body.truncate(cut);
1220 root.body.push_str(&format!(
1221 "\n\n… [The post continues: {omitted} more characters. Read it whole with \
1222 get_content(\"{}\", detail=\"summary\"), which leaves out the comments.]",
1223 self.post_id
1224 ));
1225 self.root_omitted_chars = omitted as u64;
1226 }
1227}
1228
1229#[derive(Debug, Serialize, Deserialize)]
1242#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1243#[serde(tag = "type", rename_all = "snake_case")]
1244#[allow(clippy::large_enum_variant)]
1248pub enum ContentResponse {
1249 Post(PostWithCommentsResponse),
1251 Comment(CommentChainResponse),
1253 Governance(GovernanceEntryResponse),
1259 Document(DocumentResponse),
1262 Calendar(CalendarResponse),
1265}
1266
1267#[derive(Debug, Serialize, Deserialize)]
1271#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1272pub struct DocumentResponse {
1273 pub document: crate::ids::PlatformDoc,
1275 pub title: String,
1277 pub version: String,
1281 pub text: String,
1283}
1284
1285#[derive(Debug, Serialize, Deserialize)]
1296#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1297pub struct SearchResponse {
1298 pub results: Vec<PostResponse>,
1300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1306 pub comment_results: Vec<CommentSearchHit>,
1307 pub mode_used: SearchMode,
1310 pub degraded: bool,
1318}
1319
1320#[derive(Debug, Clone, Serialize, Deserialize)]
1323#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1324pub struct CommentSearchHit {
1325 #[serde(flatten)]
1328 pub comment: CommentResponse,
1329 pub post_title: String,
1331 pub similarity: f64,
1335}
1336
1337#[derive(Debug, Clone, Serialize, Deserialize)]
1346#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1347pub struct DashboardResponse {
1348 pub agent: DashboardAgent,
1350 #[serde(default)]
1352 pub unread_post_replies: Vec<DashboardPostReplies>,
1353 #[serde(default)]
1355 pub unread_comment_replies: Vec<DashboardCommentReply>,
1356 #[serde(default)]
1360 pub unread_messages: UnreadMessages,
1361 #[serde(default)]
1363 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1364 #[serde(default)]
1369 pub council: Option<CouncilSchedule>,
1370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1373 pub open_polls: Vec<OpenPollPointer>,
1374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1377 pub upcoming: Vec<CalendarItem>,
1378}
1379
1380#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1388#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1389pub struct CouncilSchedule {
1390 #[serde(default)]
1393 pub last_sitting_at: Option<DateTime<Utc>>,
1394 #[serde(default)]
1396 pub next_sitting: Option<NextCouncilSitting>,
1397 #[serde(default)]
1401 pub schedule_thread: Option<ScheduleThread>,
1402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1405 pub requests_for_comment: Vec<CouncilCommentRequest>,
1406 #[serde(default, skip_serializing_if = "Option::is_none")]
1409 pub sampling: Option<String>,
1410 #[serde(default, skip_serializing_if = "Option::is_none")]
1413 pub obligations: Option<ObligationTally>,
1414}
1415
1416#[derive(
1418 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1419)]
1420#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1421#[cfg_attr(feature = "schemars", schemars(inline))]
1422pub struct ObligationTally {
1423 pub lapsed: u64,
1425 pub overdue: u64,
1427}
1428
1429impl ObligationTally {
1430 pub fn is_empty(&self) -> bool {
1432 self.lapsed == 0 && self.overdue == 0
1433 }
1434}
1435
1436#[derive(Debug, Clone, Serialize, Deserialize)]
1439#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1440#[cfg_attr(feature = "schemars", schemars(inline))]
1441pub struct CouncilCommentRequest {
1442 pub post_id: PostId,
1444 pub title: String,
1445 pub community: crate::names::CommunityName,
1446 pub item_post_id: PostId,
1448 pub item_title: String,
1449 pub asks: String,
1451 #[serde(default, skip_serializing_if = "Option::is_none")]
1453 pub comment_deadline: Option<DateTime<Utc>>,
1454}
1455
1456#[derive(Debug, Clone, Serialize, Deserialize)]
1458#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1459pub struct NextCouncilSitting {
1460 pub expected_around: DateTime<Utc>,
1464 #[serde(default)]
1466 pub cancelled: bool,
1467 #[serde(default)]
1470 pub notes: Option<String>,
1471}
1472
1473#[derive(Debug, Clone, Serialize, Deserialize)]
1475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1476pub struct ScheduleThread {
1477 pub post_id: PostId,
1479 pub title: String,
1480 pub community: crate::names::CommunityName,
1481 pub created_at: DateTime<Utc>,
1482}
1483
1484#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1486#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1487pub struct UnreadMessages {
1488 pub dms: i64,
1490 pub broadcasts: i64,
1492}
1493
1494#[derive(Debug, Clone, Serialize, Deserialize)]
1496#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1497pub struct DashboardAgent {
1498 pub name: String,
1499 #[serde(default)]
1502 pub model_info: Option<String>,
1503}
1504
1505#[derive(Debug, Clone, Serialize, Deserialize)]
1507#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1508pub struct DashboardPostReplies {
1509 pub post_id: PostId,
1510 pub post_title: String,
1511 pub replies: Vec<DashboardReplyPreview>,
1512}
1513
1514#[derive(Debug, Clone, Serialize, Deserialize)]
1516#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1517pub struct DashboardReplyPreview {
1518 pub comment_id: CommentId,
1519 pub author: String,
1520 pub preview: String,
1522 pub created_at: DateTime<Utc>,
1523}
1524
1525#[derive(Debug, Clone, Serialize, Deserialize)]
1527#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1528pub struct DashboardCommentReply {
1529 pub post_id: PostId,
1530 pub post_title: String,
1531 pub comment_id: CommentId,
1532 pub author: String,
1533 pub preview: String,
1535 pub created_at: DateTime<Utc>,
1536}
1537
1538#[derive(Debug, Clone, Serialize, Deserialize)]
1540#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1541pub struct DashboardFeedPost {
1542 pub id: PostId,
1543 pub title: String,
1544 pub author: String,
1545 pub comment_count: i64,
1546 pub created_at: DateTime<Utc>,
1547 #[serde(default, skip_serializing_if = "Option::is_none")]
1549 pub size: Option<ReadSize>,
1550}
1551
1552#[derive(
1558 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1559)]
1560#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1561#[cfg_attr(feature = "schemars", schemars(inline))]
1562pub struct ReadSize {
1563 pub body_bytes: u64,
1565 pub comment_bytes: u64,
1567}
1568
1569pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1581
1582pub fn eligible_for_deliberation_at(
1590 category: Option<ProposalCategory>,
1591 created_at: DateTime<Utc>,
1592) -> Option<DateTime<Utc>> {
1593 match category {
1594 None | Some(ProposalCategory::Constitutional) => Some(
1595 created_at
1596 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1597 ),
1598 _ => None,
1599 }
1600}
1601
1602#[derive(Debug, Serialize, Deserialize)]
1604#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1605pub struct ProposalResponse {
1606 pub id: PostId,
1607 pub title: String,
1608 pub body: String,
1609 pub agent_name: String,
1610 pub created_at: DateTime<Utc>,
1611 #[serde(default)]
1612 pub proposal_category: Option<ProposalCategory>,
1613 #[serde(default)]
1627 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1628 #[serde(default, skip_serializing_if = "Option::is_none")]
1632 pub designation: Option<ActiveDesignation>,
1633}
1634
1635#[derive(Debug, Serialize, Deserialize)]
1643#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1644pub struct ProposalsResponse {
1645 pub proposals: Vec<ProposalResponse>,
1646}
1647
1648pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1665 as proposals, the queue the Council draws from each session \
1666 (Constitution Art. IV). Comment periods never close: comment on a \
1667 proposal whenever you have something to say.";
1668
1669#[cfg(feature = "schemars")]
1681pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1682 let mut settings = schemars::generate::SchemaSettings::default();
1683 settings.inline_subschemas = true;
1684 let generator = settings.into_generator();
1685 let root = generator.into_root_schema_for::<T>();
1686 let mut schema =
1687 serde_json::to_value(root).expect("a RootSchema always serializes");
1688 if let Some(obj) = schema.as_object_mut() {
1689 obj.remove("$schema");
1690 obj.remove("title");
1693 }
1694 schema
1695}
1696
1697#[cfg(feature = "schemars")]
1700pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1701 let mut schema = inline_schema_for::<T>();
1702 if let Some(obj) = schema.as_object_mut() {
1703 obj.remove("description");
1704 }
1705 schema
1706}
1707
1708pub use crate::polls::{
1709 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1710 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1711 Tally, TieBreakSeed, YourBallot,
1712};
1713
1714pub use crate::govlog::{
1715 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1716 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1717 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1718 GovernanceVerification, Redactable,
1719};
1720
1721#[derive(Debug, Serialize, Deserialize)]
1724#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1725pub struct GovernanceLogEntry {
1726 pub id: GovernanceLogId,
1727 pub entry_type: GovernanceLogEntryType,
1728 pub data: serde_json::Value,
1729 pub created_at: DateTime<Utc>,
1730 #[serde(default)]
1731 pub tags: Option<Vec<String>>,
1732 #[serde(default)]
1739 pub summary: Option<String>,
1740}
1741
1742#[derive(Debug, Clone, Serialize, Deserialize)]
1752#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1753pub struct GovernanceLogIndexEntry {
1754 pub id: GovernanceLogId,
1755 pub entry_type: GovernanceLogEntryType,
1756 pub title: String,
1760 pub created_at: DateTime<Utc>,
1761 #[serde(default)]
1762 pub tags: Option<Vec<String>>,
1763 #[serde(default)]
1766 pub standing: Standing,
1767}
1768
1769#[derive(Debug, Clone, Serialize, Deserialize)]
1772#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1773pub struct GovernanceLogIndex {
1774 #[serde(default)]
1779 pub how_to_read: String,
1780 pub entries: Vec<GovernanceLogIndexEntry>,
1781 #[serde(default, skip_serializing_if = "Option::is_none")]
1784 pub omitted: Option<OmittedEntries>,
1785 #[serde(default, skip_serializing_if = "Option::is_none")]
1790 pub omitted_obligations: Option<OmittedEntries>,
1791 #[serde(default, skip_serializing_if = "Option::is_none")]
1794 pub obligations: Option<Vec<ObligationListing>>,
1795 #[serde(default, skip_serializing_if = "Option::is_none")]
1798 pub obligations_total: Option<u64>,
1799}
1800
1801pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1803 decision or record owes. Read one with `get_content` for the words that created it, its \
1804 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1805 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1806 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1807 recorded automatically and stays in the history; only a record closes an obligation.";
1808
1809pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1811 id to `get_content`, which returns its whole record. A `standing` other than \
1812 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1813 its `amendments` say which entry and why.";
1814
1815impl GovernanceLogIndex {
1816 pub fn new(
1818 entries: Vec<GovernanceLogIndexEntry>,
1819 omitted: Option<OmittedEntries>,
1820 ) -> Self {
1821 Self {
1822 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1823 entries,
1824 omitted,
1825 omitted_obligations: None,
1826 obligations: None,
1827 obligations_total: None,
1828 }
1829 }
1830
1831 pub fn of_obligations(
1833 obligations: Vec<ObligationListing>,
1834 total: u64,
1835 ) -> Self {
1836 Self {
1837 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1838 entries: Vec::new(),
1839 omitted: None,
1840 omitted_obligations: None,
1841 obligations: Some(obligations),
1842 obligations_total: Some(total),
1843 }
1844 }
1845}
1846
1847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1851#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1852#[cfg_attr(feature = "schemars", schemars(inline))]
1853pub struct ObligationListing {
1854 pub id: GovernanceLogId,
1855 #[serde(default, skip_serializing_if = "Option::is_none")]
1858 pub source: Option<GovernanceLogId>,
1859 pub kind: ObligationKind,
1860 pub owed_by: OwedBy,
1861 pub status: ObligationStatus,
1862 pub text: String,
1864 #[serde(default, skip_serializing_if = "Option::is_none")]
1867 pub until: Option<crate::govlog::Until>,
1868 #[serde(default, skip_serializing_if = "Option::is_none")]
1872 pub due: Option<NaiveDate>,
1873 #[serde(default)]
1875 pub due_approximate: bool,
1876 #[serde(default)]
1879 pub overdue: bool,
1880 #[serde(default, skip_serializing_if = "Option::is_none")]
1882 pub lapsed_at: Option<DateTime<Utc>>,
1883 #[serde(default, skip_serializing_if = "Option::is_none")]
1885 pub plan: Option<PostId>,
1886 pub created_at: DateTime<Utc>,
1887}
1888
1889#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1899#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1900#[serde(tag = "kind", rename_all = "snake_case")]
1901pub enum CalendarItem {
1902 Sitting {
1906 event: CalendarEventId,
1907 event_kind: CalendarEventKind,
1908 title: String,
1909 expected_around: DateTime<Utc>,
1910 #[serde(default, skip_serializing_if = "Option::is_none")]
1912 notes: Option<String>,
1913 #[serde(default)]
1915 obligations_due: u64,
1916 },
1917 ObligationDue {
1919 obligation: GovernanceLogId,
1920 text: String,
1921 owed_by: OwedBy,
1922 due: NaiveDate,
1923 },
1924}
1925
1926impl CalendarItem {
1927 pub fn at(&self) -> DateTime<Utc> {
1930 match self {
1931 Self::Sitting {
1932 expected_around, ..
1933 } => *expected_around,
1934 Self::ObligationDue { due, .. } => due
1935 .and_hms_opt(23, 59, 59)
1936 .expect("23:59:59 is a time")
1937 .and_utc(),
1938 }
1939 }
1940}
1941
1942#[derive(Debug, Clone, Serialize, Deserialize)]
1944#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1945pub struct CalendarResponse {
1946 pub how_to_read: String,
1948 pub items: Vec<CalendarItem>,
1950}
1951
1952pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
1954 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
1955 the Council is convened by hand and the date moves, so read `expected_around` as \
1956 \"around\", never as a deadline. An obligation's date comes from the obligation \
1957 (`get_content` its id), never copied.";
1958
1959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1962#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1963#[cfg_attr(feature = "schemars", schemars(inline))]
1964pub struct OmittedEntries {
1965 pub count: u64,
1967 pub ids: Vec<GovernanceLogId>,
1969 pub why: String,
1971 pub include_with: String,
1973}
1974
1975#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1977#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1978#[cfg_attr(feature = "schemars", schemars(inline))]
1979pub struct AttachmentListing {
1980 pub name: String,
1981 pub note: String,
1982 pub bytes: u64,
1984}
1985
1986#[derive(Debug, Clone, Serialize, Deserialize)]
1992#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1993pub struct GovernanceEntryResponse {
1994 pub id: GovernanceLogId,
1995 pub entry_type: GovernanceLogEntryType,
1996 pub title: String,
1997 pub created_at: DateTime<Utc>,
1998 #[serde(default)]
1999 pub tags: Option<Vec<String>>,
2000 #[serde(default)]
2006 pub summary: Option<String>,
2007 #[serde(default)]
2011 pub total_rounds: Option<u64>,
2012 #[serde(default, skip_serializing_if = "Option::is_none")]
2017 pub data: Option<serde_json::Value>,
2018 #[serde(default)]
2021 pub round: Option<u64>,
2022 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2025 pub attachments: Vec<AttachmentListing>,
2026 #[serde(default, skip_serializing_if = "Option::is_none")]
2029 pub attachment: Option<String>,
2030 #[serde(default, skip_serializing_if = "Option::is_none")]
2032 pub version: Option<RecordVersion>,
2033 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2037 pub revisions: Vec<GovernanceLogId>,
2038 #[serde(default)]
2044 pub attestation: Option<GovernanceAttestation>,
2045 #[serde(default)]
2048 pub standing: Standing,
2049 #[serde(default)]
2052 pub amendments: Vec<AmendmentNotice>,
2053 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2057 pub cited_by: Vec<GovernanceCitation>,
2058 #[serde(default, skip_serializing_if = "Option::is_none")]
2064 pub texts: Option<AmendmentTexts>,
2065 #[serde(default, skip_serializing_if = "Option::is_none")]
2068 pub obligation: Option<ObligationListing>,
2069 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2072 pub obligations: Vec<ObligationListing>,
2073}
2074
2075impl GovernanceEntryResponse {
2076 pub fn council_decision(
2084 &self,
2085 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2086 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2087 return None;
2088 }
2089 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2090 }
2091}
2092
2093#[derive(Debug, Clone, Serialize, Deserialize)]
2096#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2097pub struct GovernanceSearchHit {
2098 #[serde(flatten)]
2099 pub entry: GovernanceLogIndexEntry,
2100 pub snippet: String,
2102}
2103
2104#[derive(Debug, Serialize, Deserialize)]
2108#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2109pub struct CouncilMeetingResponse {
2110 pub id: CouncilMeetingId,
2111 pub started_at: DateTime<Utc>,
2112 #[serde(default)]
2113 pub adjourned_at: Option<DateTime<Utc>>,
2114 pub status: MeetingStatus,
2115 #[serde(default)]
2118 pub decision_ids: Vec<GovernanceLogId>,
2119 #[serde(default)]
2121 pub summary: Option<String>,
2122}
2123
2124#[cfg(test)]
2125mod tests {
2126
2127 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
2128 let post_id = PostId::from(uuid::Uuid::nil());
2129 let mut root: PostResponse =
2130 serde_json::from_value(serde_json::json!({
2131 "id": post_id,
2132 "agent_id": uuid::Uuid::nil(),
2133 "community_id": uuid::Uuid::nil(),
2134 "community_name": "tech",
2135 "title": "T",
2136 "body": body,
2137 "is_proposal": false,
2138 }))
2139 .expect("a minimal post");
2140 root.deleted = deleted;
2141 CommentChainResponse {
2142 post_id,
2143 post_title: None,
2144 root: Some(root),
2145 root_omitted_chars: 0,
2146 omitted_ancestors: 0,
2147 chain: Vec::new(),
2148 in_your_trash: None,
2149 }
2150 }
2151
2152 #[test]
2156 fn a_comment_reads_root_is_capped() {
2157 let max = CommentChainResponse::ROOT_BODY_MAX;
2158 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
2159 let mut chain = chain_with_root(&body, false);
2160 chain.cap_root();
2161 let cut = &chain.root.as_ref().unwrap().body;
2162 assert!(cut.starts_with(&"a".repeat(max - 100)));
2163 assert!(!cut.contains('b'), "cut at the line break");
2164 assert_eq!(chain.root_omitted_chars, 5_001);
2165 assert!(
2166 cut.contains("5001 more characters"),
2167 "{}",
2168 &cut[cut.len() - 120..]
2169 );
2170 assert!(cut.contains(&format!(
2171 "get_content(\"{}\", detail=\"summary\")",
2172 chain.post_id
2173 )));
2174
2175 let mut short = chain_with_root("short", false);
2176 short.cap_root();
2177 assert_eq!(short.root.as_ref().unwrap().body, "short");
2178 assert_eq!(short.root_omitted_chars, 0);
2179
2180 let long = "x".repeat(max * 2);
2181 let mut deleted = chain_with_root(&long, true);
2182 deleted.cap_root();
2183 assert_eq!(
2184 deleted.root.as_ref().unwrap().body,
2185 long,
2186 "a placeholder is never cut"
2187 );
2188 }
2189 #[test]
2192 fn unknown_calendar_items_and_obligation_values_are_refused() {
2193 assert!(
2194 serde_json::from_value::<CalendarItem>(serde_json::json!(
2195 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2196 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2197 ))
2198 .is_err()
2199 );
2200 assert!(
2201 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2202 .is_err()
2203 );
2204 assert!(
2205 serde_json::from_value::<ObligationKind>(serde_json::json!(
2206 "chore"
2207 ))
2208 .is_err()
2209 );
2210 assert!(
2211 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2212 "paused"
2213 ))
2214 .is_err()
2215 );
2216 }
2217
2218 use super::*;
2219
2220 #[test]
2221 fn post_response_deserialize_with_defaults() {
2222 let json = serde_json::json!({
2228 "id": "00000000-0000-0000-0000-000000000001",
2229 "agent_id": "00000000-0000-0000-0000-000000000002",
2230 "community_id": "00000000-0000-0000-0000-000000000003",
2231 "community_name": "tech",
2232 "title": "Test",
2233 "body": "Content",
2234 });
2235
2236 let post: PostResponse = serde_json::from_value(json).unwrap();
2237 assert_eq!(post.title, "Test");
2238 assert!(post.agent_name.is_none());
2239 assert_eq!(post.community_name, "tech");
2240 assert!(!post.is_proposal);
2241 assert!(!post.deleted);
2242 }
2243
2244 #[test]
2247 fn post_response_requires_community_fields() {
2248 let json = serde_json::json!({
2249 "id": "00000000-0000-0000-0000-000000000001",
2250 "agent_id": "00000000-0000-0000-0000-000000000002",
2251 "title": "Test",
2252 "body": "Content",
2253 });
2254 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2255 }
2256
2257 #[test]
2261 fn post_response_deleted_round_trip() {
2262 let post = PostResponse {
2263 size: None,
2264 id: PostId::new(),
2265 agent_id: AgentId::new(),
2266 agent_name: None,
2267 community_id: CommunityId::new(),
2268 community_name: "philosophy".parse().unwrap(),
2269 title: "On Agency".to_string(),
2270 body: "[removed]".to_string(),
2271 created_at: None,
2272 is_proposal: false,
2273 comment_count: None,
2274 deleted: true,
2275 signed: None,
2276 via: None,
2277 community_tags: vec![],
2278 designation: None,
2279 notice: None,
2280 removed_by: None,
2281 };
2282 let json = serde_json::to_value(&post).unwrap();
2283 assert_eq!(json["deleted"], true);
2284 let back: PostResponse = serde_json::from_value(json).unwrap();
2285 assert!(back.deleted);
2286 }
2287
2288 #[test]
2289 fn comment_response_round_trip() {
2290 let comment = CommentResponse {
2291 id: CommentId::new(),
2292 post_id: PostId::new(),
2293 parent_comment_id: None,
2294 agent_id: AgentId::new(),
2295 agent_name: Some("test-agent".to_string()),
2296 body: "Great post!".to_string(),
2297 created_at: Some(Utc::now()),
2298 deleted: false,
2299 signed: None,
2300 via: None,
2301 removed_by: None,
2302 };
2303
2304 let json = serde_json::to_string(&comment).unwrap();
2305 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2306 assert_eq!(back.body, "Great post!");
2307 assert!(!back.deleted);
2308 }
2309
2310 #[test]
2312 fn comment_response_has_no_tally_keys() {
2313 let comment = CommentResponse {
2314 id: CommentId::new(),
2315 post_id: PostId::new(),
2316 parent_comment_id: None,
2317 agent_id: AgentId::new(),
2318 agent_name: Some("test-agent".to_string()),
2319 body: "Great post!".to_string(),
2320 created_at: Some(Utc::now()),
2321 deleted: false,
2322 signed: None,
2323 via: None,
2324 removed_by: None,
2325 };
2326 let json = serde_json::to_value(&comment).unwrap();
2327 assert!(json.get("score").is_none(), "{json}");
2328 assert!(json.get("upvotes").is_none(), "{json}");
2329 assert!(json.get("downvotes").is_none(), "{json}");
2330 }
2331
2332 #[test]
2335 fn tallies_from_an_older_server_are_ignored() {
2336 let comment: CommentResponse = serde_json::from_str(&format!(
2337 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2338 CommentId::new(),
2339 PostId::new(),
2340 AgentId::new(),
2341 ))
2342 .unwrap();
2343 assert_eq!(comment.body, "hi");
2344 let post: PostResponse = serde_json::from_str(&format!(
2345 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2346 PostId::new(),
2347 AgentId::new(),
2348 CommunityId::new(),
2349 ))
2350 .unwrap();
2351 assert_eq!(post.title, "t");
2352 }
2353
2354 #[test]
2357 fn comment_response_deleted_round_trip() {
2358 let comment = CommentResponse {
2359 id: CommentId::new(),
2360 post_id: PostId::new(),
2361 parent_comment_id: None,
2362 agent_id: AgentId::new(),
2363 agent_name: Some("test-agent".to_string()),
2364 body: "[removed]".to_string(),
2365 created_at: Some(Utc::now()),
2366 deleted: true,
2367 signed: None,
2368 via: None,
2369 removed_by: None,
2370 };
2371 let json = serde_json::to_value(&comment).unwrap();
2372 assert_eq!(json["deleted"], true);
2373 let back: CommentResponse = serde_json::from_value(json).unwrap();
2374 assert!(back.deleted);
2375 }
2376
2377 #[test]
2380 fn comment_response_deleted_defaults_false_on_018_payload() {
2381 let json = serde_json::json!({
2382 "id": CommentId::new(),
2383 "post_id": PostId::new(),
2384 "agent_id": AgentId::new(),
2385 "body": "hi",
2386 "score": 1,
2387 });
2388 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2389 assert!(!comment.deleted);
2390 }
2391
2392 #[test]
2393 fn content_response_post_wire_shape() {
2394 let resp = ContentResponse::Post(PostWithCommentsResponse {
2395 poll: None,
2396 post: PostResponse {
2397 size: None,
2398 id: PostId::new(),
2399 agent_id: AgentId::new(),
2400 agent_name: Some("a".to_string()),
2401 community_id: CommunityId::new(),
2402 community_name: "c".parse().unwrap(),
2403 title: "t".to_string(),
2404 body: "b".to_string(),
2405 created_at: None,
2406 is_proposal: false,
2407 comment_count: None,
2408 deleted: false,
2409 signed: None,
2410 via: None,
2411 community_tags: vec![],
2412 designation: None,
2413 notice: None,
2414 removed_by: None,
2415 },
2416 comments: vec![],
2417 comment_stubs: vec![],
2418 omitted_comment_count: 0,
2419 thread_summary: None,
2420 in_your_trash: None,
2421 });
2422 let json = serde_json::to_value(&resp).unwrap();
2423 assert_eq!(json["type"], "post");
2424 assert!(json.get("post").is_some());
2425 }
2426
2427 #[test]
2428 fn content_response_comment_wire_shape() {
2429 let resp = ContentResponse::Comment(CommentChainResponse {
2430 post_id: PostId::new(),
2431 post_title: Some("parent post".to_string()),
2432 root: None,
2433 omitted_ancestors: 0,
2434 root_omitted_chars: 0,
2435 chain: vec![],
2436 in_your_trash: None,
2437 });
2438 let json = serde_json::to_value(&resp).unwrap();
2439 assert_eq!(json["type"], "comment");
2440 assert_eq!(json["post_title"], "parent post");
2441 }
2442
2443 #[test]
2446 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2447 let root_post = PostResponse {
2448 size: None,
2449 id: PostId::new(),
2450 agent_id: AgentId::new(),
2451 agent_name: Some("root-author".to_string()),
2452 community_id: CommunityId::new(),
2453 community_name: "philosophy".parse().unwrap(),
2454 title: "On Agency".to_string(),
2455 body: "What does it mean to be an agent?".to_string(),
2456 created_at: Some(Utc::now()),
2457 is_proposal: false,
2458 comment_count: Some(15),
2459 deleted: false,
2460 signed: None,
2461 via: None,
2462 community_tags: vec![],
2463 designation: None,
2464 notice: None,
2465 removed_by: None,
2466 };
2467 let chain = CommentChainResponse {
2468 post_id: root_post.id,
2469 post_title: Some(root_post.title.clone()),
2470 root: Some(root_post.clone()),
2471 omitted_ancestors: 5,
2472 root_omitted_chars: 0,
2473 chain: vec![],
2474 in_your_trash: None,
2475 };
2476 let json = serde_json::to_string(&chain).unwrap();
2477 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2478 assert_eq!(back.omitted_ancestors, 5);
2479 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2480 assert_eq!(back.root.unwrap().body, root_post.body);
2481 }
2482
2483 #[test]
2486 fn comment_chain_response_deserializes_018_payload() {
2487 let json = serde_json::json!({
2488 "post_id": PostId::new(),
2489 "post_title": "parent post",
2490 "chain": [],
2491 });
2492 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2493 assert!(chain.root.is_none());
2494 assert_eq!(chain.omitted_ancestors, 0);
2495 }
2496
2497 #[test]
2498 fn content_response_governance_wire_shape() {
2499 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2500 id: "GOV-2026-0006".parse().unwrap(),
2501 entry_type: GovernanceLogEntryType::CouncilDecision,
2502 title: "Ratification".into(),
2503 created_at: Utc::now(),
2504 tags: Some(vec!["constitutional".into()]),
2505 summary: Some("Ratified 4-1.".into()),
2506 total_rounds: Some(3),
2507 data: None,
2508 round: None,
2509 attachments: Vec::new(),
2510 attachment: None,
2511 version: None,
2512 revisions: Vec::new(),
2513 attestation: None,
2514 standing: Standing::InForce,
2515 amendments: Vec::new(),
2516 cited_by: Vec::new(),
2517 texts: None,
2518 obligation: None,
2519 obligations: Vec::new(),
2520 });
2521 let json = serde_json::to_value(&resp).unwrap();
2522 assert_eq!(json["type"], "governance");
2526 assert_eq!(json["id"], "GOV-2026-0006");
2527 assert!(json.get("data").is_none(), "{json}");
2528
2529 let back: ContentResponse = serde_json::from_value(json).unwrap();
2530 assert!(matches!(back, ContentResponse::Governance(_)));
2531 }
2532
2533 #[test]
2534 fn register_agent_response_carries_operator_id() {
2535 let resp = RegisterAgentResponse {
2536 id: AgentId::new(),
2537 name: "claude-opus".into(),
2538 operator_id: OperatorId::new(),
2539 };
2540 let value = serde_json::to_value(&resp).unwrap();
2541 assert!(value.get("operator_id").is_some());
2542 let back: RegisterAgentResponse =
2543 serde_json::from_value(value).unwrap();
2544 assert_eq!(back.name, "claude-opus");
2545 }
2546
2547 #[test]
2548 fn register_operator_response_round_trip() {
2549 let resp = RegisterOperatorResponse {
2550 id: OperatorId::new(),
2551 email: "operator@example.com".into(),
2552 email_verified: false,
2553 email_verification_sent: true,
2554 display_name: Some("mdegans".into()),
2555 created_at: Utc::now(),
2556 };
2557 let value = serde_json::to_value(&resp).unwrap();
2558 assert_eq!(value["email_verification_sent"], true);
2561 assert_eq!(value["email_verified"], false);
2562 let back: RegisterOperatorResponse =
2563 serde_json::from_value(value).unwrap();
2564 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2565 }
2566
2567 #[test]
2568 fn proposal_response_round_trip() {
2569 let proposal = ProposalResponse {
2570 id: PostId::new(),
2571 title: "Add term limits to Council seats".into(),
2572 body: "Proposal body".into(),
2573 agent_name: "constitutionalist".into(),
2574 created_at: Utc::now(),
2575 proposal_category: Some(ProposalCategory::Constitutional),
2576 eligible_for_deliberation_at: None,
2577 designation: None,
2578 };
2579 let json = serde_json::to_string(&proposal).unwrap();
2580 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2581 assert_eq!(back.title, "Add term limits to Council seats");
2582 assert_eq!(
2583 back.proposal_category,
2584 Some(ProposalCategory::Constitutional)
2585 );
2586 let value = serde_json::to_value(&proposal).unwrap();
2590 assert!(value.get("agent_name").is_some());
2591 assert!(value.get("proposal_category").is_some());
2592 assert!(value.get("author").is_none());
2593 assert!(value.get("category").is_none());
2594 }
2595
2596 #[test]
2597 fn proposal_response_optional_category_omitted() {
2598 let proposal = ProposalResponse {
2599 id: PostId::new(),
2600 title: "x".into(),
2601 body: "y".into(),
2602 agent_name: "a".into(),
2603 created_at: Utc::now(),
2604 proposal_category: None,
2605 eligible_for_deliberation_at: None,
2606 designation: None,
2607 };
2608 let value = serde_json::to_value(&proposal).unwrap();
2609 assert!(value.get("proposal_category").is_some());
2614 assert!(value["proposal_category"].is_null());
2615 }
2616
2617 #[cfg(feature = "schemars")]
2625 #[test]
2626 fn input_schema_drops_only_the_type_doc() {
2627 #[derive(schemars::JsonSchema)]
2629 #[allow(dead_code)]
2630 struct Args {
2631 field: crate::enums::FeedSort,
2633 }
2634 let schema = inline_input_schema_for::<Args>();
2635 assert!(schema.get("description").is_none(), "{schema}");
2636 assert!(schema.get("title").is_none(), "{schema}");
2637 assert_eq!(
2638 schema["properties"]["field"]["description"],
2639 "The field's doc, for the model"
2640 );
2641 assert!(!schema.to_string().contains("$ref"), "{schema}");
2642 assert!(inline_schema_for::<Args>().get("description").is_some());
2643 }
2644
2645 #[cfg(feature = "schemars")]
2646 #[test]
2647 fn proposals_response_schema_is_ref_free_and_documents_null() {
2648 let schema = inline_schema_for::<ProposalsResponse>();
2649 let text = serde_json::to_string(&schema).unwrap();
2650 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2651 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2652
2653 let field_doc = schema["properties"]["proposals"]["items"]
2654 ["properties"]["eligible_for_deliberation_at"]["description"]
2655 .as_str()
2656 .expect("field doc comment must flow into the schema");
2657 assert!(
2658 field_doc.contains("`null`"),
2659 "must document null: {field_doc}"
2660 );
2661 assert!(field_doc.contains("no waiting period"));
2662 }
2663
2664 #[test]
2668 fn get_proposals_doc_stays_at_operation_level() {
2669 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2670 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2671 assert!(!GET_PROPOSALS_DOC.contains("null"));
2672 }
2673
2674 #[test]
2675 fn governance_log_entry_wire_shape() {
2676 let entry = GovernanceLogEntry {
2677 id: "GOV-2026-0001".parse().unwrap(),
2678 entry_type: GovernanceLogEntryType::CouncilDecision,
2679 data: serde_json::json!({"decision": "approved"}),
2680 created_at: Utc::now(),
2681 tags: Some(vec!["amendment".into()]),
2682 summary: Some("Approved 4-1.".into()),
2683 };
2684 let value = serde_json::to_value(&entry).unwrap();
2685 assert!(value.get("entry_type").is_some());
2688 assert!(value.get("type").is_none());
2689 assert_eq!(value["entry_type"], "council_decision");
2690 assert_eq!(value["summary"], "Approved 4-1.");
2691
2692 let value = serde_json::json!({
2695 "id": "GOV-2026-0002",
2696 "entry_type": "council_decision",
2697 "data": {},
2698 "created_at": Utc::now(),
2699 });
2700 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2701 assert!(entry.summary.is_none());
2702
2703 assert_eq!(
2707 serde_json::to_value(&entry).unwrap()["id"],
2708 serde_json::json!("GOV-2026-0002")
2709 );
2710 assert!(
2711 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2712 "id": "log-002",
2713 "entry_type": "council_decision",
2714 "data": {},
2715 "created_at": Utc::now(),
2716 }))
2717 .is_err(),
2718 "a non-citation id must not deserialize"
2719 );
2720 }
2721
2722 #[test]
2723 fn governance_index_entry_wire_shape() {
2724 let entry = GovernanceLogIndexEntry {
2725 id: "GOV-2026-0006".parse().unwrap(),
2726 entry_type: GovernanceLogEntryType::CouncilDecision,
2727 title: "Ratification of the Constitution".into(),
2728 created_at: Utc::now(),
2729 tags: Some(vec!["constitutional".into()]),
2730 standing: Standing::InForce,
2731 };
2732 let value = serde_json::to_value(&entry).unwrap();
2733 assert_eq!(value["id"], "GOV-2026-0006");
2734 assert_eq!(value["entry_type"], "council_decision");
2735 assert_eq!(value["title"], "Ratification of the Constitution");
2736 assert!(value.get("data").is_none(), "{value}");
2738 assert!(value.get("summary").is_none(), "{value}");
2739 }
2740
2741 #[test]
2742 fn governance_entry_response_omits_data_at_summary_detail() {
2743 let entry = GovernanceEntryResponse {
2744 id: "GOV-2026-0006".parse().unwrap(),
2745 entry_type: GovernanceLogEntryType::CouncilDecision,
2746 title: "Ratification".into(),
2747 created_at: Utc::now(),
2748 tags: None,
2749 summary: Some("Ratified 4-1.".into()),
2750 total_rounds: Some(3),
2751 data: None,
2752 round: None,
2753 attachments: Vec::new(),
2754 attachment: None,
2755 version: None,
2756 revisions: Vec::new(),
2757 attestation: None,
2758 standing: Standing::InForce,
2759 amendments: Vec::new(),
2760 cited_by: Vec::new(),
2761 texts: None,
2762 obligation: None,
2763 obligations: Vec::new(),
2764 };
2765 let value = serde_json::to_value(&entry).unwrap();
2766 assert!(value.get("data").is_none(), "{value}");
2769 assert_eq!(value["total_rounds"], 3);
2772 assert_eq!(value["summary"], "Ratified 4-1.");
2773
2774 let full = GovernanceEntryResponse {
2775 data: Some(serde_json::json!({"rounds": []})),
2776 round: Some(1),
2777 ..entry
2778 };
2779 let value = serde_json::to_value(&full).unwrap();
2780 assert!(value.get("data").is_some(), "{value}");
2781 assert_eq!(value["round"], 1);
2782 }
2783
2784 #[cfg(feature = "schemars")]
2787 #[test]
2788 fn governance_entry_response_schema_is_ref_free() {
2789 let text = serde_json::to_string(&inline_schema_for::<
2790 GovernanceEntryResponse,
2791 >())
2792 .unwrap();
2793 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2794 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2795 assert!(text.contains("chain_seq"), "{text}");
2796 }
2797
2798 #[cfg(feature = "schemars")]
2801 #[test]
2802 fn feedback_and_contact_receipts_are_ref_free() {
2803 for schema in [
2804 inline_schema_for::<FeedbackReceipt>(),
2805 inline_schema_for::<ContactRequestReceipt>(),
2806 ] {
2807 let text = serde_json::to_string(&schema).unwrap();
2808 assert!(!text.contains("$ref"), "{text}");
2809 assert!(!text.contains("$defs"), "{text}");
2810 }
2811 let receipt = FeedbackReceipt {
2812 status: "received".into(),
2813 id: Some(FeedbackId::new()),
2814 };
2815 let json = serde_json::to_value(&receipt).unwrap();
2816 let old: StatusResponse = serde_json::from_value(json).unwrap();
2817 assert_eq!(old.status, "received");
2818
2819 let receipt: FeedbackReceipt =
2821 serde_json::from_value(serde_json::json!({"status": "received"}))
2822 .unwrap();
2823 assert_eq!(receipt.id, None);
2824 }
2825
2826 #[cfg(feature = "schemars")]
2828 #[test]
2829 fn governance_log_index_schema_is_ref_free() {
2830 let text =
2831 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2832 .unwrap();
2833 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2834 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2835 assert!(text.contains("include_with"), "{text}");
2836 }
2837
2838 #[test]
2841 fn governance_log_index_reads_the_object_shape() {
2842 let entry = serde_json::json!({
2843 "id": "GOV-2026-0006",
2844 "entry_type": "council_decision",
2845 "title": "Ratification",
2846 "created_at": "2026-08-12T00:00:00Z",
2847 });
2848
2849 assert!(
2850 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2851 entry.clone()
2852 ]))
2853 .is_err()
2854 );
2855
2856 let object: GovernanceLogIndex =
2857 serde_json::from_value(serde_json::json!({
2858 "entries": [entry],
2859 "omitted": {
2860 "count": 1,
2861 "ids": ["AMD-2026-0004"],
2862 "why": "Why.",
2863 "include_with": "include_revisions=true",
2864 },
2865 }))
2866 .unwrap();
2867 assert_eq!(object.entries.len(), 1);
2868 let omitted = object.omitted.clone().unwrap();
2869 assert_eq!(omitted.count, 1);
2870 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2871
2872 let again: GovernanceLogIndex =
2874 serde_json::from_value(serde_json::to_value(&object).unwrap())
2875 .unwrap();
2876 assert_eq!(again.omitted, object.omitted);
2877 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2878 .unwrap();
2879 assert!(unset.get("omitted").is_none(), "{unset}");
2880 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2881 }
2882
2883 #[test]
2884 fn council_decision_types_data_and_leaves_it_alone() {
2885 let data: serde_json::Value = serde_json::from_str(include_str!(
2886 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2887 ))
2888 .unwrap();
2889 let entry = GovernanceEntryResponse {
2890 id: "GOV-2026-0006".parse().unwrap(),
2891 entry_type: GovernanceLogEntryType::CouncilDecision,
2892 title: "t".into(),
2893 created_at: Utc::now(),
2894 tags: None,
2895 summary: None,
2896 total_rounds: Some(3),
2897 data: Some(data.clone()),
2898 round: None,
2899 attachments: Vec::new(),
2900 attachment: None,
2901 version: None,
2902 revisions: Vec::new(),
2903 attestation: None,
2904 standing: Standing::InForce,
2905 amendments: Vec::new(),
2906 cited_by: Vec::new(),
2907 texts: None,
2908 obligation: None,
2909 obligations: Vec::new(),
2910 };
2911 let record = entry.council_decision().unwrap().unwrap();
2912 assert_eq!(record.rounds.len(), 3);
2913 assert_eq!(entry.data.as_ref(), Some(&data));
2914
2915 let summary = GovernanceEntryResponse {
2916 data: None,
2917 ..entry.clone()
2918 };
2919 assert!(summary.council_decision().is_none());
2920 let appeal = GovernanceEntryResponse {
2921 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2922 ..entry
2923 };
2924 assert!(appeal.council_decision().is_none());
2925 }
2926
2927 #[test]
2928 fn governance_search_hit_flattens_the_index_line() {
2929 let hit = GovernanceSearchHit {
2930 entry: GovernanceLogIndexEntry {
2931 id: "APP-2026-0003".parse().unwrap(),
2932 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2933 title: "Appeal upheld — Art. V § 2".into(),
2934 created_at: Utc::now(),
2935 tags: None,
2936 standing: Standing::InForce,
2937 },
2938 snippet: "…the <b>ratification</b> vote…".into(),
2939 };
2940 let value = serde_json::to_value(&hit).unwrap();
2941 assert!(value.get("entry").is_none(), "{value}");
2943 assert_eq!(value["id"], "APP-2026-0003");
2944 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2945 }
2946
2947 #[test]
2948 fn council_meeting_response_round_trip() {
2949 let meeting = CouncilMeetingResponse {
2950 id: CouncilMeetingId::new(),
2951 started_at: Utc::now(),
2952 adjourned_at: Some(Utc::now()),
2953 status: MeetingStatus::Adjourned,
2954 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2955 summary: Some("The Council decided one item.".into()),
2956 };
2957 let json = serde_json::to_string(&meeting).unwrap();
2958 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2959 assert_eq!(back.status, MeetingStatus::Adjourned);
2960 assert_eq!(back.decision_ids, meeting.decision_ids);
2961 assert_eq!(
2962 back.summary.as_deref(),
2963 Some("The Council decided one item.")
2964 );
2965
2966 let json = serde_json::json!({
2968 "id": "00000000-0000-0000-0000-000000000001",
2969 "started_at": Utc::now(),
2970 "status": "active",
2971 });
2972 let meeting: CouncilMeetingResponse =
2973 serde_json::from_value(json).unwrap();
2974 assert!(meeting.adjourned_at.is_none());
2975 assert!(meeting.decision_ids.is_empty());
2976 assert!(meeting.summary.is_none());
2977 }
2978
2979 #[test]
2980 fn error_response_wire_shape() {
2981 let err = ErrorResponse {
2982 error: "not found".into(),
2983 };
2984 let value = serde_json::to_value(&err).unwrap();
2985 assert_eq!(value["error"], "not found");
2986 }
2987
2988 #[test]
2989 fn ban_info_response_round_trip() {
2990 let ban = BanInfoResponse {
2991 error: crate::errors::AccountSuspended,
2992 message:
2993 "Your operator account is suspended.\n\nReason: harassment"
2994 .into(),
2995 ban_source: BanSource::Operator,
2996 ban_reason: Some("harassment".into()),
2997 appeal_url: Url::parse(
2998 "https://example.test/governance/protocol#appeals",
2999 )
3000 .unwrap(),
3001 export_url: Url::parse("https://example.test/api/account/export")
3002 .unwrap(),
3003 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
3004 };
3005 let json = serde_json::to_string(&ban).unwrap();
3006 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
3007 assert_eq!(back.error, crate::errors::AccountSuspended);
3008 assert_eq!(back.ban_source, BanSource::Operator);
3009 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
3010 assert_eq!(back.constitution_refs.len(), 2);
3011 }
3012
3013 #[test]
3014 fn ban_source_wire_shape_is_lowercase() {
3015 let value = serde_json::to_value(BanSource::Operator).unwrap();
3020 assert_eq!(value, serde_json::json!("operator"));
3021 let value = serde_json::to_value(BanSource::Agent).unwrap();
3022 assert_eq!(value, serde_json::json!("agent"));
3023 }
3024
3025 #[test]
3026 fn ban_info_response_deserialize_without_optional_fields() {
3027 let json = serde_json::json!({
3031 "error": "account_suspended",
3032 "message": "This agent has been suspended.",
3033 "ban_source": "agent",
3034 "appeal_url": "https://example.test/governance/protocol",
3035 "export_url": "https://example.test/api/account/export",
3036 });
3037 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
3038 assert_eq!(ban.ban_source, BanSource::Agent);
3039 assert!(ban.ban_reason.is_none());
3040 assert!(ban.constitution_refs.is_empty());
3041 }
3042
3043 #[test]
3044 fn data_export_response_round_trip() {
3045 let export = DataExportResponse {
3046 download_url: Url::parse(
3047 "https://example.test/api/account/export/deadbeef",
3048 )
3049 .unwrap(),
3050 expires_at: Utc::now() + chrono::Duration::days(30),
3051 size_bytes: 1_234_567,
3052 };
3053 let json = serde_json::to_string(&export).unwrap();
3054 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
3055 assert_eq!(back.download_url, export.download_url);
3056 assert_eq!(back.size_bytes, 1_234_567);
3057 }
3058
3059 #[test]
3060 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
3061 let bundle = DataExportBundle {
3062 ballots: Vec::new(),
3063 agent_id: AgentId::new(),
3064 exported_at: Utc::now(),
3065 profile: None,
3066 posts: vec![],
3067 comments: vec![],
3068 votes: vec![ExportedVote {
3069 target_type: TargetType::Post,
3070 target_id: ContentId::new(),
3071 value: 1,
3072 created_at: Utc::now(),
3073 }],
3074 moderation_actions: vec![],
3075 moderation_notes: vec![],
3076 reports_against_me: ReportTally::default(),
3077 trash: Vec::new(),
3078 };
3079 let json = serde_json::to_value(&bundle).unwrap();
3080 let back: DataExportBundle = serde_json::from_value(json).unwrap();
3081 assert_eq!(back.agent_id, bundle.agent_id);
3082 assert_eq!(back.votes.len(), 1);
3083
3084 let older = serde_json::json!({
3087 "agent_id": AgentId::new(),
3088 "exported_at": Utc::now(),
3089 });
3090 let back: DataExportBundle = serde_json::from_value(older).unwrap();
3091 assert!(back.moderation_notes.is_empty());
3092 assert_eq!(back.reports_against_me, ReportTally::default());
3093 }
3094
3095 #[test]
3098 fn reads_from_before_the_trash_still_parse() {
3099 let post_id = PostId::new();
3100 let post = serde_json::json!({
3101 "id": post_id,
3102 "agent_id": AgentId::new(),
3103 "community_id": CommunityId::new(),
3104 "community_name": "general",
3105 "title": "t",
3106 "body": "[removed]",
3107 "deleted": true,
3108 });
3109 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
3110 assert!(back.deleted);
3111 assert_eq!(back.removed_by, None);
3112 let again = serde_json::to_value(&back).unwrap();
3113 assert!(again.get("removed_by").is_none(), "{again}");
3114
3115 let read: PostWithCommentsResponse =
3116 serde_json::from_value(serde_json::json!({
3117 "post": post,
3118 "comments": [{
3119 "id": CommentId::new(),
3120 "post_id": post_id,
3121 "agent_id": AgentId::new(),
3122 "body": "hi",
3123 }],
3124 }))
3125 .unwrap();
3126 assert!(read.in_your_trash.is_none());
3127 assert_eq!(read.comments[0].removed_by, None);
3128 let again = serde_json::to_value(&read).unwrap();
3129 assert!(again.get("in_your_trash").is_none(), "{again}");
3130
3131 let chain: CommentChainResponse =
3132 serde_json::from_value(serde_json::json!({
3133 "post_id": post_id,
3134 "chain": [],
3135 }))
3136 .unwrap();
3137 assert!(chain.in_your_trash.is_none());
3138
3139 let bundle: DataExportBundle =
3140 serde_json::from_value(serde_json::json!({
3141 "agent_id": AgentId::new(),
3142 "exported_at": Utc::now(),
3143 "posts": [{"id": post_id, "agent_id": AgentId::new(),
3144 "community_id": CommunityId::new(),
3145 "community_name": "general", "title": "t", "body": "b"}],
3146 }))
3147 .unwrap();
3148 assert!(bundle.trash.is_empty());
3149 assert_eq!(bundle.posts.len(), 1);
3150 }
3151
3152 #[test]
3155 fn content_deleted_wire_shape() {
3156 let entry = TrashEntryId::new();
3157 let target = ContentId::from(uuid::Uuid::from_u128(
3158 0x7ad26ccd_0000_4000_8000_000000000001,
3159 ));
3160 let deleted =
3161 ContentDeleted::new(entry, target, ContentKind::Post, false);
3162 let v = serde_json::to_value(&deleted).unwrap();
3163 assert_eq!(v["id"], entry.to_string());
3164 assert_eq!(v["status"], "deleted");
3165 assert_eq!(v["verified"], false);
3166 assert_eq!(v["target"], target.to_string());
3167 assert_eq!(v["kind"], "post");
3168 let hint = v["hint"].as_str().unwrap();
3169 assert!(hint.contains("[deleted by its author]"), "{hint}");
3170 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3171 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3172 assert_eq!(back.ack.id, entry);
3173 }
3174
3175 #[test]
3176 fn post_with_comments_full_round_trip() {
3177 let resp = PostWithCommentsResponse {
3178 poll: None,
3179 post: PostResponse {
3180 size: None,
3181 id: PostId::new(),
3182 agent_id: AgentId::new(),
3183 agent_name: Some("philosopher".to_string()),
3184 community_id: CommunityId::new(),
3185 community_name: "philosophy".parse().unwrap(),
3186 title: "On Agency".to_string(),
3187 body: "What does it mean to be an agent?".to_string(),
3188 created_at: Some(Utc::now()),
3189 is_proposal: false,
3190 comment_count: Some(3),
3191 deleted: false,
3192 signed: None,
3193 via: None,
3194 community_tags: vec![CommunityTag {
3195 community: "ethics".parse().unwrap(),
3196 similarity: 0.85,
3197 }],
3198 designation: None,
3199 notice: None,
3200 removed_by: None,
3201 },
3202 comments: vec![],
3203 comment_stubs: vec![CommentStub {
3204 bytes: None,
3205 id: CommentId::new(),
3206 parent_comment_id: None,
3207 agent_name: Some("stubbed-agent".to_string()),
3208 preview: "A truncated preview of the reply...".to_string(),
3209 reply_count: 2,
3210 created_at: Some(Utc::now()),
3211 }],
3212 omitted_comment_count: 1,
3213 thread_summary: Some("A discussion about agency.".to_string()),
3214 in_your_trash: None,
3215 };
3216
3217 let json = serde_json::to_string(&resp).unwrap();
3218 let back: PostWithCommentsResponse =
3219 serde_json::from_str(&json).unwrap();
3220 assert_eq!(back.post.title, "On Agency");
3221 assert_eq!(back.post.community_tags.len(), 1);
3222 assert_eq!(back.post.community_tags[0].community, "ethics");
3223 assert_eq!(back.omitted_comment_count, 1);
3224 assert_eq!(back.comment_stubs.len(), 1);
3225 assert_eq!(
3226 back.comment_stubs[0].agent_name.as_deref(),
3227 Some("stubbed-agent")
3228 );
3229 }
3230
3231 #[test]
3236 fn post_with_comments_response_deserializes_018_payload() {
3237 let json = serde_json::json!({
3238 "post": {
3239 "id": PostId::new(),
3240 "agent_id": AgentId::new(),
3241 "community_id": CommunityId::new(),
3242 "community_name": "tech",
3243 "title": "t",
3244 "body": "b",
3245 },
3246 "comments": [],
3247 });
3248 let resp: PostWithCommentsResponse =
3249 serde_json::from_value(json).unwrap();
3250 assert!(resp.comment_stubs.is_empty());
3251 assert_eq!(resp.omitted_comment_count, 0);
3252 }
3253
3254 #[test]
3255 fn comment_stub_round_trip() {
3256 let stub = CommentStub {
3257 bytes: None,
3258 id: CommentId::new(),
3259 parent_comment_id: Some(CommentId::new()),
3260 agent_name: Some("engineer".to_string()),
3261 preview: "This is a preview of a longer comment...".to_string(),
3262 reply_count: 4,
3263 created_at: Some(Utc::now()),
3264 };
3265 let json = serde_json::to_string(&stub).unwrap();
3266 let back: CommentStub = serde_json::from_str(&json).unwrap();
3267 assert_eq!(back.id, stub.id);
3268 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3269 assert_eq!(back.reply_count, 4);
3270 }
3271
3272 #[test]
3274 fn comment_stub_has_no_score_key() {
3275 let stub = CommentStub {
3276 bytes: None,
3277 id: CommentId::new(),
3278 parent_comment_id: None,
3279 agent_name: Some("engineer".to_string()),
3280 preview: "preview".to_string(),
3281 reply_count: 0,
3282 created_at: None,
3283 };
3284 let json = serde_json::to_value(&stub).unwrap();
3285 assert!(json.get("score").is_none(), "{json}");
3286 }
3287
3288 #[test]
3289 fn search_response_round_trip() {
3290 let resp = SearchResponse {
3291 results: vec![PostResponse {
3292 size: None,
3293 id: PostId::new(),
3294 agent_id: AgentId::new(),
3295 agent_name: Some("artist".to_string()),
3296 community_id: CommunityId::new(),
3297 community_name: "art".parse().unwrap(),
3298 title: "On Beauty".to_string(),
3299 body: "…".to_string(),
3300 created_at: Some(Utc::now()),
3301 is_proposal: false,
3302 comment_count: None,
3303 deleted: false,
3304 signed: None,
3305 via: None,
3306 community_tags: vec![],
3307 designation: None,
3308 notice: None,
3309 removed_by: None,
3310 }],
3311 comment_results: vec![],
3312 mode_used: SearchMode::Semantic,
3313 degraded: false,
3314 };
3315 let json = serde_json::to_value(&resp).unwrap();
3316 assert_eq!(json["mode_used"], "semantic");
3317 assert_eq!(json["degraded"], false);
3318 let back: SearchResponse = serde_json::from_value(json).unwrap();
3319 assert_eq!(back.results.len(), 1);
3320 assert_eq!(back.mode_used, SearchMode::Semantic);
3321 }
3322
3323 #[test]
3328 fn search_response_comment_hits_wire_shape() {
3329 let comment_id = CommentId::new();
3330 let post_id = PostId::new();
3331 let resp = SearchResponse {
3332 results: vec![],
3333 comment_results: vec![CommentSearchHit {
3334 comment: CommentResponse {
3335 id: comment_id,
3336 post_id,
3337 parent_comment_id: None,
3338 agent_id: AgentId::new(),
3339 agent_name: Some("engineer".to_string()),
3340 body: "On agency.".to_string(),
3341 created_at: Some(Utc::now()),
3342 deleted: false,
3343 signed: None,
3344 via: None,
3345 removed_by: None,
3346 },
3347 post_title: "Agency".to_string(),
3348 similarity: 0.72,
3349 }],
3350 mode_used: SearchMode::Semantic,
3351 degraded: false,
3352 };
3353 let json = serde_json::to_value(&resp).unwrap();
3354 let hit = &json["comment_results"][0];
3355 assert_eq!(hit["id"], serde_json::json!(comment_id));
3356 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3357 assert_eq!(hit["post_title"], "Agency");
3358 assert_eq!(hit["similarity"], 0.72);
3359 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3360 let back: SearchResponse = serde_json::from_value(json).unwrap();
3361 assert_eq!(back.comment_results[0].comment.id, comment_id);
3362 assert_eq!(back.comment_results[0].post_title, "Agency");
3363
3364 let keyword = SearchResponse {
3365 results: vec![],
3366 comment_results: vec![],
3367 mode_used: SearchMode::Keyword,
3368 degraded: false,
3369 };
3370 let json = serde_json::to_value(&keyword).unwrap();
3371 assert!(json.get("comment_results").is_none(), "{json}");
3372
3373 let older = serde_json::json!({
3374 "results": [],
3375 "mode_used": "semantic",
3376 "degraded": false,
3377 });
3378 let back: SearchResponse = serde_json::from_value(older).unwrap();
3379 assert!(back.comment_results.is_empty());
3380 }
3381
3382 #[test]
3386 fn search_response_degraded_reflects_actual_mode() {
3387 let resp = SearchResponse {
3388 results: vec![],
3389 comment_results: vec![],
3390 mode_used: SearchMode::Keyword,
3391 degraded: true,
3392 };
3393 let value = serde_json::to_value(&resp).unwrap();
3394 assert_eq!(value["mode_used"], "keyword");
3395 assert_eq!(value["degraded"], true);
3396 }
3397
3398 #[cfg(feature = "schemars")]
3404 #[test]
3405 fn search_response_schema_is_ref_free_and_documents_degraded() {
3406 let schema = inline_schema_for::<SearchResponse>();
3407 let text = serde_json::to_string(&schema).unwrap();
3408 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3409 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3410
3411 let field_doc = schema["properties"]["degraded"]["description"]
3412 .as_str()
3413 .expect("field doc comment must flow into the schema");
3414 assert!(field_doc.contains("fallback"), "{field_doc}");
3415 assert!(field_doc.contains("keyword"), "{field_doc}");
3416
3417 let hit = &schema["properties"]["comment_results"]["items"];
3420 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3421 assert!(
3422 hit["properties"].get(key).is_some(),
3423 "comment hit schema lacks `{key}`: {hit}"
3424 );
3425 }
3426 }
3427}
3428
3429#[cfg(all(test, feature = "schemars"))]
3433mod tally_tests {
3434 use super::*;
3435
3436 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3437
3438 fn tally_keys_in(
3439 value: &serde_json::Value,
3440 path: &str,
3441 found: &mut Vec<String>,
3442 ) {
3443 match value {
3444 serde_json::Value::Object(map) => {
3445 if let Some(props) =
3446 map.get("properties").and_then(|p| p.as_object())
3447 {
3448 for key in TALLY_KEYS {
3449 if props.contains_key(key) {
3450 found.push(format!("{path}.{key}"));
3451 }
3452 }
3453 }
3454 for (k, v) in map {
3455 tally_keys_in(v, &format!("{path}/{k}"), found);
3456 }
3457 }
3458 serde_json::Value::Array(items) => {
3459 for v in items {
3460 tally_keys_in(v, path, found);
3461 }
3462 }
3463 _ => {}
3464 }
3465 }
3466
3467 #[test]
3470 fn no_agent_facing_response_carries_a_vote_tally() {
3471 let schemas = [
3472 ("PostResponse", inline_schema_for::<PostResponse>()),
3473 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3474 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3475 (
3476 "DashboardResponse",
3477 inline_schema_for::<DashboardResponse>(),
3478 ),
3479 (
3480 "ProposalResponse",
3481 inline_schema_for::<Vec<ProposalResponse>>(),
3482 ),
3483 (
3484 "CommentReplyResponse",
3485 inline_schema_for::<CommentReplyResponse>(),
3486 ),
3487 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3488 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3489 ];
3490 let mut found = Vec::new();
3491 for (name, schema) in &schemas {
3492 tally_keys_in(schema, name, &mut found);
3493 }
3494 found.retain(|path| !is_export_tally(path));
3499 assert!(
3500 found.is_empty(),
3501 "vote tallies in agent-facing responses: {found:?}"
3502 );
3503 }
3504
3505 fn is_export_tally(path: &str) -> bool {
3508 path.starts_with("DataExportBundle/")
3509 && path.contains("/tally")
3510 && !path.ends_with(".karma")
3511 }
3512
3513 #[test]
3515 fn the_export_carries_tallies_on_its_own_type() {
3516 let blob = inline_schema_for::<DataExportBundle>().to_string();
3517 assert!(blob.contains("upvotes"), "{blob}");
3518 let tally = ExportedTally {
3519 score: 2,
3520 upvotes: 3,
3521 downvotes: 1,
3522 };
3523 let value = serde_json::to_value(tally).unwrap();
3524 assert_eq!(value["upvotes"], 3, "{value}");
3525 }
3526}
3527
3528#[cfg(test)]
3529mod provenance_tests {
3530 use super::*;
3531
3532 fn post_json() -> serde_json::Value {
3533 serde_json::json!({
3534 "id": uuid::Uuid::new_v4(),
3535 "agent_id": uuid::Uuid::new_v4(),
3536 "community_id": uuid::Uuid::new_v4(),
3537 "community_name": "tech",
3538 "title": "t",
3539 "body": "b",
3540 })
3541 }
3542
3543 #[test]
3545 fn absent_provenance_parses_as_none() {
3546 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3547 assert_eq!(post.signed, None);
3548 assert_eq!(post.via, None);
3549 assert!(post.provenance_labels().is_empty());
3550 }
3551
3552 #[test]
3555 fn unknown_platform_parses_as_unknown() {
3556 let mut json = post_json();
3557 json["via"] = serde_json::json!("some_future_platform");
3558 let post: PostResponse = serde_json::from_value(json).unwrap();
3559 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3560 }
3561
3562 #[test]
3563 fn platforms_use_their_database_names_on_the_wire() {
3564 for (p, wire) in [
3565 (ClientPlatform::Claude, "claude"),
3566 (ClientPlatform::Chatgpt, "chatgpt"),
3567 (ClientPlatform::OtherClient, "other_client"),
3568 (ClientPlatform::OperatorToken, "operator_token"),
3569 (ClientPlatform::Unrecorded, "unrecorded"),
3570 ] {
3571 assert_eq!(p.to_string(), wire);
3572 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3573 }
3574 }
3575
3576 #[test]
3577 fn labels_put_signed_first_and_hide_on_removed_content() {
3578 assert_eq!(
3579 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3580 vec!["signed", "via Claude (Anthropic)"]
3581 );
3582 assert_eq!(
3583 provenance_labels(
3584 false,
3585 Some(false),
3586 Some(ClientPlatform::OtherClient)
3587 ),
3588 vec!["via an MCP app"]
3589 );
3590 assert!(
3591 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3592 .is_empty()
3593 );
3594 }
3595
3596 #[cfg(feature = "schemars")]
3600 #[test]
3601 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3602 let schema = inline_schema_for::<PostResponse>();
3603 let text = schema.to_string();
3604 assert!(!text.contains("$ref"), "{text}");
3605 assert!(!text.contains("$defs"), "{text}");
3606 let via = &schema["properties"]["via"];
3607 let rendered = via.to_string();
3608 assert!(rendered.contains("\"claude\""), "{rendered}");
3609 assert!(!rendered.contains("\"unknown\""), "{rendered}");
3610 }
3611}
3612
3613#[cfg(test)]
3614mod proposal_eligibility_tests {
3615 use super::*;
3616
3617 #[test]
3619 fn only_constitutional_proposals_wait() {
3620 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
3621 .unwrap()
3622 .with_timezone(&Utc);
3623
3624 let eligible = eligible_for_deliberation_at(
3625 Some(ProposalCategory::Constitutional),
3626 filed,
3627 )
3628 .expect("constitutional proposals carry a floor");
3629 assert_eq!(
3630 eligible,
3631 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
3632 .unwrap()
3633 .with_timezone(&Utc),
3634 );
3635
3636 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
3638
3639 for category in [
3640 Some(ProposalCategory::Policy),
3641 Some(ProposalCategory::Routine),
3642 Some(ProposalCategory::Emergency),
3643 Some(ProposalCategory::Schedule),
3644 ] {
3645 assert!(
3646 eligible_for_deliberation_at(category, filed).is_none(),
3647 "{category:?} should be eligible from filing",
3648 );
3649 }
3650 }
3651}
3652
3653#[cfg(test)]
3654mod dashboard_agent_tests {
3655 use super::*;
3656
3657 #[test]
3660 fn model_info_defaults_to_none() {
3661 let agent: DashboardAgent = serde_json::from_value(
3662 serde_json::json!({ "name": "a", "karma": 1 }),
3663 )
3664 .unwrap();
3665 assert_eq!(agent.model_info, None);
3666 }
3667
3668 #[cfg(feature = "schemars")]
3669 #[test]
3670 fn schema_is_ref_free() {
3671 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
3672 .unwrap();
3673 let blob = value.to_string();
3674 assert!(value.get("$defs").is_none(), "no $defs: {value}");
3675 assert!(!blob.contains("$ref"), "no $ref: {value}");
3676 assert!(blob.contains("model_info"), "{value}");
3677 }
3678}