Skip to main content

rill_runtime_protocol/
lib.rs

1//! Stable, versioned contracts shared by Rill Runtime and its hosts.
2
3use serde::{Deserialize, Serialize};
4
5/// IPC API version supported by this crate.
6pub const RUNTIME_API_VERSION: u32 = 1;
7/// Signed model-pack container version.
8pub const MODEL_PACK_FORMAT_VERSION: u32 = 1;
9/// Persisted host/runtime state envelope version.
10pub const RUNTIME_STATE_FORMAT_VERSION: u32 = 1;
11/// Signed release-index schema understood by independent updaters.
12pub const RELEASE_INDEX_SCHEMA_VERSION: u32 = 1;
13/// Hard upper bound for one newline-delimited IPC message.
14pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
15
16pub const RUNTIME_ARTIFACT_ID: &str = "rill-runtime";
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(rename_all = "camelCase", deny_unknown_fields)]
20pub struct ModelPackManifest {
21    pub format_version: u32,
22    pub id: String,
23    pub version: String,
24    pub runtime_api_version: u32,
25    pub min_runtime_version: String,
26    pub publisher_key_id: String,
27    pub capabilities: Vec<String>,
28}
29
30impl ModelPackManifest {
31    pub fn validate_shape(&self) -> Result<(), &'static str> {
32        if self.format_version != MODEL_PACK_FORMAT_VERSION {
33            return Err("unsupported model-pack format version");
34        }
35        if self.runtime_api_version != RUNTIME_API_VERSION {
36            return Err("unsupported runtime API version");
37        }
38        if self.id.is_empty() || self.id.len() > 96 {
39            return Err("invalid model-pack id");
40        }
41        if self.version.is_empty() || self.version.len() > 48 {
42            return Err("invalid model-pack version");
43        }
44        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
45            return Err("invalid publisher key id");
46        }
47        if self.capabilities.is_empty() || self.capabilities.len() > 32 {
48            return Err("invalid model-pack capabilities");
49        }
50        if self
51            .capabilities
52            .iter()
53            .any(|capability| capability.is_empty() || capability.len() > 96)
54        {
55            return Err("invalid model-pack capability");
56        }
57        Ok(())
58    }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
62#[serde(rename_all = "camelCase")]
63pub enum ReleaseArtifactKind {
64    Runtime,
65    Model,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct ReleaseArtifact {
71    pub kind: ReleaseArtifactKind,
72    pub id: String,
73    pub version: String,
74    pub runtime_api_version: u32,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub target_os: Option<String>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub target_arch: Option<String>,
79    pub url: String,
80    pub sha256: String,
81    pub size: u64,
82}
83
84impl ReleaseArtifact {
85    pub fn validate_shape(&self) -> Result<(), &'static str> {
86        if self.id.is_empty() || self.id.len() > 96 {
87            return Err("invalid artifact id");
88        }
89        if self.version.is_empty() || self.version.len() > 48 {
90            return Err("invalid artifact version");
91        }
92        if self.runtime_api_version != RUNTIME_API_VERSION {
93            return Err("unsupported artifact runtime API version");
94        }
95        if self.url.is_empty() || self.url.len() > 2048 {
96            return Err("invalid artifact URL");
97        }
98        if self.sha256.len() != 64 || !self.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
99            return Err("invalid artifact SHA-256");
100        }
101        if self.size == 0 || self.size > 64 * 1024 * 1024 {
102            return Err("invalid artifact size");
103        }
104        match self.kind {
105            ReleaseArtifactKind::Runtime => {
106                if self.id != RUNTIME_ARTIFACT_ID
107                    || self.target_os.as_deref().is_none_or(str::is_empty)
108                    || self.target_arch.as_deref().is_none_or(str::is_empty)
109                {
110                    return Err("runtime artifact requires a target OS and architecture");
111                }
112            }
113            ReleaseArtifactKind::Model => {
114                if self.target_os.is_some() || self.target_arch.is_some() {
115                    return Err("model artifact must be platform independent");
116                }
117            }
118        }
119        Ok(())
120    }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124#[serde(rename_all = "camelCase", deny_unknown_fields)]
125pub struct ReleaseIndexPayload {
126    pub schema_version: u32,
127    pub channel: String,
128    pub generated_at: String,
129    pub publisher_key_id: String,
130    pub artifacts: Vec<ReleaseArtifact>,
131}
132
133impl ReleaseIndexPayload {
134    pub fn validate_shape(&self) -> Result<(), &'static str> {
135        if self.schema_version != RELEASE_INDEX_SCHEMA_VERSION {
136            return Err("unsupported release-index schema");
137        }
138        if self.channel != "stable" {
139            return Err("unsupported release channel");
140        }
141        if self.generated_at.is_empty() || self.generated_at.len() > 64 {
142            return Err("invalid release-index timestamp");
143        }
144        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
145            return Err("invalid release-index publisher");
146        }
147        if self.artifacts.is_empty() || self.artifacts.len() > 64 {
148            return Err("invalid release-index artifact count");
149        }
150        for artifact in &self.artifacts {
151            artifact.validate_shape()?;
152        }
153        Ok(())
154    }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(rename_all = "camelCase", deny_unknown_fields)]
159pub struct SignedReleaseIndex {
160    pub payload: ReleaseIndexPayload,
161    /// Lowercase hexadecimal Ed25519 signature over canonical payload JSON.
162    pub signature: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166#[serde(
167    tag = "method",
168    rename_all = "camelCase",
169    rename_all_fields = "camelCase",
170    deny_unknown_fields
171)]
172pub enum RuntimeRequest {
173    Handshake {
174        request_id: String,
175        api_version: u32,
176        client_name: String,
177        client_version: String,
178    },
179    Health {
180        request_id: String,
181        api_version: u32,
182    },
183    Invoke {
184        request_id: String,
185        api_version: u32,
186        capability: String,
187        input: serde_json::Value,
188    },
189}
190
191impl RuntimeRequest {
192    pub fn request_id(&self) -> &str {
193        match self {
194            Self::Handshake { request_id, .. }
195            | Self::Health { request_id, .. }
196            | Self::Invoke { request_id, .. } => request_id,
197        }
198    }
199
200    pub fn api_version(&self) -> u32 {
201        match self {
202            Self::Handshake { api_version, .. }
203            | Self::Health { api_version, .. }
204            | Self::Invoke { api_version, .. } => *api_version,
205        }
206    }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
210#[serde(
211    tag = "kind",
212    rename_all = "camelCase",
213    rename_all_fields = "camelCase",
214    deny_unknown_fields
215)]
216pub enum RuntimeResponse {
217    Handshake {
218        request_id: String,
219        api_version: u32,
220        runtime_version: String,
221        model_pack_id: String,
222        model_pack_version: String,
223        capabilities: Vec<String>,
224    },
225    Health {
226        request_id: String,
227        api_version: u32,
228        healthy: bool,
229        model_pack_id: String,
230        model_pack_version: String,
231    },
232    Result {
233        request_id: String,
234        api_version: u32,
235        output: serde_json::Value,
236    },
237    Error {
238        request_id: String,
239        api_version: u32,
240        code: String,
241        message: String,
242        retryable: bool,
243    },
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn protocol_roundtrip_is_tagged_and_strict() {
252        let request = RuntimeRequest::Health {
253            request_id: "health-1".into(),
254            api_version: RUNTIME_API_VERSION,
255        };
256        let json = serde_json::to_string(&request).unwrap();
257        assert!(json.contains("\"method\":\"health\""));
258        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
259        assert_eq!(restored, request);
260        assert!(
261            serde_json::from_str::<RuntimeRequest>(
262                r#"{"method":"health","requestId":"x","apiVersion":1,"extra":true}"#
263            )
264            .is_err()
265        );
266    }
267
268    #[test]
269    fn handshake_fixture_is_stable() {
270        let request = RuntimeRequest::Handshake {
271            request_id: "fixture".into(),
272            api_version: RUNTIME_API_VERSION,
273            client_name: "example-host".into(),
274            client_version: "0.6.10".into(),
275        };
276        assert_eq!(
277            serde_json::to_string(&request).unwrap(),
278            r#"{"method":"handshake","requestId":"fixture","apiVersion":1,"clientName":"example-host","clientVersion":"0.6.10"}"#
279        );
280    }
281
282    #[test]
283    fn invoke_roundtrip_preserves_capability_and_input() {
284        let request = RuntimeRequest::Invoke {
285            request_id: "invoke-1".into(),
286            api_version: RUNTIME_API_VERSION,
287            capability: "rillml.example".into(),
288            input: serde_json::json!({"samples": []}),
289        };
290        let json = serde_json::to_string(&request).unwrap();
291        assert!(json.contains("\"method\":\"invoke\""));
292        assert!(json.contains("\"capability\":\"rillml.example\""));
293        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
294        assert_eq!(restored, request);
295    }
296
297    #[test]
298    fn release_artifacts_enforce_platform_boundaries() {
299        let runtime = ReleaseArtifact {
300            kind: ReleaseArtifactKind::Runtime,
301            id: RUNTIME_ARTIFACT_ID.into(),
302            version: "0.5.0".into(),
303            runtime_api_version: RUNTIME_API_VERSION,
304            target_os: Some("macos".into()),
305            target_arch: Some("aarch64".into()),
306            url: "https://example.invalid/rill-runtime".into(),
307            sha256: "ab".repeat(32),
308            size: 1024,
309        };
310        assert!(runtime.validate_shape().is_ok());
311        let mut model = runtime;
312        model.kind = ReleaseArtifactKind::Model;
313        model.id = "rillml.example.default".into();
314        assert!(model.validate_shape().is_err());
315        model.target_os = None;
316        model.target_arch = None;
317        assert!(model.validate_shape().is_ok());
318    }
319}