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