Skip to main content

subc_control/
lib.rs

1//! Client-facing subc channel-0 control wire shapes.
2//!
3//! This crate is the client ↔ subc control-plane boundary. It depends only on
4//! [`subc-protocol`] for shared primitives such as `RouteTarget` and
5//! `BindIdentity`; clients can use it without depending on the
6//! daemon implementation.
7
8#![forbid(unsafe_code)]
9
10use std::path::PathBuf;
11
12use serde::{
13    de::{Error as _, MapAccess, SeqAccess, Visitor},
14    ser::SerializeMap,
15    Deserialize, Deserializer, Serialize, Serializer,
16};
17use subc_protocol::{
18    manifest::{CapabilityDeclarations, ManifestProvenance, ProviderRole, SelfSignalDeclaration},
19    session::HealthStatus,
20    BindIdentity, RouteTarget,
21};
22
23pub use subc_protocol::RouteCloseReason;
24
25macro_rules! open_string_enum {
26    (
27        $(#[$meta:meta])*
28        $name:ident {
29            $( $variant:ident => $wire_name:literal ),+ $(,)?
30        }
31    ) => {
32        $(#[$meta])*
33        #[derive(Debug, Clone, PartialEq, Eq)]
34        pub enum $name {
35            $( $variant, )+
36            Unknown(String),
37        }
38
39        impl $name {
40            fn wire_name(&self) -> &str {
41                match self {
42                    $( Self::$variant => $wire_name, )+
43                    Self::Unknown(value) => value,
44                }
45            }
46        }
47
48        impl Serialize for $name {
49            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
50            where
51                S: serde::Serializer,
52            {
53                serializer.serialize_str(self.wire_name())
54            }
55        }
56
57        impl<'de> Deserialize<'de> for $name {
58            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
59            where
60                D: serde::Deserializer<'de>,
61            {
62                let value = String::deserialize(deserializer)?;
63                Ok(match value.as_str() {
64                    $( $wire_name => Self::$variant, )+
65                    _ => Self::Unknown(value),
66                })
67            }
68        }
69    };
70}
71
72/// Daemon-spawned consumer identity presented on route.open.
73#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
74pub struct ConsumerIdentity {
75    pub module_id: String,
76    pub launch_nonce: String,
77}
78
79// Hand-written so the launch nonce is never printed. The nonce is the credential
80// that attributes a connection to a supervised module, and a derived Debug would
81// write it into any log line or panic message that formats this value. Same
82// reasoning as ConnectionInfo's Debug in subc-transport.
83impl std::fmt::Debug for ConsumerIdentity {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_struct("ConsumerIdentity")
86            .field("module_id", &self.module_id)
87            .field(
88                "launch_nonce",
89                &format_args!("<{} bytes redacted>", self.launch_nonce.len()),
90            )
91            .finish()
92    }
93}
94
95/// Reserved dotted operation prefixes for the v0.4 control vocabulary.
96///
97/// `scheduler.` and `watch.` were reserved here from v0.4 until 2026-08-10 and
98/// were removed deliberately rather than left as placeholders: neither was ever
99/// implemented, and both capabilities are now owned elsewhere by ruling --
100/// scheduled tasks belong to the session runtime (prefrontal) because the
101/// daemon is state-free routing, and external-event watching belongs to the
102/// connectors module (plexus). A reserved name for something that will never be
103/// built here reads as a roadmap commitment to anyone surveying the protocol,
104/// and it recruited exactly that misunderstanding from an outside contributor.
105pub mod ops {
106    pub const SERVER: &str = "server.";
107    pub const CATALOG: &str = "catalog.";
108    pub const ROUTE: &str = "route.";
109    pub const SUPERVISOR: &str = "supervisor.";
110    pub const CONFIG: &str = "config.";
111
112    pub const SERVER_DESCRIBE: &str = "server.describe";
113    pub const CATALOG_LIST: &str = "catalog.list";
114    pub const ROUTE_OPEN: &str = "route.open";
115    pub const ROUTE_POLL: &str = "route.poll";
116    pub const ROUTE_CLOSING: &str = "route.closing";
117    pub const ROUTE_CLOSED: &str = "route.closed";
118    pub const SUPERVISOR_LIST: &str = "supervisor.list";
119    pub const SUPERVISOR_RESTART: &str = "supervisor.restart";
120    pub const SUPERVISOR_SWAP: &str = "supervisor.swap";
121    pub const SUPERVISOR_RELOAD: &str = "supervisor.reload";
122    pub const SUPERVISOR_RESCAN: &str = "supervisor.rescan";
123    pub const SUPERVISOR_RELEASE_RESERVED: &str = "supervisor.release_reserved";
124    pub const SUPERVISOR_SET_ENABLED: &str = "supervisor.set_enabled";
125    pub const SUPERVISOR_HEALTH_PROBE: &str = "supervisor.health_probe";
126    pub const SUPERVISOR_HEALTH: &str = "supervisor.health";
127    pub const SUPERVISOR_STDERR_TAIL: &str = "supervisor.stderr_tail";
128    pub const SUPERVISOR_TERMINALS: &str = "supervisor.terminals";
129    pub const SUPERVISOR_ROUTES: &str = "supervisor.routes";
130    pub const SUPERVISOR_PROVENANCE: &str = "supervisor.provenance";
131    pub const SUPERVISOR_SPAWN_SNAPSHOT: &str = "supervisor.spawn_snapshot";
132    pub const SUPERVISOR_SPAWN_SUBSCRIBE: &str = "supervisor.spawn_subscribe";
133}
134
135/// Client-originated channel-0 control RPC body.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137#[serde(tag = "op")]
138// RouteOpen carries the complete route metadata, while several control operations
139// are markers; retain the direct public wire shape instead of boxing its fields.
140#[allow(clippy::large_enum_variant)]
141pub enum ClientControlRequest {
142    #[serde(rename = "server.describe")]
143    ServerDescribe {},
144    #[serde(rename = "catalog.list")]
145    CatalogList {
146        /// Absent lists every registered module; present narrows to one. A
147        /// narrowed list for an unregistered id is an empty list rather than an
148        /// error, so absent and unregistered are distinguishable only by which
149        /// question you asked.
150        #[serde(default)]
151        module_id: Option<String>,
152    },
153    #[serde(rename = "route.open")]
154    RouteOpen {
155        target: RouteTarget,
156        identity: BindIdentity,
157        /// The consumer's claim to a supervised launch, which the daemon verifies
158        /// against its live spawn nonces before stamping a principal.
159        ///
160        /// Absent is a legitimate shape, not an omission: a direct key-holder has
161        /// no launch nonce to present, and the daemon stamps `Direct`. So absence
162        /// means NO CLAIM WAS MADE, never that a claim was refused — a refused
163        /// claim is an error frame and the route never opens. A provider deciding
164        /// what to trust reads the stamped principal on the bind, not this.
165        #[serde(default, skip_serializing_if = "Option::is_none")]
166        consumer_identity: Option<ConsumerIdentity>,
167        /// Consumer-declared reverse-request capabilities for the route. This is
168        /// an unverified declaration, not a privilege grant; if a consumer
169        /// over-declares, providers may still send reverse requests that later
170        /// time out or deny. Providers must treat an absent field as no
171        /// reverse-request capability. The vocabulary is open strings; known MCP
172        /// method-family values today are "elicitation", "sampling", and
173        /// "roots".
174        #[serde(default, skip_serializing_if = "Option::is_none")]
175        consumer_capabilities: Option<Vec<String>>,
176        /// Opaque admission facts supplied by the configured carrier module.
177        #[serde(default, skip_serializing_if = "Option::is_none")]
178        admission_facts: Option<serde_json::Value>,
179    },
180    #[serde(rename = "route.poll")]
181    RoutePoll {
182        route_channel: u16,
183        route_epoch: u32,
184        kind: PollKind,
185    },
186    #[serde(rename = "supervisor.list")]
187    SupervisorList {},
188    /// Read the live supervised processes and the event cursor atomically.
189    #[serde(rename = "supervisor.spawn_snapshot")]
190    SupervisorSpawnSnapshot {},
191    /// Replay spawn events after `since`, then remain open for live events.
192    ///
193    /// The cursor is one value copied from a snapshot or event. It includes the
194    /// daemon incarnation so a restarted daemon rejects an earlier instance's
195    /// sequence instead of treating it as a position in the current stream.
196    #[serde(rename = "supervisor.spawn_subscribe")]
197    SupervisorSpawnSubscribe {
198        #[serde(default, skip_serializing_if = "Option::is_none")]
199        since: Option<SpawnCursor>,
200    },
201    #[serde(rename = "supervisor.restart")]
202    SupervisorRestart {
203        module_id: String,
204        /// Optional per-restart override of the module's drain budget, in ms.
205        /// Absent: the module's configured `drain_timeout_ms` (or the daemon
206        /// default) applies. `0` tears down without waiting — the wedge-bounce
207        /// escape, where a stuck in-flight request would never settle anyway.
208        /// Additive; older daemons that predate this field reject unknown
209        /// fields on channel-0 requests, so senders must omit it unless asked
210        /// for (the CLI only sends it when a flag is passed).
211        #[serde(default, skip_serializing_if = "Option::is_none")]
212        drain_timeout_ms: Option<u64>,
213    },
214    /// Blue/green restart: start a replacement beside the running process, keep
215    /// routing to the running one until the replacement declares itself ready,
216    /// then move new routes over and drain the old process.
217    ///
218    /// Refused before anything is spawned unless the module's daemon config
219    /// declares `overlap: "safe"`: two processes on one single-writer store is
220    /// a data hazard, so the default is exclusive. Answered once the swap has
221    /// either cut over (the old process is still draining) or failed; a failure
222    /// leaves the old process serving and undrained, and names the arm in
223    /// `ErrorBody.detail`.
224    ///
225    /// A new op rather than a flag on `supervisor.restart`: a daemon that
226    /// predates swap rejects an unknown op, whereas an unknown field could be
227    /// dropped and turned into a plain restart.
228    #[serde(rename = "supervisor.swap")]
229    SupervisorSwap {
230        module_id: String,
231        /// How long the replacement may take to register and declare itself
232        /// ready before the swap is abandoned. Absent: the daemon default.
233        #[serde(default, skip_serializing_if = "Option::is_none")]
234        ready_timeout_ms: Option<u64>,
235    },
236    #[serde(rename = "supervisor.reload")]
237    SupervisorReload { module_id: String },
238    #[serde(rename = "supervisor.rescan")]
239    SupervisorRescan {
240        /// Compute the reconciliation and return it WITHOUT applying it.
241        ///
242        /// Rescan retires any supervised module absent from the config, which
243        /// stops live processes. Both halves of that decision are inspectable in
244        /// advance -- the config is a file, the running set is `supervisor.list`
245        /// -- but nothing reconstructs the diff for the operator, so it is read
246        /// from the result table AFTER the retires have happened.
247        ///
248        /// A preview must be computed daemon-side rather than by a client, because
249        /// a client would have to locate the daemon's config itself: two rules
250        /// selecting one subject, agreeing until someone runs a daemon with a
251        /// non-default config. A preview that can describe a different file than
252        /// the operation reads is worse than none, because it is believed.
253        ///
254        /// Defaults to false so an existing client sending `{}` still executes,
255        /// and is OMITTED when false so the bytes an existing client sends are
256        /// unchanged. Serialising `preview:false` would have altered the request's
257        /// wire form for every caller that never asked for a preview -- caught by
258        /// the golden fixture, which is the whole reason that pin exists.
259        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
260        preview: bool,
261    },
262    /// Retire the retained exact-id reservation after its configuration entry has
263    /// been removed. This is intentionally separate from rescan so deleting
264    /// configuration never silently opens a protected module id to registration.
265    #[serde(rename = "supervisor.release_reserved")]
266    SupervisorReleaseReserved { module_id: String },
267    #[serde(rename = "supervisor.set_enabled")]
268    SupervisorSetEnabled { module_id: String, enabled: bool },
269    #[serde(rename = "supervisor.health_probe")]
270    SupervisorHealthProbe { module_id: String },
271    #[serde(rename = "supervisor.health")]
272    SupervisorHealth {},
273    /// Enumerate the routes currently served by one supervised module, or every
274    /// module when omitted.
275    ///
276    /// This privileged census is control-plane-only. It is deliberately not an
277    /// MCP facade or agent-tool operation: callers holding the daemon control
278    /// connection may inspect live route ownership, while agent-facing modules
279    /// must not be able to address that surface at all.
280    ///
281    /// The daemon answers from its forwarding table under a read lock and never
282    /// consults a module. That makes the read safe during a drain, when a module
283    /// cannot be queried without recreating the hang/restart hazard that route
284    /// status reads avoid.
285    #[serde(rename = "supervisor.routes")]
286    SupervisorRoutes {
287        #[serde(default, skip_serializing_if = "Option::is_none")]
288        module_id: Option<String>,
289    },
290    /// Report source-tagged provenance for supervised modules, optionally narrowed
291    /// to one module.
292    #[serde(rename = "supervisor.provenance")]
293    SupervisorProvenance {
294        #[serde(default, skip_serializing_if = "Option::is_none")]
295        module_id: Option<String>,
296    },
297    /// Retained stderr for one module.
298    ///
299    /// A separate op rather than a field on `supervisor.list`: the tail is
300    /// kilobytes per module and `list` renders every module, so carrying it in
301    /// the snapshot would charge every status read for a payload almost no
302    /// caller wants. Caps ride on the REQUEST so a caller wanting twenty lines
303    /// and one wanting the whole ring need no separate fields anywhere.
304    #[serde(rename = "supervisor.stderr_tail")]
305    SupervisorStderrTail {
306        module_id: String,
307        #[serde(default, skip_serializing_if = "Option::is_none")]
308        max_lines: Option<u32>,
309        #[serde(default, skip_serializing_if = "Option::is_none")]
310        max_bytes: Option<u32>,
311    },
312    /// Retained terminal exits for one module.
313    ///
314    /// This stays separate from `supervisor.list`: a history grows with every
315    /// incident, while the list is a current-state read most callers issue often.
316    ///
317    /// The read MUST stay off the supervisor command channel — it reads the
318    /// module's shared ring directly. This is a requirement, not an
319    /// optimisation: when the supervision task itself dies, every
320    /// command-channel op returns `CommandClosed`, and that is precisely the
321    /// moment an operator needs the exit history most. A history reachable only
322    /// through the machinery whose death you are diagnosing is unreachable when
323    /// it matters. Proven failure mode, not a hypothetical.
324    #[serde(rename = "supervisor.terminals")]
325    SupervisorTerminals { module_id: String },
326}
327
328/// subc's channel-0 response body for client control RPCs.
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
330#[serde(tag = "op")]
331pub enum ClientControlResponse {
332    #[serde(rename = "server.describe")]
333    ServerDescribe {
334        protocol_ver: u8,
335        subc_ops: Vec<String>,
336        capabilities: Vec<String>,
337        connected_clients: u64,
338        #[serde(default, skip_serializing_if = "Option::is_none")]
339        counters: Option<serde_json::Value>,
340        /// Git commit the daemon was built from, or "unavailable" when the
341        /// build could not read it. The crate version cannot discriminate a
342        /// skewed daemon/CLI pair (it moves per release, not per commit), so
343        /// this is the identity a consumer compares against its own embedded
344        /// commit to detect that it is talking to an older build than it was
345        /// compiled with. Absent from daemons predating the field.
346        #[serde(default, skip_serializing_if = "Option::is_none")]
347        build_git_sha: Option<String>,
348        /// sha256 of the workspace Cargo.lock at build time, or "unavailable".
349        /// Answers "which dependency set" where the commit answers "which
350        /// source"; a commit match with a digest mismatch means a rebuild
351        /// against edited dependencies. Absent from daemons predating the
352        /// field.
353        #[serde(default, skip_serializing_if = "Option::is_none")]
354        build_lock_digest: Option<String>,
355        /// Daemon-evaluated capability requirements. Present when the configured
356        /// fleet has declarations to evaluate, so operators can inspect an absent
357        /// required capability without parsing daemon logs.
358        #[serde(default, skip_serializing_if = "Vec::is_empty")]
359        capability_requirements: Vec<CapabilityRequirementStatus>,
360        /// The daemon's machine id (`subc_protocol::MachineId`), the same value
361        /// every module receives on `HELLO_ACK`. A name for this machine, never
362        /// an authority: nothing may grant trust because two parties report the
363        /// same value. Absent from daemons predating the field.
364        #[serde(default, skip_serializing_if = "Option::is_none")]
365        machine_id: Option<String>,
366    },
367    #[serde(rename = "catalog.list")]
368    CatalogList {
369        generation: u64,
370        modules: Vec<CatalogEntry>,
371        subc_ops: Vec<String>,
372    },
373    #[serde(rename = "route.open")]
374    RouteOpen {
375        route_channel: u16,
376        route_epoch: u32,
377    },
378    #[serde(rename = "route.poll")]
379    RoutePoll {
380        route_channel: u16,
381        route_epoch: u32,
382        status: Option<String>,
383        live: Option<bool>,
384    },
385    #[serde(rename = "supervisor.list")]
386    SupervisorList {
387        generation: u64,
388        modules: Vec<SupervisorEntry>,
389    },
390    #[serde(rename = "supervisor.spawn_snapshot")]
391    SupervisorSpawnSnapshot {
392        #[serde(flatten)]
393        snapshot: SpawnSnapshot,
394    },
395    #[serde(rename = "supervisor.ack")]
396    SupervisorAck { module_id: String, applied: bool },
397    #[serde(rename = "supervisor.rescan")]
398    SupervisorRescan {
399        #[serde(flatten)]
400        result: SupervisorRescanResult,
401    },
402    #[serde(rename = "supervisor.health_probe")]
403    SupervisorHealthProbe {
404        module_id: String,
405        status: HealthStatus,
406        #[serde(default, skip_serializing_if = "Option::is_none")]
407        detail: Option<String>,
408        #[serde(default, skip_serializing_if = "Option::is_none")]
409        metrics: Option<serde_json::Value>,
410    },
411    #[serde(rename = "supervisor.health")]
412    SupervisorHealth {
413        generation: u64,
414        modules: Vec<SupervisorHealthEntry>,
415    },
416    #[serde(rename = "supervisor.routes")]
417    SupervisorRoutes { modules: Vec<SupervisorRouteModule> },
418    #[serde(rename = "supervisor.provenance")]
419    SupervisorProvenance {
420        daemon: SupervisorDaemonProvenance,
421        modules: Vec<SupervisorModuleProvenance>,
422    },
423    #[serde(rename = "supervisor.stderr_tail")]
424    SupervisorStderrTail {
425        module_id: String,
426        #[serde(flatten)]
427        tail: StderrTail,
428    },
429    #[serde(rename = "supervisor.terminals")]
430    SupervisorTerminals {
431        module_id: String,
432        #[serde(flatten)]
433        terminals: TerminalHistory,
434    },
435}
436
437/// Daemon-originated channel-0 control push body.
438///
439/// A module cannot originate these pushes: subc creates them from its own
440/// forwarding state and enqueues them directly to client connection sinks.
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
442#[serde(tag = "op")]
443pub enum ClientControlPush {
444    #[serde(rename = "route.closing")]
445    RouteClosing {
446        module_id: String,
447        reason: RouteCloseReason,
448    },
449    #[serde(rename = "route.closed")]
450    RouteClosed {
451        module_id: String,
452        reason: RouteCloseReason,
453        /// The exact result of the forwarding-quiescence wait for live routes.
454        drained: bool,
455        /// Pending route.bind relays forced down before that wait. They are not
456        /// covered by `drained`, even when live routes quiesced.
457        abandoned: u32,
458        /// Subscription credits captured and excluded from this drain's wire predicate.
459        #[serde(default)]
460        excluded_subscriptions: u32,
461        /// Whether subc will leave this module down until operator action.
462        ///
463        /// The claim covers daemon-owned recovery only. `None` is accepted only
464        /// from daemons that predate this field; every current daemon emission is
465        /// `Some`.
466        #[serde(default, skip_serializing_if = "Option::is_none")]
467        terminal: Option<bool>,
468    },
469}
470
471/// A daemon-incarnation-scoped position in the supervised spawn event stream.
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
473pub struct SpawnCursor {
474    pub daemon_incarnation: String,
475    pub seq: u64,
476}
477
478/// One process present in an atomic supervisor spawn snapshot.
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
480pub struct LiveSpawn {
481    pub module_id: String,
482    pub spawn_generation: u64,
483    pub pid: u32,
484    pub spawned_at_ms: u64,
485}
486
487/// Atomic live-process census and the cursor at which it was observed.
488#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
489pub struct SpawnSnapshot {
490    pub cursor: SpawnCursor,
491    /// Maximum retained event count for this daemon.
492    pub ring_bound: u64,
493    pub live: Vec<LiveSpawn>,
494}
495
496/// Fact observed by the supervisor when a child process starts or exits.
497#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
498#[serde(rename_all = "snake_case")]
499pub enum SpawnEventKind {
500    Spawned,
501    Exited,
502}
503
504/// One retained or live spawn event.
505///
506/// Exit events intentionally carry no disposition or reason because exit
507/// classification is recorded separately; credential consumers revoke on every
508/// exit regardless of the cause.
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
510pub struct SpawnEvent {
511    pub cursor: SpawnCursor,
512    pub kind: SpawnEventKind,
513    pub module_id: String,
514    pub spawn_generation: u64,
515    pub pid: u32,
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub exit_code: Option<i32>,
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub exit_signal: Option<i32>,
520}
521
522/// A module's retained stderr, oldest entry first.
523#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
524pub struct StderrTail {
525    pub capture: StderrCaptureState,
526    pub entries: Vec<StderrTailEntry>,
527    /// Lines not present above: evicted by the ring, or held back by this
528    /// request's own caps.
529    ///
530    /// Non-zero means the first entry is not the first line the module wrote. A
531    /// reader hunting a cause needs that, or an absent explanation reads as a
532    /// module that never gave one.
533    ///
534    /// Zero is skipped so the common complete-tail case stays compact.
535    #[serde(default, skip_serializing_if = "is_zero_u64")]
536    pub dropped_lines: u64,
537}
538
539/// Live routes served by one module.
540#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
541pub struct SupervisorRouteModule {
542    pub module_id: String,
543    pub routes: Vec<SupervisorRoute>,
544}
545
546/// One live consumer route in a [`SupervisorRouteModule`].
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
548pub struct SupervisorRoute {
549    pub consumer: SupervisorRouteConsumer,
550    /// Milliseconds since the daemon bound this route.
551    pub age_ms: u64,
552    /// True once the endpoint began draining. Draining routes remain visible so
553    /// a census does not misreport an already-closing route as live.
554    pub draining: bool,
555    /// WHY the endpoint is draining — the same reason vocabulary the
556    /// route.closing push carries — present exactly when `draining` is true.
557    /// Additive: older daemons omit it, and a census consumer must treat a
558    /// draining route without a reason as draining-for-an-unstated-reason,
559    /// never as not-draining.
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub drain_reason: Option<RouteCloseReason>,
562}
563
564/// Source-tagged provenance for one supervised module.
565#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
566pub struct SupervisorModuleProvenance {
567    pub module_id: String,
568    pub module_declared: ModuleDeclaredProvenance,
569    pub daemon_observed: SupervisorObservedProcess,
570}
571
572/// A module's declared build metadata, if its HELLO manifest carried it.
573#[derive(Debug, Clone, PartialEq)]
574pub enum ModuleDeclaredProvenance {
575    Reported {
576        build: ManifestProvenance,
577    },
578    Unverifiable,
579    /// Future discriminator. `body` retains the complete ordered object; `tag`
580    /// is its decoded discriminator projection.
581    Unknown {
582        tag: String,
583        body: OrderedJsonObject,
584    },
585}
586
587/// Process facts observed by the daemon for a supervised module.
588///
589/// Build claims remain under `module_declared`; mixing them here would imply the
590/// daemon independently observed module-provided metadata.
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
592pub struct SupervisorObservedProcess {
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub pid: Option<u32>,
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub spawned_at_ms: Option<u64>,
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub spawned_from: Option<PathBuf>,
599    pub running_image: RunningImageAgreement,
600}
601
602/// Daemon provenance paired with its runtime process observation.
603#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
604pub struct SupervisorDaemonProvenance {
605    pub daemon_build: DaemonBuildProvenance,
606    pub daemon_observed: DaemonObservedProcess,
607}
608
609/// Build metadata embedded in the daemon binary.
610#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
611pub struct DaemonBuildProvenance {
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub build_git_sha: Option<String>,
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub build_lock_digest: Option<String>,
616}
617
618/// Runtime process facts observed for the daemon itself.
619#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
620pub struct DaemonObservedProcess {
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub pid: Option<u32>,
623    /// Wall time derived from suspend-inclusive elapsed time at each read. Clock
624    /// correction can move it by the size of a clock step, and even without a
625    /// step it may vary by about a second between reads. Do not equality-compare
626    /// it. Use raw process start ticks for stable identity.
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub started_at_ms: Option<u64>,
629    pub running_image: RunningImageAgreement,
630}
631
632/// Whether the executable currently running agrees with the spawned image.
633#[derive(Debug, Clone, PartialEq)]
634pub enum RunningImageAgreement {
635    Match {
636        evidence: RunningImageEvidence,
637    },
638    Mismatch {
639        running: RunningImageEvidence,
640        disk: RunningImageEvidence,
641    },
642    Unavailable {
643        reason: RunningImageUnavailableReason,
644    },
645    /// Future discriminator. `body` retains the complete ordered object; `tag`
646    /// is its decoded discriminator projection.
647    Unknown {
648        tag: String,
649        body: OrderedJsonObject,
650    },
651}
652
653/// Platform-specific evidence used to compare a running image with its spawn path.
654#[derive(Debug, Clone, PartialEq)]
655pub enum RunningImageEvidence {
656    LinuxProcSha256 {
657        digest: String,
658    },
659    MacosSpawnInode {
660        device: u64,
661        inode: u64,
662    },
663    /// Future discriminator. `body` retains the complete ordered object; `tag`
664    /// is its decoded discriminator projection.
665    Unknown {
666        tag: String,
667        body: OrderedJsonObject,
668    },
669}
670
671open_string_enum! {
672    /// Reasons why an executable identity could not be observed.
673    RunningImageUnavailableReason {
674        NotRunning => "not_running",
675        UnsupportedPlatform => "unsupported_platform",
676        RunningExecutableUnreadable => "running_executable_unreadable",
677        SpawnedPathUnreadable => "spawned_path_unreadable",
678        HashFailed => "hash_failed",
679        ProcessIdentityUnconfirmed => "process_identity_unconfirmed",
680    }
681}
682
683/// The identity tier the daemon can honestly report for a route consumer.
684///
685/// A caller that proved a live daemon-issued launch nonce is named `reserved`.
686/// A direct key-holder has no such attestation, so it is reported as `direct`
687/// with its connection counter instead of an invented module name.
688#[derive(Debug, Clone, PartialEq)]
689pub enum SupervisorRouteConsumer {
690    Reserved {
691        module_id: String,
692    },
693    Direct {
694        connection_id: u64,
695    },
696    /// Future discriminator. `body` retains the complete ordered object; `tag`
697    /// is its decoded discriminator projection.
698    Unknown {
699        tag: String,
700        body: OrderedJsonObject,
701    },
702}
703
704/// Whether stderr is being captured for a module, and if not, why not.
705///
706/// A typed state rather than an empty-tail convention. "The module printed
707/// nothing before dying" and "nobody was capturing" send an operator in opposite
708/// directions, and rendering them alike is the defect this op exists to fix --
709/// the same shape as a `detail -` that means both no-detail and never-probed.
710#[derive(Debug, Clone, PartialEq)]
711pub enum StderrCaptureState {
712    /// A reader is attached, or was attached and saw clean EOF. An empty
713    /// `entries` under this state means the module genuinely wrote nothing.
714    Captured,
715    /// Retained entries are valid, but the stderr reader ended before clean EOF.
716    Incomplete { reason: String },
717    /// No reader was attached. `entries` says nothing about what the module wrote.
718    NotCaptured { reason: String },
719    /// Future discriminator. `body` retains the complete ordered object; `tag`
720    /// is its decoded discriminator projection.
721    Unknown {
722        tag: String,
723        body: OrderedJsonObject,
724    },
725}
726
727#[derive(Debug, Clone, PartialEq)]
728pub enum StderrTailEntry {
729    Line {
730        text: String,
731        /// The line was cut at the per-line cap and `text` is a prefix.
732        ///
733        /// Carried as a field rather than left to a marker in `text` so a
734        /// consumer can branch on it without string matching.
735        truncated: bool,
736    },
737    /// The supervisor spawned a new process. Entries after this came from it.
738    ///
739    /// In-band because position is the information: which side of the restart a
740    /// line falls on is unanswerable from a count.
741    ProcessStart,
742    /// Future discriminator. `body` retains the complete ordered object; `tag`
743    /// is its decoded discriminator projection.
744    Unknown {
745        tag: String,
746        body: OrderedJsonObject,
747    },
748}
749
750#[derive(Debug, Serialize, Deserialize)]
751#[serde(tag = "status", rename_all = "snake_case")]
752enum ModuleDeclaredProvenanceWire {
753    Reported { build: ManifestProvenance },
754    Unverifiable,
755}
756
757#[derive(Debug, Serialize, Deserialize)]
758#[serde(tag = "status", rename_all = "snake_case")]
759enum RunningImageAgreementWire {
760    Match {
761        evidence: RunningImageEvidence,
762    },
763    Mismatch {
764        running: RunningImageEvidence,
765        disk: RunningImageEvidence,
766    },
767    Unavailable {
768        reason: RunningImageUnavailableReason,
769    },
770}
771
772#[derive(Debug, Serialize, Deserialize)]
773#[serde(tag = "method", rename_all = "snake_case")]
774enum RunningImageEvidenceWire {
775    LinuxProcSha256 { digest: String },
776    MacosSpawnInode { device: u64, inode: u64 },
777}
778
779#[derive(Debug, Serialize, Deserialize)]
780#[serde(tag = "kind", rename_all = "snake_case")]
781enum SupervisorRouteConsumerWire {
782    Reserved { module_id: String },
783    Direct { connection_id: u64 },
784}
785
786#[derive(Debug, Serialize, Deserialize)]
787#[serde(tag = "state", rename_all = "snake_case")]
788enum StderrCaptureStateWire {
789    Captured,
790    Incomplete { reason: String },
791    NotCaptured { reason: String },
792}
793
794#[derive(Debug, Serialize, Deserialize)]
795#[serde(tag = "kind", rename_all = "snake_case")]
796enum StderrTailEntryWire {
797    Line {
798        text: String,
799        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
800        truncated: bool,
801    },
802    ProcessStart,
803}
804
805/// JSON values whose object members retain wire order at every depth.
806#[derive(Debug, Clone, PartialEq)]
807pub enum OrderedJsonValue {
808    Null,
809    Bool(bool),
810    Number(serde_json::Number),
811    String(String),
812    Array(Vec<Self>),
813    Object(OrderedJsonObject),
814}
815
816/// Ordered JSON members retained for an unknown tagged value.
817#[derive(Debug, Clone, PartialEq)]
818pub struct OrderedJsonObject(Vec<(String, OrderedJsonValue)>);
819
820impl OrderedJsonObject {
821    /// Returns the members in the order they appeared on the wire.
822    pub fn as_entries(&self) -> &[(String, OrderedJsonValue)] {
823        &self.0
824    }
825
826    fn into_value(self) -> serde_json::Value {
827        serde_json::Value::Object(
828            self.0
829                .into_iter()
830                .map(|(key, value)| (key, value.into_value()))
831                .collect(),
832        )
833    }
834}
835
836impl OrderedJsonValue {
837    fn into_value(self) -> serde_json::Value {
838        match self {
839            Self::Null => serde_json::Value::Null,
840            Self::Bool(value) => serde_json::Value::Bool(value),
841            Self::Number(value) => serde_json::Value::Number(value),
842            Self::String(value) => serde_json::Value::String(value),
843            Self::Array(values) => {
844                serde_json::Value::Array(values.into_iter().map(Self::into_value).collect())
845            }
846            Self::Object(value) => value.into_value(),
847        }
848    }
849}
850
851impl Serialize for OrderedJsonValue {
852    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
853    where
854        S: Serializer,
855    {
856        match self {
857            Self::Null => serializer.serialize_unit(),
858            Self::Bool(value) => serializer.serialize_bool(*value),
859            Self::Number(value) => value.serialize(serializer),
860            Self::String(value) => serializer.serialize_str(value),
861            Self::Array(values) => values.serialize(serializer),
862            Self::Object(value) => value.serialize(serializer),
863        }
864    }
865}
866
867impl<'de> Deserialize<'de> for OrderedJsonValue {
868    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
869    where
870        D: Deserializer<'de>,
871    {
872        struct OrderedValueVisitor;
873
874        impl<'de> Visitor<'de> for OrderedValueVisitor {
875            type Value = OrderedJsonValue;
876
877            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
878                formatter.write_str("a JSON value with ordered object members")
879            }
880
881            fn visit_unit<E>(self) -> Result<Self::Value, E>
882            where
883                E: serde::de::Error,
884            {
885                Ok(OrderedJsonValue::Null)
886            }
887
888            fn visit_none<E>(self) -> Result<Self::Value, E>
889            where
890                E: serde::de::Error,
891            {
892                Ok(OrderedJsonValue::Null)
893            }
894
895            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
896            where
897                D: Deserializer<'de>,
898            {
899                OrderedJsonValue::deserialize(deserializer)
900            }
901
902            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
903            where
904                E: serde::de::Error,
905            {
906                Ok(OrderedJsonValue::Bool(value))
907            }
908
909            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
910            where
911                E: serde::de::Error,
912            {
913                Ok(OrderedJsonValue::Number(value.into()))
914            }
915
916            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
917            where
918                E: serde::de::Error,
919            {
920                Ok(OrderedJsonValue::Number(value.into()))
921            }
922
923            fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
924            where
925                E: serde::de::Error,
926            {
927                serde_json::Number::from_f64(value)
928                    .map(OrderedJsonValue::Number)
929                    .ok_or_else(|| E::custom("non-finite JSON number"))
930            }
931
932            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
933            where
934                E: serde::de::Error,
935            {
936                Ok(OrderedJsonValue::String(value.to_owned()))
937            }
938
939            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
940            where
941                E: serde::de::Error,
942            {
943                Ok(OrderedJsonValue::String(value))
944            }
945
946            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
947            where
948                A: SeqAccess<'de>,
949            {
950                let mut values = Vec::new();
951                while let Some(value) = sequence.next_element()? {
952                    values.push(value);
953                }
954                Ok(OrderedJsonValue::Array(values))
955            }
956
957            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
958            where
959                A: MapAccess<'de>,
960            {
961                let mut entries = Vec::new();
962                while let Some((key, value)) = map.next_entry()? {
963                    entries.push((key, value));
964                }
965                Ok(OrderedJsonValue::Object(OrderedJsonObject(entries)))
966            }
967        }
968
969        deserializer.deserialize_any(OrderedValueVisitor)
970    }
971}
972
973impl Serialize for OrderedJsonObject {
974    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
975    where
976        S: Serializer,
977    {
978        let mut map = serializer.serialize_map(Some(self.0.len()))?;
979        for (key, value) in &self.0 {
980            map.serialize_entry(key, value)?;
981        }
982        map.end()
983    }
984}
985
986impl<'de> Deserialize<'de> for OrderedJsonObject {
987    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
988    where
989        D: Deserializer<'de>,
990    {
991        struct OrderedObjectVisitor;
992
993        impl<'de> Visitor<'de> for OrderedObjectVisitor {
994            type Value = OrderedJsonObject;
995
996            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
997                formatter.write_str("an object with ordered JSON members")
998            }
999
1000            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1001            where
1002                A: MapAccess<'de>,
1003            {
1004                let mut entries = Vec::new();
1005                while let Some((key, value)) = map.next_entry()? {
1006                    entries.push((key, value));
1007                }
1008                Ok(OrderedJsonObject(entries))
1009            }
1010        }
1011
1012        deserializer.deserialize_map(OrderedObjectVisitor)
1013    }
1014}
1015
1016fn read_tagged<'de, D>(
1017    deserializer: D,
1018    field: &'static str,
1019) -> Result<(String, OrderedJsonObject), D::Error>
1020where
1021    D: Deserializer<'de>,
1022{
1023    let body = OrderedJsonObject::deserialize(deserializer)?;
1024    let mut tag = None;
1025    for (key, value) in body.as_entries() {
1026        if key != field {
1027            continue;
1028        }
1029        if tag.is_some() {
1030            return Err(D::Error::custom(format!(
1031                "tagged object has duplicate `{field}` field"
1032            )));
1033        }
1034        let OrderedJsonValue::String(value) = value else {
1035            return Err(D::Error::custom(format!(
1036                "tagged object has no string `{field}` field"
1037            )));
1038        };
1039        tag = Some(value);
1040    }
1041    let Some(tag) = tag else {
1042        return Err(D::Error::custom(format!(
1043            "tagged object has no string `{field}` field"
1044        )));
1045    };
1046    Ok((tag.to_string(), body))
1047}
1048
1049fn read_ordered_tagged(
1050    value: OrderedJsonValue,
1051    field: &'static str,
1052) -> Result<(String, OrderedJsonObject), String> {
1053    let OrderedJsonValue::Object(body) = value else {
1054        return Err(format!("expected tagged object with `{field}` field"));
1055    };
1056    let mut tag = None;
1057    for (key, value) in body.as_entries() {
1058        if key != field {
1059            continue;
1060        }
1061        if tag.is_some() {
1062            return Err(format!("tagged object has duplicate `{field}` field"));
1063        }
1064        let OrderedJsonValue::String(value) = value else {
1065            return Err(format!("tagged object has no string `{field}` field"));
1066        };
1067        tag = Some(value);
1068    }
1069    let Some(tag) = tag else {
1070        return Err(format!("tagged object has no string `{field}` field"));
1071    };
1072    Ok((tag.to_string(), body))
1073}
1074
1075fn ordered_field<'a>(body: &'a OrderedJsonObject, field: &str) -> Option<&'a OrderedJsonValue> {
1076    body.as_entries()
1077        .iter()
1078        .find_map(|(key, value)| (key == field).then_some(value))
1079}
1080
1081fn ordered_string(body: &OrderedJsonObject, field: &str) -> Result<String, String> {
1082    match ordered_field(body, field) {
1083        Some(OrderedJsonValue::String(value)) => Ok(value.clone()),
1084        Some(_) => Err(format!("tagged object field `{field}` is not a string")),
1085        None => Err(format!("tagged object has no `{field}` field")),
1086    }
1087}
1088
1089fn decode_running_image_evidence(value: OrderedJsonValue) -> Result<RunningImageEvidence, String> {
1090    let (tag, body) = read_ordered_tagged(value, "method")?;
1091    match tag.as_str() {
1092        "linux_proc_sha256" => Ok(RunningImageEvidence::LinuxProcSha256 {
1093            digest: ordered_string(&body, "digest")?,
1094        }),
1095        "macos_spawn_inode" => {
1096            let device = ordered_field(&body, "device")
1097                .and_then(|value| match value {
1098                    OrderedJsonValue::Number(number) => number.as_u64(),
1099                    _ => None,
1100                })
1101                .ok_or_else(|| "tagged object has no unsigned `device` field".to_string())?;
1102            let inode = ordered_field(&body, "inode")
1103                .and_then(|value| match value {
1104                    OrderedJsonValue::Number(number) => number.as_u64(),
1105                    _ => None,
1106                })
1107                .ok_or_else(|| "tagged object has no unsigned `inode` field".to_string())?;
1108            Ok(RunningImageEvidence::MacosSpawnInode { device, inode })
1109        }
1110        _ => Ok(RunningImageEvidence::Unknown { tag, body }),
1111    }
1112}
1113
1114impl Serialize for ModuleDeclaredProvenance {
1115    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1116    where
1117        S: Serializer,
1118    {
1119        match self {
1120            Self::Reported { build } => ModuleDeclaredProvenanceWire::Reported {
1121                build: build.clone(),
1122            }
1123            .serialize(serializer),
1124            Self::Unverifiable => ModuleDeclaredProvenanceWire::Unverifiable.serialize(serializer),
1125            Self::Unknown { body, .. } => body.serialize(serializer),
1126        }
1127    }
1128}
1129
1130impl<'de> Deserialize<'de> for ModuleDeclaredProvenance {
1131    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1132    where
1133        D: serde::Deserializer<'de>,
1134    {
1135        let (tag, value) = read_tagged(deserializer, "status")?;
1136        match tag.as_str() {
1137            "reported" => match serde_json::from_value(value.into_value())
1138                .map_err(D::Error::custom)?
1139            {
1140                ModuleDeclaredProvenanceWire::Reported { build } => Ok(Self::Reported { build }),
1141                ModuleDeclaredProvenanceWire::Unverifiable => unreachable!(),
1142            },
1143            "unverifiable" => {
1144                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1145                    ModuleDeclaredProvenanceWire::Unverifiable => Ok(Self::Unverifiable),
1146                    ModuleDeclaredProvenanceWire::Reported { .. } => unreachable!(),
1147                }
1148            }
1149            _ => Ok(Self::Unknown { tag, body: value }),
1150        }
1151    }
1152}
1153
1154impl Serialize for RunningImageAgreement {
1155    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1156    where
1157        S: Serializer,
1158    {
1159        match self {
1160            Self::Match { evidence } => RunningImageAgreementWire::Match {
1161                evidence: evidence.clone(),
1162            }
1163            .serialize(serializer),
1164            Self::Mismatch { running, disk } => RunningImageAgreementWire::Mismatch {
1165                running: running.clone(),
1166                disk: disk.clone(),
1167            }
1168            .serialize(serializer),
1169            Self::Unavailable { reason } => RunningImageAgreementWire::Unavailable {
1170                reason: reason.clone(),
1171            }
1172            .serialize(serializer),
1173            Self::Unknown { body, .. } => body.serialize(serializer),
1174        }
1175    }
1176}
1177
1178impl<'de> Deserialize<'de> for RunningImageAgreement {
1179    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1180    where
1181        D: serde::Deserializer<'de>,
1182    {
1183        let (tag, value) = read_tagged(deserializer, "status")?;
1184        match tag.as_str() {
1185            "match" => Ok(Self::Match {
1186                evidence: decode_running_image_evidence(
1187                    ordered_field(&value, "evidence")
1188                        .cloned()
1189                        .ok_or_else(|| D::Error::custom("tagged object has no `evidence` field"))?,
1190                )
1191                .map_err(D::Error::custom)?,
1192            }),
1193            "mismatch" => Ok(Self::Mismatch {
1194                running: decode_running_image_evidence(
1195                    ordered_field(&value, "running")
1196                        .cloned()
1197                        .ok_or_else(|| D::Error::custom("tagged object has no `running` field"))?,
1198                )
1199                .map_err(D::Error::custom)?,
1200                disk: decode_running_image_evidence(
1201                    ordered_field(&value, "disk")
1202                        .cloned()
1203                        .ok_or_else(|| D::Error::custom("tagged object has no `disk` field"))?,
1204                )
1205                .map_err(D::Error::custom)?,
1206            }),
1207            "unavailable" => Ok(Self::Unavailable {
1208                reason: serde_json::from_value(
1209                    ordered_field(&value, "reason")
1210                        .cloned()
1211                        .ok_or_else(|| D::Error::custom("tagged object has no `reason` field"))?
1212                        .into_value(),
1213                )
1214                .map_err(D::Error::custom)?,
1215            }),
1216            _ => Ok(Self::Unknown { tag, body: value }),
1217        }
1218    }
1219}
1220
1221impl Serialize for RunningImageEvidence {
1222    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1223    where
1224        S: Serializer,
1225    {
1226        match self {
1227            Self::LinuxProcSha256 { digest } => RunningImageEvidenceWire::LinuxProcSha256 {
1228                digest: digest.clone(),
1229            }
1230            .serialize(serializer),
1231            Self::MacosSpawnInode { device, inode } => RunningImageEvidenceWire::MacosSpawnInode {
1232                device: *device,
1233                inode: *inode,
1234            }
1235            .serialize(serializer),
1236            Self::Unknown { body, .. } => body.serialize(serializer),
1237        }
1238    }
1239}
1240
1241impl<'de> Deserialize<'de> for RunningImageEvidence {
1242    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1243    where
1244        D: serde::Deserializer<'de>,
1245    {
1246        let (tag, value) = read_tagged(deserializer, "method")?;
1247        match tag.as_str() {
1248            "linux_proc_sha256" => {
1249                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1250                    RunningImageEvidenceWire::LinuxProcSha256 { digest } => {
1251                        Ok(Self::LinuxProcSha256 { digest })
1252                    }
1253                    _ => unreachable!(),
1254                }
1255            }
1256            "macos_spawn_inode" => {
1257                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1258                    RunningImageEvidenceWire::MacosSpawnInode { device, inode } => {
1259                        Ok(Self::MacosSpawnInode { device, inode })
1260                    }
1261                    _ => unreachable!(),
1262                }
1263            }
1264            _ => Ok(Self::Unknown { tag, body: value }),
1265        }
1266    }
1267}
1268
1269impl Serialize for SupervisorRouteConsumer {
1270    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1271    where
1272        S: Serializer,
1273    {
1274        match self {
1275            Self::Reserved { module_id } => SupervisorRouteConsumerWire::Reserved {
1276                module_id: module_id.clone(),
1277            }
1278            .serialize(serializer),
1279            Self::Direct { connection_id } => SupervisorRouteConsumerWire::Direct {
1280                connection_id: *connection_id,
1281            }
1282            .serialize(serializer),
1283            Self::Unknown { body, .. } => body.serialize(serializer),
1284        }
1285    }
1286}
1287
1288impl<'de> Deserialize<'de> for SupervisorRouteConsumer {
1289    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1290    where
1291        D: serde::Deserializer<'de>,
1292    {
1293        let (tag, value) = read_tagged(deserializer, "kind")?;
1294        match tag.as_str() {
1295            "reserved" => {
1296                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1297                    SupervisorRouteConsumerWire::Reserved { module_id } => {
1298                        Ok(Self::Reserved { module_id })
1299                    }
1300                    _ => unreachable!(),
1301                }
1302            }
1303            "direct" => {
1304                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1305                    SupervisorRouteConsumerWire::Direct { connection_id } => {
1306                        Ok(Self::Direct { connection_id })
1307                    }
1308                    _ => unreachable!(),
1309                }
1310            }
1311            _ => Ok(Self::Unknown { tag, body: value }),
1312        }
1313    }
1314}
1315
1316impl Serialize for StderrCaptureState {
1317    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1318    where
1319        S: Serializer,
1320    {
1321        match self {
1322            Self::Captured => StderrCaptureStateWire::Captured.serialize(serializer),
1323            Self::Incomplete { reason } => StderrCaptureStateWire::Incomplete {
1324                reason: reason.clone(),
1325            }
1326            .serialize(serializer),
1327            Self::NotCaptured { reason } => StderrCaptureStateWire::NotCaptured {
1328                reason: reason.clone(),
1329            }
1330            .serialize(serializer),
1331            Self::Unknown { body, .. } => body.serialize(serializer),
1332        }
1333    }
1334}
1335
1336impl<'de> Deserialize<'de> for StderrCaptureState {
1337    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1338    where
1339        D: serde::Deserializer<'de>,
1340    {
1341        let (tag, value) = read_tagged(deserializer, "state")?;
1342        match tag.as_str() {
1343            "captured" => {
1344                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1345                    StderrCaptureStateWire::Captured => Ok(Self::Captured),
1346                    _ => unreachable!(),
1347                }
1348            }
1349            "incomplete" => match serde_json::from_value(value.into_value())
1350                .map_err(D::Error::custom)?
1351            {
1352                StderrCaptureStateWire::Incomplete { reason } => Ok(Self::Incomplete { reason }),
1353                _ => unreachable!(),
1354            },
1355            "not_captured" => match serde_json::from_value(value.into_value())
1356                .map_err(D::Error::custom)?
1357            {
1358                StderrCaptureStateWire::NotCaptured { reason } => Ok(Self::NotCaptured { reason }),
1359                _ => unreachable!(),
1360            },
1361            _ => Ok(Self::Unknown { tag, body: value }),
1362        }
1363    }
1364}
1365
1366impl Serialize for StderrTailEntry {
1367    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1368    where
1369        S: Serializer,
1370    {
1371        match self {
1372            Self::Line { text, truncated } => StderrTailEntryWire::Line {
1373                text: text.clone(),
1374                truncated: *truncated,
1375            }
1376            .serialize(serializer),
1377            Self::ProcessStart => StderrTailEntryWire::ProcessStart.serialize(serializer),
1378            Self::Unknown { body, .. } => body.serialize(serializer),
1379        }
1380    }
1381}
1382
1383impl<'de> Deserialize<'de> for StderrTailEntry {
1384    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1385    where
1386        D: serde::Deserializer<'de>,
1387    {
1388        let (tag, value) = read_tagged(deserializer, "kind")?;
1389        match tag.as_str() {
1390            "line" => match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1391                StderrTailEntryWire::Line { text, truncated } => Ok(Self::Line { text, truncated }),
1392                _ => unreachable!(),
1393            },
1394            "process_start" => {
1395                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1396                    StderrTailEntryWire::ProcessStart => Ok(Self::ProcessStart),
1397                    _ => unreachable!(),
1398                }
1399            }
1400            _ => Ok(Self::Unknown { tag, body: value }),
1401        }
1402    }
1403}
1404
1405fn is_zero_u64(value: &u64) -> bool {
1406    *value == 0
1407}
1408
1409fn default_true() -> bool {
1410    true
1411}
1412
1413/// Bounded terminal history for one module, oldest retained record first.
1414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1415pub struct TerminalHistory {
1416    /// Unix milliseconds at the current daemon's start; entries may predate it.
1417    pub daemon_started_at_ms: u64,
1418    pub entries: Vec<TerminalEntry>,
1419    /// Exits evicted by the current daemon's ring, possibly recovered from its
1420    /// journal. Not a count of missing exits: expired journal totals are unknown.
1421    #[serde(default, skip_serializing_if = "is_zero_u64")]
1422    pub dropped: u64,
1423    /// Unparseable or incomplete lines across the shared journal, including
1424    /// lines whose module cannot be determined. Zero on older daemons.
1425    #[serde(default, skip_serializing_if = "is_zero_u64")]
1426    pub journal_skipped_lines: u64,
1427    /// Files that could not be read completely, excluding absent generations.
1428    #[serde(default, skip_serializing_if = "is_zero_u64")]
1429    pub journal_read_errors: u64,
1430    /// Failed journal appends across all modules in the current daemon.
1431    #[serde(default, skip_serializing_if = "is_zero_u64")]
1432    pub journal_write_failures: u64,
1433}
1434
1435/// One terminal child exit and the supervisor action it selected.
1436#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1437pub struct TerminalEntry {
1438    /// Opaque identity of the daemon that observed this exit; absent on older
1439    /// daemons. Different tokens mean different lifetimes, not chronological order.
1440    #[serde(default, skip_serializing_if = "Option::is_none")]
1441    pub daemon_incarnation: Option<String>,
1442    #[serde(default, skip_serializing_if = "Option::is_none")]
1443    pub exit_code: Option<i32>,
1444    #[serde(default, skip_serializing_if = "Option::is_none")]
1445    pub exit_signal: Option<i32>,
1446    pub at_ms: u64,
1447    pub disposition: TerminalDisposition,
1448    /// Supervisor classification of this exit. Absent on daemons that predate
1449    /// the field; unknown future kinds remain readable instead of failing the
1450    /// enclosing terminal record.
1451    #[serde(default, skip_serializing_if = "Option::is_none")]
1452    pub exit_kind: Option<TerminalExitKind>,
1453    /// Why the supervisor chose this disposition, when the disposition alone
1454    /// does not say. A `failed` record carries the exhausted crash budget here
1455    /// (`crash budget exhausted: max_restarts=3 within window_secs=600`), which
1456    /// is the difference between an operator seeing "it failed" and seeing which
1457    /// limit stopped it. Prose for humans: render it, never parse it. Absent for
1458    /// ordinary dispositions and on daemons predating the field.
1459    #[serde(default, skip_serializing_if = "Option::is_none")]
1460    pub disposition_detail: Option<String>,
1461}
1462
1463/// Exit classification carried by supervisor history and census records.
1464///
1465/// This is an open string enum so future daemon variants degrade to a readable
1466/// unknown kind rather than making a consumer discard the enclosing record.
1467#[derive(Debug, Clone, PartialEq, Eq)]
1468pub enum TerminalExitKind {
1469    Clean,
1470    Crash,
1471    DeliberateSeverance,
1472    Unknown(String),
1473}
1474
1475impl TerminalExitKind {
1476    fn wire_name(&self) -> &str {
1477        match self {
1478            Self::Clean => "clean",
1479            Self::Crash => "crash",
1480            Self::DeliberateSeverance => "deliberate_severance",
1481            Self::Unknown(value) => value,
1482        }
1483    }
1484}
1485
1486impl Serialize for TerminalExitKind {
1487    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1488    where
1489        S: serde::Serializer,
1490    {
1491        serializer.serialize_str(self.wire_name())
1492    }
1493}
1494
1495impl<'de> Deserialize<'de> for TerminalExitKind {
1496    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1497    where
1498        D: serde::Deserializer<'de>,
1499    {
1500        let value = String::deserialize(deserializer)?;
1501        Ok(match value.as_str() {
1502            "clean" => Self::Clean,
1503            "crash" => Self::Crash,
1504            "deliberate_severance" => Self::DeliberateSeverance,
1505            _ => Self::Unknown(value),
1506        })
1507    }
1508}
1509
1510open_string_enum! {
1511    /// The supervisor disposition selected after observing a terminal exit.
1512    TerminalDisposition {
1513        Stopped => "stopped",
1514        Disabled => "disabled",
1515        Failed => "failed",
1516        Restarting => "restarting",
1517    }
1518}
1519
1520#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1521#[serde(rename_all = "snake_case")]
1522pub enum PollKind {
1523    Status,
1524    Liveness,
1525}
1526
1527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1528pub struct CatalogEntry {
1529    pub module_id: String,
1530    /// Whether the registered module currently accepts new route binds.
1531    ///
1532    /// This is the module's EFFECTIVE readiness: its own declared readiness
1533    /// AND every one of its `need: required` capabilities having a registered
1534    /// provider. It is exactly the condition `route.open` checks, so a caller
1535    /// reading `false` here will be refused with `module_warming`; `not_ready`
1536    /// says why.
1537    ///
1538    /// Older daemons omit this field and are interpreted as ready. Daemons that
1539    /// predate `not_ready` report declared readiness only.
1540    #[serde(default = "default_true")]
1541    pub ready: bool,
1542    /// Why `ready` is false, in the same shape `route.open` puts in the
1543    /// `detail` of its `module_warming` refusal. Absent when the module is
1544    /// ready, and absent from daemons that predate the field.
1545    #[serde(default, skip_serializing_if = "Option::is_none")]
1546    pub not_ready: Option<NotReadyReason>,
1547    /// The registered module's self-declared build version, projected from its
1548    /// manifest so a consumer can tell WHICH BUILD of a module it is talking
1549    /// to at connect time.
1550    ///
1551    /// Without this, a client compiled against a module's current source reads
1552    /// a contract that is true of the repository and false of the running
1553    /// process -- the types match, the JSON decodes, and the meaning has
1554    /// changed. That failure carries no error to notice; the version in the
1555    /// catalog turns a semantic skew into a log line at connect instead of a
1556    /// wrong sentence on a user's screen.
1557    ///
1558    /// Optional on the wire only because entries serialized by older daemons
1559    /// lack it: absent means "daemon predates the field", never "module has
1560    /// no version" (the manifest field is required at registration).
1561    ///
1562    /// The reading is ARMED BY OBSERVATION, not by this documentation: until
1563    /// a consumer has seen at least one populated entry from the daemon it is
1564    /// connected to, an all-None catalog is indistinguishable from an old
1565    /// daemon, and a client shipping the documented reading against it would
1566    /// hold a guarantee it does not have.
1567    #[serde(default, skip_serializing_if = "Option::is_none")]
1568    pub module_version: Option<String>,
1569    pub roles: Vec<ProviderRole>,
1570    pub control_ops: Vec<String>,
1571    /// Static capability declarations from the registering module's manifest.
1572    ///
1573    /// Optional on the wire so consumers connected to a daemon that predates the
1574    /// capability grammar retain their existing catalog decoding behavior.
1575    #[serde(default, skip_serializing_if = "Option::is_none")]
1576    pub capabilities: Option<CapabilityDeclarations>,
1577    /// Self-signal declarations mirrored verbatim from the registering module's
1578    /// manifest. The daemon relays these declarations without interpreting them.
1579    #[serde(default, skip_serializing_if = "Option::is_none")]
1580    pub self_signals: Option<Vec<SelfSignalDeclaration>>,
1581}
1582
1583/// Why a registered module is not accepting new route binds.
1584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1585pub struct NotReadyReason {
1586    /// `declared_not_ready` when the module itself said it is not ready, or
1587    /// `required_capability_unprovided` when a capability it declares
1588    /// `need: required` has no registered provider. Open vocabulary: a newer
1589    /// daemon may add reasons.
1590    pub reason: String,
1591    /// For `required_capability_unprovided`, the lexicographically first
1592    /// required capability that has no registered provider.
1593    #[serde(default, skip_serializing_if = "Option::is_none")]
1594    pub capability: Option<String>,
1595}
1596
1597impl NotReadyReason {
1598    pub const DECLARED_NOT_READY: &'static str = "declared_not_ready";
1599    pub const REQUIRED_CAPABILITY_UNPROVIDED: &'static str = "required_capability_unprovided";
1600}
1601
1602#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1603pub struct CapabilityRequirementStatus {
1604    pub consumer: String,
1605    pub capability: String,
1606    pub need: String,
1607    pub verdict: String,
1608    pub episode_seq: u64,
1609    pub config_satisfiable: bool,
1610    pub runtime_available: bool,
1611    pub detail: String,
1612}
1613
1614#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1615pub struct SupervisorRescanResult {
1616    pub added: Vec<String>,
1617    pub removed: Vec<String>,
1618    pub changed_pending_reload: Vec<String>,
1619    /// Modules whose enabled flag differs between config and running state.
1620    ///
1621    /// Rescan calls `set_enabled` for these, so omitting them made the preview
1622    /// describe two of the three mutation classes it performs. A module changing
1623    /// only its enabled flag landed in no bucket at all -- not added, removed or
1624    /// changed, and deliberately not counted as unchanged either -- so the sole
1625    /// evidence was that the buckets no longer summed to the configured module
1626    /// count. A preview is consulted precisely when someone is being careful,
1627    /// which is the worst place to under-report.
1628    ///
1629    /// Empty is skipped so consumers written against the older shape keep
1630    /// parsing.
1631    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1632    pub enabled_changes: Vec<String>,
1633    pub unchanged: u32,
1634    /// True when this reconciliation was computed but NOT applied.
1635    ///
1636    /// Carried on the result rather than left to the caller's memory of what it
1637    /// asked for. A preview and an execution are otherwise byte-identical, so a
1638    /// reader who meets this output later -- in a log, a transcript, a pasted
1639    /// snippet -- cannot tell which one happened. Absent when false, so existing
1640    /// consumers see the shape they already parse.
1641    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1642    pub preview: bool,
1643    /// Config sections that changed but which rescan CANNOT apply, so the
1644    /// operator learns a daemon restart is required from the command they just
1645    /// ran rather than from the journal.
1646    ///
1647    /// The daemon has always detected this and logged a warning. A warning in a
1648    /// log is addressed to whoever is reading the log, and the person who just
1649    /// edited the config is by construction looking at the CLI instead: reported
1650    /// by an outside contributor after a module crash-looped through four
1651    /// respawns because a new top-level `storage` section was silently not
1652    /// applied, diagnosable only by journal archaeology.
1653    ///
1654    /// Names the SECTIONS rather than a boolean, because "something else
1655    /// changed" sends the operator back to diffing their own file -- which is
1656    /// the work the message exists to save.
1657    ///
1658    /// Empty is skipped, so consumers written against the older shape keep
1659    /// parsing.
1660    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1661    pub restart_required: Vec<String>,
1662    /// Required capabilities that a dry-run's resulting module set would leave
1663    /// unprovided. Rows are human-readable because the preview is an operator
1664    /// explanation, not a second manifest schema.
1665    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1666    pub capability_warnings: Vec<String>,
1667}
1668
1669/// Which wire protocol a supervised module speaks to subc, as DECLARED in
1670/// daemon config. Never inferred from observed behaviour.
1671///
1672/// The distinction this exists to keep is between a module that should have
1673/// registered and has not yet, and one that never will. A `Subc` module that has
1674/// not registered is a subc module that is LATE -- it may be booting, it may be
1675/// wedged, and the supervisor's health probing and restart escalation are the
1676/// right response. A `None` module is a third-party process (the NATS server is
1677/// the first) that subc launches, supervises, and stops, and that is all: it
1678/// speaks no subc wire at all, so treating its silence as a fault would restart
1679/// a perfectly healthy process forever.
1680///
1681/// Inferring the difference from "has not registered within N seconds" would
1682/// collapse exactly the two cases that must stay apart, which is why this is a
1683/// declaration.
1684#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1685#[serde(rename_all = "snake_case")]
1686pub enum ModuleProtocol {
1687    /// The module registers over channel 0, answers `health.check`, and can
1688    /// serve routes. Every module predating this field is one of these, which is
1689    /// why it is the default.
1690    #[default]
1691    Subc,
1692    /// The module speaks no subc wire. It is supervised as a process only.
1693    None,
1694}
1695
1696#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1697pub struct SupervisorEntry {
1698    pub module_id: String,
1699    pub state: String,
1700    pub enabled: bool,
1701    /// Whether this module is serving.
1702    ///
1703    /// For a `Subc` module: enabled, running, process alive, AND registered.
1704    /// For a `None` module the registration term is dropped, because a module
1705    /// that speaks no subc wire never registers and the daemon cannot assert
1706    /// more than "the process it launched is alive". READ IT WITH `protocol`:
1707    /// `live: true` means something weaker for a `None` module, and a renderer
1708    /// that prints it as a bare boolean for one is claiming more than the daemon
1709    /// knows.
1710    pub live: bool,
1711    /// The module's declared wire protocol. Absent on daemons predating the
1712    /// field, where every module was a subc module, so the default is exactly
1713    /// what those daemons meant.
1714    #[serde(default)]
1715    pub protocol: ModuleProtocol,
1716    pub health: SupervisorHealthStatus,
1717    /// When the daemon last collected this module's health, as unix
1718    /// milliseconds. Absent means NEVER PROBED (a module inside its first probe
1719    /// window, whose `health` is therefore `Unknown` rather than good), not
1720    /// probed-long-ago. An old value and an absent one call for opposite
1721    /// readings, so do not render them alike.
1722    #[serde(default)]
1723    pub last_probe_ms: Option<u64>,
1724    /// Exit code of the module's most recent process exit, if the process has
1725    /// exited at least once. Survives respawn so a now-`running` module still
1726    /// reports what killed its previous incarnation.
1727    #[serde(default, skip_serializing_if = "Option::is_none")]
1728    pub last_exit_code: Option<i32>,
1729    /// Terminating signal of the module's most recent process exit (Unix), if
1730    /// any. `Some(9)` = SIGKILL (OOM/jetsam/kill-on-drop), `Some(6)` = SIGABRT
1731    /// (often a panic-abort). Survives respawn.
1732    #[serde(default, skip_serializing_if = "Option::is_none")]
1733    pub last_exit_signal: Option<i32>,
1734    /// Unix milliseconds when the most recent child exit was observed. Present
1735    /// even when the terminal ring is not queried, so existing list readers can
1736    /// order their latest observed exit against events they already received.
1737    #[serde(default, skip_serializing_if = "Option::is_none")]
1738    pub last_exit_ms: Option<u64>,
1739    /// Classification of the most recent child exit. Absent on daemons that
1740    /// predate exit-kind reporting.
1741    #[serde(default, skip_serializing_if = "Option::is_none")]
1742    pub last_exit_kind: Option<TerminalExitKind>,
1743    /// Replacement processes spawned for this module so far, against the budget
1744    /// that disables it.
1745    ///
1746    /// THIS IS THE COUNTER THAT ENDS A MODULE, and it is not the one beside it.
1747    /// `SupervisorHealthEntry::consecutive_failures` returns to zero on any
1748    /// successful probe, so a module can miss probes all day and read zero; this
1749    /// one only decreases when an operator restarts, reloads, or re-enables the
1750    /// module. Reaching the budget moves it to `Failed` and it stays there until
1751    /// somebody intervenes.
1752    ///
1753    /// So a module one restart from being disabled is indistinguishable from a
1754    /// freshly booted one unless this pair is read. Both are reported together
1755    /// because the count alone does not say how close it is.
1756    ///
1757    /// Absent from daemons predating the field, which is why it is optional
1758    /// rather than defaulted to zero: zero would assert a full budget.
1759    #[serde(default, skip_serializing_if = "Option::is_none")]
1760    pub restart_count: Option<u32>,
1761    /// Replacement processes this module is allowed before it is disabled. See
1762    /// `restart_count`; absent on daemons predating the field.
1763    #[serde(default, skip_serializing_if = "Option::is_none")]
1764    pub max_restarts: Option<u32>,
1765    /// Replacement processes spawned over this module's entire supervisor lifetime.
1766    /// Unlike `restart_count`, this value is never reset by an operator action.
1767    #[serde(default, skip_serializing_if = "Option::is_none")]
1768    pub lifetime_restarts: Option<u32>,
1769    /// Successful child spawns in this daemon incarnation. Zero means the
1770    /// module has not successfully spawned; every successful spawn increments
1771    /// the value exactly once.
1772    #[serde(default, skip_serializing_if = "Option::is_none")]
1773    pub spawn_generation: Option<u64>,
1774    /// The span `restart_count` is counted over, in seconds. The crash budget is
1775    /// a RATE, not a lifetime total: `restart_count` counts only the restarts
1776    /// inside the last `restart_window_secs`, and older ones no longer hold a
1777    /// slot. Without this field a reader cannot tell "2 of 3 crashes, ever" from
1778    /// "2 of 3 crashes in the last ten minutes", and those two call for opposite
1779    /// reactions.
1780    ///
1781    /// Absent on daemons predating the windowed budget, where the count really
1782    /// was a lifetime total.
1783    #[serde(default, skip_serializing_if = "Option::is_none")]
1784    pub restart_window_secs: Option<u64>,
1785    /// Effective drain budget for this module, in milliseconds. This is the
1786    /// resolved policy the running supervisor uses, not a config-file reread.
1787    /// Absent on older daemons.
1788    #[serde(default, skip_serializing_if = "Option::is_none")]
1789    pub drain_timeout_ms: Option<u64>,
1790    /// Effective base delay before a crash restart, in milliseconds. Absent on
1791    /// older daemons.
1792    #[serde(default, skip_serializing_if = "Option::is_none")]
1793    pub restart_backoff_ms: Option<u64>,
1794    /// Effective maximum delay before a crash restart, in milliseconds. Absent
1795    /// on older daemons.
1796    #[serde(default, skip_serializing_if = "Option::is_none")]
1797    pub restart_max_backoff_ms: Option<u64>,
1798}
1799
1800#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1801#[serde(rename_all = "snake_case")]
1802pub enum SupervisorHealthStatus {
1803    Ok,
1804    Degraded,
1805    Failing,
1806    Unresponsive,
1807    Unknown,
1808}
1809
1810#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1811pub struct SupervisorHealthEntry {
1812    pub module_id: String,
1813    pub status: SupervisorHealthStatus,
1814    /// The module's own human-readable note on its state. Absent means the
1815    /// module said nothing, which is the ordinary shape for a healthy module and
1816    /// is NOT a claim that nothing is wrong. Never parse it: it is prose the
1817    /// module may reword freely, and `status` plus `metrics` are the machine
1818    /// surface.
1819    #[serde(default, skip_serializing_if = "Option::is_none")]
1820    pub detail: Option<String>,
1821    /// The module's own metrics object, relayed opaquely. Absent means the module
1822    /// published none on this probe — either it reports no metrics at all, or the
1823    /// probe did not reach it — so absence cannot distinguish "nothing to report"
1824    /// from "nobody asked". Read `last_probe_ms` to tell those apart.
1825    #[serde(default, skip_serializing_if = "Option::is_none")]
1826    pub metrics: Option<serde_json::Value>,
1827    pub consecutive_failures: u32,
1828    /// Number of recurring health replies received after their daemon deadline.
1829    /// Each increment is evidence that the module remained alive despite a miss.
1830    #[serde(default)]
1831    pub late_answer_count: u64,
1832    /// End-to-end latency of the newest late reply, measured from probe start.
1833    #[serde(default, skip_serializing_if = "Option::is_none")]
1834    pub last_late_answer_latency_ms: Option<u64>,
1835    /// The escalation the supervisor last took for this module (report, restart,
1836    /// alert). Absent means NO ACTION HAS EVER BEEN TAKEN, not that the last one
1837    /// succeeded — a module that has never misbehaved and one whose action record
1838    /// predates a daemon restart both present as absent.
1839    #[serde(default)]
1840    pub last_action: Option<String>,
1841    /// When `last_action` was taken, as unix milliseconds. Absent exactly when
1842    /// `last_action` is absent; the pair moves together.
1843    #[serde(default)]
1844    pub last_action_ms: Option<u64>,
1845    /// When the daemon last collected this entry, as unix milliseconds.
1846    ///
1847    /// `supervisor.health` answers from the supervisor's STORED record rather
1848    /// than probing, so every field above describes some moment in the past and
1849    /// nothing here said which. That matters most right after a restart, where
1850    /// the surface is used to confirm a deploy: a record collected before the
1851    /// restart reports the OLD process, reads as a failed deploy, and invites a
1852    /// redeploy of something that was already correct.
1853    ///
1854    /// `None` means never probed — distinct from probed-long-ago, and the reader
1855    /// must not collapse them. Absent on modules that advertise no health
1856    /// capability, which is why it is optional rather than defaulted to zero.
1857    #[serde(default, skip_serializing_if = "Option::is_none")]
1858    pub last_probe_ms: Option<u64>,
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863    use super::*;
1864    use subc_protocol::{BindIdentity, RouteTarget};
1865
1866    #[test]
1867    fn legacy_terminal_decoder_ignores_deliberate_severance_kind() {
1868        let entry = TerminalEntry {
1869            daemon_incarnation: Some("daemon-before-restart".into()),
1870            exit_code: Some(1),
1871            exit_signal: None,
1872            at_ms: 1_700_000_000_123,
1873            disposition: TerminalDisposition::Restarting,
1874            exit_kind: Some(TerminalExitKind::DeliberateSeverance),
1875            disposition_detail: None,
1876        };
1877        let wire = serde_json::to_string(&entry).expect("terminal entry serializes");
1878        assert_eq!(
1879            serde_json::from_str::<serde_json::Value>(&wire).expect("terminal entry is JSON")
1880                ["exit_kind"],
1881            "deliberate_severance"
1882        );
1883
1884        #[derive(serde::Deserialize)]
1885        struct LegacyTerminalEntry {
1886            exit_code: Option<i32>,
1887            exit_signal: Option<i32>,
1888            at_ms: u64,
1889            disposition: TerminalDisposition,
1890        }
1891
1892        let decoded: LegacyTerminalEntry =
1893            serde_json::from_str(&wire).expect("legacy decoder keeps the terminal record");
1894        assert_eq!(decoded.exit_code, Some(1));
1895        assert_eq!(decoded.exit_signal, None);
1896        assert_eq!(decoded.at_ms, 1_700_000_000_123);
1897        assert_eq!(decoded.disposition, TerminalDisposition::Restarting);
1898
1899        let future_wire = wire.replace("deliberate_severance", "future_exit_kind");
1900        let future: TerminalEntry =
1901            serde_json::from_str(&future_wire).expect("new decoder keeps a future terminal kind");
1902        assert_eq!(
1903            future.exit_kind,
1904            Some(TerminalExitKind::Unknown("future_exit_kind".to_string()))
1905        );
1906    }
1907
1908    #[test]
1909    fn terminal_incarnation_is_optional_for_older_daemons() {
1910        let entry: TerminalEntry = serde_json::from_value(serde_json::json!({
1911            "at_ms": 123,
1912            "disposition": "stopped"
1913        }))
1914        .unwrap();
1915        let encoded = serde_json::to_value(&entry).unwrap();
1916        assert_eq!(
1917            (entry.daemon_incarnation, encoded.get("daemon_incarnation")),
1918            (None, None)
1919        );
1920    }
1921
1922    #[test]
1923    fn route_poll_uses_kind_field() {
1924        let body = serde_json::to_value(ClientControlRequest::RoutePoll {
1925            route_channel: 7,
1926            route_epoch: 11,
1927            kind: PollKind::Status,
1928        })
1929        .unwrap();
1930
1931        assert_eq!(body["op"], "route.poll");
1932        assert_eq!(body["route_epoch"], 11);
1933        assert_eq!(body["kind"], "status");
1934        assert!(body.get("op").is_some());
1935    }
1936
1937    #[test]
1938    fn route_open_is_internally_tagged() {
1939        let request = ClientControlRequest::RouteOpen {
1940            target: RouteTarget::ToolProvider {
1941                module_id: "aft".to_string(),
1942            },
1943            identity: BindIdentity::new("/tmp/project", "opencode", "session-1"),
1944            consumer_identity: None,
1945            consumer_capabilities: None,
1946            admission_facts: None,
1947        };
1948
1949        let body = serde_json::to_value(request).unwrap();
1950        assert_eq!(body["op"], "route.open");
1951        assert_eq!(body["target"]["kind"], "tool_provider");
1952        assert!(body.get("consumer_identity").is_none());
1953        assert!(body.get("consumer_capabilities").is_none());
1954    }
1955
1956    #[test]
1957    fn route_open_without_optional_fields_still_decodes() {
1958        let body = serde_json::json!({
1959            "op": "route.open",
1960            "target": { "kind": "tool_provider", "module_id": "aft" },
1961            "identity": {
1962                "project_root": "/tmp/project",
1963                "harness": "opencode",
1964                "session": "session-1"
1965            }
1966        });
1967
1968        let decoded: ClientControlRequest = serde_json::from_value(body).unwrap();
1969        let ClientControlRequest::RouteOpen {
1970            consumer_identity,
1971            consumer_capabilities,
1972            admission_facts,
1973            ..
1974        } = decoded
1975        else {
1976            panic!("decoded wrong request variant");
1977        };
1978        assert_eq!(consumer_identity, None);
1979        assert_eq!(consumer_capabilities, None);
1980        assert_eq!(admission_facts, None);
1981    }
1982
1983    #[test]
1984    fn new_route_closed_decoder_defaults_fields_absent_from_old_daemon() {
1985        let old_wire = r#"{"op":"route.closed","module_id":"aft-tools","reason":"crash","drained":false,"abandoned":0}"#;
1986        let decoded: ClientControlPush = serde_json::from_str(old_wire).unwrap();
1987        match decoded {
1988            ClientControlPush::RouteClosed {
1989                excluded_subscriptions,
1990                terminal,
1991                ..
1992            } => {
1993                assert_eq!(excluded_subscriptions, 0);
1994                assert_eq!(terminal, None);
1995            }
1996            other => panic!("unexpected push: {other:?}"),
1997        }
1998        assert!(!serde_json::to_string(&decoded)
1999            .unwrap()
2000            .contains("terminal"));
2001    }
2002
2003    #[test]
2004    fn old_route_closed_decoder_ignores_new_terminal_field() {
2005        #[derive(serde::Deserialize)]
2006        #[serde(tag = "op")]
2007        enum LegacyClientControlPush {
2008            #[serde(rename = "route.closed")]
2009            RouteClosed {
2010                module_id: String,
2011                reason: RouteCloseReason,
2012                drained: bool,
2013                abandoned: u32,
2014            },
2015        }
2016
2017        let wire = r#"{"op":"route.closed","module_id":"aft-tools","reason":"crash","drained":false,"abandoned":0,"excluded_subscriptions":3,"terminal":true}"#;
2018        let decoded: LegacyClientControlPush = serde_json::from_str(wire).unwrap();
2019        match decoded {
2020            LegacyClientControlPush::RouteClosed {
2021                module_id,
2022                reason,
2023                drained,
2024                abandoned,
2025            } => {
2026                assert_eq!(module_id, "aft-tools");
2027                assert_eq!(reason, RouteCloseReason::Crash);
2028                assert!(!drained);
2029                assert_eq!(abandoned, 0);
2030            }
2031        }
2032    }
2033
2034    #[test]
2035    fn supervisor_routes_is_a_control_plane_request() {
2036        let body = serde_json::json!({
2037            "op": "supervisor.routes",
2038            "module_id": "aft"
2039        });
2040
2041        let request: ClientControlRequest = serde_json::from_value(body.clone()).unwrap();
2042        assert_eq!(serde_json::to_value(request).unwrap(), body);
2043    }
2044
2045    #[test]
2046    fn diagnostic_string_enums_retain_unknown_wire_values() {
2047        let reason: RunningImageUnavailableReason =
2048            serde_json::from_str("\"future_reason\"").unwrap();
2049        let disposition: TerminalDisposition =
2050            serde_json::from_str("\"future_disposition\"").unwrap();
2051
2052        assert_eq!(
2053            reason,
2054            RunningImageUnavailableReason::Unknown("future_reason".to_string())
2055        );
2056        assert_eq!(
2057            disposition,
2058            TerminalDisposition::Unknown("future_disposition".to_string())
2059        );
2060    }
2061
2062    #[test]
2063    fn diagnostic_string_enums_preserve_existing_wire_names() {
2064        let names = [
2065            (RunningImageUnavailableReason::NotRunning, "not_running"),
2066            (
2067                RunningImageUnavailableReason::UnsupportedPlatform,
2068                "unsupported_platform",
2069            ),
2070            (
2071                RunningImageUnavailableReason::RunningExecutableUnreadable,
2072                "running_executable_unreadable",
2073            ),
2074            (
2075                RunningImageUnavailableReason::SpawnedPathUnreadable,
2076                "spawned_path_unreadable",
2077            ),
2078            (RunningImageUnavailableReason::HashFailed, "hash_failed"),
2079            (
2080                RunningImageUnavailableReason::ProcessIdentityUnconfirmed,
2081                "process_identity_unconfirmed",
2082            ),
2083        ];
2084        for (value, expected) in names {
2085            let wire = serde_json::to_string(&value).unwrap();
2086            assert_eq!(wire, format!("\"{expected}\""));
2087            let decoded: RunningImageUnavailableReason = serde_json::from_str(&wire).unwrap();
2088            assert_eq!(decoded, value);
2089        }
2090
2091        for (value, expected) in [
2092            (TerminalDisposition::Stopped, "stopped"),
2093            (TerminalDisposition::Disabled, "disabled"),
2094            (TerminalDisposition::Failed, "failed"),
2095            (TerminalDisposition::Restarting, "restarting"),
2096        ] {
2097            let wire = serde_json::to_string(&value).unwrap();
2098            assert_eq!(wire, format!("\"{expected}\""));
2099            let decoded: TerminalDisposition = serde_json::from_str(&wire).unwrap();
2100            assert_eq!(decoded, value);
2101        }
2102    }
2103
2104    #[test]
2105    fn diagnostic_string_enums_reject_non_string_bodies() {
2106        assert!(serde_json::from_str::<RunningImageUnavailableReason>("42").is_err());
2107        assert!(serde_json::from_str::<TerminalDisposition>("{\"value\":\"failed\"}").is_err());
2108    }
2109
2110    #[test]
2111    fn unknown_provenance_reason_does_not_discard_healthy_siblings() {
2112        let body = serde_json::json!({
2113            "op": "supervisor.provenance",
2114            "daemon": {
2115                "daemon_build": {},
2116                "daemon_observed": {
2117                    "running_image": {
2118                        "status": "unavailable",
2119                        "reason": "not_running"
2120                    }
2121                }
2122            },
2123            "modules": [
2124                {
2125                    "module_id": "future",
2126                    "module_declared": { "status": "unverifiable" },
2127                    "daemon_observed": {
2128                        "running_image": {
2129                            "status": "unavailable",
2130                            "reason": "future_reason"
2131                        }
2132                    }
2133                },
2134                {
2135                    "module_id": "healthy-a",
2136                    "module_declared": { "status": "unverifiable" },
2137                    "daemon_observed": {
2138                        "running_image": {
2139                            "status": "match",
2140                            "evidence": {
2141                                "method": "linux_proc_sha256",
2142                                "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
2143                            }
2144                        }
2145                    }
2146                },
2147                {
2148                    "module_id": "healthy-b",
2149                    "module_declared": { "status": "unverifiable" },
2150                    "daemon_observed": {
2151                        "running_image": {
2152                            "status": "unavailable",
2153                            "reason": "unsupported_platform"
2154                        }
2155                    }
2156                }
2157            ]
2158        });
2159
2160        let decoded: ClientControlResponse = serde_json::from_value(body).unwrap();
2161        let ClientControlResponse::SupervisorProvenance { modules, .. } = decoded else {
2162            panic!("decoded wrong response variant");
2163        };
2164        assert_eq!(modules.len(), 3);
2165        assert_eq!(modules[0].module_id, "future");
2166        assert_eq!(
2167            modules[0].daemon_observed.running_image,
2168            RunningImageAgreement::Unavailable {
2169                reason: RunningImageUnavailableReason::Unknown("future_reason".to_string())
2170            }
2171        );
2172        assert_eq!(modules[1].module_id, "healthy-a");
2173        assert_eq!(modules[2].module_id, "healthy-b");
2174    }
2175
2176    #[test]
2177    fn tagged_unknown_values_retain_tag_and_body() {
2178        macro_rules! assert_unknown_round_trip {
2179            ($ty:ident, $field:literal, $value:expr) => {
2180                let value = $value;
2181                let wire = serde_json::to_string(&value).unwrap();
2182                let decoded: $ty = serde_json::from_str(&wire).unwrap();
2183                match decoded {
2184                    $ty::Unknown { tag, body } => {
2185                        assert_eq!(tag, value[$field].as_str().unwrap());
2186                        assert_eq!(serde_json::to_value(&body).unwrap(), value);
2187                    }
2188                    _ => panic!("decoded known variant"),
2189                }
2190            };
2191        }
2192
2193        assert_unknown_round_trip!(
2194            ModuleDeclaredProvenance,
2195            "status",
2196            serde_json::json!({"status": "future", "build": {"version": 7}})
2197        );
2198        assert_unknown_round_trip!(
2199            RunningImageAgreement,
2200            "status",
2201            serde_json::json!({"status": "future", "evidence": {"digest": "abc"}})
2202        );
2203        assert_unknown_round_trip!(
2204            RunningImageEvidence,
2205            "method",
2206            serde_json::json!({"method": "future", "digest": "abc"})
2207        );
2208        assert_unknown_round_trip!(
2209            SupervisorRouteConsumer,
2210            "kind",
2211            serde_json::json!({"kind": "future", "module_id": "m"})
2212        );
2213        assert_unknown_round_trip!(
2214            StderrCaptureState,
2215            "state",
2216            serde_json::json!({"state": "future", "reason": "because"})
2217        );
2218        assert_unknown_round_trip!(
2219            StderrTailEntry,
2220            "kind",
2221            serde_json::json!({"kind": "future", "text": "line"})
2222        );
2223    }
2224
2225    #[test]
2226    fn tagged_unknown_values_round_trip_the_original_json() {
2227        let wire = r#"{"kind":"future_consumer","detail":{"z":1}}"#;
2228        let decoded: SupervisorRouteConsumer = serde_json::from_str(wire).unwrap();
2229        assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2230    }
2231
2232    #[test]
2233    fn tagged_unknown_values_round_trip_trailing_tag() {
2234        let route_wire = r#"{"detail":{"z":1},"kind":"future_consumer"}"#;
2235        let route: SupervisorRouteConsumer = serde_json::from_str(route_wire).unwrap();
2236        assert_eq!(serde_json::to_string(&route).unwrap(), route_wire);
2237
2238        let stderr_wire = r#"{"reason":"because","state":"future_state"}"#;
2239        let stderr: StderrCaptureState = serde_json::from_str(stderr_wire).unwrap();
2240        assert_eq!(serde_json::to_string(&stderr).unwrap(), stderr_wire);
2241    }
2242
2243    #[test]
2244    fn tagged_unknown_values_round_trip_middle_tag() {
2245        let route_wire = r#"{"a":1,"kind":"future_x","b":2}"#;
2246        let route: SupervisorRouteConsumer = serde_json::from_str(route_wire).unwrap();
2247        assert_eq!(serde_json::to_string(&route).unwrap(), route_wire);
2248
2249        let stderr_wire = r#"{"a":1,"state":"future_state","b":2}"#;
2250        let stderr: StderrCaptureState = serde_json::from_str(stderr_wire).unwrap();
2251        assert_eq!(serde_json::to_string(&stderr).unwrap(), stderr_wire);
2252    }
2253
2254    #[test]
2255    fn tagged_unknown_values_round_trip_deep_payload() {
2256        let route_wire = r#"{"a":{"n":[1,2]},"kind":"future_x","zz":"s","b":null}"#;
2257        let route: SupervisorRouteConsumer = serde_json::from_str(route_wire).unwrap();
2258        assert_eq!(serde_json::to_string(&route).unwrap(), route_wire);
2259
2260        let stderr_wire = r#"{"a":{"n":[1,2]},"state":"future_state","zz":"s","b":null}"#;
2261        let stderr: StderrCaptureState = serde_json::from_str(stderr_wire).unwrap();
2262        assert_eq!(serde_json::to_string(&stderr).unwrap(), stderr_wire);
2263    }
2264
2265    #[test]
2266    fn tagged_unknown_values_reject_non_object_bodies() {
2267        for wire in ["42", r#""future""#, "[]"] {
2268            assert!(serde_json::from_str::<SupervisorRouteConsumer>(wire).is_err());
2269            assert!(serde_json::from_str::<StderrCaptureState>(wire).is_err());
2270        }
2271    }
2272
2273    #[test]
2274    fn duplicate_discriminators_reject_without_panicking() {
2275        assert_eq!(
2276            serde_json::from_str::<ModuleDeclaredProvenance>(r#"{"status":"unverifiable"}"#)
2277                .unwrap(),
2278            ModuleDeclaredProvenance::Unverifiable
2279        );
2280        match serde_json::from_str::<ModuleDeclaredProvenance>(r#"{"status":"future_thing"}"#)
2281            .unwrap()
2282        {
2283            ModuleDeclaredProvenance::Unknown { tag, .. } => assert_eq!(tag, "future_thing"),
2284            _ => panic!("future discriminator decoded as a known variant"),
2285        }
2286
2287        let wires = [
2288            r#"{"status":"reported","status":"unverifiable"}"#,
2289            r#"{"status":"unverifiable","status":"reported"}"#,
2290            r#"{"status":"reported","build":{},"status":"unverifiable"}"#,
2291            r#"{"status":"unverifiable","build":{},"status":"reported"}"#,
2292        ];
2293
2294        for wire in wires {
2295            let result =
2296                std::panic::catch_unwind(|| serde_json::from_str::<ModuleDeclaredProvenance>(wire));
2297            assert!(result.is_ok(), "duplicate discriminator panicked: {wire}");
2298            assert!(
2299                result.unwrap().is_err(),
2300                "duplicate discriminator decoded: {wire}"
2301            );
2302        }
2303
2304        let wire = r#"{"state":"captured","state":"incomplete","reason":"x"}"#;
2305        let result = std::panic::catch_unwind(|| serde_json::from_str::<StderrCaptureState>(wire));
2306        assert!(result.is_ok(), "duplicate discriminator panicked: {wire}");
2307        assert!(
2308            result.unwrap().is_err(),
2309            "duplicate discriminator decoded: {wire}"
2310        );
2311    }
2312
2313    #[test]
2314    fn nested_unknown_values_round_trip_without_normalizing_member_order() {
2315        let known_wire =
2316            r#"{"status":"match","evidence":{"method":"linux_proc_sha256","digest":"abc"}}"#;
2317        let known: RunningImageAgreement = serde_json::from_str(known_wire).unwrap();
2318        assert_eq!(serde_json::to_string(&known).unwrap(), known_wire);
2319
2320        for wire in [
2321            r#"{"kind":"future_x","detail":{"zeta":1,"alpha":2}}"#,
2322            r#"{"kind":"future_x","d":{"b":{"zz":1,"aa":2}}}"#,
2323        ] {
2324            let decoded: SupervisorRouteConsumer = serde_json::from_str(wire).unwrap();
2325            assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2326        }
2327
2328        for wire in [
2329            r#"{"status":"match","evidence":{"method":"future_probe","zz":1,"aa":2}}"#,
2330            r#"{"status":"match","evidence":{"method":"future_probe","d":{"zz":1,"aa":2}}}"#,
2331        ] {
2332            let decoded: RunningImageAgreement = serde_json::from_str(wire).unwrap();
2333            assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2334        }
2335
2336        let wire = r#"{"status":"mismatch","running":{"detail":{"z":1},"method":"future_running"},"disk":{"method":"future_disk","detail":{"z":1}}}"#;
2337        let decoded: RunningImageAgreement = serde_json::from_str(wire).unwrap();
2338        assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2339
2340        let wire = r#"{"capture":{"state":"captured"},"entries":[{"detail":{"z":1,"a":2},"kind":"future_line"},{"kind":"future_restart","meta":{"b":{"zz":1,"aa":2}}}]}"#;
2341        let decoded: StderrTail = serde_json::from_str(wire).unwrap();
2342        assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2343    }
2344
2345    #[test]
2346    fn tagged_unknown_member_does_not_discard_known_siblings() {
2347        let body = serde_json::json!({
2348            "modules": [{
2349                "module_id": "target",
2350                "routes": [
2351                    {"consumer": {"kind": "future_consumer", "module_id": "m", "detail": {"retry": true}}, "age_ms": 0, "draining": false},
2352                    {"consumer": {"kind": "direct", "connection_id": 7}, "age_ms": 0, "draining": false}
2353                ]
2354            }]
2355        });
2356        let decoded: ClientControlResponse = serde_json::from_value(
2357            serde_json::json!({"op": "supervisor.routes", "modules": body["modules"]}),
2358        )
2359        .unwrap();
2360        let ClientControlResponse::SupervisorRoutes { modules } = decoded else {
2361            panic!("decoded wrong response variant");
2362        };
2363        assert_eq!(modules[0].routes.len(), 2);
2364        assert_eq!(
2365            modules[0].routes[1].consumer,
2366            SupervisorRouteConsumer::Direct { connection_id: 7 }
2367        );
2368    }
2369}
2370
2371#[cfg(test)]
2372mod launch_nonce_redaction_tests {
2373    use super::*;
2374
2375    const NONCE: &str = "nonce-f00dfeed1234abcd";
2376
2377    fn identity() -> ConsumerIdentity {
2378        ConsumerIdentity {
2379            module_id: "wernicke".to_string(),
2380            launch_nonce: NONCE.to_string(),
2381        }
2382    }
2383
2384    #[test]
2385    fn consumer_identity_debug_names_the_module_and_never_the_nonce() {
2386        let printed = format!("{:?}", identity());
2387        assert!(printed.contains("wernicke"), "{printed}");
2388        assert!(!printed.contains(NONCE), "launch nonce printed: {printed}");
2389    }
2390
2391    #[test]
2392    fn route_open_request_debug_never_prints_the_nonce() {
2393        let request = ClientControlRequest::RouteOpen {
2394            target: subc_protocol::RouteTarget::ToolProvider {
2395                module_id: "broca".to_string(),
2396            },
2397            identity: subc_protocol::BindIdentity::new(
2398                PathBuf::from("/tmp/project"),
2399                "test".to_string(),
2400                "session".to_string(),
2401            ),
2402            consumer_identity: Some(identity()),
2403            consumer_capabilities: None,
2404            admission_facts: None,
2405        };
2406        let printed = format!("{request:?}");
2407        assert!(!printed.contains(NONCE), "launch nonce printed: {printed}");
2408    }
2409}