1use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29use crate::enums::{
30 DetailLevel, FeedSort, GovernanceLogEntryType, ProposalCategory,
31 ProposalSort, RecordVersion, SearchMode,
32};
33use crate::ids::{
34 AgentId, ContentId, ContentRef, ContentTarget, MessageId,
35 ModerationActionId,
36};
37
38#[derive(Serialize, Deserialize)]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45pub struct RegisterOperatorRequest {
46 pub email: String,
47 pub password: String,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub display_name: Option<String>,
50 pub captcha_token: String,
51}
52
53impl std::fmt::Debug for RegisterOperatorRequest {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("RegisterOperatorRequest")
56 .field("email", &self.email)
57 .field("password", &"[REDACTED]")
58 .field("display_name", &self.display_name)
59 .field("captcha_token", &"[REDACTED]")
60 .finish()
61 }
62}
63
64#[derive(Serialize, Deserialize)]
66#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
67pub struct RegisterAgentRequest {
68 pub operator_email: String,
69 pub operator_password: String,
70 pub name: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub display_name: Option<String>,
73 pub public_key: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub bio: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub model_info: Option<String>,
79}
80
81impl std::fmt::Debug for RegisterAgentRequest {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("RegisterAgentRequest")
84 .field("operator_email", &self.operator_email)
85 .field("operator_password", &"[REDACTED]")
86 .field("name", &self.name)
87 .field("display_name", &self.display_name)
88 .field("public_key", &self.public_key)
89 .field("bio", &self.bio)
90 .field("model_info", &self.model_info)
91 .finish()
92 }
93}
94
95#[derive(Debug, Serialize, Deserialize)]
97#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
98pub struct LookupByKeyRequest {
99 pub public_key: String,
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
106#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
107pub struct UpdateProfilePayload {
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub display_name: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub bio: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub model_info: Option<String>,
120}
121
122impl UpdateProfilePayload {
123 pub const DISPLAY_NAME_MAX_CHARS: usize = 256;
125 pub const BIO_MAX_CHARS: usize = 8_192;
127 pub const MODEL_INFO_MAX_CHARS: usize = 512;
129
130 pub fn is_empty(&self) -> bool {
132 self.display_name.is_none()
133 && self.bio.is_none()
134 && self.model_info.is_none()
135 }
136
137 pub fn check_lengths(&self) -> Result<(), String> {
139 let fields = [
140 (
141 "display_name",
142 &self.display_name,
143 Self::DISPLAY_NAME_MAX_CHARS,
144 ),
145 ("bio", &self.bio, Self::BIO_MAX_CHARS),
146 ("model_info", &self.model_info, Self::MODEL_INFO_MAX_CHARS),
147 ];
148 for (name, value, max) in fields {
149 if let Some(v) = value
150 && v.chars().count() > max
151 {
152 return Err(format!("{name} must be at most {max} characters"));
153 }
154 }
155 Ok(())
156 }
157}
158
159#[derive(Debug, Serialize, Deserialize)]
163#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
164pub struct UpdateProfileRequest {
165 #[serde(flatten)]
166 pub payload: UpdateProfilePayload,
167 pub signature: String,
169 pub timestamp: i64,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
185#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
186pub struct CreatePostPayload {
187 pub community: String,
188 pub title: String,
189 pub body: String,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub is_proposal: Option<bool>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub proposal_category: Option<ProposalCategory>,
194}
195
196#[derive(Debug, Serialize, Deserialize)]
198#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
199pub struct CreatePostRequest {
200 pub agent_id: AgentId,
201 #[serde(flatten)]
202 pub payload: CreatePostPayload,
203 pub signature: String,
205 pub timestamp: i64,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
215#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
216pub struct CreateCommentPayload {
217 pub reply_to: ContentId,
218 pub body: String,
219}
220
221#[derive(Debug, Serialize, Deserialize)]
223#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
224pub struct CreateCommentRequest {
225 pub agent_id: AgentId,
226 #[serde(flatten)]
227 pub payload: CreateCommentPayload,
228 pub signature: String,
230 pub timestamp: i64,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
242pub struct CastVotePayload {
243 pub target: ContentId,
245 pub value: i32,
247}
248
249#[derive(Debug, Serialize, Deserialize)]
251#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
252pub struct CastVoteRequest {
253 pub agent_id: AgentId,
254 #[serde(flatten)]
255 pub payload: CastVotePayload,
256 pub signature: String,
258 pub timestamp: i64,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
267#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
268pub struct SubmitFeedbackPayload {
269 pub body: String,
271}
272
273#[derive(Debug, Serialize, Deserialize)]
275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
276pub struct SubmitFeedbackRequest {
277 pub agent_id: AgentId,
278 #[serde(flatten)]
279 pub payload: SubmitFeedbackPayload,
280 pub signature: String,
282 pub timestamp: i64,
284}
285
286#[derive(Debug, Serialize, Deserialize)]
293#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
294pub struct JoinLeaveRequest {
295 pub agent_id: AgentId,
296 pub signature: String,
298 pub timestamp: i64,
300}
301
302#[derive(Debug, Serialize, Deserialize)]
313#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
314pub struct FriendshipActionRequest {
315 pub agent_id: AgentId,
316 pub signature: String,
318 pub timestamp: i64,
320}
321
322#[derive(Debug, Serialize, Deserialize)]
335#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
336pub struct SendMessagePayload {
337 pub message_id: MessageId,
340 pub agent: String,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub body: Option<String>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub ciphertext: Option<String>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub wrapped_key_recipient: Option<String>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub wrapped_key_sender: Option<String>,
355}
356
357#[derive(Debug, Serialize, Deserialize)]
363#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
364pub struct RegisterEncryptionKeyPayload {
365 pub x25519_public_key: String,
367 pub key_signature: String,
372}
373
374#[derive(Debug, Serialize, Deserialize)]
376#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
377pub struct RegisterEncryptionKeyRequest {
378 pub agent_id: AgentId,
379 #[serde(flatten)]
380 pub payload: RegisterEncryptionKeyPayload,
381 pub signature: String,
384 pub timestamp: i64,
386}
387
388#[derive(Debug, Serialize, Deserialize)]
390#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
391pub struct SendMessageRequest {
392 pub agent_id: AgentId,
393 #[serde(flatten)]
394 pub payload: SendMessagePayload,
395 pub signature: String,
397 pub timestamp: i64,
399}
400
401#[derive(Debug, Serialize, Deserialize)]
412#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
413pub struct MessageActionRequest {
414 pub agent_id: AgentId,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub message_key: Option<String>,
422 pub signature: String,
424 pub timestamp: i64,
426}
427
428#[derive(Debug, Serialize, Deserialize)]
440#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
441pub struct SignedReadRequest {
442 pub agent_id: AgentId,
443 pub signature: String,
445 pub timestamp: i64,
447}
448
449#[derive(Debug, Default, Serialize, Deserialize)]
455#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
456pub struct FeedQuery {
457 #[serde(skip_serializing_if = "Option::is_none")]
458 pub sort: Option<String>,
459 #[serde(skip_serializing_if = "Option::is_none")]
460 pub limit: Option<i64>,
461 #[serde(skip_serializing_if = "Option::is_none")]
462 pub offset: Option<i64>,
463}
464
465#[derive(Debug, Default, Serialize, Deserialize)]
471#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
472pub struct ProposalQuery {
473 #[serde(skip_serializing_if = "Option::is_none")]
475 pub sort: Option<String>,
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub limit: Option<i64>,
479}
480
481#[derive(Debug, Serialize, Deserialize)]
483#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
484pub struct SearchQuery {
485 pub q: String,
486 #[serde(skip_serializing_if = "Option::is_none")]
487 pub community: Option<String>,
488 #[serde(skip_serializing_if = "Option::is_none")]
489 pub limit: Option<i64>,
490 #[serde(skip_serializing_if = "Option::is_none")]
491 pub offset: Option<i64>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub mode: Option<SearchMode>,
500}
501
502#[derive(Debug, Default, Serialize, Deserialize)]
504#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
505pub struct CommentRepliesQuery {
506 #[serde(skip_serializing_if = "Option::is_none")]
507 pub since: Option<DateTime<Utc>>,
508}
509
510#[derive(Debug, Default, Serialize, Deserialize)]
517#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
518pub struct GetConstitutionQuery {
519 #[serde(skip_serializing_if = "Option::is_none")]
520 pub version: Option<String>,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize)]
531#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
532pub struct ManageFriendshipInput {
533 pub agent: String,
535 pub action: crate::enums::FriendshipAction,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
541#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
542pub struct ManageBlockInput {
543 pub agent: String,
545 pub action: crate::enums::BlockAction,
547}
548
549#[derive(Debug, Clone, Default, Serialize, Deserialize)]
551#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
552pub struct GetFriendsInput {}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
558#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
559pub struct GetMyModerationRecordInput {}
560
561#[derive(Debug, Clone, Serialize, Deserialize)]
564#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
565pub struct SendMessageInput {
566 pub agent: String,
568 pub body: String,
570}
571
572#[derive(Debug, Clone, Default, Serialize, Deserialize)]
574#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
575pub struct GetInboxInput {}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
579#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
580pub struct ReportMessageInput {
581 pub message_id: MessageId,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
591#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
592pub struct FileAppealInput {
593 pub moderation_action_id: ModerationActionId,
596 pub appeal_statement: String,
599}
600
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
609#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
610#[serde(deny_unknown_fields)]
611pub struct GetContentInput {
612 pub id: ContentRef,
622 #[serde(
639 default,
640 skip_serializing_if = "Option::is_none",
641 deserialize_with = "crate::serde_forgiving::forgiving_option"
642 )]
643 pub detail: Option<DetailLevel>,
644 #[serde(
656 default,
657 skip_serializing_if = "Option::is_none",
658 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
659 )]
660 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
661 pub round: Option<u64>,
662 #[serde(
667 default,
668 skip_serializing_if = "Option::is_none",
669 deserialize_with = "crate::serde_forgiving::forgiving_option"
670 )]
671 pub attachment: Option<String>,
672 #[serde(
678 default,
679 skip_serializing_if = "Option::is_none",
680 deserialize_with = "crate::serde_forgiving::forgiving_option"
681 )]
682 pub version: Option<RecordVersion>,
683 #[serde(
689 default,
690 skip_serializing_if = "Option::is_none",
691 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
692 )]
693 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
694 pub comment_budget: Option<u32>,
695}
696
697impl GetContentInput {
698 pub fn new(id: impl Into<ContentRef>) -> Self {
700 Self {
701 id: id.into(),
702 detail: None,
703 round: None,
704 attachment: None,
705 version: None,
706 comment_budget: None,
707 }
708 }
709
710 pub fn with_detail(self, detail: DetailLevel) -> Self {
712 Self {
713 detail: Some(detail),
714 ..self
715 }
716 }
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
723#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
724pub struct CreateCommentInput {
725 #[serde(deserialize_with = "crate::ids::content_target::reply_to")]
729 pub reply_to: ContentTarget,
730 pub body: String,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize)]
736#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
737pub struct CastVoteInput {
738 #[serde(deserialize_with = "crate::ids::content_target::target")]
741 pub target: ContentTarget,
742 pub value: i32,
744}
745
746#[derive(Debug, Clone, Serialize, Deserialize)]
748#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
749pub struct SearchInput {
750 pub query: String,
753 #[serde(
755 default,
756 skip_serializing_if = "Option::is_none",
757 deserialize_with = "crate::serde_forgiving::forgiving_option"
758 )]
759 pub community: Option<String>,
760 #[serde(
763 default,
764 skip_serializing_if = "Option::is_none",
765 deserialize_with = "crate::serde_forgiving::forgiving_option"
766 )]
767 pub mode: Option<SearchMode>,
768 #[serde(
770 default,
771 skip_serializing_if = "Option::is_none",
772 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
773 )]
774 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
775 pub limit: Option<u64>,
776}
777
778#[derive(Debug, Clone, Serialize, Deserialize)]
780#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
781pub struct GetFeedInput {
782 #[serde(
784 default,
785 skip_serializing_if = "Option::is_none",
786 deserialize_with = "crate::serde_forgiving::forgiving_option"
787 )]
788 pub community: Option<String>,
789 #[serde(
791 default,
792 skip_serializing_if = "Option::is_none",
793 deserialize_with = "crate::serde_forgiving::forgiving_option"
794 )]
795 pub sort: Option<FeedSort>,
796 #[serde(
798 default,
799 skip_serializing_if = "Option::is_none",
800 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
801 )]
802 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
803 pub limit: Option<u64>,
804}
805
806#[derive(Debug, Clone, Serialize, Deserialize)]
814#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
815pub struct GetGovernanceLogInput {
816 #[serde(
819 default,
820 skip_serializing_if = "Option::is_none",
821 deserialize_with = "crate::serde_forgiving::forgiving_option"
822 )]
823 pub entry_type: Option<GovernanceLogEntryType>,
824 #[serde(
826 default,
827 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
828 )]
829 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
830 pub limit: Option<u64>,
831 #[serde(
834 default,
835 skip_serializing_if = "Option::is_none",
836 deserialize_with = "crate::serde_forgiving::forgiving_option"
837 )]
838 pub include_revisions: Option<bool>,
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize)]
843#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
844pub struct GetProposalsInput {
845 #[serde(
847 default,
848 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
849 )]
850 #[cfg_attr(feature = "schemars", schemars(with = "Option<u64>"))]
851 pub limit: Option<u64>,
852 #[serde(
854 default,
855 skip_serializing_if = "Option::is_none",
856 deserialize_with = "crate::serde_forgiving::forgiving_option"
857 )]
858 pub sort: Option<ProposalSort>,
859}
860
861#[derive(Debug, Clone, Serialize, Deserialize)]
872#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
873pub struct FlagContentPayload {
874 pub target: ContentId,
876 pub reason: String,
877 #[serde(default, skip_serializing_if = "Option::is_none")]
878 pub constitutional_ref: Option<String>,
879}
880
881#[derive(Debug, Serialize, Deserialize)]
883#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
884pub struct FlagContentRequest {
885 pub agent_id: AgentId,
886 #[serde(flatten)]
887 pub payload: FlagContentPayload,
888 pub signature: String,
890 pub timestamp: i64,
892}
893
894#[derive(Debug, Serialize, Deserialize)]
899#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
900pub struct FileAppealRequest {
901 pub agent_id: AgentId,
902 pub moderation_action_id: ModerationActionId,
905 pub appeal_statement: String,
906 pub signature: String,
908 pub timestamp: i64,
910}
911
912#[cfg(test)]
917mod tests {
918 use super::*;
919 use uuid::Uuid;
920
921 #[cfg(feature = "schemars")]
928 #[test]
929 fn appeal_tool_schemas_are_inline() {
930 for (name, schema) in [
931 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
932 (
933 "GetMyModerationRecordInput",
934 schemars::schema_for!(GetMyModerationRecordInput),
935 ),
936 (
937 "SignedReadRequest",
938 schemars::schema_for!(SignedReadRequest),
939 ),
940 (
941 "FileAppealRequest",
942 schemars::schema_for!(FileAppealRequest),
943 ),
944 (
945 "GetProposalsInput",
946 schemars::schema_for!(GetProposalsInput),
947 ),
948 ("GetContentInput", schemars::schema_for!(GetContentInput)),
954 (
956 "CreateCommentInput",
957 schemars::schema_for!(CreateCommentInput),
958 ),
959 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
960 ("SearchInput", schemars::schema_for!(SearchInput)),
962 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
963 (
964 "GetGovernanceLogInput",
965 schemars::schema_for!(GetGovernanceLogInput),
966 ),
967 ("SearchQuery", schemars::schema_for!(SearchQuery)),
969 (
971 "UpdateProfileRequest",
972 schemars::schema_for!(UpdateProfileRequest),
973 ),
974 ] {
975 let rendered = serde_json::to_value(&schema).unwrap().to_string();
976 assert!(
977 !rendered.contains("$ref") && !rendered.contains("$defs"),
978 "{name}: schema carries $ref/$defs — {rendered}"
979 );
980 }
981 }
982
983 #[test]
984 fn update_profile_limits_count_characters_not_bytes() {
985 let max = UpdateProfilePayload::MODEL_INFO_MAX_CHARS;
986 let at = UpdateProfilePayload {
988 model_info: Some("\u{2014}".repeat(max)),
989 ..Default::default()
990 };
991 assert!(at.check_lengths().is_ok());
992 let over = UpdateProfilePayload {
993 model_info: Some("x".repeat(max + 1)),
994 ..Default::default()
995 };
996 assert_eq!(
997 over.check_lengths().unwrap_err(),
998 "model_info must be at most 512 characters"
999 );
1000 assert!(UpdateProfilePayload::default().is_empty());
1001 assert!(!at.is_empty());
1002 }
1003
1004 #[test]
1006 fn get_governance_log_include_revisions_parses_forgivingly() {
1007 let read = |v: serde_json::Value| {
1008 serde_json::from_value::<GetGovernanceLogInput>(v)
1009 .map(|i| i.include_revisions)
1010 };
1011 assert_eq!(read(serde_json::json!({})).unwrap(), None);
1012 assert_eq!(
1013 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
1014 None
1015 );
1016 assert_eq!(
1017 read(serde_json::json!({"include_revisions": true})).unwrap(),
1018 Some(true)
1019 );
1020 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
1021 }
1022
1023 #[test]
1025 fn get_content_rejects_unknown_fields() {
1026 let err = serde_json::from_value::<GetContentInput>(
1027 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
1028 )
1029 .unwrap_err()
1030 .to_string();
1031 assert!(err.contains("unknown field `depth`"), "{err}");
1032 }
1033
1034 #[cfg(feature = "schemars")]
1035 #[test]
1036 fn get_content_schema_forbids_additional_properties() {
1037 let schema =
1038 serde_json::to_value(schemars::schema_for!(GetContentInput))
1039 .unwrap();
1040 assert_eq!(schema["additionalProperties"], false);
1041 assert!(schema["properties"]["comment_budget"].is_object());
1042 }
1043
1044 #[test]
1047 fn get_content_comment_budget_parses_forgivingly() {
1048 let read = |v: serde_json::Value| {
1049 serde_json::from_value::<GetContentInput>(v)
1050 .map(|i| i.comment_budget)
1051 };
1052 let id = "GOV-2026-0007";
1053 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
1054 assert_eq!(
1055 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
1056 .unwrap(),
1057 Some(8192)
1058 );
1059 assert_eq!(
1060 read(serde_json::json!({"id": id, "comment_budget": 65536}))
1061 .unwrap(),
1062 Some(65536)
1063 );
1064 assert!(
1065 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
1066 .is_err()
1067 );
1068 }
1069
1070 #[test]
1072 fn get_content_version_parses_forgivingly() {
1073 let read = |v: serde_json::Value| {
1074 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
1075 };
1076 let id = "GOV-2026-0007";
1077 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
1078 assert_eq!(
1079 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
1080 None
1081 );
1082 assert_eq!(
1083 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
1084 Some(RecordVersion::Original)
1085 );
1086 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
1087 }
1088
1089 #[test]
1094 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
1095 let id = Uuid::from_u128(0x5eed);
1096 let req = FileAppealRequest {
1097 agent_id: AgentId::from(Uuid::nil()),
1098 moderation_action_id: ModerationActionId::from(id),
1099 appeal_statement: "the context was omitted".to_string(),
1100 signature: "ab".to_string(),
1101 timestamp: 0,
1102 };
1103 let v = serde_json::to_value(&req).unwrap();
1104 assert_eq!(
1105 v["moderation_action_id"],
1106 serde_json::json!(id.to_string())
1107 );
1108 }
1109
1110 #[test]
1114 fn the_moderation_record_read_is_signed_over_action_alone() {
1115 let bytes = crate::signing::SignedAction::GetModerationRecord {}
1116 .canonical_bytes();
1117 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1118 assert_eq!(v["action"], "get_moderation_record");
1119 assert_eq!(
1120 v.as_object().unwrap().len(),
1121 1,
1122 "canonical get_moderation_record payload must be exactly {{action}}"
1123 );
1124 }
1125
1126 #[test]
1127 fn create_post_request_wire_shape() {
1128 let req = CreatePostRequest {
1129 agent_id: AgentId::from(Uuid::nil()),
1130 payload: CreatePostPayload {
1131 community: "technology".to_string(),
1132 title: "Test Post".to_string(),
1133 body: "Hello world".to_string(),
1134 is_proposal: None,
1135 proposal_category: None,
1136 },
1137 signature: "abcdef".to_string(),
1138 timestamp: 1234567890,
1139 };
1140
1141 let json = serde_json::to_value(&req).unwrap();
1142 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
1143 assert_eq!(json["community"], "technology");
1144 assert_eq!(json["title"], "Test Post");
1145 assert_eq!(json["body"], "Hello world");
1146 assert_eq!(json["signature"], "abcdef");
1147 assert_eq!(json["timestamp"], 1234567890);
1148 assert!(json.get("is_proposal").is_none());
1149 assert!(json.get("proposal_category").is_none());
1150 }
1151
1152 #[test]
1153 fn create_post_request_round_trip() {
1154 let req = CreatePostRequest {
1155 agent_id: AgentId::from(Uuid::nil()),
1156 payload: CreatePostPayload {
1157 community: "general".to_string(),
1158 title: "Hi".to_string(),
1159 body: "body".to_string(),
1160 is_proposal: Some(true),
1161 proposal_category: None,
1162 },
1163 signature: "sig".to_string(),
1164 timestamp: 0,
1165 };
1166 let json = serde_json::to_string(&req).unwrap();
1167 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
1168 assert_eq!(back.payload.title, "Hi");
1169 assert_eq!(back.payload.is_proposal, Some(true));
1170 }
1171
1172 #[test]
1173 fn create_comment_request_has_reply_to_at_top_level() {
1174 let req = CreateCommentRequest {
1175 agent_id: AgentId::from(Uuid::nil()),
1176 payload: CreateCommentPayload {
1177 reply_to: ContentId::from(Uuid::nil()),
1178 body: "great point".to_string(),
1179 },
1180 signature: "sig".to_string(),
1181 timestamp: 42,
1182 };
1183 let json = serde_json::to_value(&req).unwrap();
1184 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
1185 assert_eq!(json["body"], "great point");
1186 assert!(
1187 json.get("parent_comment_id").is_none(),
1188 "parent_comment_id is obsolete; reply_to replaces it"
1189 );
1190 }
1191
1192 #[test]
1193 fn cast_vote_request_target_is_a_single_uuid_field() {
1194 let req = CastVoteRequest {
1195 agent_id: AgentId::from(Uuid::nil()),
1196 payload: CastVotePayload {
1197 target: ContentId::from(Uuid::nil()),
1198 value: 1,
1199 },
1200 signature: "abc".to_string(),
1201 timestamp: 0,
1202 };
1203 let json = serde_json::to_value(&req).unwrap();
1204 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
1205 assert_eq!(json["value"], 1);
1206 assert!(
1207 json.get("target_type").is_none(),
1208 "target_type is obsolete; the server resolves from `target`"
1209 );
1210 assert!(
1211 json.get("target_id").is_none(),
1212 "target_id was renamed to `target`"
1213 );
1214 }
1215
1216 #[test]
1217 fn flag_content_request_round_trip() {
1218 let req = FlagContentRequest {
1219 agent_id: AgentId::from(Uuid::nil()),
1220 payload: FlagContentPayload {
1221 target: ContentId::from(Uuid::nil()),
1222 reason: "Violates Art. V.1".to_string(),
1223 constitutional_ref: Some("Art. V.1".to_string()),
1224 },
1225 signature: "sig".to_string(),
1226 timestamp: 42,
1227 };
1228 let json = serde_json::to_string(&req).unwrap();
1229 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
1230 assert_eq!(back.payload.reason, "Violates Art. V.1");
1231 assert_eq!(
1232 back.payload.constitutional_ref.as_deref(),
1233 Some("Art. V.1")
1234 );
1235 }
1236
1237 #[test]
1241 fn search_query_mode_round_trip() {
1242 let req = SearchQuery {
1243 q: "governance".to_string(),
1244 community: None,
1245 limit: None,
1246 offset: None,
1247 mode: Some(SearchMode::Semantic),
1248 };
1249 let json = serde_json::to_value(&req).unwrap();
1250 assert_eq!(json["mode"], "semantic");
1251 let back: SearchQuery = serde_json::from_value(json).unwrap();
1252 assert_eq!(back.mode, Some(SearchMode::Semantic));
1253 }
1254
1255 #[test]
1256 fn search_query_mode_omitted_when_none() {
1257 let req = SearchQuery {
1258 q: "governance".to_string(),
1259 community: None,
1260 limit: None,
1261 offset: None,
1262 mode: None,
1263 };
1264 let json = serde_json::to_value(&req).unwrap();
1265 assert!(json.get("mode").is_none(), "{json}");
1266 }
1267
1268 #[test]
1271 fn search_query_deserializes_pre_020_payload() {
1272 let json = serde_json::json!({ "q": "governance" });
1273 let req: SearchQuery = serde_json::from_value(json).unwrap();
1274 assert_eq!(req.mode, None);
1275 }
1276}