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