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