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}