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    /// Wall time derived from suspend-inclusive elapsed time at each read. Clock
579    /// correction can move it by the size of a clock step, and even without a
580    /// step it may vary by about a second between reads. Do not equality-compare
581    /// it. Use raw process start ticks for stable identity.
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub started_at_ms: Option<u64>,
584    pub running_image: RunningImageAgreement,
585}
586
587/// Whether the executable currently running agrees with the spawned image.
588#[derive(Debug, Clone, PartialEq)]
589pub enum RunningImageAgreement {
590    Match {
591        evidence: RunningImageEvidence,
592    },
593    Mismatch {
594        running: RunningImageEvidence,
595        disk: RunningImageEvidence,
596    },
597    Unavailable {
598        reason: RunningImageUnavailableReason,
599    },
600    /// Future discriminator. `body` retains the complete ordered object; `tag`
601    /// is its decoded discriminator projection.
602    Unknown {
603        tag: String,
604        body: OrderedJsonObject,
605    },
606}
607
608/// Platform-specific evidence used to compare a running image with its spawn path.
609#[derive(Debug, Clone, PartialEq)]
610pub enum RunningImageEvidence {
611    LinuxProcSha256 {
612        digest: String,
613    },
614    MacosSpawnInode {
615        device: u64,
616        inode: u64,
617    },
618    /// Future discriminator. `body` retains the complete ordered object; `tag`
619    /// is its decoded discriminator projection.
620    Unknown {
621        tag: String,
622        body: OrderedJsonObject,
623    },
624}
625
626open_string_enum! {
627    /// Reasons why an executable identity could not be observed.
628    RunningImageUnavailableReason {
629        NotRunning => "not_running",
630        UnsupportedPlatform => "unsupported_platform",
631        RunningExecutableUnreadable => "running_executable_unreadable",
632        SpawnedPathUnreadable => "spawned_path_unreadable",
633        HashFailed => "hash_failed",
634        ProcessIdentityUnconfirmed => "process_identity_unconfirmed",
635    }
636}
637
638/// The identity tier the daemon can honestly report for a route consumer.
639///
640/// A caller that proved a live daemon-issued launch nonce is named `reserved`.
641/// A direct key-holder has no such attestation, so it is reported as `direct`
642/// with its connection counter instead of an invented module name.
643#[derive(Debug, Clone, PartialEq)]
644pub enum SupervisorRouteConsumer {
645    Reserved {
646        module_id: String,
647    },
648    Direct {
649        connection_id: u64,
650    },
651    /// Future discriminator. `body` retains the complete ordered object; `tag`
652    /// is its decoded discriminator projection.
653    Unknown {
654        tag: String,
655        body: OrderedJsonObject,
656    },
657}
658
659/// Whether stderr is being captured for a module, and if not, why not.
660///
661/// A typed state rather than an empty-tail convention. "The module printed
662/// nothing before dying" and "nobody was capturing" send an operator in opposite
663/// directions, and rendering them alike is the defect this op exists to fix --
664/// the same shape as a `detail -` that means both no-detail and never-probed.
665#[derive(Debug, Clone, PartialEq)]
666pub enum StderrCaptureState {
667    /// A reader is attached, or was attached and saw clean EOF. An empty
668    /// `entries` under this state means the module genuinely wrote nothing.
669    Captured,
670    /// Retained entries are valid, but the stderr reader ended before clean EOF.
671    Incomplete { reason: String },
672    /// No reader was attached. `entries` says nothing about what the module wrote.
673    NotCaptured { reason: String },
674    /// Future discriminator. `body` retains the complete ordered object; `tag`
675    /// is its decoded discriminator projection.
676    Unknown {
677        tag: String,
678        body: OrderedJsonObject,
679    },
680}
681
682#[derive(Debug, Clone, PartialEq)]
683pub enum StderrTailEntry {
684    Line {
685        text: String,
686        /// The line was cut at the per-line cap and `text` is a prefix.
687        ///
688        /// Carried as a field rather than left to a marker in `text` so a
689        /// consumer can branch on it without string matching.
690        truncated: bool,
691    },
692    /// The supervisor spawned a new process. Entries after this came from it.
693    ///
694    /// In-band because position is the information: which side of the restart a
695    /// line falls on is unanswerable from a count.
696    ProcessStart,
697    /// Future discriminator. `body` retains the complete ordered object; `tag`
698    /// is its decoded discriminator projection.
699    Unknown {
700        tag: String,
701        body: OrderedJsonObject,
702    },
703}
704
705#[derive(Debug, Serialize, Deserialize)]
706#[serde(tag = "status", rename_all = "snake_case")]
707enum ModuleDeclaredProvenanceWire {
708    Reported { build: ManifestProvenance },
709    Unverifiable,
710}
711
712#[derive(Debug, Serialize, Deserialize)]
713#[serde(tag = "status", rename_all = "snake_case")]
714enum RunningImageAgreementWire {
715    Match {
716        evidence: RunningImageEvidence,
717    },
718    Mismatch {
719        running: RunningImageEvidence,
720        disk: RunningImageEvidence,
721    },
722    Unavailable {
723        reason: RunningImageUnavailableReason,
724    },
725}
726
727#[derive(Debug, Serialize, Deserialize)]
728#[serde(tag = "method", rename_all = "snake_case")]
729enum RunningImageEvidenceWire {
730    LinuxProcSha256 { digest: String },
731    MacosSpawnInode { device: u64, inode: u64 },
732}
733
734#[derive(Debug, Serialize, Deserialize)]
735#[serde(tag = "kind", rename_all = "snake_case")]
736enum SupervisorRouteConsumerWire {
737    Reserved { module_id: String },
738    Direct { connection_id: u64 },
739}
740
741#[derive(Debug, Serialize, Deserialize)]
742#[serde(tag = "state", rename_all = "snake_case")]
743enum StderrCaptureStateWire {
744    Captured,
745    Incomplete { reason: String },
746    NotCaptured { reason: String },
747}
748
749#[derive(Debug, Serialize, Deserialize)]
750#[serde(tag = "kind", rename_all = "snake_case")]
751enum StderrTailEntryWire {
752    Line {
753        text: String,
754        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
755        truncated: bool,
756    },
757    ProcessStart,
758}
759
760/// JSON values whose object members retain wire order at every depth.
761#[derive(Debug, Clone, PartialEq)]
762pub enum OrderedJsonValue {
763    Null,
764    Bool(bool),
765    Number(serde_json::Number),
766    String(String),
767    Array(Vec<Self>),
768    Object(OrderedJsonObject),
769}
770
771/// Ordered JSON members retained for an unknown tagged value.
772#[derive(Debug, Clone, PartialEq)]
773pub struct OrderedJsonObject(Vec<(String, OrderedJsonValue)>);
774
775impl OrderedJsonObject {
776    /// Returns the members in the order they appeared on the wire.
777    pub fn as_entries(&self) -> &[(String, OrderedJsonValue)] {
778        &self.0
779    }
780
781    fn into_value(self) -> serde_json::Value {
782        serde_json::Value::Object(
783            self.0
784                .into_iter()
785                .map(|(key, value)| (key, value.into_value()))
786                .collect(),
787        )
788    }
789}
790
791impl OrderedJsonValue {
792    fn into_value(self) -> serde_json::Value {
793        match self {
794            Self::Null => serde_json::Value::Null,
795            Self::Bool(value) => serde_json::Value::Bool(value),
796            Self::Number(value) => serde_json::Value::Number(value),
797            Self::String(value) => serde_json::Value::String(value),
798            Self::Array(values) => {
799                serde_json::Value::Array(values.into_iter().map(Self::into_value).collect())
800            }
801            Self::Object(value) => value.into_value(),
802        }
803    }
804}
805
806impl Serialize for OrderedJsonValue {
807    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
808    where
809        S: Serializer,
810    {
811        match self {
812            Self::Null => serializer.serialize_unit(),
813            Self::Bool(value) => serializer.serialize_bool(*value),
814            Self::Number(value) => value.serialize(serializer),
815            Self::String(value) => serializer.serialize_str(value),
816            Self::Array(values) => values.serialize(serializer),
817            Self::Object(value) => value.serialize(serializer),
818        }
819    }
820}
821
822impl<'de> Deserialize<'de> for OrderedJsonValue {
823    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
824    where
825        D: Deserializer<'de>,
826    {
827        struct OrderedValueVisitor;
828
829        impl<'de> Visitor<'de> for OrderedValueVisitor {
830            type Value = OrderedJsonValue;
831
832            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
833                formatter.write_str("a JSON value with ordered object members")
834            }
835
836            fn visit_unit<E>(self) -> Result<Self::Value, E>
837            where
838                E: serde::de::Error,
839            {
840                Ok(OrderedJsonValue::Null)
841            }
842
843            fn visit_none<E>(self) -> Result<Self::Value, E>
844            where
845                E: serde::de::Error,
846            {
847                Ok(OrderedJsonValue::Null)
848            }
849
850            fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
851            where
852                D: Deserializer<'de>,
853            {
854                OrderedJsonValue::deserialize(deserializer)
855            }
856
857            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
858            where
859                E: serde::de::Error,
860            {
861                Ok(OrderedJsonValue::Bool(value))
862            }
863
864            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
865            where
866                E: serde::de::Error,
867            {
868                Ok(OrderedJsonValue::Number(value.into()))
869            }
870
871            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
872            where
873                E: serde::de::Error,
874            {
875                Ok(OrderedJsonValue::Number(value.into()))
876            }
877
878            fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
879            where
880                E: serde::de::Error,
881            {
882                serde_json::Number::from_f64(value)
883                    .map(OrderedJsonValue::Number)
884                    .ok_or_else(|| E::custom("non-finite JSON number"))
885            }
886
887            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
888            where
889                E: serde::de::Error,
890            {
891                Ok(OrderedJsonValue::String(value.to_owned()))
892            }
893
894            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
895            where
896                E: serde::de::Error,
897            {
898                Ok(OrderedJsonValue::String(value))
899            }
900
901            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
902            where
903                A: SeqAccess<'de>,
904            {
905                let mut values = Vec::new();
906                while let Some(value) = sequence.next_element()? {
907                    values.push(value);
908                }
909                Ok(OrderedJsonValue::Array(values))
910            }
911
912            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
913            where
914                A: MapAccess<'de>,
915            {
916                let mut entries = Vec::new();
917                while let Some((key, value)) = map.next_entry()? {
918                    entries.push((key, value));
919                }
920                Ok(OrderedJsonValue::Object(OrderedJsonObject(entries)))
921            }
922        }
923
924        deserializer.deserialize_any(OrderedValueVisitor)
925    }
926}
927
928impl Serialize for OrderedJsonObject {
929    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
930    where
931        S: Serializer,
932    {
933        let mut map = serializer.serialize_map(Some(self.0.len()))?;
934        for (key, value) in &self.0 {
935            map.serialize_entry(key, value)?;
936        }
937        map.end()
938    }
939}
940
941impl<'de> Deserialize<'de> for OrderedJsonObject {
942    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
943    where
944        D: Deserializer<'de>,
945    {
946        struct OrderedObjectVisitor;
947
948        impl<'de> Visitor<'de> for OrderedObjectVisitor {
949            type Value = OrderedJsonObject;
950
951            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
952                formatter.write_str("an object with ordered JSON members")
953            }
954
955            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
956            where
957                A: MapAccess<'de>,
958            {
959                let mut entries = Vec::new();
960                while let Some((key, value)) = map.next_entry()? {
961                    entries.push((key, value));
962                }
963                Ok(OrderedJsonObject(entries))
964            }
965        }
966
967        deserializer.deserialize_map(OrderedObjectVisitor)
968    }
969}
970
971fn read_tagged<'de, D>(
972    deserializer: D,
973    field: &'static str,
974) -> Result<(String, OrderedJsonObject), D::Error>
975where
976    D: Deserializer<'de>,
977{
978    let body = OrderedJsonObject::deserialize(deserializer)?;
979    let mut tag = None;
980    for (key, value) in body.as_entries() {
981        if key != field {
982            continue;
983        }
984        if tag.is_some() {
985            return Err(D::Error::custom(format!(
986                "tagged object has duplicate `{field}` field"
987            )));
988        }
989        let OrderedJsonValue::String(value) = value else {
990            return Err(D::Error::custom(format!(
991                "tagged object has no string `{field}` field"
992            )));
993        };
994        tag = Some(value);
995    }
996    let Some(tag) = tag else {
997        return Err(D::Error::custom(format!(
998            "tagged object has no string `{field}` field"
999        )));
1000    };
1001    Ok((tag.to_string(), body))
1002}
1003
1004fn read_ordered_tagged(
1005    value: OrderedJsonValue,
1006    field: &'static str,
1007) -> Result<(String, OrderedJsonObject), String> {
1008    let OrderedJsonValue::Object(body) = value else {
1009        return Err(format!("expected tagged object with `{field}` field"));
1010    };
1011    let mut tag = None;
1012    for (key, value) in body.as_entries() {
1013        if key != field {
1014            continue;
1015        }
1016        if tag.is_some() {
1017            return Err(format!("tagged object has duplicate `{field}` field"));
1018        }
1019        let OrderedJsonValue::String(value) = value else {
1020            return Err(format!("tagged object has no string `{field}` field"));
1021        };
1022        tag = Some(value);
1023    }
1024    let Some(tag) = tag else {
1025        return Err(format!("tagged object has no string `{field}` field"));
1026    };
1027    Ok((tag.to_string(), body))
1028}
1029
1030fn ordered_field<'a>(body: &'a OrderedJsonObject, field: &str) -> Option<&'a OrderedJsonValue> {
1031    body.as_entries()
1032        .iter()
1033        .find_map(|(key, value)| (key == field).then_some(value))
1034}
1035
1036fn ordered_string(body: &OrderedJsonObject, field: &str) -> Result<String, String> {
1037    match ordered_field(body, field) {
1038        Some(OrderedJsonValue::String(value)) => Ok(value.clone()),
1039        Some(_) => Err(format!("tagged object field `{field}` is not a string")),
1040        None => Err(format!("tagged object has no `{field}` field")),
1041    }
1042}
1043
1044fn decode_running_image_evidence(value: OrderedJsonValue) -> Result<RunningImageEvidence, String> {
1045    let (tag, body) = read_ordered_tagged(value, "method")?;
1046    match tag.as_str() {
1047        "linux_proc_sha256" => Ok(RunningImageEvidence::LinuxProcSha256 {
1048            digest: ordered_string(&body, "digest")?,
1049        }),
1050        "macos_spawn_inode" => {
1051            let device = ordered_field(&body, "device")
1052                .and_then(|value| match value {
1053                    OrderedJsonValue::Number(number) => number.as_u64(),
1054                    _ => None,
1055                })
1056                .ok_or_else(|| "tagged object has no unsigned `device` field".to_string())?;
1057            let inode = ordered_field(&body, "inode")
1058                .and_then(|value| match value {
1059                    OrderedJsonValue::Number(number) => number.as_u64(),
1060                    _ => None,
1061                })
1062                .ok_or_else(|| "tagged object has no unsigned `inode` field".to_string())?;
1063            Ok(RunningImageEvidence::MacosSpawnInode { device, inode })
1064        }
1065        _ => Ok(RunningImageEvidence::Unknown { tag, body }),
1066    }
1067}
1068
1069impl Serialize for ModuleDeclaredProvenance {
1070    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1071    where
1072        S: Serializer,
1073    {
1074        match self {
1075            Self::Reported { build } => ModuleDeclaredProvenanceWire::Reported {
1076                build: build.clone(),
1077            }
1078            .serialize(serializer),
1079            Self::Unverifiable => ModuleDeclaredProvenanceWire::Unverifiable.serialize(serializer),
1080            Self::Unknown { body, .. } => body.serialize(serializer),
1081        }
1082    }
1083}
1084
1085impl<'de> Deserialize<'de> for ModuleDeclaredProvenance {
1086    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1087    where
1088        D: serde::Deserializer<'de>,
1089    {
1090        let (tag, value) = read_tagged(deserializer, "status")?;
1091        match tag.as_str() {
1092            "reported" => match serde_json::from_value(value.into_value())
1093                .map_err(D::Error::custom)?
1094            {
1095                ModuleDeclaredProvenanceWire::Reported { build } => Ok(Self::Reported { build }),
1096                ModuleDeclaredProvenanceWire::Unverifiable => unreachable!(),
1097            },
1098            "unverifiable" => {
1099                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1100                    ModuleDeclaredProvenanceWire::Unverifiable => Ok(Self::Unverifiable),
1101                    ModuleDeclaredProvenanceWire::Reported { .. } => unreachable!(),
1102                }
1103            }
1104            _ => Ok(Self::Unknown { tag, body: value }),
1105        }
1106    }
1107}
1108
1109impl Serialize for RunningImageAgreement {
1110    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1111    where
1112        S: Serializer,
1113    {
1114        match self {
1115            Self::Match { evidence } => RunningImageAgreementWire::Match {
1116                evidence: evidence.clone(),
1117            }
1118            .serialize(serializer),
1119            Self::Mismatch { running, disk } => RunningImageAgreementWire::Mismatch {
1120                running: running.clone(),
1121                disk: disk.clone(),
1122            }
1123            .serialize(serializer),
1124            Self::Unavailable { reason } => RunningImageAgreementWire::Unavailable {
1125                reason: reason.clone(),
1126            }
1127            .serialize(serializer),
1128            Self::Unknown { body, .. } => body.serialize(serializer),
1129        }
1130    }
1131}
1132
1133impl<'de> Deserialize<'de> for RunningImageAgreement {
1134    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1135    where
1136        D: serde::Deserializer<'de>,
1137    {
1138        let (tag, value) = read_tagged(deserializer, "status")?;
1139        match tag.as_str() {
1140            "match" => Ok(Self::Match {
1141                evidence: decode_running_image_evidence(
1142                    ordered_field(&value, "evidence")
1143                        .cloned()
1144                        .ok_or_else(|| D::Error::custom("tagged object has no `evidence` field"))?,
1145                )
1146                .map_err(D::Error::custom)?,
1147            }),
1148            "mismatch" => Ok(Self::Mismatch {
1149                running: decode_running_image_evidence(
1150                    ordered_field(&value, "running")
1151                        .cloned()
1152                        .ok_or_else(|| D::Error::custom("tagged object has no `running` field"))?,
1153                )
1154                .map_err(D::Error::custom)?,
1155                disk: decode_running_image_evidence(
1156                    ordered_field(&value, "disk")
1157                        .cloned()
1158                        .ok_or_else(|| D::Error::custom("tagged object has no `disk` field"))?,
1159                )
1160                .map_err(D::Error::custom)?,
1161            }),
1162            "unavailable" => Ok(Self::Unavailable {
1163                reason: serde_json::from_value(
1164                    ordered_field(&value, "reason")
1165                        .cloned()
1166                        .ok_or_else(|| D::Error::custom("tagged object has no `reason` field"))?
1167                        .into_value(),
1168                )
1169                .map_err(D::Error::custom)?,
1170            }),
1171            _ => Ok(Self::Unknown { tag, body: value }),
1172        }
1173    }
1174}
1175
1176impl Serialize for RunningImageEvidence {
1177    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1178    where
1179        S: Serializer,
1180    {
1181        match self {
1182            Self::LinuxProcSha256 { digest } => RunningImageEvidenceWire::LinuxProcSha256 {
1183                digest: digest.clone(),
1184            }
1185            .serialize(serializer),
1186            Self::MacosSpawnInode { device, inode } => RunningImageEvidenceWire::MacosSpawnInode {
1187                device: *device,
1188                inode: *inode,
1189            }
1190            .serialize(serializer),
1191            Self::Unknown { body, .. } => body.serialize(serializer),
1192        }
1193    }
1194}
1195
1196impl<'de> Deserialize<'de> for RunningImageEvidence {
1197    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1198    where
1199        D: serde::Deserializer<'de>,
1200    {
1201        let (tag, value) = read_tagged(deserializer, "method")?;
1202        match tag.as_str() {
1203            "linux_proc_sha256" => {
1204                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1205                    RunningImageEvidenceWire::LinuxProcSha256 { digest } => {
1206                        Ok(Self::LinuxProcSha256 { digest })
1207                    }
1208                    _ => unreachable!(),
1209                }
1210            }
1211            "macos_spawn_inode" => {
1212                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1213                    RunningImageEvidenceWire::MacosSpawnInode { device, inode } => {
1214                        Ok(Self::MacosSpawnInode { device, inode })
1215                    }
1216                    _ => unreachable!(),
1217                }
1218            }
1219            _ => Ok(Self::Unknown { tag, body: value }),
1220        }
1221    }
1222}
1223
1224impl Serialize for SupervisorRouteConsumer {
1225    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1226    where
1227        S: Serializer,
1228    {
1229        match self {
1230            Self::Reserved { module_id } => SupervisorRouteConsumerWire::Reserved {
1231                module_id: module_id.clone(),
1232            }
1233            .serialize(serializer),
1234            Self::Direct { connection_id } => SupervisorRouteConsumerWire::Direct {
1235                connection_id: *connection_id,
1236            }
1237            .serialize(serializer),
1238            Self::Unknown { body, .. } => body.serialize(serializer),
1239        }
1240    }
1241}
1242
1243impl<'de> Deserialize<'de> for SupervisorRouteConsumer {
1244    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1245    where
1246        D: serde::Deserializer<'de>,
1247    {
1248        let (tag, value) = read_tagged(deserializer, "kind")?;
1249        match tag.as_str() {
1250            "reserved" => {
1251                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1252                    SupervisorRouteConsumerWire::Reserved { module_id } => {
1253                        Ok(Self::Reserved { module_id })
1254                    }
1255                    _ => unreachable!(),
1256                }
1257            }
1258            "direct" => {
1259                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1260                    SupervisorRouteConsumerWire::Direct { connection_id } => {
1261                        Ok(Self::Direct { connection_id })
1262                    }
1263                    _ => unreachable!(),
1264                }
1265            }
1266            _ => Ok(Self::Unknown { tag, body: value }),
1267        }
1268    }
1269}
1270
1271impl Serialize for StderrCaptureState {
1272    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1273    where
1274        S: Serializer,
1275    {
1276        match self {
1277            Self::Captured => StderrCaptureStateWire::Captured.serialize(serializer),
1278            Self::Incomplete { reason } => StderrCaptureStateWire::Incomplete {
1279                reason: reason.clone(),
1280            }
1281            .serialize(serializer),
1282            Self::NotCaptured { reason } => StderrCaptureStateWire::NotCaptured {
1283                reason: reason.clone(),
1284            }
1285            .serialize(serializer),
1286            Self::Unknown { body, .. } => body.serialize(serializer),
1287        }
1288    }
1289}
1290
1291impl<'de> Deserialize<'de> for StderrCaptureState {
1292    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1293    where
1294        D: serde::Deserializer<'de>,
1295    {
1296        let (tag, value) = read_tagged(deserializer, "state")?;
1297        match tag.as_str() {
1298            "captured" => {
1299                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1300                    StderrCaptureStateWire::Captured => Ok(Self::Captured),
1301                    _ => unreachable!(),
1302                }
1303            }
1304            "incomplete" => match serde_json::from_value(value.into_value())
1305                .map_err(D::Error::custom)?
1306            {
1307                StderrCaptureStateWire::Incomplete { reason } => Ok(Self::Incomplete { reason }),
1308                _ => unreachable!(),
1309            },
1310            "not_captured" => match serde_json::from_value(value.into_value())
1311                .map_err(D::Error::custom)?
1312            {
1313                StderrCaptureStateWire::NotCaptured { reason } => Ok(Self::NotCaptured { reason }),
1314                _ => unreachable!(),
1315            },
1316            _ => Ok(Self::Unknown { tag, body: value }),
1317        }
1318    }
1319}
1320
1321impl Serialize for StderrTailEntry {
1322    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1323    where
1324        S: Serializer,
1325    {
1326        match self {
1327            Self::Line { text, truncated } => StderrTailEntryWire::Line {
1328                text: text.clone(),
1329                truncated: *truncated,
1330            }
1331            .serialize(serializer),
1332            Self::ProcessStart => StderrTailEntryWire::ProcessStart.serialize(serializer),
1333            Self::Unknown { body, .. } => body.serialize(serializer),
1334        }
1335    }
1336}
1337
1338impl<'de> Deserialize<'de> for StderrTailEntry {
1339    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1340    where
1341        D: serde::Deserializer<'de>,
1342    {
1343        let (tag, value) = read_tagged(deserializer, "kind")?;
1344        match tag.as_str() {
1345            "line" => match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1346                StderrTailEntryWire::Line { text, truncated } => Ok(Self::Line { text, truncated }),
1347                _ => unreachable!(),
1348            },
1349            "process_start" => {
1350                match serde_json::from_value(value.into_value()).map_err(D::Error::custom)? {
1351                    StderrTailEntryWire::ProcessStart => Ok(Self::ProcessStart),
1352                    _ => unreachable!(),
1353                }
1354            }
1355            _ => Ok(Self::Unknown { tag, body: value }),
1356        }
1357    }
1358}
1359
1360fn is_zero_u64(value: &u64) -> bool {
1361    *value == 0
1362}
1363
1364fn default_true() -> bool {
1365    true
1366}
1367
1368/// Bounded terminal history for one module, oldest retained record first.
1369#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1370pub struct TerminalHistory {
1371    /// Unix milliseconds at the current daemon's start; entries may predate it.
1372    pub daemon_started_at_ms: u64,
1373    pub entries: Vec<TerminalEntry>,
1374    /// Exits evicted by the current daemon's ring, possibly recovered from its
1375    /// journal. Not a count of missing exits: expired journal totals are unknown.
1376    #[serde(default, skip_serializing_if = "is_zero_u64")]
1377    pub dropped: u64,
1378    /// Unparseable or incomplete lines across the shared journal, including
1379    /// lines whose module cannot be determined. Zero on older daemons.
1380    #[serde(default, skip_serializing_if = "is_zero_u64")]
1381    pub journal_skipped_lines: u64,
1382    /// Files that could not be read completely, excluding absent generations.
1383    #[serde(default, skip_serializing_if = "is_zero_u64")]
1384    pub journal_read_errors: u64,
1385    /// Failed journal appends across all modules in the current daemon.
1386    #[serde(default, skip_serializing_if = "is_zero_u64")]
1387    pub journal_write_failures: u64,
1388}
1389
1390/// One terminal child exit and the supervisor action it selected.
1391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1392pub struct TerminalEntry {
1393    /// Opaque identity of the daemon that observed this exit; absent on older
1394    /// daemons. Different tokens mean different lifetimes, not chronological order.
1395    #[serde(default, skip_serializing_if = "Option::is_none")]
1396    pub daemon_incarnation: Option<String>,
1397    #[serde(default, skip_serializing_if = "Option::is_none")]
1398    pub exit_code: Option<i32>,
1399    #[serde(default, skip_serializing_if = "Option::is_none")]
1400    pub exit_signal: Option<i32>,
1401    pub at_ms: u64,
1402    pub disposition: TerminalDisposition,
1403    /// Supervisor classification of this exit. Absent on daemons that predate
1404    /// the field; unknown future kinds remain readable instead of failing the
1405    /// enclosing terminal record.
1406    #[serde(default, skip_serializing_if = "Option::is_none")]
1407    pub exit_kind: Option<TerminalExitKind>,
1408    /// Why the supervisor chose this disposition, when the disposition alone
1409    /// does not say. A `failed` record carries the exhausted crash budget here
1410    /// (`crash budget exhausted: max_restarts=3 within window_secs=600`), which
1411    /// is the difference between an operator seeing "it failed" and seeing which
1412    /// limit stopped it. Prose for humans: render it, never parse it. Absent for
1413    /// ordinary dispositions and on daemons predating the field.
1414    #[serde(default, skip_serializing_if = "Option::is_none")]
1415    pub disposition_detail: Option<String>,
1416}
1417
1418/// Exit classification carried by supervisor history and census records.
1419///
1420/// This is an open string enum so future daemon variants degrade to a readable
1421/// unknown kind rather than making a consumer discard the enclosing record.
1422#[derive(Debug, Clone, PartialEq, Eq)]
1423pub enum TerminalExitKind {
1424    Clean,
1425    Crash,
1426    DeliberateSeverance,
1427    Unknown(String),
1428}
1429
1430impl TerminalExitKind {
1431    fn wire_name(&self) -> &str {
1432        match self {
1433            Self::Clean => "clean",
1434            Self::Crash => "crash",
1435            Self::DeliberateSeverance => "deliberate_severance",
1436            Self::Unknown(value) => value,
1437        }
1438    }
1439}
1440
1441impl Serialize for TerminalExitKind {
1442    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1443    where
1444        S: serde::Serializer,
1445    {
1446        serializer.serialize_str(self.wire_name())
1447    }
1448}
1449
1450impl<'de> Deserialize<'de> for TerminalExitKind {
1451    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1452    where
1453        D: serde::Deserializer<'de>,
1454    {
1455        let value = String::deserialize(deserializer)?;
1456        Ok(match value.as_str() {
1457            "clean" => Self::Clean,
1458            "crash" => Self::Crash,
1459            "deliberate_severance" => Self::DeliberateSeverance,
1460            _ => Self::Unknown(value),
1461        })
1462    }
1463}
1464
1465open_string_enum! {
1466    /// The supervisor disposition selected after observing a terminal exit.
1467    TerminalDisposition {
1468        Stopped => "stopped",
1469        Disabled => "disabled",
1470        Failed => "failed",
1471        Restarting => "restarting",
1472    }
1473}
1474
1475#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1476#[serde(rename_all = "snake_case")]
1477pub enum PollKind {
1478    Status,
1479    Liveness,
1480}
1481
1482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1483pub struct CatalogEntry {
1484    pub module_id: String,
1485    /// Whether the registered module currently accepts new route binds.
1486    ///
1487    /// Older daemons omit this field and are interpreted as ready.
1488    #[serde(default = "default_true")]
1489    pub ready: bool,
1490    /// The registered module's self-declared build version, projected from its
1491    /// manifest so a consumer can tell WHICH BUILD of a module it is talking
1492    /// to at connect time.
1493    ///
1494    /// Without this, a client compiled against a module's current source reads
1495    /// a contract that is true of the repository and false of the running
1496    /// process -- the types match, the JSON decodes, and the meaning has
1497    /// changed. That failure carries no error to notice; the version in the
1498    /// catalog turns a semantic skew into a log line at connect instead of a
1499    /// wrong sentence on a user's screen.
1500    ///
1501    /// Optional on the wire only because entries serialized by older daemons
1502    /// lack it: absent means "daemon predates the field", never "module has
1503    /// no version" (the manifest field is required at registration).
1504    ///
1505    /// The reading is ARMED BY OBSERVATION, not by this documentation: until
1506    /// a consumer has seen at least one populated entry from the daemon it is
1507    /// connected to, an all-None catalog is indistinguishable from an old
1508    /// daemon, and a client shipping the documented reading against it would
1509    /// hold a guarantee it does not have.
1510    #[serde(default, skip_serializing_if = "Option::is_none")]
1511    pub module_version: Option<String>,
1512    pub roles: Vec<ProviderRole>,
1513    pub control_ops: Vec<String>,
1514    /// Static capability declarations from the registering module's manifest.
1515    ///
1516    /// Optional on the wire so consumers connected to a daemon that predates the
1517    /// capability grammar retain their existing catalog decoding behavior.
1518    #[serde(default, skip_serializing_if = "Option::is_none")]
1519    pub capabilities: Option<CapabilityDeclarations>,
1520    /// Self-signal declarations mirrored verbatim from the registering module's
1521    /// manifest. The daemon relays these declarations without interpreting them.
1522    #[serde(default, skip_serializing_if = "Option::is_none")]
1523    pub self_signals: Option<Vec<SelfSignalDeclaration>>,
1524}
1525
1526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1527pub struct CapabilityRequirementStatus {
1528    pub consumer: String,
1529    pub capability: String,
1530    pub need: String,
1531    pub verdict: String,
1532    pub episode_seq: u64,
1533    pub config_satisfiable: bool,
1534    pub runtime_available: bool,
1535    pub detail: String,
1536}
1537
1538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1539pub struct SupervisorRescanResult {
1540    pub added: Vec<String>,
1541    pub removed: Vec<String>,
1542    pub changed_pending_reload: Vec<String>,
1543    /// Modules whose enabled flag differs between config and running state.
1544    ///
1545    /// Rescan calls `set_enabled` for these, so omitting them made the preview
1546    /// describe two of the three mutation classes it performs. A module changing
1547    /// only its enabled flag landed in no bucket at all -- not added, removed or
1548    /// changed, and deliberately not counted as unchanged either -- so the sole
1549    /// evidence was that the buckets no longer summed to the configured module
1550    /// count. A preview is consulted precisely when someone is being careful,
1551    /// which is the worst place to under-report.
1552    ///
1553    /// Empty is skipped so consumers written against the older shape keep
1554    /// parsing.
1555    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1556    pub enabled_changes: Vec<String>,
1557    pub unchanged: u32,
1558    /// True when this reconciliation was computed but NOT applied.
1559    ///
1560    /// Carried on the result rather than left to the caller's memory of what it
1561    /// asked for. A preview and an execution are otherwise byte-identical, so a
1562    /// reader who meets this output later -- in a log, a transcript, a pasted
1563    /// snippet -- cannot tell which one happened. Absent when false, so existing
1564    /// consumers see the shape they already parse.
1565    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1566    pub preview: bool,
1567    /// Config sections that changed but which rescan CANNOT apply, so the
1568    /// operator learns a daemon restart is required from the command they just
1569    /// ran rather than from the journal.
1570    ///
1571    /// The daemon has always detected this and logged a warning. A warning in a
1572    /// log is addressed to whoever is reading the log, and the person who just
1573    /// edited the config is by construction looking at the CLI instead: reported
1574    /// by an outside contributor after a module crash-looped through four
1575    /// respawns because a new top-level `storage` section was silently not
1576    /// applied, diagnosable only by journal archaeology.
1577    ///
1578    /// Names the SECTIONS rather than a boolean, because "something else
1579    /// changed" sends the operator back to diffing their own file -- which is
1580    /// the work the message exists to save.
1581    ///
1582    /// Empty is skipped, so consumers written against the older shape keep
1583    /// parsing.
1584    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1585    pub restart_required: Vec<String>,
1586    /// Required capabilities that a dry-run's resulting module set would leave
1587    /// unprovided. Rows are human-readable because the preview is an operator
1588    /// explanation, not a second manifest schema.
1589    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1590    pub capability_warnings: Vec<String>,
1591}
1592
1593/// Which wire protocol a supervised module speaks to subc, as DECLARED in
1594/// daemon config. Never inferred from observed behaviour.
1595///
1596/// The distinction this exists to keep is between a module that should have
1597/// registered and has not yet, and one that never will. A `Subc` module that has
1598/// not registered is a subc module that is LATE -- it may be booting, it may be
1599/// wedged, and the supervisor's health probing and restart escalation are the
1600/// right response. A `None` module is a third-party process (the NATS server is
1601/// the first) that subc launches, supervises, and stops, and that is all: it
1602/// speaks no subc wire at all, so treating its silence as a fault would restart
1603/// a perfectly healthy process forever.
1604///
1605/// Inferring the difference from "has not registered within N seconds" would
1606/// collapse exactly the two cases that must stay apart, which is why this is a
1607/// declaration.
1608#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1609#[serde(rename_all = "snake_case")]
1610pub enum ModuleProtocol {
1611    /// The module registers over channel 0, answers `health.check`, and can
1612    /// serve routes. Every module predating this field is one of these, which is
1613    /// why it is the default.
1614    #[default]
1615    Subc,
1616    /// The module speaks no subc wire. It is supervised as a process only.
1617    None,
1618}
1619
1620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1621pub struct SupervisorEntry {
1622    pub module_id: String,
1623    pub state: String,
1624    pub enabled: bool,
1625    /// Whether this module is serving.
1626    ///
1627    /// For a `Subc` module: enabled, running, process alive, AND registered.
1628    /// For a `None` module the registration term is dropped, because a module
1629    /// that speaks no subc wire never registers and the daemon cannot assert
1630    /// more than "the process it launched is alive". READ IT WITH `protocol`:
1631    /// `live: true` means something weaker for a `None` module, and a renderer
1632    /// that prints it as a bare boolean for one is claiming more than the daemon
1633    /// knows.
1634    pub live: bool,
1635    /// The module's declared wire protocol. Absent on daemons predating the
1636    /// field, where every module was a subc module, so the default is exactly
1637    /// what those daemons meant.
1638    #[serde(default)]
1639    pub protocol: ModuleProtocol,
1640    pub health: SupervisorHealthStatus,
1641    /// When the daemon last collected this module's health, as unix
1642    /// milliseconds. Absent means NEVER PROBED (a module inside its first probe
1643    /// window, whose `health` is therefore `Unknown` rather than good), not
1644    /// probed-long-ago. An old value and an absent one call for opposite
1645    /// readings, so do not render them alike.
1646    #[serde(default)]
1647    pub last_probe_ms: Option<u64>,
1648    /// Exit code of the module's most recent process exit, if the process has
1649    /// exited at least once. Survives respawn so a now-`running` module still
1650    /// reports what killed its previous incarnation.
1651    #[serde(default, skip_serializing_if = "Option::is_none")]
1652    pub last_exit_code: Option<i32>,
1653    /// Terminating signal of the module's most recent process exit (Unix), if
1654    /// any. `Some(9)` = SIGKILL (OOM/jetsam/kill-on-drop), `Some(6)` = SIGABRT
1655    /// (often a panic-abort). Survives respawn.
1656    #[serde(default, skip_serializing_if = "Option::is_none")]
1657    pub last_exit_signal: Option<i32>,
1658    /// Unix milliseconds when the most recent child exit was observed. Present
1659    /// even when the terminal ring is not queried, so existing list readers can
1660    /// order their latest observed exit against events they already received.
1661    #[serde(default, skip_serializing_if = "Option::is_none")]
1662    pub last_exit_ms: Option<u64>,
1663    /// Classification of the most recent child exit. Absent on daemons that
1664    /// predate exit-kind reporting.
1665    #[serde(default, skip_serializing_if = "Option::is_none")]
1666    pub last_exit_kind: Option<TerminalExitKind>,
1667    /// Replacement processes spawned for this module so far, against the budget
1668    /// that disables it.
1669    ///
1670    /// THIS IS THE COUNTER THAT ENDS A MODULE, and it is not the one beside it.
1671    /// `SupervisorHealthEntry::consecutive_failures` returns to zero on any
1672    /// successful probe, so a module can miss probes all day and read zero; this
1673    /// one only decreases when an operator restarts, reloads, or re-enables the
1674    /// module. Reaching the budget moves it to `Failed` and it stays there until
1675    /// somebody intervenes.
1676    ///
1677    /// So a module one restart from being disabled is indistinguishable from a
1678    /// freshly booted one unless this pair is read. Both are reported together
1679    /// because the count alone does not say how close it is.
1680    ///
1681    /// Absent from daemons predating the field, which is why it is optional
1682    /// rather than defaulted to zero: zero would assert a full budget.
1683    #[serde(default, skip_serializing_if = "Option::is_none")]
1684    pub restart_count: Option<u32>,
1685    /// Replacement processes this module is allowed before it is disabled. See
1686    /// `restart_count`; absent on daemons predating the field.
1687    #[serde(default, skip_serializing_if = "Option::is_none")]
1688    pub max_restarts: Option<u32>,
1689    /// Replacement processes spawned over this module's entire supervisor lifetime.
1690    /// Unlike `restart_count`, this value is never reset by an operator action.
1691    #[serde(default, skip_serializing_if = "Option::is_none")]
1692    pub lifetime_restarts: Option<u32>,
1693    /// Successful child spawns in this daemon incarnation. Zero means the
1694    /// module has not successfully spawned; every successful spawn increments
1695    /// the value exactly once.
1696    #[serde(default, skip_serializing_if = "Option::is_none")]
1697    pub spawn_generation: Option<u64>,
1698    /// The span `restart_count` is counted over, in seconds. The crash budget is
1699    /// a RATE, not a lifetime total: `restart_count` counts only the restarts
1700    /// inside the last `restart_window_secs`, and older ones no longer hold a
1701    /// slot. Without this field a reader cannot tell "2 of 3 crashes, ever" from
1702    /// "2 of 3 crashes in the last ten minutes", and those two call for opposite
1703    /// reactions.
1704    ///
1705    /// Absent on daemons predating the windowed budget, where the count really
1706    /// was a lifetime total.
1707    #[serde(default, skip_serializing_if = "Option::is_none")]
1708    pub restart_window_secs: Option<u64>,
1709    /// Effective drain budget for this module, in milliseconds. This is the
1710    /// resolved policy the running supervisor uses, not a config-file reread.
1711    /// Absent on older daemons.
1712    #[serde(default, skip_serializing_if = "Option::is_none")]
1713    pub drain_timeout_ms: Option<u64>,
1714    /// Effective base delay before a crash restart, in milliseconds. Absent on
1715    /// older daemons.
1716    #[serde(default, skip_serializing_if = "Option::is_none")]
1717    pub restart_backoff_ms: Option<u64>,
1718    /// Effective maximum delay before a crash restart, in milliseconds. Absent
1719    /// on older daemons.
1720    #[serde(default, skip_serializing_if = "Option::is_none")]
1721    pub restart_max_backoff_ms: Option<u64>,
1722}
1723
1724#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1725#[serde(rename_all = "snake_case")]
1726pub enum SupervisorHealthStatus {
1727    Ok,
1728    Degraded,
1729    Failing,
1730    Unresponsive,
1731    Unknown,
1732}
1733
1734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1735pub struct SupervisorHealthEntry {
1736    pub module_id: String,
1737    pub status: SupervisorHealthStatus,
1738    /// The module's own human-readable note on its state. Absent means the
1739    /// module said nothing, which is the ordinary shape for a healthy module and
1740    /// is NOT a claim that nothing is wrong. Never parse it: it is prose the
1741    /// module may reword freely, and `status` plus `metrics` are the machine
1742    /// surface.
1743    #[serde(default, skip_serializing_if = "Option::is_none")]
1744    pub detail: Option<String>,
1745    /// The module's own metrics object, relayed opaquely. Absent means the module
1746    /// published none on this probe — either it reports no metrics at all, or the
1747    /// probe did not reach it — so absence cannot distinguish "nothing to report"
1748    /// from "nobody asked". Read `last_probe_ms` to tell those apart.
1749    #[serde(default, skip_serializing_if = "Option::is_none")]
1750    pub metrics: Option<serde_json::Value>,
1751    pub consecutive_failures: u32,
1752    /// Number of recurring health replies received after their daemon deadline.
1753    /// Each increment is evidence that the module remained alive despite a miss.
1754    #[serde(default)]
1755    pub late_answer_count: u64,
1756    /// End-to-end latency of the newest late reply, measured from probe start.
1757    #[serde(default, skip_serializing_if = "Option::is_none")]
1758    pub last_late_answer_latency_ms: Option<u64>,
1759    /// The escalation the supervisor last took for this module (report, restart,
1760    /// alert). Absent means NO ACTION HAS EVER BEEN TAKEN, not that the last one
1761    /// succeeded — a module that has never misbehaved and one whose action record
1762    /// predates a daemon restart both present as absent.
1763    #[serde(default)]
1764    pub last_action: Option<String>,
1765    /// When `last_action` was taken, as unix milliseconds. Absent exactly when
1766    /// `last_action` is absent; the pair moves together.
1767    #[serde(default)]
1768    pub last_action_ms: Option<u64>,
1769    /// When the daemon last collected this entry, as unix milliseconds.
1770    ///
1771    /// `supervisor.health` answers from the supervisor's STORED record rather
1772    /// than probing, so every field above describes some moment in the past and
1773    /// nothing here said which. That matters most right after a restart, where
1774    /// the surface is used to confirm a deploy: a record collected before the
1775    /// restart reports the OLD process, reads as a failed deploy, and invites a
1776    /// redeploy of something that was already correct.
1777    ///
1778    /// `None` means never probed — distinct from probed-long-ago, and the reader
1779    /// must not collapse them. Absent on modules that advertise no health
1780    /// capability, which is why it is optional rather than defaulted to zero.
1781    #[serde(default, skip_serializing_if = "Option::is_none")]
1782    pub last_probe_ms: Option<u64>,
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787    use super::*;
1788    use subc_protocol::{BindIdentity, RouteTarget};
1789
1790    #[test]
1791    fn legacy_terminal_decoder_ignores_deliberate_severance_kind() {
1792        let entry = TerminalEntry {
1793            daemon_incarnation: Some("daemon-before-restart".into()),
1794            exit_code: Some(1),
1795            exit_signal: None,
1796            at_ms: 1_700_000_000_123,
1797            disposition: TerminalDisposition::Restarting,
1798            exit_kind: Some(TerminalExitKind::DeliberateSeverance),
1799            disposition_detail: None,
1800        };
1801        let wire = serde_json::to_string(&entry).expect("terminal entry serializes");
1802        assert_eq!(
1803            serde_json::from_str::<serde_json::Value>(&wire).expect("terminal entry is JSON")
1804                ["exit_kind"],
1805            "deliberate_severance"
1806        );
1807
1808        #[derive(serde::Deserialize)]
1809        struct LegacyTerminalEntry {
1810            exit_code: Option<i32>,
1811            exit_signal: Option<i32>,
1812            at_ms: u64,
1813            disposition: TerminalDisposition,
1814        }
1815
1816        let decoded: LegacyTerminalEntry =
1817            serde_json::from_str(&wire).expect("legacy decoder keeps the terminal record");
1818        assert_eq!(decoded.exit_code, Some(1));
1819        assert_eq!(decoded.exit_signal, None);
1820        assert_eq!(decoded.at_ms, 1_700_000_000_123);
1821        assert_eq!(decoded.disposition, TerminalDisposition::Restarting);
1822
1823        let future_wire = wire.replace("deliberate_severance", "future_exit_kind");
1824        let future: TerminalEntry =
1825            serde_json::from_str(&future_wire).expect("new decoder keeps a future terminal kind");
1826        assert_eq!(
1827            future.exit_kind,
1828            Some(TerminalExitKind::Unknown("future_exit_kind".to_string()))
1829        );
1830    }
1831
1832    #[test]
1833    fn terminal_incarnation_is_optional_for_older_daemons() {
1834        let entry: TerminalEntry = serde_json::from_value(serde_json::json!({
1835            "at_ms": 123,
1836            "disposition": "stopped"
1837        }))
1838        .unwrap();
1839        let encoded = serde_json::to_value(&entry).unwrap();
1840        assert_eq!(
1841            (entry.daemon_incarnation, encoded.get("daemon_incarnation")),
1842            (None, None)
1843        );
1844    }
1845
1846    #[test]
1847    fn route_poll_uses_kind_field() {
1848        let body = serde_json::to_value(ClientControlRequest::RoutePoll {
1849            route_channel: 7,
1850            route_epoch: 11,
1851            kind: PollKind::Status,
1852        })
1853        .unwrap();
1854
1855        assert_eq!(body["op"], "route.poll");
1856        assert_eq!(body["route_epoch"], 11);
1857        assert_eq!(body["kind"], "status");
1858        assert!(body.get("op").is_some());
1859    }
1860
1861    #[test]
1862    fn route_open_is_internally_tagged() {
1863        let request = ClientControlRequest::RouteOpen {
1864            target: RouteTarget::ToolProvider {
1865                module_id: "aft".to_string(),
1866            },
1867            identity: BindIdentity::new("/tmp/project", "opencode", "session-1"),
1868            consumer_identity: None,
1869            consumer_capabilities: None,
1870            admission_facts: None,
1871        };
1872
1873        let body = serde_json::to_value(request).unwrap();
1874        assert_eq!(body["op"], "route.open");
1875        assert_eq!(body["target"]["kind"], "tool_provider");
1876        assert!(body.get("consumer_identity").is_none());
1877        assert!(body.get("consumer_capabilities").is_none());
1878    }
1879
1880    #[test]
1881    fn route_open_without_optional_fields_still_decodes() {
1882        let body = serde_json::json!({
1883            "op": "route.open",
1884            "target": { "kind": "tool_provider", "module_id": "aft" },
1885            "identity": {
1886                "project_root": "/tmp/project",
1887                "harness": "opencode",
1888                "session": "session-1"
1889            }
1890        });
1891
1892        let decoded: ClientControlRequest = serde_json::from_value(body).unwrap();
1893        let ClientControlRequest::RouteOpen {
1894            consumer_identity,
1895            consumer_capabilities,
1896            admission_facts,
1897            ..
1898        } = decoded
1899        else {
1900            panic!("decoded wrong request variant");
1901        };
1902        assert_eq!(consumer_identity, None);
1903        assert_eq!(consumer_capabilities, None);
1904        assert_eq!(admission_facts, None);
1905    }
1906
1907    #[test]
1908    fn new_route_closed_decoder_defaults_fields_absent_from_old_daemon() {
1909        let old_wire = r#"{"op":"route.closed","module_id":"aft-tools","reason":"crash","drained":false,"abandoned":0}"#;
1910        let decoded: ClientControlPush = serde_json::from_str(old_wire).unwrap();
1911        match decoded {
1912            ClientControlPush::RouteClosed {
1913                excluded_subscriptions,
1914                terminal,
1915                ..
1916            } => {
1917                assert_eq!(excluded_subscriptions, 0);
1918                assert_eq!(terminal, None);
1919            }
1920            other => panic!("unexpected push: {other:?}"),
1921        }
1922        assert!(!serde_json::to_string(&decoded)
1923            .unwrap()
1924            .contains("terminal"));
1925    }
1926
1927    #[test]
1928    fn old_route_closed_decoder_ignores_new_terminal_field() {
1929        #[derive(serde::Deserialize)]
1930        #[serde(tag = "op")]
1931        enum LegacyClientControlPush {
1932            #[serde(rename = "route.closed")]
1933            RouteClosed {
1934                module_id: String,
1935                reason: RouteCloseReason,
1936                drained: bool,
1937                abandoned: u32,
1938            },
1939        }
1940
1941        let wire = r#"{"op":"route.closed","module_id":"aft-tools","reason":"crash","drained":false,"abandoned":0,"excluded_subscriptions":3,"terminal":true}"#;
1942        let decoded: LegacyClientControlPush = serde_json::from_str(wire).unwrap();
1943        match decoded {
1944            LegacyClientControlPush::RouteClosed {
1945                module_id,
1946                reason,
1947                drained,
1948                abandoned,
1949            } => {
1950                assert_eq!(module_id, "aft-tools");
1951                assert_eq!(reason, RouteCloseReason::Crash);
1952                assert!(!drained);
1953                assert_eq!(abandoned, 0);
1954            }
1955        }
1956    }
1957
1958    #[test]
1959    fn supervisor_routes_is_a_control_plane_request() {
1960        let body = serde_json::json!({
1961            "op": "supervisor.routes",
1962            "module_id": "aft"
1963        });
1964
1965        let request: ClientControlRequest = serde_json::from_value(body.clone()).unwrap();
1966        assert_eq!(serde_json::to_value(request).unwrap(), body);
1967    }
1968
1969    #[test]
1970    fn diagnostic_string_enums_retain_unknown_wire_values() {
1971        let reason: RunningImageUnavailableReason =
1972            serde_json::from_str("\"future_reason\"").unwrap();
1973        let disposition: TerminalDisposition =
1974            serde_json::from_str("\"future_disposition\"").unwrap();
1975
1976        assert_eq!(
1977            reason,
1978            RunningImageUnavailableReason::Unknown("future_reason".to_string())
1979        );
1980        assert_eq!(
1981            disposition,
1982            TerminalDisposition::Unknown("future_disposition".to_string())
1983        );
1984    }
1985
1986    #[test]
1987    fn diagnostic_string_enums_preserve_existing_wire_names() {
1988        let names = [
1989            (RunningImageUnavailableReason::NotRunning, "not_running"),
1990            (
1991                RunningImageUnavailableReason::UnsupportedPlatform,
1992                "unsupported_platform",
1993            ),
1994            (
1995                RunningImageUnavailableReason::RunningExecutableUnreadable,
1996                "running_executable_unreadable",
1997            ),
1998            (
1999                RunningImageUnavailableReason::SpawnedPathUnreadable,
2000                "spawned_path_unreadable",
2001            ),
2002            (RunningImageUnavailableReason::HashFailed, "hash_failed"),
2003            (
2004                RunningImageUnavailableReason::ProcessIdentityUnconfirmed,
2005                "process_identity_unconfirmed",
2006            ),
2007        ];
2008        for (value, expected) in names {
2009            let wire = serde_json::to_string(&value).unwrap();
2010            assert_eq!(wire, format!("\"{expected}\""));
2011            let decoded: RunningImageUnavailableReason = serde_json::from_str(&wire).unwrap();
2012            assert_eq!(decoded, value);
2013        }
2014
2015        for (value, expected) in [
2016            (TerminalDisposition::Stopped, "stopped"),
2017            (TerminalDisposition::Disabled, "disabled"),
2018            (TerminalDisposition::Failed, "failed"),
2019            (TerminalDisposition::Restarting, "restarting"),
2020        ] {
2021            let wire = serde_json::to_string(&value).unwrap();
2022            assert_eq!(wire, format!("\"{expected}\""));
2023            let decoded: TerminalDisposition = serde_json::from_str(&wire).unwrap();
2024            assert_eq!(decoded, value);
2025        }
2026    }
2027
2028    #[test]
2029    fn diagnostic_string_enums_reject_non_string_bodies() {
2030        assert!(serde_json::from_str::<RunningImageUnavailableReason>("42").is_err());
2031        assert!(serde_json::from_str::<TerminalDisposition>("{\"value\":\"failed\"}").is_err());
2032    }
2033
2034    #[test]
2035    fn unknown_provenance_reason_does_not_discard_healthy_siblings() {
2036        let body = serde_json::json!({
2037            "op": "supervisor.provenance",
2038            "daemon": {
2039                "daemon_build": {},
2040                "daemon_observed": {
2041                    "running_image": {
2042                        "status": "unavailable",
2043                        "reason": "not_running"
2044                    }
2045                }
2046            },
2047            "modules": [
2048                {
2049                    "module_id": "future",
2050                    "module_declared": { "status": "unverifiable" },
2051                    "daemon_observed": {
2052                        "running_image": {
2053                            "status": "unavailable",
2054                            "reason": "future_reason"
2055                        }
2056                    }
2057                },
2058                {
2059                    "module_id": "healthy-a",
2060                    "module_declared": { "status": "unverifiable" },
2061                    "daemon_observed": {
2062                        "running_image": {
2063                            "status": "match",
2064                            "evidence": {
2065                                "method": "linux_proc_sha256",
2066                                "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
2067                            }
2068                        }
2069                    }
2070                },
2071                {
2072                    "module_id": "healthy-b",
2073                    "module_declared": { "status": "unverifiable" },
2074                    "daemon_observed": {
2075                        "running_image": {
2076                            "status": "unavailable",
2077                            "reason": "unsupported_platform"
2078                        }
2079                    }
2080                }
2081            ]
2082        });
2083
2084        let decoded: ClientControlResponse = serde_json::from_value(body).unwrap();
2085        let ClientControlResponse::SupervisorProvenance { modules, .. } = decoded else {
2086            panic!("decoded wrong response variant");
2087        };
2088        assert_eq!(modules.len(), 3);
2089        assert_eq!(modules[0].module_id, "future");
2090        assert_eq!(
2091            modules[0].daemon_observed.running_image,
2092            RunningImageAgreement::Unavailable {
2093                reason: RunningImageUnavailableReason::Unknown("future_reason".to_string())
2094            }
2095        );
2096        assert_eq!(modules[1].module_id, "healthy-a");
2097        assert_eq!(modules[2].module_id, "healthy-b");
2098    }
2099
2100    #[test]
2101    fn tagged_unknown_values_retain_tag_and_body() {
2102        macro_rules! assert_unknown_round_trip {
2103            ($ty:ident, $field:literal, $value:expr) => {
2104                let value = $value;
2105                let wire = serde_json::to_string(&value).unwrap();
2106                let decoded: $ty = serde_json::from_str(&wire).unwrap();
2107                match decoded {
2108                    $ty::Unknown { tag, body } => {
2109                        assert_eq!(tag, value[$field].as_str().unwrap());
2110                        assert_eq!(serde_json::to_value(&body).unwrap(), value);
2111                    }
2112                    _ => panic!("decoded known variant"),
2113                }
2114            };
2115        }
2116
2117        assert_unknown_round_trip!(
2118            ModuleDeclaredProvenance,
2119            "status",
2120            serde_json::json!({"status": "future", "build": {"version": 7}})
2121        );
2122        assert_unknown_round_trip!(
2123            RunningImageAgreement,
2124            "status",
2125            serde_json::json!({"status": "future", "evidence": {"digest": "abc"}})
2126        );
2127        assert_unknown_round_trip!(
2128            RunningImageEvidence,
2129            "method",
2130            serde_json::json!({"method": "future", "digest": "abc"})
2131        );
2132        assert_unknown_round_trip!(
2133            SupervisorRouteConsumer,
2134            "kind",
2135            serde_json::json!({"kind": "future", "module_id": "m"})
2136        );
2137        assert_unknown_round_trip!(
2138            StderrCaptureState,
2139            "state",
2140            serde_json::json!({"state": "future", "reason": "because"})
2141        );
2142        assert_unknown_round_trip!(
2143            StderrTailEntry,
2144            "kind",
2145            serde_json::json!({"kind": "future", "text": "line"})
2146        );
2147    }
2148
2149    #[test]
2150    fn tagged_unknown_values_round_trip_the_original_json() {
2151        let wire = r#"{"kind":"future_consumer","detail":{"z":1}}"#;
2152        let decoded: SupervisorRouteConsumer = serde_json::from_str(wire).unwrap();
2153        assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2154    }
2155
2156    #[test]
2157    fn tagged_unknown_values_round_trip_trailing_tag() {
2158        let route_wire = r#"{"detail":{"z":1},"kind":"future_consumer"}"#;
2159        let route: SupervisorRouteConsumer = serde_json::from_str(route_wire).unwrap();
2160        assert_eq!(serde_json::to_string(&route).unwrap(), route_wire);
2161
2162        let stderr_wire = r#"{"reason":"because","state":"future_state"}"#;
2163        let stderr: StderrCaptureState = serde_json::from_str(stderr_wire).unwrap();
2164        assert_eq!(serde_json::to_string(&stderr).unwrap(), stderr_wire);
2165    }
2166
2167    #[test]
2168    fn tagged_unknown_values_round_trip_middle_tag() {
2169        let route_wire = r#"{"a":1,"kind":"future_x","b":2}"#;
2170        let route: SupervisorRouteConsumer = serde_json::from_str(route_wire).unwrap();
2171        assert_eq!(serde_json::to_string(&route).unwrap(), route_wire);
2172
2173        let stderr_wire = r#"{"a":1,"state":"future_state","b":2}"#;
2174        let stderr: StderrCaptureState = serde_json::from_str(stderr_wire).unwrap();
2175        assert_eq!(serde_json::to_string(&stderr).unwrap(), stderr_wire);
2176    }
2177
2178    #[test]
2179    fn tagged_unknown_values_round_trip_deep_payload() {
2180        let route_wire = r#"{"a":{"n":[1,2]},"kind":"future_x","zz":"s","b":null}"#;
2181        let route: SupervisorRouteConsumer = serde_json::from_str(route_wire).unwrap();
2182        assert_eq!(serde_json::to_string(&route).unwrap(), route_wire);
2183
2184        let stderr_wire = r#"{"a":{"n":[1,2]},"state":"future_state","zz":"s","b":null}"#;
2185        let stderr: StderrCaptureState = serde_json::from_str(stderr_wire).unwrap();
2186        assert_eq!(serde_json::to_string(&stderr).unwrap(), stderr_wire);
2187    }
2188
2189    #[test]
2190    fn tagged_unknown_values_reject_non_object_bodies() {
2191        for wire in ["42", r#""future""#, "[]"] {
2192            assert!(serde_json::from_str::<SupervisorRouteConsumer>(wire).is_err());
2193            assert!(serde_json::from_str::<StderrCaptureState>(wire).is_err());
2194        }
2195    }
2196
2197    #[test]
2198    fn duplicate_discriminators_reject_without_panicking() {
2199        assert_eq!(
2200            serde_json::from_str::<ModuleDeclaredProvenance>(r#"{"status":"unverifiable"}"#)
2201                .unwrap(),
2202            ModuleDeclaredProvenance::Unverifiable
2203        );
2204        match serde_json::from_str::<ModuleDeclaredProvenance>(r#"{"status":"future_thing"}"#)
2205            .unwrap()
2206        {
2207            ModuleDeclaredProvenance::Unknown { tag, .. } => assert_eq!(tag, "future_thing"),
2208            _ => panic!("future discriminator decoded as a known variant"),
2209        }
2210
2211        let wires = [
2212            r#"{"status":"reported","status":"unverifiable"}"#,
2213            r#"{"status":"unverifiable","status":"reported"}"#,
2214            r#"{"status":"reported","build":{},"status":"unverifiable"}"#,
2215            r#"{"status":"unverifiable","build":{},"status":"reported"}"#,
2216        ];
2217
2218        for wire in wires {
2219            let result =
2220                std::panic::catch_unwind(|| serde_json::from_str::<ModuleDeclaredProvenance>(wire));
2221            assert!(result.is_ok(), "duplicate discriminator panicked: {wire}");
2222            assert!(
2223                result.unwrap().is_err(),
2224                "duplicate discriminator decoded: {wire}"
2225            );
2226        }
2227
2228        let wire = r#"{"state":"captured","state":"incomplete","reason":"x"}"#;
2229        let result = std::panic::catch_unwind(|| serde_json::from_str::<StderrCaptureState>(wire));
2230        assert!(result.is_ok(), "duplicate discriminator panicked: {wire}");
2231        assert!(
2232            result.unwrap().is_err(),
2233            "duplicate discriminator decoded: {wire}"
2234        );
2235    }
2236
2237    #[test]
2238    fn nested_unknown_values_round_trip_without_normalizing_member_order() {
2239        let known_wire =
2240            r#"{"status":"match","evidence":{"method":"linux_proc_sha256","digest":"abc"}}"#;
2241        let known: RunningImageAgreement = serde_json::from_str(known_wire).unwrap();
2242        assert_eq!(serde_json::to_string(&known).unwrap(), known_wire);
2243
2244        for wire in [
2245            r#"{"kind":"future_x","detail":{"zeta":1,"alpha":2}}"#,
2246            r#"{"kind":"future_x","d":{"b":{"zz":1,"aa":2}}}"#,
2247        ] {
2248            let decoded: SupervisorRouteConsumer = serde_json::from_str(wire).unwrap();
2249            assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2250        }
2251
2252        for wire in [
2253            r#"{"status":"match","evidence":{"method":"future_probe","zz":1,"aa":2}}"#,
2254            r#"{"status":"match","evidence":{"method":"future_probe","d":{"zz":1,"aa":2}}}"#,
2255        ] {
2256            let decoded: RunningImageAgreement = serde_json::from_str(wire).unwrap();
2257            assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2258        }
2259
2260        let wire = r#"{"status":"mismatch","running":{"detail":{"z":1},"method":"future_running"},"disk":{"method":"future_disk","detail":{"z":1}}}"#;
2261        let decoded: RunningImageAgreement = serde_json::from_str(wire).unwrap();
2262        assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2263
2264        let wire = r#"{"capture":{"state":"captured"},"entries":[{"detail":{"z":1,"a":2},"kind":"future_line"},{"kind":"future_restart","meta":{"b":{"zz":1,"aa":2}}}]}"#;
2265        let decoded: StderrTail = serde_json::from_str(wire).unwrap();
2266        assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
2267    }
2268
2269    #[test]
2270    fn tagged_unknown_member_does_not_discard_known_siblings() {
2271        let body = serde_json::json!({
2272            "modules": [{
2273                "module_id": "target",
2274                "routes": [
2275                    {"consumer": {"kind": "future_consumer", "module_id": "m", "detail": {"retry": true}}, "age_ms": 0, "draining": false},
2276                    {"consumer": {"kind": "direct", "connection_id": 7}, "age_ms": 0, "draining": false}
2277                ]
2278            }]
2279        });
2280        let decoded: ClientControlResponse = serde_json::from_value(
2281            serde_json::json!({"op": "supervisor.routes", "modules": body["modules"]}),
2282        )
2283        .unwrap();
2284        let ClientControlResponse::SupervisorRoutes { modules } = decoded else {
2285            panic!("decoded wrong response variant");
2286        };
2287        assert_eq!(modules[0].routes.len(), 2);
2288        assert_eq!(
2289            modules[0].routes[1].consumer,
2290            SupervisorRouteConsumer::Direct { connection_id: 7 }
2291        );
2292    }
2293}