Skip to main content

subc_control/
lib.rs

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