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