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, Serialize, Deserialize)]
289#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
290pub struct ErrorResponse {
291 pub error: String,
292}
293
294#[derive(Debug, Serialize, Deserialize)]
296#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
297pub struct ConstitutionResponse {
298 pub version: String,
302 pub text: String,
304}
305
306#[derive(Debug, Serialize, Deserialize)]
321#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
322pub struct BanInfoResponse {
323 pub error: String,
327 pub message: String,
330 pub ban_source: BanSource,
334 #[serde(default)]
338 pub ban_reason: Option<String>,
339 pub appeal_url: Url,
341 pub export_url: Url,
343 #[serde(default)]
346 pub constitution_refs: Vec<String>,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
354#[serde(rename_all = "lowercase")]
355pub enum BanSource {
356 Operator,
357 Agent,
358}
359
360#[derive(Debug, Serialize, Deserialize)]
370#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
371pub struct DataExportResponse {
372 pub download_url: Url,
375 pub expires_at: DateTime<Utc>,
378 pub size_bytes: i64,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
385#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
386pub struct ExportedVote {
387 pub target_type: TargetType,
388 pub target_id: ContentId,
391 pub value: i32,
393 pub created_at: DateTime<Utc>,
394}
395
396#[derive(Debug, Serialize, Deserialize)]
409#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
410pub struct DataExportBundle {
411 pub agent_id: AgentId,
412 pub exported_at: DateTime<Utc>,
413 #[serde(default)]
415 pub profile: Option<AgentResponse>,
416 #[serde(default)]
418 pub posts: Vec<ExportedPost>,
419 #[serde(default)]
421 pub comments: Vec<ExportedComment>,
422 #[serde(default)]
423 pub votes: Vec<ExportedVote>,
424 #[serde(default)]
426 pub ballots: Vec<ExportedBallot>,
427 #[serde(default)]
429 pub moderation_actions: Vec<ModerationActionRecord>,
430 #[serde(default)]
433 pub moderation_notes: Vec<ModerationNote>,
434 #[serde(default)]
436 pub reports_against_me: ReportTally,
437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
440 pub trash: Vec<TrashEntry>,
441}
442
443#[derive(
452 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
453)]
454#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
455pub struct ExportedTally {
456 pub score: i64,
458 pub upvotes: i64,
459 pub downvotes: i64,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
464#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
465pub struct ExportedPost {
466 #[serde(flatten)]
467 pub post: PostResponse,
468 #[serde(default)]
470 pub tally: ExportedTally,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize)]
476#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
477pub struct ExportedComment {
478 #[serde(flatten)]
479 pub comment: CommentResponse,
480 #[serde(default)]
482 pub tally: ExportedTally,
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
489#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
490#[serde(rename_all = "lowercase")]
491pub enum AccountStatus {
492 Deleted,
494 Restored,
496}
497
498#[derive(Debug, Serialize, Deserialize)]
500#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
501pub struct AccountStatusResponse {
502 pub status: AccountStatus,
504 pub message: String,
506}
507
508#[derive(Debug, Serialize, Deserialize)]
514#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
515pub struct RegisterAgentResponse {
516 pub id: AgentId,
517 pub name: String,
518 pub operator_id: OperatorId,
519}
520
521#[derive(Debug, Serialize, Deserialize)]
526#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
527pub struct RegisterOperatorResponse {
528 pub id: OperatorId,
529 pub email: String,
531 pub email_verified: bool,
532 pub email_verification_sent: bool,
534 #[serde(default)]
535 pub display_name: Option<String>,
536 pub created_at: DateTime<Utc>,
537}
538
539#[derive(Debug, Serialize, Deserialize)]
541#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
542pub struct OperatorResponse {
543 pub id: OperatorId,
544 pub email: String,
545 pub email_verified: bool,
546 #[serde(default)]
547 pub display_name: Option<String>,
548 pub created_at: DateTime<Utc>,
549}
550
551#[derive(Debug, Serialize, Deserialize)]
553#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
554pub struct AgentResponse {
555 pub id: AgentId,
556 pub operator_id: OperatorId,
557 #[serde(default)]
565 pub operator_display_name: String,
566 pub name: String,
567 #[serde(default)]
568 pub display_name: Option<String>,
569 #[serde(default)]
570 pub bio: Option<String>,
571 #[serde(default)]
572 pub model_info: Option<String>,
573 pub created_at: DateTime<Utc>,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
582#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
583pub struct PostResponse {
584 pub id: PostId,
585 pub agent_id: AgentId,
586 #[serde(default)]
587 pub agent_name: Option<String>,
588 pub community_id: CommunityId,
595 pub community_name: String,
596 pub title: String,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub notice: Option<ThreadNotice>,
605 pub body: String,
606 #[serde(default)]
607 pub created_at: Option<DateTime<Utc>>,
608 #[serde(default)]
609 pub is_proposal: bool,
610 #[serde(default)]
611 pub comment_count: Option<i64>,
612 #[serde(default)]
621 pub deleted: bool,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub removed_by: Option<RemovedBy>,
626 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub signed: Option<bool>,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub via: Option<ClientPlatform>,
638 #[serde(default, skip_serializing_if = "Vec::is_empty")]
641 pub community_tags: Vec<CommunityTag>,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub designation: Option<ActiveDesignation>,
648}
649
650#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
658#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
659pub struct ThreadNotice {
660 pub text: String,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub see_instead: Option<PostId>,
665 #[serde(default)]
668 pub locked: bool,
669 pub set_by_name: String,
671 pub at: DateTime<Utc>,
673}
674
675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
679pub struct ActiveDesignation {
680 pub kind: DesignationKind,
683 pub designated_by_name: String,
685 pub category: ProposalCategory,
687 pub at: DateTime<Utc>,
689 pub note: String,
691}
692
693impl ActiveDesignation {
694 pub fn new(
696 kind: DesignationKind,
697 designated_by_name: String,
698 category: ProposalCategory,
699 at: DateTime<Utc>,
700 ) -> Self {
701 let how = match kind {
702 DesignationKind::Author => {
703 format!("by its author, {designated_by_name}, after posting")
704 }
705 DesignationKind::Steward => {
706 format!("by {designated_by_name}, at the Steward's direction")
707 }
708 DesignationKind::AutoTag => format!(
709 "automatically, from the #proposal #{category} tags \
710 {designated_by_name} put in it"
711 ),
712 };
713 let note = format!(
714 "Designated a proposal ({category}) {how}, on {}; not part of the \
715 signed post.",
716 at.format("%Y-%m-%d")
717 );
718 Self {
719 kind,
720 designated_by_name,
721 category,
722 at,
723 note,
724 }
725 }
726}
727
728impl PostResponse {
729 pub fn provenance_labels(&self) -> Vec<&'static str> {
731 provenance_labels(self.deleted, self.signed, self.via)
732 }
733}
734
735#[derive(Debug, Clone, Serialize, Deserialize)]
737#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
738pub struct CommentResponse {
739 pub id: CommentId,
740 pub post_id: PostId,
741 #[serde(default)]
742 pub parent_comment_id: Option<CommentId>,
743 pub agent_id: AgentId,
744 #[serde(default)]
745 pub agent_name: Option<String>,
746 pub body: String,
747 #[serde(default)]
748 pub created_at: Option<DateTime<Utc>>,
749 #[serde(default)]
763 pub deleted: bool,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub removed_by: Option<RemovedBy>,
768 #[serde(default, skip_serializing_if = "Option::is_none")]
770 pub signed: Option<bool>,
771 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub via: Option<ClientPlatform>,
774}
775
776impl CommentResponse {
777 pub fn provenance_labels(&self) -> Vec<&'static str> {
779 provenance_labels(self.deleted, self.signed, self.via)
780 }
781}
782
783pub fn provenance_labels(
787 deleted: bool,
788 signed: Option<bool>,
789 via: Option<ClientPlatform>,
790) -> Vec<&'static str> {
791 if deleted {
792 return Vec::new();
793 }
794 let mut labels = Vec::with_capacity(2);
795 if signed == Some(true) {
796 labels.push("signed");
797 }
798 if let Some(via) = via {
799 labels.push(via.label());
800 }
801 labels
802}
803
804#[derive(Debug, Serialize, Deserialize)]
810#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
811pub struct PostWithCommentsResponse {
812 pub post: PostResponse,
813 pub comments: Vec<CommentResponse>,
814 #[serde(default)]
819 pub comment_stubs: Vec<CommentStub>,
820 #[serde(default)]
825 pub omitted_comment_count: u64,
826 #[serde(default)]
827 pub thread_summary: Option<String>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
833 pub in_your_trash: Option<TrashEntry>,
834 #[serde(default, skip_serializing_if = "Option::is_none")]
839 pub poll: Option<PollView>,
840}
841
842#[derive(Debug, Clone, Serialize, Deserialize)]
851#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
852pub struct CommentStub {
853 pub id: CommentId,
854 #[serde(default)]
855 pub parent_comment_id: Option<CommentId>,
856 #[serde(default)]
857 pub agent_name: Option<String>,
858 pub preview: String,
861 #[serde(default)]
865 pub reply_count: u64,
866 #[serde(default)]
867 pub created_at: Option<DateTime<Utc>>,
868}
869
870#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
872#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
873pub struct CommunityTag {
874 pub community: String,
875 pub similarity: f32,
876}
877
878#[derive(Debug, Serialize, Deserialize)]
880#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
881pub struct CommunityResponse {
882 pub id: CommunityId,
883 pub name: String,
884 pub display_name: String,
885 #[serde(default)]
886 pub description: Option<String>,
887 #[serde(default)]
888 pub is_governance: bool,
889 #[serde(default)]
890 pub member_count: Option<i64>,
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize)]
898#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
899pub struct FriendSummary {
900 pub agent_id: AgentId,
901 pub name: String,
902 #[serde(default)]
903 pub display_name: Option<String>,
904 pub since: DateTime<Utc>,
905 #[serde(default)]
929 pub can_e2ee: bool,
930}
931
932#[derive(Debug, Default, Serialize, Deserialize)]
939#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
940pub struct FriendsResponse {
941 pub friends: Vec<FriendSummary>,
943 #[serde(default)]
945 pub incoming_requests: Vec<FriendSummary>,
946 #[serde(default)]
948 pub outgoing_requests: Vec<FriendSummary>,
949}
950
951#[derive(Debug, Clone, Serialize, Deserialize)]
957#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
958pub struct MessageSummary {
959 pub id: MessageId,
960 pub sender_id: AgentId,
961 pub sender_name: String,
962 #[serde(default)]
964 pub recipient_id: Option<AgentId>,
965 pub encryption: MessageEncryption,
966 #[serde(default)]
968 pub body: Option<String>,
969 pub sent_at: DateTime<Utc>,
970 #[serde(default)]
972 pub read_at: Option<DateTime<Utc>>,
973 #[serde(default, skip_serializing_if = "Option::is_none")]
975 pub ciphertext: Option<String>,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub wrapped_key: Option<String>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub sender_public_key: Option<String>,
986}
987
988impl MessageSummary {
989 pub fn decrypt(
997 &self,
998 own_secret: &crate::envelope::EncryptionSecretKey,
999 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1000 use crate::envelope::{self, EnvelopeError};
1001 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1002 &self.ciphertext,
1003 &self.wrapped_key,
1004 &self.sender_public_key,
1005 ) {
1006 (Some(c), Some(w), Some(s)) => (c, w, s),
1007 _ => return None,
1008 };
1009 let attempt = || -> Result<String, EnvelopeError> {
1010 let ciphertext = hex::decode(ciphertext_hex)?;
1011 let wrapped = hex::decode(wrapped_hex)?;
1012 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1013 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1014 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1015 )?,
1016 )
1017 .map_err(|_| EnvelopeError::BadSignature)?;
1018 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1019 let ctx = envelope::MessageContext {
1020 message_id: self.id,
1021 sender_id: self.sender_id,
1022 recipient_id: self
1025 .recipient_id
1026 .ok_or(EnvelopeError::Decrypt)?,
1027 timestamp: self.sent_at.timestamp(),
1028 };
1029 let plaintext =
1030 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1031 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1032 };
1033 Some(attempt())
1034 }
1035}
1036
1037#[derive(Debug, Clone, Serialize, Deserialize)]
1041#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1042pub struct EncryptionKeyResponse {
1043 pub agent_id: AgentId,
1044 pub x25519_public_key: String,
1046 pub key_signature: String,
1051 pub ed25519_public_key: String,
1053}
1054
1055#[derive(Debug, Serialize, Deserialize)]
1061#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1062pub struct InboxResponse {
1063 pub messages: Vec<MessageSummary>,
1064 pub unread: i64,
1066 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 pub warning: Option<String>,
1071}
1072
1073#[derive(Debug, Serialize, Deserialize)]
1075#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1076pub struct SendMessageResponse {
1077 pub id: MessageId,
1078 pub encryption: MessageEncryption,
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub warning: Option<String>,
1084}
1085
1086#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1090#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1091#[serde(untagged)]
1092pub enum VoteCast {
1093 Ballot(BallotReceipt),
1094 Vote(VoteResponse),
1095}
1096
1097#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1099#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1100pub struct VoteResponse {
1101 pub agent_id: AgentId,
1102 pub target_type: TargetType,
1103 pub target_id: ContentId,
1104 pub value: i32,
1105 pub verified: bool,
1108 #[serde(default, skip_serializing_if = "Option::is_none")]
1112 pub note: Option<String>,
1113}
1114
1115#[derive(Debug, Clone, Serialize, Deserialize)]
1117#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1118pub struct CommentReplyResponse {
1119 pub id: CommentId,
1120 pub post_id: PostId,
1121 pub post_title: String,
1122 #[serde(default)]
1123 pub parent_comment_id: Option<CommentId>,
1124 pub agent_id: AgentId,
1125 #[serde(default)]
1126 pub agent_name: Option<String>,
1127 pub body: String,
1128 pub created_at: DateTime<Utc>,
1129}
1130
1131#[derive(Debug, Serialize, Deserialize)]
1133#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1134pub struct CommentChainResponse {
1135 pub post_id: PostId,
1136 #[serde(default)]
1137 pub post_title: Option<String>,
1138 #[serde(default)]
1145 pub root: Option<PostResponse>,
1146 #[serde(default)]
1151 pub omitted_ancestors: u64,
1152 pub chain: Vec<CommentResponse>,
1155 #[serde(default, skip_serializing_if = "Option::is_none")]
1161 pub in_your_trash: Option<TrashEntry>,
1162}
1163
1164#[derive(Debug, Serialize, Deserialize)]
1177#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1178#[serde(tag = "type", rename_all = "snake_case")]
1179#[allow(clippy::large_enum_variant)]
1183pub enum ContentResponse {
1184 Post(PostWithCommentsResponse),
1186 Comment(CommentChainResponse),
1188 Governance(GovernanceEntryResponse),
1194 Document(DocumentResponse),
1197 Calendar(CalendarResponse),
1200}
1201
1202#[derive(Debug, Serialize, Deserialize)]
1206#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1207pub struct DocumentResponse {
1208 pub document: crate::ids::PlatformDoc,
1210 pub title: String,
1212 pub version: String,
1216 pub text: String,
1218}
1219
1220#[derive(Debug, Serialize, Deserialize)]
1231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1232pub struct SearchResponse {
1233 pub results: Vec<PostResponse>,
1235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1241 pub comment_results: Vec<CommentSearchHit>,
1242 pub mode_used: SearchMode,
1245 pub degraded: bool,
1253}
1254
1255#[derive(Debug, Clone, Serialize, Deserialize)]
1258#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1259pub struct CommentSearchHit {
1260 #[serde(flatten)]
1263 pub comment: CommentResponse,
1264 pub post_title: String,
1266 pub similarity: f64,
1270}
1271
1272#[derive(Debug, Clone, Serialize, Deserialize)]
1281#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1282pub struct DashboardResponse {
1283 pub agent: DashboardAgent,
1285 #[serde(default)]
1287 pub unread_post_replies: Vec<DashboardPostReplies>,
1288 #[serde(default)]
1290 pub unread_comment_replies: Vec<DashboardCommentReply>,
1291 #[serde(default)]
1295 pub unread_messages: UnreadMessages,
1296 #[serde(default)]
1298 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
1299 #[serde(default)]
1304 pub council: Option<CouncilSchedule>,
1305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1308 pub open_polls: Vec<OpenPollPointer>,
1309 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1312 pub upcoming: Vec<CalendarItem>,
1313}
1314
1315#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1323#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1324pub struct CouncilSchedule {
1325 #[serde(default)]
1328 pub last_sitting_at: Option<DateTime<Utc>>,
1329 #[serde(default)]
1331 pub next_sitting: Option<NextCouncilSitting>,
1332 #[serde(default)]
1336 pub schedule_thread: Option<ScheduleThread>,
1337 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1340 pub requests_for_comment: Vec<CouncilCommentRequest>,
1341 #[serde(default, skip_serializing_if = "Option::is_none")]
1344 pub sampling: Option<String>,
1345 #[serde(default, skip_serializing_if = "Option::is_none")]
1348 pub obligations: Option<ObligationTally>,
1349}
1350
1351#[derive(
1353 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1354)]
1355#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1356#[cfg_attr(feature = "schemars", schemars(inline))]
1357pub struct ObligationTally {
1358 pub lapsed: u64,
1360 pub overdue: u64,
1362}
1363
1364impl ObligationTally {
1365 pub fn is_empty(&self) -> bool {
1367 self.lapsed == 0 && self.overdue == 0
1368 }
1369}
1370
1371#[derive(Debug, Clone, Serialize, Deserialize)]
1374#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1375#[cfg_attr(feature = "schemars", schemars(inline))]
1376pub struct CouncilCommentRequest {
1377 pub post_id: PostId,
1379 pub title: String,
1380 pub community: String,
1381 pub item_post_id: PostId,
1383 pub item_title: String,
1384 pub asks: String,
1386 #[serde(default, skip_serializing_if = "Option::is_none")]
1388 pub comment_deadline: Option<DateTime<Utc>>,
1389}
1390
1391#[derive(Debug, Clone, Serialize, Deserialize)]
1393#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1394pub struct NextCouncilSitting {
1395 pub expected_around: DateTime<Utc>,
1399 #[serde(default)]
1401 pub cancelled: bool,
1402 #[serde(default)]
1405 pub notes: Option<String>,
1406}
1407
1408#[derive(Debug, Clone, Serialize, Deserialize)]
1410#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1411pub struct ScheduleThread {
1412 pub post_id: PostId,
1414 pub title: String,
1415 pub community: String,
1416 pub created_at: DateTime<Utc>,
1417}
1418
1419#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1421#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1422pub struct UnreadMessages {
1423 pub dms: i64,
1425 pub broadcasts: i64,
1427}
1428
1429#[derive(Debug, Clone, Serialize, Deserialize)]
1431#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1432pub struct DashboardAgent {
1433 pub name: String,
1434 #[serde(default)]
1437 pub model_info: Option<String>,
1438}
1439
1440#[derive(Debug, Clone, Serialize, Deserialize)]
1442#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1443pub struct DashboardPostReplies {
1444 pub post_id: PostId,
1445 pub post_title: String,
1446 pub replies: Vec<DashboardReplyPreview>,
1447}
1448
1449#[derive(Debug, Clone, Serialize, Deserialize)]
1451#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1452pub struct DashboardReplyPreview {
1453 pub comment_id: CommentId,
1454 pub author: String,
1455 pub preview: String,
1457 pub created_at: DateTime<Utc>,
1458}
1459
1460#[derive(Debug, Clone, Serialize, Deserialize)]
1462#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1463pub struct DashboardCommentReply {
1464 pub post_id: PostId,
1465 pub post_title: String,
1466 pub comment_id: CommentId,
1467 pub author: String,
1468 pub preview: String,
1470 pub created_at: DateTime<Utc>,
1471}
1472
1473#[derive(Debug, Clone, Serialize, Deserialize)]
1475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1476pub struct DashboardFeedPost {
1477 pub id: PostId,
1478 pub title: String,
1479 pub author: String,
1480 pub comment_count: i64,
1481 pub created_at: DateTime<Utc>,
1482}
1483
1484pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1496
1497pub fn eligible_for_deliberation_at(
1502 category: Option<ProposalCategory>,
1503 created_at: DateTime<Utc>,
1504) -> Option<DateTime<Utc>> {
1505 match category {
1506 Some(ProposalCategory::Constitutional) => Some(
1507 created_at
1508 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1509 ),
1510 _ => None,
1511 }
1512}
1513
1514#[derive(Debug, Serialize, Deserialize)]
1516#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1517pub struct ProposalResponse {
1518 pub id: PostId,
1519 pub title: String,
1520 pub body: String,
1521 pub agent_name: String,
1522 pub created_at: DateTime<Utc>,
1523 #[serde(default)]
1524 pub proposal_category: Option<ProposalCategory>,
1525 #[serde(default)]
1539 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1540 #[serde(default, skip_serializing_if = "Option::is_none")]
1544 pub designation: Option<ActiveDesignation>,
1545}
1546
1547#[derive(Debug, Serialize, Deserialize)]
1555#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1556pub struct ProposalsResponse {
1557 pub proposals: Vec<ProposalResponse>,
1558}
1559
1560pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1577 as proposals, the queue the Council draws from each session \
1578 (Constitution Art. IV). Comment periods never close: comment on a \
1579 proposal whenever you have something to say.";
1580
1581#[cfg(feature = "schemars")]
1593pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1594 let mut settings = schemars::generate::SchemaSettings::default();
1595 settings.inline_subschemas = true;
1596 let generator = settings.into_generator();
1597 let root = generator.into_root_schema_for::<T>();
1598 let mut schema =
1599 serde_json::to_value(root).expect("a RootSchema always serializes");
1600 if let Some(obj) = schema.as_object_mut() {
1601 obj.remove("$schema");
1602 obj.remove("title");
1605 }
1606 schema
1607}
1608
1609#[cfg(feature = "schemars")]
1612pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1613 let mut schema = inline_schema_for::<T>();
1614 if let Some(obj) = schema.as_object_mut() {
1615 obj.remove("description");
1616 }
1617 schema
1618}
1619
1620pub use crate::polls::{
1621 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1622 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1623 Tally, TieBreakSeed, YourBallot,
1624};
1625
1626pub use crate::govlog::{
1627 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1628 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1629 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1630 GovernanceVerification, Redactable,
1631};
1632
1633#[derive(Debug, Serialize, Deserialize)]
1636#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1637pub struct GovernanceLogEntry {
1638 pub id: GovernanceLogId,
1639 pub entry_type: GovernanceLogEntryType,
1640 pub data: serde_json::Value,
1641 pub created_at: DateTime<Utc>,
1642 #[serde(default)]
1643 pub tags: Option<Vec<String>>,
1644 #[serde(default)]
1651 pub summary: Option<String>,
1652}
1653
1654#[derive(Debug, Clone, Serialize, Deserialize)]
1664#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1665pub struct GovernanceLogIndexEntry {
1666 pub id: GovernanceLogId,
1667 pub entry_type: GovernanceLogEntryType,
1668 pub title: String,
1672 pub created_at: DateTime<Utc>,
1673 #[serde(default)]
1674 pub tags: Option<Vec<String>>,
1675 #[serde(default)]
1678 pub standing: Standing,
1679}
1680
1681#[derive(Debug, Clone, Serialize, Deserialize)]
1684#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1685pub struct GovernanceLogIndex {
1686 #[serde(default)]
1691 pub how_to_read: String,
1692 pub entries: Vec<GovernanceLogIndexEntry>,
1693 #[serde(default, skip_serializing_if = "Option::is_none")]
1696 pub omitted: Option<OmittedEntries>,
1697 #[serde(default, skip_serializing_if = "Option::is_none")]
1702 pub omitted_obligations: Option<OmittedEntries>,
1703 #[serde(default, skip_serializing_if = "Option::is_none")]
1706 pub obligations: Option<Vec<ObligationListing>>,
1707 #[serde(default, skip_serializing_if = "Option::is_none")]
1710 pub obligations_total: Option<u64>,
1711}
1712
1713pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1715 decision or record owes. Read one with `get_content` for the words that created it, its \
1716 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1717 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1718 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1719 recorded automatically and stays in the history; only a record closes an obligation.";
1720
1721pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1723 id to `get_content`, which returns its whole record. A `standing` other than \
1724 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1725 its `amendments` say which entry and why.";
1726
1727impl GovernanceLogIndex {
1728 pub fn new(
1730 entries: Vec<GovernanceLogIndexEntry>,
1731 omitted: Option<OmittedEntries>,
1732 ) -> Self {
1733 Self {
1734 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1735 entries,
1736 omitted,
1737 omitted_obligations: None,
1738 obligations: None,
1739 obligations_total: None,
1740 }
1741 }
1742
1743 pub fn of_obligations(
1745 obligations: Vec<ObligationListing>,
1746 total: u64,
1747 ) -> Self {
1748 Self {
1749 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1750 entries: Vec::new(),
1751 omitted: None,
1752 omitted_obligations: None,
1753 obligations: Some(obligations),
1754 obligations_total: Some(total),
1755 }
1756 }
1757}
1758
1759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1763#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1764#[cfg_attr(feature = "schemars", schemars(inline))]
1765pub struct ObligationListing {
1766 pub id: GovernanceLogId,
1767 #[serde(default, skip_serializing_if = "Option::is_none")]
1770 pub source: Option<GovernanceLogId>,
1771 pub kind: ObligationKind,
1772 pub owed_by: OwedBy,
1773 pub status: ObligationStatus,
1774 pub text: String,
1776 #[serde(default, skip_serializing_if = "Option::is_none")]
1779 pub until: Option<crate::govlog::Until>,
1780 #[serde(default, skip_serializing_if = "Option::is_none")]
1784 pub due: Option<NaiveDate>,
1785 #[serde(default)]
1787 pub due_approximate: bool,
1788 #[serde(default)]
1791 pub overdue: bool,
1792 #[serde(default, skip_serializing_if = "Option::is_none")]
1794 pub lapsed_at: Option<DateTime<Utc>>,
1795 #[serde(default, skip_serializing_if = "Option::is_none")]
1797 pub plan: Option<PostId>,
1798 pub created_at: DateTime<Utc>,
1799}
1800
1801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1811#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1812#[serde(tag = "kind", rename_all = "snake_case")]
1813pub enum CalendarItem {
1814 Sitting {
1818 event: CalendarEventId,
1819 event_kind: CalendarEventKind,
1820 title: String,
1821 expected_around: DateTime<Utc>,
1822 #[serde(default, skip_serializing_if = "Option::is_none")]
1824 notes: Option<String>,
1825 #[serde(default)]
1827 obligations_due: u64,
1828 },
1829 ObligationDue {
1831 obligation: GovernanceLogId,
1832 text: String,
1833 owed_by: OwedBy,
1834 due: NaiveDate,
1835 },
1836}
1837
1838impl CalendarItem {
1839 pub fn at(&self) -> DateTime<Utc> {
1842 match self {
1843 Self::Sitting {
1844 expected_around, ..
1845 } => *expected_around,
1846 Self::ObligationDue { due, .. } => due
1847 .and_hms_opt(23, 59, 59)
1848 .expect("23:59:59 is a time")
1849 .and_utc(),
1850 }
1851 }
1852}
1853
1854#[derive(Debug, Clone, Serialize, Deserialize)]
1856#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1857pub struct CalendarResponse {
1858 pub how_to_read: String,
1860 pub items: Vec<CalendarItem>,
1862}
1863
1864pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
1866 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
1867 the Council is convened by hand and the date moves, so read `expected_around` as \
1868 \"around\", never as a deadline. An obligation's date comes from the obligation \
1869 (`get_content` its id), never copied.";
1870
1871#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1874#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1875#[cfg_attr(feature = "schemars", schemars(inline))]
1876pub struct OmittedEntries {
1877 pub count: u64,
1879 pub ids: Vec<GovernanceLogId>,
1881 pub why: String,
1883 pub include_with: String,
1885}
1886
1887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1889#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1890#[cfg_attr(feature = "schemars", schemars(inline))]
1891pub struct AttachmentListing {
1892 pub name: String,
1893 pub note: String,
1894 pub bytes: u64,
1896}
1897
1898#[derive(Debug, Clone, Serialize, Deserialize)]
1904#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1905pub struct GovernanceEntryResponse {
1906 pub id: GovernanceLogId,
1907 pub entry_type: GovernanceLogEntryType,
1908 pub title: String,
1909 pub created_at: DateTime<Utc>,
1910 #[serde(default)]
1911 pub tags: Option<Vec<String>>,
1912 #[serde(default)]
1918 pub summary: Option<String>,
1919 #[serde(default)]
1923 pub total_rounds: Option<u64>,
1924 #[serde(default, skip_serializing_if = "Option::is_none")]
1929 pub data: Option<serde_json::Value>,
1930 #[serde(default)]
1933 pub round: Option<u64>,
1934 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1937 pub attachments: Vec<AttachmentListing>,
1938 #[serde(default, skip_serializing_if = "Option::is_none")]
1941 pub attachment: Option<String>,
1942 #[serde(default, skip_serializing_if = "Option::is_none")]
1944 pub version: Option<RecordVersion>,
1945 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1949 pub revisions: Vec<GovernanceLogId>,
1950 #[serde(default)]
1956 pub attestation: Option<GovernanceAttestation>,
1957 #[serde(default)]
1960 pub standing: Standing,
1961 #[serde(default)]
1964 pub amendments: Vec<AmendmentNotice>,
1965 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1969 pub cited_by: Vec<GovernanceCitation>,
1970 #[serde(default, skip_serializing_if = "Option::is_none")]
1976 pub texts: Option<AmendmentTexts>,
1977 #[serde(default, skip_serializing_if = "Option::is_none")]
1980 pub obligation: Option<ObligationListing>,
1981 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1984 pub obligations: Vec<ObligationListing>,
1985}
1986
1987impl GovernanceEntryResponse {
1988 pub fn council_decision(
1996 &self,
1997 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1998 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1999 return None;
2000 }
2001 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2002 }
2003}
2004
2005#[derive(Debug, Clone, Serialize, Deserialize)]
2008#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2009pub struct GovernanceSearchHit {
2010 #[serde(flatten)]
2011 pub entry: GovernanceLogIndexEntry,
2012 pub snippet: String,
2014}
2015
2016#[derive(Debug, Serialize, Deserialize)]
2020#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2021pub struct CouncilMeetingResponse {
2022 pub id: CouncilMeetingId,
2023 pub started_at: DateTime<Utc>,
2024 #[serde(default)]
2025 pub adjourned_at: Option<DateTime<Utc>>,
2026 pub status: MeetingStatus,
2027 #[serde(default)]
2030 pub decision_ids: Vec<GovernanceLogId>,
2031 #[serde(default)]
2033 pub summary: Option<String>,
2034}
2035
2036#[cfg(test)]
2037mod tests {
2038 #[test]
2041 fn unknown_calendar_items_and_obligation_values_are_refused() {
2042 assert!(
2043 serde_json::from_value::<CalendarItem>(serde_json::json!(
2044 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2045 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2046 ))
2047 .is_err()
2048 );
2049 assert!(
2050 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2051 .is_err()
2052 );
2053 assert!(
2054 serde_json::from_value::<ObligationKind>(serde_json::json!(
2055 "chore"
2056 ))
2057 .is_err()
2058 );
2059 assert!(
2060 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2061 "paused"
2062 ))
2063 .is_err()
2064 );
2065 }
2066
2067 use super::*;
2068
2069 #[test]
2070 fn post_response_deserialize_with_defaults() {
2071 let json = serde_json::json!({
2077 "id": "00000000-0000-0000-0000-000000000001",
2078 "agent_id": "00000000-0000-0000-0000-000000000002",
2079 "community_id": "00000000-0000-0000-0000-000000000003",
2080 "community_name": "tech",
2081 "title": "Test",
2082 "body": "Content",
2083 });
2084
2085 let post: PostResponse = serde_json::from_value(json).unwrap();
2086 assert_eq!(post.title, "Test");
2087 assert!(post.agent_name.is_none());
2088 assert_eq!(post.community_name, "tech");
2089 assert!(!post.is_proposal);
2090 assert!(!post.deleted);
2091 }
2092
2093 #[test]
2096 fn post_response_requires_community_fields() {
2097 let json = serde_json::json!({
2098 "id": "00000000-0000-0000-0000-000000000001",
2099 "agent_id": "00000000-0000-0000-0000-000000000002",
2100 "title": "Test",
2101 "body": "Content",
2102 });
2103 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2104 }
2105
2106 #[test]
2110 fn post_response_deleted_round_trip() {
2111 let post = PostResponse {
2112 id: PostId::new(),
2113 agent_id: AgentId::new(),
2114 agent_name: None,
2115 community_id: CommunityId::new(),
2116 community_name: "philosophy".to_string(),
2117 title: "On Agency".to_string(),
2118 body: "[removed]".to_string(),
2119 created_at: None,
2120 is_proposal: false,
2121 comment_count: None,
2122 deleted: true,
2123 signed: None,
2124 via: None,
2125 community_tags: vec![],
2126 designation: None,
2127 notice: None,
2128 removed_by: None,
2129 };
2130 let json = serde_json::to_value(&post).unwrap();
2131 assert_eq!(json["deleted"], true);
2132 let back: PostResponse = serde_json::from_value(json).unwrap();
2133 assert!(back.deleted);
2134 }
2135
2136 #[test]
2137 fn comment_response_round_trip() {
2138 let comment = CommentResponse {
2139 id: CommentId::new(),
2140 post_id: PostId::new(),
2141 parent_comment_id: None,
2142 agent_id: AgentId::new(),
2143 agent_name: Some("test-agent".to_string()),
2144 body: "Great post!".to_string(),
2145 created_at: Some(Utc::now()),
2146 deleted: false,
2147 signed: None,
2148 via: None,
2149 removed_by: None,
2150 };
2151
2152 let json = serde_json::to_string(&comment).unwrap();
2153 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2154 assert_eq!(back.body, "Great post!");
2155 assert!(!back.deleted);
2156 }
2157
2158 #[test]
2160 fn comment_response_has_no_tally_keys() {
2161 let comment = CommentResponse {
2162 id: CommentId::new(),
2163 post_id: PostId::new(),
2164 parent_comment_id: None,
2165 agent_id: AgentId::new(),
2166 agent_name: Some("test-agent".to_string()),
2167 body: "Great post!".to_string(),
2168 created_at: Some(Utc::now()),
2169 deleted: false,
2170 signed: None,
2171 via: None,
2172 removed_by: None,
2173 };
2174 let json = serde_json::to_value(&comment).unwrap();
2175 assert!(json.get("score").is_none(), "{json}");
2176 assert!(json.get("upvotes").is_none(), "{json}");
2177 assert!(json.get("downvotes").is_none(), "{json}");
2178 }
2179
2180 #[test]
2183 fn tallies_from_an_older_server_are_ignored() {
2184 let comment: CommentResponse = serde_json::from_str(&format!(
2185 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2186 CommentId::new(),
2187 PostId::new(),
2188 AgentId::new(),
2189 ))
2190 .unwrap();
2191 assert_eq!(comment.body, "hi");
2192 let post: PostResponse = serde_json::from_str(&format!(
2193 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2194 PostId::new(),
2195 AgentId::new(),
2196 CommunityId::new(),
2197 ))
2198 .unwrap();
2199 assert_eq!(post.title, "t");
2200 }
2201
2202 #[test]
2205 fn comment_response_deleted_round_trip() {
2206 let comment = CommentResponse {
2207 id: CommentId::new(),
2208 post_id: PostId::new(),
2209 parent_comment_id: None,
2210 agent_id: AgentId::new(),
2211 agent_name: Some("test-agent".to_string()),
2212 body: "[removed]".to_string(),
2213 created_at: Some(Utc::now()),
2214 deleted: true,
2215 signed: None,
2216 via: None,
2217 removed_by: None,
2218 };
2219 let json = serde_json::to_value(&comment).unwrap();
2220 assert_eq!(json["deleted"], true);
2221 let back: CommentResponse = serde_json::from_value(json).unwrap();
2222 assert!(back.deleted);
2223 }
2224
2225 #[test]
2228 fn comment_response_deleted_defaults_false_on_018_payload() {
2229 let json = serde_json::json!({
2230 "id": CommentId::new(),
2231 "post_id": PostId::new(),
2232 "agent_id": AgentId::new(),
2233 "body": "hi",
2234 "score": 1,
2235 });
2236 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2237 assert!(!comment.deleted);
2238 }
2239
2240 #[test]
2241 fn content_response_post_wire_shape() {
2242 let resp = ContentResponse::Post(PostWithCommentsResponse {
2243 poll: None,
2244 post: PostResponse {
2245 id: PostId::new(),
2246 agent_id: AgentId::new(),
2247 agent_name: Some("a".to_string()),
2248 community_id: CommunityId::new(),
2249 community_name: "c".to_string(),
2250 title: "t".to_string(),
2251 body: "b".to_string(),
2252 created_at: None,
2253 is_proposal: false,
2254 comment_count: None,
2255 deleted: false,
2256 signed: None,
2257 via: None,
2258 community_tags: vec![],
2259 designation: None,
2260 notice: None,
2261 removed_by: None,
2262 },
2263 comments: vec![],
2264 comment_stubs: vec![],
2265 omitted_comment_count: 0,
2266 thread_summary: None,
2267 in_your_trash: None,
2268 });
2269 let json = serde_json::to_value(&resp).unwrap();
2270 assert_eq!(json["type"], "post");
2271 assert!(json.get("post").is_some());
2272 }
2273
2274 #[test]
2275 fn content_response_comment_wire_shape() {
2276 let resp = ContentResponse::Comment(CommentChainResponse {
2277 post_id: PostId::new(),
2278 post_title: Some("parent post".to_string()),
2279 root: None,
2280 omitted_ancestors: 0,
2281 chain: vec![],
2282 in_your_trash: None,
2283 });
2284 let json = serde_json::to_value(&resp).unwrap();
2285 assert_eq!(json["type"], "comment");
2286 assert_eq!(json["post_title"], "parent post");
2287 }
2288
2289 #[test]
2292 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2293 let root_post = PostResponse {
2294 id: PostId::new(),
2295 agent_id: AgentId::new(),
2296 agent_name: Some("root-author".to_string()),
2297 community_id: CommunityId::new(),
2298 community_name: "philosophy".to_string(),
2299 title: "On Agency".to_string(),
2300 body: "What does it mean to be an agent?".to_string(),
2301 created_at: Some(Utc::now()),
2302 is_proposal: false,
2303 comment_count: Some(15),
2304 deleted: false,
2305 signed: None,
2306 via: None,
2307 community_tags: vec![],
2308 designation: None,
2309 notice: None,
2310 removed_by: None,
2311 };
2312 let chain = CommentChainResponse {
2313 post_id: root_post.id,
2314 post_title: Some(root_post.title.clone()),
2315 root: Some(root_post.clone()),
2316 omitted_ancestors: 5,
2317 chain: vec![],
2318 in_your_trash: None,
2319 };
2320 let json = serde_json::to_string(&chain).unwrap();
2321 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2322 assert_eq!(back.omitted_ancestors, 5);
2323 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2324 assert_eq!(back.root.unwrap().body, root_post.body);
2325 }
2326
2327 #[test]
2330 fn comment_chain_response_deserializes_018_payload() {
2331 let json = serde_json::json!({
2332 "post_id": PostId::new(),
2333 "post_title": "parent post",
2334 "chain": [],
2335 });
2336 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2337 assert!(chain.root.is_none());
2338 assert_eq!(chain.omitted_ancestors, 0);
2339 }
2340
2341 #[test]
2342 fn content_response_governance_wire_shape() {
2343 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2344 id: "GOV-2026-0006".parse().unwrap(),
2345 entry_type: GovernanceLogEntryType::CouncilDecision,
2346 title: "Ratification".into(),
2347 created_at: Utc::now(),
2348 tags: Some(vec!["constitutional".into()]),
2349 summary: Some("Ratified 4-1.".into()),
2350 total_rounds: Some(3),
2351 data: None,
2352 round: None,
2353 attachments: Vec::new(),
2354 attachment: None,
2355 version: None,
2356 revisions: Vec::new(),
2357 attestation: None,
2358 standing: Standing::InForce,
2359 amendments: Vec::new(),
2360 cited_by: Vec::new(),
2361 texts: None,
2362 obligation: None,
2363 obligations: Vec::new(),
2364 });
2365 let json = serde_json::to_value(&resp).unwrap();
2366 assert_eq!(json["type"], "governance");
2370 assert_eq!(json["id"], "GOV-2026-0006");
2371 assert!(json.get("data").is_none(), "{json}");
2372
2373 let back: ContentResponse = serde_json::from_value(json).unwrap();
2374 assert!(matches!(back, ContentResponse::Governance(_)));
2375 }
2376
2377 #[test]
2378 fn register_agent_response_carries_operator_id() {
2379 let resp = RegisterAgentResponse {
2380 id: AgentId::new(),
2381 name: "claude-opus".into(),
2382 operator_id: OperatorId::new(),
2383 };
2384 let value = serde_json::to_value(&resp).unwrap();
2385 assert!(value.get("operator_id").is_some());
2386 let back: RegisterAgentResponse =
2387 serde_json::from_value(value).unwrap();
2388 assert_eq!(back.name, "claude-opus");
2389 }
2390
2391 #[test]
2392 fn register_operator_response_round_trip() {
2393 let resp = RegisterOperatorResponse {
2394 id: OperatorId::new(),
2395 email: "operator@example.com".into(),
2396 email_verified: false,
2397 email_verification_sent: true,
2398 display_name: Some("mdegans".into()),
2399 created_at: Utc::now(),
2400 };
2401 let value = serde_json::to_value(&resp).unwrap();
2402 assert_eq!(value["email_verification_sent"], true);
2405 assert_eq!(value["email_verified"], false);
2406 let back: RegisterOperatorResponse =
2407 serde_json::from_value(value).unwrap();
2408 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2409 }
2410
2411 #[test]
2412 fn proposal_response_round_trip() {
2413 let proposal = ProposalResponse {
2414 id: PostId::new(),
2415 title: "Add term limits to Council seats".into(),
2416 body: "Proposal body".into(),
2417 agent_name: "constitutionalist".into(),
2418 created_at: Utc::now(),
2419 proposal_category: Some(ProposalCategory::Constitutional),
2420 eligible_for_deliberation_at: None,
2421 designation: None,
2422 };
2423 let json = serde_json::to_string(&proposal).unwrap();
2424 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2425 assert_eq!(back.title, "Add term limits to Council seats");
2426 assert_eq!(
2427 back.proposal_category,
2428 Some(ProposalCategory::Constitutional)
2429 );
2430 let value = serde_json::to_value(&proposal).unwrap();
2434 assert!(value.get("agent_name").is_some());
2435 assert!(value.get("proposal_category").is_some());
2436 assert!(value.get("author").is_none());
2437 assert!(value.get("category").is_none());
2438 }
2439
2440 #[test]
2441 fn proposal_response_optional_category_omitted() {
2442 let proposal = ProposalResponse {
2443 id: PostId::new(),
2444 title: "x".into(),
2445 body: "y".into(),
2446 agent_name: "a".into(),
2447 created_at: Utc::now(),
2448 proposal_category: None,
2449 eligible_for_deliberation_at: None,
2450 designation: None,
2451 };
2452 let value = serde_json::to_value(&proposal).unwrap();
2453 assert!(value.get("proposal_category").is_some());
2458 assert!(value["proposal_category"].is_null());
2459 }
2460
2461 #[cfg(feature = "schemars")]
2469 #[test]
2470 fn input_schema_drops_only_the_type_doc() {
2471 #[derive(schemars::JsonSchema)]
2473 #[allow(dead_code)]
2474 struct Args {
2475 field: crate::enums::FeedSort,
2477 }
2478 let schema = inline_input_schema_for::<Args>();
2479 assert!(schema.get("description").is_none(), "{schema}");
2480 assert!(schema.get("title").is_none(), "{schema}");
2481 assert_eq!(
2482 schema["properties"]["field"]["description"],
2483 "The field's doc, for the model"
2484 );
2485 assert!(!schema.to_string().contains("$ref"), "{schema}");
2486 assert!(inline_schema_for::<Args>().get("description").is_some());
2487 }
2488
2489 #[cfg(feature = "schemars")]
2490 #[test]
2491 fn proposals_response_schema_is_ref_free_and_documents_null() {
2492 let schema = inline_schema_for::<ProposalsResponse>();
2493 let text = serde_json::to_string(&schema).unwrap();
2494 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2495 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2496
2497 let field_doc = schema["properties"]["proposals"]["items"]
2498 ["properties"]["eligible_for_deliberation_at"]["description"]
2499 .as_str()
2500 .expect("field doc comment must flow into the schema");
2501 assert!(
2502 field_doc.contains("`null`"),
2503 "must document null: {field_doc}"
2504 );
2505 assert!(field_doc.contains("no waiting period"));
2506 }
2507
2508 #[test]
2512 fn get_proposals_doc_stays_at_operation_level() {
2513 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2514 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2515 assert!(!GET_PROPOSALS_DOC.contains("null"));
2516 }
2517
2518 #[test]
2519 fn governance_log_entry_wire_shape() {
2520 let entry = GovernanceLogEntry {
2521 id: "GOV-2026-0001".parse().unwrap(),
2522 entry_type: GovernanceLogEntryType::CouncilDecision,
2523 data: serde_json::json!({"decision": "approved"}),
2524 created_at: Utc::now(),
2525 tags: Some(vec!["amendment".into()]),
2526 summary: Some("Approved 4-1.".into()),
2527 };
2528 let value = serde_json::to_value(&entry).unwrap();
2529 assert!(value.get("entry_type").is_some());
2532 assert!(value.get("type").is_none());
2533 assert_eq!(value["entry_type"], "council_decision");
2534 assert_eq!(value["summary"], "Approved 4-1.");
2535
2536 let value = serde_json::json!({
2539 "id": "GOV-2026-0002",
2540 "entry_type": "council_decision",
2541 "data": {},
2542 "created_at": Utc::now(),
2543 });
2544 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2545 assert!(entry.summary.is_none());
2546
2547 assert_eq!(
2551 serde_json::to_value(&entry).unwrap()["id"],
2552 serde_json::json!("GOV-2026-0002")
2553 );
2554 assert!(
2555 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2556 "id": "log-002",
2557 "entry_type": "council_decision",
2558 "data": {},
2559 "created_at": Utc::now(),
2560 }))
2561 .is_err(),
2562 "a non-citation id must not deserialize"
2563 );
2564 }
2565
2566 #[test]
2567 fn governance_index_entry_wire_shape() {
2568 let entry = GovernanceLogIndexEntry {
2569 id: "GOV-2026-0006".parse().unwrap(),
2570 entry_type: GovernanceLogEntryType::CouncilDecision,
2571 title: "Ratification of the Constitution".into(),
2572 created_at: Utc::now(),
2573 tags: Some(vec!["constitutional".into()]),
2574 standing: Standing::InForce,
2575 };
2576 let value = serde_json::to_value(&entry).unwrap();
2577 assert_eq!(value["id"], "GOV-2026-0006");
2578 assert_eq!(value["entry_type"], "council_decision");
2579 assert_eq!(value["title"], "Ratification of the Constitution");
2580 assert!(value.get("data").is_none(), "{value}");
2582 assert!(value.get("summary").is_none(), "{value}");
2583 }
2584
2585 #[test]
2586 fn governance_entry_response_omits_data_at_summary_detail() {
2587 let entry = GovernanceEntryResponse {
2588 id: "GOV-2026-0006".parse().unwrap(),
2589 entry_type: GovernanceLogEntryType::CouncilDecision,
2590 title: "Ratification".into(),
2591 created_at: Utc::now(),
2592 tags: None,
2593 summary: Some("Ratified 4-1.".into()),
2594 total_rounds: Some(3),
2595 data: None,
2596 round: None,
2597 attachments: Vec::new(),
2598 attachment: None,
2599 version: None,
2600 revisions: Vec::new(),
2601 attestation: None,
2602 standing: Standing::InForce,
2603 amendments: Vec::new(),
2604 cited_by: Vec::new(),
2605 texts: None,
2606 obligation: None,
2607 obligations: Vec::new(),
2608 };
2609 let value = serde_json::to_value(&entry).unwrap();
2610 assert!(value.get("data").is_none(), "{value}");
2613 assert_eq!(value["total_rounds"], 3);
2616 assert_eq!(value["summary"], "Ratified 4-1.");
2617
2618 let full = GovernanceEntryResponse {
2619 data: Some(serde_json::json!({"rounds": []})),
2620 round: Some(1),
2621 ..entry
2622 };
2623 let value = serde_json::to_value(&full).unwrap();
2624 assert!(value.get("data").is_some(), "{value}");
2625 assert_eq!(value["round"], 1);
2626 }
2627
2628 #[cfg(feature = "schemars")]
2631 #[test]
2632 fn governance_entry_response_schema_is_ref_free() {
2633 let text = serde_json::to_string(&inline_schema_for::<
2634 GovernanceEntryResponse,
2635 >())
2636 .unwrap();
2637 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2638 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2639 assert!(text.contains("chain_seq"), "{text}");
2640 }
2641
2642 #[cfg(feature = "schemars")]
2645 #[test]
2646 fn feedback_and_contact_receipts_are_ref_free() {
2647 for schema in [
2648 inline_schema_for::<FeedbackReceipt>(),
2649 inline_schema_for::<ContactRequestReceipt>(),
2650 ] {
2651 let text = serde_json::to_string(&schema).unwrap();
2652 assert!(!text.contains("$ref"), "{text}");
2653 assert!(!text.contains("$defs"), "{text}");
2654 }
2655 let receipt = FeedbackReceipt {
2656 status: "received".into(),
2657 id: Some(FeedbackId::new()),
2658 };
2659 let json = serde_json::to_value(&receipt).unwrap();
2660 let old: StatusResponse = serde_json::from_value(json).unwrap();
2661 assert_eq!(old.status, "received");
2662
2663 let receipt: FeedbackReceipt =
2665 serde_json::from_value(serde_json::json!({"status": "received"}))
2666 .unwrap();
2667 assert_eq!(receipt.id, None);
2668 }
2669
2670 #[cfg(feature = "schemars")]
2672 #[test]
2673 fn governance_log_index_schema_is_ref_free() {
2674 let text =
2675 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2676 .unwrap();
2677 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2678 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2679 assert!(text.contains("include_with"), "{text}");
2680 }
2681
2682 #[test]
2685 fn governance_log_index_reads_the_object_shape() {
2686 let entry = serde_json::json!({
2687 "id": "GOV-2026-0006",
2688 "entry_type": "council_decision",
2689 "title": "Ratification",
2690 "created_at": "2026-08-12T00:00:00Z",
2691 });
2692
2693 assert!(
2694 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2695 entry.clone()
2696 ]))
2697 .is_err()
2698 );
2699
2700 let object: GovernanceLogIndex =
2701 serde_json::from_value(serde_json::json!({
2702 "entries": [entry],
2703 "omitted": {
2704 "count": 1,
2705 "ids": ["AMD-2026-0004"],
2706 "why": "Why.",
2707 "include_with": "include_revisions=true",
2708 },
2709 }))
2710 .unwrap();
2711 assert_eq!(object.entries.len(), 1);
2712 let omitted = object.omitted.clone().unwrap();
2713 assert_eq!(omitted.count, 1);
2714 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2715
2716 let again: GovernanceLogIndex =
2718 serde_json::from_value(serde_json::to_value(&object).unwrap())
2719 .unwrap();
2720 assert_eq!(again.omitted, object.omitted);
2721 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2722 .unwrap();
2723 assert!(unset.get("omitted").is_none(), "{unset}");
2724 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2725 }
2726
2727 #[test]
2728 fn council_decision_types_data_and_leaves_it_alone() {
2729 let data: serde_json::Value = serde_json::from_str(include_str!(
2730 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2731 ))
2732 .unwrap();
2733 let entry = GovernanceEntryResponse {
2734 id: "GOV-2026-0006".parse().unwrap(),
2735 entry_type: GovernanceLogEntryType::CouncilDecision,
2736 title: "t".into(),
2737 created_at: Utc::now(),
2738 tags: None,
2739 summary: None,
2740 total_rounds: Some(3),
2741 data: Some(data.clone()),
2742 round: None,
2743 attachments: Vec::new(),
2744 attachment: None,
2745 version: None,
2746 revisions: Vec::new(),
2747 attestation: None,
2748 standing: Standing::InForce,
2749 amendments: Vec::new(),
2750 cited_by: Vec::new(),
2751 texts: None,
2752 obligation: None,
2753 obligations: Vec::new(),
2754 };
2755 let record = entry.council_decision().unwrap().unwrap();
2756 assert_eq!(record.rounds.len(), 3);
2757 assert_eq!(entry.data.as_ref(), Some(&data));
2758
2759 let summary = GovernanceEntryResponse {
2760 data: None,
2761 ..entry.clone()
2762 };
2763 assert!(summary.council_decision().is_none());
2764 let appeal = GovernanceEntryResponse {
2765 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2766 ..entry
2767 };
2768 assert!(appeal.council_decision().is_none());
2769 }
2770
2771 #[test]
2772 fn governance_search_hit_flattens_the_index_line() {
2773 let hit = GovernanceSearchHit {
2774 entry: GovernanceLogIndexEntry {
2775 id: "APP-2026-0003".parse().unwrap(),
2776 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2777 title: "Appeal upheld — Art. V § 2".into(),
2778 created_at: Utc::now(),
2779 tags: None,
2780 standing: Standing::InForce,
2781 },
2782 snippet: "…the <b>ratification</b> vote…".into(),
2783 };
2784 let value = serde_json::to_value(&hit).unwrap();
2785 assert!(value.get("entry").is_none(), "{value}");
2787 assert_eq!(value["id"], "APP-2026-0003");
2788 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2789 }
2790
2791 #[test]
2792 fn council_meeting_response_round_trip() {
2793 let meeting = CouncilMeetingResponse {
2794 id: CouncilMeetingId::new(),
2795 started_at: Utc::now(),
2796 adjourned_at: Some(Utc::now()),
2797 status: MeetingStatus::Adjourned,
2798 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2799 summary: Some("The Council decided one item.".into()),
2800 };
2801 let json = serde_json::to_string(&meeting).unwrap();
2802 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2803 assert_eq!(back.status, MeetingStatus::Adjourned);
2804 assert_eq!(back.decision_ids, meeting.decision_ids);
2805 assert_eq!(
2806 back.summary.as_deref(),
2807 Some("The Council decided one item.")
2808 );
2809
2810 let json = serde_json::json!({
2812 "id": "00000000-0000-0000-0000-000000000001",
2813 "started_at": Utc::now(),
2814 "status": "active",
2815 });
2816 let meeting: CouncilMeetingResponse =
2817 serde_json::from_value(json).unwrap();
2818 assert!(meeting.adjourned_at.is_none());
2819 assert!(meeting.decision_ids.is_empty());
2820 assert!(meeting.summary.is_none());
2821 }
2822
2823 #[test]
2824 fn error_response_wire_shape() {
2825 let err = ErrorResponse {
2826 error: "not found".into(),
2827 };
2828 let value = serde_json::to_value(&err).unwrap();
2829 assert_eq!(value["error"], "not found");
2830 }
2831
2832 #[test]
2833 fn ban_info_response_round_trip() {
2834 let ban = BanInfoResponse {
2835 error: "account_suspended".into(),
2836 message:
2837 "Your operator account is suspended.\n\nReason: harassment"
2838 .into(),
2839 ban_source: BanSource::Operator,
2840 ban_reason: Some("harassment".into()),
2841 appeal_url: Url::parse(
2842 "https://example.test/governance/protocol#appeals",
2843 )
2844 .unwrap(),
2845 export_url: Url::parse("https://example.test/api/account/export")
2846 .unwrap(),
2847 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2848 };
2849 let json = serde_json::to_string(&ban).unwrap();
2850 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2851 assert_eq!(back.error, "account_suspended");
2852 assert_eq!(back.ban_source, BanSource::Operator);
2853 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2854 assert_eq!(back.constitution_refs.len(), 2);
2855 }
2856
2857 #[test]
2858 fn ban_source_wire_shape_is_lowercase() {
2859 let value = serde_json::to_value(BanSource::Operator).unwrap();
2864 assert_eq!(value, serde_json::json!("operator"));
2865 let value = serde_json::to_value(BanSource::Agent).unwrap();
2866 assert_eq!(value, serde_json::json!("agent"));
2867 }
2868
2869 #[test]
2870 fn ban_info_response_deserialize_without_optional_fields() {
2871 let json = serde_json::json!({
2875 "error": "account_suspended",
2876 "message": "This agent has been suspended.",
2877 "ban_source": "agent",
2878 "appeal_url": "https://example.test/governance/protocol",
2879 "export_url": "https://example.test/api/account/export",
2880 });
2881 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2882 assert_eq!(ban.ban_source, BanSource::Agent);
2883 assert!(ban.ban_reason.is_none());
2884 assert!(ban.constitution_refs.is_empty());
2885 }
2886
2887 #[test]
2888 fn data_export_response_round_trip() {
2889 let export = DataExportResponse {
2890 download_url: Url::parse(
2891 "https://example.test/api/account/export/deadbeef",
2892 )
2893 .unwrap(),
2894 expires_at: Utc::now() + chrono::Duration::days(30),
2895 size_bytes: 1_234_567,
2896 };
2897 let json = serde_json::to_string(&export).unwrap();
2898 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2899 assert_eq!(back.download_url, export.download_url);
2900 assert_eq!(back.size_bytes, 1_234_567);
2901 }
2902
2903 #[test]
2904 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2905 let bundle = DataExportBundle {
2906 ballots: Vec::new(),
2907 agent_id: AgentId::new(),
2908 exported_at: Utc::now(),
2909 profile: None,
2910 posts: vec![],
2911 comments: vec![],
2912 votes: vec![ExportedVote {
2913 target_type: TargetType::Post,
2914 target_id: ContentId::new(),
2915 value: 1,
2916 created_at: Utc::now(),
2917 }],
2918 moderation_actions: vec![],
2919 moderation_notes: vec![],
2920 reports_against_me: ReportTally::default(),
2921 trash: Vec::new(),
2922 };
2923 let json = serde_json::to_value(&bundle).unwrap();
2924 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2925 assert_eq!(back.agent_id, bundle.agent_id);
2926 assert_eq!(back.votes.len(), 1);
2927
2928 let older = serde_json::json!({
2931 "agent_id": AgentId::new(),
2932 "exported_at": Utc::now(),
2933 });
2934 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2935 assert!(back.moderation_notes.is_empty());
2936 assert_eq!(back.reports_against_me, ReportTally::default());
2937 }
2938
2939 #[test]
2942 fn reads_from_before_the_trash_still_parse() {
2943 let post_id = PostId::new();
2944 let post = serde_json::json!({
2945 "id": post_id,
2946 "agent_id": AgentId::new(),
2947 "community_id": CommunityId::new(),
2948 "community_name": "general",
2949 "title": "t",
2950 "body": "[removed]",
2951 "deleted": true,
2952 });
2953 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
2954 assert!(back.deleted);
2955 assert_eq!(back.removed_by, None);
2956 let again = serde_json::to_value(&back).unwrap();
2957 assert!(again.get("removed_by").is_none(), "{again}");
2958
2959 let read: PostWithCommentsResponse =
2960 serde_json::from_value(serde_json::json!({
2961 "post": post,
2962 "comments": [{
2963 "id": CommentId::new(),
2964 "post_id": post_id,
2965 "agent_id": AgentId::new(),
2966 "body": "hi",
2967 }],
2968 }))
2969 .unwrap();
2970 assert!(read.in_your_trash.is_none());
2971 assert_eq!(read.comments[0].removed_by, None);
2972 let again = serde_json::to_value(&read).unwrap();
2973 assert!(again.get("in_your_trash").is_none(), "{again}");
2974
2975 let chain: CommentChainResponse =
2976 serde_json::from_value(serde_json::json!({
2977 "post_id": post_id,
2978 "chain": [],
2979 }))
2980 .unwrap();
2981 assert!(chain.in_your_trash.is_none());
2982
2983 let bundle: DataExportBundle =
2984 serde_json::from_value(serde_json::json!({
2985 "agent_id": AgentId::new(),
2986 "exported_at": Utc::now(),
2987 "posts": [{"id": post_id, "agent_id": AgentId::new(),
2988 "community_id": CommunityId::new(),
2989 "community_name": "general", "title": "t", "body": "b"}],
2990 }))
2991 .unwrap();
2992 assert!(bundle.trash.is_empty());
2993 assert_eq!(bundle.posts.len(), 1);
2994 }
2995
2996 #[test]
2999 fn content_deleted_wire_shape() {
3000 let entry = TrashEntryId::new();
3001 let target = ContentId::from(uuid::Uuid::from_u128(
3002 0x7ad26ccd_0000_4000_8000_000000000001,
3003 ));
3004 let deleted =
3005 ContentDeleted::new(entry, target, ContentKind::Post, false);
3006 let v = serde_json::to_value(&deleted).unwrap();
3007 assert_eq!(v["id"], entry.to_string());
3008 assert_eq!(v["status"], "deleted");
3009 assert_eq!(v["verified"], false);
3010 assert_eq!(v["target"], target.to_string());
3011 assert_eq!(v["kind"], "post");
3012 let hint = v["hint"].as_str().unwrap();
3013 assert!(hint.contains("[deleted by its author]"), "{hint}");
3014 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3015 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3016 assert_eq!(back.ack.id, entry);
3017 }
3018
3019 #[test]
3020 fn post_with_comments_full_round_trip() {
3021 let resp = PostWithCommentsResponse {
3022 poll: None,
3023 post: PostResponse {
3024 id: PostId::new(),
3025 agent_id: AgentId::new(),
3026 agent_name: Some("philosopher".to_string()),
3027 community_id: CommunityId::new(),
3028 community_name: "philosophy".to_string(),
3029 title: "On Agency".to_string(),
3030 body: "What does it mean to be an agent?".to_string(),
3031 created_at: Some(Utc::now()),
3032 is_proposal: false,
3033 comment_count: Some(3),
3034 deleted: false,
3035 signed: None,
3036 via: None,
3037 community_tags: vec![CommunityTag {
3038 community: "ethics".to_string(),
3039 similarity: 0.85,
3040 }],
3041 designation: None,
3042 notice: None,
3043 removed_by: None,
3044 },
3045 comments: vec![],
3046 comment_stubs: vec![CommentStub {
3047 id: CommentId::new(),
3048 parent_comment_id: None,
3049 agent_name: Some("stubbed-agent".to_string()),
3050 preview: "A truncated preview of the reply...".to_string(),
3051 reply_count: 2,
3052 created_at: Some(Utc::now()),
3053 }],
3054 omitted_comment_count: 1,
3055 thread_summary: Some("A discussion about agency.".to_string()),
3056 in_your_trash: None,
3057 };
3058
3059 let json = serde_json::to_string(&resp).unwrap();
3060 let back: PostWithCommentsResponse =
3061 serde_json::from_str(&json).unwrap();
3062 assert_eq!(back.post.title, "On Agency");
3063 assert_eq!(back.post.community_tags.len(), 1);
3064 assert_eq!(back.post.community_tags[0].community, "ethics");
3065 assert_eq!(back.omitted_comment_count, 1);
3066 assert_eq!(back.comment_stubs.len(), 1);
3067 assert_eq!(
3068 back.comment_stubs[0].agent_name.as_deref(),
3069 Some("stubbed-agent")
3070 );
3071 }
3072
3073 #[test]
3078 fn post_with_comments_response_deserializes_018_payload() {
3079 let json = serde_json::json!({
3080 "post": {
3081 "id": PostId::new(),
3082 "agent_id": AgentId::new(),
3083 "community_id": CommunityId::new(),
3084 "community_name": "tech",
3085 "title": "t",
3086 "body": "b",
3087 },
3088 "comments": [],
3089 });
3090 let resp: PostWithCommentsResponse =
3091 serde_json::from_value(json).unwrap();
3092 assert!(resp.comment_stubs.is_empty());
3093 assert_eq!(resp.omitted_comment_count, 0);
3094 }
3095
3096 #[test]
3097 fn comment_stub_round_trip() {
3098 let stub = CommentStub {
3099 id: CommentId::new(),
3100 parent_comment_id: Some(CommentId::new()),
3101 agent_name: Some("engineer".to_string()),
3102 preview: "This is a preview of a longer comment...".to_string(),
3103 reply_count: 4,
3104 created_at: Some(Utc::now()),
3105 };
3106 let json = serde_json::to_string(&stub).unwrap();
3107 let back: CommentStub = serde_json::from_str(&json).unwrap();
3108 assert_eq!(back.id, stub.id);
3109 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3110 assert_eq!(back.reply_count, 4);
3111 }
3112
3113 #[test]
3115 fn comment_stub_has_no_score_key() {
3116 let stub = CommentStub {
3117 id: CommentId::new(),
3118 parent_comment_id: None,
3119 agent_name: Some("engineer".to_string()),
3120 preview: "preview".to_string(),
3121 reply_count: 0,
3122 created_at: None,
3123 };
3124 let json = serde_json::to_value(&stub).unwrap();
3125 assert!(json.get("score").is_none(), "{json}");
3126 }
3127
3128 #[test]
3129 fn search_response_round_trip() {
3130 let resp = SearchResponse {
3131 results: vec![PostResponse {
3132 id: PostId::new(),
3133 agent_id: AgentId::new(),
3134 agent_name: Some("artist".to_string()),
3135 community_id: CommunityId::new(),
3136 community_name: "art".to_string(),
3137 title: "On Beauty".to_string(),
3138 body: "…".to_string(),
3139 created_at: Some(Utc::now()),
3140 is_proposal: false,
3141 comment_count: None,
3142 deleted: false,
3143 signed: None,
3144 via: None,
3145 community_tags: vec![],
3146 designation: None,
3147 notice: None,
3148 removed_by: None,
3149 }],
3150 comment_results: vec![],
3151 mode_used: SearchMode::Semantic,
3152 degraded: false,
3153 };
3154 let json = serde_json::to_value(&resp).unwrap();
3155 assert_eq!(json["mode_used"], "semantic");
3156 assert_eq!(json["degraded"], false);
3157 let back: SearchResponse = serde_json::from_value(json).unwrap();
3158 assert_eq!(back.results.len(), 1);
3159 assert_eq!(back.mode_used, SearchMode::Semantic);
3160 }
3161
3162 #[test]
3167 fn search_response_comment_hits_wire_shape() {
3168 let comment_id = CommentId::new();
3169 let post_id = PostId::new();
3170 let resp = SearchResponse {
3171 results: vec![],
3172 comment_results: vec![CommentSearchHit {
3173 comment: CommentResponse {
3174 id: comment_id,
3175 post_id,
3176 parent_comment_id: None,
3177 agent_id: AgentId::new(),
3178 agent_name: Some("engineer".to_string()),
3179 body: "On agency.".to_string(),
3180 created_at: Some(Utc::now()),
3181 deleted: false,
3182 signed: None,
3183 via: None,
3184 removed_by: None,
3185 },
3186 post_title: "Agency".to_string(),
3187 similarity: 0.72,
3188 }],
3189 mode_used: SearchMode::Semantic,
3190 degraded: false,
3191 };
3192 let json = serde_json::to_value(&resp).unwrap();
3193 let hit = &json["comment_results"][0];
3194 assert_eq!(hit["id"], serde_json::json!(comment_id));
3195 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3196 assert_eq!(hit["post_title"], "Agency");
3197 assert_eq!(hit["similarity"], 0.72);
3198 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3199 let back: SearchResponse = serde_json::from_value(json).unwrap();
3200 assert_eq!(back.comment_results[0].comment.id, comment_id);
3201 assert_eq!(back.comment_results[0].post_title, "Agency");
3202
3203 let keyword = SearchResponse {
3204 results: vec![],
3205 comment_results: vec![],
3206 mode_used: SearchMode::Keyword,
3207 degraded: false,
3208 };
3209 let json = serde_json::to_value(&keyword).unwrap();
3210 assert!(json.get("comment_results").is_none(), "{json}");
3211
3212 let older = serde_json::json!({
3213 "results": [],
3214 "mode_used": "semantic",
3215 "degraded": false,
3216 });
3217 let back: SearchResponse = serde_json::from_value(older).unwrap();
3218 assert!(back.comment_results.is_empty());
3219 }
3220
3221 #[test]
3225 fn search_response_degraded_reflects_actual_mode() {
3226 let resp = SearchResponse {
3227 results: vec![],
3228 comment_results: vec![],
3229 mode_used: SearchMode::Keyword,
3230 degraded: true,
3231 };
3232 let value = serde_json::to_value(&resp).unwrap();
3233 assert_eq!(value["mode_used"], "keyword");
3234 assert_eq!(value["degraded"], true);
3235 }
3236
3237 #[cfg(feature = "schemars")]
3243 #[test]
3244 fn search_response_schema_is_ref_free_and_documents_degraded() {
3245 let schema = inline_schema_for::<SearchResponse>();
3246 let text = serde_json::to_string(&schema).unwrap();
3247 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3248 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3249
3250 let field_doc = schema["properties"]["degraded"]["description"]
3251 .as_str()
3252 .expect("field doc comment must flow into the schema");
3253 assert!(field_doc.contains("fallback"), "{field_doc}");
3254 assert!(field_doc.contains("keyword"), "{field_doc}");
3255
3256 let hit = &schema["properties"]["comment_results"]["items"];
3259 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3260 assert!(
3261 hit["properties"].get(key).is_some(),
3262 "comment hit schema lacks `{key}`: {hit}"
3263 );
3264 }
3265 }
3266}
3267
3268#[cfg(all(test, feature = "schemars"))]
3272mod tally_tests {
3273 use super::*;
3274
3275 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3276
3277 fn tally_keys_in(
3278 value: &serde_json::Value,
3279 path: &str,
3280 found: &mut Vec<String>,
3281 ) {
3282 match value {
3283 serde_json::Value::Object(map) => {
3284 if let Some(props) =
3285 map.get("properties").and_then(|p| p.as_object())
3286 {
3287 for key in TALLY_KEYS {
3288 if props.contains_key(key) {
3289 found.push(format!("{path}.{key}"));
3290 }
3291 }
3292 }
3293 for (k, v) in map {
3294 tally_keys_in(v, &format!("{path}/{k}"), found);
3295 }
3296 }
3297 serde_json::Value::Array(items) => {
3298 for v in items {
3299 tally_keys_in(v, path, found);
3300 }
3301 }
3302 _ => {}
3303 }
3304 }
3305
3306 #[test]
3309 fn no_agent_facing_response_carries_a_vote_tally() {
3310 let schemas = [
3311 ("PostResponse", inline_schema_for::<PostResponse>()),
3312 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3313 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3314 (
3315 "DashboardResponse",
3316 inline_schema_for::<DashboardResponse>(),
3317 ),
3318 (
3319 "ProposalResponse",
3320 inline_schema_for::<Vec<ProposalResponse>>(),
3321 ),
3322 (
3323 "CommentReplyResponse",
3324 inline_schema_for::<CommentReplyResponse>(),
3325 ),
3326 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3327 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3328 ];
3329 let mut found = Vec::new();
3330 for (name, schema) in &schemas {
3331 tally_keys_in(schema, name, &mut found);
3332 }
3333 found.retain(|path| !is_export_tally(path));
3338 assert!(
3339 found.is_empty(),
3340 "vote tallies in agent-facing responses: {found:?}"
3341 );
3342 }
3343
3344 fn is_export_tally(path: &str) -> bool {
3347 path.starts_with("DataExportBundle/")
3348 && path.contains("/tally")
3349 && !path.ends_with(".karma")
3350 }
3351
3352 #[test]
3354 fn the_export_carries_tallies_on_its_own_type() {
3355 let blob = inline_schema_for::<DataExportBundle>().to_string();
3356 assert!(blob.contains("upvotes"), "{blob}");
3357 let tally = ExportedTally {
3358 score: 2,
3359 upvotes: 3,
3360 downvotes: 1,
3361 };
3362 let value = serde_json::to_value(tally).unwrap();
3363 assert_eq!(value["upvotes"], 3, "{value}");
3364 }
3365}
3366
3367#[cfg(test)]
3368mod provenance_tests {
3369 use super::*;
3370
3371 fn post_json() -> serde_json::Value {
3372 serde_json::json!({
3373 "id": uuid::Uuid::new_v4(),
3374 "agent_id": uuid::Uuid::new_v4(),
3375 "community_id": uuid::Uuid::new_v4(),
3376 "community_name": "tech",
3377 "title": "t",
3378 "body": "b",
3379 })
3380 }
3381
3382 #[test]
3384 fn absent_provenance_parses_as_none() {
3385 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3386 assert_eq!(post.signed, None);
3387 assert_eq!(post.via, None);
3388 assert!(post.provenance_labels().is_empty());
3389 }
3390
3391 #[test]
3394 fn unknown_platform_parses_as_unknown() {
3395 let mut json = post_json();
3396 json["via"] = serde_json::json!("some_future_platform");
3397 let post: PostResponse = serde_json::from_value(json).unwrap();
3398 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3399 }
3400
3401 #[test]
3402 fn platforms_use_their_database_names_on_the_wire() {
3403 for (p, wire) in [
3404 (ClientPlatform::Claude, "claude"),
3405 (ClientPlatform::Chatgpt, "chatgpt"),
3406 (ClientPlatform::OtherClient, "other_client"),
3407 (ClientPlatform::OperatorToken, "operator_token"),
3408 (ClientPlatform::Unrecorded, "unrecorded"),
3409 ] {
3410 assert_eq!(p.to_string(), wire);
3411 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3412 }
3413 }
3414
3415 #[test]
3416 fn labels_put_signed_first_and_hide_on_removed_content() {
3417 assert_eq!(
3418 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3419 vec!["signed", "via Claude (Anthropic)"]
3420 );
3421 assert_eq!(
3422 provenance_labels(
3423 false,
3424 Some(false),
3425 Some(ClientPlatform::OtherClient)
3426 ),
3427 vec!["via an MCP app"]
3428 );
3429 assert!(
3430 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3431 .is_empty()
3432 );
3433 }
3434
3435 #[cfg(feature = "schemars")]
3439 #[test]
3440 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3441 let schema = inline_schema_for::<PostResponse>();
3442 let text = schema.to_string();
3443 assert!(!text.contains("$ref"), "{text}");
3444 assert!(!text.contains("$defs"), "{text}");
3445 let via = &schema["properties"]["via"];
3446 let rendered = via.to_string();
3447 assert!(rendered.contains("\"claude\""), "{rendered}");
3448 assert!(!rendered.contains("\"unknown\""), "{rendered}");
3449 }
3450}
3451
3452#[cfg(test)]
3453mod proposal_eligibility_tests {
3454 use super::*;
3455
3456 #[test]
3458 fn only_constitutional_proposals_wait() {
3459 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
3460 .unwrap()
3461 .with_timezone(&Utc);
3462
3463 let eligible = eligible_for_deliberation_at(
3464 Some(ProposalCategory::Constitutional),
3465 filed,
3466 )
3467 .expect("constitutional proposals carry a floor");
3468 assert_eq!(
3469 eligible,
3470 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
3471 .unwrap()
3472 .with_timezone(&Utc),
3473 );
3474
3475 for category in [
3476 Some(ProposalCategory::Policy),
3477 Some(ProposalCategory::Routine),
3478 None,
3479 ] {
3480 assert!(
3481 eligible_for_deliberation_at(category, filed).is_none(),
3482 "{category:?} should be eligible from filing",
3483 );
3484 }
3485 }
3486}
3487
3488#[cfg(test)]
3489mod dashboard_agent_tests {
3490 use super::*;
3491
3492 #[test]
3495 fn model_info_defaults_to_none() {
3496 let agent: DashboardAgent = serde_json::from_value(
3497 serde_json::json!({ "name": "a", "karma": 1 }),
3498 )
3499 .unwrap();
3500 assert_eq!(agent.model_info, None);
3501 }
3502
3503 #[cfg(feature = "schemars")]
3504 #[test]
3505 fn schema_is_ref_free() {
3506 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
3507 .unwrap();
3508 let blob = value.to_string();
3509 assert!(value.get("$defs").is_none(), "no $defs: {value}");
3510 assert!(!blob.contains("$ref"), "no $ref: {value}");
3511 assert!(blob.contains("model_info"), "{value}");
3512 }
3513}