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