Skip to main content

rill_runtime_protocol/
lib.rs

1//! Stable, versioned contracts shared by Rill Runtime and its hosts.
2//!
3//! ## IPC API versions
4//!
5//! | Version | Introduced in | Changes |
6//! |---|---|---|
7//! | 1 | 0.5.0 | Original handshake, health, invoke |
8//! | 2 | 0.7.0 | Handshake response gains handler identity and effective capabilities |
9//!
10//! The runtime accepts both v1 and v2 requests. v1 clients receive
11//! [`RuntimeResponse`] (no handler fields). v2 clients receive
12//! [`RuntimeResponseV2`] (with handler identity). The two wire schemas are
13//! independently frozen with fixture tests.
14
15use serde::{Deserialize, Serialize};
16
17/// Minimum IPC API version the runtime still accepts.
18pub const MIN_RUNTIME_API_VERSION: u32 = 1;
19/// Latest IPC API version supported by this crate.
20pub const RUNTIME_API_VERSION: u32 = 2;
21/// Signed model-pack container version.
22pub const MODEL_PACK_FORMAT_VERSION: u32 = 1;
23/// Signed handler-pack container version.
24pub const HANDLER_PACKAGE_FORMAT_VERSION: u32 = 1;
25/// Handler ABI version (independent of IPC API version).
26pub const HANDLER_API_VERSION: u32 = 1;
27/// Persisted host/runtime state envelope version.
28pub const RUNTIME_STATE_FORMAT_VERSION: u32 = 1;
29/// Signed release-index schema understood by independent updaters.
30pub const RELEASE_INDEX_SCHEMA_VERSION: u32 = 2;
31/// Hard upper bound for one newline-delimited IPC message.
32pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
33
34pub const RUNTIME_ARTIFACT_ID: &str = "rill-runtime";
35
36// ---------------------------------------------------------------------------
37// Model pack manifest
38// ---------------------------------------------------------------------------
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct ModelPackManifest {
43    pub format_version: u32,
44    pub id: String,
45    pub version: String,
46    pub runtime_api_version: u32,
47    pub min_runtime_version: String,
48    pub publisher_key_id: String,
49    pub capabilities: Vec<String>,
50}
51
52impl ModelPackManifest {
53    pub fn validate_shape(&self) -> Result<(), &'static str> {
54        if self.format_version != MODEL_PACK_FORMAT_VERSION {
55            return Err("unsupported model-pack format version");
56        }
57        if self.runtime_api_version != RUNTIME_API_VERSION {
58            return Err("unsupported runtime API version");
59        }
60        if self.id.is_empty() || self.id.len() > 96 {
61            return Err("invalid model-pack id");
62        }
63        if self.version.is_empty() || self.version.len() > 48 {
64            return Err("invalid model-pack version");
65        }
66        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
67            return Err("invalid publisher key id");
68        }
69        Self::validate_capabilities(&self.capabilities)?;
70        Ok(())
71    }
72
73    pub fn validate_capabilities(capabilities: &[String]) -> Result<(), &'static str> {
74        if capabilities.is_empty() || capabilities.len() > 32 {
75            return Err("invalid capabilities list");
76        }
77        if capabilities
78            .iter()
79            .any(|capability| capability.is_empty() || capability.len() > 96)
80        {
81            return Err("invalid capability string");
82        }
83        if capabilities.windows(2).any(|w| w[0] == w[1]) {
84            return Err("duplicate capability");
85        }
86        Ok(())
87    }
88}
89
90// ---------------------------------------------------------------------------
91// Handler pack manifest
92// ---------------------------------------------------------------------------
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct HandlerPackManifest {
97    pub format_version: u32,
98    pub id: String,
99    pub version: String,
100    pub handler_api_version: u32,
101    pub min_runtime_version: String,
102    pub publisher_key_id: String,
103    pub capabilities: Vec<String>,
104    pub module_sha256: String,
105    pub module_size: u64,
106}
107
108impl HandlerPackManifest {
109    pub fn validate_shape(&self) -> Result<(), &'static str> {
110        if self.format_version != HANDLER_PACKAGE_FORMAT_VERSION {
111            return Err("unsupported handler-pack format version");
112        }
113        if self.handler_api_version != HANDLER_API_VERSION {
114            return Err("unsupported handler API version");
115        }
116        if self.id.is_empty() || self.id.len() > 96 {
117            return Err("invalid handler id");
118        }
119        if self.version.is_empty() || self.version.len() > 48 {
120            return Err("invalid handler version");
121        }
122        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
123            return Err("invalid handler publisher key id");
124        }
125        if self.min_runtime_version.is_empty() || self.min_runtime_version.len() > 48 {
126            return Err("invalid minimum runtime version");
127        }
128        ModelPackManifest::validate_capabilities(&self.capabilities)?;
129        if self.module_sha256.len() != 64
130            || !self
131                .module_sha256
132                .bytes()
133                .all(|byte| byte.is_ascii_hexdigit())
134        {
135            return Err("invalid module SHA-256");
136        }
137        if self.module_size == 0 || self.module_size > 4 * 1024 * 1024 {
138            return Err("invalid module size");
139        }
140        Ok(())
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Release index
146// ---------------------------------------------------------------------------
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
149#[serde(rename_all = "camelCase")]
150pub enum ReleaseArtifactKind {
151    Runtime,
152    Model,
153    Handler,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157#[serde(rename_all = "camelCase", deny_unknown_fields)]
158pub struct ReleaseArtifact {
159    pub kind: ReleaseArtifactKind,
160    pub id: String,
161    pub version: String,
162    pub runtime_api_version: u32,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub target_os: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub target_arch: Option<String>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub handler_api_version: Option<u32>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub min_runtime_version: Option<String>,
171    pub url: String,
172    pub sha256: String,
173    pub size: u64,
174}
175
176impl ReleaseArtifact {
177    pub fn validate_shape(&self) -> Result<(), &'static str> {
178        if self.id.is_empty() || self.id.len() > 96 {
179            return Err("invalid artifact id");
180        }
181        if self.version.is_empty() || self.version.len() > 48 {
182            return Err("invalid artifact version");
183        }
184        if self.url.is_empty() || self.url.len() > 2048 {
185            return Err("invalid artifact URL");
186        }
187        if self.sha256.len() != 64 || !self.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
188            return Err("invalid artifact SHA-256");
189        }
190        if self.size == 0 || self.size > 128 * 1024 * 1024 {
191            return Err("invalid artifact size");
192        }
193        match self.kind {
194            ReleaseArtifactKind::Runtime => {
195                if self.runtime_api_version != RUNTIME_API_VERSION {
196                    return Err("unsupported artifact runtime API version");
197                }
198                if self.id != RUNTIME_ARTIFACT_ID
199                    || self.target_os.as_deref().is_none_or(str::is_empty)
200                    || self.target_arch.as_deref().is_none_or(str::is_empty)
201                {
202                    return Err("runtime artifact requires a target OS and architecture");
203                }
204                if self.handler_api_version.is_some() || self.min_runtime_version.is_some() {
205                    return Err("runtime artifact must not carry handler fields");
206                }
207            }
208            ReleaseArtifactKind::Model => {
209                if self.runtime_api_version != RUNTIME_API_VERSION {
210                    return Err("unsupported artifact runtime API version");
211                }
212                if self.target_os.is_some()
213                    || self.target_arch.is_some()
214                    || self.handler_api_version.is_some()
215                    || self.min_runtime_version.is_some()
216                {
217                    return Err("model artifact must be platform independent");
218                }
219            }
220            ReleaseArtifactKind::Handler => {
221                if self.runtime_api_version != RUNTIME_API_VERSION {
222                    return Err("unsupported artifact runtime API version");
223                }
224                if self.target_os.is_some() || self.target_arch.is_some() {
225                    return Err("handler artifact must be platform independent");
226                }
227                let handler_api = self
228                    .handler_api_version
229                    .ok_or("handler artifact requires handler API version")?;
230                if handler_api != HANDLER_API_VERSION {
231                    return Err("unsupported handler API version");
232                }
233                let min_runtime = self
234                    .min_runtime_version
235                    .as_deref()
236                    .ok_or("handler artifact requires minimum runtime version")?;
237                if min_runtime.is_empty() || min_runtime.len() > 48 {
238                    return Err("invalid minimum runtime version");
239                }
240            }
241        }
242        Ok(())
243    }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
247#[serde(rename_all = "camelCase", deny_unknown_fields)]
248pub struct ReleaseIndexPayload {
249    pub schema_version: u32,
250    pub channel: String,
251    pub generated_at: String,
252    pub publisher_key_id: String,
253    pub artifacts: Vec<ReleaseArtifact>,
254}
255
256impl ReleaseIndexPayload {
257    pub fn validate_shape(&self) -> Result<(), &'static str> {
258        if self.schema_version != RELEASE_INDEX_SCHEMA_VERSION {
259            return Err("unsupported release-index schema");
260        }
261        if self.channel != "stable" {
262            return Err("unsupported release channel");
263        }
264        if self.generated_at.is_empty() || self.generated_at.len() > 64 {
265            return Err("invalid release-index timestamp");
266        }
267        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
268            return Err("invalid release-index publisher");
269        }
270        if self.artifacts.is_empty() || self.artifacts.len() > 64 {
271            return Err("invalid release-index artifact count");
272        }
273        for artifact in &self.artifacts {
274            artifact.validate_shape()?;
275        }
276        Ok(())
277    }
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(rename_all = "camelCase", deny_unknown_fields)]
282pub struct SignedReleaseIndex {
283    pub payload: ReleaseIndexPayload,
284    /// Lowercase hexadecimal Ed25519 signature over canonical payload JSON.
285    pub signature: String,
286}
287
288// ---------------------------------------------------------------------------
289// IPC requests (shared by v1 and v2)
290// ---------------------------------------------------------------------------
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
293#[serde(
294    tag = "method",
295    rename_all = "camelCase",
296    rename_all_fields = "camelCase",
297    deny_unknown_fields
298)]
299pub enum RuntimeRequest {
300    Handshake {
301        request_id: String,
302        api_version: u32,
303        client_name: String,
304        client_version: String,
305    },
306    Health {
307        request_id: String,
308        api_version: u32,
309    },
310    Invoke {
311        request_id: String,
312        api_version: u32,
313        capability: String,
314        input: serde_json::Value,
315    },
316}
317
318impl RuntimeRequest {
319    pub fn request_id(&self) -> &str {
320        match self {
321            Self::Handshake { request_id, .. }
322            | Self::Health { request_id, .. }
323            | Self::Invoke { request_id, .. } => request_id,
324        }
325    }
326
327    pub fn api_version(&self) -> u32 {
328        match self {
329            Self::Handshake { api_version, .. }
330            | Self::Health { api_version, .. }
331            | Self::Invoke { api_version, .. } => *api_version,
332        }
333    }
334}
335
336// ---------------------------------------------------------------------------
337// IPC v1 responses (frozen since 0.5.0)
338// ---------------------------------------------------------------------------
339
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341#[serde(
342    tag = "kind",
343    rename_all = "camelCase",
344    rename_all_fields = "camelCase",
345    deny_unknown_fields
346)]
347pub enum RuntimeResponse {
348    Handshake {
349        request_id: String,
350        api_version: u32,
351        runtime_version: String,
352        model_pack_id: String,
353        model_pack_version: String,
354        capabilities: Vec<String>,
355    },
356    Health {
357        request_id: String,
358        api_version: u32,
359        healthy: bool,
360        model_pack_id: String,
361        model_pack_version: String,
362    },
363    Result {
364        request_id: String,
365        api_version: u32,
366        output: serde_json::Value,
367    },
368    Error {
369        request_id: String,
370        api_version: u32,
371        code: String,
372        message: String,
373        retryable: bool,
374    },
375}
376
377// ---------------------------------------------------------------------------
378// IPC v2 responses (introduced in 0.7.0)
379// ---------------------------------------------------------------------------
380
381#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
382#[serde(
383    tag = "kind",
384    rename_all = "camelCase",
385    rename_all_fields = "camelCase",
386    deny_unknown_fields
387)]
388pub enum RuntimeResponseV2 {
389    Handshake {
390        request_id: String,
391        api_version: u32,
392        runtime_version: String,
393        model_pack_id: String,
394        model_pack_version: String,
395        capabilities: Vec<String>,
396        handler_id: String,
397        handler_version: String,
398        handler_api_version: u32,
399        effective_capabilities: Vec<String>,
400    },
401    Health {
402        request_id: String,
403        api_version: u32,
404        healthy: bool,
405        model_pack_id: String,
406        model_pack_version: String,
407    },
408    Result {
409        request_id: String,
410        api_version: u32,
411        output: serde_json::Value,
412    },
413    Error {
414        request_id: String,
415        api_version: u32,
416        code: String,
417        message: String,
418        retryable: bool,
419    },
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn protocol_v1_roundtrip_is_tagged_and_strict() {
428        let request = RuntimeRequest::Health {
429            request_id: "health-1".into(),
430            api_version: 1,
431        };
432        let json = serde_json::to_string(&request).unwrap();
433        assert!(json.contains("\"method\":\"health\""));
434        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
435        assert_eq!(restored, request);
436        assert!(
437            serde_json::from_str::<RuntimeRequest>(
438                r#"{"method":"health","requestId":"x","apiVersion":1,"extra":true}"#
439            )
440            .is_err()
441        );
442    }
443
444    #[test]
445    fn v1_handshake_fixture_is_stable() {
446        let request = RuntimeRequest::Handshake {
447            request_id: "fixture".into(),
448            api_version: 1,
449            client_name: "example-host".into(),
450            client_version: "0.6.10".into(),
451        };
452        assert_eq!(
453            serde_json::to_string(&request).unwrap(),
454            r#"{"method":"handshake","requestId":"fixture","apiVersion":1,"clientName":"example-host","clientVersion":"0.6.10"}"#
455        );
456    }
457
458    #[test]
459    fn v1_handshake_response_fixture_is_stable() {
460        let response = RuntimeResponse::Handshake {
461            request_id: "fixture".into(),
462            api_version: 1,
463            runtime_version: "0.6.0".into(),
464            model_pack_id: "rillml.example.default".into(),
465            model_pack_version: "0.6.0".into(),
466            capabilities: vec!["rillml.example".into()],
467        };
468        assert_eq!(
469            serde_json::to_string(&response).unwrap(),
470            r#"{"kind":"handshake","requestId":"fixture","apiVersion":1,"runtimeVersion":"0.6.0","modelPackId":"rillml.example.default","modelPackVersion":"0.6.0","capabilities":["rillml.example"]}"#
471        );
472    }
473
474    #[test]
475    fn v2_handshake_response_fixture_is_stable() {
476        let response = RuntimeResponseV2::Handshake {
477            request_id: "v2-fixture".into(),
478            api_version: 2,
479            runtime_version: "0.7.0".into(),
480            model_pack_id: "rillml.example.default".into(),
481            model_pack_version: "0.7.0".into(),
482            capabilities: vec!["rillml.example".into()],
483            handler_id: "org.example.handler".into(),
484            handler_version: "1.0.0".into(),
485            handler_api_version: 1,
486            effective_capabilities: vec!["rillml.example".into()],
487        };
488        let json = serde_json::to_string(&response).unwrap();
489        assert!(json.contains("\"handlerId\":\"org.example.handler\""));
490        assert!(json.contains("\"handlerApiVersion\":1"));
491        assert!(json.contains("\"effectiveCapabilities\":[\"rillml.example\"]"));
492        // Mutating the response produces a different JSON, proving the fixture
493        // is fully serialised and not relying on default values.
494        let mut bad = serde_json::from_str::<RuntimeResponseV2>(&json).unwrap();
495        if let RuntimeResponseV2::Handshake { handler_id, .. } = &mut bad {
496            handler_id.push('x');
497        }
498        let bad_json = serde_json::to_string(&bad).unwrap();
499        assert_ne!(bad_json, json);
500    }
501
502    #[test]
503    fn v1_response_rejects_handler_fields() {
504        let json = r#"{"kind":"handshake","requestId":"x","apiVersion":1,"runtimeVersion":"0.7.0","modelPackId":"m","modelPackVersion":"1","capabilities":["c"],"handlerId":"h"}"#;
505        assert!(serde_json::from_str::<RuntimeResponse>(json).is_err());
506    }
507
508    #[test]
509    fn invoke_roundtrip_preserves_capability_and_input() {
510        let request = RuntimeRequest::Invoke {
511            request_id: "invoke-1".into(),
512            api_version: 2,
513            capability: "rillml.example".into(),
514            input: serde_json::json!({"samples": []}),
515        };
516        let json = serde_json::to_string(&request).unwrap();
517        assert!(json.contains("\"method\":\"invoke\""));
518        assert!(json.contains("\"capability\":\"rillml.example\""));
519        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
520        assert_eq!(restored, request);
521    }
522
523    #[test]
524    fn release_artifacts_enforce_platform_boundaries() {
525        let runtime = ReleaseArtifact {
526            kind: ReleaseArtifactKind::Runtime,
527            id: RUNTIME_ARTIFACT_ID.into(),
528            version: "0.7.0".into(),
529            runtime_api_version: RUNTIME_API_VERSION,
530            target_os: Some("macos".into()),
531            target_arch: Some("aarch64".into()),
532            handler_api_version: None,
533            min_runtime_version: None,
534            url: "https://example.invalid/rill-runtime".into(),
535            sha256: "ab".repeat(32),
536            size: 1024,
537        };
538        assert!(runtime.validate_shape().is_ok());
539
540        let mut model = runtime.clone();
541        model.kind = ReleaseArtifactKind::Model;
542        model.id = "rillml.example.default".into();
543        model.target_os = None;
544        model.target_arch = None;
545        assert!(model.validate_shape().is_ok());
546
547        let mut handler = runtime.clone();
548        handler.kind = ReleaseArtifactKind::Handler;
549        handler.id = "org.example.handler".into();
550        handler.target_os = None;
551        handler.target_arch = None;
552        handler.handler_api_version = Some(HANDLER_API_VERSION);
553        handler.min_runtime_version = Some("0.7.0".into());
554        assert!(handler.validate_shape().is_ok());
555
556        // Handler with platform fields is rejected.
557        handler.target_os = Some("linux".into());
558        assert!(handler.validate_shape().is_err());
559        handler.target_os = None;
560
561        // Handler without handler_api_version is rejected.
562        handler.handler_api_version = None;
563        assert!(handler.validate_shape().is_err());
564        handler.handler_api_version = Some(HANDLER_API_VERSION);
565
566        // Handler without min_runtime_version is rejected.
567        handler.min_runtime_version = None;
568        assert!(handler.validate_shape().is_err());
569    }
570
571    #[test]
572    fn handler_manifest_validates_shape() {
573        let manifest = HandlerPackManifest {
574            format_version: HANDLER_PACKAGE_FORMAT_VERSION,
575            id: "org.example.handler".into(),
576            version: "1.0.0".into(),
577            handler_api_version: HANDLER_API_VERSION,
578            min_runtime_version: "0.7.0".into(),
579            publisher_key_id: "test-key".into(),
580            capabilities: vec!["org.example.predict".into()],
581            module_sha256: "ab".repeat(32),
582            module_size: 1024,
583        };
584        assert!(manifest.validate_shape().is_ok());
585
586        let mut bad = manifest.clone();
587        bad.format_version = 99;
588        assert!(bad.validate_shape().is_err());
589
590        let mut bad = manifest.clone();
591        bad.handler_api_version = 99;
592        assert!(bad.validate_shape().is_err());
593
594        let mut bad = manifest.clone();
595        bad.capabilities = vec![];
596        assert!(bad.validate_shape().is_err());
597
598        let mut bad = manifest.clone();
599        bad.module_sha256 = "short".into();
600        assert!(bad.validate_shape().is_err());
601
602        let mut bad = manifest.clone();
603        bad.module_size = 0;
604        assert!(bad.validate_shape().is_err());
605    }
606}