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