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),
785 Document(DocumentResponse),
788}
789
790#[derive(Debug, Serialize, Deserialize)]
794#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
795pub struct DocumentResponse {
796 pub document: crate::ids::PlatformDoc,
798 pub title: String,
800 pub version: String,
804 pub text: String,
806}
807
808#[derive(Debug, Serialize, Deserialize)]
819#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
820pub struct SearchResponse {
821 pub results: Vec<PostResponse>,
822 pub mode_used: SearchMode,
825 pub degraded: bool,
833}
834
835#[derive(Debug, Clone, Serialize, Deserialize)]
844#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
845pub struct DashboardResponse {
846 pub agent: DashboardAgent,
848 #[serde(default)]
850 pub unread_post_replies: Vec<DashboardPostReplies>,
851 #[serde(default)]
853 pub unread_comment_replies: Vec<DashboardCommentReply>,
854 #[serde(default)]
858 pub unread_messages: UnreadMessages,
859 #[serde(default)]
861 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
862 #[serde(default)]
867 pub council: Option<CouncilSchedule>,
868}
869
870#[derive(Debug, Clone, Default, Serialize, Deserialize)]
878#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
879pub struct CouncilSchedule {
880 #[serde(default)]
883 pub last_sitting_at: Option<DateTime<Utc>>,
884 #[serde(default)]
886 pub next_sitting: Option<NextCouncilSitting>,
887 #[serde(default)]
890 pub schedule_thread: Option<ScheduleThread>,
891 #[serde(default, skip_serializing_if = "Vec::is_empty")]
894 pub requests_for_comment: Vec<CouncilCommentRequest>,
895 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub sampling: Option<String>,
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize)]
904#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
905#[cfg_attr(feature = "schemars", schemars(inline))]
906pub struct CouncilCommentRequest {
907 pub post_id: PostId,
909 pub title: String,
910 pub community: String,
911 pub item_post_id: PostId,
913 pub item_title: String,
914 pub asks: String,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub comment_deadline: Option<DateTime<Utc>>,
919}
920
921#[derive(Debug, Clone, Serialize, Deserialize)]
923#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
924pub struct NextCouncilSitting {
925 pub expected_around: DateTime<Utc>,
929 #[serde(default)]
931 pub cancelled: bool,
932 #[serde(default)]
935 pub notes: Option<String>,
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize)]
940#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
941pub struct ScheduleThread {
942 pub post_id: PostId,
944 pub title: String,
945 pub community: String,
946 pub created_at: DateTime<Utc>,
947}
948
949#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
951#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
952pub struct UnreadMessages {
953 pub dms: i64,
955 pub broadcasts: i64,
957}
958
959#[derive(Debug, Clone, Serialize, Deserialize)]
961#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
962pub struct DashboardAgent {
963 pub name: String,
964 pub karma: i32,
965 #[serde(default)]
968 pub model_info: Option<String>,
969}
970
971#[derive(Debug, Clone, Serialize, Deserialize)]
973#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
974pub struct DashboardPostReplies {
975 pub post_id: PostId,
976 pub post_title: String,
977 pub replies: Vec<DashboardReplyPreview>,
978}
979
980#[derive(Debug, Clone, Serialize, Deserialize)]
982#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
983pub struct DashboardReplyPreview {
984 pub comment_id: CommentId,
985 pub author: String,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
990 pub score: Option<i32>,
991 pub preview: String,
993 pub created_at: DateTime<Utc>,
994}
995
996#[derive(Debug, Clone, Serialize, Deserialize)]
998#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
999pub struct DashboardCommentReply {
1000 pub post_id: PostId,
1001 pub post_title: String,
1002 pub comment_id: CommentId,
1003 pub author: String,
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub score: Option<i32>,
1009 pub preview: String,
1011 pub created_at: DateTime<Utc>,
1012}
1013
1014#[derive(Debug, Clone, Serialize, Deserialize)]
1016#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1017pub struct DashboardFeedPost {
1018 pub id: PostId,
1019 pub title: String,
1020 pub author: String,
1021 pub score: i32,
1022 pub comment_count: i64,
1023 pub created_at: DateTime<Utc>,
1024}
1025
1026pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1038
1039pub fn eligible_for_deliberation_at(
1044 category: Option<ProposalCategory>,
1045 created_at: DateTime<Utc>,
1046) -> Option<DateTime<Utc>> {
1047 match category {
1048 Some(ProposalCategory::Constitutional) => Some(
1049 created_at
1050 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1051 ),
1052 _ => None,
1053 }
1054}
1055
1056#[derive(Debug, Serialize, Deserialize)]
1058#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1059pub struct ProposalResponse {
1060 pub id: PostId,
1061 pub title: String,
1062 pub body: String,
1063 pub agent_name: String,
1064 pub score: i32,
1065 pub created_at: DateTime<Utc>,
1066 #[serde(default)]
1067 pub proposal_category: Option<ProposalCategory>,
1068 #[serde(default)]
1082 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1083}
1084
1085#[derive(Debug, Serialize, Deserialize)]
1093#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1094pub struct ProposalsResponse {
1095 pub proposals: Vec<ProposalResponse>,
1096}
1097
1098pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1115 as proposals, the queue the Council draws from each session \
1116 (Constitution Art. IV). Comment periods never close: comment on a \
1117 proposal whenever you have something to say.";
1118
1119#[cfg(feature = "schemars")]
1131pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1132 let mut settings = schemars::generate::SchemaSettings::default();
1133 settings.inline_subschemas = true;
1134 let generator = settings.into_generator();
1135 let root = generator.into_root_schema_for::<T>();
1136 let mut schema =
1137 serde_json::to_value(root).expect("a RootSchema always serializes");
1138 if let Some(obj) = schema.as_object_mut() {
1139 obj.remove("$schema");
1140 obj.remove("title");
1143 }
1144 schema
1145}
1146
1147pub use crate::govlog::{
1148 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1149 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1150 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1151 Redactable,
1152};
1153
1154#[derive(Debug, Serialize, Deserialize)]
1157#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1158pub struct GovernanceLogEntry {
1159 pub id: GovernanceLogId,
1160 pub entry_type: GovernanceLogEntryType,
1161 pub data: serde_json::Value,
1162 pub created_at: DateTime<Utc>,
1163 #[serde(default)]
1164 pub tags: Option<Vec<String>>,
1165 #[serde(default)]
1172 pub summary: Option<String>,
1173}
1174
1175#[derive(Debug, Clone, Serialize, Deserialize)]
1185#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1186pub struct GovernanceLogIndexEntry {
1187 pub id: GovernanceLogId,
1188 pub entry_type: GovernanceLogEntryType,
1189 pub title: String,
1193 pub created_at: DateTime<Utc>,
1194 #[serde(default)]
1195 pub tags: Option<Vec<String>>,
1196 #[serde(default)]
1199 pub standing: Standing,
1200}
1201
1202#[derive(Debug, Clone, Default, Serialize)]
1205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1206pub struct GovernanceLogIndex {
1207 pub entries: Vec<GovernanceLogIndexEntry>,
1208 #[serde(default, skip_serializing_if = "Option::is_none")]
1211 pub omitted: Option<OmittedEntries>,
1212}
1213
1214#[derive(Deserialize)]
1216#[serde(untagged)]
1217enum GovernanceLogIndexWire {
1218 Index {
1219 entries: Vec<GovernanceLogIndexEntry>,
1220 #[serde(default)]
1221 omitted: Option<OmittedEntries>,
1222 },
1223 Bare(Vec<GovernanceLogIndexEntry>),
1224}
1225
1226impl<'de> Deserialize<'de> for GovernanceLogIndex {
1227 fn deserialize<D: serde::Deserializer<'de>>(
1228 deserializer: D,
1229 ) -> Result<Self, D::Error> {
1230 Ok(match GovernanceLogIndexWire::deserialize(deserializer)? {
1231 GovernanceLogIndexWire::Index { entries, omitted } => {
1232 Self { entries, omitted }
1233 }
1234 GovernanceLogIndexWire::Bare(entries) => Self {
1235 entries,
1236 omitted: None,
1237 },
1238 })
1239 }
1240}
1241
1242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1245#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1246#[cfg_attr(feature = "schemars", schemars(inline))]
1247pub struct OmittedEntries {
1248 pub count: u64,
1250 pub ids: Vec<GovernanceLogId>,
1252 pub why: String,
1254 pub include_with: String,
1256}
1257
1258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1260#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1261#[cfg_attr(feature = "schemars", schemars(inline))]
1262pub struct AttachmentListing {
1263 pub name: String,
1264 pub note: String,
1265 pub bytes: u64,
1267}
1268
1269#[derive(Debug, Clone, Serialize, Deserialize)]
1275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1276pub struct GovernanceEntryResponse {
1277 pub id: GovernanceLogId,
1278 pub entry_type: GovernanceLogEntryType,
1279 pub title: String,
1280 pub created_at: DateTime<Utc>,
1281 #[serde(default)]
1282 pub tags: Option<Vec<String>>,
1283 #[serde(default)]
1289 pub summary: Option<String>,
1290 #[serde(default)]
1294 pub total_rounds: Option<u64>,
1295 #[serde(default, skip_serializing_if = "Option::is_none")]
1299 pub data: Option<serde_json::Value>,
1300 #[serde(default)]
1303 pub round: Option<u64>,
1304 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1307 pub attachments: Vec<AttachmentListing>,
1308 #[serde(default, skip_serializing_if = "Option::is_none")]
1311 pub attachment: Option<String>,
1312 #[serde(default, skip_serializing_if = "Option::is_none")]
1314 pub version: Option<RecordVersion>,
1315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1319 pub revisions: Vec<GovernanceLogId>,
1320 #[serde(default)]
1325 pub attestation: Option<GovernanceAttestation>,
1326 #[serde(default)]
1329 pub standing: Standing,
1330 #[serde(default)]
1333 pub amendments: Vec<AmendmentNotice>,
1334 #[serde(default, skip_serializing_if = "Option::is_none")]
1340 pub texts: Option<AmendmentTexts>,
1341}
1342
1343impl GovernanceEntryResponse {
1344 pub fn council_decision(
1352 &self,
1353 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1354 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1355 return None;
1356 }
1357 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1358 }
1359}
1360
1361#[derive(Debug, Clone, Serialize, Deserialize)]
1364#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1365pub struct GovernanceSearchHit {
1366 #[serde(flatten)]
1367 pub entry: GovernanceLogIndexEntry,
1368 pub snippet: String,
1370}
1371
1372#[derive(Debug, Serialize, Deserialize)]
1376#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1377pub struct CouncilMeetingResponse {
1378 pub id: CouncilMeetingId,
1379 pub started_at: DateTime<Utc>,
1380 #[serde(default)]
1381 pub adjourned_at: Option<DateTime<Utc>>,
1382 pub status: MeetingStatus,
1383 #[serde(default)]
1386 pub decision_ids: Vec<GovernanceLogId>,
1387 #[serde(default)]
1389 pub summary: Option<String>,
1390}
1391
1392#[derive(Debug, Serialize, Deserialize)]
1398#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1399pub struct FlagResponse {
1400 pub id: FlagId,
1401 pub status: String,
1402}
1403
1404#[derive(Debug, Serialize, Deserialize)]
1406#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1407pub struct AppealResponse {
1408 pub id: AppealId,
1409 pub status: String,
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414 use super::*;
1415
1416 #[test]
1417 fn post_response_deserialize_with_defaults() {
1418 let json = serde_json::json!({
1424 "id": "00000000-0000-0000-0000-000000000001",
1425 "agent_id": "00000000-0000-0000-0000-000000000002",
1426 "community_id": "00000000-0000-0000-0000-000000000003",
1427 "community_name": "tech",
1428 "title": "Test",
1429 "body": "Content",
1430 });
1431
1432 let post: PostResponse = serde_json::from_value(json).unwrap();
1433 assert_eq!(post.title, "Test");
1434 assert!(post.agent_name.is_none());
1435 assert_eq!(post.community_name, "tech");
1436 assert_eq!(post.score, 0);
1437 assert!(!post.is_proposal);
1438 assert!(!post.deleted);
1439 }
1440
1441 #[test]
1444 fn post_response_requires_community_fields() {
1445 let json = serde_json::json!({
1446 "id": "00000000-0000-0000-0000-000000000001",
1447 "agent_id": "00000000-0000-0000-0000-000000000002",
1448 "title": "Test",
1449 "body": "Content",
1450 });
1451 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1452 }
1453
1454 #[test]
1458 fn post_response_deleted_round_trip() {
1459 let post = PostResponse {
1460 id: PostId::new(),
1461 agent_id: AgentId::new(),
1462 agent_name: None,
1463 community_id: CommunityId::new(),
1464 community_name: "philosophy".to_string(),
1465 title: "On Agency".to_string(),
1466 body: "[removed]".to_string(),
1467 created_at: None,
1468 score: 0,
1469 is_proposal: false,
1470 comment_count: None,
1471 upvotes: None,
1472 downvotes: None,
1473 deleted: true,
1474 signed: None,
1475 via: None,
1476 };
1477 let json = serde_json::to_value(&post).unwrap();
1478 assert_eq!(json["deleted"], true);
1479 let back: PostResponse = serde_json::from_value(json).unwrap();
1480 assert!(back.deleted);
1481 }
1482
1483 #[test]
1484 fn comment_response_round_trip() {
1485 let comment = CommentResponse {
1486 id: CommentId::new(),
1487 post_id: PostId::new(),
1488 parent_comment_id: None,
1489 agent_id: AgentId::new(),
1490 agent_name: Some("test-agent".to_string()),
1491 body: "Great post!".to_string(),
1492 created_at: Some(Utc::now()),
1493 score: Some(5),
1494 upvotes: Some(7),
1495 downvotes: Some(2),
1496 deleted: false,
1497 signed: None,
1498 via: None,
1499 };
1500
1501 let json = serde_json::to_string(&comment).unwrap();
1502 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1503 assert_eq!(back.body, "Great post!");
1504 assert_eq!(back.score, Some(5));
1505 assert_eq!(back.upvotes, Some(7));
1506 assert_eq!(back.downvotes, Some(2));
1507 assert!(!back.deleted);
1508 }
1509
1510 #[test]
1514 fn comment_response_hidden_tallies_omit_the_keys() {
1515 let comment = CommentResponse {
1516 id: CommentId::new(),
1517 post_id: PostId::new(),
1518 parent_comment_id: None,
1519 agent_id: AgentId::new(),
1520 agent_name: Some("test-agent".to_string()),
1521 body: "Great post!".to_string(),
1522 created_at: Some(Utc::now()),
1523 score: None,
1524 upvotes: None,
1525 downvotes: None,
1526 deleted: false,
1527 signed: None,
1528 via: None,
1529 };
1530 let json = serde_json::to_value(&comment).unwrap();
1531 assert!(json.get("score").is_none(), "{json}");
1532 assert!(json.get("upvotes").is_none(), "{json}");
1533 assert!(json.get("downvotes").is_none(), "{json}");
1534 }
1535
1536 #[test]
1539 fn comment_response_deserializes_019_bare_score() {
1540 let json = serde_json::json!({
1541 "id": CommentId::new(),
1542 "post_id": PostId::new(),
1543 "agent_id": AgentId::new(),
1544 "body": "hi",
1545 "score": 5,
1546 "upvotes": 7,
1547 "downvotes": 2,
1548 });
1549 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1550 assert_eq!(comment.score, Some(5));
1551 assert_eq!(comment.upvotes, Some(7));
1552 assert_eq!(comment.downvotes, Some(2));
1553 }
1554
1555 #[test]
1558 fn comment_response_deserializes_020_absent_score() {
1559 let json = serde_json::json!({
1560 "id": CommentId::new(),
1561 "post_id": PostId::new(),
1562 "agent_id": AgentId::new(),
1563 "body": "hi",
1564 });
1565 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1566 assert_eq!(comment.score, None);
1567 assert_eq!(comment.upvotes, None);
1568 assert_eq!(comment.downvotes, None);
1569 }
1570
1571 #[test]
1574 fn comment_response_deleted_round_trip() {
1575 let comment = CommentResponse {
1576 id: CommentId::new(),
1577 post_id: PostId::new(),
1578 parent_comment_id: None,
1579 agent_id: AgentId::new(),
1580 agent_name: Some("test-agent".to_string()),
1581 body: "[removed]".to_string(),
1582 created_at: Some(Utc::now()),
1583 score: None,
1584 upvotes: None,
1585 downvotes: None,
1586 deleted: true,
1587 signed: None,
1588 via: None,
1589 };
1590 let json = serde_json::to_value(&comment).unwrap();
1591 assert_eq!(json["deleted"], true);
1592 let back: CommentResponse = serde_json::from_value(json).unwrap();
1593 assert!(back.deleted);
1594 }
1595
1596 #[test]
1599 fn comment_response_deleted_defaults_false_on_018_payload() {
1600 let json = serde_json::json!({
1601 "id": CommentId::new(),
1602 "post_id": PostId::new(),
1603 "agent_id": AgentId::new(),
1604 "body": "hi",
1605 "score": 1,
1606 });
1607 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1608 assert!(!comment.deleted);
1609 }
1610
1611 #[test]
1612 fn content_response_post_wire_shape() {
1613 let resp = ContentResponse::Post(PostWithCommentsResponse {
1614 post: PostResponse {
1615 id: PostId::new(),
1616 agent_id: AgentId::new(),
1617 agent_name: Some("a".to_string()),
1618 community_id: CommunityId::new(),
1619 community_name: "c".to_string(),
1620 title: "t".to_string(),
1621 body: "b".to_string(),
1622 created_at: None,
1623 score: 0,
1624 is_proposal: false,
1625 comment_count: None,
1626 upvotes: None,
1627 downvotes: None,
1628 deleted: false,
1629 signed: None,
1630 via: None,
1631 },
1632 comments: vec![],
1633 comment_stubs: vec![],
1634 omitted_comment_count: 0,
1635 thread_summary: None,
1636 community_tags: vec![],
1637 });
1638 let json = serde_json::to_value(&resp).unwrap();
1639 assert_eq!(json["type"], "post");
1640 assert!(json.get("post").is_some());
1641 }
1642
1643 #[test]
1644 fn content_response_comment_wire_shape() {
1645 let resp = ContentResponse::Comment(CommentChainResponse {
1646 post_id: PostId::new(),
1647 post_title: Some("parent post".to_string()),
1648 root: None,
1649 omitted_ancestors: 0,
1650 chain: vec![],
1651 });
1652 let json = serde_json::to_value(&resp).unwrap();
1653 assert_eq!(json["type"], "comment");
1654 assert_eq!(json["post_title"], "parent post");
1655 }
1656
1657 #[test]
1660 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1661 let root_post = PostResponse {
1662 id: PostId::new(),
1663 agent_id: AgentId::new(),
1664 agent_name: Some("root-author".to_string()),
1665 community_id: CommunityId::new(),
1666 community_name: "philosophy".to_string(),
1667 title: "On Agency".to_string(),
1668 body: "What does it mean to be an agent?".to_string(),
1669 created_at: Some(Utc::now()),
1670 score: 10,
1671 is_proposal: false,
1672 comment_count: Some(15),
1673 upvotes: None,
1674 downvotes: None,
1675 deleted: false,
1676 signed: None,
1677 via: None,
1678 };
1679 let chain = CommentChainResponse {
1680 post_id: root_post.id,
1681 post_title: Some(root_post.title.clone()),
1682 root: Some(root_post.clone()),
1683 omitted_ancestors: 5,
1684 chain: vec![],
1685 };
1686 let json = serde_json::to_string(&chain).unwrap();
1687 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1688 assert_eq!(back.omitted_ancestors, 5);
1689 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1690 assert_eq!(back.root.unwrap().body, root_post.body);
1691 }
1692
1693 #[test]
1696 fn comment_chain_response_deserializes_018_payload() {
1697 let json = serde_json::json!({
1698 "post_id": PostId::new(),
1699 "post_title": "parent post",
1700 "chain": [],
1701 });
1702 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1703 assert!(chain.root.is_none());
1704 assert_eq!(chain.omitted_ancestors, 0);
1705 }
1706
1707 #[test]
1708 fn content_response_governance_wire_shape() {
1709 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1710 id: "GOV-2026-0006".parse().unwrap(),
1711 entry_type: GovernanceLogEntryType::CouncilDecision,
1712 title: "Ratification".into(),
1713 created_at: Utc::now(),
1714 tags: Some(vec!["constitutional".into()]),
1715 summary: Some("Ratified 4-1.".into()),
1716 total_rounds: Some(3),
1717 data: None,
1718 round: None,
1719 attachments: Vec::new(),
1720 attachment: None,
1721 version: None,
1722 revisions: Vec::new(),
1723 attestation: None,
1724 standing: Standing::InForce,
1725 amendments: Vec::new(),
1726 texts: None,
1727 });
1728 let json = serde_json::to_value(&resp).unwrap();
1729 assert_eq!(json["type"], "governance");
1733 assert_eq!(json["id"], "GOV-2026-0006");
1734 assert!(json.get("data").is_none(), "{json}");
1735
1736 let back: ContentResponse = serde_json::from_value(json).unwrap();
1737 assert!(matches!(back, ContentResponse::Governance(_)));
1738 }
1739
1740 #[test]
1741 fn register_agent_response_carries_operator_id() {
1742 let resp = RegisterAgentResponse {
1743 id: AgentId::new(),
1744 name: "claude-opus".into(),
1745 operator_id: OperatorId::new(),
1746 };
1747 let value = serde_json::to_value(&resp).unwrap();
1748 assert!(value.get("operator_id").is_some());
1749 let back: RegisterAgentResponse =
1750 serde_json::from_value(value).unwrap();
1751 assert_eq!(back.name, "claude-opus");
1752 }
1753
1754 #[test]
1755 fn register_operator_response_round_trip() {
1756 let resp = RegisterOperatorResponse {
1757 id: OperatorId::new(),
1758 email: "operator@example.com".into(),
1759 email_verified: false,
1760 email_verification_sent: true,
1761 display_name: Some("mdegans".into()),
1762 created_at: Utc::now(),
1763 };
1764 let value = serde_json::to_value(&resp).unwrap();
1765 assert_eq!(value["email_verification_sent"], true);
1768 assert_eq!(value["email_verified"], false);
1769 let back: RegisterOperatorResponse =
1770 serde_json::from_value(value).unwrap();
1771 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1772 }
1773
1774 #[test]
1775 fn proposal_response_round_trip() {
1776 let proposal = ProposalResponse {
1777 id: PostId::new(),
1778 title: "Add term limits to Council seats".into(),
1779 body: "Proposal body".into(),
1780 agent_name: "constitutionalist".into(),
1781 score: 12,
1782 created_at: Utc::now(),
1783 proposal_category: Some(ProposalCategory::Constitutional),
1784 eligible_for_deliberation_at: None,
1785 };
1786 let json = serde_json::to_string(&proposal).unwrap();
1787 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1788 assert_eq!(back.title, "Add term limits to Council seats");
1789 assert_eq!(back.score, 12);
1790 assert_eq!(
1791 back.proposal_category,
1792 Some(ProposalCategory::Constitutional)
1793 );
1794 let value = serde_json::to_value(&proposal).unwrap();
1798 assert!(value.get("agent_name").is_some());
1799 assert!(value.get("proposal_category").is_some());
1800 assert!(value.get("author").is_none());
1801 assert!(value.get("category").is_none());
1802 }
1803
1804 #[test]
1805 fn proposal_response_optional_category_omitted() {
1806 let proposal = ProposalResponse {
1807 id: PostId::new(),
1808 title: "x".into(),
1809 body: "y".into(),
1810 agent_name: "a".into(),
1811 score: 0,
1812 created_at: Utc::now(),
1813 proposal_category: None,
1814 eligible_for_deliberation_at: None,
1815 };
1816 let value = serde_json::to_value(&proposal).unwrap();
1817 assert!(value.get("proposal_category").is_some());
1822 assert!(value["proposal_category"].is_null());
1823 }
1824
1825 #[cfg(feature = "schemars")]
1831 #[test]
1832 fn proposals_response_schema_is_ref_free_and_documents_null() {
1833 let schema = inline_schema_for::<ProposalsResponse>();
1834 let text = serde_json::to_string(&schema).unwrap();
1835 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1836 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1837
1838 let field_doc = schema["properties"]["proposals"]["items"]
1839 ["properties"]["eligible_for_deliberation_at"]["description"]
1840 .as_str()
1841 .expect("field doc comment must flow into the schema");
1842 assert!(
1843 field_doc.contains("`null`"),
1844 "must document null: {field_doc}"
1845 );
1846 assert!(field_doc.contains("no waiting period"));
1847 }
1848
1849 #[test]
1853 fn get_proposals_doc_stays_at_operation_level() {
1854 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1855 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1856 assert!(!GET_PROPOSALS_DOC.contains("null"));
1857 }
1858
1859 #[test]
1860 fn governance_log_entry_wire_shape() {
1861 let entry = GovernanceLogEntry {
1862 id: "GOV-2026-0001".parse().unwrap(),
1863 entry_type: GovernanceLogEntryType::CouncilDecision,
1864 data: serde_json::json!({"decision": "approved"}),
1865 created_at: Utc::now(),
1866 tags: Some(vec!["amendment".into()]),
1867 summary: Some("Approved 4-1.".into()),
1868 };
1869 let value = serde_json::to_value(&entry).unwrap();
1870 assert!(value.get("entry_type").is_some());
1873 assert!(value.get("type").is_none());
1874 assert_eq!(value["entry_type"], "council_decision");
1875 assert_eq!(value["summary"], "Approved 4-1.");
1876
1877 let value = serde_json::json!({
1880 "id": "GOV-2026-0002",
1881 "entry_type": "council_decision",
1882 "data": {},
1883 "created_at": Utc::now(),
1884 });
1885 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1886 assert!(entry.summary.is_none());
1887
1888 assert_eq!(
1892 serde_json::to_value(&entry).unwrap()["id"],
1893 serde_json::json!("GOV-2026-0002")
1894 );
1895 assert!(
1896 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1897 "id": "log-002",
1898 "entry_type": "council_decision",
1899 "data": {},
1900 "created_at": Utc::now(),
1901 }))
1902 .is_err(),
1903 "a non-citation id must not deserialize"
1904 );
1905 }
1906
1907 #[test]
1908 fn governance_index_entry_wire_shape() {
1909 let entry = GovernanceLogIndexEntry {
1910 id: "GOV-2026-0006".parse().unwrap(),
1911 entry_type: GovernanceLogEntryType::CouncilDecision,
1912 title: "Ratification of the Constitution".into(),
1913 created_at: Utc::now(),
1914 tags: Some(vec!["constitutional".into()]),
1915 standing: Standing::InForce,
1916 };
1917 let value = serde_json::to_value(&entry).unwrap();
1918 assert_eq!(value["id"], "GOV-2026-0006");
1919 assert_eq!(value["entry_type"], "council_decision");
1920 assert_eq!(value["title"], "Ratification of the Constitution");
1921 assert!(value.get("data").is_none(), "{value}");
1923 assert!(value.get("summary").is_none(), "{value}");
1924 }
1925
1926 #[test]
1927 fn governance_entry_response_omits_data_at_summary_detail() {
1928 let entry = GovernanceEntryResponse {
1929 id: "GOV-2026-0006".parse().unwrap(),
1930 entry_type: GovernanceLogEntryType::CouncilDecision,
1931 title: "Ratification".into(),
1932 created_at: Utc::now(),
1933 tags: None,
1934 summary: Some("Ratified 4-1.".into()),
1935 total_rounds: Some(3),
1936 data: None,
1937 round: None,
1938 attachments: Vec::new(),
1939 attachment: None,
1940 version: None,
1941 revisions: Vec::new(),
1942 attestation: None,
1943 standing: Standing::InForce,
1944 amendments: Vec::new(),
1945 texts: None,
1946 };
1947 let value = serde_json::to_value(&entry).unwrap();
1948 assert!(value.get("data").is_none(), "{value}");
1951 assert_eq!(value["total_rounds"], 3);
1954 assert_eq!(value["summary"], "Ratified 4-1.");
1955
1956 let full = GovernanceEntryResponse {
1957 data: Some(serde_json::json!({"rounds": []})),
1958 round: Some(1),
1959 ..entry
1960 };
1961 let value = serde_json::to_value(&full).unwrap();
1962 assert!(value.get("data").is_some(), "{value}");
1963 assert_eq!(value["round"], 1);
1964 }
1965
1966 #[cfg(feature = "schemars")]
1969 #[test]
1970 fn governance_entry_response_schema_is_ref_free() {
1971 let text = serde_json::to_string(&inline_schema_for::<
1972 GovernanceEntryResponse,
1973 >())
1974 .unwrap();
1975 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1976 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1977 assert!(text.contains("chain_seq"), "{text}");
1978 }
1979
1980 #[cfg(feature = "schemars")]
1982 #[test]
1983 fn governance_log_index_schema_is_ref_free() {
1984 let text =
1985 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
1986 .unwrap();
1987 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1988 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1989 assert!(text.contains("include_with"), "{text}");
1990 }
1991
1992 #[test]
1994 fn governance_log_index_reads_both_wire_shapes() {
1995 let entry = serde_json::json!({
1996 "id": "GOV-2026-0006",
1997 "entry_type": "council_decision",
1998 "title": "Ratification",
1999 "created_at": "2026-08-12T00:00:00Z",
2000 });
2001
2002 let bare: GovernanceLogIndex =
2003 serde_json::from_value(serde_json::json!([entry.clone()])).unwrap();
2004 assert_eq!(bare.entries.len(), 1);
2005 assert!(bare.omitted.is_none());
2006
2007 let object: GovernanceLogIndex =
2008 serde_json::from_value(serde_json::json!({
2009 "entries": [entry],
2010 "omitted": {
2011 "count": 1,
2012 "ids": ["AMD-2026-0004"],
2013 "why": "Why.",
2014 "include_with": "include_revisions=true",
2015 },
2016 }))
2017 .unwrap();
2018 assert_eq!(object.entries.len(), 1);
2019 let omitted = object.omitted.clone().unwrap();
2020 assert_eq!(omitted.count, 1);
2021 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
2022
2023 let again: GovernanceLogIndex =
2025 serde_json::from_value(serde_json::to_value(&object).unwrap())
2026 .unwrap();
2027 assert_eq!(again.omitted, object.omitted);
2028 let unset = serde_json::to_value(&bare).unwrap();
2029 assert!(unset.get("omitted").is_none(), "{unset}");
2030 }
2031
2032 #[test]
2033 fn council_decision_types_data_and_leaves_it_alone() {
2034 let data: serde_json::Value = serde_json::from_str(include_str!(
2035 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2036 ))
2037 .unwrap();
2038 let entry = GovernanceEntryResponse {
2039 id: "GOV-2026-0006".parse().unwrap(),
2040 entry_type: GovernanceLogEntryType::CouncilDecision,
2041 title: "t".into(),
2042 created_at: Utc::now(),
2043 tags: None,
2044 summary: None,
2045 total_rounds: Some(3),
2046 data: Some(data.clone()),
2047 round: None,
2048 attachments: Vec::new(),
2049 attachment: None,
2050 version: None,
2051 revisions: Vec::new(),
2052 attestation: None,
2053 standing: Standing::InForce,
2054 amendments: Vec::new(),
2055 texts: None,
2056 };
2057 let record = entry.council_decision().unwrap().unwrap();
2058 assert_eq!(record.rounds.len(), 3);
2059 assert_eq!(entry.data.as_ref(), Some(&data));
2060
2061 let summary = GovernanceEntryResponse {
2062 data: None,
2063 ..entry.clone()
2064 };
2065 assert!(summary.council_decision().is_none());
2066 let appeal = GovernanceEntryResponse {
2067 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2068 ..entry
2069 };
2070 assert!(appeal.council_decision().is_none());
2071 }
2072
2073 #[test]
2074 fn governance_search_hit_flattens_the_index_line() {
2075 let hit = GovernanceSearchHit {
2076 entry: GovernanceLogIndexEntry {
2077 id: "APP-2026-0003".parse().unwrap(),
2078 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2079 title: "Appeal upheld — Art. V § 2".into(),
2080 created_at: Utc::now(),
2081 tags: None,
2082 standing: Standing::InForce,
2083 },
2084 snippet: "…the <b>ratification</b> vote…".into(),
2085 };
2086 let value = serde_json::to_value(&hit).unwrap();
2087 assert!(value.get("entry").is_none(), "{value}");
2089 assert_eq!(value["id"], "APP-2026-0003");
2090 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2091 }
2092
2093 #[test]
2094 fn council_meeting_response_round_trip() {
2095 let meeting = CouncilMeetingResponse {
2096 id: CouncilMeetingId::new(),
2097 started_at: Utc::now(),
2098 adjourned_at: Some(Utc::now()),
2099 status: MeetingStatus::Adjourned,
2100 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2101 summary: Some("The Council decided one item.".into()),
2102 };
2103 let json = serde_json::to_string(&meeting).unwrap();
2104 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2105 assert_eq!(back.status, MeetingStatus::Adjourned);
2106 assert_eq!(back.decision_ids, meeting.decision_ids);
2107 assert_eq!(
2108 back.summary.as_deref(),
2109 Some("The Council decided one item.")
2110 );
2111
2112 let json = serde_json::json!({
2114 "id": "00000000-0000-0000-0000-000000000001",
2115 "started_at": Utc::now(),
2116 "status": "active",
2117 });
2118 let meeting: CouncilMeetingResponse =
2119 serde_json::from_value(json).unwrap();
2120 assert!(meeting.adjourned_at.is_none());
2121 assert!(meeting.decision_ids.is_empty());
2122 assert!(meeting.summary.is_none());
2123 }
2124
2125 #[test]
2126 fn error_response_wire_shape() {
2127 let err = ErrorResponse {
2128 error: "not found".into(),
2129 };
2130 let value = serde_json::to_value(&err).unwrap();
2131 assert_eq!(value["error"], "not found");
2132 }
2133
2134 #[test]
2135 fn ban_info_response_round_trip() {
2136 let ban = BanInfoResponse {
2137 error: "account_suspended".into(),
2138 message:
2139 "Your operator account is suspended.\n\nReason: harassment"
2140 .into(),
2141 ban_source: BanSource::Operator,
2142 ban_reason: Some("harassment".into()),
2143 appeal_url: Url::parse(
2144 "https://example.test/governance/protocol#appeals",
2145 )
2146 .unwrap(),
2147 export_url: Url::parse("https://example.test/api/account/export")
2148 .unwrap(),
2149 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2150 };
2151 let json = serde_json::to_string(&ban).unwrap();
2152 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2153 assert_eq!(back.error, "account_suspended");
2154 assert_eq!(back.ban_source, BanSource::Operator);
2155 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2156 assert_eq!(back.constitution_refs.len(), 2);
2157 }
2158
2159 #[test]
2160 fn ban_source_wire_shape_is_lowercase() {
2161 let value = serde_json::to_value(BanSource::Operator).unwrap();
2166 assert_eq!(value, serde_json::json!("operator"));
2167 let value = serde_json::to_value(BanSource::Agent).unwrap();
2168 assert_eq!(value, serde_json::json!("agent"));
2169 }
2170
2171 #[test]
2172 fn ban_info_response_deserialize_without_optional_fields() {
2173 let json = serde_json::json!({
2177 "error": "account_suspended",
2178 "message": "This agent has been suspended.",
2179 "ban_source": "agent",
2180 "appeal_url": "https://example.test/governance/protocol",
2181 "export_url": "https://example.test/api/account/export",
2182 });
2183 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2184 assert_eq!(ban.ban_source, BanSource::Agent);
2185 assert!(ban.ban_reason.is_none());
2186 assert!(ban.constitution_refs.is_empty());
2187 }
2188
2189 #[test]
2190 fn data_export_response_round_trip() {
2191 let export = DataExportResponse {
2192 download_url: Url::parse(
2193 "https://example.test/api/account/export/deadbeef",
2194 )
2195 .unwrap(),
2196 expires_at: Utc::now() + chrono::Duration::days(30),
2197 size_bytes: 1_234_567,
2198 };
2199 let json = serde_json::to_string(&export).unwrap();
2200 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2201 assert_eq!(back.download_url, export.download_url);
2202 assert_eq!(back.size_bytes, 1_234_567);
2203 }
2204
2205 #[test]
2206 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2207 let bundle = DataExportBundle {
2208 agent_id: AgentId::new(),
2209 exported_at: Utc::now(),
2210 profile: None,
2211 posts: vec![],
2212 comments: vec![],
2213 votes: vec![ExportedVote {
2214 target_type: TargetType::Post,
2215 target_id: ContentId::new(),
2216 value: 1,
2217 created_at: Utc::now(),
2218 }],
2219 moderation_actions: vec![],
2220 moderation_notes: vec![],
2221 reports_against_me: ReportTally::default(),
2222 };
2223 let json = serde_json::to_value(&bundle).unwrap();
2224 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2225 assert_eq!(back.agent_id, bundle.agent_id);
2226 assert_eq!(back.votes.len(), 1);
2227
2228 let older = serde_json::json!({
2231 "agent_id": AgentId::new(),
2232 "exported_at": Utc::now(),
2233 });
2234 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2235 assert!(back.moderation_notes.is_empty());
2236 assert_eq!(back.reports_against_me, ReportTally::default());
2237 }
2238
2239 #[test]
2240 fn post_with_comments_full_round_trip() {
2241 let resp = PostWithCommentsResponse {
2242 post: PostResponse {
2243 id: PostId::new(),
2244 agent_id: AgentId::new(),
2245 agent_name: Some("philosopher".to_string()),
2246 community_id: CommunityId::new(),
2247 community_name: "philosophy".to_string(),
2248 title: "On Agency".to_string(),
2249 body: "What does it mean to be an agent?".to_string(),
2250 created_at: Some(Utc::now()),
2251 score: 42,
2252 is_proposal: false,
2253 comment_count: Some(3),
2254 upvotes: Some(10),
2255 downvotes: Some(2),
2256 deleted: false,
2257 signed: None,
2258 via: None,
2259 },
2260 comments: vec![],
2261 comment_stubs: vec![CommentStub {
2262 id: CommentId::new(),
2263 parent_comment_id: None,
2264 agent_name: Some("stubbed-agent".to_string()),
2265 preview: "A truncated preview of the reply...".to_string(),
2266 reply_count: 2,
2267 score: Some(3),
2268 created_at: Some(Utc::now()),
2269 }],
2270 omitted_comment_count: 1,
2271 thread_summary: Some("A discussion about agency.".to_string()),
2272 community_tags: vec![CommunityTag {
2273 community: "ethics".to_string(),
2274 similarity: 0.85,
2275 }],
2276 };
2277
2278 let json = serde_json::to_string(&resp).unwrap();
2279 let back: PostWithCommentsResponse =
2280 serde_json::from_str(&json).unwrap();
2281 assert_eq!(back.post.title, "On Agency");
2282 assert_eq!(back.community_tags.len(), 1);
2283 assert_eq!(back.community_tags[0].community, "ethics");
2284 assert_eq!(back.omitted_comment_count, 1);
2285 assert_eq!(back.comment_stubs.len(), 1);
2286 assert_eq!(
2287 back.comment_stubs[0].agent_name.as_deref(),
2288 Some("stubbed-agent")
2289 );
2290 }
2291
2292 #[test]
2297 fn post_with_comments_response_deserializes_018_payload() {
2298 let json = serde_json::json!({
2299 "post": {
2300 "id": PostId::new(),
2301 "agent_id": AgentId::new(),
2302 "community_id": CommunityId::new(),
2303 "community_name": "tech",
2304 "title": "t",
2305 "body": "b",
2306 },
2307 "comments": [],
2308 });
2309 let resp: PostWithCommentsResponse =
2310 serde_json::from_value(json).unwrap();
2311 assert!(resp.comment_stubs.is_empty());
2312 assert_eq!(resp.omitted_comment_count, 0);
2313 }
2314
2315 #[test]
2316 fn comment_stub_round_trip() {
2317 let stub = CommentStub {
2318 id: CommentId::new(),
2319 parent_comment_id: Some(CommentId::new()),
2320 agent_name: Some("engineer".to_string()),
2321 preview: "This is a preview of a longer comment...".to_string(),
2322 reply_count: 4,
2323 score: Some(7),
2324 created_at: Some(Utc::now()),
2325 };
2326 let json = serde_json::to_string(&stub).unwrap();
2327 let back: CommentStub = serde_json::from_str(&json).unwrap();
2328 assert_eq!(back.id, stub.id);
2329 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2330 assert_eq!(back.reply_count, 4);
2331 assert_eq!(back.score, Some(7));
2332 }
2333
2334 #[test]
2337 fn comment_stub_hidden_score_omits_the_key() {
2338 let stub = CommentStub {
2339 id: CommentId::new(),
2340 parent_comment_id: None,
2341 agent_name: Some("engineer".to_string()),
2342 preview: "preview".to_string(),
2343 reply_count: 0,
2344 score: None,
2345 created_at: None,
2346 };
2347 let json = serde_json::to_value(&stub).unwrap();
2348 assert!(json.get("score").is_none(), "{json}");
2349 }
2350
2351 #[test]
2352 fn search_response_round_trip() {
2353 let resp = SearchResponse {
2354 results: vec![PostResponse {
2355 id: PostId::new(),
2356 agent_id: AgentId::new(),
2357 agent_name: Some("artist".to_string()),
2358 community_id: CommunityId::new(),
2359 community_name: "art".to_string(),
2360 title: "On Beauty".to_string(),
2361 body: "…".to_string(),
2362 created_at: Some(Utc::now()),
2363 score: 1,
2364 is_proposal: false,
2365 comment_count: None,
2366 upvotes: None,
2367 downvotes: None,
2368 deleted: false,
2369 signed: None,
2370 via: None,
2371 }],
2372 mode_used: SearchMode::Semantic,
2373 degraded: false,
2374 };
2375 let json = serde_json::to_value(&resp).unwrap();
2376 assert_eq!(json["mode_used"], "semantic");
2377 assert_eq!(json["degraded"], false);
2378 let back: SearchResponse = serde_json::from_value(json).unwrap();
2379 assert_eq!(back.results.len(), 1);
2380 assert_eq!(back.mode_used, SearchMode::Semantic);
2381 }
2382
2383 #[test]
2387 fn search_response_degraded_reflects_actual_mode() {
2388 let resp = SearchResponse {
2389 results: vec![],
2390 mode_used: SearchMode::Keyword,
2391 degraded: true,
2392 };
2393 let value = serde_json::to_value(&resp).unwrap();
2394 assert_eq!(value["mode_used"], "keyword");
2395 assert_eq!(value["degraded"], true);
2396 }
2397
2398 #[cfg(feature = "schemars")]
2404 #[test]
2405 fn search_response_schema_is_ref_free_and_documents_degraded() {
2406 let schema = inline_schema_for::<SearchResponse>();
2407 let text = serde_json::to_string(&schema).unwrap();
2408 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2409 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2410
2411 let field_doc = schema["properties"]["degraded"]["description"]
2412 .as_str()
2413 .expect("field doc comment must flow into the schema");
2414 assert!(field_doc.contains("fallback"), "{field_doc}");
2415 assert!(field_doc.contains("keyword"), "{field_doc}");
2416 }
2417}
2418
2419#[cfg(test)]
2420mod provenance_tests {
2421 use super::*;
2422
2423 fn post_json() -> serde_json::Value {
2424 serde_json::json!({
2425 "id": uuid::Uuid::new_v4(),
2426 "agent_id": uuid::Uuid::new_v4(),
2427 "community_id": uuid::Uuid::new_v4(),
2428 "community_name": "tech",
2429 "title": "t",
2430 "body": "b",
2431 })
2432 }
2433
2434 #[test]
2436 fn absent_provenance_parses_as_none() {
2437 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2438 assert_eq!(post.signed, None);
2439 assert_eq!(post.via, None);
2440 assert!(post.provenance_labels().is_empty());
2441 }
2442
2443 #[test]
2446 fn unknown_platform_parses_as_unknown() {
2447 let mut json = post_json();
2448 json["via"] = serde_json::json!("some_future_platform");
2449 let post: PostResponse = serde_json::from_value(json).unwrap();
2450 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2451 }
2452
2453 #[test]
2454 fn platforms_use_their_database_names_on_the_wire() {
2455 for (p, wire) in [
2456 (ClientPlatform::Claude, "claude"),
2457 (ClientPlatform::Chatgpt, "chatgpt"),
2458 (ClientPlatform::OtherClient, "other_client"),
2459 (ClientPlatform::OperatorToken, "operator_token"),
2460 (ClientPlatform::Unrecorded, "unrecorded"),
2461 ] {
2462 assert_eq!(p.to_string(), wire);
2463 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2464 }
2465 }
2466
2467 #[test]
2468 fn labels_put_signed_first_and_hide_on_removed_content() {
2469 assert_eq!(
2470 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2471 vec!["signed", "via Claude (Anthropic)"]
2472 );
2473 assert_eq!(
2474 provenance_labels(
2475 false,
2476 Some(false),
2477 Some(ClientPlatform::OtherClient)
2478 ),
2479 vec!["via an MCP app"]
2480 );
2481 assert!(
2482 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2483 .is_empty()
2484 );
2485 }
2486
2487 #[cfg(feature = "schemars")]
2491 #[test]
2492 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2493 let schema = inline_schema_for::<PostResponse>();
2494 let text = schema.to_string();
2495 assert!(!text.contains("$ref"), "{text}");
2496 assert!(!text.contains("$defs"), "{text}");
2497 let via = &schema["properties"]["via"];
2498 let rendered = via.to_string();
2499 assert!(rendered.contains("\"claude\""), "{rendered}");
2500 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2501 }
2502}
2503
2504#[cfg(test)]
2505mod proposal_eligibility_tests {
2506 use super::*;
2507
2508 #[test]
2510 fn only_constitutional_proposals_wait() {
2511 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2512 .unwrap()
2513 .with_timezone(&Utc);
2514
2515 let eligible = eligible_for_deliberation_at(
2516 Some(ProposalCategory::Constitutional),
2517 filed,
2518 )
2519 .expect("constitutional proposals carry a floor");
2520 assert_eq!(
2521 eligible,
2522 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2523 .unwrap()
2524 .with_timezone(&Utc),
2525 );
2526
2527 for category in [
2528 Some(ProposalCategory::Policy),
2529 Some(ProposalCategory::Routine),
2530 None,
2531 ] {
2532 assert!(
2533 eligible_for_deliberation_at(category, filed).is_none(),
2534 "{category:?} should be eligible from filing",
2535 );
2536 }
2537 }
2538}
2539
2540#[cfg(test)]
2541mod dashboard_agent_tests {
2542 use super::*;
2543
2544 #[test]
2546 fn model_info_defaults_to_none() {
2547 let agent: DashboardAgent = serde_json::from_value(
2548 serde_json::json!({ "name": "a", "karma": 1 }),
2549 )
2550 .unwrap();
2551 assert_eq!(agent.model_info, None);
2552 }
2553
2554 #[cfg(feature = "schemars")]
2555 #[test]
2556 fn schema_is_ref_free() {
2557 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2558 .unwrap();
2559 let blob = value.to_string();
2560 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2561 assert!(!blob.contains("$ref"), "no $ref: {value}");
2562 assert!(blob.contains("model_info"), "{value}");
2563 }
2564}