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}