1use serde::{Deserialize, Serialize};
16
17pub const MIN_RUNTIME_API_VERSION: u32 = 1;
19pub const RUNTIME_API_VERSION: u32 = 2;
21pub const MODEL_PACK_FORMAT_VERSION: u32 = 1;
23pub const HANDLER_PACKAGE_FORMAT_VERSION: u32 = 1;
25pub const HANDLER_API_VERSION: u32 = 1;
27pub const RUNTIME_STATE_FORMAT_VERSION: u32 = 1;
29pub const RELEASE_INDEX_SCHEMA_VERSION: u32 = 2;
31pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
33
34pub const RUNTIME_ARTIFACT_ID: &str = "rill-runtime";
35
36#[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#[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#[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 pub signature: String,
290}
291
292#[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#[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#[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 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.target_os = Some("linux".into());
562 assert!(handler.validate_shape().is_err());
563 handler.target_os = None;
564
565 handler.handler_api_version = None;
567 assert!(handler.validate_shape().is_err());
568 handler.handler_api_version = Some(HANDLER_API_VERSION);
569
570 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}