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 serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::{
12    manifest::{CapabilityDeclarations, ProviderRole},
13    scope::{ScopeEnded, ScopeRecord, ScopeRecordResult, ScopeStamp, ScopeStatus},
14    BindIdentity, Principal, RouteCloseReason, RouteTarget,
15};
16
17pub const MODULE_CONTROL_OP_HEALTH_CHECK: &str = "health.check";
18pub const MODULE_TO_SUBC_OP_CATALOG_UPDATE: &str = "catalog.update";
19
20#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "snake_case")]
22pub enum HealthStatus {
23    Ok,
24    Degraded,
25    Failing,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct HealthReport {
30    pub status: HealthStatus,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub detail: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub metrics: Option<Value>,
35}
36
37impl HealthReport {
38    pub fn ok() -> Self {
39        Self {
40            status: HealthStatus::Ok,
41            detail: None,
42            metrics: None,
43        }
44    }
45}
46
47/// subc-to-module channel-0 control RPC body.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(tag = "op")]
50// RouteBind carries the complete bind metadata, while HealthCheck is a marker;
51// preserving the direct wire shape is more useful than boxing every bind field.
52#[allow(clippy::large_enum_variant)]
53pub enum ModuleControlRequest {
54    #[serde(rename = "route.bind")]
55    RouteBind {
56        route_channel: u16,
57        epoch: u32,
58        target: RouteTarget,
59        identity: BindIdentity,
60        /// The daemon's attestation of the consumer, and the only field here a
61        /// provider may grant privilege on.
62        ///
63        /// `Reserved` is minted at exactly one place in the daemon, on the branch
64        /// where the consumer's launch nonce matched a supervised spawn — the
65        /// function that checks is the function that mints, so the value cannot
66        /// exist without the check having run. That property is what a provider is
67        /// relying on, and it is the reason to key authority on this rather than on
68        /// `identity`, which is client-supplied and unattested (see BindIdentity).
69        ///
70        /// Absent means the daemon made no attestation, which is not the same as a
71        /// denial: it is the shape a pre-attestation peer sends. Treat it as
72        /// unattested rather than as trusted-by-default.
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        principal: Option<Principal>,
75        /// Consumer-declared reverse-request capabilities for the route. This is
76        /// an unverified declaration, not a privilege grant; if a consumer
77        /// over-declares, providers may still send reverse requests that later
78        /// time out or deny. Providers must treat an absent field as no
79        /// reverse-request capability. The vocabulary is open strings; known MCP
80        /// method-family values today are "elicitation", "sampling", and
81        /// "roots".
82        #[serde(default, skip_serializing_if = "Option::is_none")]
83        consumer_capabilities: Option<Vec<String>>,
84        /// Opaque admission facts supplied by the configured carrier module.
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        admission_facts: Option<Value>,
87        /// The daemon's stamp of the scope the route was admitted under, taken
88        /// from the owner's synced record at admission. Like `principal`, it is
89        /// the daemon's, never the opener's: a provider may act on it (on
90        /// `owner_authorized`, `delegates` and `agent_id` together), and must
91        /// treat it as fixed for the route's life, because a change that
92        /// revokes authority closes the route.
93        ///
94        /// Absent means the route was opened without a scope, or by a daemon
95        /// that predates scopes. A provider that needs a scope refuses the bind.
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        scope: Option<ScopeStamp>,
98    },
99    #[serde(rename = "health.check")]
100    HealthCheck {},
101}
102
103/// One-way subc-to-module channel-0 control command.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(tag = "op")]
106pub enum ModuleControlCommand {
107    #[serde(rename = "module.draining")]
108    Draining {
109        reason: RouteCloseReason,
110        /// Absolute Unix-millisecond deadline for this drain.
111        ///
112        /// WALL CLOCK, WHILE THE DAEMON ENFORCES THE CEILING ON A
113        /// SUSPEND-EXCLUDING MONOTONIC CLOCK (`Instant`, supervise.rs). Both
114        /// processes share one host so `CLOCK_REALTIME` agrees exactly, and the
115        /// two clocks diverge only across host sleep: `Instant` stops, wall does
116        /// not. So a module that sleeps mid-drain wakes to a deadline further in
117        /// the past than the daemon's own ceiling, computes LESS remaining time
118        /// than it has, and seals early.
119        ///
120        /// That direction is deliberate and is the safe one — a module stopping
121        /// early loses nothing, since the daemon kills at its own ceiling
122        /// regardless. The reverse (a module believing it has time the daemon
123        /// has already spent) is the failure this ordering avoids. A module must
124        /// therefore treat this as "no later than", never as a grant.
125        deadline_ms: u64,
126    },
127}
128
129/// Module-to-subc channel-0 response body.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
131#[serde(tag = "op")]
132pub enum ModuleControlResponse {
133    /// ACK-only success. Rejections use the `FrameType::Error` lane.
134    #[serde(rename = "route.bind")]
135    RouteBindAck {},
136    #[serde(rename = "health.check")]
137    HealthCheck {
138        status: HealthStatus,
139        #[serde(default, skip_serializing_if = "Option::is_none")]
140        detail: Option<String>,
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        metrics: Option<Value>,
143    },
144}
145
146/// Module-originated channel-0 control RPC body.
147///
148/// This is intentionally separate from [`ModuleControlRequest`]: that enum is the
149/// daemon-to-module direction (`route.bind`, `health.check`), while these bodies
150/// are sent by an already-registered module to subc on a `REQUEST` frame.
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152#[serde(tag = "op")]
153pub enum ModuleControlRequestFromModule {
154    #[serde(rename = "catalog.update")]
155    CatalogUpdate {
156        provides: Vec<ProviderRole>,
157        /// An attested replacement for the static capability declaration emitted
158        /// by the module's current manifest. `None` preserves the prior
159        /// declaration so existing role-only catalog updates remain byte-identical.
160        #[serde(default, skip_serializing_if = "Option::is_none")]
161        capabilities: Option<CapabilityDeclarations>,
162        /// Updates readiness without re-registering. `None` leaves it unchanged.
163        ///
164        /// Both directions are allowed, but repeatedly flapping readiness looks
165        /// like a restart storm to callers and is a defect in the module.
166        #[serde(default, skip_serializing_if = "Option::is_none")]
167        ready: Option<bool>,
168    },
169    #[serde(rename = "supervisor.live_roots")]
170    LiveRoots {},
171    /// Register this module's full scope set. The owner is the module whose
172    /// registered connection sends it; nothing in the body names the owner.
173    /// Per-record refusals come back in the reply; a refusal of the whole sync
174    /// (not the owner's sync authority, a stale generation, a bound exceeded)
175    /// is an `Error` frame and changes nothing.
176    #[serde(rename = "scope.sync")]
177    ScopeSync {
178        generation: u64,
179        scopes: Vec<ScopeRecord>,
180    },
181    /// Read one scope's current state.
182    #[serde(rename = "scope.describe")]
183    ScopeDescribe {
184        owner: Principal,
185        #[serde(rename = "ref")]
186        scope_ref: String,
187    },
188}
189
190/// Counts of routes for one canonical project root.
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192pub struct LiveRoot {
193    pub project_root: std::path::PathBuf,
194    pub bound: u64,
195    pub pending: u64,
196}
197
198/// subc's channel-0 response body for module-originated control RPCs.
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200#[serde(tag = "op")]
201pub enum ModuleControlResponseToModule {
202    #[serde(rename = "catalog.update")]
203    CatalogUpdate {},
204    #[serde(rename = "supervisor.live_roots")]
205    LiveRoots {
206        roots: Vec<LiveRoot>,
207        unknown_root_bindings: u64,
208        total_bindings: u64,
209    },
210    #[serde(rename = "scope.sync")]
211    ScopeSync {
212        generation: u64,
213        results: Vec<ScopeRecordResult>,
214        #[serde(default, skip_serializing_if = "Vec::is_empty")]
215        ended: Vec<ScopeEnded>,
216    },
217    #[serde(rename = "scope.describe")]
218    ScopeDescribe {
219        status: ScopeStatus,
220        /// The live epoch, or for `ended` the most recent epoch that ended.
221        #[serde(default, skip_serializing_if = "Option::is_none")]
222        scope_epoch: Option<u64>,
223        daemon_incarnation: String,
224        /// Whether the owner has synced since this daemon incarnation started.
225        owner_synced: bool,
226        /// Whether the owner is a module in the daemon's supervised roster.
227        owner_configured: bool,
228        /// The stamp fields, present only when `status` is `live`.
229        #[serde(default, skip_serializing_if = "Option::is_none")]
230        scope: Option<ScopeStamp>,
231    },
232}
233
234impl From<HealthReport> for ModuleControlResponse {
235    fn from(report: HealthReport) -> Self {
236        Self::HealthCheck {
237            status: report.status,
238            detail: report.detail,
239            metrics: report.metrics,
240        }
241    }
242}
243
244impl ModuleControlResponse {
245    pub fn health_report(&self) -> Option<HealthReport> {
246        match self {
247            Self::HealthCheck {
248                status,
249                detail,
250                metrics,
251            } => Some(HealthReport {
252                status: *status,
253                detail: detail.clone(),
254                metrics: metrics.clone(),
255            }),
256            Self::RouteBindAck {} => None,
257        }
258    }
259}
260
261/// Module-to-subc channel-0 push body.
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
263#[serde(tag = "op")]
264pub enum ModuleControlPush {
265    #[serde(rename = "route.status")]
266    RouteStatus {
267        route_channel: u16,
268        route_epoch: u32,
269        status: String,
270    },
271}