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