Skip to main content

subc_protocol/
lib.rs

1//! subc wire contract.
2//!
3//! This crate is the single source of truth for the subc <-> module wire,
4//! shared by subc-core and AFT. It defines the **envelope** (the fixed
5//! 17-byte routing header subc splices on), the canonical subc-generated body
6//! schemas such as [`ErrorBody`], and the capability manifest. JSON-RPC request
7//! and response bodies remain module-owned opaque payloads to subc.
8//!
9//! ## The envelope (locked — see docs/subc-core-architecture.md §4.8)
10//!
11//! ```text
12//!  offset  size  field     type    purpose
13//!    0      4    len       u32     # of BODY bytes after this 17-byte header
14//!    4      1    ver       u8      envelope version
15//!    5      1    type      u8      frame kind (see FrameType)
16//!    6      1    flags     u8      bit0 BINARY · bits1-2 PRIORITY · bit3 LAST · 4-7 reserved
17//!    7      2    channel   u16     route = (component, session); 0 = subc itself
18//!    9      8    corr      u64     correlation id; CANCEL carries the target call's corr
19//!   17 -> body
20//! ```
21//!
22//! Little-endian (same-machine, native, no byte-swap on the hot path).
23//!
24//! **Frozen prefix (the versioning invariant):** `len` (u32 @ 0) and `ver`
25//! (u8 @ 4) keep fixed meaning + position in *every* future version. A reader
26//! of any version can therefore always read the first 5 bytes, learn `ver`,
27//! look up that version's header length, read the rest, and splice `len` body
28//! bytes. `decode_header` enforces this discipline.
29
30#![forbid(unsafe_code)]
31
32use std::{error::Error, fmt, path::PathBuf};
33
34use serde::{Deserialize, Serialize};
35
36pub mod frame;
37pub mod manifest;
38pub mod session;
39
40pub use frame::{Frame, FrameBuildError};
41
42/// Per-route bind identity shared by client-facing and module-facing control.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub struct BindIdentity {
45    pub project_root: PathBuf,
46    pub harness: String,
47    pub session: String,
48}
49
50/// Caller fact stamped by subc on each route.bind relayed to a module.
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum Principal {
54    /// A daemon-spawned module proved possession of its launch nonce.
55    Reserved { module_id: String },
56    /// No consumer identity was presented; the caller is a direct key-holder.
57    Direct,
58    /// Reserved vocabulary for a future degraded/no-key-auth mode.
59    Unverified,
60}
61
62/// Explicit target for a route open/bind operation.
63///
64/// RouteTarget.kind ↔ ProviderRole mapping:
65///
66/// | RouteTarget.kind | required ProviderRole | disambiguator |
67/// |---|---|---|
68/// | `tool_provider` | `ToolProvider` | v1: ≤1 per module |
69/// | `management_surface` | `ManagementSurface` | v1: ≤1 per module |
70/// | `internal_service` | `InternalService` | `service_id` (multiple allowed) |
71///
72/// `ProviderRole::PipelineStage` is intentionally unroutable; pipeline modules
73/// are wired by an orchestrator rather than opened directly by clients.
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(tag = "kind", rename_all = "snake_case")]
76pub enum RouteTarget {
77    ToolProvider {
78        module_id: String,
79    },
80    ManagementSurface {
81        module_id: String,
82    },
83    InternalService {
84        module_id: String,
85        service_id: String,
86    },
87}
88
89/// Envelope protocol version this build speaks.
90pub const PROTOCOL_VERSION: u8 = 1;
91
92/// Env var subc sets on each supervised child telling it the module_id it is
93/// supervised under, so it can register under that id.
94pub const SUBC_MODULE_ID_ENV: &str = "SUBC_MODULE_ID";
95
96/// Env var subc sets, on each spawn of a `reserved` module only, to a fresh
97/// one-time launch nonce. The child echoes it in `ModuleHelloBody::launch_nonce`;
98/// subc accepts a reserved module_id's HELLO only when the nonce matches the one it
99/// last injected for that id. Non-reserved modules never receive it.
100pub const SUBC_LAUNCH_NONCE_ENV: &str = "SUBC_LAUNCH_NONCE";
101
102/// Fixed header length for `PROTOCOL_VERSION` 1.
103pub const HEADER_LEN: usize = 17;
104
105/// Bytes of the frozen prefix (`len` u32 + `ver` u8) that are stable across
106/// every envelope version. A reader needs only these to learn the version and
107/// thus the full header length.
108pub const FROZEN_PREFIX_LEN: usize = 5;
109
110/// Maximum v1 frame body accepted before allocation.
111///
112/// This 64 MiB starting cap prevents a malformed header from forcing an
113/// unbounded allocation. Future protocol versions can negotiate or encode a
114/// different cap while preserving the frozen prefix.
115pub const MAX_FRAME_BODY_LEN: u32 = 64 * 1024 * 1024;
116
117/// Canonical JSON body for all subc-generated `ERROR` frames.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119pub struct ErrorBody {
120    pub code: String,
121    pub message: String,
122}
123
124/// Module-to-subc `HELLO` body used during module registration.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
126pub struct ModuleHelloBody {
127    pub manifest: manifest::ModuleManifest,
128    pub protocol_ver: u8,
129    #[serde(default)]
130    pub control_ops: Option<Vec<String>>,
131    /// One-time launch nonce, echoed back from the `SUBC_LAUNCH_NONCE` environment
132    /// variable the daemon injected when it spawned this process. Only a daemon-spawned
133    /// process for a `reserved` module receives a nonce; subc accepts a reserved
134    /// `module_id`'s HELLO only when this matches the nonce it last injected for that
135    /// id, so a different process cannot register as a reserved module while the real
136    /// one is down/restarting. Absent (`serde(default)`) for non-reserved modules and
137    /// self-connecting providers, which are never nonce-checked.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub launch_nonce: Option<String>,
140}
141
142/// subc-to-module `HELLO_ACK` body used during module registration.
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144pub struct ModuleHelloAckBody {
145    pub negotiated_ver: u8,
146    pub subc_ops: Vec<String>,
147    pub subc_capabilities: Vec<String>,
148    /// The module's resolved storage descriptor, when the daemon's central config
149    /// configures managed storage. Carried opaquely here (subc-protocol stays a
150    /// thin wire crate with no storage/database dependency); a module that uses
151    /// managed storage deserializes it into `cortexkit_store_types::StorageDescriptor`
152    /// and hands it to `cortexkit-store`. Absent when no storage is configured, and
153    /// `serde(default)` so an older module simply ignores it.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub storage: Option<serde_json::Value>,
156}
157
158/// Frame kind (`type` byte at offset 5).
159///
160/// `CANCEL`, `PING`, `PONG`, and `GOODBYE` are pure-header frames (`len == 0`);
161/// only `HELLO`/`HELLO_ACK` and the RPC payloads carry bodies.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163#[repr(u8)]
164pub enum FrameType {
165    Request = 0,
166    Response = 1,
167    Push = 2,
168    StreamData = 3,
169    StreamEnd = 4,
170    Error = 5,
171    Cancel = 6,
172    Ping = 7,
173    Pong = 8,
174    Hello = 9,
175    HelloAck = 10,
176    Goodbye = 11,
177}
178
179impl FrameType {
180    /// Map the raw `type` byte to a `FrameType`, or `None` if unknown.
181    pub fn from_u8(b: u8) -> Option<Self> {
182        Some(match b {
183            0 => Self::Request,
184            1 => Self::Response,
185            2 => Self::Push,
186            3 => Self::StreamData,
187            4 => Self::StreamEnd,
188            5 => Self::Error,
189            6 => Self::Cancel,
190            7 => Self::Ping,
191            8 => Self::Pong,
192            9 => Self::Hello,
193            10 => Self::HelloAck,
194            11 => Self::Goodbye,
195            _ => return None,
196        })
197    }
198
199    pub fn is_pure_header(self) -> bool {
200        matches!(self, Self::Cancel | Self::Ping | Self::Pong | Self::Goodbye)
201    }
202}
203
204/// Scheduling priority carried in `flags` bits 1-2. subc schedules on this
205/// without parsing the body.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207#[repr(u8)]
208pub enum Priority {
209    Passive = 0,
210    Interactive = 1,
211    Background = 2,
212}
213
214impl Priority {
215    fn from_bits(bits: u8) -> Option<Self> {
216        Some(match bits {
217            0 => Self::Passive,
218            1 => Self::Interactive,
219            2 => Self::Background,
220            _ => return None,
221        })
222    }
223}
224
225const FLAG_BINARY: u8 = 0b0000_0001; // bit 0
226const FLAG_PRIORITY_MASK: u8 = 0b0000_0110; // bits 1-2
227const FLAG_PRIORITY_SHIFT: u8 = 1;
228const FLAG_LAST: u8 = 0b0000_1000; // bit 3
229const FLAG_RESERVED_MASK: u8 = 0b1111_0000; // bits 4-7 must be zero
230
231/// The `flags` byte (offset 6): `bit0 BINARY · bits1-2 PRIORITY · bit3 LAST`.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub struct Flags(pub u8);
234
235impl Flags {
236    /// Build flags from typed components.
237    pub fn new(binary: bool, priority: Priority, last: bool) -> Self {
238        let mut b = 0u8;
239        if binary {
240            b |= FLAG_BINARY;
241        }
242        b |= (priority as u8) << FLAG_PRIORITY_SHIFT;
243        if last {
244            b |= FLAG_LAST;
245        }
246        Flags(b)
247    }
248
249    /// Body is raw bytes (bulk lane) rather than JSON-RPC.
250    pub fn is_binary(self) -> bool {
251        self.0 & FLAG_BINARY != 0
252    }
253
254    /// Final frame of a streamed message.
255    pub fn is_last(self) -> bool {
256        self.0 & FLAG_LAST != 0
257    }
258
259    /// Decode the priority bits, or `None` if they hold a reserved value.
260    pub fn priority(self) -> Option<Priority> {
261        Priority::from_bits((self.0 & FLAG_PRIORITY_MASK) >> FLAG_PRIORITY_SHIFT)
262    }
263
264    /// True if any reserved bit (4-7) is set — a malformed/forward frame.
265    pub fn has_reserved_bits(self) -> bool {
266        self.0 & FLAG_RESERVED_MASK != 0
267    }
268}
269
270/// A decoded envelope header. The body is the `len` bytes that follow it.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub struct EnvelopeHeader {
273    /// Number of body bytes after the header.
274    pub len: u32,
275    /// Envelope version.
276    pub ver: u8,
277    /// Frame kind.
278    pub ty: FrameType,
279    /// Flag bits.
280    pub flags: Flags,
281    /// Route = (component, session); 0 = subc itself.
282    pub channel: u16,
283    /// Correlation id.
284    pub corr: u64,
285}
286
287impl EnvelopeHeader {
288    /// Serialize the header to its fixed 17-byte little-endian form.
289    pub fn encode(&self) -> [u8; HEADER_LEN] {
290        let mut buf = [0u8; HEADER_LEN];
291        buf[0..4].copy_from_slice(&self.len.to_le_bytes());
292        buf[4] = self.ver;
293        buf[5] = self.ty as u8;
294        buf[6] = self.flags.0;
295        buf[7..9].copy_from_slice(&self.channel.to_le_bytes());
296        buf[9..17].copy_from_slice(&self.corr.to_le_bytes());
297        buf
298    }
299}
300
301/// Why a header could not be decoded.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum DecodeError {
304    /// Fewer than `FROZEN_PREFIX_LEN` bytes — cannot even read `len`/`ver`.
305    TooShortForPrefix { have: usize },
306    /// `ver` is not a version this build understands.
307    UnsupportedVersion { ver: u8 },
308    /// Version known but fewer than its header length is present.
309    TooShortForHeader { have: usize, need: usize },
310    /// `type` byte is not a known `FrameType`.
311    UnknownFrameType { byte: u8 },
312    /// A reserved flag bit (4-7) is set.
313    ReservedFlagBits { flags: u8 },
314    /// Priority bits 1-2 hold the reserved value `0b11`.
315    ReservedPriorityBits { flags: u8 },
316    /// A pure-header frame declared body bytes.
317    PureHeaderFrameWithBody { ty: FrameType, len: u32 },
318}
319
320impl fmt::Display for DecodeError {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        match self {
323            Self::TooShortForPrefix { have } => {
324                write!(f, "header shorter than frozen prefix: have {have} bytes")
325            }
326            Self::UnsupportedVersion { ver } => write!(f, "unsupported envelope version {ver}"),
327            Self::TooShortForHeader { have, need } => {
328                write!(
329                    f,
330                    "header too short for version: have {have} bytes, need {need}"
331                )
332            }
333            Self::UnknownFrameType { byte } => write!(f, "unknown frame type byte {byte}"),
334            Self::ReservedFlagBits { flags } => {
335                write!(f, "reserved flag bits set in flags 0b{flags:08b}")
336            }
337            Self::ReservedPriorityBits { flags } => {
338                write!(f, "reserved priority bits set in flags 0b{flags:08b}")
339            }
340            Self::PureHeaderFrameWithBody { ty, len } => {
341                write!(
342                    f,
343                    "pure-header frame {ty:?} declared non-zero body length {len}"
344                )
345            }
346        }
347    }
348}
349
350impl Error for DecodeError {}
351
352/// How many header bytes a given envelope version occupies. Driven by the
353/// frozen prefix: read `ver`, then learn the full header length here.
354fn header_len_for_version(ver: u8) -> Option<usize> {
355    match ver {
356        1 => Some(HEADER_LEN),
357        _ => None,
358    }
359}
360
361/// Decode an envelope header from the front of `bytes`, following the
362/// frozen-prefix discipline:
363/// 1. need at least the 5-byte prefix to read `len` + `ver`;
364/// 2. dispatch the full header length on `ver`;
365/// 3. need the full header present; then parse the rest.
366///
367/// Never panics on malformed input — returns a typed [`DecodeError`].
368pub fn decode_header(bytes: &[u8]) -> Result<EnvelopeHeader, DecodeError> {
369    if bytes.len() < FROZEN_PREFIX_LEN {
370        return Err(DecodeError::TooShortForPrefix { have: bytes.len() });
371    }
372    let ver = bytes[4];
373    let need = header_len_for_version(ver).ok_or(DecodeError::UnsupportedVersion { ver })?;
374    if bytes.len() < need {
375        return Err(DecodeError::TooShortForHeader {
376            have: bytes.len(),
377            need,
378        });
379    }
380
381    let len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
382    let ty =
383        FrameType::from_u8(bytes[5]).ok_or(DecodeError::UnknownFrameType { byte: bytes[5] })?;
384    let flags = Flags(bytes[6]);
385    if flags.has_reserved_bits() {
386        return Err(DecodeError::ReservedFlagBits { flags: bytes[6] });
387    }
388    if flags.priority().is_none() {
389        return Err(DecodeError::ReservedPriorityBits { flags: bytes[6] });
390    }
391    if ty.is_pure_header() && len != 0 {
392        return Err(DecodeError::PureHeaderFrameWithBody { ty, len });
393    }
394    let channel = u16::from_le_bytes([bytes[7], bytes[8]]);
395    let corr = u64::from_le_bytes([
396        bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], bytes[16],
397    ]);
398
399    Ok(EnvelopeHeader {
400        len,
401        ver,
402        ty,
403        flags,
404        channel,
405        corr,
406    })
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn hdr(len: u32, ty: FrameType, flags: Flags, channel: u16, corr: u64) -> EnvelopeHeader {
414        EnvelopeHeader {
415            len,
416            ver: PROTOCOL_VERSION,
417            ty,
418            flags,
419            channel,
420            corr,
421        }
422    }
423
424    #[test]
425    fn bind_identity_round_trips_json() {
426        let identity = BindIdentity {
427            project_root: PathBuf::from("/tmp/project"),
428            harness: "opencode".to_string(),
429            session: "session-1".to_string(),
430        };
431
432        let encoded = serde_json::to_vec(&identity).unwrap();
433        let decoded: BindIdentity = serde_json::from_slice(&encoded).unwrap();
434
435        assert_eq!(decoded, identity);
436    }
437
438    #[test]
439    fn route_target_variants_round_trip_json() {
440        let targets = [
441            RouteTarget::ToolProvider {
442                module_id: "aft".to_string(),
443            },
444            RouteTarget::ManagementSurface {
445                module_id: "memory".to_string(),
446            },
447            RouteTarget::InternalService {
448                module_id: "bus".to_string(),
449                service_id: "dm".to_string(),
450            },
451        ];
452
453        for target in targets {
454            let encoded = serde_json::to_vec(&target).unwrap();
455            let decoded: RouteTarget = serde_json::from_slice(&encoded).unwrap();
456            assert_eq!(decoded, target);
457        }
458    }
459
460    #[test]
461    fn error_body_round_trips_json() {
462        let body = ErrorBody {
463            code: "config_divergence".to_string(),
464            message: "active config differs".to_string(),
465        };
466
467        let encoded = serde_json::to_vec(&body).unwrap();
468        let decoded: ErrorBody = serde_json::from_slice(&encoded).unwrap();
469
470        assert_eq!(decoded, body);
471    }
472
473    #[test]
474    fn round_trip_request() {
475        let h = hdr(
476            1234,
477            FrameType::Request,
478            Flags::new(false, Priority::Interactive, false),
479            42,
480            0xDEAD_BEEF_0000_0001,
481        );
482        let decoded = decode_header(&h.encode()).unwrap();
483        assert_eq!(h, decoded);
484    }
485
486    #[test]
487    fn round_trip_all_frame_types() {
488        for b in 0u8..=11 {
489            let ty = FrameType::from_u8(b).unwrap();
490            let h = hdr(0, ty, Flags::new(false, Priority::Passive, false), 0, 0);
491            assert_eq!(decode_header(&h.encode()).unwrap().ty, ty);
492        }
493    }
494
495    #[test]
496    fn pure_header_frame_has_zero_len() {
497        // CANCEL carries only header (len = 0) + the target corr.
498        let h = hdr(
499            0,
500            FrameType::Cancel,
501            Flags::new(false, Priority::Passive, false),
502            7,
503            99,
504        );
505        let d = decode_header(&h.encode()).unwrap();
506        assert_eq!(d.len, 0);
507        assert_eq!(d.corr, 99);
508    }
509
510    #[test]
511    fn flags_round_trip() {
512        let f = Flags::new(true, Priority::Background, true);
513        assert!(f.is_binary());
514        assert!(f.is_last());
515        assert_eq!(f.priority(), Some(Priority::Background));
516        let h = hdr(8, FrameType::StreamData, f, 1, 1);
517        assert_eq!(decode_header(&h.encode()).unwrap().flags, f);
518    }
519
520    #[test]
521    fn little_endian_and_frozen_prefix_layout() {
522        // len = 1 occupies byte 0; ver sits at byte 4 (the frozen prefix).
523        let h = hdr(1, FrameType::Request, Flags(0), 0, 0);
524        let buf = h.encode();
525        assert_eq!(buf[0], 1);
526        assert_eq!(buf[1..4], [0, 0, 0]);
527        assert_eq!(buf[4], PROTOCOL_VERSION); // ver @ offset 4
528        assert_eq!(buf.len(), HEADER_LEN);
529    }
530
531    #[test]
532    fn reject_too_short_for_prefix() {
533        assert_eq!(
534            decode_header(&[0, 0, 0, 0]),
535            Err(DecodeError::TooShortForPrefix { have: 4 })
536        );
537    }
538
539    #[test]
540    fn reject_too_short_for_header() {
541        // Valid 5-byte prefix (ver = 1) but header truncated.
542        let mut b = [0u8; 10];
543        b[4] = PROTOCOL_VERSION;
544        assert_eq!(
545            decode_header(&b),
546            Err(DecodeError::TooShortForHeader { have: 10, need: 17 })
547        );
548    }
549
550    #[test]
551    fn reject_unsupported_version() {
552        let mut b = [0u8; HEADER_LEN];
553        b[4] = 2; // no header layout known for ver 2
554        assert_eq!(
555            decode_header(&b),
556            Err(DecodeError::UnsupportedVersion { ver: 2 })
557        );
558    }
559
560    #[test]
561    fn reject_unknown_frame_type() {
562        let mut b = [0u8; HEADER_LEN];
563        b[4] = PROTOCOL_VERSION;
564        b[5] = 99;
565        assert_eq!(
566            decode_header(&b),
567            Err(DecodeError::UnknownFrameType { byte: 99 })
568        );
569    }
570
571    #[test]
572    fn reject_reserved_flag_bits() {
573        let mut b = [0u8; HEADER_LEN];
574        b[4] = PROTOCOL_VERSION;
575        b[5] = FrameType::Request as u8;
576        b[6] = 0b1000_0000; // reserved bit 7 set
577        assert_eq!(
578            decode_header(&b),
579            Err(DecodeError::ReservedFlagBits { flags: 0b1000_0000 })
580        );
581    }
582
583    #[test]
584    fn reject_reserved_priority_bits() {
585        let mut b = [0u8; HEADER_LEN];
586        b[4] = PROTOCOL_VERSION;
587        b[5] = FrameType::Request as u8;
588        b[6] = 0b0000_0110; // priority bits 1-2 are reserved value 0b11
589        assert_eq!(
590            decode_header(&b),
591            Err(DecodeError::ReservedPriorityBits { flags: 0b0000_0110 })
592        );
593    }
594
595    #[test]
596    fn reject_pure_header_frame_with_body_len() {
597        let h = hdr(
598            1,
599            FrameType::Ping,
600            Flags::new(false, Priority::Passive, false),
601            0,
602            1,
603        );
604        assert_eq!(
605            decode_header(&h.encode()),
606            Err(DecodeError::PureHeaderFrameWithBody {
607                ty: FrameType::Ping,
608                len: 1
609            })
610        );
611    }
612}