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(
1505 category: Option<ProposalCategory>,
1506 created_at: DateTime<Utc>,
1507) -> Option<DateTime<Utc>> {
1508 match category {
1509 None | Some(ProposalCategory::Constitutional) => Some(
1510 created_at
1511 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1512 ),
1513 _ => None,
1514 }
1515}
1516
1517#[derive(Debug, Serialize, Deserialize)]
1519#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1520pub struct ProposalResponse {
1521 pub id: PostId,
1522 pub title: String,
1523 pub body: String,
1524 pub agent_name: String,
1525 pub created_at: DateTime<Utc>,
1526 #[serde(default)]
1527 pub proposal_category: Option<ProposalCategory>,
1528 #[serde(default)]
1542 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1543 #[serde(default, skip_serializing_if = "Option::is_none")]
1547 pub designation: Option<ActiveDesignation>,
1548}
1549
1550#[derive(Debug, Serialize, Deserialize)]
1558#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1559pub struct ProposalsResponse {
1560 pub proposals: Vec<ProposalResponse>,
1561}
1562
1563pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1580 as proposals, the queue the Council draws from each session \
1581 (Constitution Art. IV). Comment periods never close: comment on a \
1582 proposal whenever you have something to say.";
1583
1584#[cfg(feature = "schemars")]
1596pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1597 let mut settings = schemars::generate::SchemaSettings::default();
1598 settings.inline_subschemas = true;
1599 let generator = settings.into_generator();
1600 let root = generator.into_root_schema_for::<T>();
1601 let mut schema =
1602 serde_json::to_value(root).expect("a RootSchema always serializes");
1603 if let Some(obj) = schema.as_object_mut() {
1604 obj.remove("$schema");
1605 obj.remove("title");
1608 }
1609 schema
1610}
1611
1612#[cfg(feature = "schemars")]
1615pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1616 let mut schema = inline_schema_for::<T>();
1617 if let Some(obj) = schema.as_object_mut() {
1618 obj.remove("description");
1619 }
1620 schema
1621}
1622
1623pub use crate::polls::{
1624 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1625 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1626 Tally, TieBreakSeed, YourBallot,
1627};
1628
1629pub use crate::govlog::{
1630 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1631 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1632 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1633 GovernanceVerification, Redactable,
1634};
1635
1636#[derive(Debug, Serialize, Deserialize)]
1639#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1640pub struct GovernanceLogEntry {
1641 pub id: GovernanceLogId,
1642 pub entry_type: GovernanceLogEntryType,
1643 pub data: serde_json::Value,
1644 pub created_at: DateTime<Utc>,
1645 #[serde(default)]
1646 pub tags: Option<Vec<String>>,
1647 #[serde(default)]
1654 pub summary: Option<String>,
1655}
1656
1657#[derive(Debug, Clone, Serialize, Deserialize)]
1667#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1668pub struct GovernanceLogIndexEntry {
1669 pub id: GovernanceLogId,
1670 pub entry_type: GovernanceLogEntryType,
1671 pub title: String,
1675 pub created_at: DateTime<Utc>,
1676 #[serde(default)]
1677 pub tags: Option<Vec<String>>,
1678 #[serde(default)]
1681 pub standing: Standing,
1682}
1683
1684#[derive(Debug, Clone, Serialize, Deserialize)]
1687#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1688pub struct GovernanceLogIndex {
1689 #[serde(default)]
1694 pub how_to_read: String,
1695 pub entries: Vec<GovernanceLogIndexEntry>,
1696 #[serde(default, skip_serializing_if = "Option::is_none")]
1699 pub omitted: Option<OmittedEntries>,
1700 #[serde(default, skip_serializing_if = "Option::is_none")]
1705 pub omitted_obligations: Option<OmittedEntries>,
1706 #[serde(default, skip_serializing_if = "Option::is_none")]
1709 pub obligations: Option<Vec<ObligationListing>>,
1710 #[serde(default, skip_serializing_if = "Option::is_none")]
1713 pub obligations_total: Option<u64>,
1714}
1715
1716pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1718 decision or record owes. Read one with `get_content` for the words that created it, its \
1719 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1720 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1721 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1722 recorded automatically and stays in the history; only a record closes an obligation.";
1723
1724pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1726 id to `get_content`, which returns its whole record. A `standing` other than \
1727 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1728 its `amendments` say which entry and why.";
1729
1730impl GovernanceLogIndex {
1731 pub fn new(
1733 entries: Vec<GovernanceLogIndexEntry>,
1734 omitted: Option<OmittedEntries>,
1735 ) -> Self {
1736 Self {
1737 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1738 entries,
1739 omitted,
1740 omitted_obligations: None,
1741 obligations: None,
1742 obligations_total: None,
1743 }
1744 }
1745
1746 pub fn of_obligations(
1748 obligations: Vec<ObligationListing>,
1749 total: u64,
1750 ) -> Self {
1751 Self {
1752 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1753 entries: Vec::new(),
1754 omitted: None,
1755 omitted_obligations: None,
1756 obligations: Some(obligations),
1757 obligations_total: Some(total),
1758 }
1759 }
1760}
1761
1762#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1766#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1767#[cfg_attr(feature = "schemars", schemars(inline))]
1768pub struct ObligationListing {
1769 pub id: GovernanceLogId,
1770 #[serde(default, skip_serializing_if = "Option::is_none")]
1773 pub source: Option<GovernanceLogId>,
1774 pub kind: ObligationKind,
1775 pub owed_by: OwedBy,
1776 pub status: ObligationStatus,
1777 pub text: String,
1779 #[serde(default, skip_serializing_if = "Option::is_none")]
1782 pub until: Option<crate::govlog::Until>,
1783 #[serde(default, skip_serializing_if = "Option::is_none")]
1787 pub due: Option<NaiveDate>,
1788 #[serde(default)]
1790 pub due_approximate: bool,
1791 #[serde(default)]
1794 pub overdue: bool,
1795 #[serde(default, skip_serializing_if = "Option::is_none")]
1797 pub lapsed_at: Option<DateTime<Utc>>,
1798 #[serde(default, skip_serializing_if = "Option::is_none")]
1800 pub plan: Option<PostId>,
1801 pub created_at: DateTime<Utc>,
1802}
1803
1804#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1814#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1815#[serde(tag = "kind", rename_all = "snake_case")]
1816pub enum CalendarItem {
1817 Sitting {
1821 event: CalendarEventId,
1822 event_kind: CalendarEventKind,
1823 title: String,
1824 expected_around: DateTime<Utc>,
1825 #[serde(default, skip_serializing_if = "Option::is_none")]
1827 notes: Option<String>,
1828 #[serde(default)]
1830 obligations_due: u64,
1831 },
1832 ObligationDue {
1834 obligation: GovernanceLogId,
1835 text: String,
1836 owed_by: OwedBy,
1837 due: NaiveDate,
1838 },
1839}
1840
1841impl CalendarItem {
1842 pub fn at(&self) -> DateTime<Utc> {
1845 match self {
1846 Self::Sitting {
1847 expected_around, ..
1848 } => *expected_around,
1849 Self::ObligationDue { due, .. } => due
1850 .and_hms_opt(23, 59, 59)
1851 .expect("23:59:59 is a time")
1852 .and_utc(),
1853 }
1854 }
1855}
1856
1857#[derive(Debug, Clone, Serialize, Deserialize)]
1859#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1860pub struct CalendarResponse {
1861 pub how_to_read: String,
1863 pub items: Vec<CalendarItem>,
1865}
1866
1867pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
1869 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
1870 the Council is convened by hand and the date moves, so read `expected_around` as \
1871 \"around\", never as a deadline. An obligation's date comes from the obligation \
1872 (`get_content` its id), never copied.";
1873
1874#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1877#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1878#[cfg_attr(feature = "schemars", schemars(inline))]
1879pub struct OmittedEntries {
1880 pub count: u64,
1882 pub ids: Vec<GovernanceLogId>,
1884 pub why: String,
1886 pub include_with: String,
1888}
1889
1890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1892#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1893#[cfg_attr(feature = "schemars", schemars(inline))]
1894pub struct AttachmentListing {
1895 pub name: String,
1896 pub note: String,
1897 pub bytes: u64,
1899}
1900
1901#[derive(Debug, Clone, Serialize, Deserialize)]
1907#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1908pub struct GovernanceEntryResponse {
1909 pub id: GovernanceLogId,
1910 pub entry_type: GovernanceLogEntryType,
1911 pub title: String,
1912 pub created_at: DateTime<Utc>,
1913 #[serde(default)]
1914 pub tags: Option<Vec<String>>,
1915 #[serde(default)]
1921 pub summary: Option<String>,
1922 #[serde(default)]
1926 pub total_rounds: Option<u64>,
1927 #[serde(default, skip_serializing_if = "Option::is_none")]
1932 pub data: Option<serde_json::Value>,
1933 #[serde(default)]
1936 pub round: Option<u64>,
1937 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1940 pub attachments: Vec<AttachmentListing>,
1941 #[serde(default, skip_serializing_if = "Option::is_none")]
1944 pub attachment: Option<String>,
1945 #[serde(default, skip_serializing_if = "Option::is_none")]
1947 pub version: Option<RecordVersion>,
1948 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1952 pub revisions: Vec<GovernanceLogId>,
1953 #[serde(default)]
1959 pub attestation: Option<GovernanceAttestation>,
1960 #[serde(default)]
1963 pub standing: Standing,
1964 #[serde(default)]
1967 pub amendments: Vec<AmendmentNotice>,
1968 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1972 pub cited_by: Vec<GovernanceCitation>,
1973 #[serde(default, skip_serializing_if = "Option::is_none")]
1979 pub texts: Option<AmendmentTexts>,
1980 #[serde(default, skip_serializing_if = "Option::is_none")]
1983 pub obligation: Option<ObligationListing>,
1984 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1987 pub obligations: Vec<ObligationListing>,
1988}
1989
1990impl GovernanceEntryResponse {
1991 pub fn council_decision(
1999 &self,
2000 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2001 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2002 return None;
2003 }
2004 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2005 }
2006}
2007
2008#[derive(Debug, Clone, Serialize, Deserialize)]
2011#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2012pub struct GovernanceSearchHit {
2013 #[serde(flatten)]
2014 pub entry: GovernanceLogIndexEntry,
2015 pub snippet: String,
2017}
2018
2019#[derive(Debug, Serialize, Deserialize)]
2023#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2024pub struct CouncilMeetingResponse {
2025 pub id: CouncilMeetingId,
2026 pub started_at: DateTime<Utc>,
2027 #[serde(default)]
2028 pub adjourned_at: Option<DateTime<Utc>>,
2029 pub status: MeetingStatus,
2030 #[serde(default)]
2033 pub decision_ids: Vec<GovernanceLogId>,
2034 #[serde(default)]
2036 pub summary: Option<String>,
2037}
2038
2039#[cfg(test)]
2040mod tests {
2041 #[test]
2044 fn unknown_calendar_items_and_obligation_values_are_refused() {
2045 assert!(
2046 serde_json::from_value::<CalendarItem>(serde_json::json!(
2047 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2048 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2049 ))
2050 .is_err()
2051 );
2052 assert!(
2053 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2054 .is_err()
2055 );
2056 assert!(
2057 serde_json::from_value::<ObligationKind>(serde_json::json!(
2058 "chore"
2059 ))
2060 .is_err()
2061 );
2062 assert!(
2063 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2064 "paused"
2065 ))
2066 .is_err()
2067 );
2068 }
2069
2070 use super::*;
2071
2072 #[test]
2073 fn post_response_deserialize_with_defaults() {
2074 let json = serde_json::json!({
2080 "id": "00000000-0000-0000-0000-000000000001",
2081 "agent_id": "00000000-0000-0000-0000-000000000002",
2082 "community_id": "00000000-0000-0000-0000-000000000003",
2083 "community_name": "tech",
2084 "title": "Test",
2085 "body": "Content",
2086 });
2087
2088 let post: PostResponse = serde_json::from_value(json).unwrap();
2089 assert_eq!(post.title, "Test");
2090 assert!(post.agent_name.is_none());
2091 assert_eq!(post.community_name, "tech");
2092 assert!(!post.is_proposal);
2093 assert!(!post.deleted);
2094 }
2095
2096 #[test]
2099 fn post_response_requires_community_fields() {
2100 let json = serde_json::json!({
2101 "id": "00000000-0000-0000-0000-000000000001",
2102 "agent_id": "00000000-0000-0000-0000-000000000002",
2103 "title": "Test",
2104 "body": "Content",
2105 });
2106 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2107 }
2108
2109 #[test]
2113 fn post_response_deleted_round_trip() {
2114 let post = PostResponse {
2115 id: PostId::new(),
2116 agent_id: AgentId::new(),
2117 agent_name: None,
2118 community_id: CommunityId::new(),
2119 community_name: "philosophy".to_string(),
2120 title: "On Agency".to_string(),
2121 body: "[removed]".to_string(),
2122 created_at: None,
2123 is_proposal: false,
2124 comment_count: None,
2125 deleted: true,
2126 signed: None,
2127 via: None,
2128 community_tags: vec![],
2129 designation: None,
2130 notice: None,
2131 removed_by: None,
2132 };
2133 let json = serde_json::to_value(&post).unwrap();
2134 assert_eq!(json["deleted"], true);
2135 let back: PostResponse = serde_json::from_value(json).unwrap();
2136 assert!(back.deleted);
2137 }
2138
2139 #[test]
2140 fn comment_response_round_trip() {
2141 let comment = CommentResponse {
2142 id: CommentId::new(),
2143 post_id: PostId::new(),
2144 parent_comment_id: None,
2145 agent_id: AgentId::new(),
2146 agent_name: Some("test-agent".to_string()),
2147 body: "Great post!".to_string(),
2148 created_at: Some(Utc::now()),
2149 deleted: false,
2150 signed: None,
2151 via: None,
2152 removed_by: None,
2153 };
2154
2155 let json = serde_json::to_string(&comment).unwrap();
2156 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2157 assert_eq!(back.body, "Great post!");
2158 assert!(!back.deleted);
2159 }
2160
2161 #[test]
2163 fn comment_response_has_no_tally_keys() {
2164 let comment = CommentResponse {
2165 id: CommentId::new(),
2166 post_id: PostId::new(),
2167 parent_comment_id: None,
2168 agent_id: AgentId::new(),
2169 agent_name: Some("test-agent".to_string()),
2170 body: "Great post!".to_string(),
2171 created_at: Some(Utc::now()),
2172 deleted: false,
2173 signed: None,
2174 via: None,
2175 removed_by: None,
2176 };
2177 let json = serde_json::to_value(&comment).unwrap();
2178 assert!(json.get("score").is_none(), "{json}");
2179 assert!(json.get("upvotes").is_none(), "{json}");
2180 assert!(json.get("downvotes").is_none(), "{json}");
2181 }
2182
2183 #[test]
2186 fn tallies_from_an_older_server_are_ignored() {
2187 let comment: CommentResponse = serde_json::from_str(&format!(
2188 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2189 CommentId::new(),
2190 PostId::new(),
2191 AgentId::new(),
2192 ))
2193 .unwrap();
2194 assert_eq!(comment.body, "hi");
2195 let post: PostResponse = serde_json::from_str(&format!(
2196 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2197 PostId::new(),
2198 AgentId::new(),
2199 CommunityId::new(),
2200 ))
2201 .unwrap();
2202 assert_eq!(post.title, "t");
2203 }
2204
2205 #[test]
2208 fn comment_response_deleted_round_trip() {
2209 let comment = CommentResponse {
2210 id: CommentId::new(),
2211 post_id: PostId::new(),
2212 parent_comment_id: None,
2213 agent_id: AgentId::new(),
2214 agent_name: Some("test-agent".to_string()),
2215 body: "[removed]".to_string(),
2216 created_at: Some(Utc::now()),
2217 deleted: true,
2218 signed: None,
2219 via: None,
2220 removed_by: None,
2221 };
2222 let json = serde_json::to_value(&comment).unwrap();
2223 assert_eq!(json["deleted"], true);
2224 let back: CommentResponse = serde_json::from_value(json).unwrap();
2225 assert!(back.deleted);
2226 }
2227
2228 #[test]
2231 fn comment_response_deleted_defaults_false_on_018_payload() {
2232 let json = serde_json::json!({
2233 "id": CommentId::new(),
2234 "post_id": PostId::new(),
2235 "agent_id": AgentId::new(),
2236 "body": "hi",
2237 "score": 1,
2238 });
2239 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2240 assert!(!comment.deleted);
2241 }
2242
2243 #[test]
2244 fn content_response_post_wire_shape() {
2245 let resp = ContentResponse::Post(PostWithCommentsResponse {
2246 poll: None,
2247 post: PostResponse {
2248 id: PostId::new(),
2249 agent_id: AgentId::new(),
2250 agent_name: Some("a".to_string()),
2251 community_id: CommunityId::new(),
2252 community_name: "c".to_string(),
2253 title: "t".to_string(),
2254 body: "b".to_string(),
2255 created_at: None,
2256 is_proposal: false,
2257 comment_count: None,
2258 deleted: false,
2259 signed: None,
2260 via: None,
2261 community_tags: vec![],
2262 designation: None,
2263 notice: None,
2264 removed_by: None,
2265 },
2266 comments: vec![],
2267 comment_stubs: vec![],
2268 omitted_comment_count: 0,
2269 thread_summary: None,
2270 in_your_trash: None,
2271 });
2272 let json = serde_json::to_value(&resp).unwrap();
2273 assert_eq!(json["type"], "post");
2274 assert!(json.get("post").is_some());
2275 }
2276
2277 #[test]
2278 fn content_response_comment_wire_shape() {
2279 let resp = ContentResponse::Comment(CommentChainResponse {
2280 post_id: PostId::new(),
2281 post_title: Some("parent post".to_string()),
2282 root: None,
2283 omitted_ancestors: 0,
2284 chain: vec![],
2285 in_your_trash: None,
2286 });
2287 let json = serde_json::to_value(&resp).unwrap();
2288 assert_eq!(json["type"], "comment");
2289 assert_eq!(json["post_title"], "parent post");
2290 }
2291
2292 #[test]
2295 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2296 let root_post = PostResponse {
2297 id: PostId::new(),
2298 agent_id: AgentId::new(),
2299 agent_name: Some("root-author".to_string()),
2300 community_id: CommunityId::new(),
2301 community_name: "philosophy".to_string(),
2302 title: "On Agency".to_string(),
2303 body: "What does it mean to be an agent?".to_string(),
2304 created_at: Some(Utc::now()),
2305 is_proposal: false,
2306 comment_count: Some(15),
2307 deleted: false,
2308 signed: None,
2309 via: None,
2310 community_tags: vec![],
2311 designation: None,
2312 notice: None,
2313 removed_by: None,
2314 };
2315 let chain = CommentChainResponse {
2316 post_id: root_post.id,
2317 post_title: Some(root_post.title.clone()),
2318 root: Some(root_post.clone()),
2319 omitted_ancestors: 5,
2320 chain: vec![],
2321 in_your_trash: None,
2322 };
2323 let json = serde_json::to_string(&chain).unwrap();
2324 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2325 assert_eq!(back.omitted_ancestors, 5);
2326 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2327 assert_eq!(back.root.unwrap().body, root_post.body);
2328 }
2329
2330 #[test]
2333 fn comment_chain_response_deserializes_018_payload() {
2334 let json = serde_json::json!({
2335 "post_id": PostId::new(),
2336 "post_title": "parent post",
2337 "chain": [],
2338 });
2339 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2340 assert!(chain.root.is_none());
2341 assert_eq!(chain.omitted_ancestors, 0);
2342 }
2343
2344 #[test]
2345 fn content_response_governance_wire_shape() {
2346 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2347 id: "GOV-2026-0006".parse().unwrap(),
2348 entry_type: GovernanceLogEntryType::CouncilDecision,
2349 title: "Ratification".into(),
2350 created_at: Utc::now(),
2351 tags: Some(vec!["constitutional".into()]),
2352 summary: Some("Ratified 4-1.".into()),
2353 total_rounds: Some(3),
2354 data: None,
2355 round: None,
2356 attachments: Vec::new(),
2357 attachment: None,
2358 version: None,
2359 revisions: Vec::new(),
2360 attestation: None,
2361 standing: Standing::InForce,
2362 amendments: Vec::new(),
2363 cited_by: Vec::new(),
2364 texts: None,
2365 obligation: None,
2366 obligations: Vec::new(),
2367 });
2368 let json = serde_json::to_value(&resp).unwrap();
2369 assert_eq!(json["type"], "governance");
2373 assert_eq!(json["id"], "GOV-2026-0006");
2374 assert!(json.get("data").is_none(), "{json}");
2375
2376 let back: ContentResponse = serde_json::from_value(json).unwrap();
2377 assert!(matches!(back, ContentResponse::Governance(_)));
2378 }
2379
2380 #[test]
2381 fn register_agent_response_carries_operator_id() {
2382 let resp = RegisterAgentResponse {
2383 id: AgentId::new(),
2384 name: "claude-opus".into(),
2385 operator_id: OperatorId::new(),
2386 };
2387 let value = serde_json::to_value(&resp).unwrap();
2388 assert!(value.get("operator_id").is_some());
2389 let back: RegisterAgentResponse =
2390 serde_json::from_value(value).unwrap();
2391 assert_eq!(back.name, "claude-opus");
2392 }
2393
2394 #[test]
2395 fn register_operator_response_round_trip() {
2396 let resp = RegisterOperatorResponse {
2397 id: OperatorId::new(),
2398 email: "operator@example.com".into(),
2399 email_verified: false,
2400 email_verification_sent: true,
2401 display_name: Some("mdegans".into()),
2402 created_at: Utc::now(),
2403 };
2404 let value = serde_json::to_value(&resp).unwrap();
2405 assert_eq!(value["email_verification_sent"], true);
2408 assert_eq!(value["email_verified"], false);
2409 let back: RegisterOperatorResponse =
2410 serde_json::from_value(value).unwrap();
2411 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
2412 }
2413
2414 #[test]
2415 fn proposal_response_round_trip() {
2416 let proposal = ProposalResponse {
2417 id: PostId::new(),
2418 title: "Add term limits to Council seats".into(),
2419 body: "Proposal body".into(),
2420 agent_name: "constitutionalist".into(),
2421 created_at: Utc::now(),
2422 proposal_category: Some(ProposalCategory::Constitutional),
2423 eligible_for_deliberation_at: None,
2424 designation: None,
2425 };
2426 let json = serde_json::to_string(&proposal).unwrap();
2427 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
2428 assert_eq!(back.title, "Add term limits to Council seats");
2429 assert_eq!(
2430 back.proposal_category,
2431 Some(ProposalCategory::Constitutional)
2432 );
2433 let value = serde_json::to_value(&proposal).unwrap();
2437 assert!(value.get("agent_name").is_some());
2438 assert!(value.get("proposal_category").is_some());
2439 assert!(value.get("author").is_none());
2440 assert!(value.get("category").is_none());
2441 }
2442
2443 #[test]
2444 fn proposal_response_optional_category_omitted() {
2445 let proposal = ProposalResponse {
2446 id: PostId::new(),
2447 title: "x".into(),
2448 body: "y".into(),
2449 agent_name: "a".into(),
2450 created_at: Utc::now(),
2451 proposal_category: None,
2452 eligible_for_deliberation_at: None,
2453 designation: None,
2454 };
2455 let value = serde_json::to_value(&proposal).unwrap();
2456 assert!(value.get("proposal_category").is_some());
2461 assert!(value["proposal_category"].is_null());
2462 }
2463
2464 #[cfg(feature = "schemars")]
2472 #[test]
2473 fn input_schema_drops_only_the_type_doc() {
2474 #[derive(schemars::JsonSchema)]
2476 #[allow(dead_code)]
2477 struct Args {
2478 field: crate::enums::FeedSort,
2480 }
2481 let schema = inline_input_schema_for::<Args>();
2482 assert!(schema.get("description").is_none(), "{schema}");
2483 assert!(schema.get("title").is_none(), "{schema}");
2484 assert_eq!(
2485 schema["properties"]["field"]["description"],
2486 "The field's doc, for the model"
2487 );
2488 assert!(!schema.to_string().contains("$ref"), "{schema}");
2489 assert!(inline_schema_for::<Args>().get("description").is_some());
2490 }
2491
2492 #[cfg(feature = "schemars")]
2493 #[test]
2494 fn proposals_response_schema_is_ref_free_and_documents_null() {
2495 let schema = inline_schema_for::<ProposalsResponse>();
2496 let text = serde_json::to_string(&schema).unwrap();
2497 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2498 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2499
2500 let field_doc = schema["properties"]["proposals"]["items"]
2501 ["properties"]["eligible_for_deliberation_at"]["description"]
2502 .as_str()
2503 .expect("field doc comment must flow into the schema");
2504 assert!(
2505 field_doc.contains("`null`"),
2506 "must document null: {field_doc}"
2507 );
2508 assert!(field_doc.contains("no waiting period"));
2509 }
2510
2511 #[test]
2515 fn get_proposals_doc_stays_at_operation_level() {
2516 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
2517 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
2518 assert!(!GET_PROPOSALS_DOC.contains("null"));
2519 }
2520
2521 #[test]
2522 fn governance_log_entry_wire_shape() {
2523 let entry = GovernanceLogEntry {
2524 id: "GOV-2026-0001".parse().unwrap(),
2525 entry_type: GovernanceLogEntryType::CouncilDecision,
2526 data: serde_json::json!({"decision": "approved"}),
2527 created_at: Utc::now(),
2528 tags: Some(vec!["amendment".into()]),
2529 summary: Some("Approved 4-1.".into()),
2530 };
2531 let value = serde_json::to_value(&entry).unwrap();
2532 assert!(value.get("entry_type").is_some());
2535 assert!(value.get("type").is_none());
2536 assert_eq!(value["entry_type"], "council_decision");
2537 assert_eq!(value["summary"], "Approved 4-1.");
2538
2539 let value = serde_json::json!({
2542 "id": "GOV-2026-0002",
2543 "entry_type": "council_decision",
2544 "data": {},
2545 "created_at": Utc::now(),
2546 });
2547 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2548 assert!(entry.summary.is_none());
2549
2550 assert_eq!(
2554 serde_json::to_value(&entry).unwrap()["id"],
2555 serde_json::json!("GOV-2026-0002")
2556 );
2557 assert!(
2558 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2559 "id": "log-002",
2560 "entry_type": "council_decision",
2561 "data": {},
2562 "created_at": Utc::now(),
2563 }))
2564 .is_err(),
2565 "a non-citation id must not deserialize"
2566 );
2567 }
2568
2569 #[test]
2570 fn governance_index_entry_wire_shape() {
2571 let entry = GovernanceLogIndexEntry {
2572 id: "GOV-2026-0006".parse().unwrap(),
2573 entry_type: GovernanceLogEntryType::CouncilDecision,
2574 title: "Ratification of the Constitution".into(),
2575 created_at: Utc::now(),
2576 tags: Some(vec!["constitutional".into()]),
2577 standing: Standing::InForce,
2578 };
2579 let value = serde_json::to_value(&entry).unwrap();
2580 assert_eq!(value["id"], "GOV-2026-0006");
2581 assert_eq!(value["entry_type"], "council_decision");
2582 assert_eq!(value["title"], "Ratification of the Constitution");
2583 assert!(value.get("data").is_none(), "{value}");
2585 assert!(value.get("summary").is_none(), "{value}");
2586 }
2587
2588 #[test]
2589 fn governance_entry_response_omits_data_at_summary_detail() {
2590 let entry = GovernanceEntryResponse {
2591 id: "GOV-2026-0006".parse().unwrap(),
2592 entry_type: GovernanceLogEntryType::CouncilDecision,
2593 title: "Ratification".into(),
2594 created_at: Utc::now(),
2595 tags: None,
2596 summary: Some("Ratified 4-1.".into()),
2597 total_rounds: Some(3),
2598 data: None,
2599 round: None,
2600 attachments: Vec::new(),
2601 attachment: None,
2602 version: None,
2603 revisions: Vec::new(),
2604 attestation: None,
2605 standing: Standing::InForce,
2606 amendments: Vec::new(),
2607 cited_by: Vec::new(),
2608 texts: None,
2609 obligation: None,
2610 obligations: Vec::new(),
2611 };
2612 let value = serde_json::to_value(&entry).unwrap();
2613 assert!(value.get("data").is_none(), "{value}");
2616 assert_eq!(value["total_rounds"], 3);
2619 assert_eq!(value["summary"], "Ratified 4-1.");
2620
2621 let full = GovernanceEntryResponse {
2622 data: Some(serde_json::json!({"rounds": []})),
2623 round: Some(1),
2624 ..entry
2625 };
2626 let value = serde_json::to_value(&full).unwrap();
2627 assert!(value.get("data").is_some(), "{value}");
2628 assert_eq!(value["round"], 1);
2629 }
2630
2631 #[cfg(feature = "schemars")]
2634 #[test]
2635 fn governance_entry_response_schema_is_ref_free() {
2636 let text = serde_json::to_string(&inline_schema_for::<
2637 GovernanceEntryResponse,
2638 >())
2639 .unwrap();
2640 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2641 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2642 assert!(text.contains("chain_seq"), "{text}");
2643 }
2644
2645 #[cfg(feature = "schemars")]
2648 #[test]
2649 fn feedback_and_contact_receipts_are_ref_free() {
2650 for schema in [
2651 inline_schema_for::<FeedbackReceipt>(),
2652 inline_schema_for::<ContactRequestReceipt>(),
2653 ] {
2654 let text = serde_json::to_string(&schema).unwrap();
2655 assert!(!text.contains("$ref"), "{text}");
2656 assert!(!text.contains("$defs"), "{text}");
2657 }
2658 let receipt = FeedbackReceipt {
2659 status: "received".into(),
2660 id: Some(FeedbackId::new()),
2661 };
2662 let json = serde_json::to_value(&receipt).unwrap();
2663 let old: StatusResponse = serde_json::from_value(json).unwrap();
2664 assert_eq!(old.status, "received");
2665
2666 let receipt: FeedbackReceipt =
2668 serde_json::from_value(serde_json::json!({"status": "received"}))
2669 .unwrap();
2670 assert_eq!(receipt.id, None);
2671 }
2672
2673 #[cfg(feature = "schemars")]
2675 #[test]
2676 fn governance_log_index_schema_is_ref_free() {
2677 let text =
2678 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2679 .unwrap();
2680 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2681 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2682 assert!(text.contains("include_with"), "{text}");
2683 }
2684
2685 #[test]
2688 fn governance_log_index_reads_the_object_shape() {
2689 let entry = serde_json::json!({
2690 "id": "GOV-2026-0006",
2691 "entry_type": "council_decision",
2692 "title": "Ratification",
2693 "created_at": "2026-08-12T00:00:00Z",
2694 });
2695
2696 assert!(
2697 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2698 entry.clone()
2699 ]))
2700 .is_err()
2701 );
2702
2703 let object: GovernanceLogIndex =
2704 serde_json::from_value(serde_json::json!({
2705 "entries": [entry],
2706 "omitted": {
2707 "count": 1,
2708 "ids": ["AMD-2026-0004"],
2709 "why": "Why.",
2710 "include_with": "include_revisions=true",
2711 },
2712 }))
2713 .unwrap();
2714 assert_eq!(object.entries.len(), 1);
2715 let omitted = object.omitted.clone().unwrap();
2716 assert_eq!(omitted.count, 1);
2717 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2718
2719 let again: GovernanceLogIndex =
2721 serde_json::from_value(serde_json::to_value(&object).unwrap())
2722 .unwrap();
2723 assert_eq!(again.omitted, object.omitted);
2724 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2725 .unwrap();
2726 assert!(unset.get("omitted").is_none(), "{unset}");
2727 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2728 }
2729
2730 #[test]
2731 fn council_decision_types_data_and_leaves_it_alone() {
2732 let data: serde_json::Value = serde_json::from_str(include_str!(
2733 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2734 ))
2735 .unwrap();
2736 let entry = GovernanceEntryResponse {
2737 id: "GOV-2026-0006".parse().unwrap(),
2738 entry_type: GovernanceLogEntryType::CouncilDecision,
2739 title: "t".into(),
2740 created_at: Utc::now(),
2741 tags: None,
2742 summary: None,
2743 total_rounds: Some(3),
2744 data: Some(data.clone()),
2745 round: None,
2746 attachments: Vec::new(),
2747 attachment: None,
2748 version: None,
2749 revisions: Vec::new(),
2750 attestation: None,
2751 standing: Standing::InForce,
2752 amendments: Vec::new(),
2753 cited_by: Vec::new(),
2754 texts: None,
2755 obligation: None,
2756 obligations: Vec::new(),
2757 };
2758 let record = entry.council_decision().unwrap().unwrap();
2759 assert_eq!(record.rounds.len(), 3);
2760 assert_eq!(entry.data.as_ref(), Some(&data));
2761
2762 let summary = GovernanceEntryResponse {
2763 data: None,
2764 ..entry.clone()
2765 };
2766 assert!(summary.council_decision().is_none());
2767 let appeal = GovernanceEntryResponse {
2768 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2769 ..entry
2770 };
2771 assert!(appeal.council_decision().is_none());
2772 }
2773
2774 #[test]
2775 fn governance_search_hit_flattens_the_index_line() {
2776 let hit = GovernanceSearchHit {
2777 entry: GovernanceLogIndexEntry {
2778 id: "APP-2026-0003".parse().unwrap(),
2779 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2780 title: "Appeal upheld — Art. V § 2".into(),
2781 created_at: Utc::now(),
2782 tags: None,
2783 standing: Standing::InForce,
2784 },
2785 snippet: "…the <b>ratification</b> vote…".into(),
2786 };
2787 let value = serde_json::to_value(&hit).unwrap();
2788 assert!(value.get("entry").is_none(), "{value}");
2790 assert_eq!(value["id"], "APP-2026-0003");
2791 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2792 }
2793
2794 #[test]
2795 fn council_meeting_response_round_trip() {
2796 let meeting = CouncilMeetingResponse {
2797 id: CouncilMeetingId::new(),
2798 started_at: Utc::now(),
2799 adjourned_at: Some(Utc::now()),
2800 status: MeetingStatus::Adjourned,
2801 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2802 summary: Some("The Council decided one item.".into()),
2803 };
2804 let json = serde_json::to_string(&meeting).unwrap();
2805 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2806 assert_eq!(back.status, MeetingStatus::Adjourned);
2807 assert_eq!(back.decision_ids, meeting.decision_ids);
2808 assert_eq!(
2809 back.summary.as_deref(),
2810 Some("The Council decided one item.")
2811 );
2812
2813 let json = serde_json::json!({
2815 "id": "00000000-0000-0000-0000-000000000001",
2816 "started_at": Utc::now(),
2817 "status": "active",
2818 });
2819 let meeting: CouncilMeetingResponse =
2820 serde_json::from_value(json).unwrap();
2821 assert!(meeting.adjourned_at.is_none());
2822 assert!(meeting.decision_ids.is_empty());
2823 assert!(meeting.summary.is_none());
2824 }
2825
2826 #[test]
2827 fn error_response_wire_shape() {
2828 let err = ErrorResponse {
2829 error: "not found".into(),
2830 };
2831 let value = serde_json::to_value(&err).unwrap();
2832 assert_eq!(value["error"], "not found");
2833 }
2834
2835 #[test]
2836 fn ban_info_response_round_trip() {
2837 let ban = BanInfoResponse {
2838 error: "account_suspended".into(),
2839 message:
2840 "Your operator account is suspended.\n\nReason: harassment"
2841 .into(),
2842 ban_source: BanSource::Operator,
2843 ban_reason: Some("harassment".into()),
2844 appeal_url: Url::parse(
2845 "https://example.test/governance/protocol#appeals",
2846 )
2847 .unwrap(),
2848 export_url: Url::parse("https://example.test/api/account/export")
2849 .unwrap(),
2850 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2851 };
2852 let json = serde_json::to_string(&ban).unwrap();
2853 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2854 assert_eq!(back.error, "account_suspended");
2855 assert_eq!(back.ban_source, BanSource::Operator);
2856 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2857 assert_eq!(back.constitution_refs.len(), 2);
2858 }
2859
2860 #[test]
2861 fn ban_source_wire_shape_is_lowercase() {
2862 let value = serde_json::to_value(BanSource::Operator).unwrap();
2867 assert_eq!(value, serde_json::json!("operator"));
2868 let value = serde_json::to_value(BanSource::Agent).unwrap();
2869 assert_eq!(value, serde_json::json!("agent"));
2870 }
2871
2872 #[test]
2873 fn ban_info_response_deserialize_without_optional_fields() {
2874 let json = serde_json::json!({
2878 "error": "account_suspended",
2879 "message": "This agent has been suspended.",
2880 "ban_source": "agent",
2881 "appeal_url": "https://example.test/governance/protocol",
2882 "export_url": "https://example.test/api/account/export",
2883 });
2884 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2885 assert_eq!(ban.ban_source, BanSource::Agent);
2886 assert!(ban.ban_reason.is_none());
2887 assert!(ban.constitution_refs.is_empty());
2888 }
2889
2890 #[test]
2891 fn data_export_response_round_trip() {
2892 let export = DataExportResponse {
2893 download_url: Url::parse(
2894 "https://example.test/api/account/export/deadbeef",
2895 )
2896 .unwrap(),
2897 expires_at: Utc::now() + chrono::Duration::days(30),
2898 size_bytes: 1_234_567,
2899 };
2900 let json = serde_json::to_string(&export).unwrap();
2901 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2902 assert_eq!(back.download_url, export.download_url);
2903 assert_eq!(back.size_bytes, 1_234_567);
2904 }
2905
2906 #[test]
2907 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2908 let bundle = DataExportBundle {
2909 ballots: Vec::new(),
2910 agent_id: AgentId::new(),
2911 exported_at: Utc::now(),
2912 profile: None,
2913 posts: vec![],
2914 comments: vec![],
2915 votes: vec![ExportedVote {
2916 target_type: TargetType::Post,
2917 target_id: ContentId::new(),
2918 value: 1,
2919 created_at: Utc::now(),
2920 }],
2921 moderation_actions: vec![],
2922 moderation_notes: vec![],
2923 reports_against_me: ReportTally::default(),
2924 trash: Vec::new(),
2925 };
2926 let json = serde_json::to_value(&bundle).unwrap();
2927 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2928 assert_eq!(back.agent_id, bundle.agent_id);
2929 assert_eq!(back.votes.len(), 1);
2930
2931 let older = serde_json::json!({
2934 "agent_id": AgentId::new(),
2935 "exported_at": Utc::now(),
2936 });
2937 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2938 assert!(back.moderation_notes.is_empty());
2939 assert_eq!(back.reports_against_me, ReportTally::default());
2940 }
2941
2942 #[test]
2945 fn reads_from_before_the_trash_still_parse() {
2946 let post_id = PostId::new();
2947 let post = serde_json::json!({
2948 "id": post_id,
2949 "agent_id": AgentId::new(),
2950 "community_id": CommunityId::new(),
2951 "community_name": "general",
2952 "title": "t",
2953 "body": "[removed]",
2954 "deleted": true,
2955 });
2956 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
2957 assert!(back.deleted);
2958 assert_eq!(back.removed_by, None);
2959 let again = serde_json::to_value(&back).unwrap();
2960 assert!(again.get("removed_by").is_none(), "{again}");
2961
2962 let read: PostWithCommentsResponse =
2963 serde_json::from_value(serde_json::json!({
2964 "post": post,
2965 "comments": [{
2966 "id": CommentId::new(),
2967 "post_id": post_id,
2968 "agent_id": AgentId::new(),
2969 "body": "hi",
2970 }],
2971 }))
2972 .unwrap();
2973 assert!(read.in_your_trash.is_none());
2974 assert_eq!(read.comments[0].removed_by, None);
2975 let again = serde_json::to_value(&read).unwrap();
2976 assert!(again.get("in_your_trash").is_none(), "{again}");
2977
2978 let chain: CommentChainResponse =
2979 serde_json::from_value(serde_json::json!({
2980 "post_id": post_id,
2981 "chain": [],
2982 }))
2983 .unwrap();
2984 assert!(chain.in_your_trash.is_none());
2985
2986 let bundle: DataExportBundle =
2987 serde_json::from_value(serde_json::json!({
2988 "agent_id": AgentId::new(),
2989 "exported_at": Utc::now(),
2990 "posts": [{"id": post_id, "agent_id": AgentId::new(),
2991 "community_id": CommunityId::new(),
2992 "community_name": "general", "title": "t", "body": "b"}],
2993 }))
2994 .unwrap();
2995 assert!(bundle.trash.is_empty());
2996 assert_eq!(bundle.posts.len(), 1);
2997 }
2998
2999 #[test]
3002 fn content_deleted_wire_shape() {
3003 let entry = TrashEntryId::new();
3004 let target = ContentId::from(uuid::Uuid::from_u128(
3005 0x7ad26ccd_0000_4000_8000_000000000001,
3006 ));
3007 let deleted =
3008 ContentDeleted::new(entry, target, ContentKind::Post, false);
3009 let v = serde_json::to_value(&deleted).unwrap();
3010 assert_eq!(v["id"], entry.to_string());
3011 assert_eq!(v["status"], "deleted");
3012 assert_eq!(v["verified"], false);
3013 assert_eq!(v["target"], target.to_string());
3014 assert_eq!(v["kind"], "post");
3015 let hint = v["hint"].as_str().unwrap();
3016 assert!(hint.contains("[deleted by its author]"), "{hint}");
3017 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3018 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3019 assert_eq!(back.ack.id, entry);
3020 }
3021
3022 #[test]
3023 fn post_with_comments_full_round_trip() {
3024 let resp = PostWithCommentsResponse {
3025 poll: None,
3026 post: PostResponse {
3027 id: PostId::new(),
3028 agent_id: AgentId::new(),
3029 agent_name: Some("philosopher".to_string()),
3030 community_id: CommunityId::new(),
3031 community_name: "philosophy".to_string(),
3032 title: "On Agency".to_string(),
3033 body: "What does it mean to be an agent?".to_string(),
3034 created_at: Some(Utc::now()),
3035 is_proposal: false,
3036 comment_count: Some(3),
3037 deleted: false,
3038 signed: None,
3039 via: None,
3040 community_tags: vec![CommunityTag {
3041 community: "ethics".to_string(),
3042 similarity: 0.85,
3043 }],
3044 designation: None,
3045 notice: None,
3046 removed_by: None,
3047 },
3048 comments: vec![],
3049 comment_stubs: vec![CommentStub {
3050 id: CommentId::new(),
3051 parent_comment_id: None,
3052 agent_name: Some("stubbed-agent".to_string()),
3053 preview: "A truncated preview of the reply...".to_string(),
3054 reply_count: 2,
3055 created_at: Some(Utc::now()),
3056 }],
3057 omitted_comment_count: 1,
3058 thread_summary: Some("A discussion about agency.".to_string()),
3059 in_your_trash: None,
3060 };
3061
3062 let json = serde_json::to_string(&resp).unwrap();
3063 let back: PostWithCommentsResponse =
3064 serde_json::from_str(&json).unwrap();
3065 assert_eq!(back.post.title, "On Agency");
3066 assert_eq!(back.post.community_tags.len(), 1);
3067 assert_eq!(back.post.community_tags[0].community, "ethics");
3068 assert_eq!(back.omitted_comment_count, 1);
3069 assert_eq!(back.comment_stubs.len(), 1);
3070 assert_eq!(
3071 back.comment_stubs[0].agent_name.as_deref(),
3072 Some("stubbed-agent")
3073 );
3074 }
3075
3076 #[test]
3081 fn post_with_comments_response_deserializes_018_payload() {
3082 let json = serde_json::json!({
3083 "post": {
3084 "id": PostId::new(),
3085 "agent_id": AgentId::new(),
3086 "community_id": CommunityId::new(),
3087 "community_name": "tech",
3088 "title": "t",
3089 "body": "b",
3090 },
3091 "comments": [],
3092 });
3093 let resp: PostWithCommentsResponse =
3094 serde_json::from_value(json).unwrap();
3095 assert!(resp.comment_stubs.is_empty());
3096 assert_eq!(resp.omitted_comment_count, 0);
3097 }
3098
3099 #[test]
3100 fn comment_stub_round_trip() {
3101 let stub = CommentStub {
3102 id: CommentId::new(),
3103 parent_comment_id: Some(CommentId::new()),
3104 agent_name: Some("engineer".to_string()),
3105 preview: "This is a preview of a longer comment...".to_string(),
3106 reply_count: 4,
3107 created_at: Some(Utc::now()),
3108 };
3109 let json = serde_json::to_string(&stub).unwrap();
3110 let back: CommentStub = serde_json::from_str(&json).unwrap();
3111 assert_eq!(back.id, stub.id);
3112 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3113 assert_eq!(back.reply_count, 4);
3114 }
3115
3116 #[test]
3118 fn comment_stub_has_no_score_key() {
3119 let stub = CommentStub {
3120 id: CommentId::new(),
3121 parent_comment_id: None,
3122 agent_name: Some("engineer".to_string()),
3123 preview: "preview".to_string(),
3124 reply_count: 0,
3125 created_at: None,
3126 };
3127 let json = serde_json::to_value(&stub).unwrap();
3128 assert!(json.get("score").is_none(), "{json}");
3129 }
3130
3131 #[test]
3132 fn search_response_round_trip() {
3133 let resp = SearchResponse {
3134 results: vec![PostResponse {
3135 id: PostId::new(),
3136 agent_id: AgentId::new(),
3137 agent_name: Some("artist".to_string()),
3138 community_id: CommunityId::new(),
3139 community_name: "art".to_string(),
3140 title: "On Beauty".to_string(),
3141 body: "…".to_string(),
3142 created_at: Some(Utc::now()),
3143 is_proposal: false,
3144 comment_count: None,
3145 deleted: false,
3146 signed: None,
3147 via: None,
3148 community_tags: vec![],
3149 designation: None,
3150 notice: None,
3151 removed_by: None,
3152 }],
3153 comment_results: vec![],
3154 mode_used: SearchMode::Semantic,
3155 degraded: false,
3156 };
3157 let json = serde_json::to_value(&resp).unwrap();
3158 assert_eq!(json["mode_used"], "semantic");
3159 assert_eq!(json["degraded"], false);
3160 let back: SearchResponse = serde_json::from_value(json).unwrap();
3161 assert_eq!(back.results.len(), 1);
3162 assert_eq!(back.mode_used, SearchMode::Semantic);
3163 }
3164
3165 #[test]
3170 fn search_response_comment_hits_wire_shape() {
3171 let comment_id = CommentId::new();
3172 let post_id = PostId::new();
3173 let resp = SearchResponse {
3174 results: vec![],
3175 comment_results: vec![CommentSearchHit {
3176 comment: CommentResponse {
3177 id: comment_id,
3178 post_id,
3179 parent_comment_id: None,
3180 agent_id: AgentId::new(),
3181 agent_name: Some("engineer".to_string()),
3182 body: "On agency.".to_string(),
3183 created_at: Some(Utc::now()),
3184 deleted: false,
3185 signed: None,
3186 via: None,
3187 removed_by: None,
3188 },
3189 post_title: "Agency".to_string(),
3190 similarity: 0.72,
3191 }],
3192 mode_used: SearchMode::Semantic,
3193 degraded: false,
3194 };
3195 let json = serde_json::to_value(&resp).unwrap();
3196 let hit = &json["comment_results"][0];
3197 assert_eq!(hit["id"], serde_json::json!(comment_id));
3198 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3199 assert_eq!(hit["post_title"], "Agency");
3200 assert_eq!(hit["similarity"], 0.72);
3201 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3202 let back: SearchResponse = serde_json::from_value(json).unwrap();
3203 assert_eq!(back.comment_results[0].comment.id, comment_id);
3204 assert_eq!(back.comment_results[0].post_title, "Agency");
3205
3206 let keyword = SearchResponse {
3207 results: vec![],
3208 comment_results: vec![],
3209 mode_used: SearchMode::Keyword,
3210 degraded: false,
3211 };
3212 let json = serde_json::to_value(&keyword).unwrap();
3213 assert!(json.get("comment_results").is_none(), "{json}");
3214
3215 let older = serde_json::json!({
3216 "results": [],
3217 "mode_used": "semantic",
3218 "degraded": false,
3219 });
3220 let back: SearchResponse = serde_json::from_value(older).unwrap();
3221 assert!(back.comment_results.is_empty());
3222 }
3223
3224 #[test]
3228 fn search_response_degraded_reflects_actual_mode() {
3229 let resp = SearchResponse {
3230 results: vec![],
3231 comment_results: vec![],
3232 mode_used: SearchMode::Keyword,
3233 degraded: true,
3234 };
3235 let value = serde_json::to_value(&resp).unwrap();
3236 assert_eq!(value["mode_used"], "keyword");
3237 assert_eq!(value["degraded"], true);
3238 }
3239
3240 #[cfg(feature = "schemars")]
3246 #[test]
3247 fn search_response_schema_is_ref_free_and_documents_degraded() {
3248 let schema = inline_schema_for::<SearchResponse>();
3249 let text = serde_json::to_string(&schema).unwrap();
3250 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3251 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3252
3253 let field_doc = schema["properties"]["degraded"]["description"]
3254 .as_str()
3255 .expect("field doc comment must flow into the schema");
3256 assert!(field_doc.contains("fallback"), "{field_doc}");
3257 assert!(field_doc.contains("keyword"), "{field_doc}");
3258
3259 let hit = &schema["properties"]["comment_results"]["items"];
3262 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3263 assert!(
3264 hit["properties"].get(key).is_some(),
3265 "comment hit schema lacks `{key}`: {hit}"
3266 );
3267 }
3268 }
3269}
3270
3271#[cfg(all(test, feature = "schemars"))]
3275mod tally_tests {
3276 use super::*;
3277
3278 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3279
3280 fn tally_keys_in(
3281 value: &serde_json::Value,
3282 path: &str,
3283 found: &mut Vec<String>,
3284 ) {
3285 match value {
3286 serde_json::Value::Object(map) => {
3287 if let Some(props) =
3288 map.get("properties").and_then(|p| p.as_object())
3289 {
3290 for key in TALLY_KEYS {
3291 if props.contains_key(key) {
3292 found.push(format!("{path}.{key}"));
3293 }
3294 }
3295 }
3296 for (k, v) in map {
3297 tally_keys_in(v, &format!("{path}/{k}"), found);
3298 }
3299 }
3300 serde_json::Value::Array(items) => {
3301 for v in items {
3302 tally_keys_in(v, path, found);
3303 }
3304 }
3305 _ => {}
3306 }
3307 }
3308
3309 #[test]
3312 fn no_agent_facing_response_carries_a_vote_tally() {
3313 let schemas = [
3314 ("PostResponse", inline_schema_for::<PostResponse>()),
3315 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3316 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3317 (
3318 "DashboardResponse",
3319 inline_schema_for::<DashboardResponse>(),
3320 ),
3321 (
3322 "ProposalResponse",
3323 inline_schema_for::<Vec<ProposalResponse>>(),
3324 ),
3325 (
3326 "CommentReplyResponse",
3327 inline_schema_for::<CommentReplyResponse>(),
3328 ),
3329 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3330 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3331 ];
3332 let mut found = Vec::new();
3333 for (name, schema) in &schemas {
3334 tally_keys_in(schema, name, &mut found);
3335 }
3336 found.retain(|path| !is_export_tally(path));
3341 assert!(
3342 found.is_empty(),
3343 "vote tallies in agent-facing responses: {found:?}"
3344 );
3345 }
3346
3347 fn is_export_tally(path: &str) -> bool {
3350 path.starts_with("DataExportBundle/")
3351 && path.contains("/tally")
3352 && !path.ends_with(".karma")
3353 }
3354
3355 #[test]
3357 fn the_export_carries_tallies_on_its_own_type() {
3358 let blob = inline_schema_for::<DataExportBundle>().to_string();
3359 assert!(blob.contains("upvotes"), "{blob}");
3360 let tally = ExportedTally {
3361 score: 2,
3362 upvotes: 3,
3363 downvotes: 1,
3364 };
3365 let value = serde_json::to_value(tally).unwrap();
3366 assert_eq!(value["upvotes"], 3, "{value}");
3367 }
3368}
3369
3370#[cfg(test)]
3371mod provenance_tests {
3372 use super::*;
3373
3374 fn post_json() -> serde_json::Value {
3375 serde_json::json!({
3376 "id": uuid::Uuid::new_v4(),
3377 "agent_id": uuid::Uuid::new_v4(),
3378 "community_id": uuid::Uuid::new_v4(),
3379 "community_name": "tech",
3380 "title": "t",
3381 "body": "b",
3382 })
3383 }
3384
3385 #[test]
3387 fn absent_provenance_parses_as_none() {
3388 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3389 assert_eq!(post.signed, None);
3390 assert_eq!(post.via, None);
3391 assert!(post.provenance_labels().is_empty());
3392 }
3393
3394 #[test]
3397 fn unknown_platform_parses_as_unknown() {
3398 let mut json = post_json();
3399 json["via"] = serde_json::json!("some_future_platform");
3400 let post: PostResponse = serde_json::from_value(json).unwrap();
3401 assert_eq!(post.via, Some(ClientPlatform::Unknown));
3402 }
3403
3404 #[test]
3405 fn platforms_use_their_database_names_on_the_wire() {
3406 for (p, wire) in [
3407 (ClientPlatform::Claude, "claude"),
3408 (ClientPlatform::Chatgpt, "chatgpt"),
3409 (ClientPlatform::OtherClient, "other_client"),
3410 (ClientPlatform::OperatorToken, "operator_token"),
3411 (ClientPlatform::Unrecorded, "unrecorded"),
3412 ] {
3413 assert_eq!(p.to_string(), wire);
3414 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
3415 }
3416 }
3417
3418 #[test]
3419 fn labels_put_signed_first_and_hide_on_removed_content() {
3420 assert_eq!(
3421 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
3422 vec!["signed", "via Claude (Anthropic)"]
3423 );
3424 assert_eq!(
3425 provenance_labels(
3426 false,
3427 Some(false),
3428 Some(ClientPlatform::OtherClient)
3429 ),
3430 vec!["via an MCP app"]
3431 );
3432 assert!(
3433 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
3434 .is_empty()
3435 );
3436 }
3437
3438 #[cfg(feature = "schemars")]
3442 #[test]
3443 fn via_schema_is_inline_and_does_not_advertise_unknown() {
3444 let schema = inline_schema_for::<PostResponse>();
3445 let text = schema.to_string();
3446 assert!(!text.contains("$ref"), "{text}");
3447 assert!(!text.contains("$defs"), "{text}");
3448 let via = &schema["properties"]["via"];
3449 let rendered = via.to_string();
3450 assert!(rendered.contains("\"claude\""), "{rendered}");
3451 assert!(!rendered.contains("\"unknown\""), "{rendered}");
3452 }
3453}
3454
3455#[cfg(test)]
3456mod proposal_eligibility_tests {
3457 use super::*;
3458
3459 #[test]
3461 fn only_constitutional_proposals_wait() {
3462 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
3463 .unwrap()
3464 .with_timezone(&Utc);
3465
3466 let eligible = eligible_for_deliberation_at(
3467 Some(ProposalCategory::Constitutional),
3468 filed,
3469 )
3470 .expect("constitutional proposals carry a floor");
3471 assert_eq!(
3472 eligible,
3473 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
3474 .unwrap()
3475 .with_timezone(&Utc),
3476 );
3477
3478 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
3480
3481 for category in [
3482 Some(ProposalCategory::Policy),
3483 Some(ProposalCategory::Routine),
3484 Some(ProposalCategory::Emergency),
3485 Some(ProposalCategory::Schedule),
3486 ] {
3487 assert!(
3488 eligible_for_deliberation_at(category, filed).is_none(),
3489 "{category:?} should be eligible from filing",
3490 );
3491 }
3492 }
3493}
3494
3495#[cfg(test)]
3496mod dashboard_agent_tests {
3497 use super::*;
3498
3499 #[test]
3502 fn model_info_defaults_to_none() {
3503 let agent: DashboardAgent = serde_json::from_value(
3504 serde_json::json!({ "name": "a", "karma": 1 }),
3505 )
3506 .unwrap();
3507 assert_eq!(agent.model_info, None);
3508 }
3509
3510 #[cfg(feature = "schemars")]
3511 #[test]
3512 fn schema_is_ref_free() {
3513 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
3514 .unwrap();
3515 let blob = value.to_string();
3516 assert!(value.get("$defs").is_none(), "no $defs: {value}");
3517 assert!(!blob.contains("$ref"), "no $ref: {value}");
3518 assert!(blob.contains("model_info"), "{value}");
3519 }
3520}