Skip to main content

khive_wire_protocol/
codec.rs

1//! The length-prefixed framing codec.
2//!
3//! One wire frame is a 4-byte big-endian `u32` length prefix followed by
4//! that many bytes of JSON, matching the existing Unix-domain-socket framing
5//! this crate remains base-compatible with (`crates/khive-runtime/src/daemon.rs`,
6//! `read_frame`/`write_frame`). This crate defines what the JSON payload
7//! inside that framing is; it performs no I/O itself — [`decode_frame`] and
8//! [`encode_frame`] operate on in-memory byte slices, and a transport crate
9//! is responsible for reading/writing those bytes from a socket and for
10//! buffering partial reads until a complete frame is available.
11
12use crate::frame::Frame;
13
14/// Length of the big-endian `u32` frame-length prefix, in bytes.
15pub const LENGTH_PREFIX_BYTES: usize = 4;
16
17/// The fixed wording every id/scope rejection carries, whichever decode
18/// path produced it: the serde visitor in [`crate::frame`] (which
19/// [`decode_payload`] drives, and which a direct
20/// `serde_json::from_str::<Frame>` reaches too) reports the rule through
21/// the deserializer's error type, and [`decode_payload`] re-classifies a
22/// message carrying this prefix into
23/// [`CodecError::InconsistentErrorScope`]. Keeping one wording lets both
24/// decode paths enforce the ADR-137 rule without duplicating its logic.
25pub(crate) const INCONSISTENT_SCOPE_ERROR_PREFIX: &str = "error frame violates the id/scope rule: ";
26
27/// Default maximum frame size: 8 MiB.
28///
29/// Chosen to match the existing Unix-domain-socket transport's
30/// `MAX_FRAME_BYTES` (`crates/khive-runtime/src/daemon.rs:38`) so that
31/// migrating a connection from that framing to this crate's codec does not
32/// silently tighten or loosen the limit. ADR-137 assigns the exact default
33/// to this crate as an implementation-phase deliverable ("Implementation-phase
34/// deliverables"); deployments that need a different bound configure
35/// [`FrameCodec::max_frame_bytes`] explicitly.
36pub const DEFAULT_MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
37
38/// A codec failure, distinguishing every frame-level case this crate can
39/// identify. Every variant maps onto the wire error taxonomy —
40/// [`crate::error::WireErrorCode::MalformedFrame`],
41/// [`crate::error::WireErrorCode::Internal`], or
42/// [`crate::error::WireErrorCode::FrameTooLarge`] — canonically via
43/// [`wire_code`](Self::wire_code) (and the `From<&CodecError>` impl), so
44/// servers need no hand-maintained mapping; this crate keeps the
45/// finer-grained variant so a caller (including a test) can assert the
46/// specific failure rather than only "decoding failed".
47#[derive(Debug, thiserror::Error, PartialEq, Eq)]
48pub enum CodecError {
49    /// Fewer than [`LENGTH_PREFIX_BYTES`] bytes were supplied — the 4-byte
50    /// length prefix itself is truncated.
51    #[error("truncated length prefix: got {available} of {LENGTH_PREFIX_BYTES} bytes")]
52    TruncatedLengthPrefix { available: usize },
53
54    /// The length prefix names a payload longer than what was supplied.
55    #[error("truncated payload: declared {declared} bytes, got {available}")]
56    TruncatedPayload { declared: usize, available: usize },
57
58    /// The declared frame length exceeds the configured maximum
59    /// (payload-only; the 4-byte prefix never counts against it). `max`
60    /// is always the configured bound that was exceeded.
61    #[error("frame of {declared} bytes exceeds the {max} byte maximum")]
62    FrameTooLarge { declared: usize, max: usize },
63
64    /// The serialized payload exceeds the 4-byte length prefix's inherent
65    /// `u32::MAX` byte capacity. Distinct from [`FrameTooLarge`](Self::FrameTooLarge): this arm
66    /// can only fire when the configured maximum is at least the payload
67    /// length (otherwise [`FrameTooLarge`](Self::FrameTooLarge) fires first), so the configured
68    /// maximum was NOT the binding limit — the prefix capacity was, and
69    /// the error names it.
70    #[error("frame of {declared} bytes exceeds the u32 length prefix's {max} byte capacity")]
71    U32PrefixLimitExceeded { declared: usize, max: usize },
72
73    /// Decode side: the payload bytes are not valid JSON at all. Encode
74    /// side: the frame failed to serialize (only reachable for opaque
75    /// payloads that cannot be represented, e.g. a map with non-string
76    /// keys smuggled into `response.result`).
77    #[error("payload is not valid JSON: {0}")]
78    InvalidJson(String),
79
80    /// The payload is valid JSON but not a JSON object, or has no `"kind"`
81    /// string field (absent or not a string).
82    #[error("payload has no string \"kind\" discriminant field")]
83    MissingKind,
84
85    /// The `"kind"` field names a value outside the closed
86    /// [`crate::frame::FRAME_KINDS`] set.
87    #[error("unknown frame kind: {0:?}")]
88    UnknownFrameKind(String),
89
90    /// The frame's `"kind"` was recognized but a field required by that
91    /// frame kind's shape is missing, has the wrong type, or is unknown to
92    /// the closed grammar (strict field rejection — see the crate
93    /// documentation's "Strict field rejection" section). Encode side: an
94    /// operation id field carries the empty string, which the wire grammar
95    /// forbids ([`crate::frame::OperationId`]).
96    #[error("frame kind {kind:?}: {detail}")]
97    InvalidFields { kind: String, detail: String },
98
99    /// An `error` frame whose operation-id presence contradicts its code's
100    /// terminal scope (ADR-137, "Operation correlation"): a
101    /// connection-terminal code carries no operation id, and a
102    /// request-terminal code echoes the one it terminates. Enforced at
103    /// decode — inside [`crate::frame::Frame`]'s serde visitor, so BOTH
104    /// the codec's `decode_payload` and a direct
105    /// `serde_json::from_str::<Frame>` reject it (the codec re-classifies
106    /// the visitor's message into this typed variant) — AND at encode
107    /// ([`encode_frame_with_max`]).
108    #[error("error frame violates the id/scope rule: {detail}")]
109    InconsistentErrorScope { detail: String },
110
111    /// Encode side: the frame is a decoded unknown-code fallback — a
112    /// [`crate::frame::Frame::Error`] carrying a `Some`
113    /// `unrecognized_code`, which only a decode path sets when the wire
114    /// carried a code outside the closed set. Re-encoding one would emit
115    /// the fallback code (`internal`) and silently discard the newer code
116    /// the peer sent, so the encode path rejects this local relay attempt;
117    /// the connection remains healthy, but this relay cannot re-encode the
118    /// decoded frame. If a relay must pass unknown codes through, it has to
119    /// operate on the raw frame bytes, not on a decoded-and-re-encoded frame.
120    #[error(
121        "fallback error frame (unrecognized code {code:?}) is not re-encodable: \
122         re-encoding would emit \"internal\" and discard the newer wire code"
123    )]
124    FallbackFrameNotEncodable { code: String },
125}
126
127impl CodecError {
128    /// The wire error code this failure maps to under ADR-137's taxonomy:
129    /// [`FrameTooLarge`](Self::FrameTooLarge) and
130    /// [`U32PrefixLimitExceeded`](Self::U32PrefixLimitExceeded) — the size
131    /// failures — map to [`WireErrorCode::FrameTooLarge`]. A decoded fallback
132    /// that this local relay cannot re-encode maps to
133    /// [`WireErrorCode::Internal`]; the connection is healthy and only the
134    /// relay attempt failed. Every other variant maps to
135    /// [`WireErrorCode::MalformedFrame`]. This is the canonical
136    /// codec→wire-error mapping: servers should use it (or the
137    /// `From<&CodecError>` impl) rather than hand-maintain their own.
138    ///
139    /// [`WireErrorCode::FrameTooLarge`]: crate::error::WireErrorCode::FrameTooLarge
140    /// [`WireErrorCode::MalformedFrame`]: crate::error::WireErrorCode::MalformedFrame
141    /// [`WireErrorCode::Internal`]: crate::error::WireErrorCode::Internal
142    pub const fn wire_code(&self) -> crate::error::WireErrorCode {
143        match self {
144            CodecError::FrameTooLarge { .. } | CodecError::U32PrefixLimitExceeded { .. } => {
145                crate::error::WireErrorCode::FrameTooLarge
146            }
147            CodecError::TruncatedLengthPrefix { .. }
148            | CodecError::TruncatedPayload { .. }
149            | CodecError::InvalidJson(_)
150            | CodecError::MissingKind
151            | CodecError::UnknownFrameKind(_)
152            | CodecError::InvalidFields { .. }
153            | CodecError::InconsistentErrorScope { .. } => {
154                crate::error::WireErrorCode::MalformedFrame
155            }
156            CodecError::FallbackFrameNotEncodable { .. } => crate::error::WireErrorCode::Internal,
157        }
158    }
159}
160
161impl From<&CodecError> for crate::error::WireErrorCode {
162    fn from(err: &CodecError) -> Self {
163        err.wire_code()
164    }
165}
166
167/// A configured framing codec.
168///
169/// Stateless beyond the configured [`max_frame_bytes`](Self::max_frame_bytes)
170/// bound: [`encode`](Self::encode) and [`decode`](Self::decode) operate on
171/// one frame at a time and hold no connection state (handshake sequencing
172/// is [`crate::handshake::HandshakeGate`]'s job, not the codec's).
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct FrameCodec {
175    max_frame_bytes: usize,
176}
177
178impl FrameCodec {
179    /// A codec with the given maximum frame size (the JSON payload length,
180    /// not counting the 4-byte prefix).
181    pub const fn new(max_frame_bytes: usize) -> Self {
182        Self { max_frame_bytes }
183    }
184
185    pub const fn max_frame_bytes(&self) -> usize {
186        self.max_frame_bytes
187    }
188
189    /// Encode one frame as a complete length-prefixed wire buffer: 4-byte
190    /// BE length followed by the JSON payload, guarded by this codec's own
191    /// configured maximum so encode and decode share one bound.
192    pub fn encode(&self, frame: &Frame) -> Result<Vec<u8>, CodecError> {
193        encode_frame_with_max(frame, self.max_frame_bytes)
194    }
195
196    /// Decode one complete length-prefixed frame from `buf`.
197    ///
198    /// `buf` must contain at least the full frame (prefix + payload); this
199    /// function does not support partial/streaming input. Bytes in `buf`
200    /// beyond the decoded frame, if any, are ignored — a transport that
201    /// reads a stream is responsible for slicing exactly one frame's bytes
202    /// (using the length prefix) before calling this, or for using
203    /// [`decode_with_consumed`](Self::decode_with_consumed) to learn where
204    /// the decoded frame ends.
205    pub fn decode(&self, buf: &[u8]) -> Result<Frame, CodecError> {
206        decode_frame(buf, self.max_frame_bytes)
207    }
208
209    /// Decode one complete length-prefixed frame from `buf` and report the
210    /// number of bytes consumed: the 4-byte length prefix plus the
211    /// declared payload length. The remainder `buf[consumed..]` — if any —
212    /// is the next frame's bytes, letting a transport split a buffered
213    /// stream into frames without re-parsing the length prefix itself.
214    /// The frame itself is decoded exactly as [`decode`](Self::decode).
215    pub fn decode_with_consumed(&self, buf: &[u8]) -> Result<(Frame, usize), CodecError> {
216        decode_frame_with_consumed(buf, self.max_frame_bytes)
217    }
218}
219
220impl Default for FrameCodec {
221    fn default() -> Self {
222        Self::new(DEFAULT_MAX_FRAME_BYTES)
223    }
224}
225
226/// Encode one frame as a complete length-prefixed wire buffer, using
227/// [`DEFAULT_MAX_FRAME_BYTES`] as the encode-side size guard.
228///
229/// Use [`encode_frame_with_max`] to encode against a different bound; a
230/// [`FrameCodec`] always encodes against its own configured maximum so that
231/// both directions of one codec share a single bound.
232pub fn encode_frame(frame: &Frame) -> Result<Vec<u8>, CodecError> {
233    encode_frame_with_max(frame, DEFAULT_MAX_FRAME_BYTES)
234}
235
236/// Encode one frame against an explicit `max_frame_bytes` bound.
237///
238/// `max_frame_bytes` is PAYLOAD-ONLY: the maximum JSON payload length, not
239/// counting the 4-byte length prefix — the same bound [`decode_frame`]
240/// checks against.
241///
242/// Before serializing, the frame is validated against the same wire rules
243/// the decode side enforces (`validate_frame_for_wire`, private): an empty
244/// operation id in any id field ([`CodecError::InvalidFields`]), a handshake
245/// or handshake acknowledgment naming version 0
246/// ([`CodecError::InvalidFields`]), an
247/// `error` frame whose id presence contradicts its code's terminal scope
248/// ([`CodecError::InconsistentErrorScope`]), and a decoded unknown-code
249/// fallback `error` frame ([`CodecError::FallbackFrameNotEncodable`]) are
250/// all rejected here, so a frame this function accepts is one any
251/// conforming decoder accepts. This keeps
252/// encode and decode symmetric: a locally constructed frame that violates
253/// the grammar can never leave this crate as wire bytes.
254///
255/// Cost note: the size guard runs on the SERIALIZED payload, so the frame
256/// is fully serialized (and its bytes allocated) before the bound is
257/// checked; an oversized frame therefore pays one full serialization before
258/// [`CodecError::FrameTooLarge`] is returned. This is the accepted cost of
259/// a local, non-I/O encode: no cheaper pre-estimate of the serialized size
260/// exists without serializing.
261pub fn encode_frame_with_max(frame: &Frame, max_frame_bytes: usize) -> Result<Vec<u8>, CodecError> {
262    validate_frame_for_wire(frame)?;
263    let payload = serde_json::to_vec(frame).map_err(|e| CodecError::InvalidJson(e.to_string()))?;
264    check_encode_payload_len(payload.len(), max_frame_bytes)?;
265    let mut buf = Vec::with_capacity(LENGTH_PREFIX_BYTES + payload.len());
266    buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
267    buf.extend_from_slice(&payload);
268    Ok(buf)
269}
270
271/// The encode-side mirror of the decode-side grammar checks: rejects a
272/// frame that [`decode_payload`] would reject, so encode and decode are
273/// symmetric and a locally constructed frame can never serialize into wire
274/// bytes no conforming decoder would accept.
275///
276/// Two rules are enforced here:
277///
278/// 1. No operation id field may carry the empty string — an empty string
279///    can never be a unique caller-generated id. Decode enforces this in
280///    `OperationId`'s `Deserialize` impl; the encode side checks the
281///    in-memory value directly, because [`crate::frame::OperationId`]
282///    construction is deliberately unrestricted.
283/// 2. An `error` frame's operation-id presence must agree with its code's
284///    terminal scope (ADR-137, "Operation correlation"), exactly as
285///    [`decode_payload`] enforces via [`CodecError::InconsistentErrorScope`].
286///    Every [`crate::error::WireErrorCode`] variant is in the closed set,
287///    so the decode side's unknown-code exemption never applies here.
288/// 3. A handshake or handshake acknowledgment may not name version 0;
289///    version 0 does not exist in this protocol.
290/// 4. An `error` frame carrying a `Some` `unrecognized_code` — the
291///    decoded unknown-code fallback marker, which only a decode path sets
292///    — is rejected with [`CodecError::FallbackFrameNotEncodable`].
293///    This relay cannot re-encode a fallback: doing so would emit the
294///    fallback code (`internal`) and silently discard the newer code the
295///    peer sent, so this crate refuses to corrupt it.
296fn validate_frame_for_wire(frame: &Frame) -> Result<(), CodecError> {
297    use crate::error::TerminalScope;
298
299    fn check_id(kind: &str, id: &crate::frame::OperationId) -> Result<(), CodecError> {
300        if id.0.is_empty() {
301            return Err(CodecError::InvalidFields {
302                kind: kind.to_string(),
303                detail: "operation id must be a non-empty string".to_string(),
304            });
305        }
306        Ok(())
307    }
308
309    match frame {
310        Frame::Handshake { version } => {
311            if version.get() == 0 {
312                return Err(CodecError::InvalidFields {
313                    kind: "handshake".to_string(),
314                    detail: "protocol version 0 does not exist".to_string(),
315                });
316            }
317        }
318        Frame::HandshakeAck { version } => {
319            if version.get() == 0 {
320                return Err(CodecError::InvalidFields {
321                    kind: "handshake_ack".to_string(),
322                    detail: "protocol version 0 does not exist".to_string(),
323                });
324            }
325        }
326        Frame::Event { .. } => {}
327        Frame::Request { id, .. } => check_id("request", id)?,
328        Frame::Response { id, .. } => check_id("response", id)?,
329        Frame::Cancel { id } => check_id("cancel", id)?,
330        Frame::Subscribe { id, .. } => check_id("subscribe", id)?,
331        Frame::SubscribeAck { id, .. } => check_id("subscribe_ack", id)?,
332        Frame::Unsubscribe { id, .. } => check_id("unsubscribe", id)?,
333        Frame::UnsubscribeAck { id, .. } => check_id("unsubscribe_ack", id)?,
334        Frame::Error {
335            id,
336            code,
337            unrecognized_code,
338            ..
339        } => {
340            // A decoded-fallback frame cannot be re-encoded by this relay;
341            // see rule 4.
342            if let Some(raw_code) = unrecognized_code {
343                return Err(CodecError::FallbackFrameNotEncodable {
344                    code: raw_code.clone(),
345                });
346            }
347            if let Some(id) = id {
348                check_id("error", id)?;
349            }
350            match (code.terminal_scope(), id) {
351                (TerminalScope::Connection, Some(id)) => {
352                    return Err(CodecError::InconsistentErrorScope {
353                        detail: format!(
354                            "connection-terminal code {code} must not carry an operation id, got {id}"
355                        ),
356                    });
357                }
358                (TerminalScope::Request, None) => {
359                    return Err(CodecError::InconsistentErrorScope {
360                        detail: format!(
361                            "request-terminal code {code} must echo the operation id it terminates"
362                        ),
363                    });
364                }
365                (TerminalScope::Connection, None) | (TerminalScope::Request, Some(_)) => {}
366            }
367        }
368    }
369    Ok(())
370}
371
372/// The encode-side size decision, split out from
373/// [`encode_frame_with_max`] so both guards are testable without
374/// serializing a >4 GiB payload.
375fn check_encode_payload_len(payload_len: usize, max_frame_bytes: usize) -> Result<(), CodecError> {
376    if payload_len > max_frame_bytes {
377        return Err(CodecError::FrameTooLarge {
378            declared: payload_len,
379            max: max_frame_bytes,
380        });
381    }
382    // The 4-byte length prefix is a `u32`; without this guard a caller who
383    // configured `max_frame_bytes > u32::MAX` could admit a payload whose
384    // length would silently truncate in the cast to the prefix, writing a
385    // prefix that does not match the payload. This arm only fires when the
386    // configured maximum was NOT exceeded (that is the check above), so the
387    // error names the prefix capacity as the binding limit rather than the
388    // configured maximum.
389    if payload_len > u32::MAX as usize {
390        return Err(CodecError::U32PrefixLimitExceeded {
391            declared: payload_len,
392            max: u32::MAX as usize,
393        });
394    }
395    Ok(())
396}
397
398/// Decode one complete length-prefixed frame from `buf` against an explicit
399/// `max_frame_bytes` bound. See [`FrameCodec::decode`] for the contract.
400///
401/// `max_frame_bytes` is PAYLOAD-ONLY: the maximum JSON payload length, not
402/// counting the 4-byte prefix. Passing a total wire length here is a caller
403/// bug — it admits payloads 4 bytes over the intended bound.
404pub fn decode_frame(buf: &[u8], max_frame_bytes: usize) -> Result<Frame, CodecError> {
405    decode_frame_with_consumed(buf, max_frame_bytes).map(|(frame, _)| frame)
406}
407
408/// Decode one complete length-prefixed frame from `buf` and report the
409/// number of bytes consumed: [`LENGTH_PREFIX_BYTES`] plus the declared
410/// payload length. Bytes beyond the consumed prefix — if any — belong to
411/// the next frame, letting a transport split a buffered stream into frames
412/// without re-parsing the length prefix itself. [`decode_frame`] is this
413/// function with the consumed count discarded.
414///
415/// `max_frame_bytes` is PAYLOAD-ONLY, as in [`decode_frame`].
416///
417/// **Decode errors are connection-terminal.** An error from this function
418/// carries NO consumed count: once a frame fails to decode, this crate
419/// cannot say where the failed frame ends, so the stream position is
420/// unrecoverable. A transport must map the error through
421/// [`CodecError::wire_code`], send the corresponding wire error, and close
422/// the connection — never attempt to resynchronize and keep reading.
423pub fn decode_frame_with_consumed(
424    buf: &[u8],
425    max_frame_bytes: usize,
426) -> Result<(Frame, usize), CodecError> {
427    if buf.len() < LENGTH_PREFIX_BYTES {
428        return Err(CodecError::TruncatedLengthPrefix {
429            available: buf.len(),
430        });
431    }
432    let mut len_bytes = [0u8; LENGTH_PREFIX_BYTES];
433    len_bytes.copy_from_slice(&buf[..LENGTH_PREFIX_BYTES]);
434    let declared = u32::from_be_bytes(len_bytes) as usize;
435
436    if declared > max_frame_bytes {
437        return Err(CodecError::FrameTooLarge {
438            declared,
439            max: max_frame_bytes,
440        });
441    }
442
443    let available = buf.len() - LENGTH_PREFIX_BYTES;
444    if available < declared {
445        return Err(CodecError::TruncatedPayload {
446            declared,
447            available,
448        });
449    }
450
451    let payload = &buf[LENGTH_PREFIX_BYTES..LENGTH_PREFIX_BYTES + declared];
452    let frame = decode_payload(payload)?;
453    Ok((frame, LENGTH_PREFIX_BYTES + declared))
454}
455
456/// Decode one frame's JSON payload (without the length prefix).
457///
458/// Crate-internal split of [`decode_frame`]'s payload half; the public
459/// surface is the length-prefixed [`decode_frame`] / [`FrameCodec::decode`],
460/// which apply the `max_frame_bytes` size guard. Visible to unit tests in
461/// this module only. This function applies NO
462/// size check of its own: its only caller inside the crate is
463/// [`decode_frame_with_consumed`], which has already enforced the bound
464/// against the declared length before handing the payload over.
465///
466/// Enforces, beyond serde: the closed `"kind"` set
467/// ([`CodecError::UnknownFrameKind`]). The ADR-137 id/scope consistency
468/// rule for `error` frames and the unknown-code fallback diagnostic are
469/// enforced inside [`crate::frame::Frame`]'s serde visitor — the same
470/// visitor a direct serde decode drives — so both decode paths agree; this
471/// function re-classifies the visitor's id/scope rejection into the typed
472/// [`CodecError::InconsistentErrorScope`]. Strict field rejection (no
473/// unknown fields) is carried by the per-kind payload structs in
474/// [`crate::frame`]. For an `error` frame whose wire code is outside the
475/// closed set, the raw code string is preserved in
476/// [`Frame::Error`](crate::frame::Frame)'s `unrecognized_code` diagnostic
477/// field.
478pub(crate) fn decode_payload(payload: &[u8]) -> Result<Frame, CodecError> {
479    let value: serde_json::Value =
480        serde_json::from_slice(payload).map_err(|e| CodecError::InvalidJson(e.to_string()))?;
481
482    let kind = value
483        .as_object()
484        .and_then(|obj| obj.get("kind"))
485        .and_then(|k| k.as_str())
486        .ok_or(CodecError::MissingKind)?
487        .to_string();
488
489    if !crate::frame::FRAME_KINDS.contains(&kind.as_str()) {
490        return Err(CodecError::UnknownFrameKind(kind));
491    }
492
493    serde_json::from_value(value).map_err(|e| {
494        // The visitor reports an id/scope violation through the
495        // deserializer's error type; lift it back into the typed variant
496        // so callers can branch on the specific failure.
497        let detail = e.to_string();
498        match detail.strip_prefix(INCONSISTENT_SCOPE_ERROR_PREFIX) {
499            Some(scope_detail) => CodecError::InconsistentErrorScope {
500                detail: scope_detail.to_string(),
501            },
502            None => CodecError::InvalidFields { kind, detail },
503        }
504    })
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::frame::OperationId;
511
512    /// One representative frame per kind, with every optional field
513    /// present, for round-trip and closed-set controls.
514    fn sample_frames() -> Vec<Frame> {
515        vec![
516            Frame::Handshake {
517                version: crate::version::CURRENT_VERSION,
518            },
519            Frame::HandshakeAck {
520                version: crate::version::CURRENT_VERSION,
521            },
522            Frame::Request {
523                id: OperationId::from("op-1"),
524                ops: "stats()".to_string(),
525                deadline_ms: Some(5000),
526                namespace: Some("research".to_string()),
527                actor_id: Some("lambda".to_string()),
528                visible_namespaces: Some(vec!["research".to_string(), "ops".to_string()]),
529            },
530            Frame::Response {
531                id: OperationId::from("op-1"),
532                result: serde_json::json!({"ok": true, "tool": "stats", "result": {"entities": 3}}),
533            },
534            Frame::Error {
535                id: Some(OperationId::from("op-1")),
536                code: crate::error::WireErrorCode::PeerClassDenied,
537                message: "denied".to_string(),
538                unrecognized_code: None,
539            },
540            Frame::Cancel {
541                id: OperationId::from("op-1"),
542            },
543            Frame::Subscribe {
544                id: OperationId::from("op-2"),
545                topic: "comm.message_created".to_string(),
546                resume_cursor: Some(42),
547            },
548            Frame::SubscribeAck {
549                id: OperationId::from("op-2"),
550                topic: "comm.message_created".to_string(),
551                start_cursor: 42,
552            },
553            Frame::Unsubscribe {
554                id: OperationId::from("op-3"),
555                topic: "comm.message_created".to_string(),
556            },
557            Frame::UnsubscribeAck {
558                id: OperationId::from("op-3"),
559                topic: "comm.message_created".to_string(),
560            },
561            Frame::Event {
562                topic: "comm.message_created".to_string(),
563                cursor: 43,
564                occurred_at: "2026-08-04T11:00:00Z".to_string(),
565                payload: serde_json::json!({"message_id": "m-1"}),
566            },
567        ]
568    }
569
570    #[test]
571    fn round_trips_a_cancel_frame() {
572        let frame = Frame::Cancel {
573            id: OperationId::from("op-1"),
574        };
575        let codec = FrameCodec::default();
576        let wire = codec.encode(&frame).unwrap();
577        assert_eq!(codec.decode(&wire).unwrap(), frame);
578    }
579
580    #[test]
581    fn rejects_truncated_length_prefix() {
582        let codec = FrameCodec::default();
583        assert_eq!(
584            codec.decode(&[0u8, 1]).unwrap_err(),
585            CodecError::TruncatedLengthPrefix { available: 2 }
586        );
587    }
588
589    #[test]
590    fn rejects_truncated_payload_with_declared_and_available() {
591        // A valid 4-byte prefix declaring 64 payload bytes, followed by
592        // only 5 actual payload bytes: `available < declared` must surface
593        // as `TruncatedPayload` with both values correct.
594        let declared: u32 = 64;
595        let mut wire = declared.to_be_bytes().to_vec();
596        wire.extend_from_slice(b"part!"); // 5 bytes
597        assert_eq!(
598            decode_frame(&wire, DEFAULT_MAX_FRAME_BYTES).unwrap_err(),
599            CodecError::TruncatedPayload {
600                declared: 64,
601                available: 5
602            }
603        );
604    }
605
606    #[test]
607    fn rejects_truncated_payload_when_prefix_declares_exactly_one_byte_more() {
608        // Boundary: one byte short is still truncated.
609        let frame = Frame::Cancel {
610            id: OperationId::from("op-1"),
611        };
612        let wire = encode_frame(&frame).unwrap();
613        assert_eq!(
614            decode_frame(&wire[..wire.len() - 1], DEFAULT_MAX_FRAME_BYTES).unwrap_err(),
615            CodecError::TruncatedPayload {
616                declared: wire.len() - LENGTH_PREFIX_BYTES,
617                available: wire.len() - LENGTH_PREFIX_BYTES - 1
618            }
619        );
620    }
621
622    #[test]
623    fn rejects_oversized_frame() {
624        let codec = FrameCodec::new(4);
625        let frame = Frame::Cancel {
626            id: OperationId::from("op-1"),
627        };
628        let wire = encode_frame(&frame).unwrap();
629        assert_eq!(
630            codec.decode(&wire).unwrap_err(),
631            CodecError::FrameTooLarge {
632                declared: wire.len() - LENGTH_PREFIX_BYTES,
633                max: 4
634            }
635        );
636    }
637
638    #[test]
639    fn rejects_unknown_frame_kind() {
640        let payload = br#"{"kind":"ping"}"#;
641        assert_eq!(
642            decode_payload(payload).unwrap_err(),
643            CodecError::UnknownFrameKind("ping".to_string())
644        );
645    }
646
647    #[test]
648    fn rejects_non_json_payload() {
649        let payload = b"not json";
650        match decode_payload(payload).unwrap_err() {
651            CodecError::InvalidJson(_) => {}
652            other => panic!("expected InvalidJson, got {other:?}"),
653        }
654    }
655
656    #[test]
657    fn rejects_zero_length_payload() {
658        let codec = FrameCodec::default();
659        match codec.decode(&0u32.to_be_bytes()).unwrap_err() {
660            CodecError::InvalidJson(_) => {}
661            other => panic!("expected InvalidJson, got {other:?}"),
662        }
663    }
664
665    #[test]
666    fn rejects_missing_required_field() {
667        let payload = br#"{"kind":"cancel"}"#;
668        match decode_payload(payload).unwrap_err() {
669            CodecError::InvalidFields { kind, .. } => assert_eq!(kind, "cancel"),
670            other => panic!("expected InvalidFields, got {other:?}"),
671        }
672    }
673
674    #[test]
675    fn rejects_unknown_top_level_field() {
676        // Strict closed grammar: no field a frame kind does not declare may
677        // appear in the payload, however plausible it looks.
678        let payload = br#"{"kind":"cancel","id":"op-1","unexpected":true}"#;
679        match decode_payload(payload).unwrap_err() {
680            CodecError::InvalidFields { kind, detail } => {
681                assert_eq!(kind, "cancel");
682                assert!(
683                    detail.contains("unexpected"),
684                    "detail should name the offending field: {detail}"
685                );
686            }
687            other => panic!("expected InvalidFields, got {other:?}"),
688        }
689    }
690
691    #[test]
692    fn rejects_unknown_field_alongside_optional_fields() {
693        // Same rule with the optional fields present: the extra field is
694        // still rejected, and the rejection names it.
695        let payload =
696            br#"{"kind":"subscribe","id":"op-2","topic":"a.b","resume_cursor":1,"extra":{"nested":true}}"#;
697        match decode_payload(payload).unwrap_err() {
698            CodecError::InvalidFields { kind, detail } => {
699                assert_eq!(kind, "subscribe");
700                assert!(detail.contains("extra"), "detail: {detail}");
701            }
702            other => panic!("expected InvalidFields, got {other:?}"),
703        }
704    }
705
706    #[test]
707    fn opaque_payload_values_do_not_reject_unknown_keys() {
708        // The strict grammar covers the fields each frame KIND declares.
709        // `result` and `payload` are opaque JSON values: unknown keys
710        // inside them are data, not grammar violations, and must decode.
711        let payload = br#"{"kind":"event","topic":"a.b","cursor":1,"occurred_at":"2026-08-04T11:00:00Z","payload":{"anything":{"goes":true}}}"#;
712        decode_payload(payload).unwrap();
713    }
714
715    #[test]
716    fn every_frame_kind_round_trips_with_strict_decoding() {
717        // Must-KEEP control: strict field rejection must not break any
718        // known frame kind. One representative per kind, every optional
719        // field populated, must survive encode -> decode unchanged.
720        let frames = sample_frames();
721        assert_eq!(frames.len(), crate::frame::FRAME_KINDS.len());
722        for frame in frames {
723            let wire = encode_frame(&frame).unwrap();
724            let decoded = decode_frame(&wire, DEFAULT_MAX_FRAME_BYTES)
725                .unwrap_or_else(|e| panic!("kind {:?} failed to decode: {e}", frame.kind()));
726            assert_eq!(decoded, frame, "kind {:?} did not round-trip", frame.kind());
727        }
728    }
729
730    #[test]
731    fn rejects_connection_terminal_error_carrying_an_id() {
732        // `frame_too_large` is connection-terminal: it must not echo an
733        // operation id (ADR-137, "Operation correlation").
734        let payload =
735            br#"{"kind":"error","id":"op-1","code":"frame_too_large","message":"too big"}"#;
736        match decode_payload(payload).unwrap_err() {
737            CodecError::InconsistentErrorScope { detail } => {
738                assert!(detail.contains("frame_too_large"), "detail: {detail}");
739                assert!(detail.contains("op-1"), "detail: {detail}");
740            }
741            other => panic!("expected InconsistentErrorScope, got {other:?}"),
742        }
743    }
744
745    #[test]
746    fn rejects_request_terminal_error_without_an_id() {
747        // `cancelled` is request-terminal: it must echo the operation id it
748        // terminates.
749        let payload = br#"{"kind":"error","code":"cancelled","message":"cancelled"}"#;
750        match decode_payload(payload).unwrap_err() {
751            CodecError::InconsistentErrorScope { detail } => {
752                assert!(detail.contains("cancelled"), "detail: {detail}");
753            }
754            other => panic!("expected InconsistentErrorScope, got {other:?}"),
755        }
756    }
757
758    #[test]
759    fn accepts_connection_terminal_error_without_an_id() {
760        // Valid arm: connection-terminal scope, no id.
761        let payload =
762            br#"{"kind":"error","code":"unsupported_version","message":"no common version"}"#;
763        let frame = decode_payload(payload).unwrap();
764        assert!(matches!(frame, Frame::Error { id: None, .. }));
765    }
766
767    #[test]
768    fn accepts_request_terminal_error_with_an_id() {
769        // Valid arm: request-terminal scope, echoing the id it terminates.
770        let payload =
771            br#"{"kind":"error","id":"op-9","code":"deadline_exceeded","message":"too slow"}"#;
772        let frame = decode_payload(payload).unwrap();
773        match frame {
774            Frame::Error { id, code, .. } => {
775                assert_eq!(id, Some(OperationId::from("op-9")));
776                assert_eq!(code, crate::error::WireErrorCode::DeadlineExceeded);
777            }
778            other => panic!("expected an error frame, got {other:?}"),
779        }
780    }
781
782    #[test]
783    fn rejects_empty_operation_id() {
784        // An empty string can never be a unique caller-generated id.
785        let payload = br#"{"kind":"cancel","id":""}"#;
786        match decode_payload(payload).unwrap_err() {
787            CodecError::InvalidFields { kind, detail } => {
788                assert_eq!(kind, "cancel");
789                assert!(detail.contains("non-empty"), "detail: {detail}");
790            }
791            other => panic!("expected InvalidFields, got {other:?}"),
792        }
793    }
794
795    #[test]
796    fn rejects_valid_json_that_is_not_an_object() {
797        for payload in [
798            b"[1, 2, 3]".as_slice(),
799            b"\"cancel\"".as_slice(),
800            b"42".as_slice(),
801            b"null".as_slice(),
802        ] {
803            assert_eq!(
804                decode_payload(payload).unwrap_err(),
805                CodecError::MissingKind,
806                "payload {payload:?}"
807            );
808        }
809    }
810
811    #[test]
812    fn rejects_payload_with_no_kind_field() {
813        let payload = br#"{"id":"op-1"}"#;
814        assert_eq!(
815            decode_payload(payload).unwrap_err(),
816            CodecError::MissingKind
817        );
818    }
819
820    #[test]
821    fn rejects_non_string_kind() {
822        for payload in [
823            br#"{"kind":42}"#.as_slice(),
824            br#"{"kind":null}"#.as_slice(),
825            br#"{"kind":["cancel"]}"#.as_slice(),
826        ] {
827            assert_eq!(
828                decode_payload(payload).unwrap_err(),
829                CodecError::MissingKind,
830                "payload {payload:?}"
831            );
832        }
833    }
834
835    #[test]
836    fn rejects_wrong_typed_field() {
837        // `id` must be a string, not a number.
838        let payload = br#"{"kind":"cancel","id":7}"#;
839        match decode_payload(payload).unwrap_err() {
840            CodecError::InvalidFields { kind, detail } => {
841                assert_eq!(kind, "cancel");
842                assert!(detail.contains("id"), "detail: {detail}");
843            }
844            other => panic!("expected InvalidFields, got {other:?}"),
845        }
846
847        // `start_cursor` must be a number, not a string.
848        let payload = br#"{"kind":"subscribe_ack","id":"op-2","topic":"a.b","start_cursor":"42"}"#;
849        match decode_payload(payload).unwrap_err() {
850            CodecError::InvalidFields { kind, .. } => assert_eq!(kind, "subscribe_ack"),
851            other => panic!("expected InvalidFields, got {other:?}"),
852        }
853    }
854
855    #[test]
856    fn opaque_payloads_are_preserved_semantically_not_byte_for_byte() {
857        // Pins the REAL opaque-payload guarantee under this workspace's
858        // serde_json feature set (crates/Cargo.toml: `serde_json = "1.0"` —
859        // default features only; `preserve_order` and `arbitrary_precision`
860        // are NOT enabled):
861        //
862        // 1. Object keys are NOT preserved in input order; the re-encoded
863        //    form sorts keys (serde_json's BTreeMap-backed Map). Equality
864        //    is semantic, not byte-for-byte.
865        // 2. Integers within u64/i64 range round-trip exactly (they parse
866        //    as u64/i64, never f64) — 2^53+1 below is exact.
867        // 3. Integers outside u64/i64 range (e.g. 2^64) parse as f64 and
868        //    lose precision; there is no `arbitrary_precision` fallback.
869        let raw = br#"{"kind":"response","id":"op-1","result":{"zeta":1,"alpha":{"n":9007199254740993},"huge":18446744073709551616,"neg":-9007199254740993}}"#;
870        let frame = decode_payload(raw).unwrap();
871        let Frame::Response { result, .. } = &frame else {
872            panic!("expected a response frame");
873        };
874
875        // (2) 2^53+1 is within u64 range and survives exactly.
876        assert_eq!(result["alpha"]["n"], serde_json::json!(9007199254740993u64));
877        assert_eq!(result["neg"], serde_json::json!(-9007199254740993i64));
878
879        // (3) 2^64 exceeds u64 range: parsed as f64, precision semantics
880        // change (2^64 happens to be exactly representable as f64, but it
881        // is no longer an integer value on the serde_json type level).
882        assert!(result["huge"].is_f64());
883        assert!(!result["huge"].is_u64());
884        assert_eq!(result["huge"].as_f64().unwrap(), 2.0f64.powi(64));
885
886        // (1) Re-encode keeps semantic equality but reorders keys: input
887        // had "zeta" first; the re-encoded payload sorts "alpha" first.
888        let wire = encode_frame(&frame).unwrap();
889        assert_eq!(decode_payload(&wire[LENGTH_PREFIX_BYTES..]).unwrap(), frame);
890        let payload = std::str::from_utf8(&wire[LENGTH_PREFIX_BYTES..]).unwrap();
891        let alpha = payload.find("\"alpha\"").unwrap();
892        let zeta = payload.find("\"zeta\"").unwrap();
893        assert!(
894            alpha < zeta,
895            "expected sorted key order in re-encoded payload: {payload}"
896        );
897    }
898
899    // ── encode-side validation: the mirror of the decode checks above ──
900
901    #[test]
902    fn encode_rejects_empty_operation_id() {
903        // Encode arm of `rejects_empty_operation_id`: `From<&str>` is
904        // deliberately unrestricted, so the empty id can exist in memory —
905        // the codec must refuse to put it on the wire in any id field.
906        let frame = Frame::Cancel {
907            id: OperationId::from(""),
908        };
909        match encode_frame(&frame).unwrap_err() {
910            CodecError::InvalidFields { kind, detail } => {
911                assert_eq!(kind, "cancel");
912                assert!(detail.contains("non-empty"), "detail: {detail}");
913            }
914            other => panic!("expected InvalidFields, got {other:?}"),
915        }
916    }
917
918    #[test]
919    fn encode_rejects_empty_operation_id_in_every_id_field() {
920        // The empty-id guard covers every frame kind with an operation id,
921        // not just the one the decode test exercises.
922        let frames = [
923            Frame::Request {
924                id: OperationId::from(""),
925                ops: "stats()".to_string(),
926                deadline_ms: None,
927                namespace: None,
928                actor_id: None,
929                visible_namespaces: None,
930            },
931            Frame::Response {
932                id: OperationId::from(""),
933                result: serde_json::json!({}),
934            },
935            Frame::Subscribe {
936                id: OperationId::from(""),
937                topic: "a.b".to_string(),
938                resume_cursor: None,
939            },
940            Frame::SubscribeAck {
941                id: OperationId::from(""),
942                topic: "a.b".to_string(),
943                start_cursor: 0,
944            },
945            Frame::Unsubscribe {
946                id: OperationId::from(""),
947                topic: "a.b".to_string(),
948            },
949            Frame::UnsubscribeAck {
950                id: OperationId::from(""),
951                topic: "a.b".to_string(),
952            },
953            Frame::Error {
954                id: Some(OperationId::from("")),
955                code: crate::error::WireErrorCode::Internal,
956                message: "failure".to_string(),
957                unrecognized_code: None,
958            },
959        ];
960        for frame in frames {
961            match encode_frame(&frame).unwrap_err() {
962                CodecError::InvalidFields { detail, .. } => {
963                    assert!(detail.contains("non-empty"), "detail: {detail}");
964                }
965                other => panic!(
966                    "kind {:?}: expected InvalidFields, got {other:?}",
967                    frame.kind()
968                ),
969            }
970        }
971    }
972
973    #[test]
974    fn encode_rejects_zero_protocol_version_in_both_handshake_kinds() {
975        for frame in [
976            Frame::Handshake {
977                version: crate::version::ProtocolVersion::new(0),
978            },
979            Frame::HandshakeAck {
980                version: crate::version::ProtocolVersion::new(0),
981            },
982        ] {
983            match encode_frame(&frame).unwrap_err() {
984                CodecError::InvalidFields { kind, detail } => {
985                    assert_eq!(kind, frame.kind());
986                    assert!(detail.contains("version 0"), "detail: {detail}");
987                }
988                other => panic!(
989                    "kind {:?}: expected InvalidFields, got {other:?}",
990                    frame.kind()
991                ),
992            }
993        }
994    }
995
996    #[test]
997    fn encode_rejects_connection_terminal_error_carrying_an_id() {
998        // Encode arm of `rejects_connection_terminal_error_carrying_an_id`:
999        // `frame_too_large` is connection-terminal and must not echo an
1000        // operation id (ADR-137, "Operation correlation").
1001        let frame = Frame::Error {
1002            id: Some(OperationId::from("op-1")),
1003            code: crate::error::WireErrorCode::FrameTooLarge,
1004            message: "too big".to_string(),
1005            unrecognized_code: None,
1006        };
1007        match encode_frame(&frame).unwrap_err() {
1008            CodecError::InconsistentErrorScope { detail } => {
1009                assert!(detail.contains("frame_too_large"), "detail: {detail}");
1010                assert!(detail.contains("op-1"), "detail: {detail}");
1011            }
1012            other => panic!("expected InconsistentErrorScope, got {other:?}"),
1013        }
1014    }
1015
1016    #[test]
1017    fn encode_rejects_request_terminal_error_without_an_id() {
1018        // Encode arm of `rejects_request_terminal_error_without_an_id`:
1019        // `cancelled` is request-terminal and must echo the id it
1020        // terminates.
1021        let frame = Frame::Error {
1022            id: None,
1023            code: crate::error::WireErrorCode::Cancelled,
1024            message: "cancelled".to_string(),
1025            unrecognized_code: None,
1026        };
1027        match encode_frame(&frame).unwrap_err() {
1028            CodecError::InconsistentErrorScope { detail } => {
1029                assert!(detail.contains("cancelled"), "detail: {detail}");
1030            }
1031            other => panic!("expected InconsistentErrorScope, got {other:?}"),
1032        }
1033    }
1034
1035    #[test]
1036    fn encode_accepts_both_consistent_error_scopes() {
1037        // Happy-path control for the encode-side scope check: both legal
1038        // pairings encode and round-trip through decode unchanged.
1039        let consistent = [
1040            Frame::Error {
1041                id: None,
1042                code: crate::error::WireErrorCode::MalformedFrame,
1043                message: "connection scope".to_string(),
1044                unrecognized_code: None,
1045            },
1046            Frame::Error {
1047                id: Some(OperationId::from("op-1")),
1048                code: crate::error::WireErrorCode::DeadlineExceeded,
1049                message: "request scope".to_string(),
1050                unrecognized_code: None,
1051            },
1052        ];
1053        for frame in consistent {
1054            let wire = encode_frame(&frame).expect("consistent scope must encode");
1055            assert_eq!(decode_frame(&wire, DEFAULT_MAX_FRAME_BYTES).unwrap(), frame);
1056        }
1057    }
1058
1059    // ── unknown wire code: fallback keeps the raw string ──
1060
1061    #[test]
1062    fn unknown_code_fallback_preserves_the_raw_string_with_an_id() {
1063        // A newer peer may send a code this version does not know. The
1064        // frame must still decode (forward compatibility), the code falls
1065        // back to `internal`, the id/scope pairing is NOT enforced for it
1066        // (its true scope is unknown), and the raw string survives in the
1067        // `unrecognized_code` diagnostic instead of being discarded.
1068        let payload =
1069            br#"{"kind":"error","id":"op-7","code":"future_code_xyz","message":"from newer peer"}"#;
1070        let frame = decode_payload(payload).unwrap();
1071        match frame {
1072            Frame::Error {
1073                id,
1074                code,
1075                unrecognized_code,
1076                ..
1077            } => {
1078                assert_eq!(code, crate::error::WireErrorCode::Internal);
1079                assert_eq!(id, Some(OperationId::from("op-7")));
1080                assert_eq!(unrecognized_code.as_deref(), Some("future_code_xyz"));
1081            }
1082            other => panic!("expected an error frame, got {other:?}"),
1083        }
1084    }
1085
1086    #[test]
1087    fn unknown_code_fallback_preserves_the_raw_string_without_an_id() {
1088        let payload = br#"{"kind":"error","code":"future_code_xyz","message":"from newer peer"}"#;
1089        match decode_payload(payload).unwrap() {
1090            Frame::Error {
1091                id,
1092                code,
1093                unrecognized_code,
1094                ..
1095            } => {
1096                assert_eq!(code, crate::error::WireErrorCode::Internal);
1097                assert!(id.is_none());
1098                assert_eq!(unrecognized_code.as_deref(), Some("future_code_xyz"));
1099            }
1100            other => panic!("expected an error frame, got {other:?}"),
1101        }
1102    }
1103
1104    #[test]
1105    fn closed_set_code_never_populates_unrecognized_code() {
1106        // The diagnostic stays `None` for every code in the closed set: it
1107        // records only the serde-other fallback, never a recognized code.
1108        let payload =
1109            br#"{"kind":"error","id":"op-9","code":"deadline_exceeded","message":"too slow"}"#;
1110        match decode_payload(payload).unwrap() {
1111            Frame::Error {
1112                unrecognized_code, ..
1113            } => assert!(unrecognized_code.is_none()),
1114            other => panic!("expected an error frame, got {other:?}"),
1115        }
1116    }
1117
1118    #[test]
1119    fn decode_scope_rejection_detail_is_reclassified_without_the_shared_prefix() {
1120        // The visitor reports the id/scope rule with a fixed prefix so
1121        // every decode path uses one wording; `decode_payload` must strip
1122        // that prefix when re-classifying into the typed variant.
1123        let payload =
1124            br#"{"kind":"error","id":"op-1","code":"frame_too_large","message":"too big"}"#;
1125        match decode_payload(payload).unwrap_err() {
1126            CodecError::InconsistentErrorScope { detail } => {
1127                assert!(
1128                    !detail.contains(INCONSISTENT_SCOPE_ERROR_PREFIX),
1129                    "detail must not repeat the shared prefix: {detail}"
1130                );
1131                assert!(detail.starts_with("connection-terminal code"));
1132            }
1133            other => panic!("expected InconsistentErrorScope, got {other:?}"),
1134        }
1135    }
1136
1137    // ── fallback frames cannot be re-encoded by this relay ──
1138
1139    #[test]
1140    fn encode_rejects_a_fallback_frame_with_an_id() {
1141        // A decoded unknown-code fallback re-encoded as-is would emit
1142        // `internal` and silently discard the newer code the peer sent —
1143        // silent code loss. The encode path must reject it outright, in
1144        // both id shapes. Here: WITH an id (the request-terminal fallback
1145        // shape a newer peer would send).
1146        let payload =
1147            br#"{"kind":"error","id":"op-7","code":"future_code_xyz","message":"from newer peer"}"#;
1148        let frame = decode_payload(payload).unwrap();
1149        match encode_frame(&frame).unwrap_err() {
1150            CodecError::FallbackFrameNotEncodable { code } => {
1151                assert_eq!(code, "future_code_xyz");
1152            }
1153            other => panic!("expected FallbackFrameNotEncodable, got {other:?}"),
1154        }
1155    }
1156
1157    #[test]
1158    fn encode_rejects_a_fallback_frame_without_an_id() {
1159        // Same rule for the id-less fallback shape: decode accepts it (the
1160        // pairing is not enforced for an unknown code), encode rejects it.
1161        let payload = br#"{"kind":"error","code":"future_code_xyz","message":"from newer peer"}"#;
1162        let frame = decode_payload(payload).unwrap();
1163        match encode_frame(&frame).unwrap_err() {
1164            CodecError::FallbackFrameNotEncodable { code } => {
1165                assert_eq!(code, "future_code_xyz");
1166            }
1167            other => panic!("expected FallbackFrameNotEncodable, got {other:?}"),
1168        }
1169    }
1170
1171    #[test]
1172    fn encode_accepts_an_honest_internal_error_frame() {
1173        // The rejection keys on the `unrecognized_code` MARKER, not on the
1174        // `internal` code: a locally constructed (or closed-set-decoded)
1175        // `internal` frame has `unrecognized_code: None` and encodes fine.
1176        let frame = Frame::Error {
1177            id: Some(OperationId::from("op-1")),
1178            code: crate::error::WireErrorCode::Internal,
1179            message: "boom".to_string(),
1180            unrecognized_code: None,
1181        };
1182        let wire = encode_frame(&frame).expect("honest internal must encode");
1183        let decoded = decode_frame(&wire, DEFAULT_MAX_FRAME_BYTES).unwrap();
1184        assert_eq!(decoded, frame);
1185    }
1186
1187    // ── decode_with_consumed ──
1188
1189    #[test]
1190    fn decode_with_consumed_reports_consumed_length_on_a_two_frame_buffer() {
1191        // Two concatenated frames: the consumed count must end exactly at
1192        // the first frame's last byte, and the remainder must be the
1193        // second frame, decodable without re-slicing by hand.
1194        let first = Frame::Cancel {
1195            id: OperationId::from("op-1"),
1196        };
1197        let second = Frame::Handshake {
1198            version: crate::version::CURRENT_VERSION,
1199        };
1200        let codec = FrameCodec::default();
1201        let mut buf = codec.encode(&first).unwrap();
1202        let first_len = buf.len();
1203        buf.extend_from_slice(&codec.encode(&second).unwrap());
1204
1205        let (decoded, consumed) = codec.decode_with_consumed(&buf).unwrap();
1206        assert_eq!(decoded, first);
1207        assert_eq!(consumed, first_len);
1208
1209        let (decoded2, consumed2) = codec.decode_with_consumed(&buf[consumed..]).unwrap();
1210        assert_eq!(decoded2, second);
1211        assert_eq!(consumed + consumed2, buf.len());
1212
1213        // `decode` keeps its documented contract on the same buffer.
1214        assert_eq!(codec.decode(&buf).unwrap(), first);
1215    }
1216
1217    #[test]
1218    fn decode_with_consumed_errors_without_reporting_length_on_truncation() {
1219        let codec = FrameCodec::default();
1220        let wire = codec
1221            .encode(&Frame::Cancel {
1222                id: OperationId::from("op-1"),
1223            })
1224            .unwrap();
1225        let err = codec
1226            .decode_with_consumed(&wire[..wire.len() - 1])
1227            .unwrap_err();
1228        assert!(matches!(err, CodecError::TruncatedPayload { .. }));
1229    }
1230
1231    // ── serde_json feature posture probe ──
1232
1233    #[test]
1234    fn serde_json_feature_posture_is_default_keys_serialize_sorted() {
1235        // Golden byte-exactness (tests/golden_frames.rs) and the semantic
1236        // opaque-payload guarantee above both assume serde_json's DEFAULT
1237        // feature posture: no `preserve_order` (the Map is BTreeMap-backed,
1238        // so keys serialize in sorted order regardless of insertion) and no
1239        // `arbitrary_precision`. Cargo features are ADDITIVE across the
1240        // workspace: if any crate in the dependency graph enables
1241        // `preserve_order` or `arbitrary_precision`, serde_json unifies on
1242        // it for this crate too, silently changing key order (or number
1243        // handling) and invalidating the golden fixtures. This probe pins
1244        // the assumed posture at test time: if it fails, the workspace
1245        // feature set drifted — reconcile the workspace or the fixtures,
1246        // do not weaken this assertion.
1247        let mut map = serde_json::Map::new();
1248        map.insert("zeta".to_string(), serde_json::json!(1));
1249        map.insert("alpha".to_string(), serde_json::json!(2));
1250        map.insert("mid".to_string(), serde_json::json!(3));
1251        let wire = serde_json::to_string(&serde_json::Value::Object(map)).unwrap();
1252        assert_eq!(
1253            wire, r#"{"alpha":2,"mid":3,"zeta":1}"#,
1254            "serde_json keys did not serialize in sorted order — \
1255             `preserve_order` may have been enabled workspace-wide"
1256        );
1257    }
1258
1259    // ── CodecError → WireErrorCode canonical mapping ──
1260
1261    #[test]
1262    fn codec_errors_map_to_their_wire_error_codes() {
1263        use crate::error::WireErrorCode;
1264
1265        let cases: &[(CodecError, WireErrorCode)] = &[
1266            (
1267                CodecError::TruncatedLengthPrefix { available: 2 },
1268                WireErrorCode::MalformedFrame,
1269            ),
1270            (
1271                CodecError::TruncatedPayload {
1272                    declared: 8,
1273                    available: 3,
1274                },
1275                WireErrorCode::MalformedFrame,
1276            ),
1277            (
1278                CodecError::FrameTooLarge {
1279                    declared: 10,
1280                    max: 4,
1281                },
1282                WireErrorCode::FrameTooLarge,
1283            ),
1284            (
1285                CodecError::U32PrefixLimitExceeded {
1286                    declared: 10,
1287                    max: 4,
1288                },
1289                WireErrorCode::FrameTooLarge,
1290            ),
1291            (
1292                CodecError::InvalidJson("x".to_string()),
1293                WireErrorCode::MalformedFrame,
1294            ),
1295            (CodecError::MissingKind, WireErrorCode::MalformedFrame),
1296            (
1297                CodecError::UnknownFrameKind("ping".to_string()),
1298                WireErrorCode::MalformedFrame,
1299            ),
1300            (
1301                CodecError::InvalidFields {
1302                    kind: "cancel".to_string(),
1303                    detail: "x".to_string(),
1304                },
1305                WireErrorCode::MalformedFrame,
1306            ),
1307            (
1308                CodecError::InconsistentErrorScope {
1309                    detail: "x".to_string(),
1310                },
1311                WireErrorCode::MalformedFrame,
1312            ),
1313            (
1314                CodecError::FallbackFrameNotEncodable {
1315                    code: "future_code_xyz".to_string(),
1316                },
1317                WireErrorCode::Internal,
1318            ),
1319        ];
1320        for (err, expected) in cases {
1321            assert_eq!(
1322                err.wire_code(),
1323                *expected,
1324                "{err:?} mapped to {:?}, expected {expected:?}",
1325                err.wire_code()
1326            );
1327            assert_eq!(
1328                WireErrorCode::from(err),
1329                *expected,
1330                "From<&CodecError> disagrees"
1331            );
1332        }
1333    }
1334}
1335
1336#[cfg(test)]
1337mod configured_max_tests {
1338    use super::*;
1339    use crate::frame::Frame;
1340
1341    fn oversized_frame() -> Frame {
1342        Frame::Request {
1343            id: crate::frame::OperationId("x".into()),
1344            ops: "a".repeat(4096),
1345            deadline_ms: None,
1346            namespace: None,
1347            actor_id: None,
1348            visible_namespaces: None,
1349        }
1350    }
1351
1352    #[test]
1353    fn codec_encode_honors_its_own_maximum_not_the_default() {
1354        let codec = FrameCodec::new(1024);
1355        let err = codec.encode(&oversized_frame()).unwrap_err();
1356        match err {
1357            CodecError::FrameTooLarge { max, .. } => assert_eq!(max, 1024),
1358            other => panic!("expected FrameTooLarge with the configured max, got {other:?}"),
1359        }
1360    }
1361
1362    #[test]
1363    fn codec_encode_accepts_a_frame_within_its_own_maximum() {
1364        let codec = FrameCodec::new(1024 * 1024);
1365        let wire = codec
1366            .encode(&oversized_frame())
1367            .expect("within configured max");
1368        assert_eq!(codec.decode(&wire).unwrap(), oversized_frame());
1369    }
1370
1371    #[test]
1372    fn free_function_encode_still_uses_the_default_maximum() {
1373        let wire = encode_frame(&oversized_frame()).expect("well under 8 MiB");
1374        assert!(wire.len() > 4096);
1375    }
1376
1377    #[test]
1378    fn codec_configured_above_u32_capacity_still_encodes_normal_frames() {
1379        // What the old `encode_never_exceeds_the_u32_prefix_capacity` test
1380        // actually verified (and all it could verify without a >4 GiB
1381        // payload): configuring a codec above the u32 prefix capacity does
1382        // not disturb ordinary encoding. It did NOT exercise the u32 guard
1383        // itself — that decision logic is tested directly below.
1384        assert!(encode_frame_with_max(&oversized_frame(), usize::MAX).is_ok());
1385    }
1386
1387    #[test]
1388    fn encode_size_guard_reports_the_configured_max_when_it_is_binding() {
1389        let err = check_encode_payload_len(32, 16).unwrap_err();
1390        assert_eq!(
1391            err,
1392            CodecError::FrameTooLarge {
1393                declared: 32,
1394                max: 16
1395            }
1396        );
1397    }
1398
1399    #[cfg(target_pointer_width = "64")]
1400    #[test]
1401    fn encode_size_guard_reports_the_u32_prefix_capacity_when_it_is_binding() {
1402        // The u32 guard fires only when the configured maximum admitted the
1403        // payload (payload_len <= max) but the 4-byte prefix cannot name
1404        // it (payload_len > u32::MAX). The configured maximum was NOT
1405        // exceeded at that point, so the error must name the prefix
1406        // capacity as the binding limit — reporting the configured max
1407        // would claim an exceedance that did not happen.
1408        let err = check_encode_payload_len(u32::MAX as usize + 1, usize::MAX).unwrap_err();
1409        assert_eq!(
1410            err,
1411            CodecError::U32PrefixLimitExceeded {
1412                declared: u32::MAX as usize + 1,
1413                max: u32::MAX as usize
1414            }
1415        );
1416        // Exactly at the prefix capacity is still encodable in principle.
1417        assert!(check_encode_payload_len(u32::MAX as usize, usize::MAX).is_ok());
1418    }
1419}