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