Skip to main content

subc_control/
lib.rs

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