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