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