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 if capabilities.windows(2).any(|w| w[0] == w[1]) {
84 return Err("duplicate capability");
85 }
86 Ok(())
87 }
88}
89
90#[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#[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 pub signature: String,
286}
287
288#[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#[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#[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 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.target_os = Some("linux".into());
558 assert!(handler.validate_shape().is_err());
559 handler.target_os = None;
560
561 handler.handler_api_version = None;
563 assert!(handler.validate_shape().is_err());
564 handler.handler_api_version = Some(HANDLER_API_VERSION);
565
566 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}