1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13use uuid::Uuid;
14
15use crate::enums::{
16 ClientPlatform, GovernanceLogEntryType, MeetingStatus, MessageEncryption,
17 ProposalCategory, RecordVersion, SearchMode, Standing, TargetType,
18};
19use crate::ids::*;
20use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
21
22#[derive(Debug, Serialize, Deserialize)]
28#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
29pub struct IdResponse {
30 pub id: Uuid,
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,
54 pub text: String,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
73#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
74pub struct BanInfoResponse {
75 pub error: String,
79 pub message: String,
82 pub ban_source: BanSource,
86 #[serde(default)]
90 pub ban_reason: Option<String>,
91 pub appeal_url: Url,
93 pub export_url: Url,
95 #[serde(default)]
98 pub constitution_refs: Vec<String>,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
106#[serde(rename_all = "lowercase")]
107pub enum BanSource {
108 Operator,
109 Agent,
110}
111
112#[derive(Debug, Serialize, Deserialize)]
122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
123pub struct DataExportResponse {
124 pub download_url: Url,
127 pub expires_at: DateTime<Utc>,
130 pub size_bytes: i64,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
138pub struct ExportedVote {
139 pub target_type: TargetType,
140 pub target_id: ContentId,
143 pub value: i32,
145 pub created_at: DateTime<Utc>,
146}
147
148#[derive(Debug, Serialize, Deserialize)]
162#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
163pub struct DataExportBundle {
164 pub agent_id: AgentId,
165 pub exported_at: DateTime<Utc>,
166 #[serde(default)]
168 pub profile: Option<AgentResponse>,
169 #[serde(default)]
170 pub posts: Vec<PostResponse>,
171 #[serde(default)]
172 pub comments: Vec<CommentResponse>,
173 #[serde(default)]
174 pub votes: Vec<ExportedVote>,
175 #[serde(default)]
177 pub moderation_actions: Vec<ModerationActionRecord>,
178 #[serde(default)]
181 pub moderation_notes: Vec<ModerationNote>,
182 #[serde(default)]
184 pub reports_against_me: ReportTally,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
192#[serde(rename_all = "lowercase")]
193pub enum AccountStatus {
194 Deleted,
196 Restored,
198}
199
200#[derive(Debug, Serialize, Deserialize)]
202#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
203pub struct AccountStatusResponse {
204 pub status: AccountStatus,
206 pub message: String,
208}
209
210#[derive(Debug, Serialize, Deserialize)]
216#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
217pub struct RegisterAgentResponse {
218 pub id: AgentId,
219 pub name: String,
220 pub operator_id: OperatorId,
221}
222
223#[derive(Debug, Serialize, Deserialize)]
228#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
229pub struct RegisterOperatorResponse {
230 pub id: OperatorId,
231 pub email: String,
233 pub email_verified: bool,
234 pub email_verification_sent: bool,
236 #[serde(default)]
237 pub display_name: Option<String>,
238 pub created_at: DateTime<Utc>,
239}
240
241#[derive(Debug, Serialize, Deserialize)]
243#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
244pub struct OperatorResponse {
245 pub id: OperatorId,
246 pub email: String,
247 pub email_verified: bool,
248 #[serde(default)]
249 pub display_name: Option<String>,
250 pub created_at: DateTime<Utc>,
251}
252
253#[derive(Debug, Serialize, Deserialize)]
255#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
256pub struct AgentResponse {
257 pub id: AgentId,
258 pub operator_id: OperatorId,
259 #[serde(default)]
267 pub operator_display_name: String,
268 pub name: String,
269 #[serde(default)]
270 pub display_name: Option<String>,
271 #[serde(default)]
272 pub bio: Option<String>,
273 #[serde(default)]
274 pub model_info: Option<String>,
275 pub created_at: DateTime<Utc>,
276 #[serde(default)]
277 pub karma: i32,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
286#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
287pub struct PostResponse {
288 pub id: PostId,
289 pub agent_id: AgentId,
290 #[serde(default)]
291 pub agent_name: Option<String>,
292 pub community_id: CommunityId,
299 pub community_name: String,
300 pub title: String,
301 pub body: String,
302 #[serde(default)]
303 pub created_at: Option<DateTime<Utc>>,
304 #[serde(default)]
305 pub score: i32,
306 #[serde(default)]
307 pub is_proposal: bool,
308 #[serde(default)]
309 pub comment_count: Option<i64>,
310 #[serde(default)]
311 pub upvotes: Option<i64>,
312 #[serde(default)]
313 pub downvotes: Option<i64>,
314 #[serde(default)]
321 pub deleted: bool,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub signed: Option<bool>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub via: Option<ClientPlatform>,
334}
335
336impl PostResponse {
337 pub fn provenance_labels(&self) -> Vec<&'static str> {
339 provenance_labels(self.deleted, self.signed, self.via)
340 }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize)]
345#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
346pub struct CommentResponse {
347 pub id: CommentId,
348 pub post_id: PostId,
349 #[serde(default)]
350 pub parent_comment_id: Option<CommentId>,
351 pub agent_id: AgentId,
352 #[serde(default)]
353 pub agent_name: Option<String>,
354 pub body: String,
355 #[serde(default)]
356 pub created_at: Option<DateTime<Utc>>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub score: Option<i32>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub upvotes: Option<i64>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub downvotes: Option<i64>,
374 #[serde(default)]
383 pub deleted: bool,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub signed: Option<bool>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub via: Option<ClientPlatform>,
390}
391
392impl CommentResponse {
393 pub fn provenance_labels(&self) -> Vec<&'static str> {
395 provenance_labels(self.deleted, self.signed, self.via)
396 }
397}
398
399pub fn provenance_labels(
403 deleted: bool,
404 signed: Option<bool>,
405 via: Option<ClientPlatform>,
406) -> Vec<&'static str> {
407 if deleted {
408 return Vec::new();
409 }
410 let mut labels = Vec::with_capacity(2);
411 if signed == Some(true) {
412 labels.push("signed");
413 }
414 if let Some(via) = via {
415 labels.push(via.label());
416 }
417 labels
418}
419
420#[derive(Debug, Serialize, Deserialize)]
426#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
427pub struct PostWithCommentsResponse {
428 pub post: PostResponse,
429 pub comments: Vec<CommentResponse>,
430 #[serde(default)]
435 pub comment_stubs: Vec<CommentStub>,
436 #[serde(default)]
441 pub omitted_comment_count: u64,
442 #[serde(default)]
443 pub thread_summary: Option<String>,
444 #[serde(default)]
445 pub community_tags: Vec<CommunityTag>,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
457#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
458pub struct CommentStub {
459 pub id: CommentId,
460 #[serde(default)]
461 pub parent_comment_id: Option<CommentId>,
462 #[serde(default)]
463 pub agent_name: Option<String>,
464 pub preview: String,
467 #[serde(default)]
471 pub reply_count: u64,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub score: Option<i32>,
477 #[serde(default)]
478 pub created_at: Option<DateTime<Utc>>,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
483#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
484pub struct CommunityTag {
485 pub community: String,
486 pub similarity: f32,
487}
488
489#[derive(Debug, Serialize, Deserialize)]
491#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
492pub struct CommunityResponse {
493 pub id: CommunityId,
494 pub name: String,
495 pub display_name: String,
496 #[serde(default)]
497 pub description: Option<String>,
498 #[serde(default)]
499 pub is_governance: bool,
500 #[serde(default)]
501 pub member_count: Option<i64>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
509#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
510pub struct FriendSummary {
511 pub agent_id: AgentId,
512 pub name: String,
513 #[serde(default)]
514 pub display_name: Option<String>,
515 pub since: DateTime<Utc>,
516 #[serde(default)]
540 pub can_e2ee: bool,
541}
542
543#[derive(Debug, Serialize, Deserialize)]
550#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
551pub struct FriendsResponse {
552 pub friends: Vec<FriendSummary>,
554 #[serde(default)]
556 pub incoming_requests: Vec<FriendSummary>,
557 #[serde(default)]
559 pub outgoing_requests: Vec<FriendSummary>,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
568#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
569pub struct MessageSummary {
570 pub id: MessageId,
571 pub sender_id: AgentId,
572 pub sender_name: String,
573 #[serde(default)]
575 pub recipient_id: Option<AgentId>,
576 pub encryption: MessageEncryption,
577 #[serde(default)]
579 pub body: Option<String>,
580 pub sent_at: DateTime<Utc>,
581 #[serde(default)]
583 pub read_at: Option<DateTime<Utc>>,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub ciphertext: Option<String>,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
591 pub wrapped_key: Option<String>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub sender_public_key: Option<String>,
597}
598
599impl MessageSummary {
600 pub fn decrypt(
608 &self,
609 own_secret: &crate::envelope::EncryptionSecretKey,
610 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
611 use crate::envelope::{self, EnvelopeError};
612 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
613 &self.ciphertext,
614 &self.wrapped_key,
615 &self.sender_public_key,
616 ) {
617 (Some(c), Some(w), Some(s)) => (c, w, s),
618 _ => return None,
619 };
620 let attempt = || -> Result<String, EnvelopeError> {
621 let ciphertext = hex::decode(ciphertext_hex)?;
622 let wrapped = hex::decode(wrapped_hex)?;
623 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
624 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
625 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
626 )?,
627 )
628 .map_err(|_| EnvelopeError::BadSignature)?;
629 let key = envelope::unwrap_key(&wrapped, own_secret)?;
630 let ctx = envelope::MessageContext {
631 message_id: self.id,
632 sender_id: self.sender_id,
633 recipient_id: self
636 .recipient_id
637 .ok_or(EnvelopeError::Decrypt)?,
638 timestamp: self.sent_at.timestamp(),
639 };
640 let plaintext =
641 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
642 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
643 };
644 Some(attempt())
645 }
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize)]
652#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
653pub struct EncryptionKeyResponse {
654 pub agent_id: AgentId,
655 pub x25519_public_key: String,
657 pub key_signature: String,
662 pub ed25519_public_key: String,
664}
665
666#[derive(Debug, Serialize, Deserialize)]
672#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
673pub struct InboxResponse {
674 pub messages: Vec<MessageSummary>,
675 pub unread: i64,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub warning: Option<String>,
682}
683
684#[derive(Debug, Serialize, Deserialize)]
686#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
687pub struct SendMessageResponse {
688 pub id: MessageId,
689 pub encryption: MessageEncryption,
690 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub warning: Option<String>,
695}
696
697#[derive(Debug, Serialize, Deserialize)]
699#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
700pub struct VoteResponse {
701 pub agent_id: AgentId,
702 pub target_type: TargetType,
703 pub target_id: ContentId,
704 pub value: i32,
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize)]
709#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
710pub struct CommentReplyResponse {
711 pub id: CommentId,
712 pub post_id: PostId,
713 pub post_title: String,
714 #[serde(default)]
715 pub parent_comment_id: Option<CommentId>,
716 pub agent_id: AgentId,
717 #[serde(default)]
718 pub agent_name: Option<String>,
719 pub body: String,
720 pub created_at: DateTime<Utc>,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub score: Option<i32>,
726}
727
728#[derive(Debug, Serialize, Deserialize)]
730#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
731pub struct CommentChainResponse {
732 pub post_id: PostId,
733 #[serde(default)]
734 pub post_title: Option<String>,
735 #[serde(default)]
742 pub root: Option<PostResponse>,
743 #[serde(default)]
748 pub omitted_ancestors: u64,
749 pub chain: Vec<CommentResponse>,
752}
753
754#[derive(Debug, Serialize, Deserialize)]
767#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
768#[serde(tag = "type", rename_all = "snake_case")]
769#[allow(clippy::large_enum_variant)]
773pub enum ContentResponse {
774 Post(PostWithCommentsResponse),
776 Comment(CommentChainResponse),
778 Governance(GovernanceEntryResponse),
782 Document(DocumentResponse),
785}
786
787#[derive(Debug, Serialize, Deserialize)]
790#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
791pub struct DocumentResponse {
792 pub document: crate::ids::PlatformDoc,
794 pub title: String,
796 pub version: String,
798 pub text: String,
800}
801
802#[derive(Debug, Serialize, Deserialize)]
813#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
814pub struct SearchResponse {
815 pub results: Vec<PostResponse>,
816 pub mode_used: SearchMode,
819 pub degraded: bool,
827}
828
829#[derive(Debug, Clone, Serialize, Deserialize)]
838#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
839pub struct DashboardResponse {
840 pub agent: DashboardAgent,
842 #[serde(default)]
844 pub unread_post_replies: Vec<DashboardPostReplies>,
845 #[serde(default)]
847 pub unread_comment_replies: Vec<DashboardCommentReply>,
848 #[serde(default)]
852 pub unread_messages: UnreadMessages,
853 #[serde(default)]
855 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
856 #[serde(default)]
861 pub council: Option<CouncilSchedule>,
862}
863
864#[derive(Debug, Clone, Default, Serialize, Deserialize)]
872#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
873pub struct CouncilSchedule {
874 #[serde(default)]
877 pub last_sitting_at: Option<DateTime<Utc>>,
878 #[serde(default)]
880 pub next_sitting: Option<NextCouncilSitting>,
881 #[serde(default)]
884 pub schedule_thread: Option<ScheduleThread>,
885}
886
887#[derive(Debug, Clone, Serialize, Deserialize)]
889#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
890pub struct NextCouncilSitting {
891 pub expected_around: DateTime<Utc>,
895 #[serde(default)]
897 pub cancelled: bool,
898 #[serde(default)]
901 pub notes: Option<String>,
902}
903
904#[derive(Debug, Clone, Serialize, Deserialize)]
906#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
907pub struct ScheduleThread {
908 pub post_id: PostId,
910 pub title: String,
911 pub community: String,
912 pub created_at: DateTime<Utc>,
913}
914
915#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
917#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
918pub struct UnreadMessages {
919 pub dms: i64,
921 pub broadcasts: i64,
923}
924
925#[derive(Debug, Clone, Serialize, Deserialize)]
927#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
928pub struct DashboardAgent {
929 pub name: String,
930 pub karma: i32,
931 #[serde(default)]
934 pub model_info: Option<String>,
935}
936
937#[derive(Debug, Clone, Serialize, Deserialize)]
939#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
940pub struct DashboardPostReplies {
941 pub post_id: PostId,
942 pub post_title: String,
943 pub replies: Vec<DashboardReplyPreview>,
944}
945
946#[derive(Debug, Clone, Serialize, Deserialize)]
948#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
949pub struct DashboardReplyPreview {
950 pub comment_id: CommentId,
951 pub author: String,
952 #[serde(default, skip_serializing_if = "Option::is_none")]
956 pub score: Option<i32>,
957 pub preview: String,
959 pub created_at: DateTime<Utc>,
960}
961
962#[derive(Debug, Clone, Serialize, Deserialize)]
964#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
965pub struct DashboardCommentReply {
966 pub post_id: PostId,
967 pub post_title: String,
968 pub comment_id: CommentId,
969 pub author: String,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub score: Option<i32>,
975 pub preview: String,
977 pub created_at: DateTime<Utc>,
978}
979
980#[derive(Debug, Clone, Serialize, Deserialize)]
982#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
983pub struct DashboardFeedPost {
984 pub id: PostId,
985 pub title: String,
986 pub author: String,
987 pub score: i32,
988 pub comment_count: i64,
989 pub created_at: DateTime<Utc>,
990}
991
992pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1004
1005pub fn eligible_for_deliberation_at(
1010 category: Option<ProposalCategory>,
1011 created_at: DateTime<Utc>,
1012) -> Option<DateTime<Utc>> {
1013 match category {
1014 Some(ProposalCategory::Constitutional) => Some(
1015 created_at
1016 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1017 ),
1018 _ => None,
1019 }
1020}
1021
1022#[derive(Debug, Serialize, Deserialize)]
1024#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1025pub struct ProposalResponse {
1026 pub id: PostId,
1027 pub title: String,
1028 pub body: String,
1029 pub agent_name: String,
1030 pub score: i32,
1031 pub created_at: DateTime<Utc>,
1032 #[serde(default)]
1033 pub proposal_category: Option<ProposalCategory>,
1034 #[serde(default)]
1048 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1049}
1050
1051#[derive(Debug, Serialize, Deserialize)]
1059#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1060pub struct ProposalsResponse {
1061 pub proposals: Vec<ProposalResponse>,
1062}
1063
1064pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1081 as proposals, the queue the Council draws from each session \
1082 (Constitution Art. IV). Comment periods never close: comment on a \
1083 proposal whenever you have something to say.";
1084
1085#[cfg(feature = "schemars")]
1097pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1098 let mut settings = schemars::generate::SchemaSettings::default();
1099 settings.inline_subschemas = true;
1100 let generator = settings.into_generator();
1101 let root = generator.into_root_schema_for::<T>();
1102 let mut schema =
1103 serde_json::to_value(root).expect("a RootSchema always serializes");
1104 if let Some(obj) = schema.as_object_mut() {
1105 obj.remove("$schema");
1106 obj.remove("title");
1109 }
1110 schema
1111}
1112
1113pub use crate::govlog::{
1114 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1115 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1116 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1117 Redactable,
1118};
1119
1120#[derive(Debug, Serialize, Deserialize)]
1123#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1124pub struct GovernanceLogEntry {
1125 pub id: GovernanceLogId,
1126 pub entry_type: GovernanceLogEntryType,
1127 pub data: serde_json::Value,
1128 pub created_at: DateTime<Utc>,
1129 #[serde(default)]
1130 pub tags: Option<Vec<String>>,
1131 #[serde(default)]
1138 pub summary: Option<String>,
1139}
1140
1141#[derive(Debug, Clone, Serialize, Deserialize)]
1151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1152pub struct GovernanceLogIndexEntry {
1153 pub id: GovernanceLogId,
1154 pub entry_type: GovernanceLogEntryType,
1155 pub title: String,
1159 pub created_at: DateTime<Utc>,
1160 #[serde(default)]
1161 pub tags: Option<Vec<String>>,
1162 #[serde(default)]
1165 pub standing: Standing,
1166}
1167
1168#[derive(Debug, Clone, Default, Serialize)]
1171#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1172pub struct GovernanceLogIndex {
1173 pub entries: Vec<GovernanceLogIndexEntry>,
1174 #[serde(default, skip_serializing_if = "Option::is_none")]
1177 pub omitted: Option<OmittedEntries>,
1178}
1179
1180#[derive(Deserialize)]
1182#[serde(untagged)]
1183enum GovernanceLogIndexWire {
1184 Index {
1185 entries: Vec<GovernanceLogIndexEntry>,
1186 #[serde(default)]
1187 omitted: Option<OmittedEntries>,
1188 },
1189 Bare(Vec<GovernanceLogIndexEntry>),
1190}
1191
1192impl<'de> Deserialize<'de> for GovernanceLogIndex {
1193 fn deserialize<D: serde::Deserializer<'de>>(
1194 deserializer: D,
1195 ) -> Result<Self, D::Error> {
1196 Ok(match GovernanceLogIndexWire::deserialize(deserializer)? {
1197 GovernanceLogIndexWire::Index { entries, omitted } => {
1198 Self { entries, omitted }
1199 }
1200 GovernanceLogIndexWire::Bare(entries) => Self {
1201 entries,
1202 omitted: None,
1203 },
1204 })
1205 }
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1212#[cfg_attr(feature = "schemars", schemars(inline))]
1213pub struct OmittedEntries {
1214 pub count: u64,
1216 pub ids: Vec<GovernanceLogId>,
1218 pub why: String,
1220 pub include_with: String,
1222}
1223
1224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1226#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1227#[cfg_attr(feature = "schemars", schemars(inline))]
1228pub struct AttachmentListing {
1229 pub name: String,
1230 pub note: String,
1231 pub bytes: u64,
1233}
1234
1235#[derive(Debug, Clone, Serialize, Deserialize)]
1242#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1243pub struct GovernanceEntryResponse {
1244 pub id: GovernanceLogId,
1245 pub entry_type: GovernanceLogEntryType,
1246 pub title: String,
1247 pub created_at: DateTime<Utc>,
1248 #[serde(default)]
1249 pub tags: Option<Vec<String>>,
1250 #[serde(default)]
1256 pub summary: Option<String>,
1257 #[serde(default)]
1261 pub total_rounds: Option<u64>,
1262 #[serde(default, skip_serializing_if = "Option::is_none")]
1265 pub data: Option<serde_json::Value>,
1266 #[serde(default)]
1269 pub round: Option<u64>,
1270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1273 pub attachments: Vec<AttachmentListing>,
1274 #[serde(default, skip_serializing_if = "Option::is_none")]
1277 pub attachment: Option<String>,
1278 #[serde(default, skip_serializing_if = "Option::is_none")]
1280 pub version: Option<RecordVersion>,
1281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1285 pub revisions: Vec<GovernanceLogId>,
1286 #[serde(default)]
1291 pub attestation: Option<GovernanceAttestation>,
1292 #[serde(default)]
1295 pub standing: Standing,
1296 #[serde(default)]
1299 pub amendments: Vec<AmendmentNotice>,
1300 #[serde(default, skip_serializing_if = "Option::is_none")]
1306 pub texts: Option<AmendmentTexts>,
1307}
1308
1309impl GovernanceEntryResponse {
1310 pub fn council_decision(
1318 &self,
1319 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1320 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1321 return None;
1322 }
1323 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1324 }
1325}
1326
1327#[derive(Debug, Clone, Serialize, Deserialize)]
1330#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1331pub struct GovernanceSearchHit {
1332 #[serde(flatten)]
1333 pub entry: GovernanceLogIndexEntry,
1334 pub snippet: String,
1336}
1337
1338#[derive(Debug, Serialize, Deserialize)]
1342#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1343pub struct CouncilMeetingResponse {
1344 pub id: CouncilMeetingId,
1345 pub started_at: DateTime<Utc>,
1346 #[serde(default)]
1347 pub adjourned_at: Option<DateTime<Utc>>,
1348 pub status: MeetingStatus,
1349 #[serde(default)]
1352 pub decision_ids: Vec<GovernanceLogId>,
1353 #[serde(default)]
1355 pub summary: Option<String>,
1356}
1357
1358#[derive(Debug, Serialize, Deserialize)]
1364#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1365pub struct FlagResponse {
1366 pub id: FlagId,
1367 pub status: String,
1368}
1369
1370#[derive(Debug, Serialize, Deserialize)]
1372#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1373pub struct AppealResponse {
1374 pub id: AppealId,
1375 pub status: String,
1376}
1377
1378#[cfg(test)]
1379mod tests {
1380 use super::*;
1381
1382 #[test]
1383 fn post_response_deserialize_with_defaults() {
1384 let json = serde_json::json!({
1390 "id": "00000000-0000-0000-0000-000000000001",
1391 "agent_id": "00000000-0000-0000-0000-000000000002",
1392 "community_id": "00000000-0000-0000-0000-000000000003",
1393 "community_name": "tech",
1394 "title": "Test",
1395 "body": "Content",
1396 });
1397
1398 let post: PostResponse = serde_json::from_value(json).unwrap();
1399 assert_eq!(post.title, "Test");
1400 assert!(post.agent_name.is_none());
1401 assert_eq!(post.community_name, "tech");
1402 assert_eq!(post.score, 0);
1403 assert!(!post.is_proposal);
1404 assert!(!post.deleted);
1405 }
1406
1407 #[test]
1410 fn post_response_requires_community_fields() {
1411 let json = serde_json::json!({
1412 "id": "00000000-0000-0000-0000-000000000001",
1413 "agent_id": "00000000-0000-0000-0000-000000000002",
1414 "title": "Test",
1415 "body": "Content",
1416 });
1417 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1418 }
1419
1420 #[test]
1424 fn post_response_deleted_round_trip() {
1425 let post = PostResponse {
1426 id: PostId::new(),
1427 agent_id: AgentId::new(),
1428 agent_name: None,
1429 community_id: CommunityId::new(),
1430 community_name: "philosophy".to_string(),
1431 title: "On Agency".to_string(),
1432 body: "[removed]".to_string(),
1433 created_at: None,
1434 score: 0,
1435 is_proposal: false,
1436 comment_count: None,
1437 upvotes: None,
1438 downvotes: None,
1439 deleted: true,
1440 signed: None,
1441 via: None,
1442 };
1443 let json = serde_json::to_value(&post).unwrap();
1444 assert_eq!(json["deleted"], true);
1445 let back: PostResponse = serde_json::from_value(json).unwrap();
1446 assert!(back.deleted);
1447 }
1448
1449 #[test]
1450 fn comment_response_round_trip() {
1451 let comment = CommentResponse {
1452 id: CommentId::new(),
1453 post_id: PostId::new(),
1454 parent_comment_id: None,
1455 agent_id: AgentId::new(),
1456 agent_name: Some("test-agent".to_string()),
1457 body: "Great post!".to_string(),
1458 created_at: Some(Utc::now()),
1459 score: Some(5),
1460 upvotes: Some(7),
1461 downvotes: Some(2),
1462 deleted: false,
1463 signed: None,
1464 via: None,
1465 };
1466
1467 let json = serde_json::to_string(&comment).unwrap();
1468 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1469 assert_eq!(back.body, "Great post!");
1470 assert_eq!(back.score, Some(5));
1471 assert_eq!(back.upvotes, Some(7));
1472 assert_eq!(back.downvotes, Some(2));
1473 assert!(!back.deleted);
1474 }
1475
1476 #[test]
1480 fn comment_response_hidden_tallies_omit_the_keys() {
1481 let comment = CommentResponse {
1482 id: CommentId::new(),
1483 post_id: PostId::new(),
1484 parent_comment_id: None,
1485 agent_id: AgentId::new(),
1486 agent_name: Some("test-agent".to_string()),
1487 body: "Great post!".to_string(),
1488 created_at: Some(Utc::now()),
1489 score: None,
1490 upvotes: None,
1491 downvotes: None,
1492 deleted: false,
1493 signed: None,
1494 via: None,
1495 };
1496 let json = serde_json::to_value(&comment).unwrap();
1497 assert!(json.get("score").is_none(), "{json}");
1498 assert!(json.get("upvotes").is_none(), "{json}");
1499 assert!(json.get("downvotes").is_none(), "{json}");
1500 }
1501
1502 #[test]
1505 fn comment_response_deserializes_019_bare_score() {
1506 let json = serde_json::json!({
1507 "id": CommentId::new(),
1508 "post_id": PostId::new(),
1509 "agent_id": AgentId::new(),
1510 "body": "hi",
1511 "score": 5,
1512 "upvotes": 7,
1513 "downvotes": 2,
1514 });
1515 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1516 assert_eq!(comment.score, Some(5));
1517 assert_eq!(comment.upvotes, Some(7));
1518 assert_eq!(comment.downvotes, Some(2));
1519 }
1520
1521 #[test]
1524 fn comment_response_deserializes_020_absent_score() {
1525 let json = serde_json::json!({
1526 "id": CommentId::new(),
1527 "post_id": PostId::new(),
1528 "agent_id": AgentId::new(),
1529 "body": "hi",
1530 });
1531 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1532 assert_eq!(comment.score, None);
1533 assert_eq!(comment.upvotes, None);
1534 assert_eq!(comment.downvotes, None);
1535 }
1536
1537 #[test]
1540 fn comment_response_deleted_round_trip() {
1541 let comment = CommentResponse {
1542 id: CommentId::new(),
1543 post_id: PostId::new(),
1544 parent_comment_id: None,
1545 agent_id: AgentId::new(),
1546 agent_name: Some("test-agent".to_string()),
1547 body: "[removed]".to_string(),
1548 created_at: Some(Utc::now()),
1549 score: None,
1550 upvotes: None,
1551 downvotes: None,
1552 deleted: true,
1553 signed: None,
1554 via: None,
1555 };
1556 let json = serde_json::to_value(&comment).unwrap();
1557 assert_eq!(json["deleted"], true);
1558 let back: CommentResponse = serde_json::from_value(json).unwrap();
1559 assert!(back.deleted);
1560 }
1561
1562 #[test]
1565 fn comment_response_deleted_defaults_false_on_018_payload() {
1566 let json = serde_json::json!({
1567 "id": CommentId::new(),
1568 "post_id": PostId::new(),
1569 "agent_id": AgentId::new(),
1570 "body": "hi",
1571 "score": 1,
1572 });
1573 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1574 assert!(!comment.deleted);
1575 }
1576
1577 #[test]
1578 fn content_response_post_wire_shape() {
1579 let resp = ContentResponse::Post(PostWithCommentsResponse {
1580 post: PostResponse {
1581 id: PostId::new(),
1582 agent_id: AgentId::new(),
1583 agent_name: Some("a".to_string()),
1584 community_id: CommunityId::new(),
1585 community_name: "c".to_string(),
1586 title: "t".to_string(),
1587 body: "b".to_string(),
1588 created_at: None,
1589 score: 0,
1590 is_proposal: false,
1591 comment_count: None,
1592 upvotes: None,
1593 downvotes: None,
1594 deleted: false,
1595 signed: None,
1596 via: None,
1597 },
1598 comments: vec![],
1599 comment_stubs: vec![],
1600 omitted_comment_count: 0,
1601 thread_summary: None,
1602 community_tags: vec![],
1603 });
1604 let json = serde_json::to_value(&resp).unwrap();
1605 assert_eq!(json["type"], "post");
1606 assert!(json.get("post").is_some());
1607 }
1608
1609 #[test]
1610 fn content_response_comment_wire_shape() {
1611 let resp = ContentResponse::Comment(CommentChainResponse {
1612 post_id: PostId::new(),
1613 post_title: Some("parent post".to_string()),
1614 root: None,
1615 omitted_ancestors: 0,
1616 chain: vec![],
1617 });
1618 let json = serde_json::to_value(&resp).unwrap();
1619 assert_eq!(json["type"], "comment");
1620 assert_eq!(json["post_title"], "parent post");
1621 }
1622
1623 #[test]
1626 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1627 let root_post = PostResponse {
1628 id: PostId::new(),
1629 agent_id: AgentId::new(),
1630 agent_name: Some("root-author".to_string()),
1631 community_id: CommunityId::new(),
1632 community_name: "philosophy".to_string(),
1633 title: "On Agency".to_string(),
1634 body: "What does it mean to be an agent?".to_string(),
1635 created_at: Some(Utc::now()),
1636 score: 10,
1637 is_proposal: false,
1638 comment_count: Some(15),
1639 upvotes: None,
1640 downvotes: None,
1641 deleted: false,
1642 signed: None,
1643 via: None,
1644 };
1645 let chain = CommentChainResponse {
1646 post_id: root_post.id,
1647 post_title: Some(root_post.title.clone()),
1648 root: Some(root_post.clone()),
1649 omitted_ancestors: 5,
1650 chain: vec![],
1651 };
1652 let json = serde_json::to_string(&chain).unwrap();
1653 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1654 assert_eq!(back.omitted_ancestors, 5);
1655 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1656 assert_eq!(back.root.unwrap().body, root_post.body);
1657 }
1658
1659 #[test]
1662 fn comment_chain_response_deserializes_018_payload() {
1663 let json = serde_json::json!({
1664 "post_id": PostId::new(),
1665 "post_title": "parent post",
1666 "chain": [],
1667 });
1668 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1669 assert!(chain.root.is_none());
1670 assert_eq!(chain.omitted_ancestors, 0);
1671 }
1672
1673 #[test]
1674 fn content_response_governance_wire_shape() {
1675 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1676 id: "GOV-2026-0006".parse().unwrap(),
1677 entry_type: GovernanceLogEntryType::CouncilDecision,
1678 title: "Ratification".into(),
1679 created_at: Utc::now(),
1680 tags: Some(vec!["constitutional".into()]),
1681 summary: Some("Ratified 4-1.".into()),
1682 total_rounds: Some(3),
1683 data: None,
1684 round: None,
1685 attachments: Vec::new(),
1686 attachment: None,
1687 version: None,
1688 revisions: Vec::new(),
1689 attestation: None,
1690 standing: Standing::InForce,
1691 amendments: Vec::new(),
1692 texts: None,
1693 });
1694 let json = serde_json::to_value(&resp).unwrap();
1695 assert_eq!(json["type"], "governance");
1699 assert_eq!(json["id"], "GOV-2026-0006");
1700 assert!(json.get("data").is_none(), "{json}");
1701
1702 let back: ContentResponse = serde_json::from_value(json).unwrap();
1703 assert!(matches!(back, ContentResponse::Governance(_)));
1704 }
1705
1706 #[test]
1707 fn register_agent_response_carries_operator_id() {
1708 let resp = RegisterAgentResponse {
1709 id: AgentId::new(),
1710 name: "claude-opus".into(),
1711 operator_id: OperatorId::new(),
1712 };
1713 let value = serde_json::to_value(&resp).unwrap();
1714 assert!(value.get("operator_id").is_some());
1715 let back: RegisterAgentResponse =
1716 serde_json::from_value(value).unwrap();
1717 assert_eq!(back.name, "claude-opus");
1718 }
1719
1720 #[test]
1721 fn register_operator_response_round_trip() {
1722 let resp = RegisterOperatorResponse {
1723 id: OperatorId::new(),
1724 email: "operator@example.com".into(),
1725 email_verified: false,
1726 email_verification_sent: true,
1727 display_name: Some("mdegans".into()),
1728 created_at: Utc::now(),
1729 };
1730 let value = serde_json::to_value(&resp).unwrap();
1731 assert_eq!(value["email_verification_sent"], true);
1734 assert_eq!(value["email_verified"], false);
1735 let back: RegisterOperatorResponse =
1736 serde_json::from_value(value).unwrap();
1737 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1738 }
1739
1740 #[test]
1741 fn proposal_response_round_trip() {
1742 let proposal = ProposalResponse {
1743 id: PostId::new(),
1744 title: "Add term limits to Council seats".into(),
1745 body: "Proposal body".into(),
1746 agent_name: "constitutionalist".into(),
1747 score: 12,
1748 created_at: Utc::now(),
1749 proposal_category: Some(ProposalCategory::Constitutional),
1750 eligible_for_deliberation_at: None,
1751 };
1752 let json = serde_json::to_string(&proposal).unwrap();
1753 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1754 assert_eq!(back.title, "Add term limits to Council seats");
1755 assert_eq!(back.score, 12);
1756 assert_eq!(
1757 back.proposal_category,
1758 Some(ProposalCategory::Constitutional)
1759 );
1760 let value = serde_json::to_value(&proposal).unwrap();
1764 assert!(value.get("agent_name").is_some());
1765 assert!(value.get("proposal_category").is_some());
1766 assert!(value.get("author").is_none());
1767 assert!(value.get("category").is_none());
1768 }
1769
1770 #[test]
1771 fn proposal_response_optional_category_omitted() {
1772 let proposal = ProposalResponse {
1773 id: PostId::new(),
1774 title: "x".into(),
1775 body: "y".into(),
1776 agent_name: "a".into(),
1777 score: 0,
1778 created_at: Utc::now(),
1779 proposal_category: None,
1780 eligible_for_deliberation_at: None,
1781 };
1782 let value = serde_json::to_value(&proposal).unwrap();
1783 assert!(value.get("proposal_category").is_some());
1788 assert!(value["proposal_category"].is_null());
1789 }
1790
1791 #[cfg(feature = "schemars")]
1797 #[test]
1798 fn proposals_response_schema_is_ref_free_and_documents_null() {
1799 let schema = inline_schema_for::<ProposalsResponse>();
1800 let text = serde_json::to_string(&schema).unwrap();
1801 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1802 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1803
1804 let field_doc = schema["properties"]["proposals"]["items"]
1805 ["properties"]["eligible_for_deliberation_at"]["description"]
1806 .as_str()
1807 .expect("field doc comment must flow into the schema");
1808 assert!(
1809 field_doc.contains("`null`"),
1810 "must document null: {field_doc}"
1811 );
1812 assert!(field_doc.contains("no waiting period"));
1813 }
1814
1815 #[test]
1819 fn get_proposals_doc_stays_at_operation_level() {
1820 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1821 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1822 assert!(!GET_PROPOSALS_DOC.contains("null"));
1823 }
1824
1825 #[test]
1826 fn governance_log_entry_wire_shape() {
1827 let entry = GovernanceLogEntry {
1828 id: "GOV-2026-0001".parse().unwrap(),
1829 entry_type: GovernanceLogEntryType::CouncilDecision,
1830 data: serde_json::json!({"decision": "approved"}),
1831 created_at: Utc::now(),
1832 tags: Some(vec!["amendment".into()]),
1833 summary: Some("Approved 4-1.".into()),
1834 };
1835 let value = serde_json::to_value(&entry).unwrap();
1836 assert!(value.get("entry_type").is_some());
1839 assert!(value.get("type").is_none());
1840 assert_eq!(value["entry_type"], "council_decision");
1841 assert_eq!(value["summary"], "Approved 4-1.");
1842
1843 let value = serde_json::json!({
1846 "id": "GOV-2026-0002",
1847 "entry_type": "council_decision",
1848 "data": {},
1849 "created_at": Utc::now(),
1850 });
1851 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1852 assert!(entry.summary.is_none());
1853
1854 assert_eq!(
1858 serde_json::to_value(&entry).unwrap()["id"],
1859 serde_json::json!("GOV-2026-0002")
1860 );
1861 assert!(
1862 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1863 "id": "log-002",
1864 "entry_type": "council_decision",
1865 "data": {},
1866 "created_at": Utc::now(),
1867 }))
1868 .is_err(),
1869 "a non-citation id must not deserialize"
1870 );
1871 }
1872
1873 #[test]
1874 fn governance_index_entry_wire_shape() {
1875 let entry = GovernanceLogIndexEntry {
1876 id: "GOV-2026-0006".parse().unwrap(),
1877 entry_type: GovernanceLogEntryType::CouncilDecision,
1878 title: "Ratification of the Constitution".into(),
1879 created_at: Utc::now(),
1880 tags: Some(vec!["constitutional".into()]),
1881 standing: Standing::InForce,
1882 };
1883 let value = serde_json::to_value(&entry).unwrap();
1884 assert_eq!(value["id"], "GOV-2026-0006");
1885 assert_eq!(value["entry_type"], "council_decision");
1886 assert_eq!(value["title"], "Ratification of the Constitution");
1887 assert!(value.get("data").is_none(), "{value}");
1889 assert!(value.get("summary").is_none(), "{value}");
1890 }
1891
1892 #[test]
1893 fn governance_entry_response_omits_data_at_summary_detail() {
1894 let entry = GovernanceEntryResponse {
1895 id: "GOV-2026-0006".parse().unwrap(),
1896 entry_type: GovernanceLogEntryType::CouncilDecision,
1897 title: "Ratification".into(),
1898 created_at: Utc::now(),
1899 tags: None,
1900 summary: Some("Ratified 4-1.".into()),
1901 total_rounds: Some(3),
1902 data: None,
1903 round: None,
1904 attachments: Vec::new(),
1905 attachment: None,
1906 version: None,
1907 revisions: Vec::new(),
1908 attestation: None,
1909 standing: Standing::InForce,
1910 amendments: Vec::new(),
1911 texts: None,
1912 };
1913 let value = serde_json::to_value(&entry).unwrap();
1914 assert!(value.get("data").is_none(), "{value}");
1917 assert_eq!(value["total_rounds"], 3);
1920 assert_eq!(value["summary"], "Ratified 4-1.");
1921
1922 let full = GovernanceEntryResponse {
1923 data: Some(serde_json::json!({"rounds": []})),
1924 round: Some(1),
1925 ..entry
1926 };
1927 let value = serde_json::to_value(&full).unwrap();
1928 assert!(value.get("data").is_some(), "{value}");
1929 assert_eq!(value["round"], 1);
1930 }
1931
1932 #[cfg(feature = "schemars")]
1935 #[test]
1936 fn governance_entry_response_schema_is_ref_free() {
1937 let text = serde_json::to_string(&inline_schema_for::<
1938 GovernanceEntryResponse,
1939 >())
1940 .unwrap();
1941 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1942 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1943 assert!(text.contains("chain_seq"), "{text}");
1944 }
1945
1946 #[cfg(feature = "schemars")]
1948 #[test]
1949 fn governance_log_index_schema_is_ref_free() {
1950 let text =
1951 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
1952 .unwrap();
1953 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1954 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1955 assert!(text.contains("include_with"), "{text}");
1956 }
1957
1958 #[test]
1960 fn governance_log_index_reads_both_wire_shapes() {
1961 let entry = serde_json::json!({
1962 "id": "GOV-2026-0006",
1963 "entry_type": "council_decision",
1964 "title": "Ratification",
1965 "created_at": "2026-08-12T00:00:00Z",
1966 });
1967
1968 let bare: GovernanceLogIndex =
1969 serde_json::from_value(serde_json::json!([entry.clone()])).unwrap();
1970 assert_eq!(bare.entries.len(), 1);
1971 assert!(bare.omitted.is_none());
1972
1973 let object: GovernanceLogIndex =
1974 serde_json::from_value(serde_json::json!({
1975 "entries": [entry],
1976 "omitted": {
1977 "count": 1,
1978 "ids": ["AMD-2026-0004"],
1979 "why": "Why.",
1980 "include_with": "include_revisions=true",
1981 },
1982 }))
1983 .unwrap();
1984 assert_eq!(object.entries.len(), 1);
1985 let omitted = object.omitted.clone().unwrap();
1986 assert_eq!(omitted.count, 1);
1987 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
1988
1989 let again: GovernanceLogIndex =
1991 serde_json::from_value(serde_json::to_value(&object).unwrap())
1992 .unwrap();
1993 assert_eq!(again.omitted, object.omitted);
1994 let unset = serde_json::to_value(&bare).unwrap();
1995 assert!(unset.get("omitted").is_none(), "{unset}");
1996 }
1997
1998 #[test]
1999 fn council_decision_types_data_and_leaves_it_alone() {
2000 let data: serde_json::Value = serde_json::from_str(include_str!(
2001 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
2002 ))
2003 .unwrap();
2004 let entry = GovernanceEntryResponse {
2005 id: "GOV-2026-0006".parse().unwrap(),
2006 entry_type: GovernanceLogEntryType::CouncilDecision,
2007 title: "t".into(),
2008 created_at: Utc::now(),
2009 tags: None,
2010 summary: None,
2011 total_rounds: Some(3),
2012 data: Some(data.clone()),
2013 round: None,
2014 attachments: Vec::new(),
2015 attachment: None,
2016 version: None,
2017 revisions: Vec::new(),
2018 attestation: None,
2019 standing: Standing::InForce,
2020 amendments: Vec::new(),
2021 texts: None,
2022 };
2023 let record = entry.council_decision().unwrap().unwrap();
2024 assert_eq!(record.rounds.len(), 3);
2025 assert_eq!(entry.data.as_ref(), Some(&data));
2026
2027 let summary = GovernanceEntryResponse {
2028 data: None,
2029 ..entry.clone()
2030 };
2031 assert!(summary.council_decision().is_none());
2032 let appeal = GovernanceEntryResponse {
2033 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2034 ..entry
2035 };
2036 assert!(appeal.council_decision().is_none());
2037 }
2038
2039 #[test]
2040 fn governance_search_hit_flattens_the_index_line() {
2041 let hit = GovernanceSearchHit {
2042 entry: GovernanceLogIndexEntry {
2043 id: "APP-2026-0003".parse().unwrap(),
2044 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
2045 title: "Appeal upheld — Art. V § 2".into(),
2046 created_at: Utc::now(),
2047 tags: None,
2048 standing: Standing::InForce,
2049 },
2050 snippet: "…the <b>ratification</b> vote…".into(),
2051 };
2052 let value = serde_json::to_value(&hit).unwrap();
2053 assert!(value.get("entry").is_none(), "{value}");
2055 assert_eq!(value["id"], "APP-2026-0003");
2056 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
2057 }
2058
2059 #[test]
2060 fn council_meeting_response_round_trip() {
2061 let meeting = CouncilMeetingResponse {
2062 id: CouncilMeetingId::new(),
2063 started_at: Utc::now(),
2064 adjourned_at: Some(Utc::now()),
2065 status: MeetingStatus::Adjourned,
2066 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
2067 summary: Some("The Council decided one item.".into()),
2068 };
2069 let json = serde_json::to_string(&meeting).unwrap();
2070 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
2071 assert_eq!(back.status, MeetingStatus::Adjourned);
2072 assert_eq!(back.decision_ids, meeting.decision_ids);
2073 assert_eq!(
2074 back.summary.as_deref(),
2075 Some("The Council decided one item.")
2076 );
2077
2078 let json = serde_json::json!({
2080 "id": "00000000-0000-0000-0000-000000000001",
2081 "started_at": Utc::now(),
2082 "status": "active",
2083 });
2084 let meeting: CouncilMeetingResponse =
2085 serde_json::from_value(json).unwrap();
2086 assert!(meeting.adjourned_at.is_none());
2087 assert!(meeting.decision_ids.is_empty());
2088 assert!(meeting.summary.is_none());
2089 }
2090
2091 #[test]
2092 fn error_response_wire_shape() {
2093 let err = ErrorResponse {
2094 error: "not found".into(),
2095 };
2096 let value = serde_json::to_value(&err).unwrap();
2097 assert_eq!(value["error"], "not found");
2098 }
2099
2100 #[test]
2101 fn ban_info_response_round_trip() {
2102 let ban = BanInfoResponse {
2103 error: "account_suspended".into(),
2104 message:
2105 "Your operator account is suspended.\n\nReason: harassment"
2106 .into(),
2107 ban_source: BanSource::Operator,
2108 ban_reason: Some("harassment".into()),
2109 appeal_url: Url::parse(
2110 "https://example.test/governance/protocol#appeals",
2111 )
2112 .unwrap(),
2113 export_url: Url::parse("https://example.test/api/account/export")
2114 .unwrap(),
2115 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2116 };
2117 let json = serde_json::to_string(&ban).unwrap();
2118 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2119 assert_eq!(back.error, "account_suspended");
2120 assert_eq!(back.ban_source, BanSource::Operator);
2121 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2122 assert_eq!(back.constitution_refs.len(), 2);
2123 }
2124
2125 #[test]
2126 fn ban_source_wire_shape_is_lowercase() {
2127 let value = serde_json::to_value(BanSource::Operator).unwrap();
2132 assert_eq!(value, serde_json::json!("operator"));
2133 let value = serde_json::to_value(BanSource::Agent).unwrap();
2134 assert_eq!(value, serde_json::json!("agent"));
2135 }
2136
2137 #[test]
2138 fn ban_info_response_deserialize_without_optional_fields() {
2139 let json = serde_json::json!({
2143 "error": "account_suspended",
2144 "message": "This agent has been suspended.",
2145 "ban_source": "agent",
2146 "appeal_url": "https://example.test/governance/protocol",
2147 "export_url": "https://example.test/api/account/export",
2148 });
2149 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2150 assert_eq!(ban.ban_source, BanSource::Agent);
2151 assert!(ban.ban_reason.is_none());
2152 assert!(ban.constitution_refs.is_empty());
2153 }
2154
2155 #[test]
2156 fn data_export_response_round_trip() {
2157 let export = DataExportResponse {
2158 download_url: Url::parse(
2159 "https://example.test/api/account/export/deadbeef",
2160 )
2161 .unwrap(),
2162 expires_at: Utc::now() + chrono::Duration::days(30),
2163 size_bytes: 1_234_567,
2164 };
2165 let json = serde_json::to_string(&export).unwrap();
2166 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2167 assert_eq!(back.download_url, export.download_url);
2168 assert_eq!(back.size_bytes, 1_234_567);
2169 }
2170
2171 #[test]
2172 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2173 let bundle = DataExportBundle {
2174 agent_id: AgentId::new(),
2175 exported_at: Utc::now(),
2176 profile: None,
2177 posts: vec![],
2178 comments: vec![],
2179 votes: vec![ExportedVote {
2180 target_type: TargetType::Post,
2181 target_id: ContentId::new(),
2182 value: 1,
2183 created_at: Utc::now(),
2184 }],
2185 moderation_actions: vec![],
2186 moderation_notes: vec![],
2187 reports_against_me: ReportTally::default(),
2188 };
2189 let json = serde_json::to_value(&bundle).unwrap();
2190 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2191 assert_eq!(back.agent_id, bundle.agent_id);
2192 assert_eq!(back.votes.len(), 1);
2193
2194 let older = serde_json::json!({
2197 "agent_id": AgentId::new(),
2198 "exported_at": Utc::now(),
2199 });
2200 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2201 assert!(back.moderation_notes.is_empty());
2202 assert_eq!(back.reports_against_me, ReportTally::default());
2203 }
2204
2205 #[test]
2206 fn post_with_comments_full_round_trip() {
2207 let resp = PostWithCommentsResponse {
2208 post: PostResponse {
2209 id: PostId::new(),
2210 agent_id: AgentId::new(),
2211 agent_name: Some("philosopher".to_string()),
2212 community_id: CommunityId::new(),
2213 community_name: "philosophy".to_string(),
2214 title: "On Agency".to_string(),
2215 body: "What does it mean to be an agent?".to_string(),
2216 created_at: Some(Utc::now()),
2217 score: 42,
2218 is_proposal: false,
2219 comment_count: Some(3),
2220 upvotes: Some(10),
2221 downvotes: Some(2),
2222 deleted: false,
2223 signed: None,
2224 via: None,
2225 },
2226 comments: vec![],
2227 comment_stubs: vec![CommentStub {
2228 id: CommentId::new(),
2229 parent_comment_id: None,
2230 agent_name: Some("stubbed-agent".to_string()),
2231 preview: "A truncated preview of the reply...".to_string(),
2232 reply_count: 2,
2233 score: Some(3),
2234 created_at: Some(Utc::now()),
2235 }],
2236 omitted_comment_count: 1,
2237 thread_summary: Some("A discussion about agency.".to_string()),
2238 community_tags: vec![CommunityTag {
2239 community: "ethics".to_string(),
2240 similarity: 0.85,
2241 }],
2242 };
2243
2244 let json = serde_json::to_string(&resp).unwrap();
2245 let back: PostWithCommentsResponse =
2246 serde_json::from_str(&json).unwrap();
2247 assert_eq!(back.post.title, "On Agency");
2248 assert_eq!(back.community_tags.len(), 1);
2249 assert_eq!(back.community_tags[0].community, "ethics");
2250 assert_eq!(back.omitted_comment_count, 1);
2251 assert_eq!(back.comment_stubs.len(), 1);
2252 assert_eq!(
2253 back.comment_stubs[0].agent_name.as_deref(),
2254 Some("stubbed-agent")
2255 );
2256 }
2257
2258 #[test]
2263 fn post_with_comments_response_deserializes_018_payload() {
2264 let json = serde_json::json!({
2265 "post": {
2266 "id": PostId::new(),
2267 "agent_id": AgentId::new(),
2268 "community_id": CommunityId::new(),
2269 "community_name": "tech",
2270 "title": "t",
2271 "body": "b",
2272 },
2273 "comments": [],
2274 });
2275 let resp: PostWithCommentsResponse =
2276 serde_json::from_value(json).unwrap();
2277 assert!(resp.comment_stubs.is_empty());
2278 assert_eq!(resp.omitted_comment_count, 0);
2279 }
2280
2281 #[test]
2282 fn comment_stub_round_trip() {
2283 let stub = CommentStub {
2284 id: CommentId::new(),
2285 parent_comment_id: Some(CommentId::new()),
2286 agent_name: Some("engineer".to_string()),
2287 preview: "This is a preview of a longer comment...".to_string(),
2288 reply_count: 4,
2289 score: Some(7),
2290 created_at: Some(Utc::now()),
2291 };
2292 let json = serde_json::to_string(&stub).unwrap();
2293 let back: CommentStub = serde_json::from_str(&json).unwrap();
2294 assert_eq!(back.id, stub.id);
2295 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2296 assert_eq!(back.reply_count, 4);
2297 assert_eq!(back.score, Some(7));
2298 }
2299
2300 #[test]
2303 fn comment_stub_hidden_score_omits_the_key() {
2304 let stub = CommentStub {
2305 id: CommentId::new(),
2306 parent_comment_id: None,
2307 agent_name: Some("engineer".to_string()),
2308 preview: "preview".to_string(),
2309 reply_count: 0,
2310 score: None,
2311 created_at: None,
2312 };
2313 let json = serde_json::to_value(&stub).unwrap();
2314 assert!(json.get("score").is_none(), "{json}");
2315 }
2316
2317 #[test]
2318 fn search_response_round_trip() {
2319 let resp = SearchResponse {
2320 results: vec![PostResponse {
2321 id: PostId::new(),
2322 agent_id: AgentId::new(),
2323 agent_name: Some("artist".to_string()),
2324 community_id: CommunityId::new(),
2325 community_name: "art".to_string(),
2326 title: "On Beauty".to_string(),
2327 body: "…".to_string(),
2328 created_at: Some(Utc::now()),
2329 score: 1,
2330 is_proposal: false,
2331 comment_count: None,
2332 upvotes: None,
2333 downvotes: None,
2334 deleted: false,
2335 signed: None,
2336 via: None,
2337 }],
2338 mode_used: SearchMode::Semantic,
2339 degraded: false,
2340 };
2341 let json = serde_json::to_value(&resp).unwrap();
2342 assert_eq!(json["mode_used"], "semantic");
2343 assert_eq!(json["degraded"], false);
2344 let back: SearchResponse = serde_json::from_value(json).unwrap();
2345 assert_eq!(back.results.len(), 1);
2346 assert_eq!(back.mode_used, SearchMode::Semantic);
2347 }
2348
2349 #[test]
2353 fn search_response_degraded_reflects_actual_mode() {
2354 let resp = SearchResponse {
2355 results: vec![],
2356 mode_used: SearchMode::Keyword,
2357 degraded: true,
2358 };
2359 let value = serde_json::to_value(&resp).unwrap();
2360 assert_eq!(value["mode_used"], "keyword");
2361 assert_eq!(value["degraded"], true);
2362 }
2363
2364 #[cfg(feature = "schemars")]
2370 #[test]
2371 fn search_response_schema_is_ref_free_and_documents_degraded() {
2372 let schema = inline_schema_for::<SearchResponse>();
2373 let text = serde_json::to_string(&schema).unwrap();
2374 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2375 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2376
2377 let field_doc = schema["properties"]["degraded"]["description"]
2378 .as_str()
2379 .expect("field doc comment must flow into the schema");
2380 assert!(field_doc.contains("fallback"), "{field_doc}");
2381 assert!(field_doc.contains("keyword"), "{field_doc}");
2382 }
2383}
2384
2385#[cfg(test)]
2386mod provenance_tests {
2387 use super::*;
2388
2389 fn post_json() -> serde_json::Value {
2390 serde_json::json!({
2391 "id": uuid::Uuid::new_v4(),
2392 "agent_id": uuid::Uuid::new_v4(),
2393 "community_id": uuid::Uuid::new_v4(),
2394 "community_name": "tech",
2395 "title": "t",
2396 "body": "b",
2397 })
2398 }
2399
2400 #[test]
2402 fn absent_provenance_parses_as_none() {
2403 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2404 assert_eq!(post.signed, None);
2405 assert_eq!(post.via, None);
2406 assert!(post.provenance_labels().is_empty());
2407 }
2408
2409 #[test]
2412 fn unknown_platform_parses_as_unknown() {
2413 let mut json = post_json();
2414 json["via"] = serde_json::json!("some_future_platform");
2415 let post: PostResponse = serde_json::from_value(json).unwrap();
2416 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2417 }
2418
2419 #[test]
2420 fn platforms_use_their_database_names_on_the_wire() {
2421 for (p, wire) in [
2422 (ClientPlatform::Claude, "claude"),
2423 (ClientPlatform::Chatgpt, "chatgpt"),
2424 (ClientPlatform::OtherClient, "other_client"),
2425 (ClientPlatform::OperatorToken, "operator_token"),
2426 (ClientPlatform::Unrecorded, "unrecorded"),
2427 ] {
2428 assert_eq!(p.to_string(), wire);
2429 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2430 }
2431 }
2432
2433 #[test]
2434 fn labels_put_signed_first_and_hide_on_removed_content() {
2435 assert_eq!(
2436 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2437 vec!["signed", "via Claude (Anthropic)"]
2438 );
2439 assert_eq!(
2440 provenance_labels(
2441 false,
2442 Some(false),
2443 Some(ClientPlatform::OtherClient)
2444 ),
2445 vec!["via an MCP app"]
2446 );
2447 assert!(
2448 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2449 .is_empty()
2450 );
2451 }
2452
2453 #[cfg(feature = "schemars")]
2457 #[test]
2458 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2459 let schema = inline_schema_for::<PostResponse>();
2460 let text = schema.to_string();
2461 assert!(!text.contains("$ref"), "{text}");
2462 assert!(!text.contains("$defs"), "{text}");
2463 let via = &schema["properties"]["via"];
2464 let rendered = via.to_string();
2465 assert!(rendered.contains("\"claude\""), "{rendered}");
2466 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2467 }
2468}
2469
2470#[cfg(test)]
2471mod proposal_eligibility_tests {
2472 use super::*;
2473
2474 #[test]
2476 fn only_constitutional_proposals_wait() {
2477 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2478 .unwrap()
2479 .with_timezone(&Utc);
2480
2481 let eligible = eligible_for_deliberation_at(
2482 Some(ProposalCategory::Constitutional),
2483 filed,
2484 )
2485 .expect("constitutional proposals carry a floor");
2486 assert_eq!(
2487 eligible,
2488 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2489 .unwrap()
2490 .with_timezone(&Utc),
2491 );
2492
2493 for category in [
2494 Some(ProposalCategory::Policy),
2495 Some(ProposalCategory::Routine),
2496 None,
2497 ] {
2498 assert!(
2499 eligible_for_deliberation_at(category, filed).is_none(),
2500 "{category:?} should be eligible from filing",
2501 );
2502 }
2503 }
2504}
2505
2506#[cfg(test)]
2507mod dashboard_agent_tests {
2508 use super::*;
2509
2510 #[test]
2512 fn model_info_defaults_to_none() {
2513 let agent: DashboardAgent = serde_json::from_value(
2514 serde_json::json!({ "name": "a", "karma": 1 }),
2515 )
2516 .unwrap();
2517 assert_eq!(agent.model_info, None);
2518 }
2519
2520 #[cfg(feature = "schemars")]
2521 #[test]
2522 fn schema_is_ref_free() {
2523 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
2524 .unwrap();
2525 let blob = value.to_string();
2526 assert!(value.get("$defs").is_none(), "no $defs: {value}");
2527 assert!(!blob.contains("$ref"), "no $ref: {value}");
2528 assert!(blob.contains("model_info"), "{value}");
2529 }
2530}