Skip to main content

subc_control/
lib.rs

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