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