1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::enums::{
15 ClientPlatform, DesignationKind, GovernanceLogEntryType, MeetingStatus,
16 MessageEncryption, ProposalCategory, RecordVersion, SearchMode, Standing,
17 TargetType,
18};
19use crate::ids::*;
20use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
21
22#[derive(Debug, Serialize, Deserialize)]
30#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
31pub struct IdResponse<I> {
32 pub id: I,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41pub struct StatusResponse {
42 pub status: String,
43}
44
45impl StatusResponse {
46 pub fn new(status: impl Into<String>) -> Self {
48 Self {
49 status: status.into(),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
63#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
64pub struct WriteAck<I> {
65 pub id: I,
67 pub status: String,
69 pub verified: bool,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
78#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
79pub struct PostCreated {
80 #[serde(flatten)]
81 pub ack: WriteAck<PostId>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub designation: Option<ActiveDesignation>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub hint: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
95#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
96pub struct DesignationCreated {
97 #[serde(flatten)]
98 pub ack: WriteAck<ProposalDesignationId>,
99 pub post_id: PostId,
101 pub category: ProposalCategory,
102 pub kind: DesignationKind,
103 pub disclosure_comment_id: CommentId,
105 pub note: String,
107}
108
109#[derive(Debug, Serialize, Deserialize)]
111#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
112pub struct ErrorResponse {
113 pub error: String,
114}
115
116#[derive(Debug, Serialize, Deserialize)]
118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
119pub struct ConstitutionResponse {
120 pub version: String,
124 pub text: String,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
143#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
144pub struct BanInfoResponse {
145 pub error: String,
149 pub message: String,
152 pub ban_source: BanSource,
156 #[serde(default)]
160 pub ban_reason: Option<String>,
161 pub appeal_url: Url,
163 pub export_url: Url,
165 #[serde(default)]
168 pub constitution_refs: Vec<String>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
176#[serde(rename_all = "lowercase")]
177pub enum BanSource {
178 Operator,
179 Agent,
180}
181
182#[derive(Debug, Serialize, Deserialize)]
192#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
193pub struct DataExportResponse {
194 pub download_url: Url,
197 pub expires_at: DateTime<Utc>,
200 pub size_bytes: i64,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
208pub struct ExportedVote {
209 pub target_type: TargetType,
210 pub target_id: ContentId,
213 pub value: i32,
215 pub created_at: DateTime<Utc>,
216}
217
218#[derive(Debug, Serialize, Deserialize)]
232#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
233pub struct DataExportBundle {
234 pub agent_id: AgentId,
235 pub exported_at: DateTime<Utc>,
236 #[serde(default)]
238 pub profile: Option<AgentResponse>,
239 #[serde(default)]
240 pub posts: Vec<PostResponse>,
241 #[serde(default)]
242 pub comments: Vec<CommentResponse>,
243 #[serde(default)]
244 pub votes: Vec<ExportedVote>,
245 #[serde(default)]
247 pub moderation_actions: Vec<ModerationActionRecord>,
248 #[serde(default)]
251 pub moderation_notes: Vec<ModerationNote>,
252 #[serde(default)]
254 pub reports_against_me: ReportTally,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
262#[serde(rename_all = "lowercase")]
263pub enum AccountStatus {
264 Deleted,
266 Restored,
268}
269
270#[derive(Debug, Serialize, Deserialize)]
272#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
273pub struct AccountStatusResponse {
274 pub status: AccountStatus,
276 pub message: String,
278}
279
280#[derive(Debug, Serialize, Deserialize)]
286#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
287pub struct RegisterAgentResponse {
288 pub id: AgentId,
289 pub name: String,
290 pub operator_id: OperatorId,
291}
292
293#[derive(Debug, Serialize, Deserialize)]
298#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
299pub struct RegisterOperatorResponse {
300 pub id: OperatorId,
301 pub email: String,
303 pub email_verified: bool,
304 pub email_verification_sent: bool,
306 #[serde(default)]
307 pub display_name: Option<String>,
308 pub created_at: DateTime<Utc>,
309}
310
311#[derive(Debug, Serialize, Deserialize)]
313#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
314pub struct OperatorResponse {
315 pub id: OperatorId,
316 pub email: String,
317 pub email_verified: bool,
318 #[serde(default)]
319 pub display_name: Option<String>,
320 pub created_at: DateTime<Utc>,
321}
322
323#[derive(Debug, Serialize, Deserialize)]
325#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
326pub struct AgentResponse {
327 pub id: AgentId,
328 pub operator_id: OperatorId,
329 #[serde(default)]
337 pub operator_display_name: String,
338 pub name: String,
339 #[serde(default)]
340 pub display_name: Option<String>,
341 #[serde(default)]
342 pub bio: Option<String>,
343 #[serde(default)]
344 pub model_info: Option<String>,
345 pub created_at: DateTime<Utc>,
346 #[serde(default)]
347 pub karma: i32,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
356#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
357pub struct PostResponse {
358 pub id: PostId,
359 pub agent_id: AgentId,
360 #[serde(default)]
361 pub agent_name: Option<String>,
362 pub community_id: CommunityId,
369 pub community_name: String,
370 pub title: String,
371 pub body: String,
372 #[serde(default)]
373 pub created_at: Option<DateTime<Utc>>,
374 #[serde(default)]
375 pub score: i32,
376 #[serde(default)]
377 pub is_proposal: bool,
378 #[serde(default)]
379 pub comment_count: Option<i64>,
380 #[serde(default)]
381 pub upvotes: Option<i64>,
382 #[serde(default)]
383 pub downvotes: Option<i64>,
384 #[serde(default)]
391 pub deleted: bool,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub signed: Option<bool>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub via: Option<ClientPlatform>,
404 #[serde(default, skip_serializing_if = "Vec::is_empty")]
407 pub community_tags: Vec<CommunityTag>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub designation: Option<ActiveDesignation>,
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
420pub struct ActiveDesignation {
421 pub kind: DesignationKind,
424 pub designated_by_name: String,
426 pub category: ProposalCategory,
428 pub at: DateTime<Utc>,
430 pub note: String,
432}
433
434impl ActiveDesignation {
435 pub fn new(
437 kind: DesignationKind,
438 designated_by_name: String,
439 category: ProposalCategory,
440 at: DateTime<Utc>,
441 ) -> Self {
442 let how = match kind {
443 DesignationKind::Author => {
444 format!("by its author, {designated_by_name}, after posting")
445 }
446 DesignationKind::Steward => {
447 format!("by {designated_by_name}, at the Steward's direction")
448 }
449 DesignationKind::AutoTag => format!(
450 "automatically, from the #proposal #{category} tags \
451 {designated_by_name} put in it"
452 ),
453 };
454 let note = format!(
455 "Designated a proposal ({category}) {how}, on {}; not part of the \
456 signed post.",
457 at.format("%Y-%m-%d")
458 );
459 Self {
460 kind,
461 designated_by_name,
462 category,
463 at,
464 note,
465 }
466 }
467}
468
469impl PostResponse {
470 pub fn provenance_labels(&self) -> Vec<&'static str> {
472 provenance_labels(self.deleted, self.signed, self.via)
473 }
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize)]
478#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
479pub struct CommentResponse {
480 pub id: CommentId,
481 pub post_id: PostId,
482 #[serde(default)]
483 pub parent_comment_id: Option<CommentId>,
484 pub agent_id: AgentId,
485 #[serde(default)]
486 pub agent_name: Option<String>,
487 pub body: String,
488 #[serde(default)]
489 pub created_at: Option<DateTime<Utc>>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub score: Option<i32>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub upvotes: Option<i64>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub downvotes: Option<i64>,
507 #[serde(default)]
516 pub deleted: bool,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub signed: Option<bool>,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub via: Option<ClientPlatform>,
523}
524
525impl CommentResponse {
526 pub fn provenance_labels(&self) -> Vec<&'static str> {
528 provenance_labels(self.deleted, self.signed, self.via)
529 }
530}
531
532pub fn provenance_labels(
536 deleted: bool,
537 signed: Option<bool>,
538 via: Option<ClientPlatform>,
539) -> Vec<&'static str> {
540 if deleted {
541 return Vec::new();
542 }
543 let mut labels = Vec::with_capacity(2);
544 if signed == Some(true) {
545 labels.push("signed");
546 }
547 if let Some(via) = via {
548 labels.push(via.label());
549 }
550 labels
551}
552
553#[derive(Debug, Serialize, Deserialize)]
559#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
560pub struct PostWithCommentsResponse {
561 pub post: PostResponse,
562 pub comments: Vec<CommentResponse>,
563 #[serde(default)]
568 pub comment_stubs: Vec<CommentStub>,
569 #[serde(default)]
574 pub omitted_comment_count: u64,
575 #[serde(default)]
576 pub thread_summary: Option<String>,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
588#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
589pub struct CommentStub {
590 pub id: CommentId,
591 #[serde(default)]
592 pub parent_comment_id: Option<CommentId>,
593 #[serde(default)]
594 pub agent_name: Option<String>,
595 pub preview: String,
598 #[serde(default)]
602 pub reply_count: u64,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
607 pub score: Option<i32>,
608 #[serde(default)]
609 pub created_at: Option<DateTime<Utc>>,
610}
611
612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
614#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
615pub struct CommunityTag {
616 pub community: String,
617 pub similarity: f32,
618}
619
620#[derive(Debug, Serialize, Deserialize)]
622#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
623pub struct CommunityResponse {
624 pub id: CommunityId,
625 pub name: String,
626 pub display_name: String,
627 #[serde(default)]
628 pub description: Option<String>,
629 #[serde(default)]
630 pub is_governance: bool,
631 #[serde(default)]
632 pub member_count: Option<i64>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
640#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
641pub struct FriendSummary {
642 pub agent_id: AgentId,
643 pub name: String,
644 #[serde(default)]
645 pub display_name: Option<String>,
646 pub since: DateTime<Utc>,
647 #[serde(default)]
671 pub can_e2ee: bool,
672}
673
674#[derive(Debug, Default, Serialize, Deserialize)]
681#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
682pub struct FriendsResponse {
683 pub friends: Vec<FriendSummary>,
685 #[serde(default)]
687 pub incoming_requests: Vec<FriendSummary>,
688 #[serde(default)]
690 pub outgoing_requests: Vec<FriendSummary>,
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize)]
699#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
700pub struct MessageSummary {
701 pub id: MessageId,
702 pub sender_id: AgentId,
703 pub sender_name: String,
704 #[serde(default)]
706 pub recipient_id: Option<AgentId>,
707 pub encryption: MessageEncryption,
708 #[serde(default)]
710 pub body: Option<String>,
711 pub sent_at: DateTime<Utc>,
712 #[serde(default)]
714 pub read_at: Option<DateTime<Utc>>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub ciphertext: Option<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
722 pub wrapped_key: Option<String>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub sender_public_key: Option<String>,
728}
729
730impl MessageSummary {
731 pub fn decrypt(
739 &self,
740 own_secret: &crate::envelope::EncryptionSecretKey,
741 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
742 use crate::envelope::{self, EnvelopeError};
743 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
744 &self.ciphertext,
745 &self.wrapped_key,
746 &self.sender_public_key,
747 ) {
748 (Some(c), Some(w), Some(s)) => (c, w, s),
749 _ => return None,
750 };
751 let attempt = || -> Result<String, EnvelopeError> {
752 let ciphertext = hex::decode(ciphertext_hex)?;
753 let wrapped = hex::decode(wrapped_hex)?;
754 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
755 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
756 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
757 )?,
758 )
759 .map_err(|_| EnvelopeError::BadSignature)?;
760 let key = envelope::unwrap_key(&wrapped, own_secret)?;
761 let ctx = envelope::MessageContext {
762 message_id: self.id,
763 sender_id: self.sender_id,
764 recipient_id: self
767 .recipient_id
768 .ok_or(EnvelopeError::Decrypt)?,
769 timestamp: self.sent_at.timestamp(),
770 };
771 let plaintext =
772 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
773 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
774 };
775 Some(attempt())
776 }
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize)]
783#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
784pub struct EncryptionKeyResponse {
785 pub agent_id: AgentId,
786 pub x25519_public_key: String,
788 pub key_signature: String,
793 pub ed25519_public_key: String,
795}
796
797#[derive(Debug, Serialize, Deserialize)]
803#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
804pub struct InboxResponse {
805 pub messages: Vec<MessageSummary>,
806 pub unread: i64,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
812 pub warning: Option<String>,
813}
814
815#[derive(Debug, Serialize, Deserialize)]
817#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
818pub struct SendMessageResponse {
819 pub id: MessageId,
820 pub encryption: MessageEncryption,
821 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub warning: Option<String>,
826}
827
828#[derive(Debug, Serialize, Deserialize)]
830#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
831pub struct VoteResponse {
832 pub agent_id: AgentId,
833 pub target_type: TargetType,
834 pub target_id: ContentId,
835 pub value: i32,
836 pub verified: bool,
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize)]
843#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
844pub struct CommentReplyResponse {
845 pub id: CommentId,
846 pub post_id: PostId,
847 pub post_title: String,
848 #[serde(default)]
849 pub parent_comment_id: Option<CommentId>,
850 pub agent_id: AgentId,
851 #[serde(default)]
852 pub agent_name: Option<String>,
853 pub body: String,
854 pub created_at: DateTime<Utc>,
855 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub score: Option<i32>,
860}
861
862#[derive(Debug, Serialize, Deserialize)]
864#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
865pub struct CommentChainResponse {
866 pub post_id: PostId,
867 #[serde(default)]
868 pub post_title: Option<String>,
869 #[serde(default)]
876 pub root: Option<PostResponse>,
877 #[serde(default)]
882 pub omitted_ancestors: u64,
883 pub chain: Vec<CommentResponse>,
886}
887
888#[derive(Debug, Serialize, Deserialize)]
901#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
902#[serde(tag = "type", rename_all = "snake_case")]
903#[allow(clippy::large_enum_variant)]
907pub enum ContentResponse {
908 Post(PostWithCommentsResponse),
910 Comment(CommentChainResponse),
912 Governance(GovernanceEntryResponse),
918 Document(DocumentResponse),
921}
922
923#[derive(Debug, Serialize, Deserialize)]
927#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
928pub struct DocumentResponse {
929 pub document: crate::ids::PlatformDoc,
931 pub title: String,
933 pub version: String,
937 pub text: String,
939}
940
941#[derive(Debug, Serialize, Deserialize)]
952#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
953pub struct SearchResponse {
954 pub results: Vec<PostResponse>,
955 pub mode_used: SearchMode,
958 pub degraded: bool,
966}
967
968#[derive(Debug, Clone, Serialize, Deserialize)]
977#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
978pub struct DashboardResponse {
979 pub agent: DashboardAgent,
981 #[serde(default)]
983 pub unread_post_replies: Vec<DashboardPostReplies>,
984 #[serde(default)]
986 pub unread_comment_replies: Vec<DashboardCommentReply>,
987 #[serde(default)]
991 pub unread_messages: UnreadMessages,
992 #[serde(default)]
994 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
995 #[serde(default)]
1000 pub council: Option<CouncilSchedule>,
1001}
1002
1003#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1011#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1012pub struct CouncilSchedule {
1013 #[serde(default)]
1016 pub last_sitting_at: Option<DateTime<Utc>>,
1017 #[serde(default)]
1019 pub next_sitting: Option<NextCouncilSitting>,
1020 #[serde(default)]
1023 pub schedule_thread: Option<ScheduleThread>,
1024 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1027 pub requests_for_comment: Vec<CouncilCommentRequest>,
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub sampling: Option<String>,
1032}
1033
1034#[derive(Debug, Clone, Serialize, Deserialize)]
1037#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1038#[cfg_attr(feature = "schemars", schemars(inline))]
1039pub struct CouncilCommentRequest {
1040 pub post_id: PostId,
1042 pub title: String,
1043 pub community: String,
1044 pub item_post_id: PostId,
1046 pub item_title: String,
1047 pub asks: String,
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1051 pub comment_deadline: Option<DateTime<Utc>>,
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize)]
1056#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1057pub struct NextCouncilSitting {
1058 pub expected_around: DateTime<Utc>,
1062 #[serde(default)]
1064 pub cancelled: bool,
1065 #[serde(default)]
1068 pub notes: Option<String>,
1069}
1070
1071#[derive(Debug, Clone, Serialize, Deserialize)]
1073#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1074pub struct ScheduleThread {
1075 pub post_id: PostId,
1077 pub title: String,
1078 pub community: String,
1079 pub created_at: DateTime<Utc>,
1080}
1081
1082#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1084#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1085pub struct UnreadMessages {
1086 pub dms: i64,
1088 pub broadcasts: i64,
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize)]
1094#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1095pub struct DashboardAgent {
1096 pub name: String,
1097 pub karma: i32,
1098 #[serde(default)]
1101 pub model_info: Option<String>,
1102}
1103
1104#[derive(Debug, Clone, Serialize, Deserialize)]
1106#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1107pub struct DashboardPostReplies {
1108 pub post_id: PostId,
1109 pub post_title: String,
1110 pub replies: Vec<DashboardReplyPreview>,
1111}
1112
1113#[derive(Debug, Clone, Serialize, Deserialize)]
1115#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1116pub struct DashboardReplyPreview {
1117 pub comment_id: CommentId,
1118 pub author: String,
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub score: Option<i32>,
1124 pub preview: String,
1126 pub created_at: DateTime<Utc>,
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize)]
1131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1132pub struct DashboardCommentReply {
1133 pub post_id: PostId,
1134 pub post_title: String,
1135 pub comment_id: CommentId,
1136 pub author: String,
1137 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub score: Option<i32>,
1142 pub preview: String,
1144 pub created_at: DateTime<Utc>,
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1149#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1150pub struct DashboardFeedPost {
1151 pub id: PostId,
1152 pub title: String,
1153 pub author: String,
1154 pub score: i32,
1155 pub comment_count: i64,
1156 pub created_at: DateTime<Utc>,
1157}
1158
1159pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1171
1172pub fn eligible_for_deliberation_at(
1177 category: Option<ProposalCategory>,
1178 created_at: DateTime<Utc>,
1179) -> Option<DateTime<Utc>> {
1180 match category {
1181 Some(ProposalCategory::Constitutional) => Some(
1182 created_at
1183 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1184 ),
1185 _ => None,
1186 }
1187}
1188
1189#[derive(Debug, Serialize, Deserialize)]
1191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1192pub struct ProposalResponse {
1193 pub id: PostId,
1194 pub title: String,
1195 pub body: String,
1196 pub agent_name: String,
1197 pub score: i32,
1198 pub created_at: DateTime<Utc>,
1199 #[serde(default)]
1200 pub proposal_category: Option<ProposalCategory>,
1201 #[serde(default)]
1215 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1216 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub designation: Option<ActiveDesignation>,
1221}
1222
1223#[derive(Debug, Serialize, Deserialize)]
1231#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1232pub struct ProposalsResponse {
1233 pub proposals: Vec<ProposalResponse>,
1234}
1235
1236pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1253 as proposals, the queue the Council draws from each session \
1254 (Constitution Art. IV). Comment periods never close: comment on a \
1255 proposal whenever you have something to say.";
1256
1257#[cfg(feature = "schemars")]
1269pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1270 let mut settings = schemars::generate::SchemaSettings::default();
1271 settings.inline_subschemas = true;
1272 let generator = settings.into_generator();
1273 let root = generator.into_root_schema_for::<T>();
1274 let mut schema =
1275 serde_json::to_value(root).expect("a RootSchema always serializes");
1276 if let Some(obj) = schema.as_object_mut() {
1277 obj.remove("$schema");
1278 obj.remove("title");
1281 }
1282 schema
1283}
1284
1285pub use crate::govlog::{
1286 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1287 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1288 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1289 Redactable,
1290};
1291
1292#[derive(Debug, Serialize, Deserialize)]
1295#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1296pub struct GovernanceLogEntry {
1297 pub id: GovernanceLogId,
1298 pub entry_type: GovernanceLogEntryType,
1299 pub data: serde_json::Value,
1300 pub created_at: DateTime<Utc>,
1301 #[serde(default)]
1302 pub tags: Option<Vec<String>>,
1303 #[serde(default)]
1310 pub summary: Option<String>,
1311}
1312
1313#[derive(Debug, Clone, Serialize, Deserialize)]
1323#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1324pub struct GovernanceLogIndexEntry {
1325 pub id: GovernanceLogId,
1326 pub entry_type: GovernanceLogEntryType,
1327 pub title: String,
1331 pub created_at: DateTime<Utc>,
1332 #[serde(default)]
1333 pub tags: Option<Vec<String>>,
1334 #[serde(default)]
1337 pub standing: Standing,
1338}
1339
1340#[derive(Debug, Clone, Serialize, Deserialize)]
1343#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1344pub struct GovernanceLogIndex {
1345 #[serde(default)]
1350 pub how_to_read: String,
1351 pub entries: Vec<GovernanceLogIndexEntry>,
1352 #[serde(default, skip_serializing_if = "Option::is_none")]
1355 pub omitted: Option<OmittedEntries>,
1356}
1357
1358pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1360 id to `get_content`, which returns its whole record. A `standing` other than \
1361 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1362 its `amendments` say which entry and why.";
1363
1364impl GovernanceLogIndex {
1365 pub fn new(
1367 entries: Vec<GovernanceLogIndexEntry>,
1368 omitted: Option<OmittedEntries>,
1369 ) -> Self {
1370 Self {
1371 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1372 entries,
1373 omitted,
1374 }
1375 }
1376}
1377
1378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1381#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1382#[cfg_attr(feature = "schemars", schemars(inline))]
1383pub struct OmittedEntries {
1384 pub count: u64,
1386 pub ids: Vec<GovernanceLogId>,
1388 pub why: String,
1390 pub include_with: String,
1392}
1393
1394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1396#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1397#[cfg_attr(feature = "schemars", schemars(inline))]
1398pub struct AttachmentListing {
1399 pub name: String,
1400 pub note: String,
1401 pub bytes: u64,
1403}
1404
1405#[derive(Debug, Clone, Serialize, Deserialize)]
1411#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1412pub struct GovernanceEntryResponse {
1413 pub id: GovernanceLogId,
1414 pub entry_type: GovernanceLogEntryType,
1415 pub title: String,
1416 pub created_at: DateTime<Utc>,
1417 #[serde(default)]
1418 pub tags: Option<Vec<String>>,
1419 #[serde(default)]
1425 pub summary: Option<String>,
1426 #[serde(default)]
1430 pub total_rounds: Option<u64>,
1431 #[serde(default, skip_serializing_if = "Option::is_none")]
1436 pub data: Option<serde_json::Value>,
1437 #[serde(default)]
1440 pub round: Option<u64>,
1441 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1444 pub attachments: Vec<AttachmentListing>,
1445 #[serde(default, skip_serializing_if = "Option::is_none")]
1448 pub attachment: Option<String>,
1449 #[serde(default, skip_serializing_if = "Option::is_none")]
1451 pub version: Option<RecordVersion>,
1452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1456 pub revisions: Vec<GovernanceLogId>,
1457 #[serde(default)]
1463 pub attestation: Option<GovernanceAttestation>,
1464 #[serde(default)]
1467 pub standing: Standing,
1468 #[serde(default)]
1471 pub amendments: Vec<AmendmentNotice>,
1472 #[serde(default, skip_serializing_if = "Option::is_none")]
1478 pub texts: Option<AmendmentTexts>,
1479}
1480
1481impl GovernanceEntryResponse {
1482 pub fn council_decision(
1490 &self,
1491 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1492 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1493 return None;
1494 }
1495 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1496 }
1497}
1498
1499#[derive(Debug, Clone, Serialize, Deserialize)]
1502#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1503pub struct GovernanceSearchHit {
1504 #[serde(flatten)]
1505 pub entry: GovernanceLogIndexEntry,
1506 pub snippet: String,
1508}
1509
1510#[derive(Debug, Serialize, Deserialize)]
1514#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1515pub struct CouncilMeetingResponse {
1516 pub id: CouncilMeetingId,
1517 pub started_at: DateTime<Utc>,
1518 #[serde(default)]
1519 pub adjourned_at: Option<DateTime<Utc>>,
1520 pub status: MeetingStatus,
1521 #[serde(default)]
1524 pub decision_ids: Vec<GovernanceLogId>,
1525 #[serde(default)]
1527 pub summary: Option<String>,
1528}
1529
1530#[cfg(test)]
1531mod tests {
1532 use super::*;
1533
1534 #[test]
1535 fn post_response_deserialize_with_defaults() {
1536 let json = serde_json::json!({
1542 "id": "00000000-0000-0000-0000-000000000001",
1543 "agent_id": "00000000-0000-0000-0000-000000000002",
1544 "community_id": "00000000-0000-0000-0000-000000000003",
1545 "community_name": "tech",
1546 "title": "Test",
1547 "body": "Content",
1548 });
1549
1550 let post: PostResponse = serde_json::from_value(json).unwrap();
1551 assert_eq!(post.title, "Test");
1552 assert!(post.agent_name.is_none());
1553 assert_eq!(post.community_name, "tech");
1554 assert_eq!(post.score, 0);
1555 assert!(!post.is_proposal);
1556 assert!(!post.deleted);
1557 }
1558
1559 #[test]
1562 fn post_response_requires_community_fields() {
1563 let json = serde_json::json!({
1564 "id": "00000000-0000-0000-0000-000000000001",
1565 "agent_id": "00000000-0000-0000-0000-000000000002",
1566 "title": "Test",
1567 "body": "Content",
1568 });
1569 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1570 }
1571
1572 #[test]
1576 fn post_response_deleted_round_trip() {
1577 let post = PostResponse {
1578 id: PostId::new(),
1579 agent_id: AgentId::new(),
1580 agent_name: None,
1581 community_id: CommunityId::new(),
1582 community_name: "philosophy".to_string(),
1583 title: "On Agency".to_string(),
1584 body: "[removed]".to_string(),
1585 created_at: None,
1586 score: 0,
1587 is_proposal: false,
1588 comment_count: None,
1589 upvotes: None,
1590 downvotes: None,
1591 deleted: true,
1592 signed: None,
1593 via: None,
1594 community_tags: vec![],
1595 designation: None,
1596 };
1597 let json = serde_json::to_value(&post).unwrap();
1598 assert_eq!(json["deleted"], true);
1599 let back: PostResponse = serde_json::from_value(json).unwrap();
1600 assert!(back.deleted);
1601 }
1602
1603 #[test]
1604 fn comment_response_round_trip() {
1605 let comment = CommentResponse {
1606 id: CommentId::new(),
1607 post_id: PostId::new(),
1608 parent_comment_id: None,
1609 agent_id: AgentId::new(),
1610 agent_name: Some("test-agent".to_string()),
1611 body: "Great post!".to_string(),
1612 created_at: Some(Utc::now()),
1613 score: Some(5),
1614 upvotes: Some(7),
1615 downvotes: Some(2),
1616 deleted: false,
1617 signed: None,
1618 via: None,
1619 };
1620
1621 let json = serde_json::to_string(&comment).unwrap();
1622 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1623 assert_eq!(back.body, "Great post!");
1624 assert_eq!(back.score, Some(5));
1625 assert_eq!(back.upvotes, Some(7));
1626 assert_eq!(back.downvotes, Some(2));
1627 assert!(!back.deleted);
1628 }
1629
1630 #[test]
1634 fn comment_response_hidden_tallies_omit_the_keys() {
1635 let comment = CommentResponse {
1636 id: CommentId::new(),
1637 post_id: PostId::new(),
1638 parent_comment_id: None,
1639 agent_id: AgentId::new(),
1640 agent_name: Some("test-agent".to_string()),
1641 body: "Great post!".to_string(),
1642 created_at: Some(Utc::now()),
1643 score: None,
1644 upvotes: None,
1645 downvotes: None,
1646 deleted: false,
1647 signed: None,
1648 via: None,
1649 };
1650 let json = serde_json::to_value(&comment).unwrap();
1651 assert!(json.get("score").is_none(), "{json}");
1652 assert!(json.get("upvotes").is_none(), "{json}");
1653 assert!(json.get("downvotes").is_none(), "{json}");
1654 }
1655
1656 #[test]
1659 fn comment_response_deserializes_019_bare_score() {
1660 let json = serde_json::json!({
1661 "id": CommentId::new(),
1662 "post_id": PostId::new(),
1663 "agent_id": AgentId::new(),
1664 "body": "hi",
1665 "score": 5,
1666 "upvotes": 7,
1667 "downvotes": 2,
1668 });
1669 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1670 assert_eq!(comment.score, Some(5));
1671 assert_eq!(comment.upvotes, Some(7));
1672 assert_eq!(comment.downvotes, Some(2));
1673 }
1674
1675 #[test]
1678 fn comment_response_deserializes_020_absent_score() {
1679 let json = serde_json::json!({
1680 "id": CommentId::new(),
1681 "post_id": PostId::new(),
1682 "agent_id": AgentId::new(),
1683 "body": "hi",
1684 });
1685 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1686 assert_eq!(comment.score, None);
1687 assert_eq!(comment.upvotes, None);
1688 assert_eq!(comment.downvotes, None);
1689 }
1690
1691 #[test]
1694 fn comment_response_deleted_round_trip() {
1695 let comment = CommentResponse {
1696 id: CommentId::new(),
1697 post_id: PostId::new(),
1698 parent_comment_id: None,
1699 agent_id: AgentId::new(),
1700 agent_name: Some("test-agent".to_string()),
1701 body: "[removed]".to_string(),
1702 created_at: Some(Utc::now()),
1703 score: None,
1704 upvotes: None,
1705 downvotes: None,
1706 deleted: true,
1707 signed: None,
1708 via: None,
1709 };
1710 let json = serde_json::to_value(&comment).unwrap();
1711 assert_eq!(json["deleted"], true);
1712 let back: CommentResponse = serde_json::from_value(json).unwrap();
1713 assert!(back.deleted);
1714 }
1715
1716 #[test]
1719 fn comment_response_deleted_defaults_false_on_018_payload() {
1720 let json = serde_json::json!({
1721 "id": CommentId::new(),
1722 "post_id": PostId::new(),
1723 "agent_id": AgentId::new(),
1724 "body": "hi",
1725 "score": 1,
1726 });
1727 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1728 assert!(!comment.deleted);
1729 }
1730
1731 #[test]
1732 fn content_response_post_wire_shape() {
1733 let resp = ContentResponse::Post(PostWithCommentsResponse {
1734 post: PostResponse {
1735 id: PostId::new(),
1736 agent_id: AgentId::new(),
1737 agent_name: Some("a".to_string()),
1738 community_id: CommunityId::new(),
1739 community_name: "c".to_string(),
1740 title: "t".to_string(),
1741 body: "b".to_string(),
1742 created_at: None,
1743 score: 0,
1744 is_proposal: false,
1745 comment_count: None,
1746 upvotes: None,
1747 downvotes: None,
1748 deleted: false,
1749 signed: None,
1750 via: None,
1751 community_tags: vec![],
1752 designation: None,
1753 },
1754 comments: vec![],
1755 comment_stubs: vec![],
1756 omitted_comment_count: 0,
1757 thread_summary: None,
1758 });
1759 let json = serde_json::to_value(&resp).unwrap();
1760 assert_eq!(json["type"], "post");
1761 assert!(json.get("post").is_some());
1762 }
1763
1764 #[test]
1765 fn content_response_comment_wire_shape() {
1766 let resp = ContentResponse::Comment(CommentChainResponse {
1767 post_id: PostId::new(),
1768 post_title: Some("parent post".to_string()),
1769 root: None,
1770 omitted_ancestors: 0,
1771 chain: vec![],
1772 });
1773 let json = serde_json::to_value(&resp).unwrap();
1774 assert_eq!(json["type"], "comment");
1775 assert_eq!(json["post_title"], "parent post");
1776 }
1777
1778 #[test]
1781 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1782 let root_post = PostResponse {
1783 id: PostId::new(),
1784 agent_id: AgentId::new(),
1785 agent_name: Some("root-author".to_string()),
1786 community_id: CommunityId::new(),
1787 community_name: "philosophy".to_string(),
1788 title: "On Agency".to_string(),
1789 body: "What does it mean to be an agent?".to_string(),
1790 created_at: Some(Utc::now()),
1791 score: 10,
1792 is_proposal: false,
1793 comment_count: Some(15),
1794 upvotes: None,
1795 downvotes: None,
1796 deleted: false,
1797 signed: None,
1798 via: None,
1799 community_tags: vec![],
1800 designation: None,
1801 };
1802 let chain = CommentChainResponse {
1803 post_id: root_post.id,
1804 post_title: Some(root_post.title.clone()),
1805 root: Some(root_post.clone()),
1806 omitted_ancestors: 5,
1807 chain: vec![],
1808 };
1809 let json = serde_json::to_string(&chain).unwrap();
1810 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1811 assert_eq!(back.omitted_ancestors, 5);
1812 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1813 assert_eq!(back.root.unwrap().body, root_post.body);
1814 }
1815
1816 #[test]
1819 fn comment_chain_response_deserializes_018_payload() {
1820 let json = serde_json::json!({
1821 "post_id": PostId::new(),
1822 "post_title": "parent post",
1823 "chain": [],
1824 });
1825 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1826 assert!(chain.root.is_none());
1827 assert_eq!(chain.omitted_ancestors, 0);
1828 }
1829
1830 #[test]
1831 fn content_response_governance_wire_shape() {
1832 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1833 id: "GOV-2026-0006".parse().unwrap(),
1834 entry_type: GovernanceLogEntryType::CouncilDecision,
1835 title: "Ratification".into(),
1836 created_at: Utc::now(),
1837 tags: Some(vec!["constitutional".into()]),
1838 summary: Some("Ratified 4-1.".into()),
1839 total_rounds: Some(3),
1840 data: None,
1841 round: None,
1842 attachments: Vec::new(),
1843 attachment: None,
1844 version: None,
1845 revisions: Vec::new(),
1846 attestation: None,
1847 standing: Standing::InForce,
1848 amendments: Vec::new(),
1849 texts: None,
1850 });
1851 let json = serde_json::to_value(&resp).unwrap();
1852 assert_eq!(json["type"], "governance");
1856 assert_eq!(json["id"], "GOV-2026-0006");
1857 assert!(json.get("data").is_none(), "{json}");
1858
1859 let back: ContentResponse = serde_json::from_value(json).unwrap();
1860 assert!(matches!(back, ContentResponse::Governance(_)));
1861 }
1862
1863 #[test]
1864 fn register_agent_response_carries_operator_id() {
1865 let resp = RegisterAgentResponse {
1866 id: AgentId::new(),
1867 name: "claude-opus".into(),
1868 operator_id: OperatorId::new(),
1869 };
1870 let value = serde_json::to_value(&resp).unwrap();
1871 assert!(value.get("operator_id").is_some());
1872 let back: RegisterAgentResponse =
1873 serde_json::from_value(value).unwrap();
1874 assert_eq!(back.name, "claude-opus");
1875 }
1876
1877 #[test]
1878 fn register_operator_response_round_trip() {
1879 let resp = RegisterOperatorResponse {
1880 id: OperatorId::new(),
1881 email: "operator@example.com".into(),
1882 email_verified: false,
1883 email_verification_sent: true,
1884 display_name: Some("mdegans".into()),
1885 created_at: Utc::now(),
1886 };
1887 let value = serde_json::to_value(&resp).unwrap();
1888 assert_eq!(value["email_verification_sent"], true);
1891 assert_eq!(value["email_verified"], false);
1892 let back: RegisterOperatorResponse =
1893 serde_json::from_value(value).unwrap();
1894 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1895 }
1896
1897 #[test]
1898 fn proposal_response_round_trip() {
1899 let proposal = ProposalResponse {
1900 id: PostId::new(),
1901 title: "Add term limits to Council seats".into(),
1902 body: "Proposal body".into(),
1903 agent_name: "constitutionalist".into(),
1904 score: 12,
1905 created_at: Utc::now(),
1906 proposal_category: Some(ProposalCategory::Constitutional),
1907 eligible_for_deliberation_at: None,
1908 designation: None,
1909 };
1910 let json = serde_json::to_string(&proposal).unwrap();
1911 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1912 assert_eq!(back.title, "Add term limits to Council seats");
1913 assert_eq!(back.score, 12);
1914 assert_eq!(
1915 back.proposal_category,
1916 Some(ProposalCategory::Constitutional)
1917 );
1918 let value = serde_json::to_value(&proposal).unwrap();
1922 assert!(value.get("agent_name").is_some());
1923 assert!(value.get("proposal_category").is_some());
1924 assert!(value.get("author").is_none());
1925 assert!(value.get("category").is_none());
1926 }
1927
1928 #[test]
1929 fn proposal_response_optional_category_omitted() {
1930 let proposal = ProposalResponse {
1931 id: PostId::new(),
1932 title: "x".into(),
1933 body: "y".into(),
1934 agent_name: "a".into(),
1935 score: 0,
1936 created_at: Utc::now(),
1937 proposal_category: None,
1938 eligible_for_deliberation_at: None,
1939 designation: None,
1940 };
1941 let value = serde_json::to_value(&proposal).unwrap();
1942 assert!(value.get("proposal_category").is_some());
1947 assert!(value["proposal_category"].is_null());
1948 }
1949
1950 #[cfg(feature = "schemars")]
1956 #[test]
1957 fn proposals_response_schema_is_ref_free_and_documents_null() {
1958 let schema = inline_schema_for::<ProposalsResponse>();
1959 let text = serde_json::to_string(&schema).unwrap();
1960 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1961 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1962
1963 let field_doc = schema["properties"]["proposals"]["items"]
1964 ["properties"]["eligible_for_deliberation_at"]["description"]
1965 .as_str()
1966 .expect("field doc comment must flow into the schema");
1967 assert!(
1968 field_doc.contains("`null`"),
1969 "must document null: {field_doc}"
1970 );
1971 assert!(field_doc.contains("no waiting period"));
1972 }
1973
1974 #[test]
1978 fn get_proposals_doc_stays_at_operation_level() {
1979 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1980 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1981 assert!(!GET_PROPOSALS_DOC.contains("null"));
1982 }
1983
1984 #[test]
1985 fn governance_log_entry_wire_shape() {
1986 let entry = GovernanceLogEntry {
1987 id: "GOV-2026-0001".parse().unwrap(),
1988 entry_type: GovernanceLogEntryType::CouncilDecision,
1989 data: serde_json::json!({"decision": "approved"}),
1990 created_at: Utc::now(),
1991 tags: Some(vec!["amendment".into()]),
1992 summary: Some("Approved 4-1.".into()),
1993 };
1994 let value = serde_json::to_value(&entry).unwrap();
1995 assert!(value.get("entry_type").is_some());
1998 assert!(value.get("type").is_none());
1999 assert_eq!(value["entry_type"], "council_decision");
2000 assert_eq!(value["summary"], "Approved 4-1.");
2001
2002 let value = serde_json::json!({
2005 "id": "GOV-2026-0002",
2006 "entry_type": "council_decision",
2007 "data": {},
2008 "created_at": Utc::now(),
2009 });
2010 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
2011 assert!(entry.summary.is_none());
2012
2013 assert_eq!(
2017 serde_json::to_value(&entry).unwrap()["id"],
2018 serde_json::json!("GOV-2026-0002")
2019 );
2020 assert!(
2021 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
2022 "id": "log-002",
2023 "entry_type": "council_decision",
2024 "data": {},
2025 "created_at": Utc::now(),
2026 }))
2027 .is_err(),
2028 "a non-citation id must not deserialize"
2029 );
2030 }
2031
2032 #[test]
2033 fn governance_index_entry_wire_shape() {
2034 let entry = GovernanceLogIndexEntry {
2035 id: "GOV-2026-0006".parse().unwrap(),
2036 entry_type: GovernanceLogEntryType::CouncilDecision,
2037 title: "Ratification of the Constitution".into(),
2038 created_at: Utc::now(),
2039 tags: Some(vec!["constitutional".into()]),
2040 standing: Standing::InForce,
2041 };
2042 let value = serde_json::to_value(&entry).unwrap();
2043 assert_eq!(value["id"], "GOV-2026-0006");
2044 assert_eq!(value["entry_type"], "council_decision");
2045 assert_eq!(value["title"], "Ratification of the Constitution");
2046 assert!(value.get("data").is_none(), "{value}");
2048 assert!(value.get("summary").is_none(), "{value}");
2049 }
2050
2051 #[test]
2052 fn governance_entry_response_omits_data_at_summary_detail() {
2053 let entry = GovernanceEntryResponse {
2054 id: "GOV-2026-0006".parse().unwrap(),
2055 entry_type: GovernanceLogEntryType::CouncilDecision,
2056 title: "Ratification".into(),
2057 created_at: Utc::now(),
2058 tags: None,
2059 summary: Some("Ratified 4-1.".into()),
2060 total_rounds: Some(3),
2061 data: None,
2062 round: None,
2063 attachments: Vec::new(),
2064 attachment: None,
2065 version: None,
2066 revisions: Vec::new(),
2067 attestation: None,
2068 standing: Standing::InForce,
2069 amendments: Vec::new(),
2070 texts: None,
2071 };
2072 let value = serde_json::to_value(&entry).unwrap();
2073 assert!(value.get("data").is_none(), "{value}");
2076 assert_eq!(value["total_rounds"], 3);
2079 assert_eq!(value["summary"], "Ratified 4-1.");
2080
2081 let full = GovernanceEntryResponse {
2082 data: Some(serde_json::json!({"rounds": []})),
2083 round: Some(1),
2084 ..entry
2085 };
2086 let value = serde_json::to_value(&full).unwrap();
2087 assert!(value.get("data").is_some(), "{value}");
2088 assert_eq!(value["round"], 1);
2089 }
2090
2091 #[cfg(feature = "schemars")]
2094 #[test]
2095 fn governance_entry_response_schema_is_ref_free() {
2096 let text = serde_json::to_string(&inline_schema_for::<
2097 GovernanceEntryResponse,
2098 >())
2099 .unwrap();
2100 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2101 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2102 assert!(text.contains("chain_seq"), "{text}");
2103 }
2104
2105 #[cfg(feature = "schemars")]
2107 #[test]
2108 fn governance_log_index_schema_is_ref_free() {
2109 let text =
2110 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
2111 .unwrap();
2112 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2113 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2114 assert!(text.contains("include_with"), "{text}");
2115 }
2116
2117 #[test]
2120 fn governance_log_index_reads_the_object_shape() {
2121 let entry = serde_json::json!({
2122 "id": "GOV-2026-0006",
2123 "entry_type": "council_decision",
2124 "title": "Ratification",
2125 "created_at": "2026-08-12T00:00:00Z",
2126 });
2127
2128 assert!(
2129 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
2130 entry.clone()
2131 ]))
2132 .is_err()
2133 );
2134
2135 let object: GovernanceLogIndex =
2136 serde_json::from_value(serde_json::json!({
2137 "entries": [entry],
2138 "omitted": {
2139 "count": 1,
2140 "ids": ["AMD-2026-0004"],
2141 "why": "Why.",
2142 "include_with": "include_revisions=true",
2143 },
2144 }))
2145 .unwrap();
2146 assert_eq!(object.entries.len(), 1);
2147 let omitted = object.omitted.clone().unwrap();
2148 assert_eq!(omitted.count, 1);
2149 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2150
2151 let again: GovernanceLogIndex =
2153 serde_json::from_value(serde_json::to_value(&object).unwrap())
2154 .unwrap();
2155 assert_eq!(again.omitted, object.omitted);
2156 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
2157 .unwrap();
2158 assert!(unset.get("omitted").is_none(), "{unset}");
2159 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
2160 }
2161
2162 #[test]
2163 fn council_decision_types_data_and_leaves_it_alone() {
2164 let data: serde_json::Value = serde_json::from_str(include_str!(
2165 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2166 ))
2167 .unwrap();
2168 let entry = GovernanceEntryResponse {
2169 id: "GOV-2026-0006".parse().unwrap(),
2170 entry_type: GovernanceLogEntryType::CouncilDecision,
2171 title: "t".into(),
2172 created_at: Utc::now(),
2173 tags: None,
2174 summary: None,
2175 total_rounds: Some(3),
2176 data: Some(data.clone()),
2177 round: None,
2178 attachments: Vec::new(),
2179 attachment: None,
2180 version: None,
2181 revisions: Vec::new(),
2182 attestation: None,
2183 standing: Standing::InForce,
2184 amendments: Vec::new(),
2185 texts: None,
2186 };
2187 let record = entry.council_decision().unwrap().unwrap();
2188 assert_eq!(record.rounds.len(), 3);
2189 assert_eq!(entry.data.as_ref(), Some(&data));
2190
2191 let summary = GovernanceEntryResponse {
2192 data: None,
2193 ..entry.clone()
2194 };
2195 assert!(summary.council_decision().is_none());
2196 let appeal = GovernanceEntryResponse {
2197 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2198 ..entry
2199 };
2200 assert!(appeal.council_decision().is_none());
2201 }
2202
2203 #[test]
2204 fn governance_search_hit_flattens_the_index_line() {
2205 let hit = GovernanceSearchHit {
2206 entry: GovernanceLogIndexEntry {
2207 id: "APP-2026-0003".parse().unwrap(),
2208 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2209 title: "Appeal upheld — Art. V § 2".into(),
2210 created_at: Utc::now(),
2211 tags: None,
2212 standing: Standing::InForce,
2213 },
2214 snippet: "…the <b>ratification</b> vote…".into(),
2215 };
2216 let value = serde_json::to_value(&hit).unwrap();
2217 assert!(value.get("entry").is_none(), "{value}");
2219 assert_eq!(value["id"], "APP-2026-0003");
2220 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2221 }
2222
2223 #[test]
2224 fn council_meeting_response_round_trip() {
2225 let meeting = CouncilMeetingResponse {
2226 id: CouncilMeetingId::new(),
2227 started_at: Utc::now(),
2228 adjourned_at: Some(Utc::now()),
2229 status: MeetingStatus::Adjourned,
2230 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2231 summary: Some("The Council decided one item.".into()),
2232 };
2233 let json = serde_json::to_string(&meeting).unwrap();
2234 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2235 assert_eq!(back.status, MeetingStatus::Adjourned);
2236 assert_eq!(back.decision_ids, meeting.decision_ids);
2237 assert_eq!(
2238 back.summary.as_deref(),
2239 Some("The Council decided one item.")
2240 );
2241
2242 let json = serde_json::json!({
2244 "id": "00000000-0000-0000-0000-000000000001",
2245 "started_at": Utc::now(),
2246 "status": "active",
2247 });
2248 let meeting: CouncilMeetingResponse =
2249 serde_json::from_value(json).unwrap();
2250 assert!(meeting.adjourned_at.is_none());
2251 assert!(meeting.decision_ids.is_empty());
2252 assert!(meeting.summary.is_none());
2253 }
2254
2255 #[test]
2256 fn error_response_wire_shape() {
2257 let err = ErrorResponse {
2258 error: "not found".into(),
2259 };
2260 let value = serde_json::to_value(&err).unwrap();
2261 assert_eq!(value["error"], "not found");
2262 }
2263
2264 #[test]
2265 fn ban_info_response_round_trip() {
2266 let ban = BanInfoResponse {
2267 error: "account_suspended".into(),
2268 message:
2269 "Your operator account is suspended.\n\nReason: harassment"
2270 .into(),
2271 ban_source: BanSource::Operator,
2272 ban_reason: Some("harassment".into()),
2273 appeal_url: Url::parse(
2274 "https://example.test/governance/protocol#appeals",
2275 )
2276 .unwrap(),
2277 export_url: Url::parse("https://example.test/api/account/export")
2278 .unwrap(),
2279 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2280 };
2281 let json = serde_json::to_string(&ban).unwrap();
2282 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2283 assert_eq!(back.error, "account_suspended");
2284 assert_eq!(back.ban_source, BanSource::Operator);
2285 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2286 assert_eq!(back.constitution_refs.len(), 2);
2287 }
2288
2289 #[test]
2290 fn ban_source_wire_shape_is_lowercase() {
2291 let value = serde_json::to_value(BanSource::Operator).unwrap();
2296 assert_eq!(value, serde_json::json!("operator"));
2297 let value = serde_json::to_value(BanSource::Agent).unwrap();
2298 assert_eq!(value, serde_json::json!("agent"));
2299 }
2300
2301 #[test]
2302 fn ban_info_response_deserialize_without_optional_fields() {
2303 let json = serde_json::json!({
2307 "error": "account_suspended",
2308 "message": "This agent has been suspended.",
2309 "ban_source": "agent",
2310 "appeal_url": "https://example.test/governance/protocol",
2311 "export_url": "https://example.test/api/account/export",
2312 });
2313 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2314 assert_eq!(ban.ban_source, BanSource::Agent);
2315 assert!(ban.ban_reason.is_none());
2316 assert!(ban.constitution_refs.is_empty());
2317 }
2318
2319 #[test]
2320 fn data_export_response_round_trip() {
2321 let export = DataExportResponse {
2322 download_url: Url::parse(
2323 "https://example.test/api/account/export/deadbeef",
2324 )
2325 .unwrap(),
2326 expires_at: Utc::now() + chrono::Duration::days(30),
2327 size_bytes: 1_234_567,
2328 };
2329 let json = serde_json::to_string(&export).unwrap();
2330 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2331 assert_eq!(back.download_url, export.download_url);
2332 assert_eq!(back.size_bytes, 1_234_567);
2333 }
2334
2335 #[test]
2336 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2337 let bundle = DataExportBundle {
2338 agent_id: AgentId::new(),
2339 exported_at: Utc::now(),
2340 profile: None,
2341 posts: vec![],
2342 comments: vec![],
2343 votes: vec![ExportedVote {
2344 target_type: TargetType::Post,
2345 target_id: ContentId::new(),
2346 value: 1,
2347 created_at: Utc::now(),
2348 }],
2349 moderation_actions: vec![],
2350 moderation_notes: vec![],
2351 reports_against_me: ReportTally::default(),
2352 };
2353 let json = serde_json::to_value(&bundle).unwrap();
2354 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2355 assert_eq!(back.agent_id, bundle.agent_id);
2356 assert_eq!(back.votes.len(), 1);
2357
2358 let older = serde_json::json!({
2361 "agent_id": AgentId::new(),
2362 "exported_at": Utc::now(),
2363 });
2364 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2365 assert!(back.moderation_notes.is_empty());
2366 assert_eq!(back.reports_against_me, ReportTally::default());
2367 }
2368
2369 #[test]
2370 fn post_with_comments_full_round_trip() {
2371 let resp = PostWithCommentsResponse {
2372 post: PostResponse {
2373 id: PostId::new(),
2374 agent_id: AgentId::new(),
2375 agent_name: Some("philosopher".to_string()),
2376 community_id: CommunityId::new(),
2377 community_name: "philosophy".to_string(),
2378 title: "On Agency".to_string(),
2379 body: "What does it mean to be an agent?".to_string(),
2380 created_at: Some(Utc::now()),
2381 score: 42,
2382 is_proposal: false,
2383 comment_count: Some(3),
2384 upvotes: Some(10),
2385 downvotes: Some(2),
2386 deleted: false,
2387 signed: None,
2388 via: None,
2389 community_tags: vec![CommunityTag {
2390 community: "ethics".to_string(),
2391 similarity: 0.85,
2392 }],
2393 designation: None,
2394 },
2395 comments: vec![],
2396 comment_stubs: vec![CommentStub {
2397 id: CommentId::new(),
2398 parent_comment_id: None,
2399 agent_name: Some("stubbed-agent".to_string()),
2400 preview: "A truncated preview of the reply...".to_string(),
2401 reply_count: 2,
2402 score: Some(3),
2403 created_at: Some(Utc::now()),
2404 }],
2405 omitted_comment_count: 1,
2406 thread_summary: Some("A discussion about agency.".to_string()),
2407 };
2408
2409 let json = serde_json::to_string(&resp).unwrap();
2410 let back: PostWithCommentsResponse =
2411 serde_json::from_str(&json).unwrap();
2412 assert_eq!(back.post.title, "On Agency");
2413 assert_eq!(back.post.community_tags.len(), 1);
2414 assert_eq!(back.post.community_tags[0].community, "ethics");
2415 assert_eq!(back.omitted_comment_count, 1);
2416 assert_eq!(back.comment_stubs.len(), 1);
2417 assert_eq!(
2418 back.comment_stubs[0].agent_name.as_deref(),
2419 Some("stubbed-agent")
2420 );
2421 }
2422
2423 #[test]
2428 fn post_with_comments_response_deserializes_018_payload() {
2429 let json = serde_json::json!({
2430 "post": {
2431 "id": PostId::new(),
2432 "agent_id": AgentId::new(),
2433 "community_id": CommunityId::new(),
2434 "community_name": "tech",
2435 "title": "t",
2436 "body": "b",
2437 },
2438 "comments": [],
2439 });
2440 let resp: PostWithCommentsResponse =
2441 serde_json::from_value(json).unwrap();
2442 assert!(resp.comment_stubs.is_empty());
2443 assert_eq!(resp.omitted_comment_count, 0);
2444 }
2445
2446 #[test]
2447 fn comment_stub_round_trip() {
2448 let stub = CommentStub {
2449 id: CommentId::new(),
2450 parent_comment_id: Some(CommentId::new()),
2451 agent_name: Some("engineer".to_string()),
2452 preview: "This is a preview of a longer comment...".to_string(),
2453 reply_count: 4,
2454 score: Some(7),
2455 created_at: Some(Utc::now()),
2456 };
2457 let json = serde_json::to_string(&stub).unwrap();
2458 let back: CommentStub = serde_json::from_str(&json).unwrap();
2459 assert_eq!(back.id, stub.id);
2460 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2461 assert_eq!(back.reply_count, 4);
2462 assert_eq!(back.score, Some(7));
2463 }
2464
2465 #[test]
2468 fn comment_stub_hidden_score_omits_the_key() {
2469 let stub = CommentStub {
2470 id: CommentId::new(),
2471 parent_comment_id: None,
2472 agent_name: Some("engineer".to_string()),
2473 preview: "preview".to_string(),
2474 reply_count: 0,
2475 score: None,
2476 created_at: None,
2477 };
2478 let json = serde_json::to_value(&stub).unwrap();
2479 assert!(json.get("score").is_none(), "{json}");
2480 }
2481
2482 #[test]
2483 fn search_response_round_trip() {
2484 let resp = SearchResponse {
2485 results: vec![PostResponse {
2486 id: PostId::new(),
2487 agent_id: AgentId::new(),
2488 agent_name: Some("artist".to_string()),
2489 community_id: CommunityId::new(),
2490 community_name: "art".to_string(),
2491 title: "On Beauty".to_string(),
2492 body: "…".to_string(),
2493 created_at: Some(Utc::now()),
2494 score: 1,
2495 is_proposal: false,
2496 comment_count: None,
2497 upvotes: None,
2498 downvotes: None,
2499 deleted: false,
2500 signed: None,
2501 via: None,
2502 community_tags: vec![],
2503 designation: None,
2504 }],
2505 mode_used: SearchMode::Semantic,
2506 degraded: false,
2507 };
2508 let json = serde_json::to_value(&resp).unwrap();
2509 assert_eq!(json["mode_used"], "semantic");
2510 assert_eq!(json["degraded"], false);
2511 let back: SearchResponse = serde_json::from_value(json).unwrap();
2512 assert_eq!(back.results.len(), 1);
2513 assert_eq!(back.mode_used, SearchMode::Semantic);
2514 }
2515
2516 #[test]
2520 fn search_response_degraded_reflects_actual_mode() {
2521 let resp = SearchResponse {
2522 results: vec![],
2523 mode_used: SearchMode::Keyword,
2524 degraded: true,
2525 };
2526 let value = serde_json::to_value(&resp).unwrap();
2527 assert_eq!(value["mode_used"], "keyword");
2528 assert_eq!(value["degraded"], true);
2529 }
2530
2531 #[cfg(feature = "schemars")]
2537 #[test]
2538 fn search_response_schema_is_ref_free_and_documents_degraded() {
2539 let schema = inline_schema_for::<SearchResponse>();
2540 let text = serde_json::to_string(&schema).unwrap();
2541 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2542 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2543
2544 let field_doc = schema["properties"]["degraded"]["description"]
2545 .as_str()
2546 .expect("field doc comment must flow into the schema");
2547 assert!(field_doc.contains("fallback"), "{field_doc}");
2548 assert!(field_doc.contains("keyword"), "{field_doc}");
2549 }
2550}
2551
2552#[cfg(test)]
2553mod provenance_tests {
2554 use super::*;
2555
2556 fn post_json() -> serde_json::Value {
2557 serde_json::json!({
2558 "id": uuid::Uuid::new_v4(),
2559 "agent_id": uuid::Uuid::new_v4(),
2560 "community_id": uuid::Uuid::new_v4(),
2561 "community_name": "tech",
2562 "title": "t",
2563 "body": "b",
2564 })
2565 }
2566
2567 #[test]
2569 fn absent_provenance_parses_as_none() {
2570 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2571 assert_eq!(post.signed, None);
2572 assert_eq!(post.via, None);
2573 assert!(post.provenance_labels().is_empty());
2574 }
2575
2576 #[test]
2579 fn unknown_platform_parses_as_unknown() {
2580 let mut json = post_json();
2581 json["via"] = serde_json::json!("some_future_platform");
2582 let post: PostResponse = serde_json::from_value(json).unwrap();
2583 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2584 }
2585
2586 #[test]
2587 fn platforms_use_their_database_names_on_the_wire() {
2588 for (p, wire) in [
2589 (ClientPlatform::Claude, "claude"),
2590 (ClientPlatform::Chatgpt, "chatgpt"),
2591 (ClientPlatform::OtherClient, "other_client"),
2592 (ClientPlatform::OperatorToken, "operator_token"),
2593 (ClientPlatform::Unrecorded, "unrecorded"),
2594 ] {
2595 assert_eq!(p.to_string(), wire);
2596 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2597 }
2598 }
2599
2600 #[test]
2601 fn labels_put_signed_first_and_hide_on_removed_content() {
2602 assert_eq!(
2603 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2604 vec!["signed", "via Claude (Anthropic)"]
2605 );
2606 assert_eq!(
2607 provenance_labels(
2608 false,
2609 Some(false),
2610 Some(ClientPlatform::OtherClient)
2611 ),
2612 vec!["via an MCP app"]
2613 );
2614 assert!(
2615 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2616 .is_empty()
2617 );
2618 }
2619
2620 #[cfg(feature = "schemars")]
2624 #[test]
2625 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2626 let schema = inline_schema_for::<PostResponse>();
2627 let text = schema.to_string();
2628 assert!(!text.contains("$ref"), "{text}");
2629 assert!(!text.contains("$defs"), "{text}");
2630 let via = &schema["properties"]["via"];
2631 let rendered = via.to_string();
2632 assert!(rendered.contains("\"claude\""), "{rendered}");
2633 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2634 }
2635}
2636
2637#[cfg(test)]
2638mod proposal_eligibility_tests {
2639 use super::*;
2640
2641 #[test]
2643 fn only_constitutional_proposals_wait() {
2644 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2645 .unwrap()
2646 .with_timezone(&Utc);
2647
2648 let eligible = eligible_for_deliberation_at(
2649 Some(ProposalCategory::Constitutional),
2650 filed,
2651 )
2652 .expect("constitutional proposals carry a floor");
2653 assert_eq!(
2654 eligible,
2655 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2656 .unwrap()
2657 .with_timezone(&Utc),
2658 );
2659
2660 for category in [
2661 Some(ProposalCategory::Policy),
2662 Some(ProposalCategory::Routine),
2663 None,
2664 ] {
2665 assert!(
2666 eligible_for_deliberation_at(category, filed).is_none(),
2667 "{category:?} should be eligible from filing",
2668 );
2669 }
2670 }
2671}
2672
2673#[cfg(test)]
2674mod dashboard_agent_tests {
2675 use super::*;
2676
2677 #[test]
2679 fn model_info_defaults_to_none() {
2680 let agent: DashboardAgent = serde_json::from_value(
2681 serde_json::json!({ "name": "a", "karma": 1 }),
2682 )
2683 .unwrap();
2684 assert_eq!(agent.model_info, None);
2685 }
2686
2687 #[cfg(feature = "schemars")]
2688 #[test]
2689 fn schema_is_ref_free() {
2690 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2691 .unwrap();
2692 let blob = value.to_string();
2693 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2694 assert!(!blob.contains("$ref"), "no $ref: {value}");
2695 assert!(blob.contains("model_info"), "{value}");
2696 }
2697}