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