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 serde::{Deserialize, Serialize};
11use subc_protocol::{manifest::ProviderRole, session::HealthStatus, BindIdentity, RouteTarget};
12
13/// Daemon-spawned consumer identity presented on route.open.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
15pub struct ConsumerIdentity {
16    pub module_id: String,
17    pub launch_nonce: String,
18}
19
20/// Reserved dotted operation prefixes for the v0.4 control vocabulary.
21///
22/// `scheduler.` and `watch.` were reserved here from v0.4 until 2026-08-10 and
23/// were removed deliberately rather than left as placeholders: neither was ever
24/// implemented, and both capabilities are now owned elsewhere by ruling --
25/// scheduled tasks belong to the session runtime (prefrontal) because the
26/// daemon is state-free routing, and external-event watching belongs to the
27/// connectors module (plexus). A reserved name for something that will never be
28/// built here reads as a roadmap commitment to anyone surveying the protocol,
29/// and it recruited exactly that misunderstanding from an outside contributor.
30pub mod ops {
31    pub const SERVER: &str = "server.";
32    pub const CATALOG: &str = "catalog.";
33    pub const ROUTE: &str = "route.";
34    pub const SUPERVISOR: &str = "supervisor.";
35    pub const CONFIG: &str = "config.";
36
37    pub const SERVER_DESCRIBE: &str = "server.describe";
38    pub const CATALOG_LIST: &str = "catalog.list";
39    pub const ROUTE_OPEN: &str = "route.open";
40    pub const ROUTE_POLL: &str = "route.poll";
41    pub const SUPERVISOR_LIST: &str = "supervisor.list";
42    pub const SUPERVISOR_RESTART: &str = "supervisor.restart";
43    pub const SUPERVISOR_RELOAD: &str = "supervisor.reload";
44    pub const SUPERVISOR_RESCAN: &str = "supervisor.rescan";
45    pub const SUPERVISOR_SET_ENABLED: &str = "supervisor.set_enabled";
46    pub const SUPERVISOR_HEALTH_PROBE: &str = "supervisor.health_probe";
47    pub const SUPERVISOR_HEALTH: &str = "supervisor.health";
48}
49
50/// Client-originated channel-0 control RPC body.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52#[serde(tag = "op")]
53// RouteOpen carries the complete route metadata, while several control operations
54// are markers; retain the direct public wire shape instead of boxing its fields.
55#[allow(clippy::large_enum_variant)]
56pub enum ClientControlRequest {
57    #[serde(rename = "server.describe")]
58    ServerDescribe {},
59    #[serde(rename = "catalog.list")]
60    CatalogList {
61        /// Absent lists every registered module; present narrows to one. A
62        /// narrowed list for an unregistered id is an empty list rather than an
63        /// error, so absent and unregistered are distinguishable only by which
64        /// question you asked.
65        #[serde(default)]
66        module_id: Option<String>,
67    },
68    #[serde(rename = "route.open")]
69    RouteOpen {
70        target: RouteTarget,
71        identity: BindIdentity,
72        /// The consumer's claim to a supervised launch, which the daemon verifies
73        /// against its live spawn nonces before stamping a principal.
74        ///
75        /// Absent is a legitimate shape, not an omission: a direct key-holder has
76        /// no launch nonce to present, and the daemon stamps `Direct`. So absence
77        /// means NO CLAIM WAS MADE, never that a claim was refused — a refused
78        /// claim is an error frame and the route never opens. A provider deciding
79        /// what to trust reads the stamped principal on the bind, not this.
80        #[serde(default, skip_serializing_if = "Option::is_none")]
81        consumer_identity: Option<ConsumerIdentity>,
82        /// Consumer-declared reverse-request capabilities for the route. This is
83        /// an unverified declaration, not a privilege grant; if a consumer
84        /// over-declares, providers may still send reverse requests that later
85        /// time out or deny. Providers must treat an absent field as no
86        /// reverse-request capability. The vocabulary is open strings; known MCP
87        /// method-family values today are "elicitation", "sampling", and
88        /// "roots".
89        #[serde(default, skip_serializing_if = "Option::is_none")]
90        consumer_capabilities: Option<Vec<String>>,
91        /// Opaque admission facts supplied by the configured carrier module.
92        #[serde(default, skip_serializing_if = "Option::is_none")]
93        admission_facts: Option<serde_json::Value>,
94    },
95    #[serde(rename = "route.poll")]
96    RoutePoll {
97        route_channel: u16,
98        route_epoch: u32,
99        kind: PollKind,
100    },
101    #[serde(rename = "supervisor.list")]
102    SupervisorList {},
103    #[serde(rename = "supervisor.restart")]
104    SupervisorRestart { module_id: String },
105    #[serde(rename = "supervisor.reload")]
106    SupervisorReload { module_id: String },
107    #[serde(rename = "supervisor.rescan")]
108    SupervisorRescan {
109        /// Compute the reconciliation and return it WITHOUT applying it.
110        ///
111        /// Rescan retires any supervised module absent from the config, which
112        /// stops live processes. Both halves of that decision are inspectable in
113        /// advance -- the config is a file, the running set is `supervisor.list`
114        /// -- but nothing reconstructs the diff for the operator, so it is read
115        /// from the result table AFTER the retires have happened.
116        ///
117        /// A preview must be computed daemon-side rather than by a client, because
118        /// a client would have to locate the daemon's config itself: two rules
119        /// selecting one subject, agreeing until someone runs a daemon with a
120        /// non-default config. A preview that can describe a different file than
121        /// the operation reads is worse than none, because it is believed.
122        ///
123        /// Defaults to false so an existing client sending `{}` still executes,
124        /// and is OMITTED when false so the bytes an existing client sends are
125        /// unchanged. Serialising `preview:false` would have altered the request's
126        /// wire form for every caller that never asked for a preview -- caught by
127        /// the golden fixture, which is the whole reason that pin exists.
128        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
129        preview: bool,
130    },
131    #[serde(rename = "supervisor.set_enabled")]
132    SupervisorSetEnabled { module_id: String, enabled: bool },
133    #[serde(rename = "supervisor.health_probe")]
134    SupervisorHealthProbe { module_id: String },
135    #[serde(rename = "supervisor.health")]
136    SupervisorHealth {},
137}
138
139/// subc's channel-0 response body for client control RPCs.
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
141#[serde(tag = "op")]
142pub enum ClientControlResponse {
143    #[serde(rename = "server.describe")]
144    ServerDescribe {
145        protocol_ver: u8,
146        subc_ops: Vec<String>,
147        capabilities: Vec<String>,
148        connected_clients: u64,
149        #[serde(default, skip_serializing_if = "Option::is_none")]
150        counters: Option<serde_json::Value>,
151        /// Git commit the daemon was built from, or "unavailable" when the
152        /// build could not read it. The crate version cannot discriminate a
153        /// skewed daemon/CLI pair (it moves per release, not per commit), so
154        /// this is the identity a consumer compares against its own embedded
155        /// commit to detect that it is talking to an older build than it was
156        /// compiled with. Absent from daemons predating the field.
157        #[serde(default, skip_serializing_if = "Option::is_none")]
158        build_git_sha: Option<String>,
159        /// sha256 of the workspace Cargo.lock at build time, or "unavailable".
160        /// Answers "which dependency set" where the commit answers "which
161        /// source"; a commit match with a digest mismatch means a rebuild
162        /// against edited dependencies. Absent from daemons predating the
163        /// field.
164        #[serde(default, skip_serializing_if = "Option::is_none")]
165        build_lock_digest: Option<String>,
166    },
167    #[serde(rename = "catalog.list")]
168    CatalogList {
169        generation: u64,
170        modules: Vec<CatalogEntry>,
171        subc_ops: Vec<String>,
172    },
173    #[serde(rename = "route.open")]
174    RouteOpen {
175        route_channel: u16,
176        route_epoch: u32,
177    },
178    #[serde(rename = "route.poll")]
179    RoutePoll {
180        route_channel: u16,
181        route_epoch: u32,
182        status: Option<String>,
183        live: Option<bool>,
184    },
185    #[serde(rename = "supervisor.list")]
186    SupervisorList {
187        generation: u64,
188        modules: Vec<SupervisorEntry>,
189    },
190    #[serde(rename = "supervisor.ack")]
191    SupervisorAck { module_id: String, applied: bool },
192    #[serde(rename = "supervisor.rescan")]
193    SupervisorRescan {
194        #[serde(flatten)]
195        result: SupervisorRescanResult,
196    },
197    #[serde(rename = "supervisor.health_probe")]
198    SupervisorHealthProbe {
199        module_id: String,
200        status: HealthStatus,
201        #[serde(default, skip_serializing_if = "Option::is_none")]
202        detail: Option<String>,
203        #[serde(default, skip_serializing_if = "Option::is_none")]
204        metrics: Option<serde_json::Value>,
205    },
206    #[serde(rename = "supervisor.health")]
207    SupervisorHealth {
208        generation: u64,
209        modules: Vec<SupervisorHealthEntry>,
210    },
211}
212
213#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
214#[serde(rename_all = "snake_case")]
215pub enum PollKind {
216    Status,
217    Liveness,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
221pub struct CatalogEntry {
222    pub module_id: String,
223    /// The registered module's self-declared build version, projected from its
224    /// manifest so a consumer can tell WHICH BUILD of a module it is talking
225    /// to at connect time.
226    ///
227    /// Without this, a client compiled against a module's current source reads
228    /// a contract that is true of the repository and false of the running
229    /// process -- the types match, the JSON decodes, and the meaning has
230    /// changed. That failure carries no error to notice; the version in the
231    /// catalog turns a semantic skew into a log line at connect instead of a
232    /// wrong sentence on a user's screen.
233    ///
234    /// Optional on the wire only because entries serialized by older daemons
235    /// lack it: absent means "daemon predates the field", never "module has
236    /// no version" (the manifest field is required at registration).
237    ///
238    /// The reading is ARMED BY OBSERVATION, not by this documentation: until
239    /// a consumer has seen at least one populated entry from the daemon it is
240    /// connected to, an all-None catalog is indistinguishable from an old
241    /// daemon, and a client shipping the documented reading against it would
242    /// hold a guarantee it does not have.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub module_version: Option<String>,
245    pub roles: Vec<ProviderRole>,
246    pub control_ops: Vec<String>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
250pub struct SupervisorRescanResult {
251    pub added: Vec<String>,
252    pub removed: Vec<String>,
253    pub changed_pending_reload: Vec<String>,
254    /// Modules whose enabled flag differs between config and running state.
255    ///
256    /// Rescan calls `set_enabled` for these, so omitting them made the preview
257    /// describe two of the three mutation classes it performs. A module changing
258    /// only its enabled flag landed in no bucket at all -- not added, removed or
259    /// changed, and deliberately not counted as unchanged either -- so the sole
260    /// evidence was that the buckets no longer summed to the configured module
261    /// count. A preview is consulted precisely when someone is being careful,
262    /// which is the worst place to under-report.
263    ///
264    /// Empty is skipped so consumers written against the older shape keep
265    /// parsing.
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub enabled_changes: Vec<String>,
268    pub unchanged: u32,
269    /// True when this reconciliation was computed but NOT applied.
270    ///
271    /// Carried on the result rather than left to the caller's memory of what it
272    /// asked for. A preview and an execution are otherwise byte-identical, so a
273    /// reader who meets this output later -- in a log, a transcript, a pasted
274    /// snippet -- cannot tell which one happened. Absent when false, so existing
275    /// consumers see the shape they already parse.
276    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
277    pub preview: bool,
278    /// Config sections that changed but which rescan CANNOT apply, so the
279    /// operator learns a daemon restart is required from the command they just
280    /// ran rather than from the journal.
281    ///
282    /// The daemon has always detected this and logged a warning. A warning in a
283    /// log is addressed to whoever is reading the log, and the person who just
284    /// edited the config is by construction looking at the CLI instead: reported
285    /// by an outside contributor after a module crash-looped through four
286    /// respawns because a new top-level `storage` section was silently not
287    /// applied, diagnosable only by journal archaeology.
288    ///
289    /// Names the SECTIONS rather than a boolean, because "something else
290    /// changed" sends the operator back to diffing their own file -- which is
291    /// the work the message exists to save.
292    ///
293    /// Empty is skipped, so consumers written against the older shape keep
294    /// parsing.
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub restart_required: Vec<String>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300pub struct SupervisorEntry {
301    pub module_id: String,
302    pub state: String,
303    pub enabled: bool,
304    pub live: bool,
305    pub health: SupervisorHealthStatus,
306    /// When the daemon last collected this module's health, as unix
307    /// milliseconds. Absent means NEVER PROBED (a module inside its first probe
308    /// window, whose `health` is therefore `Unknown` rather than good), not
309    /// probed-long-ago. An old value and an absent one call for opposite
310    /// readings, so do not render them alike.
311    #[serde(default)]
312    pub last_probe_ms: Option<u64>,
313    /// Exit code of the module's most recent process exit, if the process has
314    /// exited at least once. Survives respawn so a now-`running` module still
315    /// reports what killed its previous incarnation.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub last_exit_code: Option<i32>,
318    /// Terminating signal of the module's most recent process exit (Unix), if
319    /// any. `Some(9)` = SIGKILL (OOM/jetsam/kill-on-drop), `Some(6)` = SIGABRT
320    /// (often a panic-abort). Survives respawn.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub last_exit_signal: Option<i32>,
323    /// Replacement processes spawned for this module so far, against the budget
324    /// that disables it.
325    ///
326    /// THIS IS THE COUNTER THAT ENDS A MODULE, and it is not the one beside it.
327    /// `SupervisorHealthEntry::consecutive_failures` returns to zero on any
328    /// successful probe, so a module can miss probes all day and read zero; this
329    /// one only decreases when an operator restarts, reloads, or re-enables the
330    /// module. Reaching the budget moves it to `Failed` and it stays there until
331    /// somebody intervenes.
332    ///
333    /// So a module one restart from being disabled is indistinguishable from a
334    /// freshly booted one unless this pair is read. Both are reported together
335    /// because the count alone does not say how close it is.
336    ///
337    /// Absent from daemons predating the field, which is why it is optional
338    /// rather than defaulted to zero: zero would assert a full budget.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub restart_count: Option<u32>,
341    /// Replacement processes this module is allowed before it is disabled. See
342    /// `restart_count`; absent on daemons predating the field.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub max_restarts: Option<u32>,
345}
346
347#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
348#[serde(rename_all = "snake_case")]
349pub enum SupervisorHealthStatus {
350    Ok,
351    Degraded,
352    Failing,
353    Unresponsive,
354    Unknown,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
358pub struct SupervisorHealthEntry {
359    pub module_id: String,
360    pub status: SupervisorHealthStatus,
361    /// The module's own human-readable note on its state. Absent means the
362    /// module said nothing, which is the ordinary shape for a healthy module and
363    /// is NOT a claim that nothing is wrong. Never parse it: it is prose the
364    /// module may reword freely, and `status` plus `metrics` are the machine
365    /// surface.
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub detail: Option<String>,
368    /// The module's own metrics object, relayed opaquely. Absent means the module
369    /// published none on this probe — either it reports no metrics at all, or the
370    /// probe did not reach it — so absence cannot distinguish "nothing to report"
371    /// from "nobody asked". Read `last_probe_ms` to tell those apart.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub metrics: Option<serde_json::Value>,
374    pub consecutive_failures: u32,
375    /// Number of recurring health replies received after their daemon deadline.
376    /// Each increment is evidence that the module remained alive despite a miss.
377    #[serde(default)]
378    pub late_answer_count: u64,
379    /// End-to-end latency of the newest late reply, measured from probe start.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub last_late_answer_latency_ms: Option<u64>,
382    /// The escalation the supervisor last took for this module (report, restart,
383    /// alert). Absent means NO ACTION HAS EVER BEEN TAKEN, not that the last one
384    /// succeeded — a module that has never misbehaved and one whose action record
385    /// predates a daemon restart both present as absent.
386    #[serde(default)]
387    pub last_action: Option<String>,
388    /// When `last_action` was taken, as unix milliseconds. Absent exactly when
389    /// `last_action` is absent; the pair moves together.
390    #[serde(default)]
391    pub last_action_ms: Option<u64>,
392    /// When the daemon last collected this entry, as unix milliseconds.
393    ///
394    /// `supervisor.health` answers from the supervisor's STORED record rather
395    /// than probing, so every field above describes some moment in the past and
396    /// nothing here said which. That matters most right after a restart, where
397    /// the surface is used to confirm a deploy: a record collected before the
398    /// restart reports the OLD process, reads as a failed deploy, and invites a
399    /// redeploy of something that was already correct.
400    ///
401    /// `None` means never probed — distinct from probed-long-ago, and the reader
402    /// must not collapse them. Absent on modules that advertise no health
403    /// capability, which is why it is optional rather than defaulted to zero.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub last_probe_ms: Option<u64>,
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use subc_protocol::{BindIdentity, RouteTarget};
412
413    #[test]
414    fn route_poll_uses_kind_field() {
415        let body = serde_json::to_value(ClientControlRequest::RoutePoll {
416            route_channel: 7,
417            route_epoch: 11,
418            kind: PollKind::Status,
419        })
420        .unwrap();
421
422        assert_eq!(body["op"], "route.poll");
423        assert_eq!(body["route_epoch"], 11);
424        assert_eq!(body["kind"], "status");
425        assert!(body.get("op").is_some());
426    }
427
428    #[test]
429    fn route_open_is_internally_tagged() {
430        let request = ClientControlRequest::RouteOpen {
431            target: RouteTarget::ToolProvider {
432                module_id: "aft".to_string(),
433            },
434            identity: BindIdentity {
435                project_root: "/tmp/project".into(),
436                harness: "opencode".to_string(),
437                session: "session-1".to_string(),
438            },
439            consumer_identity: None,
440            consumer_capabilities: None,
441            admission_facts: None,
442        };
443
444        let body = serde_json::to_value(request).unwrap();
445        assert_eq!(body["op"], "route.open");
446        assert_eq!(body["target"]["kind"], "tool_provider");
447        assert!(body.get("consumer_identity").is_none());
448        assert!(body.get("consumer_capabilities").is_none());
449    }
450
451    #[test]
452    fn route_open_without_optional_fields_still_decodes() {
453        let body = serde_json::json!({
454            "op": "route.open",
455            "target": { "kind": "tool_provider", "module_id": "aft" },
456            "identity": {
457                "project_root": "/tmp/project",
458                "harness": "opencode",
459                "session": "session-1"
460            }
461        });
462
463        let decoded: ClientControlRequest = serde_json::from_value(body).unwrap();
464        let ClientControlRequest::RouteOpen {
465            consumer_identity,
466            consumer_capabilities,
467            admission_facts,
468            ..
469        } = decoded
470        else {
471            panic!("decoded wrong request variant");
472        };
473        assert_eq!(consumer_identity, None);
474        assert_eq!(consumer_capabilities, None);
475        assert_eq!(admission_facts, None);
476    }
477}