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/// Preview IPC v3 types. This module is independent from the frozen v1/v2
18/// request and response types below.
19pub mod v3;
20
21/// Minimum IPC API version the runtime still accepts.
22pub const MIN_RUNTIME_API_VERSION: u32 = 1;
23/// Latest IPC API version supported by this crate.
24pub const RUNTIME_API_VERSION: u32 = 2;
25/// Signed model-pack container version.
26pub const MODEL_PACK_FORMAT_VERSION: u32 = 1;
27/// Signed handler-pack container version.
28pub const HANDLER_PACKAGE_FORMAT_VERSION: u32 = 1;
29/// Handler ABI version (independent of IPC API version).
30pub const HANDLER_API_VERSION: u32 = 1;
31/// Persisted host/runtime state envelope version.
32pub const RUNTIME_STATE_FORMAT_VERSION: u32 = 1;
33/// Signed release-index schema understood by independent updaters.
34///
35/// v3 is the current frozen stable schema. It adds an explicit ``target_libc``
36/// field (``gnu``/``musl``) to Linux runtime artifacts so libc variants of the
37/// same OS+arch are disambiguated deterministically. v3 is a versioned schema:
38/// a v1.1.0 reader (whose validator requires ``RELEASE_INDEX_SCHEMA_VERSION
39/// == 2``) rejects it at the schema boundary (fail-closed) rather than
40/// naive-matching gnu and musl builds to the same OS+arch and failing
41/// ambiguously.
42pub const RELEASE_INDEX_SCHEMA_VERSION: u32 = 3;
43/// Hard upper bound for one newline-delimited IPC message.
44pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
45
46/// Stable artifact id for the GNU (default) runtime build.
47pub const RUNTIME_ARTIFACT_ID: &str = "rill-runtime";
48/// Stable artifact id for the musl runtime build. The libc variant is part of
49/// the stable asset identity so gnu and musl builds of the same OS+arch do not
50/// collide in a v2 release index.
51pub const RUNTIME_ARTIFACT_ID_MUSL: &str = "rill-runtime-musl";
52/// Stable artifact id for the OpenWrt Performance Manager decision
53/// adapter. The adapter is a distinct artifact kind (``pm-adapter``)
54/// because it speaks the independent ``pm-rill-shadow`` v1 protocol, not
55/// the Rill Runtime IPC API.
56pub const PM_ADAPTER_ARTIFACT_ID: &str = "rill-pm-adapter";
57/// ``pm-rill-shadow`` protocol version advertised by released adapter
58/// binaries and required by ``pm-adapter`` index entries.
59pub const PM_ADAPTER_PROTOCOL_VERSION: u32 = 1;
60
61// ---------------------------------------------------------------------------
62// Stable IPC error codes
63// ---------------------------------------------------------------------------
64
65/// Stable IPC error code constants.
66///
67/// Every `RuntimeResponse::Error` / `RuntimeResponseV2::Error` `code` field
68/// produced by the runtime is one of the constants in this module. The codes
69/// are frozen for the entire 1.x cycle: existing codes are never renamed, and
70/// new codes may only be added (additive).
71///
72/// The runtime constructs error responses exclusively from these constants.
73/// Hosts and clients may switch on the string values; the constants are
74/// exported so that downstream Rust code does not have to inline string
75/// literals.
76pub mod error_code {
77    /// Request body was not valid protocol JSON.
78    pub const INVALID_JSON: &str = "invalidJson";
79    /// `requestId` was missing, empty, or longer than 128 characters.
80    pub const INVALID_REQUEST_ID: &str = "invalidRequestId";
81    /// `apiVersion` was outside `[MIN_RUNTIME_API_VERSION, RUNTIME_API_VERSION]`.
82    pub const INCOMPATIBLE_API_VERSION: &str = "incompatibleApiVersion";
83    /// `clientName` / `clientVersion` failed length or emptiness checks.
84    pub const INVALID_CLIENT_IDENTITY: &str = "invalidClientIdentity";
85    /// `Invoke` capability is not in the effective capability set.
86    pub const UNSUPPORTED_CAPABILITY: &str = "unsupportedCapability";
87    /// `Invoke` was issued but no handler is registered.
88    pub const NO_INVOKE_HANDLER: &str = "noInvokeHandler";
89    /// Handler exceeded the wall-clock deadline. Retryable.
90    pub const HANDLER_TIMEOUT: &str = "handlerTimeout";
91    /// Handler trapped (unreachable, out-of-bounds, stack overflow, …).
92    pub const HANDLER_TRAP: &str = "handlerTrap";
93    /// Handler output exceeded the host-side size limit.
94    pub const HANDLER_OUTPUT_TOO_LARGE: &str = "handlerOutputTooLarge";
95    /// Handler output was not valid JSON.
96    pub const HANDLER_INVALID_OUTPUT: &str = "handlerInvalidOutput";
97    /// Handler reported an internal error (covers all four WIT
98    /// `handler-error` variants on the wire for backwards compatibility).
99    pub const HANDLER_INTERNAL_ERROR: &str = "handlerInternalError";
100
101    /// All frozen error codes in alphabetical order.
102    ///
103    /// This slice is used by tests and by the runtime's error-code allowlist
104    /// check. Adding a new code requires appending to this slice; the order
105    /// is part of the frozen surface so test fixtures remain stable.
106    pub const FROZEN_CODES: &[&str] = &[
107        HANDLER_INTERNAL_ERROR,
108        HANDLER_INVALID_OUTPUT,
109        HANDLER_OUTPUT_TOO_LARGE,
110        HANDLER_TIMEOUT,
111        HANDLER_TRAP,
112        INCOMPATIBLE_API_VERSION,
113        INVALID_CLIENT_IDENTITY,
114        INVALID_JSON,
115        INVALID_REQUEST_ID,
116        NO_INVOKE_HANDLER,
117        UNSUPPORTED_CAPABILITY,
118    ];
119
120    /// Returns `true` if `code` is one of the frozen 1.x error codes.
121    pub fn is_frozen(code: &str) -> bool {
122        FROZEN_CODES.contains(&code)
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Model pack manifest
128// ---------------------------------------------------------------------------
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
131#[serde(rename_all = "camelCase", deny_unknown_fields)]
132pub struct ModelPackManifest {
133    pub format_version: u32,
134    pub id: String,
135    pub version: String,
136    pub runtime_api_version: u32,
137    pub min_runtime_version: String,
138    pub publisher_key_id: String,
139    pub capabilities: Vec<String>,
140}
141
142impl ModelPackManifest {
143    pub fn validate_shape(&self) -> Result<(), &'static str> {
144        if self.format_version != MODEL_PACK_FORMAT_VERSION {
145            return Err("unsupported model-pack format version");
146        }
147        if self.runtime_api_version != RUNTIME_API_VERSION {
148            return Err("unsupported runtime API version");
149        }
150        if self.id.is_empty() || self.id.len() > 96 {
151            return Err("invalid model-pack id");
152        }
153        if self.version.is_empty() || self.version.len() > 48 {
154            return Err("invalid model-pack version");
155        }
156        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
157            return Err("invalid publisher key id");
158        }
159        Self::validate_capabilities(&self.capabilities)?;
160        Ok(())
161    }
162
163    pub fn validate_capabilities(capabilities: &[String]) -> Result<(), &'static str> {
164        if capabilities.is_empty() || capabilities.len() > 32 {
165            return Err("invalid capabilities list");
166        }
167        if capabilities
168            .iter()
169            .any(|capability| capability.is_empty() || capability.len() > 96)
170        {
171            return Err("invalid capability string");
172        }
173        let mut seen = std::collections::HashSet::new();
174        if !capabilities
175            .iter()
176            .all(|capability| seen.insert(capability.clone()))
177        {
178            return Err("duplicate capability");
179        }
180        Ok(())
181    }
182}
183
184// ---------------------------------------------------------------------------
185// Handler pack manifest
186// ---------------------------------------------------------------------------
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
189#[serde(rename_all = "camelCase", deny_unknown_fields)]
190pub struct HandlerPackManifest {
191    pub format_version: u32,
192    pub id: String,
193    pub version: String,
194    pub handler_api_version: u32,
195    pub min_runtime_version: String,
196    pub publisher_key_id: String,
197    pub capabilities: Vec<String>,
198    pub module_sha256: String,
199    pub module_size: u64,
200}
201
202impl HandlerPackManifest {
203    pub fn validate_shape(&self) -> Result<(), &'static str> {
204        if self.format_version != HANDLER_PACKAGE_FORMAT_VERSION {
205            return Err("unsupported handler-pack format version");
206        }
207        if self.handler_api_version != HANDLER_API_VERSION {
208            return Err("unsupported handler API version");
209        }
210        if self.id.is_empty() || self.id.len() > 96 {
211            return Err("invalid handler id");
212        }
213        if self.version.is_empty() || self.version.len() > 48 {
214            return Err("invalid handler version");
215        }
216        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
217            return Err("invalid handler publisher key id");
218        }
219        if self.min_runtime_version.is_empty() || self.min_runtime_version.len() > 48 {
220            return Err("invalid minimum runtime version");
221        }
222        ModelPackManifest::validate_capabilities(&self.capabilities)?;
223        if self.module_sha256.len() != 64
224            || !self
225                .module_sha256
226                .bytes()
227                .all(|byte| byte.is_ascii_hexdigit())
228        {
229            return Err("invalid module SHA-256");
230        }
231        if self.module_size == 0 || self.module_size > 4 * 1024 * 1024 {
232            return Err("invalid module size");
233        }
234        Ok(())
235    }
236}
237
238// ---------------------------------------------------------------------------
239// Release index
240// ---------------------------------------------------------------------------
241
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
243#[serde(rename_all = "camelCase")]
244pub enum ReleaseArtifactKind {
245    Runtime,
246    Model,
247    Handler,
248    #[serde(rename = "pm-adapter")]
249    PmAdapter,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253#[serde(rename_all = "camelCase", deny_unknown_fields)]
254pub struct ReleaseArtifact {
255    pub kind: ReleaseArtifactKind,
256    pub id: String,
257    pub version: String,
258    #[serde(default)]
259    pub runtime_api_version: u32,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub target_os: Option<String>,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub target_arch: Option<String>,
264    /// The libc/ABI variant (``gnu`` or ``musl``) of a Linux target. Present
265    /// only on Linux runtime/adapter artifacts; non-Linux targets (macOS,
266    /// Windows, FreeBSD) omit it. Introduced in release-index schema v3 so
267    /// gnu and musl builds of the same OS+arch coexist unambiguously.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub target_libc: Option<String>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub handler_api_version: Option<u32>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub min_runtime_version: Option<String>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub pm_adapter_protocol_version: Option<u32>,
276    pub url: String,
277    pub sha256: String,
278    pub size: u64,
279}
280
281impl ReleaseArtifact {
282    pub fn validate_shape(&self) -> Result<(), &'static str> {
283        if self.id.is_empty() || self.id.len() > 96 {
284            return Err("invalid artifact id");
285        }
286        if self.version.is_empty() || self.version.len() > 48 {
287            return Err("invalid artifact version");
288        }
289        if self.url.is_empty() || self.url.len() > 2048 {
290            return Err("invalid artifact URL");
291        }
292        if self.sha256.len() != 64 || !self.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
293            return Err("invalid artifact SHA-256");
294        }
295        if self.size == 0 || self.size > 128 * 1024 * 1024 {
296            return Err("invalid artifact size");
297        }
298        match self.kind {
299            ReleaseArtifactKind::Runtime => {
300                if self.runtime_api_version != RUNTIME_API_VERSION {
301                    return Err("unsupported artifact runtime API version");
302                }
303                // The artifact ``id`` is part of the stable asset identity. On
304                // Linux, the libc/ABI variant (gnu vs musl) is encoded in the
305                // ``id`` and recorded explicitly in ``target_libc`` so both
306                // builds of the same OS+arch coexist unambiguously in a v3
307                // index.
308                if (self.id != RUNTIME_ARTIFACT_ID && self.id != RUNTIME_ARTIFACT_ID_MUSL)
309                    || self.target_os.as_deref().is_none_or(str::is_empty)
310                    || self.target_arch.as_deref().is_none_or(str::is_empty)
311                {
312                    return Err("runtime artifact requires a target OS and architecture");
313                }
314                // On Linux the libc variant must be explicit (gnu or musl).
315                // Non-Linux targets must not carry a libc variant.
316                match self.target_os.as_deref() {
317                    Some("linux") => {
318                        let libc = self.target_libc.as_deref();
319                        let expected = if self.id == RUNTIME_ARTIFACT_ID_MUSL {
320                            Some("musl")
321                        } else {
322                            Some("gnu")
323                        };
324                        if libc != expected {
325                            return Err("runtime artifact libc variant does not match its id");
326                        }
327                    }
328                    _ => {
329                        if self.target_libc.is_some() {
330                            return Err("non-Linux runtime artifact must not carry a libc variant");
331                        }
332                    }
333                }
334                if self.handler_api_version.is_some() || self.min_runtime_version.is_some() {
335                    return Err("runtime artifact must not carry handler fields");
336                }
337            }
338            ReleaseArtifactKind::Model => {
339                if self.runtime_api_version != RUNTIME_API_VERSION {
340                    return Err("unsupported artifact runtime API version");
341                }
342                if self.target_os.is_some()
343                    || self.target_arch.is_some()
344                    || self.handler_api_version.is_some()
345                    || self.min_runtime_version.is_some()
346                {
347                    return Err("model artifact must be platform independent");
348                }
349            }
350            ReleaseArtifactKind::Handler => {
351                if self.runtime_api_version != RUNTIME_API_VERSION {
352                    return Err("unsupported artifact runtime API version");
353                }
354                if self.target_os.is_some() || self.target_arch.is_some() {
355                    return Err("handler artifact must be platform independent");
356                }
357                let handler_api = self
358                    .handler_api_version
359                    .ok_or("handler artifact requires handler API version")?;
360                if handler_api != HANDLER_API_VERSION {
361                    return Err("unsupported handler API version");
362                }
363                let min_runtime = self
364                    .min_runtime_version
365                    .as_deref()
366                    .ok_or("handler artifact requires minimum runtime version")?;
367                if min_runtime.is_empty() || min_runtime.len() > 48 {
368                    return Err("invalid minimum runtime version");
369                }
370            }
371            ReleaseArtifactKind::PmAdapter => {
372                // The PM adapter speaks the independent ``pm-rill-shadow``
373                // protocol, not the Rill Runtime IPC API, so
374                // ``runtimeApiVersion`` is not applicable and must remain
375                // unset (serde default 0).
376                if self.runtime_api_version != 0 {
377                    return Err("pm-adapter artifact must not set runtime API version");
378                }
379                if self.id != PM_ADAPTER_ARTIFACT_ID
380                    || self.target_os.as_deref().is_none_or(str::is_empty)
381                    || self.target_arch.as_deref().is_none_or(str::is_empty)
382                {
383                    return Err("pm-adapter artifact requires a target OS and architecture");
384                }
385                if self.handler_api_version.is_some() || self.min_runtime_version.is_some() {
386                    return Err("pm-adapter artifact must not carry handler fields");
387                }
388                if self.pm_adapter_protocol_version != Some(PM_ADAPTER_PROTOCOL_VERSION) {
389                    return Err("unsupported pm-adapter protocol version");
390                }
391            }
392        }
393        Ok(())
394    }
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
398#[serde(rename_all = "camelCase", deny_unknown_fields)]
399pub struct ReleaseIndexPayload {
400    pub schema_version: u32,
401    pub channel: String,
402    pub generated_at: String,
403    pub publisher_key_id: String,
404    pub artifacts: Vec<ReleaseArtifact>,
405}
406
407impl ReleaseIndexPayload {
408    pub fn validate_shape(&self) -> Result<(), &'static str> {
409        if self.schema_version != RELEASE_INDEX_SCHEMA_VERSION {
410            return Err("unsupported release-index schema");
411        }
412        if !matches!(self.channel.as_str(), "stable" | "candidate") {
413            return Err("unsupported release channel");
414        }
415        if self.generated_at.is_empty() || self.generated_at.len() > 64 {
416            return Err("invalid release-index timestamp");
417        }
418        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
419            return Err("invalid release-index publisher");
420        }
421        if self.artifacts.is_empty() || self.artifacts.len() > 64 {
422            return Err("invalid release-index artifact count");
423        }
424        for artifact in &self.artifacts {
425            artifact.validate_shape()?;
426        }
427        Ok(())
428    }
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
432#[serde(rename_all = "camelCase", deny_unknown_fields)]
433pub struct SignedReleaseIndex {
434    pub payload: ReleaseIndexPayload,
435    /// Lowercase hexadecimal Ed25519 signature over canonical payload JSON.
436    pub signature: String,
437}
438
439// ---------------------------------------------------------------------------
440// IPC requests (shared by v1 and v2)
441// ---------------------------------------------------------------------------
442
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
444#[serde(
445    tag = "method",
446    rename_all = "camelCase",
447    rename_all_fields = "camelCase",
448    deny_unknown_fields
449)]
450pub enum RuntimeRequest {
451    Handshake {
452        request_id: String,
453        api_version: u32,
454        client_name: String,
455        client_version: String,
456    },
457    Health {
458        request_id: String,
459        api_version: u32,
460    },
461    Invoke {
462        request_id: String,
463        api_version: u32,
464        capability: String,
465        input: serde_json::Value,
466    },
467}
468
469impl RuntimeRequest {
470    pub fn request_id(&self) -> &str {
471        match self {
472            Self::Handshake { request_id, .. }
473            | Self::Health { request_id, .. }
474            | Self::Invoke { request_id, .. } => request_id,
475        }
476    }
477
478    pub fn api_version(&self) -> u32 {
479        match self {
480            Self::Handshake { api_version, .. }
481            | Self::Health { api_version, .. }
482            | Self::Invoke { api_version, .. } => *api_version,
483        }
484    }
485}
486
487// ---------------------------------------------------------------------------
488// IPC v1 responses (frozen since 0.5.0)
489// ---------------------------------------------------------------------------
490
491#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
492#[serde(
493    tag = "kind",
494    rename_all = "camelCase",
495    rename_all_fields = "camelCase",
496    deny_unknown_fields
497)]
498pub enum RuntimeResponse {
499    Handshake {
500        request_id: String,
501        api_version: u32,
502        runtime_version: String,
503        model_pack_id: String,
504        model_pack_version: String,
505        capabilities: Vec<String>,
506    },
507    Health {
508        request_id: String,
509        api_version: u32,
510        healthy: bool,
511        model_pack_id: String,
512        model_pack_version: String,
513    },
514    Result {
515        request_id: String,
516        api_version: u32,
517        output: serde_json::Value,
518    },
519    Error {
520        request_id: String,
521        api_version: u32,
522        code: String,
523        message: String,
524        retryable: bool,
525    },
526}
527
528// ---------------------------------------------------------------------------
529// IPC v2 responses (introduced in 0.7.0)
530// ---------------------------------------------------------------------------
531
532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
533#[serde(
534    tag = "kind",
535    rename_all = "camelCase",
536    rename_all_fields = "camelCase",
537    deny_unknown_fields
538)]
539pub enum RuntimeResponseV2 {
540    Handshake {
541        request_id: String,
542        api_version: u32,
543        runtime_version: String,
544        model_pack_id: String,
545        model_pack_version: String,
546        capabilities: Vec<String>,
547        handler_id: String,
548        handler_version: String,
549        handler_api_version: u32,
550        effective_capabilities: Vec<String>,
551    },
552    Health {
553        request_id: String,
554        api_version: u32,
555        healthy: bool,
556        model_pack_id: String,
557        model_pack_version: String,
558    },
559    Result {
560        request_id: String,
561        api_version: u32,
562        output: serde_json::Value,
563    },
564    Error {
565        request_id: String,
566        api_version: u32,
567        code: String,
568        message: String,
569        retryable: bool,
570    },
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn protocol_v1_roundtrip_is_tagged_and_strict() {
579        let request = RuntimeRequest::Health {
580            request_id: "health-1".into(),
581            api_version: 1,
582        };
583        let json = serde_json::to_string(&request).unwrap();
584        assert!(json.contains("\"method\":\"health\""));
585        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
586        assert_eq!(restored, request);
587        assert!(
588            serde_json::from_str::<RuntimeRequest>(
589                r#"{"method":"health","requestId":"x","apiVersion":1,"extra":true}"#
590            )
591            .is_err()
592        );
593    }
594
595    #[test]
596    fn v1_handshake_fixture_is_stable() {
597        let request = RuntimeRequest::Handshake {
598            request_id: "fixture".into(),
599            api_version: 1,
600            client_name: "example-host".into(),
601            client_version: "0.6.10".into(),
602        };
603        assert_eq!(
604            serde_json::to_string(&request).unwrap(),
605            r#"{"method":"handshake","requestId":"fixture","apiVersion":1,"clientName":"example-host","clientVersion":"0.6.10"}"#
606        );
607    }
608
609    #[test]
610    fn v1_handshake_response_fixture_is_stable() {
611        let response = RuntimeResponse::Handshake {
612            request_id: "fixture".into(),
613            api_version: 1,
614            runtime_version: "0.6.0".into(),
615            model_pack_id: "rillml.example.default".into(),
616            model_pack_version: "0.6.0".into(),
617            capabilities: vec!["rillml.example".into()],
618        };
619        assert_eq!(
620            serde_json::to_string(&response).unwrap(),
621            r#"{"kind":"handshake","requestId":"fixture","apiVersion":1,"runtimeVersion":"0.6.0","modelPackId":"rillml.example.default","modelPackVersion":"0.6.0","capabilities":["rillml.example"]}"#
622        );
623    }
624
625    #[test]
626    fn v2_handshake_response_fixture_is_stable() {
627        let response = RuntimeResponseV2::Handshake {
628            request_id: "v2-fixture".into(),
629            api_version: 2,
630            runtime_version: "0.7.0".into(),
631            model_pack_id: "rillml.example.default".into(),
632            model_pack_version: "0.7.0".into(),
633            capabilities: vec!["rillml.example".into()],
634            handler_id: "org.example.handler".into(),
635            handler_version: "1.0.0".into(),
636            handler_api_version: 1,
637            effective_capabilities: vec!["rillml.example".into()],
638        };
639        let json = serde_json::to_string(&response).unwrap();
640        assert!(json.contains("\"handlerId\":\"org.example.handler\""));
641        assert!(json.contains("\"handlerApiVersion\":1"));
642        assert!(json.contains("\"effectiveCapabilities\":[\"rillml.example\"]"));
643        // Mutating the response produces a different JSON, proving the fixture
644        // is fully serialised and not relying on default values.
645        let mut bad = serde_json::from_str::<RuntimeResponseV2>(&json).unwrap();
646        if let RuntimeResponseV2::Handshake { handler_id, .. } = &mut bad {
647            handler_id.push('x');
648        }
649        let bad_json = serde_json::to_string(&bad).unwrap();
650        assert_ne!(bad_json, json);
651    }
652
653    #[test]
654    fn v1_response_rejects_handler_fields() {
655        let json = r#"{"kind":"handshake","requestId":"x","apiVersion":1,"runtimeVersion":"0.7.0","modelPackId":"m","modelPackVersion":"1","capabilities":["c"],"handlerId":"h"}"#;
656        assert!(serde_json::from_str::<RuntimeResponse>(json).is_err());
657    }
658
659    #[test]
660    fn invoke_roundtrip_preserves_capability_and_input() {
661        let request = RuntimeRequest::Invoke {
662            request_id: "invoke-1".into(),
663            api_version: 2,
664            capability: "rillml.example".into(),
665            input: serde_json::json!({"samples": []}),
666        };
667        let json = serde_json::to_string(&request).unwrap();
668        assert!(json.contains("\"method\":\"invoke\""));
669        assert!(json.contains("\"capability\":\"rillml.example\""));
670        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
671        assert_eq!(restored, request);
672    }
673
674    #[test]
675    fn release_artifacts_enforce_platform_boundaries() {
676        let runtime = ReleaseArtifact {
677            kind: ReleaseArtifactKind::Runtime,
678            id: RUNTIME_ARTIFACT_ID.into(),
679            version: "0.7.0".into(),
680            runtime_api_version: RUNTIME_API_VERSION,
681            target_os: Some("macos".into()),
682            target_arch: Some("aarch64".into()),
683            target_libc: None,
684            handler_api_version: None,
685            min_runtime_version: None,
686            pm_adapter_protocol_version: None,
687            url: "https://example.invalid/rill-runtime".into(),
688            sha256: "ab".repeat(32),
689            size: 1024,
690        };
691        assert!(runtime.validate_shape().is_ok());
692
693        let mut model = runtime.clone();
694        model.kind = ReleaseArtifactKind::Model;
695        model.id = "rillml.example.default".into();
696        model.target_os = None;
697        model.target_arch = None;
698        assert!(model.validate_shape().is_ok());
699
700        let mut handler = runtime.clone();
701        handler.kind = ReleaseArtifactKind::Handler;
702        handler.id = "org.example.handler".into();
703        handler.target_os = None;
704        handler.target_arch = None;
705        handler.handler_api_version = Some(HANDLER_API_VERSION);
706        handler.min_runtime_version = Some("0.7.0".into());
707        assert!(handler.validate_shape().is_ok());
708
709        // Handler with platform fields is rejected.
710        handler.target_os = Some("linux".into());
711        assert!(handler.validate_shape().is_err());
712        handler.target_os = None;
713
714        // Handler without handler_api_version is rejected.
715        handler.handler_api_version = None;
716        assert!(handler.validate_shape().is_err());
717        handler.handler_api_version = Some(HANDLER_API_VERSION);
718
719        // Handler without min_runtime_version is rejected.
720        handler.min_runtime_version = None;
721        assert!(handler.validate_shape().is_err());
722    }
723
724    #[test]
725    fn pm_adapter_artifact_roundtrip_and_shape_validation() {
726        // The release index emits kebab-case ``pm-adapter`` (not camelCase
727        // ``pmAdapter``).
728        let json = r#"{"kind":"pm-adapter","id":"rill-pm-adapter","version":"1.2.0-rc.1","pmAdapterProtocolVersion":1,"targetOs":"linux","targetArch":"x86_64","url":"https://example.invalid/adapter","sha256":"3333333333333333333333333333333333333333333333333333333333333333","size":4096}"#;
729        let artifact: ReleaseArtifact = serde_json::from_str(json).unwrap();
730        assert_eq!(artifact.kind, ReleaseArtifactKind::PmAdapter);
731        assert_eq!(
732            artifact.pm_adapter_protocol_version,
733            Some(PM_ADAPTER_PROTOCOL_VERSION)
734        );
735        assert!(artifact.validate_shape().is_ok());
736
737        // Unknown kind is still rejected.
738        assert!(
739            serde_json::from_str::<ReleaseArtifact>(&json.replace("pm-adapter", "pmAdapter"))
740                .is_err()
741        );
742
743        // Wrong protocol version is rejected.
744        let mut bad = artifact.clone();
745        bad.pm_adapter_protocol_version = Some(99);
746        assert!(bad.validate_shape().is_err());
747
748        // Handler fields are rejected on a pm-adapter.
749        let mut bad = artifact.clone();
750        bad.handler_api_version = Some(HANDLER_API_VERSION);
751        assert!(bad.validate_shape().is_err());
752
753        // Setting a runtime API version is rejected on a pm-adapter.
754        let mut bad = artifact.clone();
755        bad.runtime_api_version = RUNTIME_API_VERSION;
756        assert!(bad.validate_shape().is_err());
757
758        // Missing target platform is rejected.
759        let mut bad = artifact.clone();
760        bad.target_os = None;
761        assert!(bad.validate_shape().is_err());
762
763        // Wrong artifact id is rejected.
764        let mut bad = artifact.clone();
765        bad.id = "org.example.other".into();
766        assert!(bad.validate_shape().is_err());
767    }
768
769    #[test]
770    fn handler_manifest_validates_shape() {
771        let manifest = HandlerPackManifest {
772            format_version: HANDLER_PACKAGE_FORMAT_VERSION,
773            id: "org.example.handler".into(),
774            version: "1.0.0".into(),
775            handler_api_version: HANDLER_API_VERSION,
776            min_runtime_version: "0.7.0".into(),
777            publisher_key_id: "test-key".into(),
778            capabilities: vec!["org.example.predict".into()],
779            module_sha256: "ab".repeat(32),
780            module_size: 1024,
781        };
782        assert!(manifest.validate_shape().is_ok());
783
784        let mut bad = manifest.clone();
785        bad.format_version = 99;
786        assert!(bad.validate_shape().is_err());
787
788        let mut bad = manifest.clone();
789        bad.handler_api_version = 99;
790        assert!(bad.validate_shape().is_err());
791
792        let mut bad = manifest.clone();
793        bad.capabilities = vec![];
794        assert!(bad.validate_shape().is_err());
795
796        let mut bad = manifest.clone();
797        bad.module_sha256 = "short".into();
798        assert!(bad.validate_shape().is_err());
799
800        let mut bad = manifest.clone();
801        bad.module_size = 0;
802        assert!(bad.validate_shape().is_err());
803    }
804}