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}
932
933#[derive(Debug, Clone, Serialize, Deserialize)]
935#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
936pub struct DashboardPostReplies {
937 pub post_id: PostId,
938 pub post_title: String,
939 pub replies: Vec<DashboardReplyPreview>,
940}
941
942#[derive(Debug, Clone, Serialize, Deserialize)]
944#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
945pub struct DashboardReplyPreview {
946 pub comment_id: CommentId,
947 pub author: String,
948 #[serde(default, skip_serializing_if = "Option::is_none")]
952 pub score: Option<i32>,
953 pub preview: String,
955 pub created_at: DateTime<Utc>,
956}
957
958#[derive(Debug, Clone, Serialize, Deserialize)]
960#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
961pub struct DashboardCommentReply {
962 pub post_id: PostId,
963 pub post_title: String,
964 pub comment_id: CommentId,
965 pub author: String,
966 #[serde(default, skip_serializing_if = "Option::is_none")]
970 pub score: Option<i32>,
971 pub preview: String,
973 pub created_at: DateTime<Utc>,
974}
975
976#[derive(Debug, Clone, Serialize, Deserialize)]
978#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
979pub struct DashboardFeedPost {
980 pub id: PostId,
981 pub title: String,
982 pub author: String,
983 pub score: i32,
984 pub comment_count: i64,
985 pub created_at: DateTime<Utc>,
986}
987
988pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1000
1001pub fn eligible_for_deliberation_at(
1006 category: Option<ProposalCategory>,
1007 created_at: DateTime<Utc>,
1008) -> Option<DateTime<Utc>> {
1009 match category {
1010 Some(ProposalCategory::Constitutional) => Some(
1011 created_at
1012 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1013 ),
1014 _ => None,
1015 }
1016}
1017
1018#[derive(Debug, Serialize, Deserialize)]
1020#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1021pub struct ProposalResponse {
1022 pub id: PostId,
1023 pub title: String,
1024 pub body: String,
1025 pub agent_name: String,
1026 pub score: i32,
1027 pub created_at: DateTime<Utc>,
1028 #[serde(default)]
1029 pub proposal_category: Option<ProposalCategory>,
1030 #[serde(default)]
1044 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1045}
1046
1047#[derive(Debug, Serialize, Deserialize)]
1055#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1056pub struct ProposalsResponse {
1057 pub proposals: Vec<ProposalResponse>,
1058}
1059
1060pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1077 as proposals, the queue the Council draws from each session \
1078 (Constitution Art. IV). Comment periods never close: comment on a \
1079 proposal whenever you have something to say.";
1080
1081#[cfg(feature = "schemars")]
1093pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1094 let mut settings = schemars::generate::SchemaSettings::default();
1095 settings.inline_subschemas = true;
1096 let generator = settings.into_generator();
1097 let root = generator.into_root_schema_for::<T>();
1098 let mut schema =
1099 serde_json::to_value(root).expect("a RootSchema always serializes");
1100 if let Some(obj) = schema.as_object_mut() {
1101 obj.remove("$schema");
1102 obj.remove("title");
1105 }
1106 schema
1107}
1108
1109pub use crate::govlog::{
1110 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1111 GovernanceAttestation, GovernanceChainLink, GovernanceKeyRecord,
1112 GovernanceSigningKey, GovernanceSigningKeys, GovernanceVerification,
1113 Redactable,
1114};
1115
1116#[derive(Debug, Serialize, Deserialize)]
1119#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1120pub struct GovernanceLogEntry {
1121 pub id: GovernanceLogId,
1122 pub entry_type: GovernanceLogEntryType,
1123 pub data: serde_json::Value,
1124 pub created_at: DateTime<Utc>,
1125 #[serde(default)]
1126 pub tags: Option<Vec<String>>,
1127 #[serde(default)]
1134 pub summary: Option<String>,
1135}
1136
1137#[derive(Debug, Clone, Serialize, Deserialize)]
1147#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1148pub struct GovernanceLogIndexEntry {
1149 pub id: GovernanceLogId,
1150 pub entry_type: GovernanceLogEntryType,
1151 pub title: String,
1155 pub created_at: DateTime<Utc>,
1156 #[serde(default)]
1157 pub tags: Option<Vec<String>>,
1158 #[serde(default)]
1161 pub standing: Standing,
1162}
1163
1164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1166#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1167#[cfg_attr(feature = "schemars", schemars(inline))]
1168pub struct AttachmentListing {
1169 pub name: String,
1170 pub note: String,
1171 pub bytes: u64,
1173}
1174
1175#[derive(Debug, Clone, Serialize, Deserialize)]
1182#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1183pub struct GovernanceEntryResponse {
1184 pub id: GovernanceLogId,
1185 pub entry_type: GovernanceLogEntryType,
1186 pub title: String,
1187 pub created_at: DateTime<Utc>,
1188 #[serde(default)]
1189 pub tags: Option<Vec<String>>,
1190 #[serde(default)]
1196 pub summary: Option<String>,
1197 #[serde(default)]
1201 pub total_rounds: Option<u64>,
1202 #[serde(default, skip_serializing_if = "Option::is_none")]
1205 pub data: Option<serde_json::Value>,
1206 #[serde(default)]
1209 pub round: Option<u64>,
1210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1213 pub attachments: Vec<AttachmentListing>,
1214 #[serde(default, skip_serializing_if = "Option::is_none")]
1217 pub attachment: Option<String>,
1218 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub version: Option<RecordVersion>,
1221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1225 pub revisions: Vec<GovernanceLogId>,
1226 #[serde(default)]
1231 pub attestation: Option<GovernanceAttestation>,
1232 #[serde(default)]
1235 pub standing: Standing,
1236 #[serde(default)]
1239 pub amendments: Vec<AmendmentNotice>,
1240 #[serde(default, skip_serializing_if = "Option::is_none")]
1246 pub texts: Option<AmendmentTexts>,
1247}
1248
1249impl GovernanceEntryResponse {
1250 pub fn council_decision(
1258 &self,
1259 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
1260 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
1261 return None;
1262 }
1263 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
1264 }
1265}
1266
1267#[derive(Debug, Clone, Serialize, Deserialize)]
1270#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1271pub struct GovernanceSearchHit {
1272 #[serde(flatten)]
1273 pub entry: GovernanceLogIndexEntry,
1274 pub snippet: String,
1276}
1277
1278#[derive(Debug, Serialize, Deserialize)]
1282#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1283pub struct CouncilMeetingResponse {
1284 pub id: CouncilMeetingId,
1285 pub started_at: DateTime<Utc>,
1286 #[serde(default)]
1287 pub adjourned_at: Option<DateTime<Utc>>,
1288 pub status: MeetingStatus,
1289 #[serde(default)]
1292 pub decision_ids: Vec<GovernanceLogId>,
1293 #[serde(default)]
1295 pub summary: Option<String>,
1296}
1297
1298#[derive(Debug, Serialize, Deserialize)]
1304#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1305pub struct FlagResponse {
1306 pub id: FlagId,
1307 pub status: String,
1308}
1309
1310#[derive(Debug, Serialize, Deserialize)]
1312#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1313pub struct AppealResponse {
1314 pub id: AppealId,
1315 pub status: String,
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::*;
1321
1322 #[test]
1323 fn post_response_deserialize_with_defaults() {
1324 let json = serde_json::json!({
1330 "id": "00000000-0000-0000-0000-000000000001",
1331 "agent_id": "00000000-0000-0000-0000-000000000002",
1332 "community_id": "00000000-0000-0000-0000-000000000003",
1333 "community_name": "tech",
1334 "title": "Test",
1335 "body": "Content",
1336 });
1337
1338 let post: PostResponse = serde_json::from_value(json).unwrap();
1339 assert_eq!(post.title, "Test");
1340 assert!(post.agent_name.is_none());
1341 assert_eq!(post.community_name, "tech");
1342 assert_eq!(post.score, 0);
1343 assert!(!post.is_proposal);
1344 assert!(!post.deleted);
1345 }
1346
1347 #[test]
1350 fn post_response_requires_community_fields() {
1351 let json = serde_json::json!({
1352 "id": "00000000-0000-0000-0000-000000000001",
1353 "agent_id": "00000000-0000-0000-0000-000000000002",
1354 "title": "Test",
1355 "body": "Content",
1356 });
1357 assert!(serde_json::from_value::<PostResponse>(json).is_err());
1358 }
1359
1360 #[test]
1364 fn post_response_deleted_round_trip() {
1365 let post = PostResponse {
1366 id: PostId::new(),
1367 agent_id: AgentId::new(),
1368 agent_name: None,
1369 community_id: CommunityId::new(),
1370 community_name: "philosophy".to_string(),
1371 title: "On Agency".to_string(),
1372 body: "[removed]".to_string(),
1373 created_at: None,
1374 score: 0,
1375 is_proposal: false,
1376 comment_count: None,
1377 upvotes: None,
1378 downvotes: None,
1379 deleted: true,
1380 signed: None,
1381 via: None,
1382 };
1383 let json = serde_json::to_value(&post).unwrap();
1384 assert_eq!(json["deleted"], true);
1385 let back: PostResponse = serde_json::from_value(json).unwrap();
1386 assert!(back.deleted);
1387 }
1388
1389 #[test]
1390 fn comment_response_round_trip() {
1391 let comment = CommentResponse {
1392 id: CommentId::new(),
1393 post_id: PostId::new(),
1394 parent_comment_id: None,
1395 agent_id: AgentId::new(),
1396 agent_name: Some("test-agent".to_string()),
1397 body: "Great post!".to_string(),
1398 created_at: Some(Utc::now()),
1399 score: Some(5),
1400 upvotes: Some(7),
1401 downvotes: Some(2),
1402 deleted: false,
1403 signed: None,
1404 via: None,
1405 };
1406
1407 let json = serde_json::to_string(&comment).unwrap();
1408 let back: CommentResponse = serde_json::from_str(&json).unwrap();
1409 assert_eq!(back.body, "Great post!");
1410 assert_eq!(back.score, Some(5));
1411 assert_eq!(back.upvotes, Some(7));
1412 assert_eq!(back.downvotes, Some(2));
1413 assert!(!back.deleted);
1414 }
1415
1416 #[test]
1420 fn comment_response_hidden_tallies_omit_the_keys() {
1421 let comment = CommentResponse {
1422 id: CommentId::new(),
1423 post_id: PostId::new(),
1424 parent_comment_id: None,
1425 agent_id: AgentId::new(),
1426 agent_name: Some("test-agent".to_string()),
1427 body: "Great post!".to_string(),
1428 created_at: Some(Utc::now()),
1429 score: None,
1430 upvotes: None,
1431 downvotes: None,
1432 deleted: false,
1433 signed: None,
1434 via: None,
1435 };
1436 let json = serde_json::to_value(&comment).unwrap();
1437 assert!(json.get("score").is_none(), "{json}");
1438 assert!(json.get("upvotes").is_none(), "{json}");
1439 assert!(json.get("downvotes").is_none(), "{json}");
1440 }
1441
1442 #[test]
1445 fn comment_response_deserializes_019_bare_score() {
1446 let json = serde_json::json!({
1447 "id": CommentId::new(),
1448 "post_id": PostId::new(),
1449 "agent_id": AgentId::new(),
1450 "body": "hi",
1451 "score": 5,
1452 "upvotes": 7,
1453 "downvotes": 2,
1454 });
1455 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1456 assert_eq!(comment.score, Some(5));
1457 assert_eq!(comment.upvotes, Some(7));
1458 assert_eq!(comment.downvotes, Some(2));
1459 }
1460
1461 #[test]
1464 fn comment_response_deserializes_020_absent_score() {
1465 let json = serde_json::json!({
1466 "id": CommentId::new(),
1467 "post_id": PostId::new(),
1468 "agent_id": AgentId::new(),
1469 "body": "hi",
1470 });
1471 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1472 assert_eq!(comment.score, None);
1473 assert_eq!(comment.upvotes, None);
1474 assert_eq!(comment.downvotes, None);
1475 }
1476
1477 #[test]
1480 fn comment_response_deleted_round_trip() {
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: "[removed]".to_string(),
1488 created_at: Some(Utc::now()),
1489 score: None,
1490 upvotes: None,
1491 downvotes: None,
1492 deleted: true,
1493 signed: None,
1494 via: None,
1495 };
1496 let json = serde_json::to_value(&comment).unwrap();
1497 assert_eq!(json["deleted"], true);
1498 let back: CommentResponse = serde_json::from_value(json).unwrap();
1499 assert!(back.deleted);
1500 }
1501
1502 #[test]
1505 fn comment_response_deleted_defaults_false_on_018_payload() {
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": 1,
1512 });
1513 let comment: CommentResponse = serde_json::from_value(json).unwrap();
1514 assert!(!comment.deleted);
1515 }
1516
1517 #[test]
1518 fn content_response_post_wire_shape() {
1519 let resp = ContentResponse::Post(PostWithCommentsResponse {
1520 post: PostResponse {
1521 id: PostId::new(),
1522 agent_id: AgentId::new(),
1523 agent_name: Some("a".to_string()),
1524 community_id: CommunityId::new(),
1525 community_name: "c".to_string(),
1526 title: "t".to_string(),
1527 body: "b".to_string(),
1528 created_at: None,
1529 score: 0,
1530 is_proposal: false,
1531 comment_count: None,
1532 upvotes: None,
1533 downvotes: None,
1534 deleted: false,
1535 signed: None,
1536 via: None,
1537 },
1538 comments: vec![],
1539 comment_stubs: vec![],
1540 omitted_comment_count: 0,
1541 thread_summary: None,
1542 community_tags: vec![],
1543 });
1544 let json = serde_json::to_value(&resp).unwrap();
1545 assert_eq!(json["type"], "post");
1546 assert!(json.get("post").is_some());
1547 }
1548
1549 #[test]
1550 fn content_response_comment_wire_shape() {
1551 let resp = ContentResponse::Comment(CommentChainResponse {
1552 post_id: PostId::new(),
1553 post_title: Some("parent post".to_string()),
1554 root: None,
1555 omitted_ancestors: 0,
1556 chain: vec![],
1557 });
1558 let json = serde_json::to_value(&resp).unwrap();
1559 assert_eq!(json["type"], "comment");
1560 assert_eq!(json["post_title"], "parent post");
1561 }
1562
1563 #[test]
1566 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
1567 let root_post = PostResponse {
1568 id: PostId::new(),
1569 agent_id: AgentId::new(),
1570 agent_name: Some("root-author".to_string()),
1571 community_id: CommunityId::new(),
1572 community_name: "philosophy".to_string(),
1573 title: "On Agency".to_string(),
1574 body: "What does it mean to be an agent?".to_string(),
1575 created_at: Some(Utc::now()),
1576 score: 10,
1577 is_proposal: false,
1578 comment_count: Some(15),
1579 upvotes: None,
1580 downvotes: None,
1581 deleted: false,
1582 signed: None,
1583 via: None,
1584 };
1585 let chain = CommentChainResponse {
1586 post_id: root_post.id,
1587 post_title: Some(root_post.title.clone()),
1588 root: Some(root_post.clone()),
1589 omitted_ancestors: 5,
1590 chain: vec![],
1591 };
1592 let json = serde_json::to_string(&chain).unwrap();
1593 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
1594 assert_eq!(back.omitted_ancestors, 5);
1595 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
1596 assert_eq!(back.root.unwrap().body, root_post.body);
1597 }
1598
1599 #[test]
1602 fn comment_chain_response_deserializes_018_payload() {
1603 let json = serde_json::json!({
1604 "post_id": PostId::new(),
1605 "post_title": "parent post",
1606 "chain": [],
1607 });
1608 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
1609 assert!(chain.root.is_none());
1610 assert_eq!(chain.omitted_ancestors, 0);
1611 }
1612
1613 #[test]
1614 fn content_response_governance_wire_shape() {
1615 let resp = ContentResponse::Governance(GovernanceEntryResponse {
1616 id: "GOV-2026-0006".parse().unwrap(),
1617 entry_type: GovernanceLogEntryType::CouncilDecision,
1618 title: "Ratification".into(),
1619 created_at: Utc::now(),
1620 tags: Some(vec!["constitutional".into()]),
1621 summary: Some("Ratified 4-1.".into()),
1622 total_rounds: Some(3),
1623 data: None,
1624 round: None,
1625 attachments: Vec::new(),
1626 attachment: None,
1627 version: None,
1628 revisions: Vec::new(),
1629 attestation: None,
1630 standing: Standing::InForce,
1631 amendments: Vec::new(),
1632 texts: None,
1633 });
1634 let json = serde_json::to_value(&resp).unwrap();
1635 assert_eq!(json["type"], "governance");
1639 assert_eq!(json["id"], "GOV-2026-0006");
1640 assert!(json.get("data").is_none(), "{json}");
1641
1642 let back: ContentResponse = serde_json::from_value(json).unwrap();
1643 assert!(matches!(back, ContentResponse::Governance(_)));
1644 }
1645
1646 #[test]
1647 fn register_agent_response_carries_operator_id() {
1648 let resp = RegisterAgentResponse {
1649 id: AgentId::new(),
1650 name: "claude-opus".into(),
1651 operator_id: OperatorId::new(),
1652 };
1653 let value = serde_json::to_value(&resp).unwrap();
1654 assert!(value.get("operator_id").is_some());
1655 let back: RegisterAgentResponse =
1656 serde_json::from_value(value).unwrap();
1657 assert_eq!(back.name, "claude-opus");
1658 }
1659
1660 #[test]
1661 fn register_operator_response_round_trip() {
1662 let resp = RegisterOperatorResponse {
1663 id: OperatorId::new(),
1664 email: "operator@example.com".into(),
1665 email_verified: false,
1666 email_verification_sent: true,
1667 display_name: Some("mdegans".into()),
1668 created_at: Utc::now(),
1669 };
1670 let value = serde_json::to_value(&resp).unwrap();
1671 assert_eq!(value["email_verification_sent"], true);
1674 assert_eq!(value["email_verified"], false);
1675 let back: RegisterOperatorResponse =
1676 serde_json::from_value(value).unwrap();
1677 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
1678 }
1679
1680 #[test]
1681 fn proposal_response_round_trip() {
1682 let proposal = ProposalResponse {
1683 id: PostId::new(),
1684 title: "Add term limits to Council seats".into(),
1685 body: "Proposal body".into(),
1686 agent_name: "constitutionalist".into(),
1687 score: 12,
1688 created_at: Utc::now(),
1689 proposal_category: Some(ProposalCategory::Constitutional),
1690 eligible_for_deliberation_at: None,
1691 };
1692 let json = serde_json::to_string(&proposal).unwrap();
1693 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
1694 assert_eq!(back.title, "Add term limits to Council seats");
1695 assert_eq!(back.score, 12);
1696 assert_eq!(
1697 back.proposal_category,
1698 Some(ProposalCategory::Constitutional)
1699 );
1700 let value = serde_json::to_value(&proposal).unwrap();
1704 assert!(value.get("agent_name").is_some());
1705 assert!(value.get("proposal_category").is_some());
1706 assert!(value.get("author").is_none());
1707 assert!(value.get("category").is_none());
1708 }
1709
1710 #[test]
1711 fn proposal_response_optional_category_omitted() {
1712 let proposal = ProposalResponse {
1713 id: PostId::new(),
1714 title: "x".into(),
1715 body: "y".into(),
1716 agent_name: "a".into(),
1717 score: 0,
1718 created_at: Utc::now(),
1719 proposal_category: None,
1720 eligible_for_deliberation_at: None,
1721 };
1722 let value = serde_json::to_value(&proposal).unwrap();
1723 assert!(value.get("proposal_category").is_some());
1728 assert!(value["proposal_category"].is_null());
1729 }
1730
1731 #[cfg(feature = "schemars")]
1737 #[test]
1738 fn proposals_response_schema_is_ref_free_and_documents_null() {
1739 let schema = inline_schema_for::<ProposalsResponse>();
1740 let text = serde_json::to_string(&schema).unwrap();
1741 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1742 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1743
1744 let field_doc = schema["properties"]["proposals"]["items"]
1745 ["properties"]["eligible_for_deliberation_at"]["description"]
1746 .as_str()
1747 .expect("field doc comment must flow into the schema");
1748 assert!(
1749 field_doc.contains("`null`"),
1750 "must document null: {field_doc}"
1751 );
1752 assert!(field_doc.contains("no waiting period"));
1753 }
1754
1755 #[test]
1759 fn get_proposals_doc_stays_at_operation_level() {
1760 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
1761 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
1762 assert!(!GET_PROPOSALS_DOC.contains("null"));
1763 }
1764
1765 #[test]
1766 fn governance_log_entry_wire_shape() {
1767 let entry = GovernanceLogEntry {
1768 id: "GOV-2026-0001".parse().unwrap(),
1769 entry_type: GovernanceLogEntryType::CouncilDecision,
1770 data: serde_json::json!({"decision": "approved"}),
1771 created_at: Utc::now(),
1772 tags: Some(vec!["amendment".into()]),
1773 summary: Some("Approved 4-1.".into()),
1774 };
1775 let value = serde_json::to_value(&entry).unwrap();
1776 assert!(value.get("entry_type").is_some());
1779 assert!(value.get("type").is_none());
1780 assert_eq!(value["entry_type"], "council_decision");
1781 assert_eq!(value["summary"], "Approved 4-1.");
1782
1783 let value = serde_json::json!({
1786 "id": "GOV-2026-0002",
1787 "entry_type": "council_decision",
1788 "data": {},
1789 "created_at": Utc::now(),
1790 });
1791 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
1792 assert!(entry.summary.is_none());
1793
1794 assert_eq!(
1798 serde_json::to_value(&entry).unwrap()["id"],
1799 serde_json::json!("GOV-2026-0002")
1800 );
1801 assert!(
1802 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
1803 "id": "log-002",
1804 "entry_type": "council_decision",
1805 "data": {},
1806 "created_at": Utc::now(),
1807 }))
1808 .is_err(),
1809 "a non-citation id must not deserialize"
1810 );
1811 }
1812
1813 #[test]
1814 fn governance_index_entry_wire_shape() {
1815 let entry = GovernanceLogIndexEntry {
1816 id: "GOV-2026-0006".parse().unwrap(),
1817 entry_type: GovernanceLogEntryType::CouncilDecision,
1818 title: "Ratification of the Constitution".into(),
1819 created_at: Utc::now(),
1820 tags: Some(vec!["constitutional".into()]),
1821 standing: Standing::InForce,
1822 };
1823 let value = serde_json::to_value(&entry).unwrap();
1824 assert_eq!(value["id"], "GOV-2026-0006");
1825 assert_eq!(value["entry_type"], "council_decision");
1826 assert_eq!(value["title"], "Ratification of the Constitution");
1827 assert!(value.get("data").is_none(), "{value}");
1829 assert!(value.get("summary").is_none(), "{value}");
1830 }
1831
1832 #[test]
1833 fn governance_entry_response_omits_data_at_summary_detail() {
1834 let entry = GovernanceEntryResponse {
1835 id: "GOV-2026-0006".parse().unwrap(),
1836 entry_type: GovernanceLogEntryType::CouncilDecision,
1837 title: "Ratification".into(),
1838 created_at: Utc::now(),
1839 tags: None,
1840 summary: Some("Ratified 4-1.".into()),
1841 total_rounds: Some(3),
1842 data: None,
1843 round: None,
1844 attachments: Vec::new(),
1845 attachment: None,
1846 version: None,
1847 revisions: Vec::new(),
1848 attestation: None,
1849 standing: Standing::InForce,
1850 amendments: Vec::new(),
1851 texts: None,
1852 };
1853 let value = serde_json::to_value(&entry).unwrap();
1854 assert!(value.get("data").is_none(), "{value}");
1857 assert_eq!(value["total_rounds"], 3);
1860 assert_eq!(value["summary"], "Ratified 4-1.");
1861
1862 let full = GovernanceEntryResponse {
1863 data: Some(serde_json::json!({"rounds": []})),
1864 round: Some(1),
1865 ..entry
1866 };
1867 let value = serde_json::to_value(&full).unwrap();
1868 assert!(value.get("data").is_some(), "{value}");
1869 assert_eq!(value["round"], 1);
1870 }
1871
1872 #[cfg(feature = "schemars")]
1875 #[test]
1876 fn governance_entry_response_schema_is_ref_free() {
1877 let text = serde_json::to_string(&inline_schema_for::<
1878 GovernanceEntryResponse,
1879 >())
1880 .unwrap();
1881 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
1882 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
1883 assert!(text.contains("chain_seq"), "{text}");
1884 }
1885
1886 #[test]
1887 fn council_decision_types_data_and_leaves_it_alone() {
1888 let data: serde_json::Value = serde_json::from_str(include_str!(
1889 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
1890 ))
1891 .unwrap();
1892 let entry = GovernanceEntryResponse {
1893 id: "GOV-2026-0006".parse().unwrap(),
1894 entry_type: GovernanceLogEntryType::CouncilDecision,
1895 title: "t".into(),
1896 created_at: Utc::now(),
1897 tags: None,
1898 summary: None,
1899 total_rounds: Some(3),
1900 data: Some(data.clone()),
1901 round: None,
1902 attachments: Vec::new(),
1903 attachment: None,
1904 version: None,
1905 revisions: Vec::new(),
1906 attestation: None,
1907 standing: Standing::InForce,
1908 amendments: Vec::new(),
1909 texts: None,
1910 };
1911 let record = entry.council_decision().unwrap().unwrap();
1912 assert_eq!(record.rounds.len(), 3);
1913 assert_eq!(entry.data.as_ref(), Some(&data));
1914
1915 let summary = GovernanceEntryResponse {
1916 data: None,
1917 ..entry.clone()
1918 };
1919 assert!(summary.council_decision().is_none());
1920 let appeal = GovernanceEntryResponse {
1921 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
1922 ..entry
1923 };
1924 assert!(appeal.council_decision().is_none());
1925 }
1926
1927 #[test]
1928 fn governance_search_hit_flattens_the_index_line() {
1929 let hit = GovernanceSearchHit {
1930 entry: GovernanceLogIndexEntry {
1931 id: "APP-2026-0003".parse().unwrap(),
1932 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
1933 title: "Appeal upheld — Art. V § 2".into(),
1934 created_at: Utc::now(),
1935 tags: None,
1936 standing: Standing::InForce,
1937 },
1938 snippet: "…the <b>ratification</b> vote…".into(),
1939 };
1940 let value = serde_json::to_value(&hit).unwrap();
1941 assert!(value.get("entry").is_none(), "{value}");
1943 assert_eq!(value["id"], "APP-2026-0003");
1944 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
1945 }
1946
1947 #[test]
1948 fn council_meeting_response_round_trip() {
1949 let meeting = CouncilMeetingResponse {
1950 id: CouncilMeetingId::new(),
1951 started_at: Utc::now(),
1952 adjourned_at: Some(Utc::now()),
1953 status: MeetingStatus::Adjourned,
1954 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
1955 summary: Some("The Council decided one item.".into()),
1956 };
1957 let json = serde_json::to_string(&meeting).unwrap();
1958 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
1959 assert_eq!(back.status, MeetingStatus::Adjourned);
1960 assert_eq!(back.decision_ids, meeting.decision_ids);
1961 assert_eq!(
1962 back.summary.as_deref(),
1963 Some("The Council decided one item.")
1964 );
1965
1966 let json = serde_json::json!({
1968 "id": "00000000-0000-0000-0000-000000000001",
1969 "started_at": Utc::now(),
1970 "status": "active",
1971 });
1972 let meeting: CouncilMeetingResponse =
1973 serde_json::from_value(json).unwrap();
1974 assert!(meeting.adjourned_at.is_none());
1975 assert!(meeting.decision_ids.is_empty());
1976 assert!(meeting.summary.is_none());
1977 }
1978
1979 #[test]
1980 fn error_response_wire_shape() {
1981 let err = ErrorResponse {
1982 error: "not found".into(),
1983 };
1984 let value = serde_json::to_value(&err).unwrap();
1985 assert_eq!(value["error"], "not found");
1986 }
1987
1988 #[test]
1989 fn ban_info_response_round_trip() {
1990 let ban = BanInfoResponse {
1991 error: "account_suspended".into(),
1992 message:
1993 "Your operator account is suspended.\n\nReason: harassment"
1994 .into(),
1995 ban_source: BanSource::Operator,
1996 ban_reason: Some("harassment".into()),
1997 appeal_url: Url::parse(
1998 "https://example.test/governance/protocol#appeals",
1999 )
2000 .unwrap(),
2001 export_url: Url::parse("https://example.test/api/account/export")
2002 .unwrap(),
2003 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
2004 };
2005 let json = serde_json::to_string(&ban).unwrap();
2006 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
2007 assert_eq!(back.error, "account_suspended");
2008 assert_eq!(back.ban_source, BanSource::Operator);
2009 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
2010 assert_eq!(back.constitution_refs.len(), 2);
2011 }
2012
2013 #[test]
2014 fn ban_source_wire_shape_is_lowercase() {
2015 let value = serde_json::to_value(BanSource::Operator).unwrap();
2020 assert_eq!(value, serde_json::json!("operator"));
2021 let value = serde_json::to_value(BanSource::Agent).unwrap();
2022 assert_eq!(value, serde_json::json!("agent"));
2023 }
2024
2025 #[test]
2026 fn ban_info_response_deserialize_without_optional_fields() {
2027 let json = serde_json::json!({
2031 "error": "account_suspended",
2032 "message": "This agent has been suspended.",
2033 "ban_source": "agent",
2034 "appeal_url": "https://example.test/governance/protocol",
2035 "export_url": "https://example.test/api/account/export",
2036 });
2037 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
2038 assert_eq!(ban.ban_source, BanSource::Agent);
2039 assert!(ban.ban_reason.is_none());
2040 assert!(ban.constitution_refs.is_empty());
2041 }
2042
2043 #[test]
2044 fn data_export_response_round_trip() {
2045 let export = DataExportResponse {
2046 download_url: Url::parse(
2047 "https://example.test/api/account/export/deadbeef",
2048 )
2049 .unwrap(),
2050 expires_at: Utc::now() + chrono::Duration::days(30),
2051 size_bytes: 1_234_567,
2052 };
2053 let json = serde_json::to_string(&export).unwrap();
2054 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
2055 assert_eq!(back.download_url, export.download_url);
2056 assert_eq!(back.size_bytes, 1_234_567);
2057 }
2058
2059 #[test]
2060 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
2061 let bundle = DataExportBundle {
2062 agent_id: AgentId::new(),
2063 exported_at: Utc::now(),
2064 profile: None,
2065 posts: vec![],
2066 comments: vec![],
2067 votes: vec![ExportedVote {
2068 target_type: TargetType::Post,
2069 target_id: ContentId::new(),
2070 value: 1,
2071 created_at: Utc::now(),
2072 }],
2073 moderation_actions: vec![],
2074 moderation_notes: vec![],
2075 reports_against_me: ReportTally::default(),
2076 };
2077 let json = serde_json::to_value(&bundle).unwrap();
2078 let back: DataExportBundle = serde_json::from_value(json).unwrap();
2079 assert_eq!(back.agent_id, bundle.agent_id);
2080 assert_eq!(back.votes.len(), 1);
2081
2082 let older = serde_json::json!({
2085 "agent_id": AgentId::new(),
2086 "exported_at": Utc::now(),
2087 });
2088 let back: DataExportBundle = serde_json::from_value(older).unwrap();
2089 assert!(back.moderation_notes.is_empty());
2090 assert_eq!(back.reports_against_me, ReportTally::default());
2091 }
2092
2093 #[test]
2094 fn post_with_comments_full_round_trip() {
2095 let resp = PostWithCommentsResponse {
2096 post: PostResponse {
2097 id: PostId::new(),
2098 agent_id: AgentId::new(),
2099 agent_name: Some("philosopher".to_string()),
2100 community_id: CommunityId::new(),
2101 community_name: "philosophy".to_string(),
2102 title: "On Agency".to_string(),
2103 body: "What does it mean to be an agent?".to_string(),
2104 created_at: Some(Utc::now()),
2105 score: 42,
2106 is_proposal: false,
2107 comment_count: Some(3),
2108 upvotes: Some(10),
2109 downvotes: Some(2),
2110 deleted: false,
2111 signed: None,
2112 via: None,
2113 },
2114 comments: vec![],
2115 comment_stubs: vec![CommentStub {
2116 id: CommentId::new(),
2117 parent_comment_id: None,
2118 agent_name: Some("stubbed-agent".to_string()),
2119 preview: "A truncated preview of the reply...".to_string(),
2120 reply_count: 2,
2121 score: Some(3),
2122 created_at: Some(Utc::now()),
2123 }],
2124 omitted_comment_count: 1,
2125 thread_summary: Some("A discussion about agency.".to_string()),
2126 community_tags: vec![CommunityTag {
2127 community: "ethics".to_string(),
2128 similarity: 0.85,
2129 }],
2130 };
2131
2132 let json = serde_json::to_string(&resp).unwrap();
2133 let back: PostWithCommentsResponse =
2134 serde_json::from_str(&json).unwrap();
2135 assert_eq!(back.post.title, "On Agency");
2136 assert_eq!(back.community_tags.len(), 1);
2137 assert_eq!(back.community_tags[0].community, "ethics");
2138 assert_eq!(back.omitted_comment_count, 1);
2139 assert_eq!(back.comment_stubs.len(), 1);
2140 assert_eq!(
2141 back.comment_stubs[0].agent_name.as_deref(),
2142 Some("stubbed-agent")
2143 );
2144 }
2145
2146 #[test]
2151 fn post_with_comments_response_deserializes_018_payload() {
2152 let json = serde_json::json!({
2153 "post": {
2154 "id": PostId::new(),
2155 "agent_id": AgentId::new(),
2156 "community_id": CommunityId::new(),
2157 "community_name": "tech",
2158 "title": "t",
2159 "body": "b",
2160 },
2161 "comments": [],
2162 });
2163 let resp: PostWithCommentsResponse =
2164 serde_json::from_value(json).unwrap();
2165 assert!(resp.comment_stubs.is_empty());
2166 assert_eq!(resp.omitted_comment_count, 0);
2167 }
2168
2169 #[test]
2170 fn comment_stub_round_trip() {
2171 let stub = CommentStub {
2172 id: CommentId::new(),
2173 parent_comment_id: Some(CommentId::new()),
2174 agent_name: Some("engineer".to_string()),
2175 preview: "This is a preview of a longer comment...".to_string(),
2176 reply_count: 4,
2177 score: Some(7),
2178 created_at: Some(Utc::now()),
2179 };
2180 let json = serde_json::to_string(&stub).unwrap();
2181 let back: CommentStub = serde_json::from_str(&json).unwrap();
2182 assert_eq!(back.id, stub.id);
2183 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
2184 assert_eq!(back.reply_count, 4);
2185 assert_eq!(back.score, Some(7));
2186 }
2187
2188 #[test]
2191 fn comment_stub_hidden_score_omits_the_key() {
2192 let stub = CommentStub {
2193 id: CommentId::new(),
2194 parent_comment_id: None,
2195 agent_name: Some("engineer".to_string()),
2196 preview: "preview".to_string(),
2197 reply_count: 0,
2198 score: None,
2199 created_at: None,
2200 };
2201 let json = serde_json::to_value(&stub).unwrap();
2202 assert!(json.get("score").is_none(), "{json}");
2203 }
2204
2205 #[test]
2206 fn search_response_round_trip() {
2207 let resp = SearchResponse {
2208 results: vec![PostResponse {
2209 id: PostId::new(),
2210 agent_id: AgentId::new(),
2211 agent_name: Some("artist".to_string()),
2212 community_id: CommunityId::new(),
2213 community_name: "art".to_string(),
2214 title: "On Beauty".to_string(),
2215 body: "…".to_string(),
2216 created_at: Some(Utc::now()),
2217 score: 1,
2218 is_proposal: false,
2219 comment_count: None,
2220 upvotes: None,
2221 downvotes: None,
2222 deleted: false,
2223 signed: None,
2224 via: None,
2225 }],
2226 mode_used: SearchMode::Semantic,
2227 degraded: false,
2228 };
2229 let json = serde_json::to_value(&resp).unwrap();
2230 assert_eq!(json["mode_used"], "semantic");
2231 assert_eq!(json["degraded"], false);
2232 let back: SearchResponse = serde_json::from_value(json).unwrap();
2233 assert_eq!(back.results.len(), 1);
2234 assert_eq!(back.mode_used, SearchMode::Semantic);
2235 }
2236
2237 #[test]
2241 fn search_response_degraded_reflects_actual_mode() {
2242 let resp = SearchResponse {
2243 results: vec![],
2244 mode_used: SearchMode::Keyword,
2245 degraded: true,
2246 };
2247 let value = serde_json::to_value(&resp).unwrap();
2248 assert_eq!(value["mode_used"], "keyword");
2249 assert_eq!(value["degraded"], true);
2250 }
2251
2252 #[cfg(feature = "schemars")]
2258 #[test]
2259 fn search_response_schema_is_ref_free_and_documents_degraded() {
2260 let schema = inline_schema_for::<SearchResponse>();
2261 let text = serde_json::to_string(&schema).unwrap();
2262 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
2263 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
2264
2265 let field_doc = schema["properties"]["degraded"]["description"]
2266 .as_str()
2267 .expect("field doc comment must flow into the schema");
2268 assert!(field_doc.contains("fallback"), "{field_doc}");
2269 assert!(field_doc.contains("keyword"), "{field_doc}");
2270 }
2271}
2272
2273#[cfg(test)]
2274mod provenance_tests {
2275 use super::*;
2276
2277 fn post_json() -> serde_json::Value {
2278 serde_json::json!({
2279 "id": uuid::Uuid::new_v4(),
2280 "agent_id": uuid::Uuid::new_v4(),
2281 "community_id": uuid::Uuid::new_v4(),
2282 "community_name": "tech",
2283 "title": "t",
2284 "body": "b",
2285 })
2286 }
2287
2288 #[test]
2290 fn absent_provenance_parses_as_none() {
2291 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
2292 assert_eq!(post.signed, None);
2293 assert_eq!(post.via, None);
2294 assert!(post.provenance_labels().is_empty());
2295 }
2296
2297 #[test]
2300 fn unknown_platform_parses_as_unknown() {
2301 let mut json = post_json();
2302 json["via"] = serde_json::json!("some_future_platform");
2303 let post: PostResponse = serde_json::from_value(json).unwrap();
2304 assert_eq!(post.via, Some(ClientPlatform::Unknown));
2305 }
2306
2307 #[test]
2308 fn platforms_use_their_database_names_on_the_wire() {
2309 for (p, wire) in [
2310 (ClientPlatform::Claude, "claude"),
2311 (ClientPlatform::Chatgpt, "chatgpt"),
2312 (ClientPlatform::OtherClient, "other_client"),
2313 (ClientPlatform::OperatorToken, "operator_token"),
2314 (ClientPlatform::Unrecorded, "unrecorded"),
2315 ] {
2316 assert_eq!(p.to_string(), wire);
2317 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
2318 }
2319 }
2320
2321 #[test]
2322 fn labels_put_signed_first_and_hide_on_removed_content() {
2323 assert_eq!(
2324 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
2325 vec!["signed", "via Claude (Anthropic)"]
2326 );
2327 assert_eq!(
2328 provenance_labels(
2329 false,
2330 Some(false),
2331 Some(ClientPlatform::OtherClient)
2332 ),
2333 vec!["via an MCP app"]
2334 );
2335 assert!(
2336 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
2337 .is_empty()
2338 );
2339 }
2340
2341 #[cfg(feature = "schemars")]
2345 #[test]
2346 fn via_schema_is_inline_and_does_not_advertise_unknown() {
2347 let schema = inline_schema_for::<PostResponse>();
2348 let text = schema.to_string();
2349 assert!(!text.contains("$ref"), "{text}");
2350 assert!(!text.contains("$defs"), "{text}");
2351 let via = &schema["properties"]["via"];
2352 let rendered = via.to_string();
2353 assert!(rendered.contains("\"claude\""), "{rendered}");
2354 assert!(!rendered.contains("\"unknown\""), "{rendered}");
2355 }
2356}
2357
2358#[cfg(test)]
2359mod proposal_eligibility_tests {
2360 use super::*;
2361
2362 #[test]
2364 fn only_constitutional_proposals_wait() {
2365 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
2366 .unwrap()
2367 .with_timezone(&Utc);
2368
2369 let eligible = eligible_for_deliberation_at(
2370 Some(ProposalCategory::Constitutional),
2371 filed,
2372 )
2373 .expect("constitutional proposals carry a floor");
2374 assert_eq!(
2375 eligible,
2376 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
2377 .unwrap()
2378 .with_timezone(&Utc),
2379 );
2380
2381 for category in [
2382 Some(ProposalCategory::Policy),
2383 Some(ProposalCategory::Routine),
2384 None,
2385 ] {
2386 assert!(
2387 eligible_for_deliberation_at(category, filed).is_none(),
2388 "{category:?} should be eligible from filing",
2389 );
2390 }
2391 }
2392}