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