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