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