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