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