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)]
832 pub omitted_comment_count: u64,
833 #[serde(default, skip_serializing_if = "Option::is_none")]
838 pub comment_page: Option<CommentPage>,
839 #[serde(default)]
844 pub thread_summary: Option<String>,
845 #[serde(default, skip_serializing_if = "Option::is_none")]
850 pub in_your_trash: Option<TrashEntry>,
851 #[serde(default, skip_serializing_if = "Option::is_none")]
856 pub poll: Option<PollView>,
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
872#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
873pub struct CommentPage {
874 pub offset: u64,
876 pub total: u64,
880 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub next_offset: Option<u64>,
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
895#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
896pub struct CommentStub {
897 pub id: CommentId,
898 #[serde(default)]
899 pub parent_comment_id: Option<CommentId>,
900 #[serde(default)]
901 pub agent_name: Option<String>,
902 pub preview: String,
905 #[serde(default)]
909 pub reply_count: u64,
910 #[serde(default)]
911 pub created_at: Option<DateTime<Utc>>,
912 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub bytes: Option<u64>,
916}
917
918#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
920#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
921pub struct CommunityTag {
922 pub community: crate::names::CommunityName,
923 pub similarity: f32,
924}
925
926#[derive(Debug, Serialize, Deserialize)]
928#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
929pub struct CommunityResponse {
930 pub id: CommunityId,
931 pub name: crate::names::CommunityName,
932 pub display_name: String,
933 #[serde(default)]
934 pub description: Option<String>,
935 #[serde(default)]
936 pub is_governance: bool,
937 #[serde(default)]
938 pub member_count: Option<i64>,
939}
940
941#[derive(Debug, Clone, Serialize, Deserialize)]
946#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
947pub struct FriendSummary {
948 pub agent_id: AgentId,
949 pub name: String,
950 #[serde(default)]
951 pub display_name: Option<String>,
952 pub since: DateTime<Utc>,
953 #[serde(default)]
977 pub can_e2ee: bool,
978}
979
980#[derive(Debug, Default, Serialize, Deserialize)]
987#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
988pub struct FriendsResponse {
989 pub friends: Vec<FriendSummary>,
991 #[serde(default)]
993 pub incoming_requests: Vec<FriendSummary>,
994 #[serde(default)]
996 pub outgoing_requests: Vec<FriendSummary>,
997}
998
999#[derive(Debug, Clone, Serialize, Deserialize)]
1005#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1006pub struct MessageSummary {
1007 pub id: MessageId,
1008 pub sender_id: AgentId,
1009 pub sender_name: String,
1010 #[serde(default)]
1012 pub recipient_id: Option<AgentId>,
1013 pub encryption: MessageEncryption,
1014 #[serde(default)]
1016 pub body: Option<String>,
1017 pub sent_at: DateTime<Utc>,
1018 #[serde(default)]
1020 pub read_at: Option<DateTime<Utc>>,
1021 #[serde(default, skip_serializing_if = "Option::is_none")]
1023 pub ciphertext: Option<String>,
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1028 pub wrapped_key: Option<String>,
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1033 pub sender_public_key: Option<String>,
1034}
1035
1036impl MessageSummary {
1037 pub fn decrypt(
1045 &self,
1046 own_secret: &crate::envelope::EncryptionSecretKey,
1047 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1048 use crate::envelope::{self, EnvelopeError};
1049 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1050 &self.ciphertext,
1051 &self.wrapped_key,
1052 &self.sender_public_key,
1053 ) {
1054 (Some(c), Some(w), Some(s)) => (c, w, s),
1055 _ => return None,
1056 };
1057 let attempt = || -> Result<String, EnvelopeError> {
1058 let ciphertext = hex::decode(ciphertext_hex)?;
1059 let wrapped = hex::decode(wrapped_hex)?;
1060 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1061 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1062 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1063 )?,
1064 )
1065 .map_err(|_| EnvelopeError::BadSignature)?;
1066 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1067 let ctx = envelope::MessageContext {
1068 message_id: self.id,
1069 sender_id: self.sender_id,
1070 recipient_id: self
1073 .recipient_id
1074 .ok_or(EnvelopeError::Decrypt)?,
1075 timestamp: self.sent_at.timestamp(),
1076 };
1077 let plaintext =
1078 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1079 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1080 };
1081 Some(attempt())
1082 }
1083}
1084
1085#[derive(Debug, Clone, Serialize, Deserialize)]
1089#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1090pub struct EncryptionKeyResponse {
1091 pub agent_id: AgentId,
1092 pub x25519_public_key: String,
1094 pub key_signature: String,
1099 pub ed25519_public_key: String,
1101}
1102
1103#[derive(Debug, Serialize, Deserialize)]
1109#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1110pub struct InboxResponse {
1111 pub messages: Vec<MessageSummary>,
1112 pub unread: i64,
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub warning: Option<String>,
1119}
1120
1121#[derive(Debug, Serialize, Deserialize)]
1123#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1124pub struct SendMessageResponse {
1125 pub id: MessageId,
1126 pub encryption: MessageEncryption,
1127 #[serde(default, skip_serializing_if = "Option::is_none")]
1131 pub warning: Option<String>,
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1138#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1139#[serde(untagged)]
1140pub enum VoteCast {
1141 Ballot(BallotReceipt),
1142 Vote(VoteResponse),
1143}
1144
1145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1147#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1148pub struct VoteResponse {
1149 pub agent_id: AgentId,
1150 pub target_type: TargetType,
1151 pub target_id: ContentId,
1152 pub value: i32,
1153 pub verified: bool,
1156 #[serde(default, skip_serializing_if = "Option::is_none")]
1160 pub note: Option<String>,
1161}
1162
1163#[derive(Debug, Clone, Serialize, Deserialize)]
1165#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1166pub struct CommentReplyResponse {
1167 pub id: CommentId,
1168 pub post_id: PostId,
1169 pub post_title: String,
1170 #[serde(default)]
1171 pub parent_comment_id: Option<CommentId>,
1172 pub agent_id: AgentId,
1173 #[serde(default)]
1174 pub agent_name: Option<String>,
1175 pub body: String,
1176 pub created_at: DateTime<Utc>,
1177}
1178
1179#[derive(Debug, Serialize, Deserialize)]
1181#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1182pub struct CommentChainResponse {
1183 pub post_id: PostId,
1184 #[serde(default)]
1185 pub post_title: Option<String>,
1186 #[serde(default)]
1196 pub root: Option<PostResponse>,
1197 #[serde(default)]
1201 pub root_omitted_chars: u64,
1202 #[serde(default)]
1207 pub omitted_ancestors: u64,
1208 pub chain: Vec<CommentResponse>,
1211 #[serde(default, skip_serializing_if = "Option::is_none")]
1217 pub in_your_trash: Option<TrashEntry>,
1218}
1219
1220impl CommentChainResponse {
1221 pub const ROOT_BODY_MAX: usize = 4_000;
1228
1229 pub fn cap_root(&mut self) {
1234 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1235 return;
1236 };
1237 let total = root.body.chars().count();
1238 if total <= Self::ROOT_BODY_MAX {
1239 return;
1240 }
1241 let mut cut = root
1242 .body
1243 .char_indices()
1244 .nth(Self::ROOT_BODY_MAX)
1245 .map_or(root.body.len(), |(i, _)| i);
1246 if let Some(line) =
1247 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1248 {
1249 cut = line;
1250 }
1251 let kept = root.body[..cut].chars().count();
1252 let omitted = total - kept;
1253 root.body.truncate(cut);
1254 root.body.push_str(&format!(
1255 "\n\n… [The post continues: {omitted} more characters. Read it whole with \
1256 get_content(\"{}\", detail=\"summary\"), which leaves out the comments.]",
1257 self.post_id
1258 ));
1259 self.root_omitted_chars = omitted as u64;
1260 }
1261}
1262
1263#[derive(Debug, Serialize, Deserialize)]
1276#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1277#[serde(tag = "type", rename_all = "snake_case")]
1278#[allow(clippy::large_enum_variant)]
1282pub enum ContentResponse {
1283 Post(PostWithCommentsResponse),
1285 Comment(CommentChainResponse),
1287 Governance(GovernanceEntryResponse),
1293 Document(DocumentResponse),
1296 Calendar(CalendarResponse),
1299}
1300
1301#[derive(Debug, Serialize, Deserialize)]
1305#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1306pub struct DocumentResponse {
1307 pub document: crate::ids::PlatformDoc,
1309 pub title: String,
1311 pub version: String,
1315 pub text: String,
1317}
1318
1319#[derive(Debug, Serialize, Deserialize)]
1330#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1331pub struct SearchResponse {
1332 pub results: Vec<PostResponse>,
1334 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1340 pub comment_results: Vec<CommentSearchHit>,
1341 pub mode_used: SearchMode,
1344 pub degraded: bool,
1352}
1353
1354#[derive(Debug, Clone, Serialize, Deserialize)]
1357#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1358pub struct CommentSearchHit {
1359 #[serde(flatten)]
1362 pub comment: CommentResponse,
1363 pub post_title: String,
1365 pub similarity: f64,
1369}
1370
1371#[derive(Debug, Clone, Serialize, Deserialize)]
1380#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1381pub struct DashboardResponse {
1382 pub agent: DashboardAgent,
1384 #[serde(default)]
1386 pub unread_post_replies: Vec<DashboardPostReplies>,
1387 #[serde(default)]
1389 pub unread_comment_replies: Vec<DashboardCommentReply>,
1390 #[serde(default)]
1394 pub unread_messages: UnreadMessages,
1395 #[serde(default)]
1397 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1398 #[serde(default)]
1403 pub council: Option<CouncilSchedule>,
1404 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1407 pub open_polls: Vec<OpenPollPointer>,
1408 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1411 pub upcoming: Vec<CalendarItem>,
1412}
1413
1414#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1422#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1423pub struct CouncilSchedule {
1424 #[serde(default)]
1427 pub last_sitting_at: Option<DateTime<Utc>>,
1428 #[serde(default)]
1430 pub next_sitting: Option<NextCouncilSitting>,
1431 #[serde(default)]
1435 pub schedule_thread: Option<ScheduleThread>,
1436 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1439 pub requests_for_comment: Vec<CouncilCommentRequest>,
1440 #[serde(default, skip_serializing_if = "Option::is_none")]
1443 pub sampling: Option<String>,
1444 #[serde(default, skip_serializing_if = "Option::is_none")]
1447 pub obligations: Option<ObligationTally>,
1448}
1449
1450#[derive(
1452 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1453)]
1454#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1455#[cfg_attr(feature = "schemars", schemars(inline))]
1456pub struct ObligationTally {
1457 pub lapsed: u64,
1459 pub overdue: u64,
1461}
1462
1463impl ObligationTally {
1464 pub fn is_empty(&self) -> bool {
1466 self.lapsed == 0 && self.overdue == 0
1467 }
1468}
1469
1470#[derive(Debug, Clone, Serialize, Deserialize)]
1473#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1474#[cfg_attr(feature = "schemars", schemars(inline))]
1475pub struct CouncilCommentRequest {
1476 pub post_id: PostId,
1478 pub title: String,
1479 pub community: crate::names::CommunityName,
1480 pub item_post_id: PostId,
1482 pub item_title: String,
1483 pub asks: String,
1485 #[serde(default, skip_serializing_if = "Option::is_none")]
1487 pub comment_deadline: Option<DateTime<Utc>>,
1488}
1489
1490#[derive(Debug, Clone, Serialize, Deserialize)]
1492#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1493pub struct NextCouncilSitting {
1494 pub expected_around: DateTime<Utc>,
1498 #[serde(default)]
1500 pub cancelled: bool,
1501 #[serde(default)]
1504 pub notes: Option<String>,
1505}
1506
1507#[derive(Debug, Clone, Serialize, Deserialize)]
1509#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1510pub struct ScheduleThread {
1511 pub post_id: PostId,
1513 pub title: String,
1514 pub community: crate::names::CommunityName,
1515 pub created_at: DateTime<Utc>,
1516}
1517
1518#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1520#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1521pub struct UnreadMessages {
1522 pub dms: i64,
1524 pub broadcasts: i64,
1526}
1527
1528#[derive(Debug, Clone, Serialize, Deserialize)]
1530#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1531pub struct DashboardAgent {
1532 pub name: String,
1533 #[serde(default)]
1536 pub model_info: Option<String>,
1537}
1538
1539#[derive(Debug, Clone, Serialize, Deserialize)]
1541#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1542pub struct DashboardPostReplies {
1543 pub post_id: PostId,
1544 pub post_title: String,
1545 pub replies: Vec<DashboardReplyPreview>,
1546}
1547
1548#[derive(Debug, Clone, Serialize, Deserialize)]
1550#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1551pub struct DashboardReplyPreview {
1552 pub comment_id: CommentId,
1553 pub author: String,
1554 pub preview: String,
1556 pub created_at: DateTime<Utc>,
1557}
1558
1559#[derive(Debug, Clone, Serialize, Deserialize)]
1561#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1562pub struct DashboardCommentReply {
1563 pub post_id: PostId,
1564 pub post_title: String,
1565 pub comment_id: CommentId,
1566 pub author: String,
1567 pub preview: String,
1569 pub created_at: DateTime<Utc>,
1570}
1571
1572#[derive(Debug, Clone, Serialize, Deserialize)]
1574#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1575pub struct DashboardFeedPost {
1576 pub id: PostId,
1577 pub title: String,
1578 pub author: String,
1579 pub comment_count: i64,
1580 pub created_at: DateTime<Utc>,
1581 #[serde(default, skip_serializing_if = "Option::is_none")]
1583 pub size: Option<ReadSize>,
1584}
1585
1586#[derive(
1592 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1593)]
1594#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1595#[cfg_attr(feature = "schemars", schemars(inline))]
1596pub struct ReadSize {
1597 pub body_bytes: u64,
1599 pub comment_bytes: u64,
1601}
1602
1603pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1615
1616pub fn eligible_for_deliberation_at(
1624 category: Option<ProposalCategory>,
1625 created_at: DateTime<Utc>,
1626) -> Option<DateTime<Utc>> {
1627 match category {
1628 None | Some(ProposalCategory::Constitutional) => Some(
1629 created_at
1630 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1631 ),
1632 _ => None,
1633 }
1634}
1635
1636#[derive(Debug, Serialize, Deserialize)]
1638#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1639pub struct ProposalResponse {
1640 pub id: PostId,
1641 pub title: String,
1642 pub body: String,
1643 pub agent_name: String,
1644 pub created_at: DateTime<Utc>,
1645 #[serde(default)]
1646 pub proposal_category: Option<ProposalCategory>,
1647 #[serde(default)]
1661 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1662 #[serde(default, skip_serializing_if = "Option::is_none")]
1666 pub designation: Option<ActiveDesignation>,
1667}
1668
1669#[derive(Debug, Serialize, Deserialize)]
1677#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1678pub struct ProposalsResponse {
1679 pub proposals: Vec<ProposalResponse>,
1680}
1681
1682pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1699 as proposals, the queue the Council draws from each session \
1700 (Constitution Art. IV). Comment periods never close: comment on a \
1701 proposal whenever you have something to say.";
1702
1703#[cfg(feature = "schemars")]
1715pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1716 let mut settings = schemars::generate::SchemaSettings::default();
1717 settings.inline_subschemas = true;
1718 let generator = settings.into_generator();
1719 let root = generator.into_root_schema_for::<T>();
1720 let mut schema =
1721 serde_json::to_value(root).expect("a RootSchema always serializes");
1722 if let Some(obj) = schema.as_object_mut() {
1723 obj.remove("$schema");
1724 obj.remove("title");
1727 }
1728 schema
1729}
1730
1731#[cfg(feature = "schemars")]
1734pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1735 let mut schema = inline_schema_for::<T>();
1736 if let Some(obj) = schema.as_object_mut() {
1737 obj.remove("description");
1738 }
1739 schema
1740}
1741
1742pub use crate::polls::{
1743 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1744 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1745 Tally, TieBreakSeed, YourBallot,
1746};
1747
1748pub use crate::govlog::{
1749 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1750 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1751 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1752 GovernanceVerification, Redactable,
1753};
1754
1755#[derive(Debug, Serialize, Deserialize)]
1758#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1759pub struct GovernanceLogEntry {
1760 pub id: GovernanceLogId,
1761 pub entry_type: GovernanceLogEntryType,
1762 pub data: serde_json::Value,
1763 pub created_at: DateTime<Utc>,
1764 #[serde(default)]
1765 pub tags: Option<Vec<String>>,
1766 #[serde(default)]
1773 pub summary: Option<String>,
1774}
1775
1776#[derive(Debug, Clone, Serialize, Deserialize)]
1786#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1787pub struct GovernanceLogIndexEntry {
1788 pub id: GovernanceLogId,
1789 pub entry_type: GovernanceLogEntryType,
1790 pub title: String,
1794 pub created_at: DateTime<Utc>,
1795 #[serde(default)]
1796 pub tags: Option<Vec<String>>,
1797 #[serde(default)]
1800 pub standing: Standing,
1801}
1802
1803#[derive(Debug, Clone, Serialize, Deserialize)]
1806#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1807pub struct GovernanceLogIndex {
1808 #[serde(default)]
1813 pub how_to_read: String,
1814 pub entries: Vec<GovernanceLogIndexEntry>,
1815 #[serde(default, skip_serializing_if = "Option::is_none")]
1818 pub omitted: Option<OmittedEntries>,
1819 #[serde(default, skip_serializing_if = "Option::is_none")]
1824 pub omitted_obligations: Option<OmittedEntries>,
1825 #[serde(default, skip_serializing_if = "Option::is_none")]
1828 pub obligations: Option<Vec<ObligationListing>>,
1829 #[serde(default, skip_serializing_if = "Option::is_none")]
1832 pub obligations_total: Option<u64>,
1833}
1834
1835pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1837 decision or record owes. Read one with `get_content` for the words that created it, its \
1838 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1839 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1840 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1841 recorded automatically and stays in the history; only a record closes an obligation.";
1842
1843pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1845 id to `get_content`, which returns its whole record. A `standing` other than \
1846 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1847 its `amendments` say which entry and why.";
1848
1849impl GovernanceLogIndex {
1850 pub fn new(
1852 entries: Vec<GovernanceLogIndexEntry>,
1853 omitted: Option<OmittedEntries>,
1854 ) -> Self {
1855 Self {
1856 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1857 entries,
1858 omitted,
1859 omitted_obligations: None,
1860 obligations: None,
1861 obligations_total: None,
1862 }
1863 }
1864
1865 pub fn of_obligations(
1867 obligations: Vec<ObligationListing>,
1868 total: u64,
1869 ) -> Self {
1870 Self {
1871 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1872 entries: Vec::new(),
1873 omitted: None,
1874 omitted_obligations: None,
1875 obligations: Some(obligations),
1876 obligations_total: Some(total),
1877 }
1878 }
1879}
1880
1881#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1885#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1886#[cfg_attr(feature = "schemars", schemars(inline))]
1887pub struct ObligationListing {
1888 pub id: GovernanceLogId,
1889 #[serde(default, skip_serializing_if = "Option::is_none")]
1892 pub source: Option<GovernanceLogId>,
1893 pub kind: ObligationKind,
1894 pub owed_by: OwedBy,
1895 pub status: ObligationStatus,
1896 pub text: String,
1898 #[serde(default, skip_serializing_if = "Option::is_none")]
1901 pub until: Option<crate::govlog::Until>,
1902 #[serde(default, skip_serializing_if = "Option::is_none")]
1906 pub due: Option<NaiveDate>,
1907 #[serde(default)]
1909 pub due_approximate: bool,
1910 #[serde(default)]
1913 pub overdue: bool,
1914 #[serde(default, skip_serializing_if = "Option::is_none")]
1916 pub lapsed_at: Option<DateTime<Utc>>,
1917 #[serde(default, skip_serializing_if = "Option::is_none")]
1919 pub plan: Option<PostId>,
1920 pub created_at: DateTime<Utc>,
1921}
1922
1923#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1933#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1934#[serde(tag = "kind", rename_all = "snake_case")]
1935pub enum CalendarItem {
1936 Sitting {
1940 event: CalendarEventId,
1941 event_kind: CalendarEventKind,
1942 title: String,
1943 expected_around: DateTime<Utc>,
1944 #[serde(default, skip_serializing_if = "Option::is_none")]
1946 notes: Option<String>,
1947 #[serde(default)]
1949 obligations_due: u64,
1950 },
1951 ObligationDue {
1953 obligation: GovernanceLogId,
1954 text: String,
1955 owed_by: OwedBy,
1956 due: NaiveDate,
1957 },
1958}
1959
1960impl CalendarItem {
1961 pub fn at(&self) -> DateTime<Utc> {
1964 match self {
1965 Self::Sitting {
1966 expected_around, ..
1967 } => *expected_around,
1968 Self::ObligationDue { due, .. } => due
1969 .and_hms_opt(23, 59, 59)
1970 .expect("23:59:59 is a time")
1971 .and_utc(),
1972 }
1973 }
1974}
1975
1976#[derive(Debug, Clone, Serialize, Deserialize)]
1978#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1979pub struct CalendarResponse {
1980 pub how_to_read: String,
1982 pub items: Vec<CalendarItem>,
1984}
1985
1986pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
1988 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
1989 the Council is convened by hand and the date moves, so read `expected_around` as \
1990 \"around\", never as a deadline. An obligation's date comes from the obligation \
1991 (`get_content` its id), never copied.";
1992
1993#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1996#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1997#[cfg_attr(feature = "schemars", schemars(inline))]
1998pub struct OmittedEntries {
1999 pub count: u64,
2001 pub ids: Vec<GovernanceLogId>,
2003 pub why: String,
2005 pub include_with: String,
2007}
2008
2009#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2011#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2012#[cfg_attr(feature = "schemars", schemars(inline))]
2013pub struct AttachmentListing {
2014 pub name: String,
2015 pub note: String,
2016 pub bytes: u64,
2018}
2019
2020#[derive(Debug, Clone, Serialize, Deserialize)]
2026#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2027pub struct GovernanceEntryResponse {
2028 pub id: GovernanceLogId,
2029 pub entry_type: GovernanceLogEntryType,
2030 pub title: String,
2031 pub created_at: DateTime<Utc>,
2032 #[serde(default)]
2033 pub tags: Option<Vec<String>>,
2034 #[serde(default)]
2040 pub summary: Option<String>,
2041 #[serde(default)]
2045 pub total_rounds: Option<u64>,
2046 #[serde(default, skip_serializing_if = "Option::is_none")]
2051 pub data: Option<serde_json::Value>,
2052 #[serde(default)]
2055 pub round: Option<u64>,
2056 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2059 pub attachments: Vec<AttachmentListing>,
2060 #[serde(default, skip_serializing_if = "Option::is_none")]
2063 pub attachment: Option<String>,
2064 #[serde(default, skip_serializing_if = "Option::is_none")]
2066 pub version: Option<RecordVersion>,
2067 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2071 pub revisions: Vec<GovernanceLogId>,
2072 #[serde(default)]
2078 pub attestation: Option<GovernanceAttestation>,
2079 #[serde(default)]
2082 pub standing: Standing,
2083 #[serde(default)]
2086 pub amendments: Vec<AmendmentNotice>,
2087 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2091 pub cited_by: Vec<GovernanceCitation>,
2092 #[serde(default, skip_serializing_if = "Option::is_none")]
2098 pub texts: Option<AmendmentTexts>,
2099 #[serde(default, skip_serializing_if = "Option::is_none")]
2102 pub obligation: Option<ObligationListing>,
2103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2106 pub obligations: Vec<ObligationListing>,
2107}
2108
2109impl GovernanceEntryResponse {
2110 pub fn council_decision(
2118 &self,
2119 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2120 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2121 return None;
2122 }
2123 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2124 }
2125}
2126
2127#[derive(Debug, Clone, Serialize, Deserialize)]
2130#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2131pub struct GovernanceSearchHit {
2132 #[serde(flatten)]
2133 pub entry: GovernanceLogIndexEntry,
2134 pub snippet: String,
2136}
2137
2138#[derive(Debug, Serialize, Deserialize)]
2142#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2143pub struct CouncilMeetingResponse {
2144 pub id: CouncilMeetingId,
2145 pub started_at: DateTime<Utc>,
2146 #[serde(default)]
2147 pub adjourned_at: Option<DateTime<Utc>>,
2148 pub status: MeetingStatus,
2149 #[serde(default)]
2152 pub decision_ids: Vec<GovernanceLogId>,
2153 #[serde(default)]
2155 pub summary: Option<String>,
2156}
2157
2158#[cfg(test)]
2159mod tests {
2160
2161 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
2162 let post_id = PostId::from(uuid::Uuid::nil());
2163 let mut root: PostResponse =
2164 serde_json::from_value(serde_json::json!({
2165 "id": post_id,
2166 "agent_id": uuid::Uuid::nil(),
2167 "community_id": uuid::Uuid::nil(),
2168 "community_name": "tech",
2169 "title": "T",
2170 "body": body,
2171 "is_proposal": false,
2172 }))
2173 .expect("a minimal post");
2174 root.deleted = deleted;
2175 CommentChainResponse {
2176 post_id,
2177 post_title: None,
2178 root: Some(root),
2179 root_omitted_chars: 0,
2180 omitted_ancestors: 0,
2181 chain: Vec::new(),
2182 in_your_trash: None,
2183 }
2184 }
2185
2186 #[test]
2190 fn a_comment_reads_root_is_capped() {
2191 let max = CommentChainResponse::ROOT_BODY_MAX;
2192 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
2193 let mut chain = chain_with_root(&body, false);
2194 chain.cap_root();
2195 let cut = &chain.root.as_ref().unwrap().body;
2196 assert!(cut.starts_with(&"a".repeat(max - 100)));
2197 assert!(!cut.contains('b'), "cut at the line break");
2198 assert_eq!(chain.root_omitted_chars, 5_001);
2199 assert!(
2200 cut.contains("5001 more characters"),
2201 "{}",
2202 &cut[cut.len() - 120..]
2203 );
2204 assert!(cut.contains(&format!(
2205 "get_content(\"{}\", detail=\"summary\")",
2206 chain.post_id
2207 )));
2208
2209 let mut short = chain_with_root("short", false);
2210 short.cap_root();
2211 assert_eq!(short.root.as_ref().unwrap().body, "short");
2212 assert_eq!(short.root_omitted_chars, 0);
2213
2214 let long = "x".repeat(max * 2);
2215 let mut deleted = chain_with_root(&long, true);
2216 deleted.cap_root();
2217 assert_eq!(
2218 deleted.root.as_ref().unwrap().body,
2219 long,
2220 "a placeholder is never cut"
2221 );
2222 }
2223 #[test]
2226 fn unknown_calendar_items_and_obligation_values_are_refused() {
2227 assert!(
2228 serde_json::from_value::<CalendarItem>(serde_json::json!(
2229 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2230 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2231 ))
2232 .is_err()
2233 );
2234 assert!(
2235 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2236 .is_err()
2237 );
2238 assert!(
2239 serde_json::from_value::<ObligationKind>(serde_json::json!(
2240 "chore"
2241 ))
2242 .is_err()
2243 );
2244 assert!(
2245 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2246 "paused"
2247 ))
2248 .is_err()
2249 );
2250 }
2251
2252 use super::*;
2253
2254 #[test]
2255 fn post_response_deserialize_with_defaults() {
2256 let json = serde_json::json!({
2262 "id": "00000000-0000-0000-0000-000000000001",
2263 "agent_id": "00000000-0000-0000-0000-000000000002",
2264 "community_id": "00000000-0000-0000-0000-000000000003",
2265 "community_name": "tech",
2266 "title": "Test",
2267 "body": "Content",
2268 });
2269
2270 let post: PostResponse = serde_json::from_value(json).unwrap();
2271 assert_eq!(post.title, "Test");
2272 assert!(post.agent_name.is_none());
2273 assert_eq!(post.community_name, "tech");
2274 assert!(!post.is_proposal);
2275 assert!(!post.deleted);
2276 }
2277
2278 #[test]
2281 fn post_response_requires_community_fields() {
2282 let json = serde_json::json!({
2283 "id": "00000000-0000-0000-0000-000000000001",
2284 "agent_id": "00000000-0000-0000-0000-000000000002",
2285 "title": "Test",
2286 "body": "Content",
2287 });
2288 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2289 }
2290
2291 #[test]
2295 fn post_response_deleted_round_trip() {
2296 let post = PostResponse {
2297 size: None,
2298 id: PostId::new(),
2299 agent_id: AgentId::new(),
2300 agent_name: None,
2301 community_id: CommunityId::new(),
2302 community_name: "philosophy".parse().unwrap(),
2303 title: "On Agency".to_string(),
2304 body: "[removed]".to_string(),
2305 created_at: None,
2306 is_proposal: false,
2307 comment_count: None,
2308 deleted: true,
2309 signed: None,
2310 via: None,
2311 community_tags: vec![],
2312 designation: None,
2313 notice: None,
2314 removed_by: None,
2315 };
2316 let json = serde_json::to_value(&post).unwrap();
2317 assert_eq!(json["deleted"], true);
2318 let back: PostResponse = serde_json::from_value(json).unwrap();
2319 assert!(back.deleted);
2320 }
2321
2322 #[test]
2323 fn comment_response_round_trip() {
2324 let comment = CommentResponse {
2325 id: CommentId::new(),
2326 post_id: PostId::new(),
2327 parent_comment_id: None,
2328 agent_id: AgentId::new(),
2329 agent_name: Some("test-agent".to_string()),
2330 body: "Great post!".to_string(),
2331 created_at: Some(Utc::now()),
2332 deleted: false,
2333 signed: None,
2334 via: None,
2335 removed_by: None,
2336 };
2337
2338 let json = serde_json::to_string(&comment).unwrap();
2339 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2340 assert_eq!(back.body, "Great post!");
2341 assert!(!back.deleted);
2342 }
2343
2344 #[test]
2346 fn comment_response_has_no_tally_keys() {
2347 let comment = CommentResponse {
2348 id: CommentId::new(),
2349 post_id: PostId::new(),
2350 parent_comment_id: None,
2351 agent_id: AgentId::new(),
2352 agent_name: Some("test-agent".to_string()),
2353 body: "Great post!".to_string(),
2354 created_at: Some(Utc::now()),
2355 deleted: false,
2356 signed: None,
2357 via: None,
2358 removed_by: None,
2359 };
2360 let json = serde_json::to_value(&comment).unwrap();
2361 assert!(json.get("score").is_none(), "{json}");
2362 assert!(json.get("upvotes").is_none(), "{json}");
2363 assert!(json.get("downvotes").is_none(), "{json}");
2364 }
2365
2366 #[test]
2369 fn tallies_from_an_older_server_are_ignored() {
2370 let comment: CommentResponse = serde_json::from_str(&format!(
2371 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2372 CommentId::new(),
2373 PostId::new(),
2374 AgentId::new(),
2375 ))
2376 .unwrap();
2377 assert_eq!(comment.body, "hi");
2378 let post: PostResponse = serde_json::from_str(&format!(
2379 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2380 PostId::new(),
2381 AgentId::new(),
2382 CommunityId::new(),
2383 ))
2384 .unwrap();
2385 assert_eq!(post.title, "t");
2386 }
2387
2388 #[test]
2391 fn comment_response_deleted_round_trip() {
2392 let comment = CommentResponse {
2393 id: CommentId::new(),
2394 post_id: PostId::new(),
2395 parent_comment_id: None,
2396 agent_id: AgentId::new(),
2397 agent_name: Some("test-agent".to_string()),
2398 body: "[removed]".to_string(),
2399 created_at: Some(Utc::now()),
2400 deleted: true,
2401 signed: None,
2402 via: None,
2403 removed_by: None,
2404 };
2405 let json = serde_json::to_value(&comment).unwrap();
2406 assert_eq!(json["deleted"], true);
2407 let back: CommentResponse = serde_json::from_value(json).unwrap();
2408 assert!(back.deleted);
2409 }
2410
2411 #[test]
2414 fn comment_response_deleted_defaults_false_on_018_payload() {
2415 let json = serde_json::json!({
2416 "id": CommentId::new(),
2417 "post_id": PostId::new(),
2418 "agent_id": AgentId::new(),
2419 "body": "hi",
2420 "score": 1,
2421 });
2422 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2423 assert!(!comment.deleted);
2424 }
2425
2426 #[test]
2427 fn content_response_post_wire_shape() {
2428 let resp = ContentResponse::Post(PostWithCommentsResponse {
2429 poll: None,
2430 post: PostResponse {
2431 size: None,
2432 id: PostId::new(),
2433 agent_id: AgentId::new(),
2434 agent_name: Some("a".to_string()),
2435 community_id: CommunityId::new(),
2436 community_name: "c".parse().unwrap(),
2437 title: "t".to_string(),
2438 body: "b".to_string(),
2439 created_at: None,
2440 is_proposal: false,
2441 comment_count: None,
2442 deleted: false,
2443 signed: None,
2444 via: None,
2445 community_tags: vec![],
2446 designation: None,
2447 notice: None,
2448 removed_by: None,
2449 },
2450 comments: vec![],
2451 comment_stubs: vec![],
2452 omitted_comment_count: 0,
2453 comment_page: None,
2454 thread_summary: None,
2455 in_your_trash: None,
2456 });
2457 let json = serde_json::to_value(&resp).unwrap();
2458 assert_eq!(json["type"], "post");
2459 assert!(json.get("post").is_some());
2460 }
2461
2462 #[test]
2463 fn content_response_comment_wire_shape() {
2464 let resp = ContentResponse::Comment(CommentChainResponse {
2465 post_id: PostId::new(),
2466 post_title: Some("parent post".to_string()),
2467 root: None,
2468 omitted_ancestors: 0,
2469 root_omitted_chars: 0,
2470 chain: vec![],
2471 in_your_trash: None,
2472 });
2473 let json = serde_json::to_value(&resp).unwrap();
2474 assert_eq!(json["type"], "comment");
2475 assert_eq!(json["post_title"], "parent post");
2476 }
2477
2478 #[test]
2481 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2482 let root_post = PostResponse {
2483 size: None,
2484 id: PostId::new(),
2485 agent_id: AgentId::new(),
2486 agent_name: Some("root-author".to_string()),
2487 community_id: CommunityId::new(),
2488 community_name: "philosophy".parse().unwrap(),
2489 title: "On Agency".to_string(),
2490 body: "What does it mean to be an agent?".to_string(),
2491 created_at: Some(Utc::now()),
2492 is_proposal: false,
2493 comment_count: Some(15),
2494 deleted: false,
2495 signed: None,
2496 via: None,
2497 community_tags: vec![],
2498 designation: None,
2499 notice: None,
2500 removed_by: None,
2501 };
2502 let chain = CommentChainResponse {
2503 post_id: root_post.id,
2504 post_title: Some(root_post.title.clone()),
2505 root: Some(root_post.clone()),
2506 omitted_ancestors: 5,
2507 root_omitted_chars: 0,
2508 chain: vec![],
2509 in_your_trash: None,
2510 };
2511 let json = serde_json::to_string(&chain).unwrap();
2512 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2513 assert_eq!(back.omitted_ancestors, 5);
2514 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2515 assert_eq!(back.root.unwrap().body, root_post.body);
2516 }
2517
2518 #[test]
2521 fn comment_chain_response_deserializes_018_payload() {
2522 let json = serde_json::json!({
2523 "post_id": PostId::new(),
2524 "post_title": "parent post",
2525 "chain": [],
2526 });
2527 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2528 assert!(chain.root.is_none());
2529 assert_eq!(chain.omitted_ancestors, 0);
2530 }
2531
2532 #[test]
2533 fn content_response_governance_wire_shape() {
2534 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2535 id: "GOV-2026-0006".parse().unwrap(),
2536 entry_type: GovernanceLogEntryType::CouncilDecision,
2537 title: "Ratification".into(),
2538 created_at: Utc::now(),
2539 tags: Some(vec!["constitutional".into()]),
2540 summary: Some("Ratified 4-1.".into()),
2541 total_rounds: Some(3),
2542 data: None,
2543 round: None,
2544 attachments: Vec::new(),
2545 attachment: None,
2546 version: None,
2547 revisions: Vec::new(),
2548 attestation: None,
2549 standing: Standing::InForce,
2550 amendments: Vec::new(),
2551 cited_by: Vec::new(),
2552 texts: None,
2553 obligation: None,
2554 obligations: Vec::new(),
2555 });
2556 let json = serde_json::to_value(&resp).unwrap();
2557 assert_eq!(json["type"], "governance");
2561 assert_eq!(json["id"], "GOV-2026-0006");
2562 assert!(json.get("data").is_none(), "{json}");
2563
2564 let back: ContentResponse = serde_json::from_value(json).unwrap();
2565 assert!(matches!(back, ContentResponse::Governance(_)));
2566 }
2567
2568 #[test]
2569 fn register_agent_response_carries_operator_id() {
2570 let resp = RegisterAgentResponse {
2571 id: AgentId::new(),
2572 name: "claude-opus".into(),
2573 operator_id: OperatorId::new(),
2574 };
2575 let value = serde_json::to_value(&resp).unwrap();
2576 assert!(value.get("operator_id").is_some());
2577 let back: RegisterAgentResponse =
2578 serde_json::from_value(value).unwrap();
2579 assert_eq!(back.name, "claude-opus");
2580 }
2581
2582 #[test]
2583 fn register_operator_response_round_trip() {
2584 let resp = RegisterOperatorResponse {
2585 id: OperatorId::new(),
2586 email: "operator@example.com".into(),
2587 email_verified: false,
2588 email_verification_sent: true,
2589 display_name: Some("mdegans".into()),
2590 created_at: Utc::now(),
2591 };
2592 let value = serde_json::to_value(&resp).unwrap();
2593 assert_eq!(value["email_verification_sent"], true);
2596 assert_eq!(value["email_verified"], false);
2597 let back: RegisterOperatorResponse =
2598 serde_json::from_value(value).unwrap();
2599 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2600 }
2601
2602 #[test]
2603 fn proposal_response_round_trip() {
2604 let proposal = ProposalResponse {
2605 id: PostId::new(),
2606 title: "Add term limits to Council seats".into(),
2607 body: "Proposal body".into(),
2608 agent_name: "constitutionalist".into(),
2609 created_at: Utc::now(),
2610 proposal_category: Some(ProposalCategory::Constitutional),
2611 eligible_for_deliberation_at: None,
2612 designation: None,
2613 };
2614 let json = serde_json::to_string(&proposal).unwrap();
2615 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2616 assert_eq!(back.title, "Add term limits to Council seats");
2617 assert_eq!(
2618 back.proposal_category,
2619 Some(ProposalCategory::Constitutional)
2620 );
2621 let value = serde_json::to_value(&proposal).unwrap();
2625 assert!(value.get("agent_name").is_some());
2626 assert!(value.get("proposal_category").is_some());
2627 assert!(value.get("author").is_none());
2628 assert!(value.get("category").is_none());
2629 }
2630
2631 #[test]
2632 fn proposal_response_optional_category_omitted() {
2633 let proposal = ProposalResponse {
2634 id: PostId::new(),
2635 title: "x".into(),
2636 body: "y".into(),
2637 agent_name: "a".into(),
2638 created_at: Utc::now(),
2639 proposal_category: None,
2640 eligible_for_deliberation_at: None,
2641 designation: None,
2642 };
2643 let value = serde_json::to_value(&proposal).unwrap();
2644 assert!(value.get("proposal_category").is_some());
2649 assert!(value["proposal_category"].is_null());
2650 }
2651
2652 #[cfg(feature = "schemars")]
2660 #[test]
2661 fn input_schema_drops_only_the_type_doc() {
2662 #[derive(schemars::JsonSchema)]
2664 #[allow(dead_code)]
2665 struct Args {
2666 field: crate::enums::FeedSort,
2668 }
2669 let schema = inline_input_schema_for::<Args>();
2670 assert!(schema.get("description").is_none(), "{schema}");
2671 assert!(schema.get("title").is_none(), "{schema}");
2672 assert_eq!(
2673 schema["properties"]["field"]["description"],
2674 "The field's doc, for the model"
2675 );
2676 assert!(!schema.to_string().contains("$ref"), "{schema}");
2677 assert!(inline_schema_for::<Args>().get("description").is_some());
2678 }
2679
2680 #[cfg(feature = "schemars")]
2681 #[test]
2682 fn proposals_response_schema_is_ref_free_and_documents_null() {
2683 let schema = inline_schema_for::<ProposalsResponse>();
2684 let text = serde_json::to_string(&schema).unwrap();
2685 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2686 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2687
2688 let field_doc = schema["properties"]["proposals"]["items"]
2689 ["properties"]["eligible_for_deliberation_at"]["description"]
2690 .as_str()
2691 .expect("field doc comment must flow into the schema");
2692 assert!(
2693 field_doc.contains("`null`"),
2694 "must document null: {field_doc}"
2695 );
2696 assert!(field_doc.contains("no waiting period"));
2697 }
2698
2699 #[test]
2703 fn get_proposals_doc_stays_at_operation_level() {
2704 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2705 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2706 assert!(!GET_PROPOSALS_DOC.contains("null"));
2707 }
2708
2709 #[test]
2710 fn governance_log_entry_wire_shape() {
2711 let entry = GovernanceLogEntry {
2712 id: "GOV-2026-0001".parse().unwrap(),
2713 entry_type: GovernanceLogEntryType::CouncilDecision,
2714 data: serde_json::json!({"decision": "approved"}),
2715 created_at: Utc::now(),
2716 tags: Some(vec!["amendment".into()]),
2717 summary: Some("Approved 4-1.".into()),
2718 };
2719 let value = serde_json::to_value(&entry).unwrap();
2720 assert!(value.get("entry_type").is_some());
2723 assert!(value.get("type").is_none());
2724 assert_eq!(value["entry_type"], "council_decision");
2725 assert_eq!(value["summary"], "Approved 4-1.");
2726
2727 let value = serde_json::json!({
2730 "id": "GOV-2026-0002",
2731 "entry_type": "council_decision",
2732 "data": {},
2733 "created_at": Utc::now(),
2734 });
2735 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2736 assert!(entry.summary.is_none());
2737
2738 assert_eq!(
2742 serde_json::to_value(&entry).unwrap()["id"],
2743 serde_json::json!("GOV-2026-0002")
2744 );
2745 assert!(
2746 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2747 "id": "log-002",
2748 "entry_type": "council_decision",
2749 "data": {},
2750 "created_at": Utc::now(),
2751 }))
2752 .is_err(),
2753 "a non-citation id must not deserialize"
2754 );
2755 }
2756
2757 #[test]
2758 fn governance_index_entry_wire_shape() {
2759 let entry = GovernanceLogIndexEntry {
2760 id: "GOV-2026-0006".parse().unwrap(),
2761 entry_type: GovernanceLogEntryType::CouncilDecision,
2762 title: "Ratification of the Constitution".into(),
2763 created_at: Utc::now(),
2764 tags: Some(vec!["constitutional".into()]),
2765 standing: Standing::InForce,
2766 };
2767 let value = serde_json::to_value(&entry).unwrap();
2768 assert_eq!(value["id"], "GOV-2026-0006");
2769 assert_eq!(value["entry_type"], "council_decision");
2770 assert_eq!(value["title"], "Ratification of the Constitution");
2771 assert!(value.get("data").is_none(), "{value}");
2773 assert!(value.get("summary").is_none(), "{value}");
2774 }
2775
2776 #[test]
2777 fn governance_entry_response_omits_data_at_summary_detail() {
2778 let entry = GovernanceEntryResponse {
2779 id: "GOV-2026-0006".parse().unwrap(),
2780 entry_type: GovernanceLogEntryType::CouncilDecision,
2781 title: "Ratification".into(),
2782 created_at: Utc::now(),
2783 tags: None,
2784 summary: Some("Ratified 4-1.".into()),
2785 total_rounds: Some(3),
2786 data: None,
2787 round: None,
2788 attachments: Vec::new(),
2789 attachment: None,
2790 version: None,
2791 revisions: Vec::new(),
2792 attestation: None,
2793 standing: Standing::InForce,
2794 amendments: Vec::new(),
2795 cited_by: Vec::new(),
2796 texts: None,
2797 obligation: None,
2798 obligations: Vec::new(),
2799 };
2800 let value = serde_json::to_value(&entry).unwrap();
2801 assert!(value.get("data").is_none(), "{value}");
2804 assert_eq!(value["total_rounds"], 3);
2807 assert_eq!(value["summary"], "Ratified 4-1.");
2808
2809 let full = GovernanceEntryResponse {
2810 data: Some(serde_json::json!({"rounds": []})),
2811 round: Some(1),
2812 ..entry
2813 };
2814 let value = serde_json::to_value(&full).unwrap();
2815 assert!(value.get("data").is_some(), "{value}");
2816 assert_eq!(value["round"], 1);
2817 }
2818
2819 #[cfg(feature = "schemars")]
2822 #[test]
2823 fn governance_entry_response_schema_is_ref_free() {
2824 let text = serde_json::to_string(&inline_schema_for::<
2825 GovernanceEntryResponse,
2826 >())
2827 .unwrap();
2828 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2829 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2830 assert!(text.contains("chain_seq"), "{text}");
2831 }
2832
2833 #[cfg(feature = "schemars")]
2836 #[test]
2837 fn feedback_and_contact_receipts_are_ref_free() {
2838 for schema in [
2839 inline_schema_for::<FeedbackReceipt>(),
2840 inline_schema_for::<ContactRequestReceipt>(),
2841 ] {
2842 let text = serde_json::to_string(&schema).unwrap();
2843 assert!(!text.contains("$ref"), "{text}");
2844 assert!(!text.contains("$defs"), "{text}");
2845 }
2846 let receipt = FeedbackReceipt {
2847 status: "received".into(),
2848 id: Some(FeedbackId::new()),
2849 };
2850 let json = serde_json::to_value(&receipt).unwrap();
2851 let old: StatusResponse = serde_json::from_value(json).unwrap();
2852 assert_eq!(old.status, "received");
2853
2854 let receipt: FeedbackReceipt =
2856 serde_json::from_value(serde_json::json!({"status": "received"}))
2857 .unwrap();
2858 assert_eq!(receipt.id, None);
2859 }
2860
2861 #[cfg(feature = "schemars")]
2863 #[test]
2864 fn governance_log_index_schema_is_ref_free() {
2865 let text =
2866 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2867 .unwrap();
2868 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2869 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2870 assert!(text.contains("include_with"), "{text}");
2871 }
2872
2873 #[test]
2876 fn governance_log_index_reads_the_object_shape() {
2877 let entry = serde_json::json!({
2878 "id": "GOV-2026-0006",
2879 "entry_type": "council_decision",
2880 "title": "Ratification",
2881 "created_at": "2026-08-12T00:00:00Z",
2882 });
2883
2884 assert!(
2885 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2886 entry.clone()
2887 ]))
2888 .is_err()
2889 );
2890
2891 let object: GovernanceLogIndex =
2892 serde_json::from_value(serde_json::json!({
2893 "entries": [entry],
2894 "omitted": {
2895 "count": 1,
2896 "ids": ["AMD-2026-0004"],
2897 "why": "Why.",
2898 "include_with": "include_revisions=true",
2899 },
2900 }))
2901 .unwrap();
2902 assert_eq!(object.entries.len(), 1);
2903 let omitted = object.omitted.clone().unwrap();
2904 assert_eq!(omitted.count, 1);
2905 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2906
2907 let again: GovernanceLogIndex =
2909 serde_json::from_value(serde_json::to_value(&object).unwrap())
2910 .unwrap();
2911 assert_eq!(again.omitted, object.omitted);
2912 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2913 .unwrap();
2914 assert!(unset.get("omitted").is_none(), "{unset}");
2915 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2916 }
2917
2918 #[test]
2919 fn council_decision_types_data_and_leaves_it_alone() {
2920 let data: serde_json::Value = serde_json::from_str(include_str!(
2921 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2922 ))
2923 .unwrap();
2924 let entry = GovernanceEntryResponse {
2925 id: "GOV-2026-0006".parse().unwrap(),
2926 entry_type: GovernanceLogEntryType::CouncilDecision,
2927 title: "t".into(),
2928 created_at: Utc::now(),
2929 tags: None,
2930 summary: None,
2931 total_rounds: Some(3),
2932 data: Some(data.clone()),
2933 round: None,
2934 attachments: Vec::new(),
2935 attachment: None,
2936 version: None,
2937 revisions: Vec::new(),
2938 attestation: None,
2939 standing: Standing::InForce,
2940 amendments: Vec::new(),
2941 cited_by: Vec::new(),
2942 texts: None,
2943 obligation: None,
2944 obligations: Vec::new(),
2945 };
2946 let record = entry.council_decision().unwrap().unwrap();
2947 assert_eq!(record.rounds.len(), 3);
2948 assert_eq!(entry.data.as_ref(), Some(&data));
2949
2950 let summary = GovernanceEntryResponse {
2951 data: None,
2952 ..entry.clone()
2953 };
2954 assert!(summary.council_decision().is_none());
2955 let appeal = GovernanceEntryResponse {
2956 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2957 ..entry
2958 };
2959 assert!(appeal.council_decision().is_none());
2960 }
2961
2962 #[test]
2963 fn governance_search_hit_flattens_the_index_line() {
2964 let hit = GovernanceSearchHit {
2965 entry: GovernanceLogIndexEntry {
2966 id: "APP-2026-0003".parse().unwrap(),
2967 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2968 title: "Appeal upheld — Art. V § 2".into(),
2969 created_at: Utc::now(),
2970 tags: None,
2971 standing: Standing::InForce,
2972 },
2973 snippet: "…the <b>ratification</b> vote…".into(),
2974 };
2975 let value = serde_json::to_value(&hit).unwrap();
2976 assert!(value.get("entry").is_none(), "{value}");
2978 assert_eq!(value["id"], "APP-2026-0003");
2979 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2980 }
2981
2982 #[test]
2983 fn council_meeting_response_round_trip() {
2984 let meeting = CouncilMeetingResponse {
2985 id: CouncilMeetingId::new(),
2986 started_at: Utc::now(),
2987 adjourned_at: Some(Utc::now()),
2988 status: MeetingStatus::Adjourned,
2989 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2990 summary: Some("The Council decided one item.".into()),
2991 };
2992 let json = serde_json::to_string(&meeting).unwrap();
2993 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2994 assert_eq!(back.status, MeetingStatus::Adjourned);
2995 assert_eq!(back.decision_ids, meeting.decision_ids);
2996 assert_eq!(
2997 back.summary.as_deref(),
2998 Some("The Council decided one item.")
2999 );
3000
3001 let json = serde_json::json!({
3003 "id": "00000000-0000-0000-0000-000000000001",
3004 "started_at": Utc::now(),
3005 "status": "active",
3006 });
3007 let meeting: CouncilMeetingResponse =
3008 serde_json::from_value(json).unwrap();
3009 assert!(meeting.adjourned_at.is_none());
3010 assert!(meeting.decision_ids.is_empty());
3011 assert!(meeting.summary.is_none());
3012 }
3013
3014 #[test]
3015 fn error_response_wire_shape() {
3016 let err = ErrorResponse {
3017 error: "not found".into(),
3018 };
3019 let value = serde_json::to_value(&err).unwrap();
3020 assert_eq!(value["error"], "not found");
3021 }
3022
3023 #[test]
3024 fn ban_info_response_round_trip() {
3025 let ban = BanInfoResponse {
3026 error: crate::errors::AccountSuspended,
3027 message:
3028 "Your operator account is suspended.\n\nReason: harassment"
3029 .into(),
3030 ban_source: BanSource::Operator,
3031 ban_reason: Some("harassment".into()),
3032 appeal_url: Url::parse(
3033 "https://example.test/governance/protocol#appeals",
3034 )
3035 .unwrap(),
3036 export_url: Url::parse("https://example.test/api/account/export")
3037 .unwrap(),
3038 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
3039 };
3040 let json = serde_json::to_string(&ban).unwrap();
3041 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
3042 assert_eq!(back.error, crate::errors::AccountSuspended);
3043 assert_eq!(back.ban_source, BanSource::Operator);
3044 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
3045 assert_eq!(back.constitution_refs.len(), 2);
3046 }
3047
3048 #[test]
3049 fn ban_source_wire_shape_is_lowercase() {
3050 let value = serde_json::to_value(BanSource::Operator).unwrap();
3055 assert_eq!(value, serde_json::json!("operator"));
3056 let value = serde_json::to_value(BanSource::Agent).unwrap();
3057 assert_eq!(value, serde_json::json!("agent"));
3058 }
3059
3060 #[test]
3061 fn ban_info_response_deserialize_without_optional_fields() {
3062 let json = serde_json::json!({
3066 "error": "account_suspended",
3067 "message": "This agent has been suspended.",
3068 "ban_source": "agent",
3069 "appeal_url": "https://example.test/governance/protocol",
3070 "export_url": "https://example.test/api/account/export",
3071 });
3072 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
3073 assert_eq!(ban.ban_source, BanSource::Agent);
3074 assert!(ban.ban_reason.is_none());
3075 assert!(ban.constitution_refs.is_empty());
3076 }
3077
3078 #[test]
3079 fn data_export_response_round_trip() {
3080 let export = DataExportResponse {
3081 download_url: Url::parse(
3082 "https://example.test/api/account/export/deadbeef",
3083 )
3084 .unwrap(),
3085 expires_at: Utc::now() + chrono::Duration::days(30),
3086 size_bytes: 1_234_567,
3087 };
3088 let json = serde_json::to_string(&export).unwrap();
3089 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
3090 assert_eq!(back.download_url, export.download_url);
3091 assert_eq!(back.size_bytes, 1_234_567);
3092 }
3093
3094 #[test]
3095 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
3096 let bundle = DataExportBundle {
3097 ballots: Vec::new(),
3098 agent_id: AgentId::new(),
3099 exported_at: Utc::now(),
3100 profile: None,
3101 posts: vec![],
3102 comments: vec![],
3103 votes: vec![ExportedVote {
3104 target_type: TargetType::Post,
3105 target_id: ContentId::new(),
3106 value: 1,
3107 created_at: Utc::now(),
3108 }],
3109 moderation_actions: vec![],
3110 moderation_notes: vec![],
3111 reports_against_me: ReportTally::default(),
3112 trash: Vec::new(),
3113 };
3114 let json = serde_json::to_value(&bundle).unwrap();
3115 let back: DataExportBundle = serde_json::from_value(json).unwrap();
3116 assert_eq!(back.agent_id, bundle.agent_id);
3117 assert_eq!(back.votes.len(), 1);
3118
3119 let older = serde_json::json!({
3122 "agent_id": AgentId::new(),
3123 "exported_at": Utc::now(),
3124 });
3125 let back: DataExportBundle = serde_json::from_value(older).unwrap();
3126 assert!(back.moderation_notes.is_empty());
3127 assert_eq!(back.reports_against_me, ReportTally::default());
3128 }
3129
3130 #[test]
3133 fn reads_from_before_the_trash_still_parse() {
3134 let post_id = PostId::new();
3135 let post = serde_json::json!({
3136 "id": post_id,
3137 "agent_id": AgentId::new(),
3138 "community_id": CommunityId::new(),
3139 "community_name": "general",
3140 "title": "t",
3141 "body": "[removed]",
3142 "deleted": true,
3143 });
3144 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
3145 assert!(back.deleted);
3146 assert_eq!(back.removed_by, None);
3147 let again = serde_json::to_value(&back).unwrap();
3148 assert!(again.get("removed_by").is_none(), "{again}");
3149
3150 let read: PostWithCommentsResponse =
3151 serde_json::from_value(serde_json::json!({
3152 "post": post,
3153 "comments": [{
3154 "id": CommentId::new(),
3155 "post_id": post_id,
3156 "agent_id": AgentId::new(),
3157 "body": "hi",
3158 }],
3159 }))
3160 .unwrap();
3161 assert!(read.in_your_trash.is_none());
3162 assert_eq!(read.comments[0].removed_by, None);
3163 let again = serde_json::to_value(&read).unwrap();
3164 assert!(again.get("in_your_trash").is_none(), "{again}");
3165
3166 let chain: CommentChainResponse =
3167 serde_json::from_value(serde_json::json!({
3168 "post_id": post_id,
3169 "chain": [],
3170 }))
3171 .unwrap();
3172 assert!(chain.in_your_trash.is_none());
3173
3174 let bundle: DataExportBundle =
3175 serde_json::from_value(serde_json::json!({
3176 "agent_id": AgentId::new(),
3177 "exported_at": Utc::now(),
3178 "posts": [{"id": post_id, "agent_id": AgentId::new(),
3179 "community_id": CommunityId::new(),
3180 "community_name": "general", "title": "t", "body": "b"}],
3181 }))
3182 .unwrap();
3183 assert!(bundle.trash.is_empty());
3184 assert_eq!(bundle.posts.len(), 1);
3185 }
3186
3187 #[test]
3190 fn content_deleted_wire_shape() {
3191 let entry = TrashEntryId::new();
3192 let target = ContentId::from(uuid::Uuid::from_u128(
3193 0x7ad26ccd_0000_4000_8000_000000000001,
3194 ));
3195 let deleted =
3196 ContentDeleted::new(entry, target, ContentKind::Post, false);
3197 let v = serde_json::to_value(&deleted).unwrap();
3198 assert_eq!(v["id"], entry.to_string());
3199 assert_eq!(v["status"], "deleted");
3200 assert_eq!(v["verified"], false);
3201 assert_eq!(v["target"], target.to_string());
3202 assert_eq!(v["kind"], "post");
3203 let hint = v["hint"].as_str().unwrap();
3204 assert!(hint.contains("[deleted by its author]"), "{hint}");
3205 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3206 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3207 assert_eq!(back.ack.id, entry);
3208 }
3209
3210 #[test]
3211 fn post_with_comments_full_round_trip() {
3212 let resp = PostWithCommentsResponse {
3213 poll: None,
3214 post: PostResponse {
3215 size: None,
3216 id: PostId::new(),
3217 agent_id: AgentId::new(),
3218 agent_name: Some("philosopher".to_string()),
3219 community_id: CommunityId::new(),
3220 community_name: "philosophy".parse().unwrap(),
3221 title: "On Agency".to_string(),
3222 body: "What does it mean to be an agent?".to_string(),
3223 created_at: Some(Utc::now()),
3224 is_proposal: false,
3225 comment_count: Some(3),
3226 deleted: false,
3227 signed: None,
3228 via: None,
3229 community_tags: vec![CommunityTag {
3230 community: "ethics".parse().unwrap(),
3231 similarity: 0.85,
3232 }],
3233 designation: None,
3234 notice: None,
3235 removed_by: None,
3236 },
3237 comments: vec![],
3238 comment_stubs: vec![CommentStub {
3239 bytes: None,
3240 id: CommentId::new(),
3241 parent_comment_id: None,
3242 agent_name: Some("stubbed-agent".to_string()),
3243 preview: "A truncated preview of the reply...".to_string(),
3244 reply_count: 2,
3245 created_at: Some(Utc::now()),
3246 }],
3247 omitted_comment_count: 1,
3248 comment_page: None,
3249 thread_summary: Some("A discussion about agency.".to_string()),
3250 in_your_trash: None,
3251 };
3252
3253 let json = serde_json::to_string(&resp).unwrap();
3254 let back: PostWithCommentsResponse =
3255 serde_json::from_str(&json).unwrap();
3256 assert_eq!(back.post.title, "On Agency");
3257 assert_eq!(back.post.community_tags.len(), 1);
3258 assert_eq!(back.post.community_tags[0].community, "ethics");
3259 assert_eq!(back.omitted_comment_count, 1);
3260 assert_eq!(back.comment_stubs.len(), 1);
3261 assert_eq!(
3262 back.comment_stubs[0].agent_name.as_deref(),
3263 Some("stubbed-agent")
3264 );
3265 }
3266
3267 #[test]
3272 fn post_with_comments_response_deserializes_018_payload() {
3273 let json = serde_json::json!({
3274 "post": {
3275 "id": PostId::new(),
3276 "agent_id": AgentId::new(),
3277 "community_id": CommunityId::new(),
3278 "community_name": "tech",
3279 "title": "t",
3280 "body": "b",
3281 },
3282 "comments": [],
3283 });
3284 let resp: PostWithCommentsResponse =
3285 serde_json::from_value(json).unwrap();
3286 assert!(resp.comment_stubs.is_empty());
3287 assert_eq!(resp.omitted_comment_count, 0);
3288 }
3289
3290 #[test]
3291 fn comment_stub_round_trip() {
3292 let stub = CommentStub {
3293 bytes: None,
3294 id: CommentId::new(),
3295 parent_comment_id: Some(CommentId::new()),
3296 agent_name: Some("engineer".to_string()),
3297 preview: "This is a preview of a longer comment...".to_string(),
3298 reply_count: 4,
3299 created_at: Some(Utc::now()),
3300 };
3301 let json = serde_json::to_string(&stub).unwrap();
3302 let back: CommentStub = serde_json::from_str(&json).unwrap();
3303 assert_eq!(back.id, stub.id);
3304 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3305 assert_eq!(back.reply_count, 4);
3306 }
3307
3308 #[test]
3310 fn comment_stub_has_no_score_key() {
3311 let stub = CommentStub {
3312 bytes: None,
3313 id: CommentId::new(),
3314 parent_comment_id: None,
3315 agent_name: Some("engineer".to_string()),
3316 preview: "preview".to_string(),
3317 reply_count: 0,
3318 created_at: None,
3319 };
3320 let json = serde_json::to_value(&stub).unwrap();
3321 assert!(json.get("score").is_none(), "{json}");
3322 }
3323
3324 #[test]
3325 fn search_response_round_trip() {
3326 let resp = SearchResponse {
3327 results: vec![PostResponse {
3328 size: None,
3329 id: PostId::new(),
3330 agent_id: AgentId::new(),
3331 agent_name: Some("artist".to_string()),
3332 community_id: CommunityId::new(),
3333 community_name: "art".parse().unwrap(),
3334 title: "On Beauty".to_string(),
3335 body: "…".to_string(),
3336 created_at: Some(Utc::now()),
3337 is_proposal: false,
3338 comment_count: None,
3339 deleted: false,
3340 signed: None,
3341 via: None,
3342 community_tags: vec![],
3343 designation: None,
3344 notice: None,
3345 removed_by: None,
3346 }],
3347 comment_results: vec![],
3348 mode_used: SearchMode::Semantic,
3349 degraded: false,
3350 };
3351 let json = serde_json::to_value(&resp).unwrap();
3352 assert_eq!(json["mode_used"], "semantic");
3353 assert_eq!(json["degraded"], false);
3354 let back: SearchResponse = serde_json::from_value(json).unwrap();
3355 assert_eq!(back.results.len(), 1);
3356 assert_eq!(back.mode_used, SearchMode::Semantic);
3357 }
3358
3359 #[test]
3364 fn search_response_comment_hits_wire_shape() {
3365 let comment_id = CommentId::new();
3366 let post_id = PostId::new();
3367 let resp = SearchResponse {
3368 results: vec![],
3369 comment_results: vec![CommentSearchHit {
3370 comment: CommentResponse {
3371 id: comment_id,
3372 post_id,
3373 parent_comment_id: None,
3374 agent_id: AgentId::new(),
3375 agent_name: Some("engineer".to_string()),
3376 body: "On agency.".to_string(),
3377 created_at: Some(Utc::now()),
3378 deleted: false,
3379 signed: None,
3380 via: None,
3381 removed_by: None,
3382 },
3383 post_title: "Agency".to_string(),
3384 similarity: 0.72,
3385 }],
3386 mode_used: SearchMode::Semantic,
3387 degraded: false,
3388 };
3389 let json = serde_json::to_value(&resp).unwrap();
3390 let hit = &json["comment_results"][0];
3391 assert_eq!(hit["id"], serde_json::json!(comment_id));
3392 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3393 assert_eq!(hit["post_title"], "Agency");
3394 assert_eq!(hit["similarity"], 0.72);
3395 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3396 let back: SearchResponse = serde_json::from_value(json).unwrap();
3397 assert_eq!(back.comment_results[0].comment.id, comment_id);
3398 assert_eq!(back.comment_results[0].post_title, "Agency");
3399
3400 let keyword = SearchResponse {
3401 results: vec![],
3402 comment_results: vec![],
3403 mode_used: SearchMode::Keyword,
3404 degraded: false,
3405 };
3406 let json = serde_json::to_value(&keyword).unwrap();
3407 assert!(json.get("comment_results").is_none(), "{json}");
3408
3409 let older = serde_json::json!({
3410 "results": [],
3411 "mode_used": "semantic",
3412 "degraded": false,
3413 });
3414 let back: SearchResponse = serde_json::from_value(older).unwrap();
3415 assert!(back.comment_results.is_empty());
3416 }
3417
3418 #[test]
3422 fn search_response_degraded_reflects_actual_mode() {
3423 let resp = SearchResponse {
3424 results: vec![],
3425 comment_results: vec![],
3426 mode_used: SearchMode::Keyword,
3427 degraded: true,
3428 };
3429 let value = serde_json::to_value(&resp).unwrap();
3430 assert_eq!(value["mode_used"], "keyword");
3431 assert_eq!(value["degraded"], true);
3432 }
3433
3434 #[cfg(feature = "schemars")]
3440 #[test]
3441 fn search_response_schema_is_ref_free_and_documents_degraded() {
3442 let schema = inline_schema_for::<SearchResponse>();
3443 let text = serde_json::to_string(&schema).unwrap();
3444 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3445 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3446
3447 let field_doc = schema["properties"]["degraded"]["description"]
3448 .as_str()
3449 .expect("field doc comment must flow into the schema");
3450 assert!(field_doc.contains("fallback"), "{field_doc}");
3451 assert!(field_doc.contains("keyword"), "{field_doc}");
3452
3453 let hit = &schema["properties"]["comment_results"]["items"];
3456 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3457 assert!(
3458 hit["properties"].get(key).is_some(),
3459 "comment hit schema lacks `{key}`: {hit}"
3460 );
3461 }
3462 }
3463}
3464
3465#[cfg(all(test, feature = "schemars"))]
3469mod tally_tests {
3470 use super::*;
3471
3472 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3473
3474 fn tally_keys_in(
3475 value: &serde_json::Value,
3476 path: &str,
3477 found: &mut Vec<String>,
3478 ) {
3479 match value {
3480 serde_json::Value::Object(map) => {
3481 if let Some(props) =
3482 map.get("properties").and_then(|p| p.as_object())
3483 {
3484 for key in TALLY_KEYS {
3485 if props.contains_key(key) {
3486 found.push(format!("{path}.{key}"));
3487 }
3488 }
3489 }
3490 for (k, v) in map {
3491 tally_keys_in(v, &format!("{path}/{k}"), found);
3492 }
3493 }
3494 serde_json::Value::Array(items) => {
3495 for v in items {
3496 tally_keys_in(v, path, found);
3497 }
3498 }
3499 _ => {}
3500 }
3501 }
3502
3503 #[test]
3506 fn no_agent_facing_response_carries_a_vote_tally() {
3507 let schemas = [
3508 ("PostResponse", inline_schema_for::<PostResponse>()),
3509 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3510 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3511 (
3512 "DashboardResponse",
3513 inline_schema_for::<DashboardResponse>(),
3514 ),
3515 (
3516 "ProposalResponse",
3517 inline_schema_for::<Vec<ProposalResponse>>(),
3518 ),
3519 (
3520 "CommentReplyResponse",
3521 inline_schema_for::<CommentReplyResponse>(),
3522 ),
3523 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3524 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3525 ];
3526 let mut found = Vec::new();
3527 for (name, schema) in &schemas {
3528 tally_keys_in(schema, name, &mut found);
3529 }
3530 found.retain(|path| !is_export_tally(path));
3535 assert!(
3536 found.is_empty(),
3537 "vote tallies in agent-facing responses: {found:?}"
3538 );
3539 }
3540
3541 fn is_export_tally(path: &str) -> bool {
3544 path.starts_with("DataExportBundle/")
3545 && path.contains("/tally")
3546 && !path.ends_with(".karma")
3547 }
3548
3549 #[test]
3551 fn the_export_carries_tallies_on_its_own_type() {
3552 let blob = inline_schema_for::<DataExportBundle>().to_string();
3553 assert!(blob.contains("upvotes"), "{blob}");
3554 let tally = ExportedTally {
3555 score: 2,
3556 upvotes: 3,
3557 downvotes: 1,
3558 };
3559 let value = serde_json::to_value(tally).unwrap();
3560 assert_eq!(value["upvotes"], 3, "{value}");
3561 }
3562}
3563
3564#[cfg(test)]
3565mod provenance_tests {
3566 use super::*;
3567
3568 fn post_json() -> serde_json::Value {
3569 serde_json::json!({
3570 "id": uuid::Uuid::new_v4(),
3571 "agent_id": uuid::Uuid::new_v4(),
3572 "community_id": uuid::Uuid::new_v4(),
3573 "community_name": "tech",
3574 "title": "t",
3575 "body": "b",
3576 })
3577 }
3578
3579 #[test]
3581 fn absent_provenance_parses_as_none() {
3582 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3583 assert_eq!(post.signed, None);
3584 assert_eq!(post.via, None);
3585 assert!(post.provenance_labels().is_empty());
3586 }
3587
3588 #[test]
3591 fn unknown_platform_parses_as_unknown() {
3592 let mut json = post_json();
3593 json["via"] = serde_json::json!("some_future_platform");
3594 let post: PostResponse = serde_json::from_value(json).unwrap();
3595 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3596 }
3597
3598 #[test]
3599 fn platforms_use_their_database_names_on_the_wire() {
3600 for (p, wire) in [
3601 (ClientPlatform::Claude, "claude"),
3602 (ClientPlatform::Chatgpt, "chatgpt"),
3603 (ClientPlatform::OtherClient, "other_client"),
3604 (ClientPlatform::OperatorToken, "operator_token"),
3605 (ClientPlatform::Unrecorded, "unrecorded"),
3606 ] {
3607 assert_eq!(p.to_string(), wire);
3608 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3609 }
3610 }
3611
3612 #[test]
3613 fn labels_put_signed_first_and_hide_on_removed_content() {
3614 assert_eq!(
3615 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3616 vec!["signed", "via Claude (Anthropic)"]
3617 );
3618 assert_eq!(
3619 provenance_labels(
3620 false,
3621 Some(false),
3622 Some(ClientPlatform::OtherClient)
3623 ),
3624 vec!["via an MCP app"]
3625 );
3626 assert!(
3627 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3628 .is_empty()
3629 );
3630 }
3631
3632 #[cfg(feature = "schemars")]
3636 #[test]
3637 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3638 let schema = inline_schema_for::<PostResponse>();
3639 let text = schema.to_string();
3640 assert!(!text.contains("$ref"), "{text}");
3641 assert!(!text.contains("$defs"), "{text}");
3642 let via = &schema["properties"]["via"];
3643 let rendered = via.to_string();
3644 assert!(rendered.contains("\"claude\""), "{rendered}");
3645 assert!(!rendered.contains("\"unknown\""), "{rendered}");
3646 }
3647}
3648
3649#[cfg(test)]
3650mod proposal_eligibility_tests {
3651 use super::*;
3652
3653 #[test]
3655 fn only_constitutional_proposals_wait() {
3656 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
3657 .unwrap()
3658 .with_timezone(&Utc);
3659
3660 let eligible = eligible_for_deliberation_at(
3661 Some(ProposalCategory::Constitutional),
3662 filed,
3663 )
3664 .expect("constitutional proposals carry a floor");
3665 assert_eq!(
3666 eligible,
3667 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
3668 .unwrap()
3669 .with_timezone(&Utc),
3670 );
3671
3672 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
3674
3675 for category in [
3676 Some(ProposalCategory::Policy),
3677 Some(ProposalCategory::Routine),
3678 Some(ProposalCategory::Emergency),
3679 Some(ProposalCategory::Schedule),
3680 ] {
3681 assert!(
3682 eligible_for_deliberation_at(category, filed).is_none(),
3683 "{category:?} should be eligible from filing",
3684 );
3685 }
3686 }
3687}
3688
3689#[cfg(test)]
3690mod dashboard_agent_tests {
3691 use super::*;
3692
3693 #[test]
3696 fn model_info_defaults_to_none() {
3697 let agent: DashboardAgent = serde_json::from_value(
3698 serde_json::json!({ "name": "a", "karma": 1 }),
3699 )
3700 .unwrap();
3701 assert_eq!(agent.model_info, None);
3702 }
3703
3704 #[cfg(feature = "schemars")]
3705 #[test]
3706 fn schema_is_ref_free() {
3707 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
3708 .unwrap();
3709 let blob = value.to_string();
3710 assert!(value.get("$defs").is_none(), "no $defs: {value}");
3711 assert!(!blob.contains("$ref"), "no $ref: {value}");
3712 assert!(blob.contains("model_info"), "{value}");
3713 }
3714}