Skip to main content

meerkat_comms/
types.rs

1//! Core message types for Meerkat comms.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value as JsonValue;
6use uuid::Uuid;
7
8use crate::identity::{Keypair, PubKey, Signature};
9use ciborium::value::{CanonicalValue, Value};
10use meerkat_core::comms::PeerLifecycleKind;
11pub use meerkat_core::comms::SenderContentTaint;
12use meerkat_core::types::{ContentBlock, HandlingMode, RenderMetadata};
13
14/// Response status for Request messages.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
16#[serde(rename_all = "lowercase")]
17pub enum Status {
18    Accepted,
19    Completed,
20    Failed,
21}
22
23impl From<Status> for meerkat_core::interaction::ResponseStatus {
24    fn from(s: Status) -> Self {
25        match s {
26            Status::Accepted => meerkat_core::interaction::ResponseStatus::Accepted,
27            Status::Completed => meerkat_core::interaction::ResponseStatus::Completed,
28            Status::Failed => meerkat_core::interaction::ResponseStatus::Failed,
29        }
30    }
31}
32
33impl From<meerkat_core::interaction::ResponseStatus> for Status {
34    fn from(s: meerkat_core::interaction::ResponseStatus) -> Self {
35        match s {
36            meerkat_core::interaction::ResponseStatus::Accepted => Status::Accepted,
37            meerkat_core::interaction::ResponseStatus::Completed => Status::Completed,
38            meerkat_core::interaction::ResponseStatus::Failed => Status::Failed,
39        }
40    }
41}
42
43/// The kind of message being sent.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "lowercase")]
46pub enum MessageKind {
47    /// A simple text message.
48    Message {
49        body: String,
50        /// Optional multimodal content blocks. When present, carries full
51        /// multimodal content; `body` remains the canonical text projection.
52        #[serde(default, skip_serializing_if = "Option::is_none")]
53        blocks: Option<Vec<ContentBlock>>,
54        /// Sender-declared content taint. Lives INSIDE the signed region
55        /// (`kind` is part of the signable tuple): omitted-when-`None` keeps
56        /// old envelopes byte-identical and verifying; present-when-`Some` is
57        /// covered by the signature. `None` = no declaration — a real third
58        /// state receivers must never coalesce into
59        /// [`SenderContentTaint::Clean`].
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        content_taint: Option<SenderContentTaint>,
62        #[serde(default, skip_serializing_if = "Option::is_none")]
63        handling_mode: Option<HandlingMode>,
64    },
65    /// A request for the peer to perform an action.
66    Request {
67        intent: String,
68        params: JsonValue,
69        #[serde(default, skip_serializing_if = "Option::is_none")]
70        blocks: Option<Vec<ContentBlock>>,
71        /// Sender-declared content taint (signed-when-present; see
72        /// [`MessageKind::Message::content_taint`]).
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        content_taint: Option<SenderContentTaint>,
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        handling_mode: Option<HandlingMode>,
77    },
78    /// A one-way peer lifecycle notification.
79    Lifecycle {
80        kind: PeerLifecycleKind,
81        params: JsonValue,
82    },
83    /// A response to a previous request.
84    Response {
85        in_reply_to: Uuid,
86        status: Status,
87        result: JsonValue,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        blocks: Option<Vec<ContentBlock>>,
90        /// Sender-declared content taint (signed-when-present; see
91        /// [`MessageKind::Message::content_taint`]).
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        content_taint: Option<SenderContentTaint>,
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        handling_mode: Option<HandlingMode>,
96    },
97    /// Acknowledgment of message receipt.
98    Ack { in_reply_to: Uuid },
99}
100
101impl MessageKind {
102    /// Sender-declared content taint carried by this kind, when it is a
103    /// content-bearing kind (`Message` / `Request` / `Response`) and a
104    /// declaration is present.
105    #[must_use]
106    pub fn content_taint(&self) -> Option<SenderContentTaint> {
107        match self {
108            Self::Message { content_taint, .. }
109            | Self::Request { content_taint, .. }
110            | Self::Response { content_taint, .. } => *content_taint,
111            Self::Lifecycle { .. } | Self::Ack { .. } => None,
112        }
113    }
114
115    /// Stamp the resolved outbound taint declaration onto content-bearing
116    /// kinds. Non-content kinds (`Lifecycle` / `Ack`) are returned unchanged.
117    ///
118    /// This is the router's single outbound stamping hook: construction sites
119    /// leave `content_taint` as `None` and [`crate::router::Router`] resolves
120    /// the effective declaration at the send choke point.
121    pub(crate) fn with_content_taint(mut self, taint: Option<SenderContentTaint>) -> Self {
122        match &mut self {
123            Self::Message { content_taint, .. }
124            | Self::Request { content_taint, .. }
125            | Self::Response { content_taint, .. } => *content_taint = taint,
126            Self::Lifecycle { .. } | Self::Ack { .. } => {}
127        }
128        self
129    }
130}
131
132/// A signed message envelope.
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub struct Envelope {
135    /// Unique message identifier.
136    pub id: Uuid,
137    /// Sender's public key.
138    pub from: PubKey,
139    /// Recipient's public key.
140    pub to: PubKey,
141    /// The message content.
142    pub kind: MessageKind,
143    /// Ed25519 signature over canonical CBOR of [id, from, to, kind].
144    pub sig: Signature,
145}
146
147impl Envelope {
148    /// Compute canonical CBOR bytes to sign: `[id, from, to, kind]`.
149    ///
150    /// Field order is fixed per spec for cross-implementation compatibility.
151    ///
152    /// Note: `ciborium`'s serde `into_writer` does **not** sort map keys. To ensure
153    /// deterministic (RFC 8949) encoding, we serialize into `ciborium::value::Value`,
154    /// then recursively sort all maps by canonical key order before encoding.
155    ///
156    /// `PubKey` is encoded as a CBOR byte string (major type 2), not an array.
157    pub fn signable_bytes(&self) -> Vec<u8> {
158        fn canonicalize(value: &mut Value) {
159            match value {
160                Value::Array(items) => {
161                    for item in items {
162                        canonicalize(item);
163                    }
164                }
165                Value::Map(entries) => {
166                    for (key, val) in entries.iter_mut() {
167                        canonicalize(key);
168                        canonicalize(val);
169                    }
170
171                    entries.sort_by(|(k1, _), (k2, _)| match (k1, k2) {
172                        (Value::Text(a), Value::Text(b)) => match a.len().cmp(&b.len()) {
173                            std::cmp::Ordering::Equal => a.cmp(b),
174                            ord => ord,
175                        },
176                        _ => {
177                            CanonicalValue::from(k1.clone()).cmp(&CanonicalValue::from(k2.clone()))
178                        }
179                    });
180                }
181                Value::Tag(_, inner) => canonicalize(inner),
182                _ => {}
183            }
184        }
185
186        // Create a tuple of (id, from, to, kind) for signing.
187        let signable = (&self.id, &self.from, &self.to, &self.kind);
188
189        let mut value = match Value::serialized(&signable) {
190            Ok(value) => value,
191            Err(_) => return Vec::new(),
192        };
193        canonicalize(&mut value);
194
195        let mut buf = Vec::new();
196        if ciborium::into_writer(&value, &mut buf).is_err() {
197            return Vec::new();
198        }
199        buf
200    }
201
202    /// Sign the envelope with the given keypair, updating the sig field.
203    pub fn sign(&mut self, keypair: &Keypair) {
204        let bytes = self.signable_bytes();
205        if bytes.is_empty() {
206            self.sig = Signature::new([0u8; 64]);
207            return;
208        }
209        self.sig = keypair.sign(&bytes);
210    }
211
212    /// Verify the signature using the sender's public key.
213    pub fn verify(&self) -> bool {
214        let bytes = self.signable_bytes();
215        if bytes.is_empty() {
216            return false;
217        }
218        self.from.verify(&bytes, &self.sig)
219    }
220}
221
222/// An item in the agent's inbox.
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224#[serde(tag = "type", rename_all = "snake_case")]
225pub enum InboxItem {
226    /// A message received from an external peer (signed CBOR envelope).
227    External { envelope: Envelope },
228    /// A plain-text event from an external (unauthenticated) source.
229    PlainEvent {
230        body: String,
231        source: meerkat_core::PlainEventSource,
232        #[serde(default)]
233        handling_mode: HandlingMode,
234        /// Optional interaction ID for subscription correlation.
235        /// Set by `inject_with_subscription`, `None` for untracked events.
236        #[serde(default, skip_serializing_if = "Option::is_none")]
237        interaction_id: Option<Uuid>,
238        /// Optional multimodal content blocks.
239        #[serde(default, skip_serializing_if = "Option::is_none")]
240        blocks: Option<Vec<ContentBlock>>,
241        /// Optional normalized rendering metadata.
242        #[serde(default, skip_serializing_if = "Option::is_none")]
243        render_metadata: Option<RenderMetadata>,
244    },
245}
246
247#[cfg(test)]
248#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_status_variants() {
254        // Verify all 3 variants exist
255        let _ = Status::Accepted;
256        let _ = Status::Completed;
257        let _ = Status::Failed;
258    }
259
260    #[test]
261    fn test_message_kind_message_fields() {
262        let msg = MessageKind::Message {
263            body: "hello".to_string(),
264            blocks: None,
265            content_taint: None,
266            handling_mode: None,
267        };
268        if let MessageKind::Message {
269            body,
270            blocks,
271            content_taint,
272            handling_mode,
273        } = msg
274        {
275            assert_eq!(body, "hello");
276            assert_eq!(blocks, None);
277            assert_eq!(content_taint, None);
278            assert_eq!(handling_mode, None);
279        } else {
280            panic!("Expected Message variant");
281        }
282    }
283
284    #[test]
285    fn test_message_kind_request_fields() {
286        let req = MessageKind::Request {
287            intent: "review-pr".to_string(),
288            params: serde_json::json!({"pr": 42}),
289            blocks: None,
290            content_taint: None,
291            handling_mode: None,
292        };
293        if let MessageKind::Request {
294            intent,
295            params,
296            blocks,
297            content_taint,
298            handling_mode,
299        } = req
300        {
301            assert_eq!(intent, "review-pr");
302            assert_eq!(params["pr"], 42);
303            assert_eq!(blocks, None);
304            assert_eq!(content_taint, None);
305            assert_eq!(handling_mode, None);
306        } else {
307            panic!("Expected Request variant");
308        }
309    }
310
311    #[test]
312    fn test_message_kind_response_fields() {
313        let id = Uuid::new_v4();
314        let resp = MessageKind::Response {
315            in_reply_to: id,
316            status: Status::Completed,
317            result: serde_json::json!({"approved": true}),
318            blocks: None,
319            content_taint: None,
320            handling_mode: None,
321        };
322        if let MessageKind::Response {
323            in_reply_to,
324            status,
325            result,
326            ..
327        } = resp
328        {
329            assert_eq!(in_reply_to, id);
330            assert_eq!(status, Status::Completed);
331            assert_eq!(result["approved"], true);
332        } else {
333            panic!("Expected Response variant");
334        }
335    }
336
337    #[test]
338    fn test_message_kind_ack_fields() {
339        let id = Uuid::new_v4();
340        let ack = MessageKind::Ack { in_reply_to: id };
341        if let MessageKind::Ack { in_reply_to } = ack {
342            assert_eq!(in_reply_to, id);
343        } else {
344            panic!("Expected Ack variant");
345        }
346    }
347
348    #[test]
349    fn test_envelope_fields() {
350        let envelope = Envelope {
351            id: Uuid::new_v4(),
352            from: PubKey::new([1u8; 32]),
353            to: PubKey::new([2u8; 32]),
354            kind: MessageKind::Message {
355                body: "test".to_string(),
356                blocks: None,
357                content_taint: None,
358                handling_mode: None,
359            },
360            sig: Signature::new([0u8; 64]),
361        };
362        assert!(envelope.id != Uuid::nil());
363        assert_eq!(envelope.from.as_bytes()[0], 1);
364        assert_eq!(envelope.to.as_bytes()[0], 2);
365    }
366
367    #[test]
368    fn test_status_cbor_roundtrip() {
369        for status in [Status::Accepted, Status::Completed, Status::Failed] {
370            let mut buf = Vec::new();
371            ciborium::into_writer(&status, &mut buf).unwrap();
372            let decoded: Status = ciborium::from_reader(&buf[..]).unwrap();
373            assert_eq!(status, decoded);
374        }
375    }
376
377    #[test]
378    fn test_message_kind_cbor_roundtrip() {
379        let kinds = vec![
380            MessageKind::Message {
381                body: "hello".to_string(),
382                blocks: None,
383                content_taint: None,
384                handling_mode: None,
385            },
386            MessageKind::Request {
387                intent: "test".to_string(),
388                params: serde_json::json!({}),
389                blocks: None,
390                content_taint: None,
391                handling_mode: None,
392            },
393            MessageKind::Response {
394                in_reply_to: Uuid::new_v4(),
395                status: Status::Completed,
396                result: serde_json::json!(null),
397                blocks: None,
398                content_taint: None,
399                handling_mode: None,
400            },
401            MessageKind::Message {
402                body: "declared".to_string(),
403                blocks: None,
404                content_taint: Some(SenderContentTaint::Tainted),
405                handling_mode: None,
406            },
407            MessageKind::Response {
408                in_reply_to: Uuid::new_v4(),
409                status: Status::Completed,
410                result: serde_json::json!(null),
411                blocks: None,
412                content_taint: Some(SenderContentTaint::Clean),
413                handling_mode: None,
414            },
415            MessageKind::Ack {
416                in_reply_to: Uuid::new_v4(),
417            },
418        ];
419        for kind in kinds {
420            let mut buf = Vec::new();
421            ciborium::into_writer(&kind, &mut buf).unwrap();
422            let decoded: MessageKind = ciborium::from_reader(&buf[..]).unwrap();
423            assert_eq!(kind, decoded);
424        }
425    }
426
427    #[test]
428    fn test_envelope_cbor_roundtrip() {
429        let envelope = Envelope {
430            id: Uuid::new_v4(),
431            from: PubKey::new([1u8; 32]),
432            to: PubKey::new([2u8; 32]),
433            kind: MessageKind::Message {
434                body: "test".to_string(),
435                blocks: None,
436                content_taint: None,
437                handling_mode: None,
438            },
439            sig: Signature::new([0u8; 64]),
440        };
441        let mut buf = Vec::new();
442        ciborium::into_writer(&envelope, &mut buf).unwrap();
443        let decoded: Envelope = ciborium::from_reader(&buf[..]).unwrap();
444        assert_eq!(envelope, decoded);
445    }
446
447    #[test]
448    fn test_inbox_item_external_fields() {
449        let envelope = Envelope {
450            id: Uuid::new_v4(),
451            from: PubKey::new([1u8; 32]),
452            to: PubKey::new([2u8; 32]),
453            kind: MessageKind::Ack {
454                in_reply_to: Uuid::new_v4(),
455            },
456            sig: Signature::new([0u8; 64]),
457        };
458        let item = InboxItem::External {
459            envelope: envelope.clone(),
460        };
461        if let InboxItem::External { envelope: e } = item {
462            assert_eq!(e.id, envelope.id);
463        } else {
464            panic!("Expected External variant");
465        }
466    }
467
468    #[test]
469    fn test_inbox_item_cbor_roundtrip() {
470        let items = vec![
471            InboxItem::External {
472                envelope: Envelope {
473                    id: Uuid::new_v4(),
474                    from: PubKey::new([1u8; 32]),
475                    to: PubKey::new([2u8; 32]),
476                    kind: MessageKind::Ack {
477                        in_reply_to: Uuid::new_v4(),
478                    },
479                    sig: Signature::new([0u8; 64]),
480                },
481            },
482            InboxItem::PlainEvent {
483                body: "event".to_string(),
484                source: meerkat_core::PlainEventSource::Tcp,
485                handling_mode: HandlingMode::Queue,
486                interaction_id: Some(Uuid::new_v4()),
487                blocks: None,
488                render_metadata: None,
489            },
490        ];
491        for item in items {
492            let mut buf = Vec::new();
493            ciborium::into_writer(&item, &mut buf).unwrap();
494            let decoded: InboxItem = ciborium::from_reader(&buf[..]).unwrap();
495            assert_eq!(item, decoded);
496        }
497    }
498
499    #[test]
500    fn test_status_encodes_as_string() {
501        // Verify Status encodes as string, not ordinal
502        let status = Status::Accepted;
503        let mut buf = Vec::new();
504        ciborium::into_writer(&status, &mut buf).unwrap();
505        // CBOR text string starts with 0x60-0x7f for short strings
506        // "accepted" is 8 chars, so it should be 0x68 followed by "accepted"
507        let cbor_str = String::from_utf8_lossy(&buf);
508        assert!(
509            cbor_str.contains("accepted"),
510            "Status should encode as string 'accepted', got: {buf:?}"
511        );
512    }
513
514    #[test]
515    fn test_message_kind_tags_are_strings() {
516        // Verify MessageKind uses string tags, not ordinals
517        let msg = MessageKind::Message {
518            body: "test".to_string(),
519            blocks: None,
520            content_taint: None,
521            handling_mode: None,
522        };
523        let mut buf = Vec::new();
524        ciborium::into_writer(&msg, &mut buf).unwrap();
525        let cbor_str = String::from_utf8_lossy(&buf);
526        // Should contain "type" and "message" as strings
527        assert!(
528            cbor_str.contains("type") && cbor_str.contains("message"),
529            "MessageKind should use string tags, got: {buf:?}"
530        );
531    }
532
533    // Phase 1: Envelope signing tests
534
535    #[test]
536    fn test_signable_bytes_deterministic() {
537        let envelope = Envelope {
538            id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
539            from: PubKey::new([1u8; 32]),
540            to: PubKey::new([2u8; 32]),
541            kind: MessageKind::Message {
542                body: "test".to_string(),
543                blocks: None,
544                content_taint: None,
545                handling_mode: None,
546            },
547            sig: Signature::new([0u8; 64]),
548        };
549        let bytes1 = envelope.signable_bytes();
550        let bytes2 = envelope.signable_bytes();
551        assert_eq!(bytes1, bytes2, "signable_bytes must be deterministic");
552    }
553
554    #[test]
555    fn test_envelope_sign() {
556        use crate::identity::Keypair;
557
558        let keypair = Keypair::generate();
559        let mut envelope = Envelope {
560            id: Uuid::new_v4(),
561            from: keypair.public_key(),
562            to: PubKey::new([2u8; 32]),
563            kind: MessageKind::Message {
564                body: "test".to_string(),
565                blocks: None,
566                content_taint: None,
567                handling_mode: None,
568            },
569            sig: Signature::new([0u8; 64]),
570        };
571        envelope.sign(&keypair);
572        // Verify sig field is no longer all zeros
573        assert_ne!(envelope.sig, Signature::new([0u8; 64]));
574    }
575
576    #[test]
577    fn test_envelope_verify() {
578        use crate::identity::Keypair;
579
580        let keypair = Keypair::generate();
581        let mut envelope = Envelope {
582            id: Uuid::new_v4(),
583            from: keypair.public_key(),
584            to: PubKey::new([2u8; 32]),
585            kind: MessageKind::Message {
586                body: "test".to_string(),
587                blocks: None,
588                content_taint: None,
589                handling_mode: None,
590            },
591            sig: Signature::new([0u8; 64]),
592        };
593        envelope.sign(&keypair);
594        assert!(envelope.verify(), "signed envelope should verify");
595    }
596
597    #[test]
598    fn test_rct_contracts_envelope_signable_bytes_are_canonical() {
599        let envelope = Envelope {
600            id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
601            from: PubKey::new([1u8; 32]),
602            to: PubKey::new([2u8; 32]),
603            kind: MessageKind::Message {
604                body: "hello".to_string(),
605                blocks: None,
606                content_taint: None,
607                handling_mode: None,
608            },
609            sig: Signature::new([0u8; 64]),
610        };
611
612        let bytes = envelope.signable_bytes();
613
614        let mut expected = Vec::new();
615        expected.extend_from_slice(&[
616            0x84, 0x50, 0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b, 0x41, 0xd4, 0xa7, 0x16, 0x44, 0x66,
617            0x55, 0x44, 0x00, 0x00, 0x58, 0x20,
618        ]);
619        expected.extend(std::iter::repeat_n(0x01, 32));
620        expected.extend_from_slice(&[0x58, 0x20]);
621        expected.extend(std::iter::repeat_n(0x02, 32));
622        expected.extend_from_slice(&[
623            0xa2, 0x64, b'b', b'o', b'd', b'y', 0x65, b'h', b'e', b'l', b'l', b'o', 0x64, b't',
624            b'y', b'p', b'e', 0x67, b'm', b'e', b's', b's', b'a', b'g', b'e',
625        ]);
626
627        assert_eq!(bytes, expected);
628    }
629
630    #[test]
631    fn test_regression_ack_must_match_sent_message() {
632        use crate::identity::Keypair;
633
634        let sender_keypair = Keypair::generate();
635        let receiver_keypair = Keypair::generate();
636
637        let sent_id = Uuid::new_v4();
638        let sent_envelope = Envelope {
639            id: sent_id,
640            from: sender_keypair.public_key(),
641            to: receiver_keypair.public_key(),
642            kind: MessageKind::Message {
643                body: "hello".to_string(),
644                blocks: None,
645                content_taint: None,
646                handling_mode: None,
647            },
648            sig: Signature::new([0u8; 64]),
649        };
650
651        let mut valid_ack = Envelope {
652            id: Uuid::new_v4(),
653            from: receiver_keypair.public_key(),
654            to: sender_keypair.public_key(),
655            kind: MessageKind::Ack {
656                in_reply_to: sent_id,
657            },
658            sig: Signature::new([0u8; 64]),
659        };
660        valid_ack.sign(&receiver_keypair);
661
662        assert!(valid_ack.verify(), "valid ACK should verify");
663        assert_eq!(
664            valid_ack.from, sent_envelope.to,
665            "ACK from should match sent to"
666        );
667        if let MessageKind::Ack { in_reply_to } = valid_ack.kind {
668            assert_eq!(in_reply_to, sent_id, "ACK in_reply_to should match sent id");
669        }
670    }
671
672    #[test]
673    fn test_regression_ack_wrong_in_reply_to_is_invalid() {
674        use crate::identity::Keypair;
675
676        let sender_keypair = Keypair::generate();
677        let receiver_keypair = Keypair::generate();
678
679        let sent_id = Uuid::new_v4();
680        let wrong_id = Uuid::new_v4();
681
682        let mut wrong_ack = Envelope {
683            id: Uuid::new_v4(),
684            from: receiver_keypair.public_key(),
685            to: sender_keypair.public_key(),
686            kind: MessageKind::Ack {
687                in_reply_to: wrong_id,
688            },
689            sig: Signature::new([0u8; 64]),
690        };
691        wrong_ack.sign(&receiver_keypair);
692
693        assert!(wrong_ack.verify(), "signature should still verify");
694        if let MessageKind::Ack { in_reply_to } = wrong_ack.kind {
695            assert_ne!(
696                in_reply_to, sent_id,
697                "wrong ACK in_reply_to should not match sent id"
698            );
699        }
700    }
701
702    #[test]
703    fn test_regression_ack_from_wrong_peer_is_invalid() {
704        use crate::identity::Keypair;
705
706        let sender_keypair = Keypair::generate();
707        let receiver_keypair = Keypair::generate();
708        let imposter_keypair = Keypair::generate();
709
710        let sent_id = Uuid::new_v4();
711        let sent_to = receiver_keypair.public_key();
712
713        let mut imposter_ack = Envelope {
714            id: Uuid::new_v4(),
715            from: imposter_keypair.public_key(),
716            to: sender_keypair.public_key(),
717            kind: MessageKind::Ack {
718                in_reply_to: sent_id,
719            },
720            sig: Signature::new([0u8; 64]),
721        };
722        imposter_ack.sign(&imposter_keypair);
723
724        assert!(imposter_ack.verify(), "imposter signature should verify");
725        assert_ne!(
726            imposter_ack.from, sent_to,
727            "imposter ACK from should not match sent to"
728        );
729    }
730
731    #[test]
732    fn test_regression_ack_to_wrong_recipient_is_invalid() {
733        use crate::identity::Keypair;
734
735        let sender_keypair = Keypair::generate();
736        let receiver_keypair = Keypair::generate();
737        let wrong_recipient_keypair = Keypair::generate();
738
739        let sent_id = Uuid::new_v4();
740
741        let mut misrouted_ack = Envelope {
742            id: Uuid::new_v4(),
743            from: receiver_keypair.public_key(),
744            to: wrong_recipient_keypair.public_key(),
745            kind: MessageKind::Ack {
746                in_reply_to: sent_id,
747            },
748            sig: Signature::new([0u8; 64]),
749        };
750        misrouted_ack.sign(&receiver_keypair);
751
752        assert!(
753            misrouted_ack.verify(),
754            "misrouted ACK signature should verify"
755        );
756        assert_ne!(
757            misrouted_ack.to,
758            sender_keypair.public_key(),
759            "misrouted ACK 'to' should not match sender's public key"
760        );
761    }
762
763    // === PlainEvent tests ===
764
765    #[test]
766    fn test_inbox_item_plain_event_serde_roundtrip() {
767        use meerkat_core::PlainEventSource;
768
769        let item = InboxItem::PlainEvent {
770            body: "New email from john@example.com".to_string(),
771            source: PlainEventSource::Tcp,
772            handling_mode: HandlingMode::Queue,
773            interaction_id: None,
774            blocks: None,
775            render_metadata: None,
776        };
777
778        // JSON round-trip
779        let json = serde_json::to_string(&item).unwrap();
780        let parsed: InboxItem = serde_json::from_str(&json).unwrap();
781        assert_eq!(item, parsed);
782
783        // Verify tag
784        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
785        assert_eq!(value["type"], "plain_event");
786        assert_eq!(value["body"], "New email from john@example.com");
787        assert_eq!(value["source"], "tcp");
788    }
789
790    #[test]
791    fn test_inbox_item_plain_event_cbor_roundtrip() {
792        use meerkat_core::PlainEventSource;
793
794        let items = vec![
795            InboxItem::PlainEvent {
796                body: "hello".to_string(),
797                source: PlainEventSource::Tcp,
798                handling_mode: HandlingMode::Queue,
799                interaction_id: None,
800                blocks: None,
801                render_metadata: None,
802            },
803            InboxItem::PlainEvent {
804                body: r#"{"event":"email"}"#.to_string(),
805                source: PlainEventSource::Stdin,
806                handling_mode: HandlingMode::Queue,
807                interaction_id: None,
808                blocks: None,
809                render_metadata: None,
810            },
811            InboxItem::PlainEvent {
812                body: "webhook payload".to_string(),
813                source: PlainEventSource::Webhook,
814                handling_mode: HandlingMode::Queue,
815                interaction_id: None,
816                blocks: None,
817                render_metadata: None,
818            },
819            InboxItem::PlainEvent {
820                body: "rpc event".to_string(),
821                source: PlainEventSource::Rpc,
822                handling_mode: HandlingMode::Queue,
823                interaction_id: None,
824                blocks: None,
825                render_metadata: None,
826            },
827        ];
828        for item in items {
829            let mut buf = Vec::new();
830            ciborium::into_writer(&item, &mut buf).unwrap();
831            let decoded: InboxItem = ciborium::from_reader(&buf[..]).unwrap();
832            assert_eq!(item, decoded);
833        }
834    }
835
836    #[test]
837    fn test_inbox_item_plain_event_backward_compat_json() {
838        // Old format (no interaction_id, no blocks) should deserialize with defaults
839        let old_json = r#"{"type":"plain_event","body":"hello","source":"tcp"}"#;
840        let parsed: InboxItem = serde_json::from_str(old_json).unwrap();
841        match parsed {
842            InboxItem::PlainEvent {
843                body,
844                source,
845                handling_mode: _,
846                interaction_id,
847                blocks,
848                render_metadata,
849            } => {
850                assert_eq!(body, "hello");
851                assert_eq!(source, meerkat_core::PlainEventSource::Tcp);
852                assert_eq!(interaction_id, None);
853                assert_eq!(blocks, None);
854                assert_eq!(render_metadata, None);
855            }
856            other => panic!("Expected PlainEvent, got {other:?}"),
857        }
858    }
859
860    #[test]
861    fn test_inbox_item_plain_event_with_interaction_id_json_roundtrip() {
862        let id = Uuid::new_v4();
863        let item = InboxItem::PlainEvent {
864            body: "tracked event".to_string(),
865            source: meerkat_core::PlainEventSource::Rpc,
866            handling_mode: HandlingMode::Queue,
867            interaction_id: Some(id),
868            blocks: None,
869            render_metadata: None,
870        };
871
872        let json = serde_json::to_string(&item).unwrap();
873        let parsed: InboxItem = serde_json::from_str(&json).unwrap();
874        assert_eq!(item, parsed);
875
876        // Verify interaction_id is present in JSON
877        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
878        assert_eq!(value["interaction_id"], id.to_string());
879    }
880
881    #[test]
882    fn test_inbox_item_plain_event_with_interaction_id_cbor_roundtrip() {
883        let id = Uuid::new_v4();
884        let item = InboxItem::PlainEvent {
885            body: "tracked event".to_string(),
886            source: meerkat_core::PlainEventSource::Rpc,
887            handling_mode: HandlingMode::Queue,
888            interaction_id: Some(id),
889            blocks: None,
890            render_metadata: None,
891        };
892
893        let mut buf = Vec::new();
894        ciborium::into_writer(&item, &mut buf).unwrap();
895        let decoded: InboxItem = ciborium::from_reader(&buf[..]).unwrap();
896        assert_eq!(item, decoded);
897    }
898
899    #[test]
900    fn test_inbox_item_plain_event_none_interaction_id_skipped_in_json() {
901        let item = InboxItem::PlainEvent {
902            body: "untracked".to_string(),
903            source: meerkat_core::PlainEventSource::Tcp,
904            handling_mode: HandlingMode::Queue,
905            interaction_id: None,
906            blocks: None,
907            render_metadata: None,
908        };
909
910        let json = serde_json::to_string(&item).unwrap();
911        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
912        // interaction_id should not be present (skip_serializing_if = "Option::is_none")
913        assert!(
914            value.get("interaction_id").is_none(),
915            "interaction_id: None should not be serialized"
916        );
917    }
918
919    // === Multimodal blocks tests ===
920
921    #[test]
922    fn message_kind_with_blocks_cbor_roundtrip() {
923        let kind = MessageKind::Message {
924            body: "hello".to_string(),
925            blocks: Some(vec![
926                ContentBlock::Text {
927                    text: "hello".to_string(),
928                },
929                ContentBlock::Image {
930                    media_type: "image/png".to_string(),
931                    data: "iVBORw0KGgo=".into(),
932                },
933            ]),
934            content_taint: None,
935            handling_mode: None,
936        };
937        let mut buf = Vec::new();
938        ciborium::into_writer(&kind, &mut buf).unwrap();
939        let decoded: MessageKind = ciborium::from_reader(&buf[..]).unwrap();
940        assert_eq!(kind, decoded);
941    }
942
943    #[test]
944    fn message_kind_without_blocks_backwards_compat() {
945        use crate::identity::Keypair;
946
947        // Serialize a Message without blocks, sign it, verify it.
948        // Then deserialize and verify blocks defaults to None.
949        let keypair = Keypair::generate();
950        let kind = MessageKind::Message {
951            body: "test".to_string(),
952            blocks: None,
953            content_taint: None,
954            handling_mode: None,
955        };
956
957        // CBOR roundtrip preserves None
958        let mut buf = Vec::new();
959        ciborium::into_writer(&kind, &mut buf).unwrap();
960        let decoded: MessageKind = ciborium::from_reader(&buf[..]).unwrap();
961        assert_eq!(kind, decoded);
962
963        // Envelope sign+verify still works
964        let mut envelope = Envelope {
965            id: Uuid::new_v4(),
966            from: keypair.public_key(),
967            to: PubKey::new([2u8; 32]),
968            kind,
969            sig: Signature::new([0u8; 64]),
970        };
971        envelope.sign(&keypair);
972        assert!(
973            envelope.verify(),
974            "blocks=None envelope should still verify"
975        );
976
977        // JSON roundtrip omits blocks field entirely
978        let json = serde_json::to_string(&envelope.kind).unwrap();
979        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
980        assert!(
981            value.get("blocks").is_none(),
982            "blocks: None should not appear in JSON"
983        );
984        // content_taint: None is omitted from the wire exactly like blocks —
985        // undeclared taint keeps old envelopes byte-identical and verifying.
986        assert!(
987            value.get("content_taint").is_none(),
988            "content_taint: None should not appear in JSON"
989        );
990        let cbor_value: ciborium::value::Value = {
991            let mut buf = Vec::new();
992            ciborium::into_writer(&envelope.kind, &mut buf).unwrap();
993            ciborium::from_reader(&buf[..]).unwrap()
994        };
995        let ciborium::value::Value::Map(entries) = cbor_value else {
996            panic!("MessageKind should encode as a CBOR map");
997        };
998        assert!(
999            !entries.iter().any(|(key, _)| matches!(
1000                key,
1001                ciborium::value::Value::Text(text) if text == "content_taint"
1002            )),
1003            "content_taint: None should not appear in CBOR"
1004        );
1005    }
1006
1007    #[test]
1008    fn test_rct_contracts_signable_bytes_with_content_taint_are_canonical() {
1009        // Exact-byte pin for a DECLARED taint: `content_taint` (len 13) sorts
1010        // immediately before `handling_mode` (len 13, lexicographically later)
1011        // in the RFC 8949 length-then-lex canonical key order. Any change to
1012        // the signed encoding of the taint declaration breaks this pin.
1013        let envelope = Envelope {
1014            id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
1015            from: PubKey::new([1u8; 32]),
1016            to: PubKey::new([2u8; 32]),
1017            kind: MessageKind::Message {
1018                body: "hello".to_string(),
1019                blocks: None,
1020                content_taint: Some(SenderContentTaint::Tainted),
1021                handling_mode: Some(HandlingMode::Queue),
1022            },
1023            sig: Signature::new([0u8; 64]),
1024        };
1025
1026        let bytes = envelope.signable_bytes();
1027
1028        let mut expected = Vec::new();
1029        expected.extend_from_slice(&[
1030            0x84, 0x50, 0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b, 0x41, 0xd4, 0xa7, 0x16, 0x44, 0x66,
1031            0x55, 0x44, 0x00, 0x00, 0x58, 0x20,
1032        ]);
1033        expected.extend(std::iter::repeat_n(0x01, 32));
1034        expected.extend_from_slice(&[0x58, 0x20]);
1035        expected.extend(std::iter::repeat_n(0x02, 32));
1036        // Map of 4 entries in canonical order:
1037        //   "body" -> "hello", "type" -> "message",
1038        //   "content_taint" -> "tainted", "handling_mode" -> "queue".
1039        expected.push(0xa4);
1040        expected.extend_from_slice(&[0x64, b'b', b'o', b'd', b'y']);
1041        expected.extend_from_slice(&[0x65, b'h', b'e', b'l', b'l', b'o']);
1042        expected.extend_from_slice(&[0x64, b't', b'y', b'p', b'e']);
1043        expected.extend_from_slice(&[0x67, b'm', b'e', b's', b's', b'a', b'g', b'e']);
1044        expected.extend_from_slice(&[
1045            0x6d, b'c', b'o', b'n', b't', b'e', b'n', b't', b'_', b't', b'a', b'i', b'n', b't',
1046        ]);
1047        expected.extend_from_slice(&[0x67, b't', b'a', b'i', b'n', b't', b'e', b'd']);
1048        expected.extend_from_slice(&[
1049            0x6d, b'h', b'a', b'n', b'd', b'l', b'i', b'n', b'g', b'_', b'm', b'o', b'd', b'e',
1050        ]);
1051        expected.extend_from_slice(&[0x65, b'q', b'u', b'e', b'u', b'e']);
1052
1053        assert_eq!(bytes, expected);
1054    }
1055
1056    #[test]
1057    fn envelope_with_content_taint_signs_and_verifies() {
1058        use crate::identity::Keypair;
1059
1060        for taint in [SenderContentTaint::Clean, SenderContentTaint::Tainted] {
1061            let keypair = Keypair::generate();
1062            let mut envelope = Envelope {
1063                id: Uuid::new_v4(),
1064                from: keypair.public_key(),
1065                to: PubKey::new([2u8; 32]),
1066                kind: MessageKind::Request {
1067                    intent: "review".to_string(),
1068                    params: serde_json::json!({"pr": 7}),
1069                    blocks: None,
1070                    content_taint: Some(taint),
1071                    handling_mode: None,
1072                },
1073                sig: Signature::new([0u8; 64]),
1074            };
1075            envelope.sign(&keypair);
1076            assert!(
1077                envelope.verify(),
1078                "envelope with content_taint {taint} should verify"
1079            );
1080
1081            // The declaration is inside the signed region: flipping it after
1082            // signing must break verification.
1083            envelope.kind = envelope.kind.with_content_taint(Some(match taint {
1084                SenderContentTaint::Clean => SenderContentTaint::Tainted,
1085                SenderContentTaint::Tainted => SenderContentTaint::Clean,
1086            }));
1087            assert!(
1088                !envelope.verify(),
1089                "tampered content_taint must fail signature verification"
1090            );
1091        }
1092    }
1093
1094    #[test]
1095    fn content_taint_none_and_clean_are_distinct_wire_states() {
1096        // None (no declaration) omits the field; Clean is an affirmative,
1097        // serialized declaration. A receiver coalescing the two would erase
1098        // the sender's claim; the wire keeps them distinct.
1099        let undeclared = MessageKind::Message {
1100            body: "hello".to_string(),
1101            blocks: None,
1102            content_taint: None,
1103            handling_mode: None,
1104        };
1105        let clean = MessageKind::Message {
1106            body: "hello".to_string(),
1107            blocks: None,
1108            content_taint: Some(SenderContentTaint::Clean),
1109            handling_mode: None,
1110        };
1111        assert_ne!(undeclared, clean);
1112
1113        let undeclared_json = serde_json::to_value(&undeclared).unwrap();
1114        let clean_json = serde_json::to_value(&clean).unwrap();
1115        assert!(undeclared_json.get("content_taint").is_none());
1116        assert_eq!(clean_json["content_taint"], "clean");
1117
1118        let undeclared_roundtrip: MessageKind = serde_json::from_value(undeclared_json).unwrap();
1119        let clean_roundtrip: MessageKind = serde_json::from_value(clean_json).unwrap();
1120        assert_eq!(undeclared_roundtrip.content_taint(), None);
1121        assert_eq!(
1122            clean_roundtrip.content_taint(),
1123            Some(SenderContentTaint::Clean)
1124        );
1125    }
1126}