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