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`, `session`, and `project_id` are strings the caller chose.
85/// A client 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 four 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)]
101#[non_exhaustive]
102pub struct BindIdentity {
103    pub project_root: PathBuf,
104    pub harness: String,
105    pub session: String,
106    /// The entorhinal-registered project id (`pj-…`) for `project_root`, when
107    /// the root is a registered project. Aliases count as registered projects;
108    /// implicit roots do not. Absent means "no stable id, key on the triple",
109    /// not "unknown".
110    ///
111    /// A producer sends the id on every bind of a session or on none. A producer
112    /// that alternates between `Some(id)` and `None` across binds silently forks
113    /// the consumer's lineage into separate stores, with no error at either end.
114    /// Therefore, a producer that cannot answer consistently must answer `None`
115    /// consistently.
116    ///
117    /// Resolve this at most once per session, before its first bind, and persist
118    /// the outcome with the session. ALF's resolver has real `Resolved`, `Unavailable`,
119    /// and `Disabled` outcomes: if unavailable at cold start is re-resolved on a
120    /// later bind, the session can alternate from `None` to `Some(id)`. Send only
121    /// registered or alias resolutions, never implicit, unavailable, or disabled
122    /// fallback ids.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub project_id: Option<String>,
125}
126
127impl BindIdentity {
128    /// Constructs an identity with no registered project id.
129    ///
130    /// Use this instead of a struct literal so future additive identity fields do
131    /// not force construction-site migrations across the fleet.
132    pub fn new(
133        project_root: impl Into<PathBuf>,
134        harness: impl Into<String>,
135        session: impl Into<String>,
136    ) -> Self {
137        Self {
138            project_root: project_root.into(),
139            harness: harness.into(),
140            session: session.into(),
141            project_id: None,
142        }
143    }
144}
145
146/// Caller fact stamped by subc on each route.bind relayed to a module.
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
148#[serde(tag = "kind", rename_all = "snake_case")]
149pub enum Principal {
150    /// A daemon-spawned module proved possession of its launch nonce.
151    Reserved { module_id: String },
152    /// No consumer identity was presented; the caller is a direct key-holder.
153    Direct,
154    /// Reserved vocabulary for a future degraded/no-key-auth mode.
155    Unverified,
156}
157
158/// Explicit target for a route open/bind operation.
159///
160/// RouteTarget.kind ↔ ProviderRole mapping:
161///
162/// | RouteTarget.kind | required ProviderRole | disambiguator |
163/// |---|---|---|
164/// | `tool_provider` | `ToolProvider` | v1: ≤1 per module |
165/// | `management_surface` | `ManagementSurface` | v1: ≤1 per module |
166/// | `internal_service` | `InternalService` | `service_id` (multiple allowed) |
167///
168/// `ProviderRole::PipelineStage` is intentionally unroutable; pipeline modules
169/// are wired by an orchestrator rather than opened directly by clients.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(tag = "kind", rename_all = "snake_case")]
172pub enum RouteTarget {
173    ToolProvider {
174        module_id: String,
175    },
176    ManagementSurface {
177        module_id: String,
178    },
179    InternalService {
180        module_id: String,
181        service_id: String,
182    },
183}
184
185/// Envelope protocol version this build speaks.
186pub const PROTOCOL_VERSION: u8 = 2;
187
188/// The version of THIS crate (`subc-protocol`) as compiled into the linking
189/// binary — the fleet's shared wire-vocabulary version, and the value
190/// `ManifestProvenance.wire_crate_version` declares. Not the version of a
191/// module's own envelope or payload crates: those are different numbering
192/// spaces, and declaring one here produces a confident wrong answer at any
193/// version gate (insula shipped exactly that before the referent was written
194/// down). `env!` makes it a property of the compiled binary, not of whatever
195/// source tree sits beside it at run time.
196pub const SUBC_PROTOCOL_CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
197
198/// Oldest envelope protocol version this build accepts.
199pub const MIN_SUPPORTED_VERSION: u8 = 2;
200
201/// Env var subc sets on each supervised child telling it the module_id it is
202/// supervised under, so it can register under that id.
203pub const SUBC_MODULE_ID_ENV: &str = "SUBC_MODULE_ID";
204
205/// Env var subc sets, on each spawn of a `reserved` module only, to a fresh
206/// one-time launch nonce. The child echoes it in `ModuleHelloBody::launch_nonce`;
207/// subc accepts a reserved module_id's HELLO only when the nonce matches the one it
208/// last injected for that id. Non-reserved modules never receive it.
209pub const SUBC_LAUNCH_NONCE_ENV: &str = "SUBC_LAUNCH_NONCE";
210
211/// Fixed header length for `PROTOCOL_VERSION` 2.
212pub const HEADER_LEN: usize = 21;
213
214/// Bytes of the frozen prefix (`len` u32 + `ver` u8) that are stable across
215/// every envelope version. A reader needs only these to learn the version and
216/// thus the full header length.
217pub const FROZEN_PREFIX_LEN: usize = 5;
218
219/// Maximum frame body accepted before allocation.
220///
221/// This 64 MiB starting cap prevents a malformed header from forcing an
222/// unbounded allocation. Future protocol versions can negotiate or encode a
223/// different cap while preserving the frozen prefix.
224pub const MAX_FRAME_BODY_LEN: u32 = 64 * 1024 * 1024;
225
226/// Canonical JSON body for all subc-generated `ERROR` frames.
227///
228/// `detail` is an optional machine-parsable surface for refusals whose remedy
229/// needs more than a code (e.g. a producer-published backoff number, an
230/// observed-vs-configured size pair). Absent detail serializes to nothing, so
231/// bodies without it are byte-identical to the pre-detail wire and older
232/// readers simply never see the field. Producers document each code's detail
233/// fields where the code is defined; `detail` must never carry secrets.
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
235pub struct ErrorBody {
236    pub code: String,
237    pub message: String,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub detail: Option<serde_json::Value>,
240}
241
242impl ErrorBody {
243    /// A detail-less error body; the common case.
244    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
245        Self {
246            code: code.into(),
247            message: message.into(),
248            detail: None,
249        }
250    }
251
252    /// Attach a machine-parsable detail object to this error.
253    pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
254        self.detail = Some(detail);
255        self
256    }
257}
258
259/// Module-to-subc `HELLO` body used during module registration.
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
261pub struct ModuleHelloBody {
262    pub manifest: manifest::ModuleManifest,
263    pub protocol_ver: u8,
264    #[serde(default)]
265    pub control_ops: Option<Vec<String>>,
266    /// One-time launch nonce, echoed back from the `SUBC_LAUNCH_NONCE` environment
267    /// variable the daemon injected when it spawned this process. Only a daemon-spawned
268    /// process for a `reserved` module receives a nonce; subc accepts a reserved
269    /// `module_id`'s HELLO only when this matches the nonce it last injected for that
270    /// id, so a different process cannot register as a reserved module while the real
271    /// one is down/restarting. Absent (`serde(default)`) for non-reserved modules and
272    /// self-connecting providers, which are never nonce-checked.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub launch_nonce: Option<String>,
275}
276
277/// subc-to-module `HELLO_ACK` body used during module registration.
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279pub struct ModuleHelloAckBody {
280    pub negotiated_ver: u8,
281    pub subc_ops: Vec<String>,
282    pub subc_capabilities: Vec<String>,
283    /// The module's resolved storage descriptor, when the daemon's central config
284    /// configures managed storage. Carried opaquely here (subc-protocol stays a
285    /// thin wire crate with no storage/database dependency); a module that uses
286    /// managed storage deserializes it into `cortexkit_store_types::StorageDescriptor`
287    /// and hands it to `cortexkit-store`. Absent when no storage is configured, and
288    /// `serde(default)` so an older module simply ignores it.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub storage: Option<serde_json::Value>,
291}
292
293/// Frame kind (`type` byte at offset 5).
294///
295/// `CANCEL`, `PING`, `PONG`, and `GOODBYE` are pure-header frames (`len == 0`);
296/// only `HELLO`/`HELLO_ACK` and the RPC payloads carry bodies.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298#[repr(u8)]
299pub enum FrameType {
300    Request = 0,
301    Response = 1,
302    Push = 2,
303    StreamData = 3,
304    StreamEnd = 4,
305    Error = 5,
306    Cancel = 6,
307    Ping = 7,
308    Pong = 8,
309    Hello = 9,
310    HelloAck = 10,
311    Goodbye = 11,
312}
313
314impl FrameType {
315    /// Map the raw `type` byte to a `FrameType`, or `None` if unknown.
316    pub fn from_u8(b: u8) -> Option<Self> {
317        Some(match b {
318            0 => Self::Request,
319            1 => Self::Response,
320            2 => Self::Push,
321            3 => Self::StreamData,
322            4 => Self::StreamEnd,
323            5 => Self::Error,
324            6 => Self::Cancel,
325            7 => Self::Ping,
326            8 => Self::Pong,
327            9 => Self::Hello,
328            10 => Self::HelloAck,
329            11 => Self::Goodbye,
330            _ => return None,
331        })
332    }
333
334    pub fn is_pure_header(self) -> bool {
335        matches!(self, Self::Cancel | Self::Ping | Self::Pong | Self::Goodbye)
336    }
337}
338
339/// Scheduling priority carried in `flags` bits 1-2. subc schedules on this
340/// without parsing the body.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342#[repr(u8)]
343pub enum Priority {
344    Passive = 0,
345    Interactive = 1,
346    Background = 2,
347}
348
349impl Priority {
350    fn from_bits(bits: u8) -> Option<Self> {
351        Some(match bits {
352            0 => Self::Passive,
353            1 => Self::Interactive,
354            2 => Self::Background,
355            _ => return None,
356        })
357    }
358}
359
360/// Admission behavior carried in `flags` bits 4-5.
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362#[repr(u8)]
363pub enum AdmissionClass {
364    Normal = 0,
365    Expedite = 1,
366    Sheddable = 2,
367}
368
369impl AdmissionClass {
370    fn from_bits(bits: u8) -> Option<Self> {
371        Some(match bits {
372            0 => Self::Normal,
373            1 => Self::Expedite,
374            2 => Self::Sheddable,
375            _ => return None,
376        })
377    }
378}
379
380const FLAG_BINARY: u8 = 0b0000_0001; // bit 0
381const FLAG_PRIORITY_MASK: u8 = 0b0000_0110; // bits 1-2
382const FLAG_PRIORITY_SHIFT: u8 = 1;
383const FLAG_LAST: u8 = 0b0000_1000; // bit 3
384const FLAG_ADMISSION_MASK: u8 = 0b0011_0000; // bits 4-5
385const FLAG_ADMISSION_SHIFT: u8 = 4;
386pub const FLAG_DAEMON_ORIGIN: u8 = 0b0100_0000;
387const FLAG_RESERVED_MASK: u8 = 0b1000_0000; // bit 7 must be zero
388
389/// The `flags` byte (offset 6): binary, priority, last, admission, then reserved bits.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub struct Flags(pub u8);
392
393impl Flags {
394    /// Build flags with the default [`AdmissionClass::Normal`] class.
395    pub fn new(binary: bool, priority: Priority, last: bool) -> Self {
396        let mut b = 0u8;
397        if binary {
398            b |= FLAG_BINARY;
399        }
400        b |= (priority as u8) << FLAG_PRIORITY_SHIFT;
401        if last {
402            b |= FLAG_LAST;
403        }
404        Flags(b)
405    }
406
407    /// Return these flags with a typed admission class.
408    pub fn with_admission_class(mut self, admission_class: AdmissionClass) -> Self {
409        self.0 =
410            (self.0 & !FLAG_ADMISSION_MASK) | ((admission_class as u8) << FLAG_ADMISSION_SHIFT);
411        self
412    }
413
414    /// Body is raw bytes (bulk lane) rather than JSON-RPC.
415    pub fn is_binary(self) -> bool {
416        self.0 & FLAG_BINARY != 0
417    }
418
419    /// Final frame of a streamed message.
420    pub fn is_last(self) -> bool {
421        self.0 & FLAG_LAST != 0
422    }
423
424    /// Decode the priority bits, or `None` if they hold a reserved value.
425    pub fn priority(self) -> Option<Priority> {
426        Priority::from_bits((self.0 & FLAG_PRIORITY_MASK) >> FLAG_PRIORITY_SHIFT)
427    }
428
429    /// Decode the admission-class bits, or `None` if they hold `0b11`.
430    pub fn admission_class(self) -> Option<AdmissionClass> {
431        AdmissionClass::from_bits((self.0 & FLAG_ADMISSION_MASK) >> FLAG_ADMISSION_SHIFT)
432    }
433
434    /// True if the reserved bit 7 is set.
435    pub fn has_reserved_bits(self) -> bool {
436        self.0 & FLAG_RESERVED_MASK != 0
437    }
438
439    /// True when the frame was authored by the daemon.
440    pub fn is_daemon_origin(self) -> bool {
441        self.0 & FLAG_DAEMON_ORIGIN != 0
442    }
443
444    /// Return these flags with daemon origin asserted.
445    pub fn with_daemon_origin(mut self) -> Self {
446        self.0 |= FLAG_DAEMON_ORIGIN;
447        self
448    }
449
450    /// Return these flags with daemon origin cleared.
451    pub fn without_daemon_origin(self) -> Self {
452        Self(self.0 & !FLAG_DAEMON_ORIGIN)
453    }
454}
455
456/// A decoded envelope header. The body is the `len` bytes that follow it.
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub struct EnvelopeHeader {
459    /// Number of body bytes after the header.
460    pub len: u32,
461    /// Envelope version.
462    pub ver: u8,
463    /// Frame kind.
464    pub ty: FrameType,
465    /// Flag bits.
466    pub flags: Flags,
467    /// Sender-local route slot; 0 is the control channel.
468    pub channel: u16,
469    /// Sender-local binding epoch; 0 is reserved for the control channel.
470    pub epoch: u32,
471    /// Correlation id.
472    pub corr: u64,
473}
474
475impl EnvelopeHeader {
476    /// Serialize the header to its fixed 21-byte little-endian form.
477    pub fn encode(&self) -> [u8; HEADER_LEN] {
478        let mut buf = [0u8; HEADER_LEN];
479        buf[0..4].copy_from_slice(&self.len.to_le_bytes());
480        buf[4] = self.ver;
481        buf[5] = self.ty as u8;
482        buf[6] = self.flags.0;
483        buf[7..9].copy_from_slice(&self.channel.to_le_bytes());
484        buf[9..13].copy_from_slice(&self.epoch.to_le_bytes());
485        buf[13..21].copy_from_slice(&self.corr.to_le_bytes());
486        buf
487    }
488}
489
490/// Why a header could not be decoded.
491#[derive(Debug, Clone, Copy, PartialEq, Eq)]
492pub enum DecodeError {
493    /// Fewer than `FROZEN_PREFIX_LEN` bytes — cannot even read `len`/`ver`.
494    TooShortForPrefix { have: usize },
495    /// `ver` is not a version this build understands.
496    UnsupportedVersion { ver: u8 },
497    /// Version known but fewer than its header length is present.
498    TooShortForHeader { have: usize, need: usize },
499    /// `type` byte is not a known `FrameType`.
500    UnknownFrameType { byte: u8 },
501    /// A reserved flag bit (6-7) is set.
502    ReservedFlagBits { flags: u8 },
503    /// Priority bits 1-2 hold the reserved value `0b11`.
504    ReservedPriorityBits { flags: u8 },
505    /// Admission bits 4-5 hold the reserved value `0b11`.
506    ReservedAdmissionClass { flags: u8 },
507    /// SHEDDABLE is set on a frame type that must be delivered.
508    SheddableIllegalFrameType { ty: FrameType, flags: u8 },
509    /// Channel 0 carried an epoch other than its reserved epoch 0.
510    NonzeroEpochOnControlChannel { epoch: u32 },
511    /// A pure-header frame declared body bytes.
512    PureHeaderFrameWithBody { ty: FrameType, len: u32 },
513}
514
515impl fmt::Display for DecodeError {
516    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
517        match self {
518            Self::TooShortForPrefix { have } => {
519                write!(f, "header shorter than frozen prefix: have {have} bytes")
520            }
521            Self::UnsupportedVersion { ver } => write!(f, "unsupported envelope version {ver}"),
522            Self::TooShortForHeader { have, need } => {
523                write!(
524                    f,
525                    "header too short for version: have {have} bytes, need {need}"
526                )
527            }
528            Self::UnknownFrameType { byte } => write!(f, "unknown frame type byte {byte}"),
529            Self::ReservedFlagBits { flags } => {
530                write!(f, "reserved flag bits set in flags 0b{flags:08b}")
531            }
532            Self::ReservedPriorityBits { flags } => {
533                write!(f, "reserved priority bits set in flags 0b{flags:08b}")
534            }
535            Self::ReservedAdmissionClass { flags } => {
536                write!(f, "reserved admission class set in flags 0b{flags:08b}")
537            }
538            Self::SheddableIllegalFrameType { ty, flags } => write!(
539                f,
540                "SHEDDABLE admission class is illegal on {ty:?} in flags 0b{flags:08b}"
541            ),
542            Self::NonzeroEpochOnControlChannel { epoch } => {
543                write!(f, "control channel carried nonzero epoch {epoch}")
544            }
545            Self::PureHeaderFrameWithBody { ty, len } => {
546                write!(
547                    f,
548                    "pure-header frame {ty:?} declared non-zero body length {len}"
549                )
550            }
551        }
552    }
553}
554
555impl Error for DecodeError {}
556
557/// How many header bytes a given envelope version occupies. Driven by the
558/// frozen prefix: read `ver`, then learn the full header length here.
559fn header_len_for_version(ver: u8) -> Option<usize> {
560    match ver {
561        PROTOCOL_VERSION => Some(HEADER_LEN),
562        _ => None,
563    }
564}
565
566/// Decode an envelope header from the front of `bytes`, following the
567/// frozen-prefix discipline:
568/// 1. need at least the 5-byte prefix to read `len` + `ver`;
569/// 2. dispatch the full header length on `ver`;
570/// 3. need the full header present; then parse the rest.
571///
572/// Never panics on malformed input — returns a typed [`DecodeError`].
573pub fn decode_header(bytes: &[u8]) -> Result<EnvelopeHeader, DecodeError> {
574    if bytes.len() < FROZEN_PREFIX_LEN {
575        return Err(DecodeError::TooShortForPrefix { have: bytes.len() });
576    }
577    let ver = bytes[4];
578    let need = header_len_for_version(ver).ok_or(DecodeError::UnsupportedVersion { ver })?;
579    if bytes.len() < need {
580        return Err(DecodeError::TooShortForHeader {
581            have: bytes.len(),
582            need,
583        });
584    }
585
586    let len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
587    let ty =
588        FrameType::from_u8(bytes[5]).ok_or(DecodeError::UnknownFrameType { byte: bytes[5] })?;
589    let flags = Flags(bytes[6]);
590    if flags.has_reserved_bits() {
591        return Err(DecodeError::ReservedFlagBits { flags: bytes[6] });
592    }
593    if flags.priority().is_none() {
594        return Err(DecodeError::ReservedPriorityBits { flags: bytes[6] });
595    }
596    let admission_class = flags
597        .admission_class()
598        .ok_or(DecodeError::ReservedAdmissionClass { flags: bytes[6] })?;
599    if admission_class == AdmissionClass::Sheddable
600        && !matches!(ty, FrameType::Push | FrameType::StreamData)
601    {
602        return Err(DecodeError::SheddableIllegalFrameType {
603            ty,
604            flags: bytes[6],
605        });
606    }
607    if ty.is_pure_header() && len != 0 {
608        return Err(DecodeError::PureHeaderFrameWithBody { ty, len });
609    }
610    let channel = u16::from_le_bytes([bytes[7], bytes[8]]);
611    let epoch = u32::from_le_bytes([bytes[9], bytes[10], bytes[11], bytes[12]]);
612    if channel == 0 && epoch != 0 {
613        return Err(DecodeError::NonzeroEpochOnControlChannel { epoch });
614    }
615    let corr = u64::from_le_bytes([
616        bytes[13], bytes[14], bytes[15], bytes[16], bytes[17], bytes[18], bytes[19], bytes[20],
617    ]);
618
619    Ok(EnvelopeHeader {
620        len,
621        ver,
622        ty,
623        flags,
624        channel,
625        epoch,
626        corr,
627    })
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    fn hdr(len: u32, ty: FrameType, flags: Flags, channel: u16, corr: u64) -> EnvelopeHeader {
635        hdr_with_epoch(len, ty, flags, channel, u32::from(channel != 0), corr)
636    }
637
638    fn hdr_with_epoch(
639        len: u32,
640        ty: FrameType,
641        flags: Flags,
642        channel: u16,
643        epoch: u32,
644        corr: u64,
645    ) -> EnvelopeHeader {
646        EnvelopeHeader {
647            len,
648            ver: PROTOCOL_VERSION,
649            ty,
650            flags,
651            channel,
652            epoch,
653            corr,
654        }
655    }
656
657    #[test]
658    fn bind_identity_with_project_id_round_trips_json() {
659        let mut identity = BindIdentity::new("/tmp/project", "opencode", "session-1");
660        identity.project_id = Some("pj-a1b2c3d4".to_string());
661
662        let encoded = serde_json::to_vec(&identity).unwrap();
663        let decoded: BindIdentity = serde_json::from_slice(&encoded).unwrap();
664
665        assert_eq!(decoded, identity);
666    }
667
668    #[test]
669    fn bind_identity_without_project_id_round_trips_json() {
670        let identity = BindIdentity::new("/tmp/project", "opencode", "session-1");
671
672        let encoded = serde_json::to_vec(&identity).unwrap();
673        let decoded: BindIdentity = serde_json::from_slice(&encoded).unwrap();
674
675        assert_eq!(decoded, identity);
676    }
677
678    #[test]
679    fn legacy_bind_identity_without_project_id_decodes() {
680        let decoded: BindIdentity = serde_json::from_value(serde_json::json!({
681            "project_root": "/tmp/project",
682            "harness": "opencode",
683            "session": "session-1"
684        }))
685        .unwrap();
686
687        assert_eq!(decoded.project_id, None);
688    }
689
690    #[test]
691    fn bind_identity_none_omits_project_id_instead_of_serializing_null() {
692        let encoded =
693            serde_json::to_value(BindIdentity::new("/tmp/project", "opencode", "session-1"))
694                .unwrap();
695
696        assert!(encoded.get("project_id").is_none());
697    }
698
699    #[test]
700    fn wire_crate_version_is_a_numeric_three_component_version() {
701        let components = SUBC_PROTOCOL_CRATE_VERSION.split('.').collect::<Vec<_>>();
702
703        assert!(!SUBC_PROTOCOL_CRATE_VERSION.is_empty());
704        assert_eq!(components.len(), 3);
705        assert!(components
706            .iter()
707            .all(|component| !component.is_empty() && component.parse::<u64>().is_ok()));
708    }
709
710    #[test]
711    fn route_target_variants_round_trip_json() {
712        let targets = [
713            RouteTarget::ToolProvider {
714                module_id: "aft".to_string(),
715            },
716            RouteTarget::ManagementSurface {
717                module_id: "memory".to_string(),
718            },
719            RouteTarget::InternalService {
720                module_id: "bus".to_string(),
721                service_id: "dm".to_string(),
722            },
723        ];
724
725        for target in targets {
726            let encoded = serde_json::to_vec(&target).unwrap();
727            let decoded: RouteTarget = serde_json::from_slice(&encoded).unwrap();
728            assert_eq!(decoded, target);
729        }
730    }
731
732    #[test]
733    fn error_body_round_trips_json() {
734        let body = ErrorBody {
735            code: "config_divergence".to_string(),
736            message: "active config differs".to_string(),
737            detail: None,
738        };
739
740        let encoded = serde_json::to_vec(&body).unwrap();
741        let decoded: ErrorBody = serde_json::from_slice(&encoded).unwrap();
742
743        assert_eq!(decoded, body);
744    }
745
746    #[test]
747    fn round_trip_request() {
748        let h = hdr(
749            1234,
750            FrameType::Request,
751            Flags::new(false, Priority::Interactive, false),
752            42,
753            0xDEAD_BEEF_0000_0001,
754        );
755        let decoded = decode_header(&h.encode()).unwrap();
756        assert_eq!(h, decoded);
757    }
758
759    #[test]
760    fn round_trip_all_frame_types() {
761        for b in 0u8..=11 {
762            let ty = FrameType::from_u8(b).unwrap();
763            let h = hdr(0, ty, Flags::new(false, Priority::Passive, false), 0, 0);
764            assert_eq!(decode_header(&h.encode()).unwrap().ty, ty);
765        }
766    }
767
768    #[test]
769    fn pure_header_frame_has_zero_len() {
770        // CANCEL carries only header (len = 0) + the target corr.
771        let h = hdr(
772            0,
773            FrameType::Cancel,
774            Flags::new(false, Priority::Passive, false),
775            7,
776            99,
777        );
778        let d = decode_header(&h.encode()).unwrap();
779        assert_eq!(d.len, 0);
780        assert_eq!(d.corr, 99);
781    }
782
783    #[test]
784    fn flags_round_trip() {
785        let f = Flags::new(true, Priority::Background, true)
786            .with_admission_class(AdmissionClass::Expedite);
787        assert!(f.is_binary());
788        assert!(f.is_last());
789        assert_eq!(f.priority(), Some(Priority::Background));
790        assert_eq!(f.admission_class(), Some(AdmissionClass::Expedite));
791        let h = hdr(8, FrameType::StreamData, f, 1, 1);
792        assert_eq!(decode_header(&h.encode()).unwrap().flags, f);
793    }
794
795    #[test]
796    fn daemon_origin_flags_decode_and_round_trip() {
797        let old = hdr(0, FrameType::Error, Flags(0), 7, 1);
798        let old_decoded = decode_header(&old.encode()).unwrap();
799        assert!(!old_decoded.flags.is_daemon_origin());
800
801        let daemon = hdr(0, FrameType::Error, Flags(0).with_daemon_origin(), 7, 1);
802        let daemon_decoded = decode_header(&daemon.encode()).unwrap();
803        assert!(daemon_decoded.flags.is_daemon_origin());
804        assert_eq!(daemon_decoded.flags.without_daemon_origin(), Flags(0));
805        assert!(Flags(0).with_daemon_origin().is_daemon_origin());
806    }
807
808    #[test]
809    fn little_endian_and_frozen_prefix_layout() {
810        let h = hdr_with_epoch(
811            0x0403_0201,
812            FrameType::Request,
813            Flags(0),
814            0x0605,
815            0x0a09_0807,
816            0x1211_100f_0e0d_0c0b,
817        );
818        let buf = h.encode();
819        assert_eq!(&buf[0..4], &[1, 2, 3, 4]);
820        assert_eq!(buf[4], PROTOCOL_VERSION);
821        assert_eq!(&buf[7..9], &[5, 6]);
822        assert_eq!(&buf[9..13], &[7, 8, 9, 10]);
823        assert_eq!(&buf[13..21], &[11, 12, 13, 14, 15, 16, 17, 18]);
824        assert_eq!(buf.len(), HEADER_LEN);
825    }
826
827    #[test]
828    fn reject_too_short_for_prefix() {
829        assert_eq!(
830            decode_header(&[0, 0, 0, 0]),
831            Err(DecodeError::TooShortForPrefix { have: 4 })
832        );
833    }
834
835    #[test]
836    fn reject_too_short_for_header() {
837        // Valid 5-byte prefix but the v2 header is truncated.
838        let mut b = [0u8; 10];
839        b[4] = PROTOCOL_VERSION;
840        assert_eq!(
841            decode_header(&b),
842            Err(DecodeError::TooShortForHeader {
843                have: 10,
844                need: HEADER_LEN
845            })
846        );
847    }
848
849    #[test]
850    fn reject_unsupported_version() {
851        let mut b = [0u8; HEADER_LEN];
852        b[4] = 1;
853        assert_eq!(
854            decode_header(&b),
855            Err(DecodeError::UnsupportedVersion { ver: 1 })
856        );
857    }
858
859    #[test]
860    fn reject_unknown_frame_type() {
861        let mut b = [0u8; HEADER_LEN];
862        b[4] = PROTOCOL_VERSION;
863        b[5] = 99;
864        assert_eq!(
865            decode_header(&b),
866            Err(DecodeError::UnknownFrameType { byte: 99 })
867        );
868    }
869
870    #[test]
871    fn reject_reserved_flag_bits() {
872        let mut b = [0u8; HEADER_LEN];
873        b[4] = PROTOCOL_VERSION;
874        b[5] = FrameType::Request as u8;
875        b[6] = 0b1000_0000; // reserved bit 7 set
876        assert_eq!(
877            decode_header(&b),
878            Err(DecodeError::ReservedFlagBits { flags: 0b1000_0000 })
879        );
880    }
881
882    #[test]
883    fn reject_reserved_priority_bits() {
884        let mut b = [0u8; HEADER_LEN];
885        b[4] = PROTOCOL_VERSION;
886        b[5] = FrameType::Request as u8;
887        b[6] = 0b0000_0110; // priority bits 1-2 are reserved value 0b11
888        assert_eq!(
889            decode_header(&b),
890            Err(DecodeError::ReservedPriorityBits { flags: 0b0000_0110 })
891        );
892    }
893
894    #[test]
895    fn reject_pure_header_frame_with_body_len() {
896        let h = hdr(
897            1,
898            FrameType::Ping,
899            Flags::new(false, Priority::Passive, false),
900            0,
901            1,
902        );
903        assert_eq!(
904            decode_header(&h.encode()),
905            Err(DecodeError::PureHeaderFrameWithBody {
906                ty: FrameType::Ping,
907                len: 1
908            })
909        );
910    }
911
912    #[test]
913    fn epoch_boundaries_round_trip() {
914        for (channel, epoch) in [(0, 0), (1, 1), (u16::MAX, u32::MAX)] {
915            let h = hdr_with_epoch(
916                0,
917                FrameType::Request,
918                Flags::new(false, Priority::Passive, false),
919                channel,
920                epoch,
921                9,
922            );
923            assert_eq!(decode_header(&h.encode()).unwrap(), h);
924        }
925    }
926
927    #[test]
928    fn admission_classes_accept_three_values_and_reject_reserved_value() {
929        for (ty, admission_class) in [
930            (FrameType::Request, AdmissionClass::Normal),
931            (FrameType::Request, AdmissionClass::Expedite),
932            (FrameType::Push, AdmissionClass::Sheddable),
933            (FrameType::StreamData, AdmissionClass::Sheddable),
934        ] {
935            let flags = Flags::new(false, Priority::Interactive, false)
936                .with_admission_class(admission_class);
937            let h = hdr(0, ty, flags, 1, 2);
938            assert_eq!(decode_header(&h.encode()).unwrap().flags, flags);
939        }
940
941        let mut h = hdr(
942            0,
943            FrameType::Push,
944            Flags::new(false, Priority::Passive, false),
945            1,
946            2,
947        )
948        .encode();
949        h[6] |= 0b0011_0000;
950        assert_eq!(
951            decode_header(&h),
952            Err(DecodeError::ReservedAdmissionClass { flags: h[6] })
953        );
954    }
955
956    #[test]
957    fn sheddable_rejected_on_every_illegal_frame_type() {
958        let flags = Flags::new(false, Priority::Passive, false)
959            .with_admission_class(AdmissionClass::Sheddable);
960        for ty in [
961            FrameType::Request,
962            FrameType::Response,
963            FrameType::StreamEnd,
964            FrameType::Error,
965            FrameType::Cancel,
966            FrameType::Ping,
967            FrameType::Pong,
968            FrameType::Hello,
969            FrameType::HelloAck,
970            FrameType::Goodbye,
971        ] {
972            let h = hdr(0, ty, flags, 1, 2);
973            assert_eq!(
974                decode_header(&h.encode()),
975                Err(DecodeError::SheddableIllegalFrameType { ty, flags: flags.0 })
976            );
977        }
978    }
979
980    #[test]
981    fn nonzero_epoch_on_control_channel_is_rejected() {
982        let h = hdr_with_epoch(
983            0,
984            FrameType::Request,
985            Flags::new(false, Priority::Passive, false),
986            0,
987            u32::MAX,
988            2,
989        );
990        assert_eq!(
991            decode_header(&h.encode()),
992            Err(DecodeError::NonzeroEpochOnControlChannel { epoch: u32::MAX })
993        );
994    }
995}