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