Skip to main content

subc_protocol/
lib.rs

1//! subc wire contract.
2//!
3//! This crate is the single source of truth for the subc <-> module wire,
4//! shared by subc-core and AFT. It defines the **envelope** (the fixed
5//! 21-byte routing header subc splices on), the canonical subc-generated body
6//! schemas such as [`ErrorBody`], and the capability manifest. JSON-RPC request
7//! and response bodies remain module-owned opaque payloads to subc.
8//!
9//! ## The envelope (locked — see docs/subc-core-architecture.md §4.8)
10//!
11//! ```text
12//!  offset  size  field     type    purpose
13//!    0      4    len       u32     # of BODY bytes after this 21-byte header
14//!    4      1    ver       u8      envelope version
15//!    5      1    type      u8      frame kind (see FrameType)
16//!    6      1    flags     u8     bit0 BINARY · bits1-2 PRIORITY · bit3 LAST · bits4-5 ADMISSION · bit6 DAEMON_ORIGIN · bit7 reserved
17//!    7      2    channel   u16     route = (component, session); 0 = subc itself
18//!    9      4    epoch     u32     per-slot binding epoch; 0 on channel 0
19//!   13      8    corr      u64     correlation id; CANCEL carries the target call's corr
20//!   21 -> body
21//! ```
22//!
23//! Little-endian (same-machine, native, no byte-swap on the hot path).
24//!
25//! **Frozen prefix (the versioning invariant):** `len` (u32 @ 0) and `ver`
26//! (u8 @ 4) keep fixed meaning + position in *every* future version. A reader
27//! of any version can therefore always read the first 5 bytes, learn `ver`,
28//! look up that version's header length, read the rest, and splice `len` body
29//! bytes. `decode_header` enforces this discipline.
30
31#![forbid(unsafe_code)]
32
33use std::{error::Error, fmt, path::PathBuf};
34
35use serde::{Deserialize, Serialize};
36
37pub mod frame;
38pub mod manifest;
39pub mod session;
40pub mod tool_call;
41
42/// Canonical error codes emitted while opening a client route.
43///
44/// Error frames remain extensible strings, but these daemon-owned route-open
45/// outcomes need identical spelling across the daemon and SDK retry policies.
46pub mod error_codes {
47    pub const UNKNOWN_MODULE: &str = "unknown_module";
48    pub const MODULE_REMOVED: &str = "module_removed";
49    pub const MODULE_RELOADING: &str = "module_reloading";
50    pub const MODULE_WARMING: &str = "module_warming";
51    pub const TARGET_UNAVAILABLE: &str = "target_unavailable";
52    pub const MODULE_TIMEOUT: &str = "module_timeout";
53
54    /// Whether a `route.open` refusal carrying `code` may be retried in place
55    /// within the caller's deadline, or is terminal for the target as named.
56    ///
57    /// This lives beside the codes because every consumer with its own
58    /// connection layer needs the same answer: a copied list breaks loudly on
59    /// a renamed code and silently on an added one. The SDKs call this; the
60    /// golden `decision_tables.json` (`route_open_retryable`) is the record
61    /// the daemon and every SDK are tested against, and the test in
62    /// `golden_json.rs` holds this function to it.
63    ///
64    /// Unknown codes are terminal: a refusal this crate has never heard of
65    /// must not be retried on the strength of a match-all arm.
66    pub fn is_retryable_route_open(code: &str) -> bool {
67        matches!(
68            code,
69            UNKNOWN_MODULE
70                | MODULE_RELOADING
71                | MODULE_WARMING
72                | TARGET_UNAVAILABLE
73                | MODULE_TIMEOUT
74        )
75    }
76}
77
78pub use frame::{Frame, FrameBuildError};
79
80/// Per-route bind identity shared by client-facing and module-facing control.
81///
82/// EVERY FIELD HERE IS CLIENT-SUPPLIED AND UNATTESTED. The daemon canonicalizes
83/// `project_root` as a path but does not verify that the caller has any relation
84/// to it, and `harness` and `session` are strings the caller chose. A client
85/// holding the connection key can present any values it likes.
86///
87/// This sits directly above `Principal`, which is the opposite: stamped BY the
88/// daemon from a launch nonce it minted. The two travel together on every
89/// `route.bind`, so a module reading them side by side is reading one fact it can
90/// trust and three it cannot. THE DISTINCTION IS INVISIBLE FROM THE TYPES, which
91/// is why it is written here.
92///
93/// So these fields are for SCOPING AND ATTRIBUTION -- which project's state to
94/// open, which session to thread, what to log -- and never for authorization. A
95/// module that grants capability on `harness` or trusts `project_root` to bound
96/// what a caller may reach has built an authorization check on a value the caller
97/// controls. Gate on `Principal` instead, and where a module needs a caller fact
98/// subc does not stamp, it must establish that fact itself rather than believe
99/// this struct.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct BindIdentity {
102    pub project_root: PathBuf,
103    pub harness: String,
104    pub session: String,
105}
106
107/// Caller fact stamped by subc on each route.bind relayed to a module.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[serde(tag = "kind", rename_all = "snake_case")]
110pub enum Principal {
111    /// A daemon-spawned module proved possession of its launch nonce.
112    Reserved { module_id: String },
113    /// No consumer identity was presented; the caller is a direct key-holder.
114    Direct,
115    /// Reserved vocabulary for a future degraded/no-key-auth mode.
116    Unverified,
117}
118
119/// Explicit target for a route open/bind operation.
120///
121/// RouteTarget.kind ↔ ProviderRole mapping:
122///
123/// | RouteTarget.kind | required ProviderRole | disambiguator |
124/// |---|---|---|
125/// | `tool_provider` | `ToolProvider` | v1: ≤1 per module |
126/// | `management_surface` | `ManagementSurface` | v1: ≤1 per module |
127/// | `internal_service` | `InternalService` | `service_id` (multiple allowed) |
128///
129/// `ProviderRole::PipelineStage` is intentionally unroutable; pipeline modules
130/// are wired by an orchestrator rather than opened directly by clients.
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(tag = "kind", rename_all = "snake_case")]
133pub enum RouteTarget {
134    ToolProvider {
135        module_id: String,
136    },
137    ManagementSurface {
138        module_id: String,
139    },
140    InternalService {
141        module_id: String,
142        service_id: String,
143    },
144}
145
146/// Envelope protocol version this build speaks.
147pub const PROTOCOL_VERSION: u8 = 2;
148
149/// The version of THIS crate (`subc-protocol`) as compiled into the linking
150/// binary — the fleet's shared wire-vocabulary version, and the value
151/// `ManifestProvenance.wire_crate_version` declares. Not the version of a
152/// module's own envelope or payload crates: those are different numbering
153/// spaces, and declaring one here produces a confident wrong answer at any
154/// version gate (insula shipped exactly that before the referent was written
155/// down). `env!` makes it a property of the compiled binary, not of whatever
156/// source tree sits beside it at run time.
157pub const SUBC_PROTOCOL_CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
158
159/// Oldest envelope protocol version this build accepts.
160pub const MIN_SUPPORTED_VERSION: u8 = 2;
161
162/// Env var subc sets on each supervised child telling it the module_id it is
163/// supervised under, so it can register under that id.
164pub const SUBC_MODULE_ID_ENV: &str = "SUBC_MODULE_ID";
165
166/// Env var subc sets, on each spawn of a `reserved` module only, to a fresh
167/// one-time launch nonce. The child echoes it in `ModuleHelloBody::launch_nonce`;
168/// subc accepts a reserved module_id's HELLO only when the nonce matches the one it
169/// last injected for that id. Non-reserved modules never receive it.
170pub const SUBC_LAUNCH_NONCE_ENV: &str = "SUBC_LAUNCH_NONCE";
171
172/// Fixed header length for `PROTOCOL_VERSION` 2.
173pub const HEADER_LEN: usize = 21;
174
175/// Bytes of the frozen prefix (`len` u32 + `ver` u8) that are stable across
176/// every envelope version. A reader needs only these to learn the version and
177/// thus the full header length.
178pub const FROZEN_PREFIX_LEN: usize = 5;
179
180/// Maximum frame body accepted before allocation.
181///
182/// This 64 MiB starting cap prevents a malformed header from forcing an
183/// unbounded allocation. Future protocol versions can negotiate or encode a
184/// different cap while preserving the frozen prefix.
185pub const MAX_FRAME_BODY_LEN: u32 = 64 * 1024 * 1024;
186
187/// Canonical JSON body for all subc-generated `ERROR` frames.
188///
189/// `detail` is an optional machine-parsable surface for refusals whose remedy
190/// needs more than a code (e.g. a producer-published backoff number, an
191/// observed-vs-configured size pair). Absent detail serializes to nothing, so
192/// bodies without it are byte-identical to the pre-detail wire and older
193/// readers simply never see the field. Producers document each code's detail
194/// fields where the code is defined; `detail` must never carry secrets.
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
196pub struct ErrorBody {
197    pub code: String,
198    pub message: String,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub detail: Option<serde_json::Value>,
201}
202
203impl ErrorBody {
204    /// A detail-less error body; the common case.
205    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
206        Self {
207            code: code.into(),
208            message: message.into(),
209            detail: None,
210        }
211    }
212
213    /// Attach a machine-parsable detail object to this error.
214    pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
215        self.detail = Some(detail);
216        self
217    }
218}
219
220/// Module-to-subc `HELLO` body used during module registration.
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
222pub struct ModuleHelloBody {
223    pub manifest: manifest::ModuleManifest,
224    pub protocol_ver: u8,
225    #[serde(default)]
226    pub control_ops: Option<Vec<String>>,
227    /// One-time launch nonce, echoed back from the `SUBC_LAUNCH_NONCE` environment
228    /// variable the daemon injected when it spawned this process. Only a daemon-spawned
229    /// process for a `reserved` module receives a nonce; subc accepts a reserved
230    /// `module_id`'s HELLO only when this matches the nonce it last injected for that
231    /// id, so a different process cannot register as a reserved module while the real
232    /// one is down/restarting. Absent (`serde(default)`) for non-reserved modules and
233    /// self-connecting providers, which are never nonce-checked.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub launch_nonce: Option<String>,
236}
237
238/// subc-to-module `HELLO_ACK` body used during module registration.
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240pub struct ModuleHelloAckBody {
241    pub negotiated_ver: u8,
242    pub subc_ops: Vec<String>,
243    pub subc_capabilities: Vec<String>,
244    /// The module's resolved storage descriptor, when the daemon's central config
245    /// configures managed storage. Carried opaquely here (subc-protocol stays a
246    /// thin wire crate with no storage/database dependency); a module that uses
247    /// managed storage deserializes it into `cortexkit_store_types::StorageDescriptor`
248    /// and hands it to `cortexkit-store`. Absent when no storage is configured, and
249    /// `serde(default)` so an older module simply ignores it.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub storage: Option<serde_json::Value>,
252}
253
254/// Frame kind (`type` byte at offset 5).
255///
256/// `CANCEL`, `PING`, `PONG`, and `GOODBYE` are pure-header frames (`len == 0`);
257/// only `HELLO`/`HELLO_ACK` and the RPC payloads carry bodies.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259#[repr(u8)]
260pub enum FrameType {
261    Request = 0,
262    Response = 1,
263    Push = 2,
264    StreamData = 3,
265    StreamEnd = 4,
266    Error = 5,
267    Cancel = 6,
268    Ping = 7,
269    Pong = 8,
270    Hello = 9,
271    HelloAck = 10,
272    Goodbye = 11,
273}
274
275impl FrameType {
276    /// Map the raw `type` byte to a `FrameType`, or `None` if unknown.
277    pub fn from_u8(b: u8) -> Option<Self> {
278        Some(match b {
279            0 => Self::Request,
280            1 => Self::Response,
281            2 => Self::Push,
282            3 => Self::StreamData,
283            4 => Self::StreamEnd,
284            5 => Self::Error,
285            6 => Self::Cancel,
286            7 => Self::Ping,
287            8 => Self::Pong,
288            9 => Self::Hello,
289            10 => Self::HelloAck,
290            11 => Self::Goodbye,
291            _ => return None,
292        })
293    }
294
295    pub fn is_pure_header(self) -> bool {
296        matches!(self, Self::Cancel | Self::Ping | Self::Pong | Self::Goodbye)
297    }
298}
299
300/// Scheduling priority carried in `flags` bits 1-2. subc schedules on this
301/// without parsing the body.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303#[repr(u8)]
304pub enum Priority {
305    Passive = 0,
306    Interactive = 1,
307    Background = 2,
308}
309
310impl Priority {
311    fn from_bits(bits: u8) -> Option<Self> {
312        Some(match bits {
313            0 => Self::Passive,
314            1 => Self::Interactive,
315            2 => Self::Background,
316            _ => return None,
317        })
318    }
319}
320
321/// Admission behavior carried in `flags` bits 4-5.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323#[repr(u8)]
324pub enum AdmissionClass {
325    Normal = 0,
326    Expedite = 1,
327    Sheddable = 2,
328}
329
330impl AdmissionClass {
331    fn from_bits(bits: u8) -> Option<Self> {
332        Some(match bits {
333            0 => Self::Normal,
334            1 => Self::Expedite,
335            2 => Self::Sheddable,
336            _ => return None,
337        })
338    }
339}
340
341const FLAG_BINARY: u8 = 0b0000_0001; // bit 0
342const FLAG_PRIORITY_MASK: u8 = 0b0000_0110; // bits 1-2
343const FLAG_PRIORITY_SHIFT: u8 = 1;
344const FLAG_LAST: u8 = 0b0000_1000; // bit 3
345const FLAG_ADMISSION_MASK: u8 = 0b0011_0000; // bits 4-5
346const FLAG_ADMISSION_SHIFT: u8 = 4;
347pub const FLAG_DAEMON_ORIGIN: u8 = 0b0100_0000;
348const FLAG_RESERVED_MASK: u8 = 0b1000_0000; // bit 7 must be zero
349
350/// The `flags` byte (offset 6): binary, priority, last, admission, then reserved bits.
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub struct Flags(pub u8);
353
354impl Flags {
355    /// Build flags with the default [`AdmissionClass::Normal`] class.
356    pub fn new(binary: bool, priority: Priority, last: bool) -> Self {
357        let mut b = 0u8;
358        if binary {
359            b |= FLAG_BINARY;
360        }
361        b |= (priority as u8) << FLAG_PRIORITY_SHIFT;
362        if last {
363            b |= FLAG_LAST;
364        }
365        Flags(b)
366    }
367
368    /// Return these flags with a typed admission class.
369    pub fn with_admission_class(mut self, admission_class: AdmissionClass) -> Self {
370        self.0 =
371            (self.0 & !FLAG_ADMISSION_MASK) | ((admission_class as u8) << FLAG_ADMISSION_SHIFT);
372        self
373    }
374
375    /// Body is raw bytes (bulk lane) rather than JSON-RPC.
376    pub fn is_binary(self) -> bool {
377        self.0 & FLAG_BINARY != 0
378    }
379
380    /// Final frame of a streamed message.
381    pub fn is_last(self) -> bool {
382        self.0 & FLAG_LAST != 0
383    }
384
385    /// Decode the priority bits, or `None` if they hold a reserved value.
386    pub fn priority(self) -> Option<Priority> {
387        Priority::from_bits((self.0 & FLAG_PRIORITY_MASK) >> FLAG_PRIORITY_SHIFT)
388    }
389
390    /// Decode the admission-class bits, or `None` if they hold `0b11`.
391    pub fn admission_class(self) -> Option<AdmissionClass> {
392        AdmissionClass::from_bits((self.0 & FLAG_ADMISSION_MASK) >> FLAG_ADMISSION_SHIFT)
393    }
394
395    /// True if the reserved bit 7 is set.
396    pub fn has_reserved_bits(self) -> bool {
397        self.0 & FLAG_RESERVED_MASK != 0
398    }
399
400    /// True when the frame was authored by the daemon.
401    pub fn is_daemon_origin(self) -> bool {
402        self.0 & FLAG_DAEMON_ORIGIN != 0
403    }
404
405    /// Return these flags with daemon origin asserted.
406    pub fn with_daemon_origin(mut self) -> Self {
407        self.0 |= FLAG_DAEMON_ORIGIN;
408        self
409    }
410
411    /// Return these flags with daemon origin cleared.
412    pub fn without_daemon_origin(self) -> Self {
413        Self(self.0 & !FLAG_DAEMON_ORIGIN)
414    }
415}
416
417/// A decoded envelope header. The body is the `len` bytes that follow it.
418#[derive(Debug, Clone, Copy, PartialEq, Eq)]
419pub struct EnvelopeHeader {
420    /// Number of body bytes after the header.
421    pub len: u32,
422    /// Envelope version.
423    pub ver: u8,
424    /// Frame kind.
425    pub ty: FrameType,
426    /// Flag bits.
427    pub flags: Flags,
428    /// Sender-local route slot; 0 is the control channel.
429    pub channel: u16,
430    /// Sender-local binding epoch; 0 is reserved for the control channel.
431    pub epoch: u32,
432    /// Correlation id.
433    pub corr: u64,
434}
435
436impl EnvelopeHeader {
437    /// Serialize the header to its fixed 21-byte little-endian form.
438    pub fn encode(&self) -> [u8; HEADER_LEN] {
439        let mut buf = [0u8; HEADER_LEN];
440        buf[0..4].copy_from_slice(&self.len.to_le_bytes());
441        buf[4] = self.ver;
442        buf[5] = self.ty as u8;
443        buf[6] = self.flags.0;
444        buf[7..9].copy_from_slice(&self.channel.to_le_bytes());
445        buf[9..13].copy_from_slice(&self.epoch.to_le_bytes());
446        buf[13..21].copy_from_slice(&self.corr.to_le_bytes());
447        buf
448    }
449}
450
451/// Why a header could not be decoded.
452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum DecodeError {
454    /// Fewer than `FROZEN_PREFIX_LEN` bytes — cannot even read `len`/`ver`.
455    TooShortForPrefix { have: usize },
456    /// `ver` is not a version this build understands.
457    UnsupportedVersion { ver: u8 },
458    /// Version known but fewer than its header length is present.
459    TooShortForHeader { have: usize, need: usize },
460    /// `type` byte is not a known `FrameType`.
461    UnknownFrameType { byte: u8 },
462    /// A reserved flag bit (6-7) is set.
463    ReservedFlagBits { flags: u8 },
464    /// Priority bits 1-2 hold the reserved value `0b11`.
465    ReservedPriorityBits { flags: u8 },
466    /// Admission bits 4-5 hold the reserved value `0b11`.
467    ReservedAdmissionClass { flags: u8 },
468    /// SHEDDABLE is set on a frame type that must be delivered.
469    SheddableIllegalFrameType { ty: FrameType, flags: u8 },
470    /// Channel 0 carried an epoch other than its reserved epoch 0.
471    NonzeroEpochOnControlChannel { epoch: u32 },
472    /// A pure-header frame declared body bytes.
473    PureHeaderFrameWithBody { ty: FrameType, len: u32 },
474}
475
476impl fmt::Display for DecodeError {
477    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478        match self {
479            Self::TooShortForPrefix { have } => {
480                write!(f, "header shorter than frozen prefix: have {have} bytes")
481            }
482            Self::UnsupportedVersion { ver } => write!(f, "unsupported envelope version {ver}"),
483            Self::TooShortForHeader { have, need } => {
484                write!(
485                    f,
486                    "header too short for version: have {have} bytes, need {need}"
487                )
488            }
489            Self::UnknownFrameType { byte } => write!(f, "unknown frame type byte {byte}"),
490            Self::ReservedFlagBits { flags } => {
491                write!(f, "reserved flag bits set in flags 0b{flags:08b}")
492            }
493            Self::ReservedPriorityBits { flags } => {
494                write!(f, "reserved priority bits set in flags 0b{flags:08b}")
495            }
496            Self::ReservedAdmissionClass { flags } => {
497                write!(f, "reserved admission class set in flags 0b{flags:08b}")
498            }
499            Self::SheddableIllegalFrameType { ty, flags } => write!(
500                f,
501                "SHEDDABLE admission class is illegal on {ty:?} in flags 0b{flags:08b}"
502            ),
503            Self::NonzeroEpochOnControlChannel { epoch } => {
504                write!(f, "control channel carried nonzero epoch {epoch}")
505            }
506            Self::PureHeaderFrameWithBody { ty, len } => {
507                write!(
508                    f,
509                    "pure-header frame {ty:?} declared non-zero body length {len}"
510                )
511            }
512        }
513    }
514}
515
516impl Error for DecodeError {}
517
518/// How many header bytes a given envelope version occupies. Driven by the
519/// frozen prefix: read `ver`, then learn the full header length here.
520fn header_len_for_version(ver: u8) -> Option<usize> {
521    match ver {
522        PROTOCOL_VERSION => Some(HEADER_LEN),
523        _ => None,
524    }
525}
526
527/// Decode an envelope header from the front of `bytes`, following the
528/// frozen-prefix discipline:
529/// 1. need at least the 5-byte prefix to read `len` + `ver`;
530/// 2. dispatch the full header length on `ver`;
531/// 3. need the full header present; then parse the rest.
532///
533/// Never panics on malformed input — returns a typed [`DecodeError`].
534pub fn decode_header(bytes: &[u8]) -> Result<EnvelopeHeader, DecodeError> {
535    if bytes.len() < FROZEN_PREFIX_LEN {
536        return Err(DecodeError::TooShortForPrefix { have: bytes.len() });
537    }
538    let ver = bytes[4];
539    let need = header_len_for_version(ver).ok_or(DecodeError::UnsupportedVersion { ver })?;
540    if bytes.len() < need {
541        return Err(DecodeError::TooShortForHeader {
542            have: bytes.len(),
543            need,
544        });
545    }
546
547    let len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
548    let ty =
549        FrameType::from_u8(bytes[5]).ok_or(DecodeError::UnknownFrameType { byte: bytes[5] })?;
550    let flags = Flags(bytes[6]);
551    if flags.has_reserved_bits() {
552        return Err(DecodeError::ReservedFlagBits { flags: bytes[6] });
553    }
554    if flags.priority().is_none() {
555        return Err(DecodeError::ReservedPriorityBits { flags: bytes[6] });
556    }
557    let admission_class = flags
558        .admission_class()
559        .ok_or(DecodeError::ReservedAdmissionClass { flags: bytes[6] })?;
560    if admission_class == AdmissionClass::Sheddable
561        && !matches!(ty, FrameType::Push | FrameType::StreamData)
562    {
563        return Err(DecodeError::SheddableIllegalFrameType {
564            ty,
565            flags: bytes[6],
566        });
567    }
568    if ty.is_pure_header() && len != 0 {
569        return Err(DecodeError::PureHeaderFrameWithBody { ty, len });
570    }
571    let channel = u16::from_le_bytes([bytes[7], bytes[8]]);
572    let epoch = u32::from_le_bytes([bytes[9], bytes[10], bytes[11], bytes[12]]);
573    if channel == 0 && epoch != 0 {
574        return Err(DecodeError::NonzeroEpochOnControlChannel { epoch });
575    }
576    let corr = u64::from_le_bytes([
577        bytes[13], bytes[14], bytes[15], bytes[16], bytes[17], bytes[18], bytes[19], bytes[20],
578    ]);
579
580    Ok(EnvelopeHeader {
581        len,
582        ver,
583        ty,
584        flags,
585        channel,
586        epoch,
587        corr,
588    })
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn hdr(len: u32, ty: FrameType, flags: Flags, channel: u16, corr: u64) -> EnvelopeHeader {
596        hdr_with_epoch(len, ty, flags, channel, u32::from(channel != 0), corr)
597    }
598
599    fn hdr_with_epoch(
600        len: u32,
601        ty: FrameType,
602        flags: Flags,
603        channel: u16,
604        epoch: u32,
605        corr: u64,
606    ) -> EnvelopeHeader {
607        EnvelopeHeader {
608            len,
609            ver: PROTOCOL_VERSION,
610            ty,
611            flags,
612            channel,
613            epoch,
614            corr,
615        }
616    }
617
618    #[test]
619    fn bind_identity_round_trips_json() {
620        let identity = BindIdentity {
621            project_root: PathBuf::from("/tmp/project"),
622            harness: "opencode".to_string(),
623            session: "session-1".to_string(),
624        };
625
626        let encoded = serde_json::to_vec(&identity).unwrap();
627        let decoded: BindIdentity = serde_json::from_slice(&encoded).unwrap();
628
629        assert_eq!(decoded, identity);
630    }
631
632    #[test]
633    fn route_target_variants_round_trip_json() {
634        let targets = [
635            RouteTarget::ToolProvider {
636                module_id: "aft".to_string(),
637            },
638            RouteTarget::ManagementSurface {
639                module_id: "memory".to_string(),
640            },
641            RouteTarget::InternalService {
642                module_id: "bus".to_string(),
643                service_id: "dm".to_string(),
644            },
645        ];
646
647        for target in targets {
648            let encoded = serde_json::to_vec(&target).unwrap();
649            let decoded: RouteTarget = serde_json::from_slice(&encoded).unwrap();
650            assert_eq!(decoded, target);
651        }
652    }
653
654    #[test]
655    fn error_body_round_trips_json() {
656        let body = ErrorBody {
657            code: "config_divergence".to_string(),
658            message: "active config differs".to_string(),
659            detail: None,
660        };
661
662        let encoded = serde_json::to_vec(&body).unwrap();
663        let decoded: ErrorBody = serde_json::from_slice(&encoded).unwrap();
664
665        assert_eq!(decoded, body);
666    }
667
668    #[test]
669    fn round_trip_request() {
670        let h = hdr(
671            1234,
672            FrameType::Request,
673            Flags::new(false, Priority::Interactive, false),
674            42,
675            0xDEAD_BEEF_0000_0001,
676        );
677        let decoded = decode_header(&h.encode()).unwrap();
678        assert_eq!(h, decoded);
679    }
680
681    #[test]
682    fn round_trip_all_frame_types() {
683        for b in 0u8..=11 {
684            let ty = FrameType::from_u8(b).unwrap();
685            let h = hdr(0, ty, Flags::new(false, Priority::Passive, false), 0, 0);
686            assert_eq!(decode_header(&h.encode()).unwrap().ty, ty);
687        }
688    }
689
690    #[test]
691    fn pure_header_frame_has_zero_len() {
692        // CANCEL carries only header (len = 0) + the target corr.
693        let h = hdr(
694            0,
695            FrameType::Cancel,
696            Flags::new(false, Priority::Passive, false),
697            7,
698            99,
699        );
700        let d = decode_header(&h.encode()).unwrap();
701        assert_eq!(d.len, 0);
702        assert_eq!(d.corr, 99);
703    }
704
705    #[test]
706    fn flags_round_trip() {
707        let f = Flags::new(true, Priority::Background, true)
708            .with_admission_class(AdmissionClass::Expedite);
709        assert!(f.is_binary());
710        assert!(f.is_last());
711        assert_eq!(f.priority(), Some(Priority::Background));
712        assert_eq!(f.admission_class(), Some(AdmissionClass::Expedite));
713        let h = hdr(8, FrameType::StreamData, f, 1, 1);
714        assert_eq!(decode_header(&h.encode()).unwrap().flags, f);
715    }
716
717    #[test]
718    fn daemon_origin_flags_decode_and_round_trip() {
719        let old = hdr(0, FrameType::Error, Flags(0), 7, 1);
720        let old_decoded = decode_header(&old.encode()).unwrap();
721        assert!(!old_decoded.flags.is_daemon_origin());
722
723        let daemon = hdr(0, FrameType::Error, Flags(0).with_daemon_origin(), 7, 1);
724        let daemon_decoded = decode_header(&daemon.encode()).unwrap();
725        assert!(daemon_decoded.flags.is_daemon_origin());
726        assert_eq!(daemon_decoded.flags.without_daemon_origin(), Flags(0));
727        assert!(Flags(0).with_daemon_origin().is_daemon_origin());
728    }
729
730    #[test]
731    fn little_endian_and_frozen_prefix_layout() {
732        let h = hdr_with_epoch(
733            0x0403_0201,
734            FrameType::Request,
735            Flags(0),
736            0x0605,
737            0x0a09_0807,
738            0x1211_100f_0e0d_0c0b,
739        );
740        let buf = h.encode();
741        assert_eq!(&buf[0..4], &[1, 2, 3, 4]);
742        assert_eq!(buf[4], PROTOCOL_VERSION);
743        assert_eq!(&buf[7..9], &[5, 6]);
744        assert_eq!(&buf[9..13], &[7, 8, 9, 10]);
745        assert_eq!(&buf[13..21], &[11, 12, 13, 14, 15, 16, 17, 18]);
746        assert_eq!(buf.len(), HEADER_LEN);
747    }
748
749    #[test]
750    fn reject_too_short_for_prefix() {
751        assert_eq!(
752            decode_header(&[0, 0, 0, 0]),
753            Err(DecodeError::TooShortForPrefix { have: 4 })
754        );
755    }
756
757    #[test]
758    fn reject_too_short_for_header() {
759        // Valid 5-byte prefix but the v2 header is truncated.
760        let mut b = [0u8; 10];
761        b[4] = PROTOCOL_VERSION;
762        assert_eq!(
763            decode_header(&b),
764            Err(DecodeError::TooShortForHeader {
765                have: 10,
766                need: HEADER_LEN
767            })
768        );
769    }
770
771    #[test]
772    fn reject_unsupported_version() {
773        let mut b = [0u8; HEADER_LEN];
774        b[4] = 1;
775        assert_eq!(
776            decode_header(&b),
777            Err(DecodeError::UnsupportedVersion { ver: 1 })
778        );
779    }
780
781    #[test]
782    fn reject_unknown_frame_type() {
783        let mut b = [0u8; HEADER_LEN];
784        b[4] = PROTOCOL_VERSION;
785        b[5] = 99;
786        assert_eq!(
787            decode_header(&b),
788            Err(DecodeError::UnknownFrameType { byte: 99 })
789        );
790    }
791
792    #[test]
793    fn reject_reserved_flag_bits() {
794        let mut b = [0u8; HEADER_LEN];
795        b[4] = PROTOCOL_VERSION;
796        b[5] = FrameType::Request as u8;
797        b[6] = 0b1000_0000; // reserved bit 7 set
798        assert_eq!(
799            decode_header(&b),
800            Err(DecodeError::ReservedFlagBits { flags: 0b1000_0000 })
801        );
802    }
803
804    #[test]
805    fn reject_reserved_priority_bits() {
806        let mut b = [0u8; HEADER_LEN];
807        b[4] = PROTOCOL_VERSION;
808        b[5] = FrameType::Request as u8;
809        b[6] = 0b0000_0110; // priority bits 1-2 are reserved value 0b11
810        assert_eq!(
811            decode_header(&b),
812            Err(DecodeError::ReservedPriorityBits { flags: 0b0000_0110 })
813        );
814    }
815
816    #[test]
817    fn reject_pure_header_frame_with_body_len() {
818        let h = hdr(
819            1,
820            FrameType::Ping,
821            Flags::new(false, Priority::Passive, false),
822            0,
823            1,
824        );
825        assert_eq!(
826            decode_header(&h.encode()),
827            Err(DecodeError::PureHeaderFrameWithBody {
828                ty: FrameType::Ping,
829                len: 1
830            })
831        );
832    }
833
834    #[test]
835    fn epoch_boundaries_round_trip() {
836        for (channel, epoch) in [(0, 0), (1, 1), (u16::MAX, u32::MAX)] {
837            let h = hdr_with_epoch(
838                0,
839                FrameType::Request,
840                Flags::new(false, Priority::Passive, false),
841                channel,
842                epoch,
843                9,
844            );
845            assert_eq!(decode_header(&h.encode()).unwrap(), h);
846        }
847    }
848
849    #[test]
850    fn admission_classes_accept_three_values_and_reject_reserved_value() {
851        for (ty, admission_class) in [
852            (FrameType::Request, AdmissionClass::Normal),
853            (FrameType::Request, AdmissionClass::Expedite),
854            (FrameType::Push, AdmissionClass::Sheddable),
855            (FrameType::StreamData, AdmissionClass::Sheddable),
856        ] {
857            let flags = Flags::new(false, Priority::Interactive, false)
858                .with_admission_class(admission_class);
859            let h = hdr(0, ty, flags, 1, 2);
860            assert_eq!(decode_header(&h.encode()).unwrap().flags, flags);
861        }
862
863        let mut h = hdr(
864            0,
865            FrameType::Push,
866            Flags::new(false, Priority::Passive, false),
867            1,
868            2,
869        )
870        .encode();
871        h[6] |= 0b0011_0000;
872        assert_eq!(
873            decode_header(&h),
874            Err(DecodeError::ReservedAdmissionClass { flags: h[6] })
875        );
876    }
877
878    #[test]
879    fn sheddable_rejected_on_every_illegal_frame_type() {
880        let flags = Flags::new(false, Priority::Passive, false)
881            .with_admission_class(AdmissionClass::Sheddable);
882        for ty in [
883            FrameType::Request,
884            FrameType::Response,
885            FrameType::StreamEnd,
886            FrameType::Error,
887            FrameType::Cancel,
888            FrameType::Ping,
889            FrameType::Pong,
890            FrameType::Hello,
891            FrameType::HelloAck,
892            FrameType::Goodbye,
893        ] {
894            let h = hdr(0, ty, flags, 1, 2);
895            assert_eq!(
896                decode_header(&h.encode()),
897                Err(DecodeError::SheddableIllegalFrameType { ty, flags: flags.0 })
898            );
899        }
900    }
901
902    #[test]
903    fn nonzero_epoch_on_control_channel_is_rejected() {
904        let h = hdr_with_epoch(
905            0,
906            FrameType::Request,
907            Flags::new(false, Priority::Passive, false),
908            0,
909            u32::MAX,
910            2,
911        );
912        assert_eq!(
913            decode_header(&h.encode()),
914            Err(DecodeError::NonzeroEpochOnControlChannel { epoch: u32::MAX })
915        );
916    }
917}