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