Skip to main content

subc_control/
lib.rs

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