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        RunningExecutablePermissionDenied => "running_executable_permission_denied",
773        ProcessHidden => "process_hidden",
774        SpawnedPathUnreadable => "spawned_path_unreadable",
775        HashFailed => "hash_failed",
776        ProcessIdentityUnconfirmed => "process_identity_unconfirmed",
777    }
778}
779
780/// The identity tier the daemon can honestly report for a route consumer.
781///
782/// A caller that proved a live daemon-issued launch nonce is named `reserved`.
783/// A direct key-holder has no such attestation, so it is reported as `direct`
784/// with its connection counter instead of an invented module name.
785#[derive(Debug, Clone, PartialEq)]
786pub enum SupervisorRouteConsumer {
787    Reserved {
788        module_id: String,
789    },
790    Direct {
791        connection_id: u64,
792    },
793    /// Future discriminator. `body` retains the complete ordered object; `tag`
794    /// is its decoded discriminator projection.
795    Unknown {
796        tag: String,
797        body: OrderedJsonObject,
798    },
799}
800
801/// Whether stderr is being captured for a module, and if not, why not.
802///
803/// A typed state rather than an empty-tail convention. "The module printed
804/// nothing before dying" and "nobody was capturing" send an operator in opposite
805/// directions, and rendering them alike is the defect this op exists to fix --
806/// the same shape as a `detail -` that means both no-detail and never-probed.
807#[derive(Debug, Clone, PartialEq)]
808pub enum StderrCaptureState {
809    /// A reader is attached, or was attached and saw clean EOF. An empty
810    /// `entries` under this state means the module genuinely wrote nothing.
811    Captured,
812    /// Retained entries are valid, but the stderr reader ended before clean EOF.
813    Incomplete { reason: String },
814    /// No reader was attached. `entries` says nothing about what the module wrote.
815    NotCaptured { reason: String },
816    /// Future discriminator. `body` retains the complete ordered object; `tag`
817    /// is its decoded discriminator projection.
818    Unknown {
819        tag: String,
820        body: OrderedJsonObject,
821    },
822}
823
824#[derive(Debug, Clone, PartialEq)]
825pub enum StderrTailEntry {
826    Line {
827        text: String,
828        /// The line was cut at the per-line cap and `text` is a prefix.
829        ///
830        /// Carried as a field rather than left to a marker in `text` so a
831        /// consumer can branch on it without string matching.
832        truncated: bool,
833        /// Wall-clock Unix milliseconds at which the daemon read this line off
834        /// the module's pipe. `None` from a daemon that predates the field; a
835        /// reader must then show no time rather than make one up.
836        at_ms: Option<u64>,
837    },
838    /// The supervisor spawned a new process. Entries after this came from it.
839    ///
840    /// In-band because position is the information: which side of the restart a
841    /// line falls on is unanswerable from a count.
842    ProcessStart,
843    /// Future discriminator. `body` retains the complete ordered object; `tag`
844    /// is its decoded discriminator projection.
845    Unknown {
846        tag: String,
847        body: OrderedJsonObject,
848    },
849}
850
851#[derive(Debug, Serialize, Deserialize)]
852#[serde(tag = "status", rename_all = "snake_case")]
853enum ModuleDeclaredProvenanceWire {
854    Reported { build: ManifestProvenance },
855    Unverifiable,
856}
857
858#[derive(Debug, Serialize, Deserialize)]
859#[serde(tag = "status", rename_all = "snake_case")]
860enum RunningImageAgreementWire {
861    Match {
862        evidence: RunningImageEvidence,
863    },
864    Mismatch {
865        running: RunningImageEvidence,
866        disk: RunningImageEvidence,
867    },
868    Unavailable {
869        reason: RunningImageUnavailableReason,
870    },
871}
872
873#[derive(Debug, Serialize, Deserialize)]
874#[serde(tag = "status", rename_all = "snake_case")]
875enum ReloadPathAgreementWire {
876    Match,
877    Mismatch {
878        configured: PathBuf,
879        spawned_from: PathBuf,
880    },
881    Unavailable {
882        reason: ReloadPathUnavailableReason,
883    },
884}
885
886#[derive(Debug, Serialize, Deserialize)]
887#[serde(tag = "method", rename_all = "snake_case")]
888enum RunningImageEvidenceWire {
889    LinuxProcSha256 {
890        digest: String,
891    },
892    MacosSpawnInode {
893        device: u64,
894        inode: u64,
895    },
896    WindowsSpawnFileId {
897        volume_serial_number: u64,
898        file_id: [u8; 16],
899    },
900}
901
902#[derive(Debug, Serialize, Deserialize)]
903#[serde(tag = "kind", rename_all = "snake_case")]
904enum SupervisorRouteConsumerWire {
905    Reserved { module_id: String },
906    Direct { connection_id: u64 },
907}
908
909#[derive(Debug, Serialize, Deserialize)]
910#[serde(tag = "state", rename_all = "snake_case")]
911enum StderrCaptureStateWire {
912    Captured,
913    Incomplete { reason: String },
914    NotCaptured { reason: String },
915}
916
917#[derive(Debug, Serialize, Deserialize)]
918#[serde(tag = "status", rename_all = "snake_case")]
919enum ChildResourceUsageWire {
920    Measured(ChildResourceReading),
921    Unavailable {
922        reason: ChildResourceUnavailableReason,
923    },
924}
925
926#[derive(Debug, Serialize, Deserialize)]
927#[serde(tag = "kind", rename_all = "snake_case")]
928enum StderrTailEntryWire {
929    Line {
930        text: String,
931        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
932        truncated: bool,
933        // Omitted when absent so a reply without it is byte-identical to what
934        // an older daemon sends. Older decoders ignore the member when present.
935        #[serde(default, skip_serializing_if = "Option::is_none")]
936        at_ms: Option<u64>,
937    },
938    ProcessStart,
939}
940
941/// JSON values whose object members retain wire order at every depth.
942#[derive(Debug, Clone, PartialEq)]
943pub enum OrderedJsonValue {
944    Null,
945    Bool(bool),
946    Number(serde_json::Number),
947    String(String),
948    Array(Vec<Self>),
949    Object(OrderedJsonObject),
950}
951
952/// Ordered JSON members retained for an unknown tagged value.
953#[derive(Debug, Clone, PartialEq)]
954pub struct OrderedJsonObject(Vec<(String, OrderedJsonValue)>);
955
956impl OrderedJsonObject {
957    /// Returns the members in the order they appeared on the wire.
958    pub fn as_entries(&self) -> &[(String, OrderedJsonValue)] {
959        &self.0
960    }
961
962    fn into_value(self) -> serde_json::Value {
963        serde_json::Value::Object(
964            self.0
965                .into_iter()
966                .map(|(key, value)| (key, value.into_value()))
967                .collect(),
968        )
969    }
970}
971
972impl OrderedJsonValue {
973    fn into_value(self) -> serde_json::Value {
974        match self {
975            Self::Null => serde_json::Value::Null,
976            Self::Bool(value) => serde_json::Value::Bool(value),
977            Self::Number(value) => serde_json::Value::Number(value),
978            Self::String(value) => serde_json::Value::String(value),
979            Self::Array(values) => {
980                serde_json::Value::Array(values.into_iter().map(Self::into_value).collect())
981            }
982            Self::Object(value) => value.into_value(),
983        }
984    }
985}
986
987impl Serialize for OrderedJsonValue {
988    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
989    where
990        S: Serializer,
991    {
992        match self {
993            Self::Null => serializer.serialize_unit(),
994            Self::Bool(value) => serializer.serialize_bool(*value),
995            Self::Number(value) => value.serialize(serializer),
996            Self::String(value) => serializer.serialize_str(value),
997            Self::Array(values) => values.serialize(serializer),
998            Self::Object(value) => value.serialize(serializer),
999        }
1000    }
1001}
1002
1003impl<'de> Deserialize<'de> for OrderedJsonValue {
1004    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1005    where
1006        D: Deserializer<'de>,
1007    {
1008        struct OrderedValueVisitor;
1009
1010        impl<'de> Visitor<'de> for OrderedValueVisitor {
1011            type Value = OrderedJsonValue;
1012
1013            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1014                formatter.write_str("a JSON value with ordered object members")
1015            }
1016
1017            fn visit_unit<E>(self) -> Result<Self::Value, E>
1018            where
1019                E: serde::de::Error,
1020            {
1021                Ok(OrderedJsonValue::Null)
1022            }
1023
1024            fn visit_none<E>(self) -> Result<Self::Value, E>
1025            where
1026                E: serde::de::Error,
1027            {
1028                Ok(OrderedJsonValue::Null)
1029            }
1030
1031            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1032            where
1033                D: Deserializer<'de>,
1034            {
1035                OrderedJsonValue::deserialize(deserializer)
1036            }
1037
1038            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
1039            where
1040                E: serde::de::Error,
1041            {
1042                Ok(OrderedJsonValue::Bool(value))
1043            }
1044
1045            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
1046            where
1047                E: serde::de::Error,
1048            {
1049                Ok(OrderedJsonValue::Number(value.into()))
1050            }
1051
1052            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
1053            where
1054                E: serde::de::Error,
1055            {
1056                Ok(OrderedJsonValue::Number(value.into()))
1057            }
1058
1059            fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
1060            where
1061                E: serde::de::Error,
1062            {
1063                serde_json::Number::from_f64(value)
1064                    .map(OrderedJsonValue::Number)
1065                    .ok_or_else(|| E::custom("non-finite JSON number"))
1066            }
1067
1068            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1069            where
1070                E: serde::de::Error,
1071            {
1072                Ok(OrderedJsonValue::String(value.to_owned()))
1073            }
1074
1075            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
1076            where
1077                E: serde::de::Error,
1078            {
1079                Ok(OrderedJsonValue::String(value))
1080            }
1081
1082            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1083            where
1084                A: SeqAccess<'de>,
1085            {
1086                let mut values = Vec::new();
1087                while let Some(value) = sequence.next_element()? {
1088                    values.push(value);
1089                }
1090                Ok(OrderedJsonValue::Array(values))
1091            }
1092
1093            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1094            where
1095                A: MapAccess<'de>,
1096            {
1097                let mut entries = Vec::new();
1098                while let Some((key, value)) = map.next_entry()? {
1099                    entries.push((key, value));
1100                }
1101                Ok(OrderedJsonValue::Object(OrderedJsonObject(entries)))
1102            }
1103        }
1104
1105        deserializer.deserialize_any(OrderedValueVisitor)
1106    }
1107}
1108
1109impl Serialize for OrderedJsonObject {
1110    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1111    where
1112        S: Serializer,
1113    {
1114        let mut map = serializer.serialize_map(Some(self.0.len()))?;
1115        for (key, value) in &self.0 {
1116            map.serialize_entry(key, value)?;
1117        }
1118        map.end()
1119    }
1120}
1121
1122impl<'de> Deserialize<'de> for OrderedJsonObject {
1123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1124    where
1125        D: Deserializer<'de>,
1126    {
1127        struct OrderedObjectVisitor;
1128
1129        impl<'de> Visitor<'de> for OrderedObjectVisitor {
1130            type Value = OrderedJsonObject;
1131
1132            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1133                formatter.write_str("an object with ordered JSON members")
1134            }
1135
1136            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1137            where
1138                A: MapAccess<'de>,
1139            {
1140                let mut entries = Vec::new();
1141                while let Some((key, value)) = map.next_entry()? {
1142                    entries.push((key, value));
1143                }
1144                Ok(OrderedJsonObject(entries))
1145            }
1146        }
1147
1148        deserializer.deserialize_map(OrderedObjectVisitor)
1149    }
1150}
1151
1152fn read_tagged<'de, D>(
1153    deserializer: D,
1154    field: &'static str,
1155) -> Result<(String, OrderedJsonObject), D::Error>
1156where
1157    D: Deserializer<'de>,
1158{
1159    let body = OrderedJsonObject::deserialize(deserializer)?;
1160    let mut tag = None;
1161    for (key, value) in body.as_entries() {
1162        if key != field {
1163            continue;
1164        }
1165        if tag.is_some() {
1166            return Err(D::Error::custom(format!(
1167                "tagged object has duplicate `{field}` field"
1168            )));
1169        }
1170        let OrderedJsonValue::String(value) = value else {
1171            return Err(D::Error::custom(format!(
1172                "tagged object has no string `{field}` field"
1173            )));
1174        };
1175        tag = Some(value);
1176    }
1177    let Some(tag) = tag else {
1178        return Err(D::Error::custom(format!(
1179            "tagged object has no string `{field}` field"
1180        )));
1181    };
1182    Ok((tag.to_string(), body))
1183}
1184
1185fn read_ordered_tagged(
1186    value: OrderedJsonValue,
1187    field: &'static str,
1188) -> Result<(String, OrderedJsonObject), String> {
1189    let OrderedJsonValue::Object(body) = value else {
1190        return Err(format!("expected tagged object with `{field}` field"));
1191    };
1192    let mut tag = None;
1193    for (key, value) in body.as_entries() {
1194        if key != field {
1195            continue;
1196        }
1197        if tag.is_some() {
1198            return Err(format!("tagged object has duplicate `{field}` field"));
1199        }
1200        let OrderedJsonValue::String(value) = value else {
1201            return Err(format!("tagged object has no string `{field}` field"));
1202        };
1203        tag = Some(value);
1204    }
1205    let Some(tag) = tag else {
1206        return Err(format!("tagged object has no string `{field}` field"));
1207    };
1208    Ok((tag.to_string(), body))
1209}
1210
1211fn ordered_field<'a>(body: &'a OrderedJsonObject, field: &str) -> Option<&'a OrderedJsonValue> {
1212    body.as_entries()
1213        .iter()
1214        .find_map(|(key, value)| (key == field).then_some(value))
1215}
1216
1217fn ordered_string(body: &OrderedJsonObject, field: &str) -> Result<String, String> {
1218    match ordered_field(body, field) {
1219        Some(OrderedJsonValue::String(value)) => Ok(value.clone()),
1220        Some(_) => Err(format!("tagged object field `{field}` is not a string")),
1221        None => Err(format!("tagged object has no `{field}` field")),
1222    }
1223}
1224
1225fn decode_running_image_evidence(value: OrderedJsonValue) -> Result<RunningImageEvidence, String> {
1226    let (tag, body) = read_ordered_tagged(value, "method")?;
1227    match tag.as_str() {
1228        "linux_proc_sha256" => Ok(RunningImageEvidence::LinuxProcSha256 {
1229            digest: ordered_string(&body, "digest")?,
1230        }),
1231        "macos_spawn_inode" => {
1232            let device = ordered_field(&body, "device")
1233                .and_then(|value| match value {
1234                    OrderedJsonValue::Number(number) => number.as_u64(),
1235                    _ => None,
1236                })
1237                .ok_or_else(|| "tagged object has no unsigned `device` field".to_string())?;
1238            let inode = ordered_field(&body, "inode")
1239                .and_then(|value| match value {
1240                    OrderedJsonValue::Number(number) => number.as_u64(),
1241                    _ => None,
1242                })
1243                .ok_or_else(|| "tagged object has no unsigned `inode` field".to_string())?;
1244            Ok(RunningImageEvidence::MacosSpawnInode { device, inode })
1245        }
1246        "windows_spawn_file_id" => {
1247            let wire: RunningImageEvidenceWire = serde_json::from_value(
1248                serde_json::to_value(&body).map_err(|error| error.to_string())?,
1249            )
1250            .map_err(|error| error.to_string())?;
1251            match wire {
1252                RunningImageEvidenceWire::WindowsSpawnFileId {
1253                    volume_serial_number,
1254                    file_id,
1255                } => Ok(RunningImageEvidence::WindowsSpawnFileId {
1256                    volume_serial_number,
1257                    file_id,
1258                }),
1259                _ => unreachable!(),
1260            }
1261        }
1262        _ => Ok(RunningImageEvidence::Unknown { tag, body }),
1263    }
1264}
1265
1266impl Serialize for ModuleDeclaredProvenance {
1267    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1268    where
1269        S: Serializer,
1270    {
1271        match self {
1272            Self::Reported { build } => ModuleDeclaredProvenanceWire::Reported {
1273                build: build.clone(),
1274            }
1275            .serialize(serializer),
1276            Self::Unverifiable => ModuleDeclaredProvenanceWire::Unverifiable.serialize(serializer),
1277            Self::Unknown { body, .. } => body.serialize(serializer),
1278        }
1279    }
1280}
1281
1282impl<'de> Deserialize<'de> for ModuleDeclaredProvenance {
1283    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1284    where
1285        D: serde::Deserializer<'de>,
1286    {
1287        let (tag, value) = read_tagged(deserializer, "status")?;
1288        match tag.as_str() {
1289            "reported" => match serde_json::from_value(value.into_value())
1290                .map_err(D::Error::custom)?
1291            {
1292                ModuleDeclaredProvenanceWire::Reported { build } => Ok(Self::Reported { build }),
1293                ModuleDeclaredProvenanceWire::Unverifiable => unreachable!(),
1294            },
1295            "unverifiable" => {
1296                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1297                    ModuleDeclaredProvenanceWire::Unverifiable => Ok(Self::Unverifiable),
1298                    ModuleDeclaredProvenanceWire::Reported { .. } => unreachable!(),
1299                }
1300            }
1301            _ => Ok(Self::Unknown { tag, body: value }),
1302        }
1303    }
1304}
1305
1306impl Serialize for RunningImageAgreement {
1307    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1308    where
1309        S: Serializer,
1310    {
1311        match self {
1312            Self::Match { evidence } => RunningImageAgreementWire::Match {
1313                evidence: evidence.clone(),
1314            }
1315            .serialize(serializer),
1316            Self::Mismatch { running, disk } => RunningImageAgreementWire::Mismatch {
1317                running: running.clone(),
1318                disk: disk.clone(),
1319            }
1320            .serialize(serializer),
1321            Self::Unavailable { reason } => RunningImageAgreementWire::Unavailable {
1322                reason: reason.clone(),
1323            }
1324            .serialize(serializer),
1325            Self::Unknown { body, .. } => body.serialize(serializer),
1326        }
1327    }
1328}
1329
1330impl Serialize for ReloadPathAgreement {
1331    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1332    where
1333        S: Serializer,
1334    {
1335        match self {
1336            Self::Match => ReloadPathAgreementWire::Match.serialize(serializer),
1337            Self::Mismatch {
1338                configured,
1339                spawned_from,
1340            } => ReloadPathAgreementWire::Mismatch {
1341                configured: configured.clone(),
1342                spawned_from: spawned_from.clone(),
1343            }
1344            .serialize(serializer),
1345            Self::Unavailable { reason } => ReloadPathAgreementWire::Unavailable {
1346                reason: reason.clone(),
1347            }
1348            .serialize(serializer),
1349            Self::Unknown { body, .. } => body.serialize(serializer),
1350        }
1351    }
1352}
1353
1354impl<'de> Deserialize<'de> for ReloadPathAgreement {
1355    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1356    where
1357        D: Deserializer<'de>,
1358    {
1359        let (tag, body) = read_tagged(deserializer, "status")?;
1360        match tag.as_str() {
1361            "match" => Ok(Self::Match),
1362            "mismatch" => {
1363                match serde_json::from_value(body.into_value()).map_err(D::Error::custom)? {
1364                    ReloadPathAgreementWire::Mismatch {
1365                        configured,
1366                        spawned_from,
1367                    } => Ok(Self::Mismatch {
1368                        configured,
1369                        spawned_from,
1370                    }),
1371                    _ => unreachable!(),
1372                }
1373            }
1374            "unavailable" => match serde_json::from_value(body.into_value())
1375                .map_err(D::Error::custom)?
1376            {
1377                ReloadPathAgreementWire::Unavailable { reason } => Ok(Self::Unavailable { reason }),
1378                _ => unreachable!(),
1379            },
1380            _ => Ok(Self::Unknown { tag, body }),
1381        }
1382    }
1383}
1384
1385impl<'de> Deserialize<'de> for RunningImageAgreement {
1386    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1387    where
1388        D: serde::Deserializer<'de>,
1389    {
1390        let (tag, value) = read_tagged(deserializer, "status")?;
1391        match tag.as_str() {
1392            "match" => Ok(Self::Match {
1393                evidence: decode_running_image_evidence(
1394                    ordered_field(&value, "evidence")
1395                        .cloned()
1396                        .ok_or_else(|| D::Error::custom("tagged object has no `evidence` field"))?,
1397                )
1398                .map_err(D::Error::custom)?,
1399            }),
1400            "mismatch" => Ok(Self::Mismatch {
1401                running: decode_running_image_evidence(
1402                    ordered_field(&value, "running")
1403                        .cloned()
1404                        .ok_or_else(|| D::Error::custom("tagged object has no `running` field"))?,
1405                )
1406                .map_err(D::Error::custom)?,
1407                disk: decode_running_image_evidence(
1408                    ordered_field(&value, "disk")
1409                        .cloned()
1410                        .ok_or_else(|| D::Error::custom("tagged object has no `disk` field"))?,
1411                )
1412                .map_err(D::Error::custom)?,
1413            }),
1414            "unavailable" => Ok(Self::Unavailable {
1415                reason: serde_json::from_value(
1416                    ordered_field(&value, "reason")
1417                        .cloned()
1418                        .ok_or_else(|| D::Error::custom("tagged object has no `reason` field"))?
1419                        .into_value(),
1420                )
1421                .map_err(D::Error::custom)?,
1422            }),
1423            _ => Ok(Self::Unknown { tag, body: value }),
1424        }
1425    }
1426}
1427
1428impl Serialize for ChildResourceUsage {
1429    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1430    where
1431        S: Serializer,
1432    {
1433        match self {
1434            Self::Measured(reading) => {
1435                ChildResourceUsageWire::Measured(reading.clone()).serialize(serializer)
1436            }
1437            Self::Unavailable { reason } => ChildResourceUsageWire::Unavailable {
1438                reason: reason.clone(),
1439            }
1440            .serialize(serializer),
1441            Self::Unknown { body, .. } => body.serialize(serializer),
1442        }
1443    }
1444}
1445
1446impl<'de> Deserialize<'de> for ChildResourceUsage {
1447    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1448    where
1449        D: Deserializer<'de>,
1450    {
1451        let (tag, body) = read_tagged(deserializer, "status")?;
1452        match tag.as_str() {
1453            "measured" | "unavailable" => {
1454                match serde_json::from_value(body.into_value()).map_err(D::Error::custom)? {
1455                    ChildResourceUsageWire::Measured(reading) => Ok(Self::Measured(reading)),
1456                    ChildResourceUsageWire::Unavailable { reason } => {
1457                        Ok(Self::Unavailable { reason })
1458                    }
1459                }
1460            }
1461            _ => Ok(Self::Unknown { tag, body }),
1462        }
1463    }
1464}
1465
1466impl Serialize for RunningImageEvidence {
1467    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1468    where
1469        S: Serializer,
1470    {
1471        match self {
1472            Self::LinuxProcSha256 { digest } => RunningImageEvidenceWire::LinuxProcSha256 {
1473                digest: digest.clone(),
1474            }
1475            .serialize(serializer),
1476            Self::MacosSpawnInode { device, inode } => RunningImageEvidenceWire::MacosSpawnInode {
1477                device: *device,
1478                inode: *inode,
1479            }
1480            .serialize(serializer),
1481            Self::WindowsSpawnFileId {
1482                volume_serial_number,
1483                file_id,
1484            } => RunningImageEvidenceWire::WindowsSpawnFileId {
1485                volume_serial_number: *volume_serial_number,
1486                file_id: *file_id,
1487            }
1488            .serialize(serializer),
1489            Self::Unknown { body, .. } => body.serialize(serializer),
1490        }
1491    }
1492}
1493
1494impl<'de> Deserialize<'de> for RunningImageEvidence {
1495    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1496    where
1497        D: serde::Deserializer<'de>,
1498    {
1499        let (tag, value) = read_tagged(deserializer, "method")?;
1500        match tag.as_str() {
1501            "linux_proc_sha256" => {
1502                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1503                    RunningImageEvidenceWire::LinuxProcSha256 { digest } => {
1504                        Ok(Self::LinuxProcSha256 { digest })
1505                    }
1506                    _ => unreachable!(),
1507                }
1508            }
1509            "macos_spawn_inode" => {
1510                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1511                    RunningImageEvidenceWire::MacosSpawnInode { device, inode } => {
1512                        Ok(Self::MacosSpawnInode { device, inode })
1513                    }
1514                    _ => unreachable!(),
1515                }
1516            }
1517            "windows_spawn_file_id" => {
1518                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1519                    RunningImageEvidenceWire::WindowsSpawnFileId {
1520                        volume_serial_number,
1521                        file_id,
1522                    } => Ok(Self::WindowsSpawnFileId {
1523                        volume_serial_number,
1524                        file_id,
1525                    }),
1526                    _ => unreachable!(),
1527                }
1528            }
1529            _ => Ok(Self::Unknown { tag, body: value }),
1530        }
1531    }
1532}
1533
1534impl Serialize for SupervisorRouteConsumer {
1535    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1536    where
1537        S: Serializer,
1538    {
1539        match self {
1540            Self::Reserved { module_id } => SupervisorRouteConsumerWire::Reserved {
1541                module_id: module_id.clone(),
1542            }
1543            .serialize(serializer),
1544            Self::Direct { connection_id } => SupervisorRouteConsumerWire::Direct {
1545                connection_id: *connection_id,
1546            }
1547            .serialize(serializer),
1548            Self::Unknown { body, .. } => body.serialize(serializer),
1549        }
1550    }
1551}
1552
1553impl<'de> Deserialize<'de> for SupervisorRouteConsumer {
1554    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1555    where
1556        D: serde::Deserializer<'de>,
1557    {
1558        let (tag, value) = read_tagged(deserializer, "kind")?;
1559        match tag.as_str() {
1560            "reserved" => {
1561                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1562                    SupervisorRouteConsumerWire::Reserved { module_id } => {
1563                        Ok(Self::Reserved { module_id })
1564                    }
1565                    _ => unreachable!(),
1566                }
1567            }
1568            "direct" => {
1569                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1570                    SupervisorRouteConsumerWire::Direct { connection_id } => {
1571                        Ok(Self::Direct { connection_id })
1572                    }
1573                    _ => unreachable!(),
1574                }
1575            }
1576            _ => Ok(Self::Unknown { tag, body: value }),
1577        }
1578    }
1579}
1580
1581impl Serialize for StderrCaptureState {
1582    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1583    where
1584        S: Serializer,
1585    {
1586        match self {
1587            Self::Captured => StderrCaptureStateWire::Captured.serialize(serializer),
1588            Self::Incomplete { reason } => StderrCaptureStateWire::Incomplete {
1589                reason: reason.clone(),
1590            }
1591            .serialize(serializer),
1592            Self::NotCaptured { reason } => StderrCaptureStateWire::NotCaptured {
1593                reason: reason.clone(),
1594            }
1595            .serialize(serializer),
1596            Self::Unknown { body, .. } => body.serialize(serializer),
1597        }
1598    }
1599}
1600
1601impl<'de> Deserialize<'de> for StderrCaptureState {
1602    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1603    where
1604        D: serde::Deserializer<'de>,
1605    {
1606        let (tag, value) = read_tagged(deserializer, "state")?;
1607        match tag.as_str() {
1608            "captured" => {
1609                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1610                    StderrCaptureStateWire::Captured => Ok(Self::Captured),
1611                    _ => unreachable!(),
1612                }
1613            }
1614            "incomplete" => match serde_json::from_value(value.into_value())
1615                .map_err(D::Error::custom)?
1616            {
1617                StderrCaptureStateWire::Incomplete { reason } => Ok(Self::Incomplete { reason }),
1618                _ => unreachable!(),
1619            },
1620            "not_captured" => match serde_json::from_value(value.into_value())
1621                .map_err(D::Error::custom)?
1622            {
1623                StderrCaptureStateWire::NotCaptured { reason } => Ok(Self::NotCaptured { reason }),
1624                _ => unreachable!(),
1625            },
1626            _ => Ok(Self::Unknown { tag, body: value }),
1627        }
1628    }
1629}
1630
1631impl Serialize for StderrTailEntry {
1632    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1633    where
1634        S: Serializer,
1635    {
1636        match self {
1637            Self::Line {
1638                text,
1639                truncated,
1640                at_ms,
1641            } => StderrTailEntryWire::Line {
1642                text: text.clone(),
1643                truncated: *truncated,
1644                at_ms: *at_ms,
1645            }
1646            .serialize(serializer),
1647            Self::ProcessStart => StderrTailEntryWire::ProcessStart.serialize(serializer),
1648            Self::Unknown { body, .. } => body.serialize(serializer),
1649        }
1650    }
1651}
1652
1653impl<'de> Deserialize<'de> for StderrTailEntry {
1654    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1655    where
1656        D: serde::Deserializer<'de>,
1657    {
1658        let (tag, value) = read_tagged(deserializer, "kind")?;
1659        match tag.as_str() {
1660            "line" => match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1661                StderrTailEntryWire::Line {
1662                    text,
1663                    truncated,
1664                    at_ms,
1665                } => Ok(Self::Line {
1666                    text,
1667                    truncated,
1668                    at_ms,
1669                }),
1670                _ => unreachable!(),
1671            },
1672            "process_start" => {
1673                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1674                    StderrTailEntryWire::ProcessStart => Ok(Self::ProcessStart),
1675                    _ => unreachable!(),
1676                }
1677            }
1678            _ => Ok(Self::Unknown { tag, body: value }),
1679        }
1680    }
1681}
1682
1683fn is_zero_u64(value: &u64) -> bool {
1684    *value == 0
1685}
1686
1687fn default_true() -> bool {
1688    true
1689}
1690
1691/// Bounded terminal history for one module, oldest retained record first.
1692#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1693pub struct TerminalHistory {
1694    /// Unix milliseconds at the current daemon's start; entries may predate it.
1695    pub daemon_started_at_ms: u64,
1696    pub entries: Vec<TerminalEntry>,
1697    /// Exits evicted by the current daemon's ring, possibly recovered from its
1698    /// journal. Not a count of missing exits: expired journal totals are unknown.
1699    #[serde(default, skip_serializing_if = "is_zero_u64")]
1700    pub dropped: u64,
1701    /// Unparseable or incomplete lines across the shared journal, including
1702    /// lines whose module cannot be determined. Zero on older daemons.
1703    #[serde(default, skip_serializing_if = "is_zero_u64")]
1704    pub journal_skipped_lines: u64,
1705    /// Files that could not be read completely, excluding absent generations.
1706    #[serde(default, skip_serializing_if = "is_zero_u64")]
1707    pub journal_read_errors: u64,
1708    /// Failed journal appends across all modules in the current daemon.
1709    #[serde(default, skip_serializing_if = "is_zero_u64")]
1710    pub journal_write_failures: u64,
1711}
1712
1713/// One terminal child exit and the supervisor action it selected.
1714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1715pub struct TerminalEntry {
1716    /// Opaque identity of the daemon that observed this exit; absent on older
1717    /// daemons. Different tokens mean different lifetimes, not chronological order.
1718    #[serde(default, skip_serializing_if = "Option::is_none")]
1719    pub daemon_incarnation: Option<String>,
1720    #[serde(default, skip_serializing_if = "Option::is_none")]
1721    pub exit_code: Option<i32>,
1722    #[serde(default, skip_serializing_if = "Option::is_none")]
1723    pub exit_signal: Option<i32>,
1724    pub at_ms: u64,
1725    pub disposition: TerminalDisposition,
1726    /// Supervisor classification of this exit. Absent on daemons that predate
1727    /// the field; unknown future kinds remain readable instead of failing the
1728    /// enclosing terminal record.
1729    #[serde(default, skip_serializing_if = "Option::is_none")]
1730    pub exit_kind: Option<TerminalExitKind>,
1731    /// Why the supervisor chose this disposition, when the disposition alone
1732    /// does not say. A `failed` record carries the exhausted crash budget here
1733    /// (`crash budget exhausted: max_restarts=3 within window_secs=600`), which
1734    /// is the difference between an operator seeing "it failed" and seeing which
1735    /// limit stopped it. Prose for humans: render it, never parse it. Absent for
1736    /// ordinary dispositions and on daemons predating the field.
1737    #[serde(default, skip_serializing_if = "Option::is_none")]
1738    pub disposition_detail: Option<String>,
1739}
1740
1741/// Exit classification carried by supervisor history and census records.
1742///
1743/// This is an open string enum so future daemon variants degrade to a readable
1744/// unknown kind rather than making a consumer discard the enclosing record.
1745#[derive(Debug, Clone, PartialEq, Eq)]
1746pub enum TerminalExitKind {
1747    Clean,
1748    Crash,
1749    DeliberateSeverance,
1750    Unknown(String),
1751}
1752
1753impl TerminalExitKind {
1754    fn wire_name(&self) -> &str {
1755        match self {
1756            Self::Clean => "clean",
1757            Self::Crash => "crash",
1758            Self::DeliberateSeverance => "deliberate_severance",
1759            Self::Unknown(value) => value,
1760        }
1761    }
1762}
1763
1764impl Serialize for TerminalExitKind {
1765    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1766    where
1767        S: serde::Serializer,
1768    {
1769        serializer.serialize_str(self.wire_name())
1770    }
1771}
1772
1773impl<'de> Deserialize<'de> for TerminalExitKind {
1774    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1775    where
1776        D: serde::Deserializer<'de>,
1777    {
1778        let value = String::deserialize(deserializer)?;
1779        Ok(match value.as_str() {
1780            "clean" => Self::Clean,
1781            "crash" => Self::Crash,
1782            "deliberate_severance" => Self::DeliberateSeverance,
1783            _ => Self::Unknown(value),
1784        })
1785    }
1786}
1787
1788open_string_enum! {
1789    /// The supervisor disposition selected after observing a terminal exit.
1790    TerminalDisposition {
1791        Stopped => "stopped",
1792        Disabled => "disabled",
1793        Failed => "failed",
1794        Restarting => "restarting",
1795        /// The child exited after the daemon had begun its own announced
1796        /// shutdown, whatever its exit code or signal. Such an exit is not
1797        /// a crash and is never followed by a respawn. Readers that predate
1798        /// this value decode it as `Unknown("daemon_shutdown")`.
1799        DaemonShutdown => "daemon_shutdown",
1800    }
1801}
1802
1803#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1804#[serde(rename_all = "snake_case")]
1805pub enum PollKind {
1806    Status,
1807    Liveness,
1808}
1809
1810/// A registered module's catalog projection.
1811///
1812/// Use [`CatalogEntry::new`] and its `with_*` methods to construct entries so
1813/// future fields do not require changes to callers.
1814/// Struct literals and functional record updates are not supported outside this crate:
1815///
1816/// ```compile_fail,E0639
1817/// use subc_control::CatalogEntry;
1818/// let entry = CatalogEntry::new("provider", vec![], vec![]);
1819/// let entry = CatalogEntry { ready: false, ..entry };
1820/// ```
1821#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1822#[non_exhaustive]
1823pub struct CatalogEntry {
1824    pub module_id: String,
1825    /// Whether the registered module currently accepts new route binds.
1826    ///
1827    /// This is the module's EFFECTIVE readiness: its own declared readiness
1828    /// AND every one of its `need: required` capabilities having a registered
1829    /// provider. It is exactly the condition `route.open` checks, so a caller
1830    /// reading `false` here will be refused with `module_warming`; `not_ready`
1831    /// says why.
1832    ///
1833    /// Older daemons omit this field and are interpreted as ready. Daemons that
1834    /// predate `not_ready` report declared readiness only.
1835    #[serde(default = "default_true")]
1836    pub ready: bool,
1837    /// Why `ready` is false, in the same shape `route.open` puts in the
1838    /// `detail` of its `module_warming` refusal. Absent when the module is
1839    /// ready, and absent from daemons that predate the field.
1840    #[serde(default, skip_serializing_if = "Option::is_none")]
1841    pub not_ready: Option<NotReadyReason>,
1842    /// The registered module's self-declared build version, projected from its
1843    /// manifest so a consumer can tell WHICH BUILD of a module it is talking
1844    /// to at connect time.
1845    ///
1846    /// Without this, a client compiled against a module's current source reads
1847    /// a contract that is true of the repository and false of the running
1848    /// process -- the types match, the JSON decodes, and the meaning has
1849    /// changed. That failure carries no error to notice; the version in the
1850    /// catalog turns a semantic skew into a log line at connect instead of a
1851    /// wrong sentence on a user's screen.
1852    ///
1853    /// Optional on the wire only because entries serialized by older daemons
1854    /// lack it: absent means "daemon predates the field", never "module has
1855    /// no version" (the manifest field is required at registration).
1856    ///
1857    /// The reading is ARMED BY OBSERVATION, not by this documentation: until
1858    /// a consumer has seen at least one populated entry from the daemon it is
1859    /// connected to, an all-None catalog is indistinguishable from an old
1860    /// daemon, and a client shipping the documented reading against it would
1861    /// hold a guarantee it does not have.
1862    #[serde(default, skip_serializing_if = "Option::is_none")]
1863    pub module_version: Option<String>,
1864    pub roles: Vec<ProviderRole>,
1865    pub control_ops: Vec<String>,
1866    /// Static capability declarations from the registering module's manifest.
1867    ///
1868    /// Optional on the wire so consumers connected to a daemon that predates the
1869    /// capability grammar retain their existing catalog decoding behavior.
1870    #[serde(default, skip_serializing_if = "Option::is_none")]
1871    pub capabilities: Option<CapabilityDeclarations>,
1872    /// Self-signal declarations mirrored verbatim from the registering module's
1873    /// manifest. The daemon relays these declarations without interpreting them.
1874    #[serde(default, skip_serializing_if = "Option::is_none")]
1875    pub self_signals: Option<Vec<SelfSignalDeclaration>>,
1876    /// Event declarations mirrored verbatim from the registering module's manifest.
1877    /// Absent when the manifest omits `events`; an empty array records an affirmative
1878    /// declaration that the module publishes no events.
1879    #[serde(default, skip_serializing_if = "Option::is_none")]
1880    pub events: Option<Vec<EventDeclaration>>,
1881}
1882
1883impl CatalogEntry {
1884    /// Construct an entry with its required fields and wire-compatible defaults.
1885    #[must_use]
1886    pub fn new(
1887        module_id: impl Into<String>,
1888        roles: Vec<ProviderRole>,
1889        control_ops: Vec<String>,
1890    ) -> Self {
1891        Self {
1892            module_id: module_id.into(),
1893            ready: default_true(),
1894            not_ready: None,
1895            module_version: None,
1896            roles,
1897            control_ops,
1898            capabilities: None,
1899            self_signals: None,
1900            events: None,
1901        }
1902    }
1903
1904    /// Set the module's effective readiness.
1905    #[must_use]
1906    pub fn with_ready(mut self, ready: bool) -> Self {
1907        self.ready = ready;
1908        self
1909    }
1910
1911    /// Set or clear the reason the module is not ready.
1912    #[must_use]
1913    pub fn with_not_ready(mut self, not_ready: Option<NotReadyReason>) -> Self {
1914        self.not_ready = not_ready;
1915        self
1916    }
1917
1918    /// Set or clear the module's self-declared version.
1919    #[must_use]
1920    pub fn with_module_version(mut self, module_version: Option<String>) -> Self {
1921        self.module_version = module_version;
1922        self
1923    }
1924
1925    /// Set or clear the module's static capability declarations.
1926    #[must_use]
1927    pub fn with_capabilities(mut self, capabilities: Option<CapabilityDeclarations>) -> Self {
1928        self.capabilities = capabilities;
1929        self
1930    }
1931
1932    /// Set or clear the module's self-signal declarations.
1933    #[must_use]
1934    pub fn with_self_signals(mut self, self_signals: Option<Vec<SelfSignalDeclaration>>) -> Self {
1935        self.self_signals = self_signals;
1936        self
1937    }
1938
1939    /// Set or clear the event declarations mirrored from the module manifest.
1940    #[must_use]
1941    pub fn with_events(mut self, events: Option<Vec<EventDeclaration>>) -> Self {
1942        self.events = events;
1943        self
1944    }
1945}
1946
1947/// Why a registered module is not accepting new route binds.
1948#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1949pub struct NotReadyReason {
1950    /// `declared_not_ready` when the module itself said it is not ready, or
1951    /// `required_capability_unprovided` when a capability it declares
1952    /// `need: required` has no registered provider. Open vocabulary: a newer
1953    /// daemon may add reasons.
1954    pub reason: String,
1955    /// For `required_capability_unprovided`, the lexicographically first
1956    /// required capability that has no registered provider.
1957    #[serde(default, skip_serializing_if = "Option::is_none")]
1958    pub capability: Option<String>,
1959}
1960
1961impl NotReadyReason {
1962    pub const DECLARED_NOT_READY: &'static str = "declared_not_ready";
1963    pub const REQUIRED_CAPABILITY_UNPROVIDED: &'static str = "required_capability_unprovided";
1964}
1965
1966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1967pub struct CapabilityRequirementStatus {
1968    pub consumer: String,
1969    pub capability: String,
1970    pub need: String,
1971    pub verdict: String,
1972    pub episode_seq: u64,
1973    pub config_satisfiable: bool,
1974    pub runtime_available: bool,
1975    pub detail: String,
1976}
1977
1978#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1979pub struct SupervisorRescanResult {
1980    pub added: Vec<String>,
1981    pub removed: Vec<String>,
1982    pub changed_pending_reload: Vec<String>,
1983    /// Modules whose enabled flag differs between config and running state.
1984    ///
1985    /// Rescan calls `set_enabled` for these, so omitting them made the preview
1986    /// describe two of the three mutation classes it performs. A module changing
1987    /// only its enabled flag landed in no bucket at all -- not added, removed or
1988    /// changed, and deliberately not counted as unchanged either -- so the sole
1989    /// evidence was that the buckets no longer summed to the configured module
1990    /// count. A preview is consulted precisely when someone is being careful,
1991    /// which is the worst place to under-report.
1992    ///
1993    /// Empty is skipped so consumers written against the older shape keep
1994    /// parsing.
1995    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1996    pub enabled_changes: Vec<String>,
1997    pub unchanged: u32,
1998    /// True when this reconciliation was computed but NOT applied.
1999    ///
2000    /// Carried on the result rather than left to the caller's memory of what it
2001    /// asked for. A preview and an execution are otherwise byte-identical, so a
2002    /// reader who meets this output later -- in a log, a transcript, a pasted
2003    /// snippet -- cannot tell which one happened. Absent when false, so existing
2004    /// consumers see the shape they already parse.
2005    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2006    pub preview: bool,
2007    /// Config sections that changed but which rescan CANNOT apply, so the
2008    /// operator learns a daemon restart is required from the command they just
2009    /// ran rather than from the journal.
2010    ///
2011    /// The daemon has always detected this and logged a warning. A warning in a
2012    /// log is addressed to whoever is reading the log, and the person who just
2013    /// edited the config is by construction looking at the CLI instead: reported
2014    /// by an outside contributor after a module crash-looped through four
2015    /// respawns because a new top-level `storage` section was silently not
2016    /// applied, diagnosable only by journal archaeology.
2017    ///
2018    /// Names the SECTIONS rather than a boolean, because "something else
2019    /// changed" sends the operator back to diffing their own file -- which is
2020    /// the work the message exists to save.
2021    ///
2022    /// Empty is skipped, so consumers written against the older shape keep
2023    /// parsing.
2024    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2025    pub restart_required: Vec<String>,
2026    /// Required capabilities that a dry-run's resulting module set would leave
2027    /// unprovided. Rows are human-readable because the preview is an operator
2028    /// explanation, not a second manifest schema.
2029    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2030    pub capability_warnings: Vec<String>,
2031}
2032
2033/// Which wire protocol a supervised module speaks to subc, as DECLARED in
2034/// daemon config. Never inferred from observed behaviour.
2035///
2036/// The distinction this exists to keep is between a module that should have
2037/// registered and has not yet, and one that never will. A `Subc` module that has
2038/// not registered is a subc module that is LATE -- it may be booting, it may be
2039/// wedged, and the supervisor's health probing and restart escalation are the
2040/// right response. A `None` module is a third-party process (the NATS server is
2041/// the first) that subc launches, supervises, and stops, and that is all: it
2042/// speaks no subc wire at all, so treating its silence as a fault would restart
2043/// a perfectly healthy process forever.
2044///
2045/// Inferring the difference from "has not registered within N seconds" would
2046/// collapse exactly the two cases that must stay apart, which is why this is a
2047/// declaration.
2048#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2049#[serde(rename_all = "snake_case")]
2050pub enum ModuleProtocol {
2051    /// The module registers over channel 0, answers `health.check`, and can
2052    /// serve routes. Every module predating this field is one of these, which is
2053    /// why it is the default.
2054    #[default]
2055    Subc,
2056    /// The module speaks no subc wire. It is supervised as a process only.
2057    ///
2058    /// A clean exit (status 0) that the daemon did not request is restarted as
2059    /// a crash, counting against the restart budget, instead of being recorded
2060    /// as a stop. Such a module is usually a stock program that exits 0 on
2061    /// SIGTERM, so a stray outside signal would otherwise leave it down for
2062    /// good; a subc-wire module re-raises SIGTERM instead, so this rule is not
2063    /// needed for it.
2064    None,
2065}
2066
2067/// A supervised module's current state and process observations.
2068///
2069/// Use [`SupervisorEntry::new`] and its `with_*` methods to construct entries so
2070/// future fields do not require changes to callers.
2071/// Struct literals and functional record updates are not supported outside this crate:
2072///
2073/// ```compile_fail,E0639
2074/// use subc_control::{SupervisorEntry, SupervisorHealthStatus};
2075/// let entry = SupervisorEntry::new("provider", "running", true, true, SupervisorHealthStatus::Unknown);
2076/// let entry = SupervisorEntry { resources: None, ..entry };
2077/// ```
2078#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2079#[non_exhaustive]
2080pub struct SupervisorEntry {
2081    /// A hidden previous instance blocks only this module's next spawn. This
2082    /// PID is not the supervised child and must not be presented as its identity.
2083    #[serde(default, skip_serializing_if = "Option::is_none")]
2084    pub waiting_for_previous_pid: Option<u32>,
2085    pub module_id: String,
2086    pub state: String,
2087    pub enabled: bool,
2088    /// Whether this module is serving.
2089    ///
2090    /// For a `Subc` module: enabled, running, process alive, AND registered.
2091    /// For a `None` module the registration term is dropped, because a module
2092    /// that speaks no subc wire never registers and the daemon cannot assert
2093    /// more than "the process it launched is alive". READ IT WITH `protocol`:
2094    /// `live: true` means something weaker for a `None` module, and a renderer
2095    /// that prints it as a bare boolean for one is claiming more than the daemon
2096    /// knows.
2097    pub live: bool,
2098    /// The module's declared wire protocol. Absent on daemons predating the
2099    /// field, where every module was a subc module, so the default is exactly
2100    /// what those daemons meant.
2101    #[serde(default)]
2102    pub protocol: ModuleProtocol,
2103    /// Retained for one release for older status consumers: false on Unix, true
2104    /// on Windows. Non-wire modules receive no nonce on either platform.
2105    #[serde(default, skip_serializing_if = "Option::is_none")]
2106    pub launch_nonce_env: Option<bool>,
2107    /// The daemon's Windows per-spawn delivery result: `pipe` after the spawned
2108    /// process read all bytes, otherwise `env` for the offered environment copy.
2109    /// Returned in `supervisor.list`, independently of the source the module
2110    /// declares in `manifest.provenance.launch_nonce_source` when registering.
2111    /// Processes that do not speak the wire protocol and stopped modules omit it.
2112    #[serde(default, skip_serializing_if = "Option::is_none")]
2113    pub launch_nonce_source: Option<String>,
2114    pub health: SupervisorHealthStatus,
2115    /// Computed from the stored launch spec and observed process at list time;
2116    /// None means an older daemon did not report this comparison.
2117    #[serde(default, skip_serializing_if = "Option::is_none")]
2118    pub pending_reload: Option<PendingReloadVerdict>,
2119    /// When the daemon last collected this module's health, as unix
2120    /// milliseconds. Absent means NEVER PROBED (a module inside its first probe
2121    /// window, whose `health` is therefore `Unknown` rather than good), not
2122    /// probed-long-ago. An old value and an absent one call for opposite
2123    /// readings, so do not render them alike.
2124    #[serde(default)]
2125    pub last_probe_ms: Option<u64>,
2126    /// Exit code of the module's most recent process exit, if the process has
2127    /// exited at least once. Survives respawn so a now-`running` module still
2128    /// reports what killed its previous incarnation.
2129    #[serde(default, skip_serializing_if = "Option::is_none")]
2130    pub last_exit_code: Option<i32>,
2131    /// Terminating signal of the module's most recent process exit (Unix), if
2132    /// any. `Some(9)` = SIGKILL (OOM/jetsam/kill-on-drop), `Some(6)` = SIGABRT
2133    /// (often a panic-abort). Survives respawn.
2134    #[serde(default, skip_serializing_if = "Option::is_none")]
2135    pub last_exit_signal: Option<i32>,
2136    /// Unix milliseconds when the most recent child exit was observed. Present
2137    /// even when the terminal ring is not queried, so existing list readers can
2138    /// order their latest observed exit against events they already received.
2139    #[serde(default, skip_serializing_if = "Option::is_none")]
2140    pub last_exit_ms: Option<u64>,
2141    /// Classification of the most recent child exit. Absent on daemons that
2142    /// predate exit-kind reporting.
2143    #[serde(default, skip_serializing_if = "Option::is_none")]
2144    pub last_exit_kind: Option<TerminalExitKind>,
2145    /// Replacement processes spawned for this module so far, against the budget
2146    /// that disables it.
2147    ///
2148    /// THIS IS THE COUNTER THAT ENDS A MODULE, and it is not the one beside it.
2149    /// `SupervisorHealthEntry::consecutive_failures` returns to zero on any
2150    /// successful probe, so a module can miss probes all day and read zero; this
2151    /// one only decreases when an operator restarts, reloads, or re-enables the
2152    /// module. Reaching the budget moves it to `Failed` and it stays there until
2153    /// somebody intervenes.
2154    ///
2155    /// So a module one restart from being disabled is indistinguishable from a
2156    /// freshly booted one unless this pair is read. Both are reported together
2157    /// because the count alone does not say how close it is.
2158    ///
2159    /// Absent from daemons predating the field, which is why it is optional
2160    /// rather than defaulted to zero: zero would assert a full budget.
2161    #[serde(default, skip_serializing_if = "Option::is_none")]
2162    pub restart_count: Option<u32>,
2163    /// Replacement processes this module is allowed before it is disabled. See
2164    /// `restart_count`; absent on daemons predating the field.
2165    #[serde(default, skip_serializing_if = "Option::is_none")]
2166    pub max_restarts: Option<u32>,
2167    /// Replacement processes spawned over this module's entire supervisor lifetime.
2168    /// Unlike `restart_count`, this value is never reset by an operator action.
2169    #[serde(default, skip_serializing_if = "Option::is_none")]
2170    pub lifetime_restarts: Option<u32>,
2171    /// Successful child spawns in this daemon incarnation. Zero means the
2172    /// module has not successfully spawned; every successful spawn increments
2173    /// the value exactly once.
2174    #[serde(default, skip_serializing_if = "Option::is_none")]
2175    pub spawn_generation: Option<u64>,
2176    /// The span `restart_count` is counted over, in seconds. The crash budget is
2177    /// a RATE, not a lifetime total: `restart_count` counts only the restarts
2178    /// inside the last `restart_window_secs`, and older ones no longer hold a
2179    /// slot. Without this field a reader cannot tell "2 of 3 crashes, ever" from
2180    /// "2 of 3 crashes in the last ten minutes", and those two call for opposite
2181    /// reactions.
2182    ///
2183    /// Absent on daemons predating the windowed budget, where the count really
2184    /// was a lifetime total.
2185    #[serde(default, skip_serializing_if = "Option::is_none")]
2186    pub restart_window_secs: Option<u64>,
2187    /// Effective drain budget for this module, in milliseconds. This is the
2188    /// resolved policy the running supervisor uses, not a config-file reread.
2189    /// Absent on older daemons.
2190    #[serde(default, skip_serializing_if = "Option::is_none")]
2191    pub drain_timeout_ms: Option<u64>,
2192    /// Effective base delay before a crash restart, in milliseconds. Absent on
2193    /// older daemons.
2194    #[serde(default, skip_serializing_if = "Option::is_none")]
2195    pub restart_backoff_ms: Option<u64>,
2196    /// Effective maximum delay before a crash restart, in milliseconds. Absent
2197    /// on older daemons.
2198    #[serde(default, skip_serializing_if = "Option::is_none")]
2199    pub restart_max_backoff_ms: Option<u64>,
2200    /// Memory and cumulative CPU time of the module's process, read when this
2201    /// list was answered. Report only: the daemon keeps no history and acts on
2202    /// none of it.
2203    ///
2204    /// It describes the one process the supervisor spawned (its `pid`), not
2205    /// processes that one has started in turn, so a module that forks workers
2206    /// reports only its own share.
2207    ///
2208    /// Absent means the daemon predates the field. A daemon that has the field
2209    /// but could not read the process (not running, unsupported platform, read
2210    /// failed) says so with `Unavailable` and a reason, so neither case can be
2211    /// mistaken for a process using nothing.
2212    #[serde(default, skip_serializing_if = "Option::is_none")]
2213    pub resources: Option<ChildResourceUsage>,
2214}
2215
2216impl SupervisorEntry {
2217    /// Construct an entry with its required fields and wire-compatible defaults.
2218    #[must_use]
2219    pub fn new(
2220        module_id: impl Into<String>,
2221        state: impl Into<String>,
2222        enabled: bool,
2223        live: bool,
2224        health: SupervisorHealthStatus,
2225    ) -> Self {
2226        Self {
2227            module_id: module_id.into(),
2228            state: state.into(),
2229            waiting_for_previous_pid: None,
2230            enabled,
2231            live,
2232            protocol: ModuleProtocol::default(),
2233            launch_nonce_env: None,
2234            launch_nonce_source: None,
2235            health,
2236            pending_reload: None,
2237            last_probe_ms: None,
2238            last_exit_code: None,
2239            last_exit_signal: None,
2240            last_exit_ms: None,
2241            last_exit_kind: None,
2242            restart_count: None,
2243            max_restarts: None,
2244            lifetime_restarts: None,
2245            spawn_generation: None,
2246            restart_window_secs: None,
2247            drain_timeout_ms: None,
2248            restart_backoff_ms: None,
2249            restart_max_backoff_ms: None,
2250            resources: None,
2251        }
2252    }
2253
2254    /// Set the module's declared wire protocol.
2255    #[must_use]
2256    pub fn with_protocol(mut self, protocol: ModuleProtocol) -> Self {
2257        self.protocol = protocol;
2258        self
2259    }
2260
2261    /// Set or clear the launch nonce environment indicator.
2262    #[must_use]
2263    pub fn with_launch_nonce_env(mut self, launch_nonce_env: Option<bool>) -> Self {
2264        self.launch_nonce_env = launch_nonce_env;
2265        self
2266    }
2267
2268    pub fn with_launch_nonce_source(mut self, source: Option<String>) -> Self {
2269        self.launch_nonce_source = source;
2270        self
2271    }
2272
2273    #[must_use]
2274    pub fn with_waiting_for_previous_pid(mut self, pid: Option<u32>) -> Self {
2275        self.waiting_for_previous_pid = pid;
2276        self
2277    }
2278
2279    /// Set or clear the comparison between the launch spec and observed process.
2280    #[must_use]
2281    pub fn with_pending_reload(mut self, pending_reload: Option<PendingReloadVerdict>) -> Self {
2282        self.pending_reload = pending_reload;
2283        self
2284    }
2285
2286    /// Set or clear the last health collection timestamp.
2287    #[must_use]
2288    pub fn with_last_probe_ms(mut self, last_probe_ms: Option<u64>) -> Self {
2289        self.last_probe_ms = last_probe_ms;
2290        self
2291    }
2292
2293    /// Set or clear the most recent process exit code.
2294    #[must_use]
2295    pub fn with_last_exit_code(mut self, last_exit_code: Option<i32>) -> Self {
2296        self.last_exit_code = last_exit_code;
2297        self
2298    }
2299
2300    /// Set or clear the most recent process exit signal.
2301    #[must_use]
2302    pub fn with_last_exit_signal(mut self, last_exit_signal: Option<i32>) -> Self {
2303        self.last_exit_signal = last_exit_signal;
2304        self
2305    }
2306
2307    /// Set or clear the most recent process exit timestamp.
2308    #[must_use]
2309    pub fn with_last_exit_ms(mut self, last_exit_ms: Option<u64>) -> Self {
2310        self.last_exit_ms = last_exit_ms;
2311        self
2312    }
2313
2314    /// Set or clear the most recent process exit classification.
2315    #[must_use]
2316    pub fn with_last_exit_kind(mut self, last_exit_kind: Option<TerminalExitKind>) -> Self {
2317        self.last_exit_kind = last_exit_kind;
2318        self
2319    }
2320
2321    /// Set or clear the restart count for the current crash budget.
2322    #[must_use]
2323    pub fn with_restart_count(mut self, restart_count: Option<u32>) -> Self {
2324        self.restart_count = restart_count;
2325        self
2326    }
2327
2328    /// Set or clear the maximum restarts allowed by the crash budget.
2329    #[must_use]
2330    pub fn with_max_restarts(mut self, max_restarts: Option<u32>) -> Self {
2331        self.max_restarts = max_restarts;
2332        self
2333    }
2334
2335    /// Set or clear the lifetime restart count.
2336    #[must_use]
2337    pub fn with_lifetime_restarts(mut self, lifetime_restarts: Option<u32>) -> Self {
2338        self.lifetime_restarts = lifetime_restarts;
2339        self
2340    }
2341
2342    /// Set or clear the successful spawn count for this daemon incarnation.
2343    #[must_use]
2344    pub fn with_spawn_generation(mut self, spawn_generation: Option<u64>) -> Self {
2345        self.spawn_generation = spawn_generation;
2346        self
2347    }
2348
2349    /// Set or clear the time window of the crash budget.
2350    #[must_use]
2351    pub fn with_restart_window_secs(mut self, restart_window_secs: Option<u64>) -> Self {
2352        self.restart_window_secs = restart_window_secs;
2353        self
2354    }
2355
2356    /// Set or clear the effective drain budget in milliseconds.
2357    #[must_use]
2358    pub fn with_drain_timeout_ms(mut self, drain_timeout_ms: Option<u64>) -> Self {
2359        self.drain_timeout_ms = drain_timeout_ms;
2360        self
2361    }
2362
2363    /// Set or clear the effective base restart delay in milliseconds.
2364    #[must_use]
2365    pub fn with_restart_backoff_ms(mut self, restart_backoff_ms: Option<u64>) -> Self {
2366        self.restart_backoff_ms = restart_backoff_ms;
2367        self
2368    }
2369
2370    /// Set or clear the effective maximum restart delay in milliseconds.
2371    #[must_use]
2372    pub fn with_restart_max_backoff_ms(mut self, restart_max_backoff_ms: Option<u64>) -> Self {
2373        self.restart_max_backoff_ms = restart_max_backoff_ms;
2374        self
2375    }
2376
2377    /// Set or clear the observed resource usage of the supervised process.
2378    #[must_use]
2379    pub fn with_resources(mut self, resources: Option<ChildResourceUsage>) -> Self {
2380        self.resources = resources;
2381        self
2382    }
2383}
2384
2385/// A module process's memory and CPU time as read at list time, or why none
2386/// could be read.
2387#[derive(Debug, Clone, PartialEq)]
2388pub enum ChildResourceUsage {
2389    Measured(ChildResourceReading),
2390    Unavailable {
2391        reason: ChildResourceUnavailableReason,
2392    },
2393    /// Future discriminator. `body` retains the complete ordered object; `tag`
2394    /// is its decoded discriminator projection.
2395    Unknown {
2396        tag: String,
2397        body: OrderedJsonObject,
2398    },
2399}
2400
2401/// One reading of a module process's memory and CPU time.
2402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2403pub struct ChildResourceReading {
2404    /// Memory in bytes, measured as `memory_kind` says. The figures differ
2405    /// by platform and are not comparable across kinds.
2406    pub memory_bytes: u64,
2407    pub memory_kind: ChildMemoryKind,
2408    /// Bytes swapped out, where the platform reports it per process (Linux).
2409    /// Absent means not reported, not zero.
2410    #[serde(default, skip_serializing_if = "Option::is_none")]
2411    pub swap_bytes: Option<u64>,
2412    /// CPU time spent in user mode since the process started, in
2413    /// milliseconds. Cumulative, not a rate: a percentage needs two readings
2414    /// and the elapsed time between them.
2415    pub cpu_user_ms: u64,
2416    /// CPU time spent in the kernel on the process's behalf since it started,
2417    /// in milliseconds.
2418    pub cpu_system_ms: u64,
2419}
2420
2421open_string_enum! {
2422    /// What `ChildResourceReading::memory_bytes` measures.
2423    ChildMemoryKind {
2424        /// macOS `phys_footprint`: memory the kernel charges to the process,
2425        /// the figure jetsam acts on. Unlike resident size it does not count
2426        /// pages an allocator has already released with `MADV_FREE`.
2427        PhysFootprint => "phys_footprint",
2428        /// Linux `VmRSS`: pages resident in RAM, shared file-backed pages
2429        /// included and swapped-out pages excluded.
2430        ResidentSet => "resident_set",
2431        /// Windows `WorkingSetSize`: resident pageable memory, including shared
2432        /// pages, not Unix RSS or private committed bytes.
2433        WindowsWorkingSet => "windows_working_set",
2434    }
2435}
2436
2437open_string_enum! {
2438    /// Why a module process's resources could not be read.
2439    ChildResourceUnavailableReason {
2440        /// The module has no running process.
2441        NotRunning => "not_running",
2442        /// The daemon's platform has no per-process source.
2443        UnsupportedPlatform => "unsupported_platform",
2444        /// The process could not be read, typically because it exited while
2445        /// the list was being answered.
2446        Unreadable => "unreadable",
2447        /// The pid no longer names the process the supervisor spawned, so a
2448        /// reading would describe some other process.
2449        ProcessIdentityUnconfirmed => "process_identity_unconfirmed",
2450    }
2451}
2452
2453#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2454#[serde(rename_all = "snake_case")]
2455pub enum SupervisorHealthStatus {
2456    Ok,
2457    Degraded,
2458    Failing,
2459    Unresponsive,
2460    Unknown,
2461}
2462
2463#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
2464pub struct SupervisorHealthEntry {
2465    pub module_id: String,
2466    pub status: SupervisorHealthStatus,
2467    /// The module's own human-readable note on its state. Absent means the
2468    /// module said nothing, which is the ordinary shape for a healthy module and
2469    /// is NOT a claim that nothing is wrong. Never parse it: it is prose the
2470    /// module may reword freely, and `status` plus `metrics` are the machine
2471    /// surface.
2472    #[serde(default, skip_serializing_if = "Option::is_none")]
2473    pub detail: Option<String>,
2474    /// The module's own metrics object, relayed opaquely. Absent means the module
2475    /// published none on this probe — either it reports no metrics at all, or the
2476    /// probe did not reach it — so absence cannot distinguish "nothing to report"
2477    /// from "nobody asked". Read `last_probe_ms` to tell those apart.
2478    #[serde(default, skip_serializing_if = "Option::is_none")]
2479    pub metrics: Option<serde_json::Value>,
2480    pub consecutive_failures: u32,
2481    /// Number of recurring health replies received after their daemon deadline.
2482    /// Each increment is evidence that the module remained alive despite a miss.
2483    #[serde(default)]
2484    pub late_answer_count: u64,
2485    /// End-to-end latency of the newest late reply, measured from probe start.
2486    #[serde(default, skip_serializing_if = "Option::is_none")]
2487    pub last_late_answer_latency_ms: Option<u64>,
2488    /// The escalation the supervisor last took for this module (report, restart,
2489    /// alert). Absent means NO ACTION HAS EVER BEEN TAKEN, not that the last one
2490    /// succeeded — a module that has never misbehaved and one whose action record
2491    /// predates a daemon restart both present as absent.
2492    #[serde(default)]
2493    pub last_action: Option<String>,
2494    /// When `last_action` was taken, as unix milliseconds. Absent exactly when
2495    /// `last_action` is absent; the pair moves together.
2496    #[serde(default)]
2497    pub last_action_ms: Option<u64>,
2498    /// When the daemon last collected this entry, as unix milliseconds.
2499    ///
2500    /// `supervisor.health` answers from the supervisor's STORED record rather
2501    /// than probing, so every field above describes some moment in the past and
2502    /// nothing here said which. That matters most right after a restart, where
2503    /// the surface is used to confirm a deploy: a record collected before the
2504    /// restart reports the OLD process, reads as a failed deploy, and invites a
2505    /// redeploy of something that was already correct.
2506    ///
2507    /// `None` means never probed — distinct from probed-long-ago, and the reader
2508    /// must not collapse them. Absent on modules that advertise no health
2509    /// capability, which is why it is optional rather than defaulted to zero.
2510    #[serde(default, skip_serializing_if = "Option::is_none")]
2511    pub last_probe_ms: Option<u64>,
2512}
2513
2514#[cfg(test)]
2515mod tests {
2516    #[test]
2517    fn windows_running_image_evidence_round_trips_all_sixteen_id_bytes() {
2518        let evidence = super::RunningImageEvidence::WindowsSpawnFileId {
2519            volume_serial_number: u64::MAX,
2520            file_id: [
2521                0, 1, 2, 3, 4, 5, 6, 7, 128, 129, 130, 131, 252, 253, 254, 255,
2522            ],
2523        };
2524        let agreement = super::RunningImageAgreement::Match { evidence };
2525        let encoded = serde_json::to_string(&agreement).unwrap();
2526        assert!(encoded.contains("windows_spawn_file_id"));
2527        assert_eq!(
2528            serde_json::from_str::<super::RunningImageAgreement>(&encoded).unwrap(),
2529            agreement
2530        );
2531    }
2532
2533    #[test]
2534    fn unix_running_image_evidence_keeps_original_wire_bytes() {
2535        assert_eq!(
2536            serde_json::to_string(&super::RunningImageEvidence::MacosSpawnInode {
2537                device: 7,
2538                inode: 11
2539            })
2540            .unwrap(),
2541            "{\"method\":\"macos_spawn_inode\",\"device\":7,\"inode\":11}"
2542        );
2543        assert_eq!(
2544            serde_json::to_string(&super::RunningImageEvidence::LinuxProcSha256 {
2545                digest: "abc".to_owned()
2546            })
2547            .unwrap(),
2548            "{\"method\":\"linux_proc_sha256\",\"digest\":\"abc\"}"
2549        );
2550    }
2551    use super::*;
2552    use subc_protocol::{BindIdentity, RouteTarget};
2553
2554    #[test]
2555    fn legacy_terminal_decoder_ignores_deliberate_severance_kind() {
2556        let entry = TerminalEntry {
2557            daemon_incarnation: Some("daemon-before-restart".into()),
2558            exit_code: Some(1),
2559            exit_signal: None,
2560            at_ms: 1_700_000_000_123,
2561            disposition: TerminalDisposition::Restarting,
2562            exit_kind: Some(TerminalExitKind::DeliberateSeverance),
2563            disposition_detail: None,
2564        };
2565        let wire = serde_json::to_string(&entry).expect("terminal entry serializes");
2566        assert_eq!(
2567            serde_json::from_str::<serde_json::Value>(&wire).expect("terminal entry is JSON")
2568                ["exit_kind"],
2569            "deliberate_severance"
2570        );
2571
2572        #[derive(serde::Deserialize)]
2573        struct LegacyTerminalEntry {
2574            exit_code: Option<i32>,
2575            exit_signal: Option<i32>,
2576            at_ms: u64,
2577            disposition: TerminalDisposition,
2578        }
2579
2580        let decoded: LegacyTerminalEntry =
2581            serde_json::from_str(&wire).expect("legacy decoder keeps the terminal record");
2582        assert_eq!(decoded.exit_code, Some(1));
2583        assert_eq!(decoded.exit_signal, None);
2584        assert_eq!(decoded.at_ms, 1_700_000_000_123);
2585        assert_eq!(decoded.disposition, TerminalDisposition::Restarting);
2586
2587        let future_wire = wire.replace("deliberate_severance", "future_exit_kind");
2588        let future: TerminalEntry =
2589            serde_json::from_str(&future_wire).expect("new decoder keeps a future terminal kind");
2590        assert_eq!(
2591            future.exit_kind,
2592            Some(TerminalExitKind::Unknown("future_exit_kind".to_string()))
2593        );
2594    }
2595
2596    #[test]
2597    fn terminal_incarnation_is_optional_for_older_daemons() {
2598        let entry: TerminalEntry = serde_json::from_value(serde_json::json!({
2599            "at_ms": 123,
2600            "disposition": "stopped"
2601        }))
2602        .unwrap();
2603        let encoded = serde_json::to_value(&entry).unwrap();
2604        assert_eq!(
2605            (entry.daemon_incarnation, encoded.get("daemon_incarnation")),
2606            (None, None)
2607        );
2608    }
2609
2610    #[test]
2611    fn route_poll_uses_kind_field() {
2612        let body = serde_json::to_value(ClientControlRequest::RoutePoll {
2613            route_channel: 7,
2614            route_epoch: 11,
2615            kind: PollKind::Status,
2616        })
2617        .unwrap();
2618
2619        assert_eq!(body["op"], "route.poll");
2620        assert_eq!(body["route_epoch"], 11);
2621        assert_eq!(body["kind"], "status");
2622        assert!(body.get("op").is_some());
2623    }
2624
2625    #[test]
2626    fn route_open_is_internally_tagged() {
2627        let request = ClientControlRequest::RouteOpen {
2628            target: RouteTarget::ToolProvider {
2629                module_id: "aft".to_string(),
2630            },
2631            identity: BindIdentity::new("/tmp/project", "opencode", "session-1"),
2632            consumer_identity: None,
2633            consumer_capabilities: None,
2634            role_versions: None,
2635            admission_facts: None,
2636            scope: None,
2637        };
2638
2639        let body = serde_json::to_value(request).unwrap();
2640        assert_eq!(body["op"], "route.open");
2641        assert_eq!(body["target"]["kind"], "tool_provider");
2642        assert!(body.get("consumer_identity").is_none());
2643        assert!(body.get("consumer_capabilities").is_none());
2644        assert!(body.get("role_versions").is_none());
2645    }
2646
2647    #[test]
2648    fn route_open_without_optional_fields_still_decodes() {
2649        let body = serde_json::json!({
2650            "op": "route.open",
2651            "target": { "kind": "tool_provider", "module_id": "aft" },
2652            "identity": {
2653                "project_root": "/tmp/project",
2654                "harness": "opencode",
2655                "session": "session-1"
2656            }
2657        });
2658
2659        let decoded: ClientControlRequest = serde_json::from_value(body).unwrap();
2660        let ClientControlRequest::RouteOpen {
2661            consumer_identity,
2662            consumer_capabilities,
2663            role_versions,
2664            admission_facts,
2665            ..
2666        } = decoded
2667        else {
2668            panic!("decoded wrong request variant");
2669        };
2670        assert_eq!(consumer_identity, None);
2671        assert_eq!(consumer_capabilities, None);
2672        assert_eq!(role_versions, None);
2673        assert_eq!(admission_facts, None);
2674    }
2675
2676    #[test]
2677    fn new_route_closed_decoder_defaults_fields_absent_from_old_daemon() {
2678        let old_wire = r#"{"op":"route.closed","module_id":"aft-tools","reason":"crash","drained":false,"abandoned":0}"#;
2679        let decoded: ClientControlPush = serde_json::from_str(old_wire).unwrap();
2680        match decoded {
2681            ClientControlPush::RouteClosed {
2682                excluded_subscriptions,
2683                terminal,
2684                ..
2685            } => {
2686                assert_eq!(excluded_subscriptions, 0);
2687                assert_eq!(terminal, None);
2688            }
2689            other => panic!("unexpected push: {other:?}"),
2690        }
2691        assert!(!serde_json::to_string(&decoded)
2692            .unwrap()
2693            .contains("terminal"));
2694    }
2695
2696    #[test]
2697    fn old_route_closed_decoder_ignores_new_terminal_field() {
2698        #[derive(serde::Deserialize)]
2699        #[serde(tag = "op")]
2700        enum LegacyClientControlPush {
2701            #[serde(rename = "route.closed")]
2702            RouteClosed {
2703                module_id: String,
2704                reason: RouteCloseReason,
2705                drained: bool,
2706                abandoned: u32,
2707            },
2708        }
2709
2710        let wire = r#"{"op":"route.closed","module_id":"aft-tools","reason":"crash","drained":false,"abandoned":0,"excluded_subscriptions":3,"terminal":true}"#;
2711        let decoded: LegacyClientControlPush = serde_json::from_str(wire).unwrap();
2712        match decoded {
2713            LegacyClientControlPush::RouteClosed {
2714                module_id,
2715                reason,
2716                drained,
2717                abandoned,
2718            } => {
2719                assert_eq!(module_id, "aft-tools");
2720                assert_eq!(reason, RouteCloseReason::Crash);
2721                assert!(!drained);
2722                assert_eq!(abandoned, 0);
2723            }
2724        }
2725    }
2726
2727    #[test]
2728    fn supervisor_routes_is_a_control_plane_request() {
2729        let body = serde_json::json!({
2730            "op": "supervisor.routes",
2731            "module_id": "aft"
2732        });
2733
2734        let request: ClientControlRequest = serde_json::from_value(body.clone()).unwrap();
2735        assert_eq!(serde_json::to_value(request).unwrap(), body);
2736    }
2737
2738    #[test]
2739    fn diagnostic_string_enums_retain_unknown_wire_values() {
2740        let reason: RunningImageUnavailableReason =
2741            serde_json::from_str("\"future_reason\"").unwrap();
2742        let disposition: TerminalDisposition =
2743            serde_json::from_str("\"future_disposition\"").unwrap();
2744
2745        assert_eq!(
2746            reason,
2747            RunningImageUnavailableReason::Unknown("future_reason".to_string())
2748        );
2749        assert_eq!(
2750            disposition,
2751            TerminalDisposition::Unknown("future_disposition".to_string())
2752        );
2753    }
2754
2755    #[test]
2756    fn diagnostic_string_enums_preserve_existing_wire_names() {
2757        let names = [
2758            (RunningImageUnavailableReason::NotRunning, "not_running"),
2759            (
2760                RunningImageUnavailableReason::UnsupportedPlatform,
2761                "unsupported_platform",
2762            ),
2763            (
2764                RunningImageUnavailableReason::RunningExecutableUnreadable,
2765                "running_executable_unreadable",
2766            ),
2767            (
2768                RunningImageUnavailableReason::RunningExecutablePermissionDenied,
2769                "running_executable_permission_denied",
2770            ),
2771            (
2772                RunningImageUnavailableReason::ProcessHidden,
2773                "process_hidden",
2774            ),
2775            (
2776                RunningImageUnavailableReason::SpawnedPathUnreadable,
2777                "spawned_path_unreadable",
2778            ),
2779            (RunningImageUnavailableReason::HashFailed, "hash_failed"),
2780            (
2781                RunningImageUnavailableReason::ProcessIdentityUnconfirmed,
2782                "process_identity_unconfirmed",
2783            ),
2784        ];
2785        for (value, expected) in names {
2786            let wire = serde_json::to_string(&value).unwrap();
2787            assert_eq!(wire, format!("\"{expected}\""));
2788            let decoded: RunningImageUnavailableReason = serde_json::from_str(&wire).unwrap();
2789            assert_eq!(decoded, value);
2790        }
2791
2792        for (value, expected) in [
2793            (TerminalDisposition::Stopped, "stopped"),
2794            (TerminalDisposition::Disabled, "disabled"),
2795            (TerminalDisposition::Failed, "failed"),
2796            (TerminalDisposition::Restarting, "restarting"),
2797            (TerminalDisposition::DaemonShutdown, "daemon_shutdown"),
2798        ] {
2799            let wire = serde_json::to_string(&value).unwrap();
2800            assert_eq!(wire, format!("\"{expected}\""));
2801            let decoded: TerminalDisposition = serde_json::from_str(&wire).unwrap();
2802            assert_eq!(decoded, value);
2803        }
2804    }
2805
2806    #[test]
2807    fn supervisor_waiting_previous_pid_is_optional_and_round_trips() {
2808        let entry = SupervisorEntry::new(
2809            "waiting-module",
2810            "starting",
2811            true,
2812            false,
2813            SupervisorHealthStatus::Unknown,
2814        );
2815        let old_wire = serde_json::to_value(&entry).unwrap();
2816        assert!(old_wire.get("waiting_for_previous_pid").is_none());
2817        let decoded: SupervisorEntry = serde_json::from_value(old_wire).unwrap();
2818        assert_eq!(decoded.waiting_for_previous_pid, None);
2819        let entry = entry.with_waiting_for_previous_pid(Some(42));
2820        let wire = serde_json::to_value(&entry).unwrap();
2821        assert_eq!(wire["waiting_for_previous_pid"], 42);
2822        let decoded: SupervisorEntry = serde_json::from_value(wire).unwrap();
2823        assert_eq!(decoded.waiting_for_previous_pid, Some(42));
2824    }
2825
2826    #[test]
2827    fn diagnostic_string_enums_reject_non_string_bodies() {
2828        assert!(serde_json::from_str::<RunningImageUnavailableReason>("42").is_err());
2829        assert!(serde_json::from_str::<TerminalDisposition>("{\"value\":\"failed\"}").is_err());
2830    }
2831
2832    #[test]
2833    fn unknown_provenance_reason_does_not_discard_healthy_siblings() {
2834        let body = serde_json::json!({
2835            "op": "supervisor.provenance",
2836            "daemon": {
2837                "daemon_build": {},
2838                "daemon_observed": {
2839                    "running_image": {
2840                        "status": "unavailable",
2841                        "reason": "not_running"
2842                    }
2843                }
2844            },
2845            "modules": [
2846                {
2847                    "module_id": "future",
2848                    "module_declared": { "status": "unverifiable" },
2849                    "daemon_observed": {
2850                        "running_image": {
2851                            "status": "unavailable",
2852                            "reason": "future_reason"
2853                        }
2854                    }
2855                },
2856                {
2857                    "module_id": "healthy-a",
2858                    "module_declared": { "status": "unverifiable" },
2859                    "daemon_observed": {
2860                        "running_image": {
2861                            "status": "match",
2862                            "evidence": {
2863                                "method": "linux_proc_sha256",
2864                                "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
2865                            }
2866                        }
2867                    }
2868                },
2869                {
2870                    "module_id": "healthy-b",
2871                    "module_declared": { "status": "unverifiable" },
2872                    "daemon_observed": {
2873                        "running_image": {
2874                            "status": "unavailable",
2875                            "reason": "unsupported_platform"
2876                        }
2877                    }
2878                }
2879            ]
2880        });
2881
2882        let decoded: ClientControlResponse = serde_json::from_value(body).unwrap();
2883        let ClientControlResponse::SupervisorProvenance { modules, .. } = decoded else {
2884            panic!("decoded wrong response variant");
2885        };
2886        assert_eq!(modules.len(), 3);
2887        assert_eq!(modules[0].module_id, "future");
2888        assert_eq!(
2889            modules[0].daemon_observed.running_image,
2890            RunningImageAgreement::Unavailable {
2891                reason: RunningImageUnavailableReason::Unknown("future_reason".to_string())
2892            }
2893        );
2894        assert_eq!(modules[1].module_id, "healthy-a");
2895        assert_eq!(modules[2].module_id, "healthy-b");
2896    }
2897
2898    #[test]
2899    fn tagged_unknown_values_retain_tag_and_body() {
2900        macro_rules! assert_unknown_round_trip {
2901            ($ty:ident, $field:literal, $value:expr) => {
2902                let value = $value;
2903                let wire = serde_json::to_string(&value).unwrap();
2904                let decoded: $ty = serde_json::from_str(&wire).unwrap();
2905                match decoded {
2906                    $ty::Unknown { tag, body } => {
2907                        assert_eq!(tag, value[$field].as_str().unwrap());
2908                        assert_eq!(serde_json::to_value(&body).unwrap(), value);
2909                    }
2910                    _ => panic!("decoded known variant"),
2911                }
2912            };
2913        }
2914
2915        assert_unknown_round_trip!(
2916            ModuleDeclaredProvenance,
2917            "status",
2918            serde_json::json!({"status": "future", "build": {"version": 7}})
2919        );
2920        assert_unknown_round_trip!(
2921            RunningImageAgreement,
2922            "status",
2923            serde_json::json!({"status": "future", "evidence": {"digest": "abc"}})
2924        );
2925        assert_unknown_round_trip!(
2926            RunningImageEvidence,
2927            "method",
2928            serde_json::json!({"method": "future", "digest": "abc"})
2929        );
2930        assert_unknown_round_trip!(
2931            SupervisorRouteConsumer,
2932            "kind",
2933            serde_json::json!({"kind": "future", "module_id": "m"})
2934        );
2935        assert_unknown_round_trip!(
2936            StderrCaptureState,
2937            "state",
2938            serde_json::json!({"state": "future", "reason": "because"})
2939        );
2940        assert_unknown_round_trip!(
2941            StderrTailEntry,
2942            "kind",
2943            serde_json::json!({"kind": "future", "text": "line"})
2944        );
2945        assert_unknown_round_trip!(
2946            ChildResourceUsage,
2947            "status",
2948            serde_json::json!({"status": "future", "memory_bytes": 1})
2949        );
2950    }
2951
2952    #[test]
2953    fn child_resource_usage_round_trips_both_known_states() {
2954        let measured = ChildResourceUsage::Measured(ChildResourceReading {
2955            memory_bytes: 0,
2956            memory_kind: ChildMemoryKind::ResidentSet,
2957            swap_bytes: Some(0),
2958            cpu_user_ms: 0,
2959            cpu_system_ms: 0,
2960        });
2961        let wire = serde_json::to_value(&measured).unwrap();
2962        assert_eq!(
2963            wire,
2964            serde_json::json!({
2965                "status": "measured",
2966                "memory_bytes": 0,
2967                "memory_kind": "resident_set",
2968                "swap_bytes": 0,
2969                "cpu_user_ms": 0,
2970                "cpu_system_ms": 0
2971            })
2972        );
2973        assert_eq!(
2974            serde_json::from_value::<ChildResourceUsage>(wire).unwrap(),
2975            measured
2976        );
2977
2978        let unavailable = ChildResourceUsage::Unavailable {
2979            reason: ChildResourceUnavailableReason::NotRunning,
2980        };
2981        let wire = serde_json::to_value(&unavailable).unwrap();
2982        assert_eq!(
2983            wire,
2984            serde_json::json!({"status": "unavailable", "reason": "not_running"})
2985        );
2986        assert_eq!(
2987            serde_json::from_value::<ChildResourceUsage>(wire).unwrap(),
2988            unavailable
2989        );
2990    }
2991
2992    #[test]
2993    fn a_stderr_line_decodes_with_and_without_its_capture_time() {
2994        // A current daemon stamps each line; an older one sends no `at_ms`.
2995        // Both must decode, and the absent case must stay absent rather than
2996        // turn into a time nobody recorded.
2997        let stamped: StderrTailEntry = serde_json::from_str(
2998            r#"{"kind":"line","text":"boom","truncated":true,"at_ms":1789801440685}"#,
2999        )
3000        .unwrap();
3001        assert_eq!(
3002            stamped,
3003            StderrTailEntry::Line {
3004                text: "boom".to_string(),
3005                truncated: true,
3006                at_ms: Some(1_789_801_440_685),
3007            }
3008        );
3009        let unstamped: StderrTailEntry =
3010            serde_json::from_str(r#"{"kind":"line","text":"boom"}"#).unwrap();
3011        assert_eq!(
3012            unstamped,
3013            StderrTailEntry::Line {
3014                text: "boom".to_string(),
3015                truncated: false,
3016                at_ms: None,
3017            }
3018        );
3019        // Absent stays absent on the way out, so a reply without stamps is
3020        // exactly what an older daemon would have sent.
3021        assert_eq!(
3022            serde_json::to_string(&unstamped).unwrap(),
3023            r#"{"kind":"line","text":"boom"}"#
3024        );
3025        assert_eq!(
3026            serde_json::to_value(&stamped).unwrap()["at_ms"],
3027            serde_json::json!(1_789_801_440_685u64)
3028        );
3029    }
3030
3031    #[test]
3032    fn tagged_unknown_values_round_trip_the_original_json() {
3033        let wire = r#"{"kind":"future_consumer","detail":{"z":1}}"#;
3034        let decoded: SupervisorRouteConsumer = serde_json::from_str(wire).unwrap();
3035        assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
3036    }
3037
3038    #[test]
3039    fn tagged_unknown_values_round_trip_trailing_tag() {
3040        let route_wire = r#"{"detail":{"z":1},"kind":"future_consumer"}"#;
3041        let route: SupervisorRouteConsumer = serde_json::from_str(route_wire).unwrap();
3042        assert_eq!(serde_json::to_string(&route).unwrap(), route_wire);
3043
3044        let stderr_wire = r#"{"reason":"because","state":"future_state"}"#;
3045        let stderr: StderrCaptureState = serde_json::from_str(stderr_wire).unwrap();
3046        assert_eq!(serde_json::to_string(&stderr).unwrap(), stderr_wire);
3047    }
3048
3049    #[test]
3050    fn tagged_unknown_values_round_trip_middle_tag() {
3051        let route_wire = r#"{"a":1,"kind":"future_x","b":2}"#;
3052        let route: SupervisorRouteConsumer = serde_json::from_str(route_wire).unwrap();
3053        assert_eq!(serde_json::to_string(&route).unwrap(), route_wire);
3054
3055        let stderr_wire = r#"{"a":1,"state":"future_state","b":2}"#;
3056        let stderr: StderrCaptureState = serde_json::from_str(stderr_wire).unwrap();
3057        assert_eq!(serde_json::to_string(&stderr).unwrap(), stderr_wire);
3058    }
3059
3060    #[test]
3061    fn tagged_unknown_values_round_trip_deep_payload() {
3062        let route_wire = r#"{"a":{"n":[1,2]},"kind":"future_x","zz":"s","b":null}"#;
3063        let route: SupervisorRouteConsumer = serde_json::from_str(route_wire).unwrap();
3064        assert_eq!(serde_json::to_string(&route).unwrap(), route_wire);
3065
3066        let stderr_wire = r#"{"a":{"n":[1,2]},"state":"future_state","zz":"s","b":null}"#;
3067        let stderr: StderrCaptureState = serde_json::from_str(stderr_wire).unwrap();
3068        assert_eq!(serde_json::to_string(&stderr).unwrap(), stderr_wire);
3069    }
3070
3071    #[test]
3072    fn tagged_unknown_values_reject_non_object_bodies() {
3073        for wire in ["42", r#""future""#, "[]"] {
3074            assert!(serde_json::from_str::<SupervisorRouteConsumer>(wire).is_err());
3075            assert!(serde_json::from_str::<StderrCaptureState>(wire).is_err());
3076        }
3077    }
3078
3079    #[test]
3080    fn duplicate_discriminators_reject_without_panicking() {
3081        assert_eq!(
3082            serde_json::from_str::<ModuleDeclaredProvenance>(r#"{"status":"unverifiable"}"#)
3083                .unwrap(),
3084            ModuleDeclaredProvenance::Unverifiable
3085        );
3086        match serde_json::from_str::<ModuleDeclaredProvenance>(r#"{"status":"future_thing"}"#)
3087            .unwrap()
3088        {
3089            ModuleDeclaredProvenance::Unknown { tag, .. } => assert_eq!(tag, "future_thing"),
3090            _ => panic!("future discriminator decoded as a known variant"),
3091        }
3092
3093        let wires = [
3094            r#"{"status":"reported","status":"unverifiable"}"#,
3095            r#"{"status":"unverifiable","status":"reported"}"#,
3096            r#"{"status":"reported","build":{},"status":"unverifiable"}"#,
3097            r#"{"status":"unverifiable","build":{},"status":"reported"}"#,
3098        ];
3099
3100        for wire in wires {
3101            let result =
3102                std::panic::catch_unwind(|| serde_json::from_str::<ModuleDeclaredProvenance>(wire));
3103            assert!(result.is_ok(), "duplicate discriminator panicked: {wire}");
3104            assert!(
3105                result.unwrap().is_err(),
3106                "duplicate discriminator decoded: {wire}"
3107            );
3108        }
3109
3110        let wire = r#"{"state":"captured","state":"incomplete","reason":"x"}"#;
3111        let result = std::panic::catch_unwind(|| serde_json::from_str::<StderrCaptureState>(wire));
3112        assert!(result.is_ok(), "duplicate discriminator panicked: {wire}");
3113        assert!(
3114            result.unwrap().is_err(),
3115            "duplicate discriminator decoded: {wire}"
3116        );
3117    }
3118
3119    #[test]
3120    fn nested_unknown_values_round_trip_without_normalizing_member_order() {
3121        let known_wire =
3122            r#"{"status":"match","evidence":{"method":"linux_proc_sha256","digest":"abc"}}"#;
3123        let known: RunningImageAgreement = serde_json::from_str(known_wire).unwrap();
3124        assert_eq!(serde_json::to_string(&known).unwrap(), known_wire);
3125
3126        for wire in [
3127            r#"{"kind":"future_x","detail":{"zeta":1,"alpha":2}}"#,
3128            r#"{"kind":"future_x","d":{"b":{"zz":1,"aa":2}}}"#,
3129        ] {
3130            let decoded: SupervisorRouteConsumer = serde_json::from_str(wire).unwrap();
3131            assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
3132        }
3133
3134        for wire in [
3135            r#"{"status":"match","evidence":{"method":"future_probe","zz":1,"aa":2}}"#,
3136            r#"{"status":"match","evidence":{"method":"future_probe","d":{"zz":1,"aa":2}}}"#,
3137        ] {
3138            let decoded: RunningImageAgreement = serde_json::from_str(wire).unwrap();
3139            assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
3140        }
3141
3142        let wire = r#"{"status":"mismatch","running":{"detail":{"z":1},"method":"future_running"},"disk":{"method":"future_disk","detail":{"z":1}}}"#;
3143        let decoded: RunningImageAgreement = serde_json::from_str(wire).unwrap();
3144        assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
3145
3146        let wire = r#"{"capture":{"state":"captured"},"entries":[{"detail":{"z":1,"a":2},"kind":"future_line"},{"kind":"future_restart","meta":{"b":{"zz":1,"aa":2}}}]}"#;
3147        let decoded: StderrTail = serde_json::from_str(wire).unwrap();
3148        assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
3149    }
3150
3151    #[test]
3152    fn tagged_unknown_member_does_not_discard_known_siblings() {
3153        let body = serde_json::json!({
3154            "modules": [{
3155                "module_id": "target",
3156                "routes": [
3157                    {"consumer": {"kind": "future_consumer", "module_id": "m", "detail": {"retry": true}}, "age_ms": 0, "draining": false},
3158                    {"consumer": {"kind": "direct", "connection_id": 7}, "age_ms": 0, "draining": false}
3159                ]
3160            }]
3161        });
3162        let decoded: ClientControlResponse = serde_json::from_value(
3163            serde_json::json!({"op": "supervisor.routes", "modules": body["modules"]}),
3164        )
3165        .unwrap();
3166        let ClientControlResponse::SupervisorRoutes { modules } = decoded else {
3167            panic!("decoded wrong response variant");
3168        };
3169        assert_eq!(modules[0].routes.len(), 2);
3170        assert_eq!(
3171            modules[0].routes[1].consumer,
3172            SupervisorRouteConsumer::Direct { connection_id: 7 }
3173        );
3174    }
3175}
3176
3177#[cfg(test)]
3178mod launch_nonce_redaction_tests {
3179    use super::*;
3180
3181    const NONCE: &str = "nonce-f00dfeed1234abcd";
3182
3183    fn identity() -> ConsumerIdentity {
3184        ConsumerIdentity {
3185            module_id: "wernicke".to_string(),
3186            launch_nonce: NONCE.to_string(),
3187        }
3188    }
3189
3190    #[test]
3191    fn consumer_identity_debug_names_the_module_and_never_the_nonce() {
3192        let printed = format!("{:?}", identity());
3193        assert!(printed.contains("wernicke"), "{printed}");
3194        assert!(!printed.contains(NONCE), "launch nonce printed: {printed}");
3195    }
3196
3197    #[test]
3198    fn route_open_request_debug_never_prints_the_nonce() {
3199        let request = ClientControlRequest::RouteOpen {
3200            target: subc_protocol::RouteTarget::ToolProvider {
3201                module_id: "broca".to_string(),
3202            },
3203            identity: subc_protocol::BindIdentity::new(
3204                PathBuf::from("/tmp/project"),
3205                "test".to_string(),
3206                "session".to_string(),
3207            ),
3208            consumer_identity: Some(identity()),
3209            consumer_capabilities: None,
3210            role_versions: None,
3211            admission_facts: None,
3212            scope: None,
3213        };
3214        let printed = format!("{request:?}");
3215        assert!(!printed.contains(NONCE), "launch nonce printed: {printed}");
3216    }
3217}