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