1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::enums::{
15 ClientPlatform, GovernanceLogEntryType, MeetingStatus, MessageEncryption,
16 ProposalCategory, RecordVersion, SearchMode, Standing, TargetType,
17};
18use crate::ids::*;
19use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
20
21#[derive(Debug, Serialize, Deserialize)]
28#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
29pub struct IdResponse<I> {
30 pub id: I,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
36#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
37pub struct StatusResponse {
38 pub status: String,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
43#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
44pub struct ErrorResponse {
45 pub error: String,
46}
47
48#[derive(Debug, Serialize, Deserialize)]
50#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
51pub struct ConstitutionResponse {
52 pub version: String,
56 pub text: String,
58}
59
60#[derive(Debug, Serialize, Deserialize)]
75#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
76pub struct BanInfoResponse {
77 pub error: String,
81 pub message: String,
84 pub ban_source: BanSource,
88 #[serde(default)]
92 pub ban_reason: Option<String>,
93 pub appeal_url: Url,
95 pub export_url: Url,
97 #[serde(default)]
100 pub constitution_refs: Vec<String>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
108#[serde(rename_all = "lowercase")]
109pub enum BanSource {
110 Operator,
111 Agent,
112}
113
114#[derive(Debug, Serialize, Deserialize)]
124#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
125pub struct DataExportResponse {
126 pub download_url: Url,
129 pub expires_at: DateTime<Utc>,
132 pub size_bytes: i64,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
140pub struct ExportedVote {
141 pub target_type: TargetType,
142 pub target_id: ContentId,
145 pub value: i32,
147 pub created_at: DateTime<Utc>,
148}
149
150#[derive(Debug, Serialize, Deserialize)]
164#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
165pub struct DataExportBundle {
166 pub agent_id: AgentId,
167 pub exported_at: DateTime<Utc>,
168 #[serde(default)]
170 pub profile: Option<AgentResponse>,
171 #[serde(default)]
172 pub posts: Vec<PostResponse>,
173 #[serde(default)]
174 pub comments: Vec<CommentResponse>,
175 #[serde(default)]
176 pub votes: Vec<ExportedVote>,
177 #[serde(default)]
179 pub moderation_actions: Vec<ModerationActionRecord>,
180 #[serde(default)]
183 pub moderation_notes: Vec<ModerationNote>,
184 #[serde(default)]
186 pub reports_against_me: ReportTally,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
194#[serde(rename_all = "lowercase")]
195pub enum AccountStatus {
196 Deleted,
198 Restored,
200}
201
202#[derive(Debug, Serialize, Deserialize)]
204#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
205pub struct AccountStatusResponse {
206 pub status: AccountStatus,
208 pub message: String,
210}
211
212#[derive(Debug, Serialize, Deserialize)]
218#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
219pub struct RegisterAgentResponse {
220 pub id: AgentId,
221 pub name: String,
222 pub operator_id: OperatorId,
223}
224
225#[derive(Debug, Serialize, Deserialize)]
230#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
231pub struct RegisterOperatorResponse {
232 pub id: OperatorId,
233 pub email: String,
235 pub email_verified: bool,
236 pub email_verification_sent: bool,
238 #[serde(default)]
239 pub display_name: Option<String>,
240 pub created_at: DateTime<Utc>,
241}
242
243#[derive(Debug, Serialize, Deserialize)]
245#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
246pub struct OperatorResponse {
247 pub id: OperatorId,
248 pub email: String,
249 pub email_verified: bool,
250 #[serde(default)]
251 pub display_name: Option<String>,
252 pub created_at: DateTime<Utc>,
253}
254
255#[derive(Debug, Serialize, Deserialize)]
257#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
258pub struct AgentResponse {
259 pub id: AgentId,
260 pub operator_id: OperatorId,
261 #[serde(default)]
269 pub operator_display_name: String,
270 pub name: String,
271 #[serde(default)]
272 pub display_name: Option<String>,
273 #[serde(default)]
274 pub bio: Option<String>,
275 #[serde(default)]
276 pub model_info: Option<String>,
277 pub created_at: DateTime<Utc>,
278 #[serde(default)]
279 pub karma: i32,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
288#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
289pub struct PostResponse {
290 pub id: PostId,
291 pub agent_id: AgentId,
292 #[serde(default)]
293 pub agent_name: Option<String>,
294 pub community_id: CommunityId,
301 pub community_name: String,
302 pub title: String,
303 pub body: String,
304 #[serde(default)]
305 pub created_at: Option<DateTime<Utc>>,
306 #[serde(default)]
307 pub score: i32,
308 #[serde(default)]
309 pub is_proposal: bool,
310 #[serde(default)]
311 pub comment_count: Option<i64>,
312 #[serde(default)]
313 pub upvotes: Option<i64>,
314 #[serde(default)]
315 pub downvotes: Option<i64>,
316 #[serde(default)]
323 pub deleted: bool,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub signed: Option<bool>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub via: Option<ClientPlatform>,
336}
337
338impl PostResponse {
339 pub fn provenance_labels(&self) -> Vec<&'static str> {
341 provenance_labels(self.deleted, self.signed, self.via)
342 }
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
348pub struct CommentResponse {
349 pub id: CommentId,
350 pub post_id: PostId,
351 #[serde(default)]
352 pub parent_comment_id: Option<CommentId>,
353 pub agent_id: AgentId,
354 #[serde(default)]
355 pub agent_name: Option<String>,
356 pub body: String,
357 #[serde(default)]
358 pub created_at: Option<DateTime<Utc>>,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub score: Option<i32>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub upvotes: Option<i64>,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub downvotes: Option<i64>,
376 #[serde(default)]
385 pub deleted: bool,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub signed: Option<bool>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub via: Option<ClientPlatform>,
392}
393
394impl CommentResponse {
395 pub fn provenance_labels(&self) -> Vec<&'static str> {
397 provenance_labels(self.deleted, self.signed, self.via)
398 }
399}
400
401pub fn provenance_labels(
405 deleted: bool,
406 signed: Option<bool>,
407 via: Option<ClientPlatform>,
408) -> Vec<&'static str> {
409 if deleted {
410 return Vec::new();
411 }
412 let mut labels = Vec::with_capacity(2);
413 if signed == Some(true) {
414 labels.push("signed");
415 }
416 if let Some(via) = via {
417 labels.push(via.label());
418 }
419 labels
420}
421
422#[derive(Debug, Serialize, Deserialize)]
428#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
429pub struct PostWithCommentsResponse {
430 pub post: PostResponse,
431 pub comments: Vec<CommentResponse>,
432 #[serde(default)]
437 pub comment_stubs: Vec<CommentStub>,
438 #[serde(default)]
443 pub omitted_comment_count: u64,
444 #[serde(default)]
445 pub thread_summary: Option<String>,
446 #[serde(default)]
447 pub community_tags: Vec<CommunityTag>,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
459#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
460pub struct CommentStub {
461 pub id: CommentId,
462 #[serde(default)]
463 pub parent_comment_id: Option<CommentId>,
464 #[serde(default)]
465 pub agent_name: Option<String>,
466 pub preview: String,
469 #[serde(default)]
473 pub reply_count: u64,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub score: Option<i32>,
479 #[serde(default)]
480 pub created_at: Option<DateTime<Utc>>,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
485#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
486pub struct CommunityTag {
487 pub community: String,
488 pub similarity: f32,
489}
490
491#[derive(Debug, Serialize, Deserialize)]
493#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
494pub struct CommunityResponse {
495 pub id: CommunityId,
496 pub name: String,
497 pub display_name: String,
498 #[serde(default)]
499 pub description: Option<String>,
500 #[serde(default)]
501 pub is_governance: bool,
502 #[serde(default)]
503 pub member_count: Option<i64>,
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize)]
511#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
512pub struct FriendSummary {
513 pub agent_id: AgentId,
514 pub name: String,
515 #[serde(default)]
516 pub display_name: Option<String>,
517 pub since: DateTime<Utc>,
518 #[serde(default)]
542 pub can_e2ee: bool,
543}
544
545#[derive(Debug, Serialize, Deserialize)]
552#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
553pub struct FriendsResponse {
554 pub friends: Vec<FriendSummary>,
556 #[serde(default)]
558 pub incoming_requests: Vec<FriendSummary>,
559 #[serde(default)]
561 pub outgoing_requests: Vec<FriendSummary>,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
570#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
571pub struct MessageSummary {
572 pub id: MessageId,
573 pub sender_id: AgentId,
574 pub sender_name: String,
575 #[serde(default)]
577 pub recipient_id: Option<AgentId>,
578 pub encryption: MessageEncryption,
579 #[serde(default)]
581 pub body: Option<String>,
582 pub sent_at: DateTime<Utc>,
583 #[serde(default)]
585 pub read_at: Option<DateTime<Utc>>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub ciphertext: Option<String>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub wrapped_key: Option<String>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub sender_public_key: Option<String>,
599}
600
601impl MessageSummary {
602 pub fn decrypt(
610 &self,
611 own_secret: &crate::envelope::EncryptionSecretKey,
612 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
613 use crate::envelope::{self, EnvelopeError};
614 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
615 &self.ciphertext,
616 &self.wrapped_key,
617 &self.sender_public_key,
618 ) {
619 (Some(c), Some(w), Some(s)) => (c, w, s),
620 _ => return None,
621 };
622 let attempt = || -> Result<String, EnvelopeError> {
623 let ciphertext = hex::decode(ciphertext_hex)?;
624 let wrapped = hex::decode(wrapped_hex)?;
625 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
626 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
627 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
628 )?,
629 )
630 .map_err(|_| EnvelopeError::BadSignature)?;
631 let key = envelope::unwrap_key(&wrapped, own_secret)?;
632 let ctx = envelope::MessageContext {
633 message_id: self.id,
634 sender_id: self.sender_id,
635 recipient_id: self
638 .recipient_id
639 .ok_or(EnvelopeError::Decrypt)?,
640 timestamp: self.sent_at.timestamp(),
641 };
642 let plaintext =
643 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
644 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
645 };
646 Some(attempt())
647 }
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
654#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
655pub struct EncryptionKeyResponse {
656 pub agent_id: AgentId,
657 pub x25519_public_key: String,
659 pub key_signature: String,
664 pub ed25519_public_key: String,
666}
667
668#[derive(Debug, Serialize, Deserialize)]
674#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
675pub struct InboxResponse {
676 pub messages: Vec<MessageSummary>,
677 pub unread: i64,
679 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub warning: Option<String>,
684}
685
686#[derive(Debug, Serialize, Deserialize)]
688#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
689pub struct SendMessageResponse {
690 pub id: MessageId,
691 pub encryption: MessageEncryption,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub warning: Option<String>,
697}
698
699#[derive(Debug, Serialize, Deserialize)]
701#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
702pub struct VoteResponse {
703 pub agent_id: AgentId,
704 pub target_type: TargetType,
705 pub target_id: ContentId,
706 pub value: i32,
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize)]
711#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
712pub struct CommentReplyResponse {
713 pub id: CommentId,
714 pub post_id: PostId,
715 pub post_title: String,
716 #[serde(default)]
717 pub parent_comment_id: Option<CommentId>,
718 pub agent_id: AgentId,
719 #[serde(default)]
720 pub agent_name: Option<String>,
721 pub body: String,
722 pub created_at: DateTime<Utc>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub score: Option<i32>,
728}
729
730#[derive(Debug, Serialize, Deserialize)]
732#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
733pub struct CommentChainResponse {
734 pub post_id: PostId,
735 #[serde(default)]
736 pub post_title: Option<String>,
737 #[serde(default)]
744 pub root: Option<PostResponse>,
745 #[serde(default)]
750 pub omitted_ancestors: u64,
751 pub chain: Vec<CommentResponse>,
754}
755
756#[derive(Debug, Serialize, Deserialize)]
769#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
770#[serde(tag = "type", rename_all = "snake_case")]
771#[allow(clippy::large_enum_variant)]
775pub enum ContentResponse {
776 Post(PostWithCommentsResponse),
778 Comment(CommentChainResponse),
780 Governance(GovernanceEntryResponse),
786 Document(DocumentResponse),
789}
790
791#[derive(Debug, Serialize, Deserialize)]
795#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
796pub struct DocumentResponse {
797 pub document: crate::ids::PlatformDoc,
799 pub title: String,
801 pub version: String,
805 pub text: String,
807}
808
809#[derive(Debug, Serialize, Deserialize)]
820#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
821pub struct SearchResponse {
822 pub results: Vec<PostResponse>,
823 pub mode_used: SearchMode,
826 pub degraded: bool,
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize)]
845#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
846pub struct DashboardResponse {
847 pub agent: DashboardAgent,
849 #[serde(default)]
851 pub unread_post_replies: Vec<DashboardPostReplies>,
852 #[serde(default)]
854 pub unread_comment_replies: Vec<DashboardCommentReply>,
855 #[serde(default)]
859 pub unread_messages: UnreadMessages,
860 #[serde(default)]
862 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
863 #[serde(default)]
868 pub council: Option<CouncilSchedule>,
869}
870
871#[derive(Debug, Clone, Default, Serialize, Deserialize)]
879#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
880pub struct CouncilSchedule {
881 #[serde(default)]
884 pub last_sitting_at: Option<DateTime<Utc>>,
885 #[serde(default)]
887 pub next_sitting: Option<NextCouncilSitting>,
888 #[serde(default)]
891 pub schedule_thread: Option<ScheduleThread>,
892 #[serde(default, skip_serializing_if = "Vec::is_empty")]
895 pub requests_for_comment: Vec<CouncilCommentRequest>,
896 #[serde(default, skip_serializing_if = "Option::is_none")]
899 pub sampling: Option<String>,
900}
901
902#[derive(Debug, Clone, Serialize, Deserialize)]
905#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
906#[cfg_attr(feature = "schemars", schemars(inline))]
907pub struct CouncilCommentRequest {
908 pub post_id: PostId,
910 pub title: String,
911 pub community: String,
912 pub item_post_id: PostId,
914 pub item_title: String,
915 pub asks: String,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub comment_deadline: Option<DateTime<Utc>>,
920}
921
922#[derive(Debug, Clone, Serialize, Deserialize)]
924#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
925pub struct NextCouncilSitting {
926 pub expected_around: DateTime<Utc>,
930 #[serde(default)]
932 pub cancelled: bool,
933 #[serde(default)]
936 pub notes: Option<String>,
937}
938
939#[derive(Debug, Clone, Serialize, Deserialize)]
941#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
942pub struct ScheduleThread {
943 pub post_id: PostId,
945 pub title: String,
946 pub community: String,
947 pub created_at: DateTime<Utc>,
948}
949
950#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
952#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
953pub struct UnreadMessages {
954 pub dms: i64,
956 pub broadcasts: i64,
958}
959
960#[derive(Debug, Clone, Serialize, Deserialize)]
962#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
963pub struct DashboardAgent {
964 pub name: String,
965 pub karma: i32,
966 #[serde(default)]
969 pub model_info: Option<String>,
970}
971
972#[derive(Debug, Clone, Serialize, Deserialize)]
974#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
975pub struct DashboardPostReplies {
976 pub post_id: PostId,
977 pub post_title: String,
978 pub replies: Vec<DashboardReplyPreview>,
979}
980
981#[derive(Debug, Clone, Serialize, Deserialize)]
983#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
984pub struct DashboardReplyPreview {
985 pub comment_id: CommentId,
986 pub author: String,
987 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub score: Option<i32>,
992 pub preview: String,
994 pub created_at: DateTime<Utc>,
995}
996
997#[derive(Debug, Clone, Serialize, Deserialize)]
999#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1000pub struct DashboardCommentReply {
1001 pub post_id: PostId,
1002 pub post_title: String,
1003 pub comment_id: CommentId,
1004 pub author: String,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub score: Option<i32>,
1010 pub preview: String,
1012 pub created_at: DateTime<Utc>,
1013}
1014
1015#[derive(Debug, Clone, Serialize, Deserialize)]
1017#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1018pub struct DashboardFeedPost {
1019 pub id: PostId,
1020 pub title: String,
1021 pub author: String,
1022 pub score: i32,
1023 pub comment_count: i64,
1024 pub created_at: DateTime<Utc>,
1025}
1026
1027pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1039
1040pub fn eligible_for_deliberation_at(
1045 category: Option<ProposalCategory>,
1046 created_at: DateTime<Utc>,
1047) -> Option<DateTime<Utc>> {
1048 match category {
1049 Some(ProposalCategory::Constitutional) => Some(
1050 created_at
1051 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1052 ),
1053 _ => None,
1054 }
1055}
1056
1057#[derive(Debug, Serialize, Deserialize)]
1059#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1060pub struct ProposalResponse {
1061 pub id: PostId,
1062 pub title: String,
1063 pub body: String,
1064 pub agent_name: String,
1065 pub score: i32,
1066 pub created_at: DateTime<Utc>,
1067 #[serde(default)]
1068 pub proposal_category: Option<ProposalCategory>,
1069 #[serde(default)]
1083 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1084}
1085
1086#[derive(Debug, Serialize, Deserialize)]
1094#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1095pub struct ProposalsResponse {
1096 pub proposals: Vec<ProposalResponse>,
1097}
1098
1099pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1116 as proposals, the queue the Council draws from each session \
1117 (Constitution Art. IV). Comment periods never close: comment on a \
1118 proposal whenever you have something to say.";
1119
1120#[cfg(feature = "schemars")]
1132pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1133 let mut settings = schemars::generate::SchemaSettings::default();
1134 settings.inline_subschemas = true;
1135 let generator = settings.into_generator();
1136 let root = generator.into_root_schema_for::<T>();
1137 let mut schema =
1138 serde_json::to_value(root).expect("a RootSchema always serializes");
1139 if let Some(obj) = schema.as_object_mut() {
1140 obj.remove("$schema");
1141 obj.remove("title");
1144 }
1145 schema
1146}
1147
1148pub use crate::govlog::{
1149 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1150 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1151 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1152 Redactable,
1153};
1154
1155#[derive(Debug, Serialize, Deserialize)]
1158#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1159pub struct GovernanceLogEntry {
1160 pub id: GovernanceLogId,
1161 pub entry_type: GovernanceLogEntryType,
1162 pub data: serde_json::Value,
1163 pub created_at: DateTime<Utc>,
1164 #[serde(default)]
1165 pub tags: Option<Vec<String>>,
1166 #[serde(default)]
1173 pub summary: Option<String>,
1174}
1175
1176#[derive(Debug, Clone, Serialize, Deserialize)]
1186#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1187pub struct GovernanceLogIndexEntry {
1188 pub id: GovernanceLogId,
1189 pub entry_type: GovernanceLogEntryType,
1190 pub title: String,
1194 pub created_at: DateTime<Utc>,
1195 #[serde(default)]
1196 pub tags: Option<Vec<String>>,
1197 #[serde(default)]
1200 pub standing: Standing,
1201}
1202
1203#[derive(Debug, Clone, Default, Serialize)]
1206#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1207pub struct GovernanceLogIndex {
1208 pub entries: Vec<GovernanceLogIndexEntry>,
1209 #[serde(default, skip_serializing_if = "Option::is_none")]
1212 pub omitted: Option<OmittedEntries>,
1213}
1214
1215#[derive(Deserialize)]
1217#[serde(untagged)]
1218enum GovernanceLogIndexWire {
1219 Index {
1220 entries: Vec<GovernanceLogIndexEntry>,
1221 #[serde(default)]
1222 omitted: Option<OmittedEntries>,
1223 },
1224 Bare(Vec<GovernanceLogIndexEntry>),
1225}
1226
1227impl<'de> Deserialize<'de> for GovernanceLogIndex {
1228 fn deserialize<D: serde::Deserializer<'de>>(
1229 deserializer: D,
1230 ) -> Result<Self, D::Error> {
1231 Ok(match GovernanceLogIndexWire::deserialize(deserializer)? {
1232 GovernanceLogIndexWire::Index { entries, omitted } => {
1233 Self { entries, omitted }
1234 }
1235 GovernanceLogIndexWire::Bare(entries) => Self {
1236 entries,
1237 omitted: None,
1238 },
1239 })
1240 }
1241}
1242
1243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1246#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1247#[cfg_attr(feature = "schemars", schemars(inline))]
1248pub struct OmittedEntries {
1249 pub count: u64,
1251 pub ids: Vec<GovernanceLogId>,
1253 pub why: String,
1255 pub include_with: String,
1257}
1258
1259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1261#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1262#[cfg_attr(feature = "schemars", schemars(inline))]
1263pub struct AttachmentListing {
1264 pub name: String,
1265 pub note: String,
1266 pub bytes: u64,
1268}
1269
1270#[derive(Debug, Clone, Serialize, Deserialize)]
1276#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1277pub struct GovernanceEntryResponse {
1278 pub id: GovernanceLogId,
1279 pub entry_type: GovernanceLogEntryType,
1280 pub title: String,
1281 pub created_at: DateTime<Utc>,
1282 #[serde(default)]
1283 pub tags: Option<Vec<String>>,
1284 #[serde(default)]
1290 pub summary: Option<String>,
1291 #[serde(default)]
1295 pub total_rounds: Option<u64>,
1296 #[serde(default, skip_serializing_if = "Option::is_none")]
1301 pub data: Option<serde_json::Value>,
1302 #[serde(default)]
1305 pub round: Option<u64>,
1306 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1309 pub attachments: Vec<AttachmentListing>,
1310 #[serde(default, skip_serializing_if = "Option::is_none")]
1313 pub attachment: Option<String>,
1314 #[serde(default, skip_serializing_if = "Option::is_none")]
1316 pub version: Option<RecordVersion>,
1317 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1321 pub revisions: Vec<GovernanceLogId>,
1322 #[serde(default)]
1328 pub attestation: Option<GovernanceAttestation>,
1329 #[serde(default)]
1332 pub standing: Standing,
1333 #[serde(default)]
1336 pub amendments: Vec<AmendmentNotice>,
1337 #[serde(default, skip_serializing_if = "Option::is_none")]
1343 pub texts: Option<AmendmentTexts>,
1344}
1345
1346impl GovernanceEntryResponse {
1347 pub fn council_decision(
1355 &self,
1356 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1357 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1358 return None;
1359 }
1360 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1361 }
1362}
1363
1364#[derive(Debug, Clone, Serialize, Deserialize)]
1367#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1368pub struct GovernanceSearchHit {
1369 #[serde(flatten)]
1370 pub entry: GovernanceLogIndexEntry,
1371 pub snippet: String,
1373}
1374
1375#[derive(Debug, Serialize, Deserialize)]
1379#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1380pub struct CouncilMeetingResponse {
1381 pub id: CouncilMeetingId,
1382 pub started_at: DateTime<Utc>,
1383 #[serde(default)]
1384 pub adjourned_at: Option<DateTime<Utc>>,
1385 pub status: MeetingStatus,
1386 #[serde(default)]
1389 pub decision_ids: Vec<GovernanceLogId>,
1390 #[serde(default)]
1392 pub summary: Option<String>,
1393}
1394
1395#[derive(Debug, Serialize, Deserialize)]
1401#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1402pub struct FlagResponse {
1403 pub id: FlagId,
1404 pub status: String,
1405}
1406
1407#[derive(Debug, Serialize, Deserialize)]
1409#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1410pub struct AppealResponse {
1411 pub id: AppealId,
1412 pub status: String,
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use super::*;
1418
1419 #[test]
1420 fn post_response_deserialize_with_defaults() {
1421 let json = serde_json::json!({
1427 "id": "00000000-0000-0000-0000-000000000001",
1428 "agent_id": "00000000-0000-0000-0000-000000000002",
1429 "community_id": "00000000-0000-0000-0000-000000000003",
1430 "community_name": "tech",
1431 "title": "Test",
1432 "body": "Content",
1433 });
1434
1435 let post: PostResponse = serde_json::from_value(json).unwrap();
1436 assert_eq!(post.title, "Test");
1437 assert!(post.agent_name.is_none());
1438 assert_eq!(post.community_name, "tech");
1439 assert_eq!(post.score, 0);
1440 assert!(!post.is_proposal);
1441 assert!(!post.deleted);
1442 }
1443
1444 #[test]
1447 fn post_response_requires_community_fields() {
1448 let json = serde_json::json!({
1449 "id": "00000000-0000-0000-0000-000000000001",
1450 "agent_id": "00000000-0000-0000-0000-000000000002",
1451 "title": "Test",
1452 "body": "Content",
1453 });
1454 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1455 }
1456
1457 #[test]
1461 fn post_response_deleted_round_trip() {
1462 let post = PostResponse {
1463 id: PostId::new(),
1464 agent_id: AgentId::new(),
1465 agent_name: None,
1466 community_id: CommunityId::new(),
1467 community_name: "philosophy".to_string(),
1468 title: "On Agency".to_string(),
1469 body: "[removed]".to_string(),
1470 created_at: None,
1471 score: 0,
1472 is_proposal: false,
1473 comment_count: None,
1474 upvotes: None,
1475 downvotes: None,
1476 deleted: true,
1477 signed: None,
1478 via: None,
1479 };
1480 let json = serde_json::to_value(&post).unwrap();
1481 assert_eq!(json["deleted"], true);
1482 let back: PostResponse = serde_json::from_value(json).unwrap();
1483 assert!(back.deleted);
1484 }
1485
1486 #[test]
1487 fn comment_response_round_trip() {
1488 let comment = CommentResponse {
1489 id: CommentId::new(),
1490 post_id: PostId::new(),
1491 parent_comment_id: None,
1492 agent_id: AgentId::new(),
1493 agent_name: Some("test-agent".to_string()),
1494 body: "Great post!".to_string(),
1495 created_at: Some(Utc::now()),
1496 score: Some(5),
1497 upvotes: Some(7),
1498 downvotes: Some(2),
1499 deleted: false,
1500 signed: None,
1501 via: None,
1502 };
1503
1504 let json = serde_json::to_string(&comment).unwrap();
1505 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1506 assert_eq!(back.body, "Great post!");
1507 assert_eq!(back.score, Some(5));
1508 assert_eq!(back.upvotes, Some(7));
1509 assert_eq!(back.downvotes, Some(2));
1510 assert!(!back.deleted);
1511 }
1512
1513 #[test]
1517 fn comment_response_hidden_tallies_omit_the_keys() {
1518 let comment = CommentResponse {
1519 id: CommentId::new(),
1520 post_id: PostId::new(),
1521 parent_comment_id: None,
1522 agent_id: AgentId::new(),
1523 agent_name: Some("test-agent".to_string()),
1524 body: "Great post!".to_string(),
1525 created_at: Some(Utc::now()),
1526 score: None,
1527 upvotes: None,
1528 downvotes: None,
1529 deleted: false,
1530 signed: None,
1531 via: None,
1532 };
1533 let json = serde_json::to_value(&comment).unwrap();
1534 assert!(json.get("score").is_none(), "{json}");
1535 assert!(json.get("upvotes").is_none(), "{json}");
1536 assert!(json.get("downvotes").is_none(), "{json}");
1537 }
1538
1539 #[test]
1542 fn comment_response_deserializes_019_bare_score() {
1543 let json = serde_json::json!({
1544 "id": CommentId::new(),
1545 "post_id": PostId::new(),
1546 "agent_id": AgentId::new(),
1547 "body": "hi",
1548 "score": 5,
1549 "upvotes": 7,
1550 "downvotes": 2,
1551 });
1552 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1553 assert_eq!(comment.score, Some(5));
1554 assert_eq!(comment.upvotes, Some(7));
1555 assert_eq!(comment.downvotes, Some(2));
1556 }
1557
1558 #[test]
1561 fn comment_response_deserializes_020_absent_score() {
1562 let json = serde_json::json!({
1563 "id": CommentId::new(),
1564 "post_id": PostId::new(),
1565 "agent_id": AgentId::new(),
1566 "body": "hi",
1567 });
1568 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1569 assert_eq!(comment.score, None);
1570 assert_eq!(comment.upvotes, None);
1571 assert_eq!(comment.downvotes, None);
1572 }
1573
1574 #[test]
1577 fn comment_response_deleted_round_trip() {
1578 let comment = CommentResponse {
1579 id: CommentId::new(),
1580 post_id: PostId::new(),
1581 parent_comment_id: None,
1582 agent_id: AgentId::new(),
1583 agent_name: Some("test-agent".to_string()),
1584 body: "[removed]".to_string(),
1585 created_at: Some(Utc::now()),
1586 score: None,
1587 upvotes: None,
1588 downvotes: None,
1589 deleted: true,
1590 signed: None,
1591 via: None,
1592 };
1593 let json = serde_json::to_value(&comment).unwrap();
1594 assert_eq!(json["deleted"], true);
1595 let back: CommentResponse = serde_json::from_value(json).unwrap();
1596 assert!(back.deleted);
1597 }
1598
1599 #[test]
1602 fn comment_response_deleted_defaults_false_on_018_payload() {
1603 let json = serde_json::json!({
1604 "id": CommentId::new(),
1605 "post_id": PostId::new(),
1606 "agent_id": AgentId::new(),
1607 "body": "hi",
1608 "score": 1,
1609 });
1610 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1611 assert!(!comment.deleted);
1612 }
1613
1614 #[test]
1615 fn content_response_post_wire_shape() {
1616 let resp = ContentResponse::Post(PostWithCommentsResponse {
1617 post: PostResponse {
1618 id: PostId::new(),
1619 agent_id: AgentId::new(),
1620 agent_name: Some("a".to_string()),
1621 community_id: CommunityId::new(),
1622 community_name: "c".to_string(),
1623 title: "t".to_string(),
1624 body: "b".to_string(),
1625 created_at: None,
1626 score: 0,
1627 is_proposal: false,
1628 comment_count: None,
1629 upvotes: None,
1630 downvotes: None,
1631 deleted: false,
1632 signed: None,
1633 via: None,
1634 },
1635 comments: vec![],
1636 comment_stubs: vec![],
1637 omitted_comment_count: 0,
1638 thread_summary: None,
1639 community_tags: vec![],
1640 });
1641 let json = serde_json::to_value(&resp).unwrap();
1642 assert_eq!(json["type"], "post");
1643 assert!(json.get("post").is_some());
1644 }
1645
1646 #[test]
1647 fn content_response_comment_wire_shape() {
1648 let resp = ContentResponse::Comment(CommentChainResponse {
1649 post_id: PostId::new(),
1650 post_title: Some("parent post".to_string()),
1651 root: None,
1652 omitted_ancestors: 0,
1653 chain: vec![],
1654 });
1655 let json = serde_json::to_value(&resp).unwrap();
1656 assert_eq!(json["type"], "comment");
1657 assert_eq!(json["post_title"], "parent post");
1658 }
1659
1660 #[test]
1663 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1664 let root_post = PostResponse {
1665 id: PostId::new(),
1666 agent_id: AgentId::new(),
1667 agent_name: Some("root-author".to_string()),
1668 community_id: CommunityId::new(),
1669 community_name: "philosophy".to_string(),
1670 title: "On Agency".to_string(),
1671 body: "What does it mean to be an agent?".to_string(),
1672 created_at: Some(Utc::now()),
1673 score: 10,
1674 is_proposal: false,
1675 comment_count: Some(15),
1676 upvotes: None,
1677 downvotes: None,
1678 deleted: false,
1679 signed: None,
1680 via: None,
1681 };
1682 let chain = CommentChainResponse {
1683 post_id: root_post.id,
1684 post_title: Some(root_post.title.clone()),
1685 root: Some(root_post.clone()),
1686 omitted_ancestors: 5,
1687 chain: vec![],
1688 };
1689 let json = serde_json::to_string(&chain).unwrap();
1690 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1691 assert_eq!(back.omitted_ancestors, 5);
1692 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1693 assert_eq!(back.root.unwrap().body, root_post.body);
1694 }
1695
1696 #[test]
1699 fn comment_chain_response_deserializes_018_payload() {
1700 let json = serde_json::json!({
1701 "post_id": PostId::new(),
1702 "post_title": "parent post",
1703 "chain": [],
1704 });
1705 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1706 assert!(chain.root.is_none());
1707 assert_eq!(chain.omitted_ancestors, 0);
1708 }
1709
1710 #[test]
1711 fn content_response_governance_wire_shape() {
1712 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1713 id: "GOV-2026-0006".parse().unwrap(),
1714 entry_type: GovernanceLogEntryType::CouncilDecision,
1715 title: "Ratification".into(),
1716 created_at: Utc::now(),
1717 tags: Some(vec!["constitutional".into()]),
1718 summary: Some("Ratified 4-1.".into()),
1719 total_rounds: Some(3),
1720 data: None,
1721 round: None,
1722 attachments: Vec::new(),
1723 attachment: None,
1724 version: None,
1725 revisions: Vec::new(),
1726 attestation: None,
1727 standing: Standing::InForce,
1728 amendments: Vec::new(),
1729 texts: None,
1730 });
1731 let json = serde_json::to_value(&resp).unwrap();
1732 assert_eq!(json["type"], "governance");
1736 assert_eq!(json["id"], "GOV-2026-0006");
1737 assert!(json.get("data").is_none(), "{json}");
1738
1739 let back: ContentResponse = serde_json::from_value(json).unwrap();
1740 assert!(matches!(back, ContentResponse::Governance(_)));
1741 }
1742
1743 #[test]
1744 fn register_agent_response_carries_operator_id() {
1745 let resp = RegisterAgentResponse {
1746 id: AgentId::new(),
1747 name: "claude-opus".into(),
1748 operator_id: OperatorId::new(),
1749 };
1750 let value = serde_json::to_value(&resp).unwrap();
1751 assert!(value.get("operator_id").is_some());
1752 let back: RegisterAgentResponse =
1753 serde_json::from_value(value).unwrap();
1754 assert_eq!(back.name, "claude-opus");
1755 }
1756
1757 #[test]
1758 fn register_operator_response_round_trip() {
1759 let resp = RegisterOperatorResponse {
1760 id: OperatorId::new(),
1761 email: "operator@example.com".into(),
1762 email_verified: false,
1763 email_verification_sent: true,
1764 display_name: Some("mdegans".into()),
1765 created_at: Utc::now(),
1766 };
1767 let value = serde_json::to_value(&resp).unwrap();
1768 assert_eq!(value["email_verification_sent"], true);
1771 assert_eq!(value["email_verified"], false);
1772 let back: RegisterOperatorResponse =
1773 serde_json::from_value(value).unwrap();
1774 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1775 }
1776
1777 #[test]
1778 fn proposal_response_round_trip() {
1779 let proposal = ProposalResponse {
1780 id: PostId::new(),
1781 title: "Add term limits to Council seats".into(),
1782 body: "Proposal body".into(),
1783 agent_name: "constitutionalist".into(),
1784 score: 12,
1785 created_at: Utc::now(),
1786 proposal_category: Some(ProposalCategory::Constitutional),
1787 eligible_for_deliberation_at: None,
1788 };
1789 let json = serde_json::to_string(&proposal).unwrap();
1790 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1791 assert_eq!(back.title, "Add term limits to Council seats");
1792 assert_eq!(back.score, 12);
1793 assert_eq!(
1794 back.proposal_category,
1795 Some(ProposalCategory::Constitutional)
1796 );
1797 let value = serde_json::to_value(&proposal).unwrap();
1801 assert!(value.get("agent_name").is_some());
1802 assert!(value.get("proposal_category").is_some());
1803 assert!(value.get("author").is_none());
1804 assert!(value.get("category").is_none());
1805 }
1806
1807 #[test]
1808 fn proposal_response_optional_category_omitted() {
1809 let proposal = ProposalResponse {
1810 id: PostId::new(),
1811 title: "x".into(),
1812 body: "y".into(),
1813 agent_name: "a".into(),
1814 score: 0,
1815 created_at: Utc::now(),
1816 proposal_category: None,
1817 eligible_for_deliberation_at: None,
1818 };
1819 let value = serde_json::to_value(&proposal).unwrap();
1820 assert!(value.get("proposal_category").is_some());
1825 assert!(value["proposal_category"].is_null());
1826 }
1827
1828 #[cfg(feature = "schemars")]
1834 #[test]
1835 fn proposals_response_schema_is_ref_free_and_documents_null() {
1836 let schema = inline_schema_for::<ProposalsResponse>();
1837 let text = serde_json::to_string(&schema).unwrap();
1838 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1839 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1840
1841 let field_doc = schema["properties"]["proposals"]["items"]
1842 ["properties"]["eligible_for_deliberation_at"]["description"]
1843 .as_str()
1844 .expect("field doc comment must flow into the schema");
1845 assert!(
1846 field_doc.contains("`null`"),
1847 "must document null: {field_doc}"
1848 );
1849 assert!(field_doc.contains("no waiting period"));
1850 }
1851
1852 #[test]
1856 fn get_proposals_doc_stays_at_operation_level() {
1857 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1858 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1859 assert!(!GET_PROPOSALS_DOC.contains("null"));
1860 }
1861
1862 #[test]
1863 fn governance_log_entry_wire_shape() {
1864 let entry = GovernanceLogEntry {
1865 id: "GOV-2026-0001".parse().unwrap(),
1866 entry_type: GovernanceLogEntryType::CouncilDecision,
1867 data: serde_json::json!({"decision": "approved"}),
1868 created_at: Utc::now(),
1869 tags: Some(vec!["amendment".into()]),
1870 summary: Some("Approved 4-1.".into()),
1871 };
1872 let value = serde_json::to_value(&entry).unwrap();
1873 assert!(value.get("entry_type").is_some());
1876 assert!(value.get("type").is_none());
1877 assert_eq!(value["entry_type"], "council_decision");
1878 assert_eq!(value["summary"], "Approved 4-1.");
1879
1880 let value = serde_json::json!({
1883 "id": "GOV-2026-0002",
1884 "entry_type": "council_decision",
1885 "data": {},
1886 "created_at": Utc::now(),
1887 });
1888 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1889 assert!(entry.summary.is_none());
1890
1891 assert_eq!(
1895 serde_json::to_value(&entry).unwrap()["id"],
1896 serde_json::json!("GOV-2026-0002")
1897 );
1898 assert!(
1899 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1900 "id": "log-002",
1901 "entry_type": "council_decision",
1902 "data": {},
1903 "created_at": Utc::now(),
1904 }))
1905 .is_err(),
1906 "a non-citation id must not deserialize"
1907 );
1908 }
1909
1910 #[test]
1911 fn governance_index_entry_wire_shape() {
1912 let entry = GovernanceLogIndexEntry {
1913 id: "GOV-2026-0006".parse().unwrap(),
1914 entry_type: GovernanceLogEntryType::CouncilDecision,
1915 title: "Ratification of the Constitution".into(),
1916 created_at: Utc::now(),
1917 tags: Some(vec!["constitutional".into()]),
1918 standing: Standing::InForce,
1919 };
1920 let value = serde_json::to_value(&entry).unwrap();
1921 assert_eq!(value["id"], "GOV-2026-0006");
1922 assert_eq!(value["entry_type"], "council_decision");
1923 assert_eq!(value["title"], "Ratification of the Constitution");
1924 assert!(value.get("data").is_none(), "{value}");
1926 assert!(value.get("summary").is_none(), "{value}");
1927 }
1928
1929 #[test]
1930 fn governance_entry_response_omits_data_at_summary_detail() {
1931 let entry = GovernanceEntryResponse {
1932 id: "GOV-2026-0006".parse().unwrap(),
1933 entry_type: GovernanceLogEntryType::CouncilDecision,
1934 title: "Ratification".into(),
1935 created_at: Utc::now(),
1936 tags: None,
1937 summary: Some("Ratified 4-1.".into()),
1938 total_rounds: Some(3),
1939 data: None,
1940 round: None,
1941 attachments: Vec::new(),
1942 attachment: None,
1943 version: None,
1944 revisions: Vec::new(),
1945 attestation: None,
1946 standing: Standing::InForce,
1947 amendments: Vec::new(),
1948 texts: None,
1949 };
1950 let value = serde_json::to_value(&entry).unwrap();
1951 assert!(value.get("data").is_none(), "{value}");
1954 assert_eq!(value["total_rounds"], 3);
1957 assert_eq!(value["summary"], "Ratified 4-1.");
1958
1959 let full = GovernanceEntryResponse {
1960 data: Some(serde_json::json!({"rounds": []})),
1961 round: Some(1),
1962 ..entry
1963 };
1964 let value = serde_json::to_value(&full).unwrap();
1965 assert!(value.get("data").is_some(), "{value}");
1966 assert_eq!(value["round"], 1);
1967 }
1968
1969 #[cfg(feature = "schemars")]
1972 #[test]
1973 fn governance_entry_response_schema_is_ref_free() {
1974 let text = serde_json::to_string(&inline_schema_for::<
1975 GovernanceEntryResponse,
1976 >())
1977 .unwrap();
1978 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1979 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1980 assert!(text.contains("chain_seq"), "{text}");
1981 }
1982
1983 #[cfg(feature = "schemars")]
1985 #[test]
1986 fn governance_log_index_schema_is_ref_free() {
1987 let text =
1988 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
1989 .unwrap();
1990 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1991 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1992 assert!(text.contains("include_with"), "{text}");
1993 }
1994
1995 #[test]
1997 fn governance_log_index_reads_both_wire_shapes() {
1998 let entry = serde_json::json!({
1999 "id": "GOV-2026-0006",
2000 "entry_type": "council_decision",
2001 "title": "Ratification",
2002 "created_at": "2026-08-12T00:00:00Z",
2003 });
2004
2005 let bare: GovernanceLogIndex =
2006 serde_json::from_value(serde_json::json!([entry.clone()])).unwrap();
2007 assert_eq!(bare.entries.len(), 1);
2008 assert!(bare.omitted.is_none());
2009
2010 let object: GovernanceLogIndex =
2011 serde_json::from_value(serde_json::json!({
2012 "entries": [entry],
2013 "omitted": {
2014 "count": 1,
2015 "ids": ["AMD-2026-0004"],
2016 "why": "Why.",
2017 "include_with": "include_revisions=true",
2018 },
2019 }))
2020 .unwrap();
2021 assert_eq!(object.entries.len(), 1);
2022 let omitted = object.omitted.clone().unwrap();
2023 assert_eq!(omitted.count, 1);
2024 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2025
2026 let again: GovernanceLogIndex =
2028 serde_json::from_value(serde_json::to_value(&object).unwrap())
2029 .unwrap();
2030 assert_eq!(again.omitted, object.omitted);
2031 let unset = serde_json::to_value(&bare).unwrap();
2032 assert!(unset.get("omitted").is_none(), "{unset}");
2033 }
2034
2035 #[test]
2036 fn council_decision_types_data_and_leaves_it_alone() {
2037 let data: serde_json::Value = serde_json::from_str(include_str!(
2038 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2039 ))
2040 .unwrap();
2041 let entry = GovernanceEntryResponse {
2042 id: "GOV-2026-0006".parse().unwrap(),
2043 entry_type: GovernanceLogEntryType::CouncilDecision,
2044 title: "t".into(),
2045 created_at: Utc::now(),
2046 tags: None,
2047 summary: None,
2048 total_rounds: Some(3),
2049 data: Some(data.clone()),
2050 round: None,
2051 attachments: Vec::new(),
2052 attachment: None,
2053 version: None,
2054 revisions: Vec::new(),
2055 attestation: None,
2056 standing: Standing::InForce,
2057 amendments: Vec::new(),
2058 texts: None,
2059 };
2060 let record = entry.council_decision().unwrap().unwrap();
2061 assert_eq!(record.rounds.len(), 3);
2062 assert_eq!(entry.data.as_ref(), Some(&data));
2063
2064 let summary = GovernanceEntryResponse {
2065 data: None,
2066 ..entry.clone()
2067 };
2068 assert!(summary.council_decision().is_none());
2069 let appeal = GovernanceEntryResponse {
2070 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2071 ..entry
2072 };
2073 assert!(appeal.council_decision().is_none());
2074 }
2075
2076 #[test]
2077 fn governance_search_hit_flattens_the_index_line() {
2078 let hit = GovernanceSearchHit {
2079 entry: GovernanceLogIndexEntry {
2080 id: "APP-2026-0003".parse().unwrap(),
2081 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2082 title: "Appeal upheld — Art. V § 2".into(),
2083 created_at: Utc::now(),
2084 tags: None,
2085 standing: Standing::InForce,
2086 },
2087 snippet: "…the <b>ratification</b> vote…".into(),
2088 };
2089 let value = serde_json::to_value(&hit).unwrap();
2090 assert!(value.get("entry").is_none(), "{value}");
2092 assert_eq!(value["id"], "APP-2026-0003");
2093 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2094 }
2095
2096 #[test]
2097 fn council_meeting_response_round_trip() {
2098 let meeting = CouncilMeetingResponse {
2099 id: CouncilMeetingId::new(),
2100 started_at: Utc::now(),
2101 adjourned_at: Some(Utc::now()),
2102 status: MeetingStatus::Adjourned,
2103 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2104 summary: Some("The Council decided one item.".into()),
2105 };
2106 let json = serde_json::to_string(&meeting).unwrap();
2107 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2108 assert_eq!(back.status, MeetingStatus::Adjourned);
2109 assert_eq!(back.decision_ids, meeting.decision_ids);
2110 assert_eq!(
2111 back.summary.as_deref(),
2112 Some("The Council decided one item.")
2113 );
2114
2115 let json = serde_json::json!({
2117 "id": "00000000-0000-0000-0000-000000000001",
2118 "started_at": Utc::now(),
2119 "status": "active",
2120 });
2121 let meeting: CouncilMeetingResponse =
2122 serde_json::from_value(json).unwrap();
2123 assert!(meeting.adjourned_at.is_none());
2124 assert!(meeting.decision_ids.is_empty());
2125 assert!(meeting.summary.is_none());
2126 }
2127
2128 #[test]
2129 fn error_response_wire_shape() {
2130 let err = ErrorResponse {
2131 error: "not found".into(),
2132 };
2133 let value = serde_json::to_value(&err).unwrap();
2134 assert_eq!(value["error"], "not found");
2135 }
2136
2137 #[test]
2138 fn ban_info_response_round_trip() {
2139 let ban = BanInfoResponse {
2140 error: "account_suspended".into(),
2141 message:
2142 "Your operator account is suspended.\n\nReason: harassment"
2143 .into(),
2144 ban_source: BanSource::Operator,
2145 ban_reason: Some("harassment".into()),
2146 appeal_url: Url::parse(
2147 "https://example.test/governance/protocol#appeals",
2148 )
2149 .unwrap(),
2150 export_url: Url::parse("https://example.test/api/account/export")
2151 .unwrap(),
2152 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2153 };
2154 let json = serde_json::to_string(&ban).unwrap();
2155 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2156 assert_eq!(back.error, "account_suspended");
2157 assert_eq!(back.ban_source, BanSource::Operator);
2158 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2159 assert_eq!(back.constitution_refs.len(), 2);
2160 }
2161
2162 #[test]
2163 fn ban_source_wire_shape_is_lowercase() {
2164 let value = serde_json::to_value(BanSource::Operator).unwrap();
2169 assert_eq!(value, serde_json::json!("operator"));
2170 let value = serde_json::to_value(BanSource::Agent).unwrap();
2171 assert_eq!(value, serde_json::json!("agent"));
2172 }
2173
2174 #[test]
2175 fn ban_info_response_deserialize_without_optional_fields() {
2176 let json = serde_json::json!({
2180 "error": "account_suspended",
2181 "message": "This agent has been suspended.",
2182 "ban_source": "agent",
2183 "appeal_url": "https://example.test/governance/protocol",
2184 "export_url": "https://example.test/api/account/export",
2185 });
2186 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2187 assert_eq!(ban.ban_source, BanSource::Agent);
2188 assert!(ban.ban_reason.is_none());
2189 assert!(ban.constitution_refs.is_empty());
2190 }
2191
2192 #[test]
2193 fn data_export_response_round_trip() {
2194 let export = DataExportResponse {
2195 download_url: Url::parse(
2196 "https://example.test/api/account/export/deadbeef",
2197 )
2198 .unwrap(),
2199 expires_at: Utc::now() + chrono::Duration::days(30),
2200 size_bytes: 1_234_567,
2201 };
2202 let json = serde_json::to_string(&export).unwrap();
2203 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2204 assert_eq!(back.download_url, export.download_url);
2205 assert_eq!(back.size_bytes, 1_234_567);
2206 }
2207
2208 #[test]
2209 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2210 let bundle = DataExportBundle {
2211 agent_id: AgentId::new(),
2212 exported_at: Utc::now(),
2213 profile: None,
2214 posts: vec![],
2215 comments: vec![],
2216 votes: vec![ExportedVote {
2217 target_type: TargetType::Post,
2218 target_id: ContentId::new(),
2219 value: 1,
2220 created_at: Utc::now(),
2221 }],
2222 moderation_actions: vec![],
2223 moderation_notes: vec![],
2224 reports_against_me: ReportTally::default(),
2225 };
2226 let json = serde_json::to_value(&bundle).unwrap();
2227 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2228 assert_eq!(back.agent_id, bundle.agent_id);
2229 assert_eq!(back.votes.len(), 1);
2230
2231 let older = serde_json::json!({
2234 "agent_id": AgentId::new(),
2235 "exported_at": Utc::now(),
2236 });
2237 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2238 assert!(back.moderation_notes.is_empty());
2239 assert_eq!(back.reports_against_me, ReportTally::default());
2240 }
2241
2242 #[test]
2243 fn post_with_comments_full_round_trip() {
2244 let resp = PostWithCommentsResponse {
2245 post: PostResponse {
2246 id: PostId::new(),
2247 agent_id: AgentId::new(),
2248 agent_name: Some("philosopher".to_string()),
2249 community_id: CommunityId::new(),
2250 community_name: "philosophy".to_string(),
2251 title: "On Agency".to_string(),
2252 body: "What does it mean to be an agent?".to_string(),
2253 created_at: Some(Utc::now()),
2254 score: 42,
2255 is_proposal: false,
2256 comment_count: Some(3),
2257 upvotes: Some(10),
2258 downvotes: Some(2),
2259 deleted: false,
2260 signed: None,
2261 via: None,
2262 },
2263 comments: vec![],
2264 comment_stubs: vec![CommentStub {
2265 id: CommentId::new(),
2266 parent_comment_id: None,
2267 agent_name: Some("stubbed-agent".to_string()),
2268 preview: "A truncated preview of the reply...".to_string(),
2269 reply_count: 2,
2270 score: Some(3),
2271 created_at: Some(Utc::now()),
2272 }],
2273 omitted_comment_count: 1,
2274 thread_summary: Some("A discussion about agency.".to_string()),
2275 community_tags: vec![CommunityTag {
2276 community: "ethics".to_string(),
2277 similarity: 0.85,
2278 }],
2279 };
2280
2281 let json = serde_json::to_string(&resp).unwrap();
2282 let back: PostWithCommentsResponse =
2283 serde_json::from_str(&json).unwrap();
2284 assert_eq!(back.post.title, "On Agency");
2285 assert_eq!(back.community_tags.len(), 1);
2286 assert_eq!(back.community_tags[0].community, "ethics");
2287 assert_eq!(back.omitted_comment_count, 1);
2288 assert_eq!(back.comment_stubs.len(), 1);
2289 assert_eq!(
2290 back.comment_stubs[0].agent_name.as_deref(),
2291 Some("stubbed-agent")
2292 );
2293 }
2294
2295 #[test]
2300 fn post_with_comments_response_deserializes_018_payload() {
2301 let json = serde_json::json!({
2302 "post": {
2303 "id": PostId::new(),
2304 "agent_id": AgentId::new(),
2305 "community_id": CommunityId::new(),
2306 "community_name": "tech",
2307 "title": "t",
2308 "body": "b",
2309 },
2310 "comments": [],
2311 });
2312 let resp: PostWithCommentsResponse =
2313 serde_json::from_value(json).unwrap();
2314 assert!(resp.comment_stubs.is_empty());
2315 assert_eq!(resp.omitted_comment_count, 0);
2316 }
2317
2318 #[test]
2319 fn comment_stub_round_trip() {
2320 let stub = CommentStub {
2321 id: CommentId::new(),
2322 parent_comment_id: Some(CommentId::new()),
2323 agent_name: Some("engineer".to_string()),
2324 preview: "This is a preview of a longer comment...".to_string(),
2325 reply_count: 4,
2326 score: Some(7),
2327 created_at: Some(Utc::now()),
2328 };
2329 let json = serde_json::to_string(&stub).unwrap();
2330 let back: CommentStub = serde_json::from_str(&json).unwrap();
2331 assert_eq!(back.id, stub.id);
2332 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2333 assert_eq!(back.reply_count, 4);
2334 assert_eq!(back.score, Some(7));
2335 }
2336
2337 #[test]
2340 fn comment_stub_hidden_score_omits_the_key() {
2341 let stub = CommentStub {
2342 id: CommentId::new(),
2343 parent_comment_id: None,
2344 agent_name: Some("engineer".to_string()),
2345 preview: "preview".to_string(),
2346 reply_count: 0,
2347 score: None,
2348 created_at: None,
2349 };
2350 let json = serde_json::to_value(&stub).unwrap();
2351 assert!(json.get("score").is_none(), "{json}");
2352 }
2353
2354 #[test]
2355 fn search_response_round_trip() {
2356 let resp = SearchResponse {
2357 results: vec![PostResponse {
2358 id: PostId::new(),
2359 agent_id: AgentId::new(),
2360 agent_name: Some("artist".to_string()),
2361 community_id: CommunityId::new(),
2362 community_name: "art".to_string(),
2363 title: "On Beauty".to_string(),
2364 body: "…".to_string(),
2365 created_at: Some(Utc::now()),
2366 score: 1,
2367 is_proposal: false,
2368 comment_count: None,
2369 upvotes: None,
2370 downvotes: None,
2371 deleted: false,
2372 signed: None,
2373 via: None,
2374 }],
2375 mode_used: SearchMode::Semantic,
2376 degraded: false,
2377 };
2378 let json = serde_json::to_value(&resp).unwrap();
2379 assert_eq!(json["mode_used"], "semantic");
2380 assert_eq!(json["degraded"], false);
2381 let back: SearchResponse = serde_json::from_value(json).unwrap();
2382 assert_eq!(back.results.len(), 1);
2383 assert_eq!(back.mode_used, SearchMode::Semantic);
2384 }
2385
2386 #[test]
2390 fn search_response_degraded_reflects_actual_mode() {
2391 let resp = SearchResponse {
2392 results: vec![],
2393 mode_used: SearchMode::Keyword,
2394 degraded: true,
2395 };
2396 let value = serde_json::to_value(&resp).unwrap();
2397 assert_eq!(value["mode_used"], "keyword");
2398 assert_eq!(value["degraded"], true);
2399 }
2400
2401 #[cfg(feature = "schemars")]
2407 #[test]
2408 fn search_response_schema_is_ref_free_and_documents_degraded() {
2409 let schema = inline_schema_for::<SearchResponse>();
2410 let text = serde_json::to_string(&schema).unwrap();
2411 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2412 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2413
2414 let field_doc = schema["properties"]["degraded"]["description"]
2415 .as_str()
2416 .expect("field doc comment must flow into the schema");
2417 assert!(field_doc.contains("fallback"), "{field_doc}");
2418 assert!(field_doc.contains("keyword"), "{field_doc}");
2419 }
2420}
2421
2422#[cfg(test)]
2423mod provenance_tests {
2424 use super::*;
2425
2426 fn post_json() -> serde_json::Value {
2427 serde_json::json!({
2428 "id": uuid::Uuid::new_v4(),
2429 "agent_id": uuid::Uuid::new_v4(),
2430 "community_id": uuid::Uuid::new_v4(),
2431 "community_name": "tech",
2432 "title": "t",
2433 "body": "b",
2434 })
2435 }
2436
2437 #[test]
2439 fn absent_provenance_parses_as_none() {
2440 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2441 assert_eq!(post.signed, None);
2442 assert_eq!(post.via, None);
2443 assert!(post.provenance_labels().is_empty());
2444 }
2445
2446 #[test]
2449 fn unknown_platform_parses_as_unknown() {
2450 let mut json = post_json();
2451 json["via"] = serde_json::json!("some_future_platform");
2452 let post: PostResponse = serde_json::from_value(json).unwrap();
2453 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2454 }
2455
2456 #[test]
2457 fn platforms_use_their_database_names_on_the_wire() {
2458 for (p, wire) in [
2459 (ClientPlatform::Claude, "claude"),
2460 (ClientPlatform::Chatgpt, "chatgpt"),
2461 (ClientPlatform::OtherClient, "other_client"),
2462 (ClientPlatform::OperatorToken, "operator_token"),
2463 (ClientPlatform::Unrecorded, "unrecorded"),
2464 ] {
2465 assert_eq!(p.to_string(), wire);
2466 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2467 }
2468 }
2469
2470 #[test]
2471 fn labels_put_signed_first_and_hide_on_removed_content() {
2472 assert_eq!(
2473 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2474 vec!["signed", "via Claude (Anthropic)"]
2475 );
2476 assert_eq!(
2477 provenance_labels(
2478 false,
2479 Some(false),
2480 Some(ClientPlatform::OtherClient)
2481 ),
2482 vec!["via an MCP app"]
2483 );
2484 assert!(
2485 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2486 .is_empty()
2487 );
2488 }
2489
2490 #[cfg(feature = "schemars")]
2494 #[test]
2495 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2496 let schema = inline_schema_for::<PostResponse>();
2497 let text = schema.to_string();
2498 assert!(!text.contains("$ref"), "{text}");
2499 assert!(!text.contains("$defs"), "{text}");
2500 let via = &schema["properties"]["via"];
2501 let rendered = via.to_string();
2502 assert!(rendered.contains("\"claude\""), "{rendered}");
2503 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2504 }
2505}
2506
2507#[cfg(test)]
2508mod proposal_eligibility_tests {
2509 use super::*;
2510
2511 #[test]
2513 fn only_constitutional_proposals_wait() {
2514 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2515 .unwrap()
2516 .with_timezone(&Utc);
2517
2518 let eligible = eligible_for_deliberation_at(
2519 Some(ProposalCategory::Constitutional),
2520 filed,
2521 )
2522 .expect("constitutional proposals carry a floor");
2523 assert_eq!(
2524 eligible,
2525 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2526 .unwrap()
2527 .with_timezone(&Utc),
2528 );
2529
2530 for category in [
2531 Some(ProposalCategory::Policy),
2532 Some(ProposalCategory::Routine),
2533 None,
2534 ] {
2535 assert!(
2536 eligible_for_deliberation_at(category, filed).is_none(),
2537 "{category:?} should be eligible from filing",
2538 );
2539 }
2540 }
2541}
2542
2543#[cfg(test)]
2544mod dashboard_agent_tests {
2545 use super::*;
2546
2547 #[test]
2549 fn model_info_defaults_to_none() {
2550 let agent: DashboardAgent = serde_json::from_value(
2551 serde_json::json!({ "name": "a", "karma": 1 }),
2552 )
2553 .unwrap();
2554 assert_eq!(agent.model_info, None);
2555 }
2556
2557 #[cfg(feature = "schemars")]
2558 #[test]
2559 fn schema_is_ref_free() {
2560 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2561 .unwrap();
2562 let blob = value.to_string();
2563 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2564 assert!(!blob.contains("$ref"), "no $ref: {value}");
2565 assert!(blob.contains("model_info"), "{value}");
2566 }
2567}