Skip to main content

clawft_kernel/
mesh_ipc.rs

1//! Cross-node IPC message serialization for mesh transport (K6.3).
2//!
3//! Handles serializing `KernelMessage` (via [`MeshIpcEnvelope`]) to wire
4//! format and deserializing incoming mesh messages.
5//!
6//! ## Encoding (ADR-031 / WEFT-683)
7//!
8//! **Shipped default:** JSON (`serde_json`) — [`MeshIpcEncoding::Json`] /
9//! [`DEFAULT_MESH_IPC_ENCODING`].
10//!
11//! **Deferred:** RVF wire-segment encoding for production performance is
12//! reserved behind the Cargo feature `mesh-rvf` as [`MeshIpcEncoding::Rvf`].
13//! That path currently returns [`MeshIpcError::UnsupportedEncoding`]; enable
14//! the feature only to exercise the API surface until Option B is built.
15//!
16//! Frame *kind* (`mesh_framing::FrameType`, e.g. `IpcMessage = 0x02`) is
17//! orthogonal to IPC *payload encoding*. See ADR-031.
18
19use serde::{Deserialize, Serialize};
20
21use crate::ipc::{KernelMessage, MessageTarget};
22
23/// Maximum IPC message size over mesh (16 MiB).
24const MAX_IPC_MESSAGE_SIZE: usize = 16 * 1024 * 1024;
25
26/// Maximum number of relay hops before a message is dropped.
27const MAX_HOPS: u8 = 8;
28
29/// Wire encoding for mesh IPC envelopes (ADR-031 / WEFT-683).
30///
31/// Orthogonal to [`crate::mesh_framing::FrameType`]: frame type selects the
32/// message kind (`IpcMessage`, `ChainSync`, …); this enum selects how the
33/// bytes *inside* an IPC frame are serialized.
34///
35/// Discriminant values match the conceptual encoding table in ADR-031
36/// (`0x01` JSON, `0x02` RVF). They are **not** written on the wire today —
37/// shipped traffic is bare JSON with no encoding prefix.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39#[repr(u8)]
40pub enum MeshIpcEncoding {
41    /// JSON (`serde_json`). Production and development default as of WEFT-683.
42    #[default]
43    Json = 0x01,
44    /// RVF wire segment (ADR-031 production target). Only compiled with
45    /// `--features mesh-rvf`; encode/decode return
46    /// [`MeshIpcError::UnsupportedEncoding`] until Option B lands.
47    #[cfg(feature = "mesh-rvf")]
48    Rvf = 0x02,
49}
50
51/// Production IPC encoding for all default builds (JSON).
52pub const DEFAULT_MESH_IPC_ENCODING: MeshIpcEncoding = MeshIpcEncoding::Json;
53
54impl MeshIpcEncoding {
55    /// Stable name for logs / errors.
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::Json => "json",
59            #[cfg(feature = "mesh-rvf")]
60            Self::Rvf => "rvf",
61        }
62    }
63
64    /// Parse a conceptual encoding byte (`0x01` JSON, `0x02` RVF when gated).
65    pub fn from_byte(b: u8) -> Option<Self> {
66        match b {
67            0x01 => Some(Self::Json),
68            #[cfg(feature = "mesh-rvf")]
69            0x02 => Some(Self::Rvf),
70            _ => None,
71        }
72    }
73}
74
75/// Tenant scope carried on a mesh envelope (P3 plan section 1.1).
76///
77/// `user_id` is 32 hex; `project_id` is a 26-char ULID when present. The
78/// machine mesh service stamps `src_scope` from the sending registration;
79/// the kernel only carries the fields.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Scope {
82    /// Owning user id.
83    pub user_id: String,
84    /// Project within the user, when the address is project-level.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub project_id: Option<String>,
87}
88
89/// A mesh IPC envelope wrapping a KernelMessage with routing metadata.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct MeshIpcEnvelope {
92    /// Source node ID.
93    pub source_node: String,
94    /// Destination node ID.
95    pub dest_node: String,
96    /// The kernel message being transported.
97    pub message: KernelMessage,
98    /// Hop count (incremented at each relay, max 8).
99    pub hop_count: u8,
100    /// Unique envelope ID for deduplication.
101    pub envelope_id: String,
102    /// Destination tenant scope. Absent on the wire when `None`, so
103    /// envelopes without scopes stay byte-identical to the old format.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub dest_scope: Option<Scope>,
106    /// Source tenant scope (stamped by the mesh service, never trusted
107    /// from an unregistered peer). Absent on the wire when `None`.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub src_scope: Option<Scope>,
110}
111
112impl MeshIpcEnvelope {
113    /// Create a new envelope for a message being sent to a remote node.
114    pub fn new(source_node: String, dest_node: String, message: KernelMessage) -> Self {
115        Self {
116            source_node,
117            dest_node,
118            message,
119            hop_count: 0,
120            envelope_id: uuid::Uuid::new_v4().to_string(),
121            dest_scope: None,
122            src_scope: None,
123        }
124    }
125
126    /// Serialize the envelope using [`DEFAULT_MESH_IPC_ENCODING`] (JSON).
127    pub fn to_bytes(&self) -> Result<Vec<u8>, MeshIpcError> {
128        self.to_bytes_with_encoding(DEFAULT_MESH_IPC_ENCODING)
129    }
130
131    /// Serialize with an explicit encoding.
132    ///
133    /// JSON is always available. RVF requires `feature = "mesh-rvf"` and
134    /// currently returns [`MeshIpcError::UnsupportedEncoding`] (WEFT-683
135    /// Option A — deferred until revisit triggers in ADR-031 fire).
136    pub fn to_bytes_with_encoding(
137        &self,
138        encoding: MeshIpcEncoding,
139    ) -> Result<Vec<u8>, MeshIpcError> {
140        match encoding {
141            MeshIpcEncoding::Json => {
142                let bytes = serde_json::to_vec(self)
143                    .map_err(|e| MeshIpcError::Serialization(e.to_string()))?;
144                if bytes.len() > MAX_IPC_MESSAGE_SIZE {
145                    return Err(MeshIpcError::MessageTooLarge {
146                        size: bytes.len(),
147                        max: MAX_IPC_MESSAGE_SIZE,
148                    });
149                }
150                Ok(bytes)
151            }
152            #[cfg(feature = "mesh-rvf")]
153            MeshIpcEncoding::Rvf => Err(MeshIpcError::UnsupportedEncoding {
154                encoding: encoding.as_str(),
155            }),
156        }
157    }
158
159    /// Deserialize an envelope from JSON bytes (default encoding).
160    pub fn from_bytes(data: &[u8]) -> Result<Self, MeshIpcError> {
161        Self::from_bytes_with_encoding(data, DEFAULT_MESH_IPC_ENCODING)
162    }
163
164    /// Deserialize with an explicit encoding.
165    pub fn from_bytes_with_encoding(
166        data: &[u8],
167        encoding: MeshIpcEncoding,
168    ) -> Result<Self, MeshIpcError> {
169        if data.len() > MAX_IPC_MESSAGE_SIZE {
170            return Err(MeshIpcError::MessageTooLarge {
171                size: data.len(),
172                max: MAX_IPC_MESSAGE_SIZE,
173            });
174        }
175        match encoding {
176            MeshIpcEncoding::Json => {
177                let envelope: Self = serde_json::from_slice(data)
178                    .map_err(|e| MeshIpcError::Deserialization(e.to_string()))?;
179                Self::validate_boundary(&envelope)?;
180                Ok(envelope)
181            }
182            #[cfg(feature = "mesh-rvf")]
183            MeshIpcEncoding::Rvf => Err(MeshIpcError::UnsupportedEncoding {
184                encoding: encoding.as_str(),
185            }),
186        }
187    }
188
189    /// Validate required fields at the mesh boundary.
190    fn validate_boundary(envelope: &Self) -> Result<(), MeshIpcError> {
191        if envelope.envelope_id.is_empty() {
192            return Err(MeshIpcError::Deserialization(
193                "envelope_id must not be empty".into(),
194            ));
195        }
196        if envelope.source_node.is_empty() {
197            return Err(MeshIpcError::Deserialization(
198                "source_node must not be empty".into(),
199            ));
200        }
201        Ok(())
202    }
203
204    /// Increment hop count and check if max hops exceeded.
205    pub fn increment_hop(&mut self) -> Result<(), MeshIpcError> {
206        self.hop_count += 1;
207        if self.hop_count > MAX_HOPS {
208            return Err(MeshIpcError::MaxHopsExceeded {
209                hops: self.hop_count,
210            });
211        }
212        Ok(())
213    }
214
215    /// Extract the inner target (unwrap RemoteNode if present).
216    pub fn inner_target(&self) -> &MessageTarget {
217        match &self.message.target {
218            MessageTarget::RemoteNode { target, .. } => target.as_ref(),
219            other => other,
220        }
221    }
222}
223
224/// Mesh IPC errors.
225#[non_exhaustive]
226#[derive(Debug, thiserror::Error)]
227pub enum MeshIpcError {
228    #[error("serialization error: {0}")]
229    Serialization(String),
230    #[error("deserialization error: {0}")]
231    Deserialization(String),
232    #[error("message too large: {size} bytes (max {max})")]
233    MessageTooLarge { size: usize, max: usize },
234    #[error("max hops exceeded: {hops}")]
235    MaxHopsExceeded { hops: u8 },
236    #[error("duplicate message: {envelope_id}")]
237    DuplicateMessage { envelope_id: String },
238    /// Encoding requested but not implemented (or not enabled).
239    ///
240    /// Used for [`MeshIpcEncoding::Rvf`] under `feature = "mesh-rvf"` until
241    /// ADR-031 Option B lands (WEFT-683).
242    #[error("unsupported mesh IPC encoding: {encoding} (ADR-031 / WEFT-683 — deferred)")]
243    UnsupportedEncoding { encoding: &'static str },
244}
245
246/// A correlated mesh request awaiting response.
247#[derive(Debug)]
248pub struct MeshRequest {
249    /// Request envelope.
250    pub request: MeshIpcEnvelope,
251    /// Correlation ID for matching response.
252    pub correlation_id: String,
253    /// When the request was sent.
254    pub sent_at: std::time::Instant,
255    /// Timeout duration.
256    pub timeout: std::time::Duration,
257}
258
259impl MeshRequest {
260    /// Create a new mesh request with an auto-generated correlation ID.
261    pub fn new(mut envelope: MeshIpcEnvelope, timeout: std::time::Duration) -> Self {
262        let correlation_id = uuid::Uuid::new_v4().to_string();
263        envelope.message.correlation_id = Some(correlation_id.clone());
264        Self {
265            request: envelope,
266            correlation_id,
267            sent_at: std::time::Instant::now(),
268            timeout,
269        }
270    }
271
272    /// Check if the request has timed out.
273    pub fn is_timed_out(&self) -> bool {
274        // `>=` so a zero-duration timeout (and the exact-deadline instant)
275        // counts as timed out — `>` left a sub-tick race where a freshly
276        // created zero-timeout request read as not-yet-expired under load.
277        self.sent_at.elapsed() >= self.timeout
278    }
279
280    /// Check if a response envelope matches this request.
281    pub fn matches_response(&self, response: &MeshIpcEnvelope) -> bool {
282        response.message.correlation_id.as_deref() == Some(&self.correlation_id)
283    }
284}
285
286/// Pending request tracker for correlated request-response.
287pub struct PendingRequests {
288    requests: std::collections::HashMap<String, MeshRequest>,
289    /// Optional chain manager for exochain audit logging.
290    #[cfg(feature = "exochain")]
291    chain_manager: Option<std::sync::Arc<crate::chain::ChainManager>>,
292}
293
294impl PendingRequests {
295    /// Create a new empty tracker.
296    pub fn new() -> Self {
297        Self {
298            requests: std::collections::HashMap::new(),
299            #[cfg(feature = "exochain")]
300            chain_manager: None,
301        }
302    }
303
304    /// Attach a chain manager for exochain audit logging.
305    #[cfg(feature = "exochain")]
306    pub fn set_chain_manager(&mut self, cm: std::sync::Arc<crate::chain::ChainManager>) {
307        self.chain_manager = Some(cm);
308    }
309
310    /// Register a pending request.
311    pub fn register(&mut self, request: MeshRequest) {
312        #[cfg(feature = "exochain")]
313        if let Some(ref cm) = self.chain_manager {
314            cm.append(
315                "mesh_ipc",
316                crate::chain::EVENT_KIND_MESH_IPC_SEND,
317                Some(serde_json::json!({
318                    "correlation_id": &request.correlation_id,
319                    "source_node": &request.request.source_node,
320                    "dest_node": &request.request.dest_node,
321                    "envelope_id": &request.request.envelope_id,
322                })),
323            );
324        }
325        self.requests
326            .insert(request.correlation_id.clone(), request);
327    }
328
329    /// Try to match a response to a pending request, removing it on match.
330    pub fn try_complete(&mut self, response: &MeshIpcEnvelope) -> Option<MeshRequest> {
331        if let Some(corr_id) = &response.message.correlation_id {
332            self.requests.remove(corr_id)
333        } else {
334            None
335        }
336    }
337
338    /// Remove timed-out requests. Returns their correlation IDs.
339    pub fn evict_timed_out(&mut self) -> Vec<String> {
340        let timed_out: Vec<String> = self
341            .requests
342            .iter()
343            .filter(|(_, r)| r.is_timed_out())
344            .map(|(id, _)| id.clone())
345            .collect();
346        for id in &timed_out {
347            self.requests.remove(id);
348        }
349        timed_out
350    }
351
352    /// Number of pending requests.
353    pub fn len(&self) -> usize {
354        self.requests.len()
355    }
356
357    /// Whether there are no pending requests.
358    pub fn is_empty(&self) -> bool {
359        self.requests.is_empty()
360    }
361}
362
363impl Default for PendingRequests {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::ipc::{KernelMessage, MessagePayload, MessageTarget};
373
374    fn make_test_message() -> KernelMessage {
375        KernelMessage::text(1, MessageTarget::Process(2), "hello mesh")
376    }
377
378    #[test]
379    fn envelope_new_sets_defaults() {
380        let msg = make_test_message();
381        let env = MeshIpcEnvelope::new("node-a".into(), "node-b".into(), msg);
382        assert_eq!(env.source_node, "node-a");
383        assert_eq!(env.dest_node, "node-b");
384        assert_eq!(env.hop_count, 0);
385        assert!(!env.envelope_id.is_empty());
386    }
387
388    #[test]
389    fn to_bytes_from_bytes_roundtrip() {
390        let msg = make_test_message();
391        let env = MeshIpcEnvelope::new("src".into(), "dst".into(), msg);
392        let bytes = env.to_bytes().unwrap();
393        let restored = MeshIpcEnvelope::from_bytes(&bytes).unwrap();
394        assert_eq!(restored.source_node, "src");
395        assert_eq!(restored.dest_node, "dst");
396        assert_eq!(restored.envelope_id, env.envelope_id);
397        assert_eq!(restored.message.id, env.message.id);
398    }
399
400    #[test]
401    fn from_bytes_rejects_oversized() {
402        let big = vec![0u8; MAX_IPC_MESSAGE_SIZE + 1];
403        let err = MeshIpcEnvelope::from_bytes(&big).unwrap_err();
404        assert!(matches!(err, MeshIpcError::MessageTooLarge { .. }));
405    }
406
407    #[test]
408    fn from_bytes_rejects_invalid_json() {
409        let err = MeshIpcEnvelope::from_bytes(b"not json").unwrap_err();
410        assert!(matches!(err, MeshIpcError::Deserialization(_)));
411    }
412
413    #[test]
414    fn increment_hop_within_limit() {
415        let msg = make_test_message();
416        let mut env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
417        for _ in 0..MAX_HOPS {
418            env.increment_hop().unwrap();
419        }
420        assert_eq!(env.hop_count, MAX_HOPS);
421    }
422
423    #[test]
424    fn increment_hop_exceeds_max() {
425        let msg = make_test_message();
426        let mut env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
427        env.hop_count = MAX_HOPS;
428        let err = env.increment_hop().unwrap_err();
429        assert!(matches!(err, MeshIpcError::MaxHopsExceeded { .. }));
430    }
431
432    #[test]
433    fn inner_target_unwraps_remote_node() {
434        let inner = MessageTarget::Process(7);
435        let remote_target = MessageTarget::RemoteNode {
436            node_id: "node-42".into(),
437            target: Box::new(inner),
438        };
439        let msg = KernelMessage::new(1, remote_target, MessagePayload::Text("hi".into()));
440        let env = MeshIpcEnvelope::new("a".into(), "node-42".into(), msg);
441        assert!(matches!(env.inner_target(), MessageTarget::Process(7)));
442    }
443
444    #[test]
445    fn inner_target_returns_non_remote_as_is() {
446        let msg = KernelMessage::text(1, MessageTarget::Broadcast, "hi");
447        let env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
448        assert!(matches!(env.inner_target(), MessageTarget::Broadcast));
449    }
450
451    #[test]
452    fn from_bytes_rejects_empty_source_node() {
453        let msg = make_test_message();
454        let mut env = MeshIpcEnvelope::new("".into(), "b".into(), msg);
455        env.source_node = "".into();
456        let bytes = serde_json::to_vec(&env).unwrap();
457        let err = MeshIpcEnvelope::from_bytes(&bytes).unwrap_err();
458        assert!(matches!(err, MeshIpcError::Deserialization(_)));
459    }
460
461    #[test]
462    fn from_bytes_rejects_empty_envelope_id() {
463        let msg = make_test_message();
464        let mut env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
465        env.envelope_id = "".into();
466        let bytes = serde_json::to_vec(&env).unwrap();
467        let err = MeshIpcEnvelope::from_bytes(&bytes).unwrap_err();
468        assert!(matches!(err, MeshIpcError::Deserialization(_)));
469    }
470
471    #[test]
472    fn k6_cross_node_ipc_via_did_addressing() {
473        // GlobalPid.node_id is derived from Ed25519 pubkey hash,
474        // functioning as a decentralized identifier (DID).
475        // This test proves messages can be addressed and routed via DID-based node IDs.
476        use crate::ipc::GlobalPid;
477
478        // Simulated DIDs: hex(SHA-256(pubkey)[0..16]) per node identity
479        let node_a_did = "a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8";
480        let node_b_did = "b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8a1";
481
482        // Address a process using DID-based GlobalPid
483        let target_pid = GlobalPid::local(42, node_b_did);
484        assert_eq!(target_pid.node_id, node_b_did);
485        assert!(!target_pid.is_local(node_a_did));
486        assert!(target_pid.is_local(node_b_did));
487
488        // Wrap in RemoteNode target for cross-node delivery
489        let msg = KernelMessage::text(
490            1,
491            MessageTarget::RemoteNode {
492                node_id: node_b_did.to_string(),
493                target: Box::new(MessageTarget::Process(42)),
494            },
495            "hello via DID",
496        );
497
498        // Create envelope with DID-based source and dest
499        let envelope = MeshIpcEnvelope::new(node_a_did.to_string(), node_b_did.to_string(), msg);
500
501        // Roundtrip serialization preserves DID addressing
502        let bytes = envelope.to_bytes().unwrap();
503        let restored = MeshIpcEnvelope::from_bytes(&bytes).unwrap();
504        assert_eq!(restored.dest_node, node_b_did);
505        assert_eq!(restored.source_node, node_a_did);
506
507        // Inner target unwraps to the addressed process
508        assert!(matches!(
509            restored.inner_target(),
510            MessageTarget::Process(42)
511        ));
512    }
513
514    #[test]
515    fn mesh_request_correlation() {
516        let msg = make_test_message();
517        let env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
518        let req = MeshRequest::new(env, std::time::Duration::from_secs(5));
519        assert!(!req.correlation_id.is_empty());
520        assert_eq!(
521            req.request.message.correlation_id.as_deref(),
522            Some(req.correlation_id.as_str())
523        );
524    }
525
526    #[test]
527    fn mesh_request_timeout() {
528        let msg = make_test_message();
529        let env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
530        // Zero-duration timeout: immediately timed out
531        let req = MeshRequest::new(env, std::time::Duration::from_secs(0));
532        assert!(req.is_timed_out());
533    }
534
535    #[test]
536    fn pending_requests_complete() {
537        let msg = make_test_message();
538        let env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
539        let req = MeshRequest::new(env, std::time::Duration::from_secs(30));
540        let corr_id = req.correlation_id.clone();
541
542        let mut pending = PendingRequests::new();
543        pending.register(req);
544        assert_eq!(pending.len(), 1);
545
546        // Build a response with matching correlation_id
547        let resp_msg = KernelMessage::with_correlation(
548            2,
549            MessageTarget::Process(1),
550            MessagePayload::Text("ok".into()),
551            corr_id.clone(),
552        );
553        let resp_env = MeshIpcEnvelope::new("b".into(), "a".into(), resp_msg);
554        let completed = pending.try_complete(&resp_env);
555        assert!(completed.is_some());
556        assert_eq!(completed.unwrap().correlation_id, corr_id);
557        assert!(pending.is_empty());
558    }
559
560    #[test]
561    fn pending_requests_evict_timed_out() {
562        let msg = make_test_message();
563        let env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
564        let req = MeshRequest::new(env, std::time::Duration::from_secs(0));
565        let corr_id = req.correlation_id.clone();
566
567        let mut pending = PendingRequests::new();
568        pending.register(req);
569        assert_eq!(pending.len(), 1);
570
571        let evicted = pending.evict_timed_out();
572        assert_eq!(evicted.len(), 1);
573        assert_eq!(evicted[0], corr_id);
574        assert!(pending.is_empty());
575    }
576
577    #[test]
578    fn envelope_serde_with_correlation_id() {
579        let msg = KernelMessage::with_correlation(
580            1,
581            MessageTarget::Process(2),
582            MessagePayload::Text("req".into()),
583            "corr-99".into(),
584        );
585        let env = MeshIpcEnvelope::new("n1".into(), "n2".into(), msg);
586        let bytes = env.to_bytes().unwrap();
587        let restored = MeshIpcEnvelope::from_bytes(&bytes).unwrap();
588        assert_eq!(restored.message.correlation_id, Some("corr-99".into()));
589    }
590
591    #[test]
592    fn default_encoding_is_json() {
593        assert_eq!(DEFAULT_MESH_IPC_ENCODING, MeshIpcEncoding::Json);
594        assert_eq!(MeshIpcEncoding::default(), MeshIpcEncoding::Json);
595        assert_eq!(MeshIpcEncoding::Json.as_str(), "json");
596        assert_eq!(
597            MeshIpcEncoding::from_byte(0x01),
598            Some(MeshIpcEncoding::Json)
599        );
600        // RVF byte is unknown unless mesh-rvf is enabled.
601        #[cfg(not(feature = "mesh-rvf"))]
602        assert_eq!(MeshIpcEncoding::from_byte(0x02), None);
603        #[cfg(feature = "mesh-rvf")]
604        assert_eq!(MeshIpcEncoding::from_byte(0x02), Some(MeshIpcEncoding::Rvf));
605    }
606
607    #[test]
608    fn to_bytes_with_encoding_json_matches_to_bytes() {
609        let msg = make_test_message();
610        let env = MeshIpcEnvelope::new("src".into(), "dst".into(), msg);
611        let a = env.to_bytes().unwrap();
612        let b = env.to_bytes_with_encoding(MeshIpcEncoding::Json).unwrap();
613        assert_eq!(a, b);
614        let restored =
615            MeshIpcEnvelope::from_bytes_with_encoding(&a, MeshIpcEncoding::Json).unwrap();
616        assert_eq!(restored.envelope_id, env.envelope_id);
617    }
618
619    #[cfg(feature = "mesh-rvf")]
620    #[test]
621    fn rvf_encoding_returns_unsupported() {
622        let msg = make_test_message();
623        let env = MeshIpcEnvelope::new("src".into(), "dst".into(), msg);
624        let err = env
625            .to_bytes_with_encoding(MeshIpcEncoding::Rvf)
626            .unwrap_err();
627        assert!(matches!(
628            err,
629            MeshIpcError::UnsupportedEncoding { encoding: "rvf" }
630        ));
631        let err =
632            MeshIpcEnvelope::from_bytes_with_encoding(b"{}", MeshIpcEncoding::Rvf).unwrap_err();
633        assert!(matches!(
634            err,
635            MeshIpcError::UnsupportedEncoding { encoding: "rvf" }
636        ));
637    }
638
639    // ── P3-K0: scope fields are wire-compatible ──────────────────
640
641    /// Frozen wire bytes of an envelope as written before scopes existed.
642    /// Do not regenerate: if this fails, the wire format changed.
643    const FROZEN_PRE_SCOPE_ENVELOPE: &str = concat!(
644        r#"{"source_node":"a","dest_node":"b","message":{"id":"m1","from":1,"#,
645        r#""target":{"Topic":"t"},"payload":{"Text":"x"},"#,
646        r#""timestamp":"2026-01-02T03:04:05Z"},"hop_count":0,"envelope_id":"fixed-id"}"#
647    );
648
649    #[test]
650    fn envelope_without_scopes_serializes_byte_identical_to_old_format() {
651        let env = MeshIpcEnvelope::from_bytes(FROZEN_PRE_SCOPE_ENVELOPE.as_bytes()).unwrap();
652        assert!(env.dest_scope.is_none() && env.src_scope.is_none());
653        assert_eq!(serde_json::to_string(&env).unwrap(), FROZEN_PRE_SCOPE_ENVELOPE);
654        assert!(!FROZEN_PRE_SCOPE_ENVELOPE.contains("scope"));
655    }
656
657    #[test]
658    fn old_json_without_scopes_deserializes() {
659        let msg = KernelMessage::text(1, MessageTarget::Topic("t".into()), "x");
660        let env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
661        let bytes = env.to_bytes().unwrap();
662        let back = MeshIpcEnvelope::from_bytes(&bytes).unwrap();
663        assert!(back.dest_scope.is_none() && back.src_scope.is_none());
664    }
665
666    #[test]
667    fn scoped_envelope_round_trips() {
668        let msg = KernelMessage::text(1, MessageTarget::Topic("t".into()), "x");
669        let mut env = MeshIpcEnvelope::new("a".into(), "b".into(), msg);
670        env.dest_scope = Some(Scope {
671            user_id: "u".repeat(32),
672            project_id: Some("P".repeat(26)),
673        });
674        env.src_scope = Some(Scope {
675            user_id: "v".repeat(32),
676            project_id: None,
677        });
678        let back = MeshIpcEnvelope::from_bytes(&env.to_bytes().unwrap()).unwrap();
679        assert_eq!(back.dest_scope, env.dest_scope);
680        assert_eq!(back.src_scope, env.src_scope);
681    }
682}