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