Skip to main content

subc_protocol/
session.rs

1//! Session route control wire contract.
2//!
3//! subc has two distinct channel-0 handshakes. Module registration is the
4//! module-to-subc `HELLO`/`HELLO_ACK` handshake that registers the manifest and
5//! liveness. Route bind is the client-to-subc-to-module request/response
6//! handshake that binds one client route to a module route channel.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::{
14    manifest::{CapabilityDeclarations, ProviderRole},
15    scope::{ScopeEnded, ScopeRecord, ScopeRecordResult, ScopeStamp, ScopeStatus},
16    BindIdentity, Principal, RouteCloseReason, RouteTarget,
17};
18
19pub const MODULE_CONTROL_OP_HEALTH_CHECK: &str = "health.check";
20pub const MODULE_TO_SUBC_OP_CATALOG_UPDATE: &str = "catalog.update";
21
22#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24pub enum HealthStatus {
25    Ok,
26    Degraded,
27    Failing,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct HealthReport {
32    pub status: HealthStatus,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub detail: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub metrics: Option<Value>,
37}
38
39impl HealthReport {
40    pub fn ok() -> Self {
41        Self {
42            status: HealthStatus::Ok,
43            detail: None,
44            metrics: None,
45        }
46    }
47}
48
49/// subc-to-module channel-0 control RPC body.
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51#[serde(tag = "op")]
52// RouteBind carries the complete bind metadata, while HealthCheck is a marker;
53// preserving the direct wire shape is more useful than boxing every bind field.
54#[allow(clippy::large_enum_variant)]
55pub enum ModuleControlRequest {
56    #[serde(rename = "route.bind")]
57    RouteBind {
58        route_channel: u16,
59        epoch: u32,
60        target: RouteTarget,
61        identity: BindIdentity,
62        /// The daemon's attestation of the consumer, and the only field here a
63        /// provider may grant privilege on.
64        ///
65        /// `Reserved` is minted at exactly one place in the daemon, on the branch
66        /// where the consumer's launch nonce matched a supervised spawn — the
67        /// function that checks is the function that mints, so the value cannot
68        /// exist without the check having run. That property is what a provider is
69        /// relying on, and it is the reason to key authority on this rather than on
70        /// `identity`, which is client-supplied and unattested (see BindIdentity).
71        ///
72        /// Absent means the daemon made no attestation, which is not the same as a
73        /// denial: it is the shape a pre-attestation peer sends. Treat it as
74        /// unattested rather than as trusted-by-default.
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        principal: Option<Principal>,
77        /// Consumer-declared reverse-request capabilities for the route. This is
78        /// an unverified declaration, not a privilege grant; if a consumer
79        /// over-declares, providers may still send reverse requests that later
80        /// time out or deny. Providers must treat an absent field as no
81        /// reverse-request capability. The vocabulary is open strings; known MCP
82        /// method-family values today are "elicitation", "sampling", and
83        /// "roots".
84        #[serde(default, skip_serializing_if = "Option::is_none")]
85        consumer_capabilities: Option<Vec<String>>,
86        /// The versions of provider roles the consumer speaks on this route,
87        /// role name to version (`{"tool-provider": "v1"}`), copied from the
88        /// consumer's `route.open` unchanged. Like `consumer_capabilities` it
89        /// is the consumer's unverified declaration and grants nothing; a
90        /// provider uses it to choose which version of a role's wire shape to
91        /// speak. The daemon has checked it with [`validate_role_versions`] and
92        /// never sends an empty map. Absent means the consumer declared none:
93        /// a legacy consumer, or a daemon that predates the field.
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        role_versions: Option<BTreeMap<String, String>>,
96        /// Opaque admission facts supplied by the configured carrier module.
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        admission_facts: Option<Value>,
99        /// The daemon's stamp of the scope the route was admitted under, taken
100        /// from the owner's synced record at admission. Like `principal`, it is
101        /// the daemon's, never the opener's: a provider may act on it (on
102        /// `owner_authorized`, `delegates` and `agent_id` together), and must
103        /// treat it as fixed for the route's life, because a change that
104        /// revokes authority closes the route.
105        ///
106        /// Absent means the route was opened without a scope, or by a daemon
107        /// that predates scopes. A provider that needs a scope refuses the bind.
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        scope: Option<ScopeStamp>,
110    },
111    #[serde(rename = "health.check")]
112    HealthCheck {},
113}
114
115/// One-way subc-to-module channel-0 control command.
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(tag = "op")]
118pub enum ModuleControlCommand {
119    #[serde(rename = "module.draining")]
120    Draining {
121        reason: RouteCloseReason,
122        /// Absolute Unix-millisecond deadline for this drain.
123        ///
124        /// WALL CLOCK, WHILE THE DAEMON ENFORCES THE CEILING ON A
125        /// SUSPEND-EXCLUDING MONOTONIC CLOCK (`Instant`, supervise.rs). Both
126        /// processes share one host so `CLOCK_REALTIME` agrees exactly, and the
127        /// two clocks diverge only across host sleep: `Instant` stops, wall does
128        /// not. So a module that sleeps mid-drain wakes to a deadline further in
129        /// the past than the daemon's own ceiling, computes LESS remaining time
130        /// than it has, and seals early.
131        ///
132        /// That direction is deliberate and is the safe one — a module stopping
133        /// early loses nothing, since the daemon kills at its own ceiling
134        /// regardless. The reverse (a module believing it has time the daemon
135        /// has already spent) is the failure this ordering avoids. A module must
136        /// therefore treat this as "no later than", never as a grant.
137        deadline_ms: u64,
138    },
139}
140
141/// Module-to-subc channel-0 response body.
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
143#[serde(tag = "op")]
144pub enum ModuleControlResponse {
145    /// ACK-only success. Rejections use the `FrameType::Error` lane.
146    #[serde(rename = "route.bind")]
147    RouteBindAck {},
148    #[serde(rename = "health.check")]
149    HealthCheck {
150        status: HealthStatus,
151        #[serde(default, skip_serializing_if = "Option::is_none")]
152        detail: Option<String>,
153        #[serde(default, skip_serializing_if = "Option::is_none")]
154        metrics: Option<Value>,
155    },
156}
157
158/// Module-originated channel-0 control RPC body.
159///
160/// This is intentionally separate from [`ModuleControlRequest`]: that enum is the
161/// daemon-to-module direction (`route.bind`, `health.check`), while these bodies
162/// are sent by an already-registered module to subc on a `REQUEST` frame.
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
164#[serde(tag = "op")]
165pub enum ModuleControlRequestFromModule {
166    #[serde(rename = "catalog.update")]
167    CatalogUpdate {
168        provides: Vec<ProviderRole>,
169        /// An attested replacement for the static capability declaration emitted
170        /// by the module's current manifest. `None` preserves the prior
171        /// declaration so existing role-only catalog updates remain byte-identical.
172        #[serde(default, skip_serializing_if = "Option::is_none")]
173        capabilities: Option<CapabilityDeclarations>,
174        /// Updates readiness without re-registering. `None` leaves it unchanged.
175        ///
176        /// Both directions are allowed, but repeatedly flapping readiness looks
177        /// like a restart storm to callers and is a defect in the module.
178        #[serde(default, skip_serializing_if = "Option::is_none")]
179        ready: Option<bool>,
180    },
181    #[serde(rename = "supervisor.live_roots")]
182    LiveRoots {},
183    /// Register this module's full scope set. The owner is the module whose
184    /// registered connection sends it; nothing in the body names the owner.
185    /// Per-record refusals come back in the reply; a refusal of the whole sync
186    /// (not the owner's sync authority, a stale generation, a bound exceeded)
187    /// is an `Error` frame and changes nothing.
188    #[serde(rename = "scope.sync")]
189    ScopeSync {
190        generation: u64,
191        scopes: Vec<ScopeRecord>,
192    },
193    /// Read one scope's current state.
194    #[serde(rename = "scope.describe")]
195    ScopeDescribe {
196        owner: Principal,
197        #[serde(rename = "ref")]
198        scope_ref: String,
199    },
200}
201
202/// Counts of routes for one canonical project root.
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
204pub struct LiveRoot {
205    pub project_root: std::path::PathBuf,
206    pub bound: u64,
207    pub pending: u64,
208}
209
210/// subc's channel-0 response body for module-originated control RPCs.
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212#[serde(tag = "op")]
213pub enum ModuleControlResponseToModule {
214    #[serde(rename = "catalog.update")]
215    CatalogUpdate {},
216    #[serde(rename = "supervisor.live_roots")]
217    LiveRoots {
218        roots: Vec<LiveRoot>,
219        unknown_root_bindings: u64,
220        total_bindings: u64,
221    },
222    #[serde(rename = "scope.sync")]
223    ScopeSync {
224        generation: u64,
225        results: Vec<ScopeRecordResult>,
226        #[serde(default, skip_serializing_if = "Vec::is_empty")]
227        ended: Vec<ScopeEnded>,
228    },
229    #[serde(rename = "scope.describe")]
230    ScopeDescribe {
231        status: ScopeStatus,
232        /// The live epoch, or for `ended` the most recent epoch that ended.
233        #[serde(default, skip_serializing_if = "Option::is_none")]
234        scope_epoch: Option<u64>,
235        daemon_incarnation: String,
236        /// Whether the owner has synced since this daemon incarnation started.
237        owner_synced: bool,
238        /// Whether the owner is a module in the daemon's supervised roster.
239        owner_configured: bool,
240        /// The stamp fields, present only when `status` is `live`.
241        #[serde(default, skip_serializing_if = "Option::is_none")]
242        scope: Option<ScopeStamp>,
243    },
244}
245
246impl From<HealthReport> for ModuleControlResponse {
247    fn from(report: HealthReport) -> Self {
248        Self::HealthCheck {
249            status: report.status,
250            detail: report.detail,
251            metrics: report.metrics,
252        }
253    }
254}
255
256impl ModuleControlResponse {
257    pub fn health_report(&self) -> Option<HealthReport> {
258        match self {
259            Self::HealthCheck {
260                status,
261                detail,
262                metrics,
263            } => Some(HealthReport {
264                status: *status,
265                detail: detail.clone(),
266                metrics: metrics.clone(),
267            }),
268            Self::RouteBindAck {} => None,
269        }
270    }
271}
272
273/// Module-to-subc channel-0 push body.
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275#[serde(tag = "op")]
276pub enum ModuleControlPush {
277    #[serde(rename = "route.status")]
278    RouteStatus {
279        route_channel: u16,
280        route_epoch: u32,
281        status: String,
282    },
283}
284
285/// The wire name of the `role_versions` field on `route.open` and
286/// `route.bind`, for the `detail.field` of the `invalid_request` error that
287/// refuses a malformed one.
288pub const ROLE_VERSIONS_FIELD: &str = "role_versions";
289
290/// Most entries a `role_versions` map may hold.
291pub const MAX_ROLE_VERSIONS: usize = 8;
292
293/// The longest role name accepted in `role_versions`, in bytes.
294pub const MAX_ROLE_NAME_LEN: usize = 64;
295
296/// Why a `role_versions` map was refused.
297#[derive(Clone, Debug, PartialEq, Eq)]
298#[non_exhaustive]
299pub enum RoleVersionsError {
300    /// The map has more than [`MAX_ROLE_VERSIONS`] entries.
301    TooMany { count: usize },
302    /// A role name is not lowercase ASCII letters and digits in words joined
303    /// by single hyphens (`tool-provider`), or is longer than
304    /// [`MAX_ROLE_NAME_LEN`] bytes.
305    InvalidRole { role: String },
306    /// A version is not `v` followed by a positive integer without leading
307    /// zeros (`v1`, `v12`).
308    InvalidVersion { role: String, version: String },
309}
310
311impl RoleVersionsError {
312    /// The request field the error is about: always [`ROLE_VERSIONS_FIELD`].
313    pub fn field(&self) -> &'static str {
314        ROLE_VERSIONS_FIELD
315    }
316}
317
318impl std::fmt::Display for RoleVersionsError {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        match self {
321            Self::TooMany { count } => write!(
322                f,
323                "{ROLE_VERSIONS_FIELD} has {count} entries; at most {MAX_ROLE_VERSIONS} are allowed"
324            ),
325            Self::InvalidRole { role } => write!(
326                f,
327                "{ROLE_VERSIONS_FIELD} names role {role:?}, which is not lowercase letters and \
328                 digits in words joined by '-', at most {MAX_ROLE_NAME_LEN} bytes"
329            ),
330            Self::InvalidVersion { role, version } => write!(
331                f,
332                "{ROLE_VERSIONS_FIELD} gives role {role:?} version {version:?}, which is not 'v' \
333                 followed by a positive integer without leading zeros"
334            ),
335        }
336    }
337}
338
339impl std::error::Error for RoleVersionsError {}
340
341/// Check a `role_versions` map: at most [`MAX_ROLE_VERSIONS`] entries, each
342/// role name matching `^[a-z0-9]+(-[a-z0-9]+)*$` in at most
343/// [`MAX_ROLE_NAME_LEN`] bytes, and each version matching `^v[1-9][0-9]*$`.
344///
345/// The daemon refuses a `route.open` that fails this, so a consumer can run
346/// the same check before sending. An empty map passes; the daemon treats it
347/// as no declaration at all.
348pub fn validate_role_versions(
349    role_versions: &BTreeMap<String, String>,
350) -> Result<(), RoleVersionsError> {
351    if role_versions.len() > MAX_ROLE_VERSIONS {
352        return Err(RoleVersionsError::TooMany {
353            count: role_versions.len(),
354        });
355    }
356    for (role, version) in role_versions {
357        if !is_role_name(role) {
358            return Err(RoleVersionsError::InvalidRole { role: role.clone() });
359        }
360        if !is_role_version(version) {
361            return Err(RoleVersionsError::InvalidVersion {
362                role: role.clone(),
363                version: version.clone(),
364            });
365        }
366    }
367    Ok(())
368}
369
370fn is_role_name(role: &str) -> bool {
371    !role.is_empty()
372        && role.len() <= MAX_ROLE_NAME_LEN
373        && role.split('-').all(|word| {
374            !word.is_empty()
375                && word
376                    .bytes()
377                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
378        })
379}
380
381fn is_role_version(version: &str) -> bool {
382    let bytes = version.as_bytes();
383    bytes.len() >= 2
384        && bytes[0] == b'v'
385        && (b'1'..=b'9').contains(&bytes[1])
386        && bytes[2..].iter().all(u8::is_ascii_digit)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn map(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
394        entries
395            .iter()
396            .map(|(role, version)| (role.to_string(), version.to_string()))
397            .collect()
398    }
399
400    #[test]
401    fn role_versions_accept_role_names_and_positive_versions() {
402        for entries in [
403            vec![],
404            vec![("tool-provider", "v1")],
405            vec![("a", "v9"), ("b2", "v10"), ("x-1-y", "v1203")],
406        ] {
407            assert_eq!(
408                validate_role_versions(&map(&entries)),
409                Ok(()),
410                "{entries:?}"
411            );
412        }
413        let longest = "a".repeat(MAX_ROLE_NAME_LEN);
414        assert_eq!(validate_role_versions(&map(&[(&longest, "v1")])), Ok(()));
415        let full: BTreeMap<String, String> = (0..MAX_ROLE_VERSIONS)
416            .map(|index| (format!("role-{index}"), "v1".to_string()))
417            .collect();
418        assert_eq!(validate_role_versions(&full), Ok(()));
419    }
420
421    #[test]
422    fn role_versions_refuse_malformed_role_names() {
423        let too_long = "a".repeat(MAX_ROLE_NAME_LEN + 1);
424        for role in [
425            "",
426            "Tool-provider",
427            "tool_provider",
428            "tool provider",
429            "-tool",
430            "tool-",
431            "tool--provider",
432            "tool.provider",
433            "outil-é",
434            too_long.as_str(),
435        ] {
436            let error = validate_role_versions(&map(&[(role, "v1")])).unwrap_err();
437            assert_eq!(
438                error,
439                RoleVersionsError::InvalidRole {
440                    role: role.to_string()
441                },
442                "{role:?}"
443            );
444            assert_eq!(error.field(), "role_versions");
445        }
446    }
447
448    #[test]
449    fn role_versions_refuse_malformed_versions() {
450        for version in [
451            "", "v", "v0", "v01", "1", "V1", "v1.0", "v-1", "v1 ", " v1", "vx",
452        ] {
453            let error = validate_role_versions(&map(&[("tool-provider", version)])).unwrap_err();
454            assert_eq!(
455                error,
456                RoleVersionsError::InvalidVersion {
457                    role: "tool-provider".to_string(),
458                    version: version.to_string(),
459                },
460                "{version:?}"
461            );
462            assert_eq!(error.field(), "role_versions");
463        }
464    }
465
466    #[test]
467    fn role_versions_refuse_more_than_eight_entries() {
468        let nine: BTreeMap<String, String> = (0..=MAX_ROLE_VERSIONS)
469            .map(|index| (format!("role-{index}"), "v1".to_string()))
470            .collect();
471        let error = validate_role_versions(&nine).unwrap_err();
472        assert_eq!(error, RoleVersionsError::TooMany { count: 9 });
473        assert_eq!(error.field(), "role_versions");
474        assert!(error.to_string().starts_with("role_versions"), "{error}");
475    }
476
477    #[test]
478    fn route_bind_omits_absent_role_versions_and_carries_present_ones_verbatim() {
479        let bind = |role_versions| ModuleControlRequest::RouteBind {
480            route_channel: 1,
481            epoch: 1,
482            target: crate::RouteTarget::ToolProvider {
483                module_id: "aft".to_string(),
484            },
485            identity: crate::BindIdentity::new("/tmp/p", "h", "s"),
486            principal: None,
487            consumer_capabilities: None,
488            role_versions,
489            admission_facts: None,
490            scope: None,
491        };
492        let absent = serde_json::to_value(bind(None)).unwrap();
493        assert!(absent.get("role_versions").is_none(), "{absent}");
494        let present = bind(Some(map(&[("tool-provider", "v1")])));
495        let encoded = serde_json::to_value(&present).unwrap();
496        assert_eq!(
497            encoded["role_versions"],
498            serde_json::json!({ "tool-provider": "v1" })
499        );
500        let decoded: ModuleControlRequest = serde_json::from_value(encoded).unwrap();
501        assert_eq!(decoded, present);
502    }
503}