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