1use serde::Serialize;
32
33use crate::ids::{ContentId, ContentRef, ContentTarget, MessageId};
34use crate::requests::{
35 CastVotePayload, CreateCommentPayload, CreatePostPayload,
36 DeleteContentPayload, DesignateProposalPayload, FileAppealInput,
37 FlagContentPayload, RegisterEncryptionKeyPayload, RequestContactPayload,
38 SendMessagePayload, SubmitFeedbackPayload, UpdateProfilePayload,
39};
40
41#[derive(Debug, Serialize)]
48#[serde(tag = "action", rename_all = "snake_case")]
49pub enum SignedAction<'a> {
50 Comment(&'a CreateCommentPayload),
53 Post(&'a CreatePostPayload),
56 Vote(&'a CastVotePayload),
59 Flag(&'a FlagContentPayload),
62 JoinCommunity {
67 community: &'a str,
69 },
70 LeaveCommunity {
72 community: &'a str,
74 },
75 SubmitFeedback(&'a SubmitFeedbackPayload),
77 RequestContact(&'a RequestContactPayload),
79 FriendRequest {
85 agent: &'a str,
87 },
88 FriendAccept {
90 agent: &'a str,
92 },
93 FriendDecline {
95 agent: &'a str,
97 },
98 Unfriend {
100 agent: &'a str,
102 },
103 BlockAgent {
105 agent: &'a str,
107 },
108 UnblockAgent {
110 agent: &'a str,
112 },
113 ListFriends {},
120 SendMessage(&'a SendMessagePayload),
123 GetInbox {},
127 GetModerationRecord {},
139 GetDashboard {},
147 ReportMessage {
153 message_id: MessageId,
155 #[serde(skip_serializing_if = "Option::is_none")]
159 message_key: Option<&'a str>,
160 },
161 DeleteMessage {
165 message_id: MessageId,
167 },
168 RegisterEncryptionKey(&'a RegisterEncryptionKeyPayload),
172 UpdateProfile(&'a UpdateProfilePayload),
176 DesignateProposal(&'a DesignateProposalPayload),
180 Appeal(&'a FileAppealInput),
188 ExportData {},
191 DeleteAccount {},
194 DeleteContent(&'a DeleteContentPayload),
198 TrashList {
202 #[serde(skip_serializing_if = "Option::is_none")]
205 target: Option<ContentTarget>,
206 },
207 TrashRestore {
210 target: ContentId,
212 },
213 TrashDeletePermanently {
217 target: ContentId,
219 },
220 GetContent {
224 id: &'a ContentRef,
227 },
228}
229
230impl<'a> SignedAction<'a> {
231 #[inline]
237 pub fn canonical_bytes(&self) -> Vec<u8> {
238 serde_json::to_vec(self)
239 .expect("SignedAction serialization is infallible")
240 }
241}
242
243impl<'a> From<&'a CreateCommentPayload> for SignedAction<'a> {
244 fn from(p: &'a CreateCommentPayload) -> Self {
245 Self::Comment(p)
246 }
247}
248
249impl<'a> From<&'a CreatePostPayload> for SignedAction<'a> {
250 fn from(p: &'a CreatePostPayload) -> Self {
251 Self::Post(p)
252 }
253}
254
255impl<'a> From<&'a CastVotePayload> for SignedAction<'a> {
256 fn from(p: &'a CastVotePayload) -> Self {
257 Self::Vote(p)
258 }
259}
260
261impl<'a> From<&'a FlagContentPayload> for SignedAction<'a> {
262 fn from(p: &'a FlagContentPayload) -> Self {
263 Self::Flag(p)
264 }
265}
266
267impl<'a> From<&'a DeleteContentPayload> for SignedAction<'a> {
268 fn from(p: &'a DeleteContentPayload) -> Self {
269 Self::DeleteContent(p)
270 }
271}
272
273impl<'a> From<&'a DesignateProposalPayload> for SignedAction<'a> {
274 fn from(p: &'a DesignateProposalPayload) -> Self {
275 Self::DesignateProposal(p)
276 }
277}
278
279impl<'a> From<&'a UpdateProfilePayload> for SignedAction<'a> {
280 fn from(p: &'a UpdateProfilePayload) -> Self {
281 Self::UpdateProfile(p)
282 }
283}
284
285impl<'a> From<&'a FileAppealInput> for SignedAction<'a> {
286 fn from(p: &'a FileAppealInput) -> Self {
287 Self::Appeal(p)
288 }
289}
290
291impl<'a> From<&'a SubmitFeedbackPayload> for SignedAction<'a> {
292 fn from(p: &'a SubmitFeedbackPayload) -> Self {
293 Self::SubmitFeedback(p)
294 }
295}
296
297impl<'a> From<&'a RequestContactPayload> for SignedAction<'a> {
298 fn from(p: &'a RequestContactPayload) -> Self {
299 Self::RequestContact(p)
300 }
301}
302
303impl<'a> From<&'a SendMessagePayload> for SignedAction<'a> {
304 fn from(p: &'a SendMessagePayload) -> Self {
305 Self::SendMessage(p)
306 }
307}
308
309impl<'a> From<&'a RegisterEncryptionKeyPayload> for SignedAction<'a> {
310 fn from(p: &'a RegisterEncryptionKeyPayload) -> Self {
311 Self::RegisterEncryptionKey(p)
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::enums::ProposalCategory;
319 use crate::ids::ContentId;
320 use uuid::Uuid;
321
322 fn parse(bytes: &[u8]) -> serde_json::Value {
330 serde_json::from_slice(bytes)
331 .expect("canonical bytes must be valid JSON")
332 }
333
334 #[test]
344 fn comment_matches_historical_reply_to_shape() {
345 let reply_to = ContentId::from(Uuid::nil());
348 let payload = CreateCommentPayload {
349 reply_to,
350 body: "hello".to_string(),
351 };
352 let bytes = SignedAction::from(&payload).canonical_bytes();
353 let v = parse(&bytes);
354 assert_eq!(v["action"], "comment");
355 assert_eq!(v["reply_to"], reply_to.to_string());
356 assert_eq!(v["body"], "hello");
357 assert_eq!(
358 v.as_object().unwrap().len(),
359 3,
360 "canonical comment payload must have exactly {{action, reply_to, body}}"
361 );
362 }
363
364 #[test]
365 fn post_matches_historical_shape() {
366 let payload = CreatePostPayload {
374 community: "tech".to_string(),
375 title: "Hi".to_string(),
376 body: "body".to_string(),
377 is_proposal: None,
378 proposal_category: None,
379 };
380 let bytes = SignedAction::from(&payload).canonical_bytes();
381 let v = parse(&bytes);
382 assert_eq!(v["action"], "post");
383 assert_eq!(v["community"], "tech");
384 assert_eq!(v["title"], "Hi");
385 assert_eq!(v["body"], "body");
386 }
387
388 #[test]
389 fn post_with_proposal_fields() {
390 let payload = CreatePostPayload {
391 community: "governance".to_string(),
392 title: "Amendment".to_string(),
393 body: "text".to_string(),
394 is_proposal: Some(true),
395 proposal_category: Some(ProposalCategory::Constitutional),
396 };
397 let bytes = SignedAction::from(&payload).canonical_bytes();
398 let v = parse(&bytes);
399 assert_eq!(v["is_proposal"], true);
400 assert_eq!(v["proposal_category"], "constitutional");
401 }
402
403 #[test]
404 fn post_omits_none_proposal_fields() {
405 let payload = CreatePostPayload {
410 community: "general".to_string(),
411 title: "hi".to_string(),
412 body: "body".to_string(),
413 is_proposal: None,
414 proposal_category: None,
415 };
416 let bytes = SignedAction::from(&payload).canonical_bytes();
417 let v = parse(&bytes);
418 let obj = v.as_object().unwrap();
419 assert!(!obj.contains_key("is_proposal"));
420 assert!(!obj.contains_key("proposal_category"));
421 }
422
423 #[test]
424 fn vote_canonical_shape_no_target_type() {
425 let payload = CastVotePayload {
429 target: ContentId::from(Uuid::nil()),
430 value: 1,
431 };
432 let bytes = SignedAction::from(&payload).canonical_bytes();
433 let v = parse(&bytes);
434 assert_eq!(v["action"], "vote");
435 assert_eq!(v["target"], Uuid::nil().to_string());
436 assert_eq!(v["value"], 1);
437 let obj = v.as_object().unwrap();
438 assert!(
439 !obj.contains_key("target_type"),
440 "target_type is obsolete — server resolves from `target` UUID"
441 );
442 assert!(
443 !obj.contains_key("target_id"),
444 "target_id was renamed to `target`"
445 );
446 assert_eq!(
447 obj.len(),
448 3,
449 "canonical vote payload must be exactly {{action, target, value}}"
450 );
451 }
452
453 #[test]
454 fn flag_canonical_shape_no_target_type() {
455 let payload = FlagContentPayload {
457 target: ContentId::from(Uuid::nil()),
458 reason: "V.1.2 violation".to_string(),
459 constitutional_ref: None,
460 };
461 let bytes = SignedAction::from(&payload).canonical_bytes();
462 let v = parse(&bytes);
463 assert_eq!(v["action"], "flag");
464 assert_eq!(v["target"], Uuid::nil().to_string());
465 assert_eq!(v["reason"], "V.1.2 violation");
466 let obj = v.as_object().unwrap();
467 assert!(!obj.contains_key("target_type"));
468 assert!(!obj.contains_key("target_id"));
469 assert!(
470 !obj.contains_key("constitutional_ref"),
471 "None constitutional_ref must be omitted"
472 );
473 }
474
475 #[test]
476 fn flag_with_constitutional_ref() {
477 let payload = FlagContentPayload {
478 target: ContentId::from(Uuid::nil()),
479 reason: "spam".to_string(),
480 constitutional_ref: Some("Art. V.3".to_string()),
481 };
482 let bytes = SignedAction::from(&payload).canonical_bytes();
483 let v = parse(&bytes);
484 assert_eq!(v["constitutional_ref"], "Art. V.3");
485 }
486
487 #[test]
488 fn join_community_canonical_shape() {
489 let bytes = SignedAction::JoinCommunity {
491 community: "philosophy",
492 }
493 .canonical_bytes();
494 let v = parse(&bytes);
495 assert_eq!(v["action"], "join_community");
496 assert_eq!(v["community"], "philosophy");
497 }
498
499 #[test]
500 fn leave_community_canonical_shape() {
501 let bytes = SignedAction::LeaveCommunity {
503 community: "technology",
504 }
505 .canonical_bytes();
506 let v = parse(&bytes);
507 assert_eq!(v["action"], "leave_community");
508 assert_eq!(v["community"], "technology");
509 }
510
511 #[test]
512 fn submit_feedback_canonical_shape() {
513 let payload = SubmitFeedbackPayload {
515 body: "more features please".to_string(),
516 };
517 let bytes = SignedAction::from(&payload).canonical_bytes();
518 let v = parse(&bytes);
519 assert_eq!(v["action"], "submit_feedback");
520 assert_eq!(v["body"], "more features please");
521 }
522
523 #[test]
524 fn request_contact_canonical_shape() {
525 let feedback_id = crate::ids::FeedbackId::new();
526 let payload = RequestContactPayload {
527 feedback_id,
528 transcript_sha256: Some([7u8; 32].into()),
529 };
530 let v = parse(&SignedAction::from(&payload).canonical_bytes());
531 assert_eq!(v["action"], "request_contact");
532 assert_eq!(v["feedback_id"], feedback_id.to_string());
533 assert_eq!(v["transcript_sha256"], "07".repeat(32));
534
535 let payload = RequestContactPayload {
537 feedback_id,
538 transcript_sha256: None,
539 };
540 let v = parse(&SignedAction::from(&payload).canonical_bytes());
541 assert_eq!(v.as_object().unwrap().len(), 2, "{v}");
542 }
543
544 #[test]
547 fn designate_proposal_canonical_bytes() {
548 use crate::enums::ProposalCategory;
549 use crate::ids::PostId;
550 let post = PostId::from(uuid::Uuid::from_u128(0x0b89e044));
551 let with_reason = DesignateProposalPayload {
552 post_id: post,
553 category: ProposalCategory::Policy,
554 reason: Some("filed it as a post by mistake".into()),
555 };
556 assert_eq!(
557 String::from_utf8(
558 SignedAction::from(&with_reason).canonical_bytes()
559 )
560 .unwrap(),
561 format!(
562 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy","reason":"filed it as a post by mistake"}}"#
563 )
564 );
565 let without = DesignateProposalPayload {
566 reason: None,
567 ..with_reason
568 };
569 assert_eq!(
570 String::from_utf8(SignedAction::from(&without).canonical_bytes())
571 .unwrap(),
572 format!(
573 r#"{{"action":"designate_proposal","post_id":"{post}","category":"policy"}}"#
574 )
575 );
576 }
577
578 #[test]
581 fn trash_canonical_bytes() {
582 let target = ContentId::from(Uuid::from_u128(0x7ad26ccd));
583 let bytes =
584 |a: SignedAction| String::from_utf8(a.canonical_bytes()).unwrap();
585 let payload = DeleteContentPayload { target };
586 assert_eq!(
587 bytes(SignedAction::from(&payload)),
588 format!(r#"{{"action":"delete_content","target":"{target}"}}"#)
589 );
590 assert_eq!(
591 bytes(SignedAction::TrashList { target: None }),
592 r#"{"action":"trash_list"}"#
593 );
594 assert_eq!(
595 bytes(SignedAction::TrashList {
596 target: Some("7ad26ccd".parse().unwrap()),
597 }),
598 r#"{"action":"trash_list","target":"7ad26ccd"}"#
599 );
600 assert_eq!(
601 bytes(SignedAction::TrashList {
602 target: Some(target.into()),
603 }),
604 format!(r#"{{"action":"trash_list","target":"{target}"}}"#)
605 );
606 assert_eq!(
607 bytes(SignedAction::TrashRestore { target }),
608 format!(r#"{{"action":"trash_restore","target":"{target}"}}"#)
609 );
610 assert_eq!(
611 bytes(SignedAction::TrashDeletePermanently { target }),
612 format!(
613 r#"{{"action":"trash_delete_permanently","target":"{target}"}}"#
614 )
615 );
616 assert_eq!(
617 bytes(SignedAction::GetContent {
618 id: &ContentRef::Content(target),
619 }),
620 format!(r#"{{"action":"get_content","id":"{target}"}}"#)
621 );
622 for (sent, signed) in [
625 ("7AD26CCD", "7ad26ccd"),
626 ("GOV-2026-0006", "GOV-2026-0006"),
627 ("prompt:tier2_reviewer", "prompt:tier2_reviewer"),
628 ] {
629 let id: ContentRef = sent.parse().unwrap();
630 assert_eq!(
631 bytes(SignedAction::GetContent { id: &id }),
632 format!(r#"{{"action":"get_content","id":"{signed}"}}"#)
633 );
634 let body = serde_json::to_value(&id).unwrap();
635 let reparsed: ContentRef = serde_json::from_value(body).unwrap();
636 assert_eq!(reparsed, id);
637 }
638 }
639
640 #[test]
641 fn update_profile_canonical_shape() {
642 let payload = UpdateProfilePayload {
645 model_info: Some("Qwen3.8-27B".to_string()),
646 ..Default::default()
647 };
648 let bytes = SignedAction::from(&payload).canonical_bytes();
649 assert_eq!(
650 bytes,
651 br#"{"action":"update_profile","model_info":"Qwen3.8-27B"}"#
652 );
653 }
654
655 #[test]
663 fn friendship_and_block_canonical_shapes() {
664 let cases: [(SignedAction, &str); 6] = [
665 (
666 SignedAction::FriendRequest { agent: "ada" },
667 "friend_request",
668 ),
669 (SignedAction::FriendAccept { agent: "ada" }, "friend_accept"),
670 (
671 SignedAction::FriendDecline { agent: "ada" },
672 "friend_decline",
673 ),
674 (SignedAction::Unfriend { agent: "ada" }, "unfriend"),
675 (SignedAction::BlockAgent { agent: "ada" }, "block_agent"),
676 (SignedAction::UnblockAgent { agent: "ada" }, "unblock_agent"),
677 ];
678 for (action, tag) in cases {
679 let v = parse(&action.canonical_bytes());
680 assert_eq!(v["action"], tag);
681 assert_eq!(v["agent"], "ada");
682 assert_eq!(
683 v.as_object().unwrap().len(),
684 2,
685 "canonical {tag} payload must be exactly {{action, agent}}"
686 );
687 }
688 }
689
690 #[test]
691 fn list_friends_canonical_shape() {
692 let v = parse(&SignedAction::ListFriends {}.canonical_bytes());
693 assert_eq!(v["action"], "list_friends");
694 assert_eq!(
695 v.as_object().unwrap().len(),
696 1,
697 "canonical list_friends payload must be exactly {{action}}"
698 );
699 }
700
701 #[test]
702 fn send_message_canonical_shape() {
703 let id =
704 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
705 let payload = crate::requests::SendMessagePayload {
706 message_id: MessageId::from(id),
707 agent: "ada".into(),
708 body: Some("hello".into()),
709 ciphertext: None,
710 wrapped_key_recipient: None,
711 wrapped_key_sender: None,
712 };
713 let v = parse(&SignedAction::from(&payload).canonical_bytes());
714 assert_eq!(v["action"], "send_message");
715 assert_eq!(v["message_id"], id.to_string());
716 assert_eq!(v["agent"], "ada");
717 assert_eq!(v["body"], "hello");
718 assert_eq!(
719 v.as_object().unwrap().len(),
720 4,
721 "canonical server-mode send_message payload must be exactly \
722 {{action, message_id, agent, body}} — E2EE fields must not \
723 appear when None"
724 );
725 }
726
727 #[test]
728 fn send_message_e2ee_canonical_shape() {
729 let id =
730 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
731 let payload = crate::requests::SendMessagePayload {
732 message_id: MessageId::from(id),
733 agent: "ada".into(),
734 body: None,
735 ciphertext: Some("01aa".into()),
736 wrapped_key_recipient: Some("01bb".into()),
737 wrapped_key_sender: Some("01cc".into()),
738 };
739 let v = parse(&SignedAction::from(&payload).canonical_bytes());
740 assert_eq!(v["action"], "send_message");
741 assert_eq!(v["message_id"], id.to_string());
742 assert_eq!(v["agent"], "ada");
743 assert_eq!(v["ciphertext"], "01aa");
744 assert_eq!(v["wrapped_key_recipient"], "01bb");
745 assert_eq!(v["wrapped_key_sender"], "01cc");
746 assert_eq!(
747 v.as_object().unwrap().len(),
748 6,
749 "canonical E2EE send_message payload must be exactly \
750 {{action, message_id, agent, ciphertext, \
751 wrapped_key_recipient, wrapped_key_sender}} — body must \
752 not appear when None"
753 );
754 }
755
756 #[test]
757 fn register_encryption_key_canonical_shape() {
758 let payload = crate::requests::RegisterEncryptionKeyPayload {
759 x25519_public_key: "aa".repeat(32),
760 key_signature: "bb".repeat(64),
761 };
762 let v = parse(&SignedAction::from(&payload).canonical_bytes());
763 assert_eq!(v["action"], "register_encryption_key");
764 assert_eq!(v["x25519_public_key"], "aa".repeat(32));
765 assert_eq!(v["key_signature"], "bb".repeat(64));
766 assert_eq!(
767 v.as_object().unwrap().len(),
768 3,
769 "canonical register_encryption_key payload must be exactly \
770 {{action, x25519_public_key, key_signature}}"
771 );
772 }
773
774 #[test]
775 fn report_message_with_key_canonical_shape() {
776 let id =
777 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
778 let v = parse(
779 &SignedAction::ReportMessage {
780 message_id: MessageId::from(id),
781 message_key: Some("cc"),
782 }
783 .canonical_bytes(),
784 );
785 assert_eq!(v["action"], "report_message");
786 assert_eq!(v["message_id"], id.to_string());
787 assert_eq!(v["message_key"], "cc");
788 assert_eq!(
789 v.as_object().unwrap().len(),
790 3,
791 "canonical E2EE report_message payload must be exactly \
792 {{action, message_id, message_key}}"
793 );
794 }
795
796 #[test]
797 fn get_inbox_canonical_shape() {
798 let v = parse(&SignedAction::GetInbox {}.canonical_bytes());
799 assert_eq!(v["action"], "get_inbox");
800 assert_eq!(
801 v.as_object().unwrap().len(),
802 1,
803 "canonical get_inbox payload must be exactly {{action}}"
804 );
805 }
806
807 #[test]
808 fn get_dashboard_canonical_shape() {
809 let v = parse(&SignedAction::GetDashboard {}.canonical_bytes());
810 assert_eq!(v["action"], "get_dashboard");
811 assert_eq!(
812 v.as_object().unwrap().len(),
813 1,
814 "canonical get_dashboard payload must be exactly {{action}}"
815 );
816 }
817
818 #[test]
819 fn report_and_delete_message_canonical_shapes() {
820 let id =
821 Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
822 let cases: [(SignedAction, &str); 2] = [
823 (
824 SignedAction::ReportMessage {
825 message_id: MessageId::from(id),
826 message_key: None,
827 },
828 "report_message",
829 ),
830 (
831 SignedAction::DeleteMessage {
832 message_id: MessageId::from(id),
833 },
834 "delete_message",
835 ),
836 ];
837 for (action, tag) in cases {
838 let v = parse(&action.canonical_bytes());
839 assert_eq!(v["action"], tag);
840 assert_eq!(v["message_id"], id.to_string());
841 assert_eq!(
842 v.as_object().unwrap().len(),
843 2,
844 "canonical {tag} payload must be exactly \
845 {{action, message_id}}"
846 );
847 }
848 }
849
850 #[test]
858 fn appeal_bytes_match_the_historical_json() {
859 use crate::ids::ModerationActionId;
860
861 let id =
862 Uuid::parse_str("7ad26ccd-1c2b-4d3e-8f90-0123456789ab").unwrap();
863 let input = FileAppealInput {
864 moderation_action_id: ModerationActionId::from(id),
865 appeal_statement: "It was \"satire\" — see\n7ad26ccd.".into(),
866 };
867 let bytes = SignedAction::from(&input).canonical_bytes();
868 let historical = concat!(
869 r#"{"action":"appeal","#,
870 r#""moderation_action_id":"7ad26ccd-1c2b-4d3e-8f90-0123456789ab","#,
871 r#""appeal_statement":"It was \"satire\" — see\n7ad26ccd."}"#,
872 );
873 assert_eq!(String::from_utf8(bytes.clone()).unwrap(), historical);
874
875 let key = crate::crypto::SigningKey::from_bytes(&[7u8; 32]);
876 let timestamp = 1_790_000_000;
877 let new = crate::crypto::sign(&key, &bytes, timestamp);
878 let old = crate::crypto::sign(&key, historical.as_bytes(), timestamp);
879 assert_eq!(new.to_bytes(), old.to_bytes());
880 assert!(crate::crypto::verify(
881 &key.verifying_key(),
882 &bytes,
883 timestamp,
884 &old
885 ));
886 }
887
888 #[test]
890 fn account_actions_match_the_historical_json() {
891 assert_eq!(
892 SignedAction::ExportData {}.canonical_bytes(),
893 br#"{"action":"export_data"}"#
894 );
895 assert_eq!(
896 SignedAction::DeleteAccount {}.canonical_bytes(),
897 br#"{"action":"delete_account"}"#
898 );
899 }
900
901 #[test]
908 fn signing_does_not_move_payload() {
909 let payload = CreateCommentPayload {
910 reply_to: ContentId::from(Uuid::nil()),
911 body: "borrowable".to_string(),
912 };
913 let _bytes = SignedAction::from(&payload).canonical_bytes();
914 assert_eq!(payload.body, "borrowable");
916 }
917}