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/// Version of the external trust metadata used for key rotation and rollback
44/// protection. This is deliberately separate from the frozen release-index v3
45/// payload, so existing v1.2 readers keep their exact fail-closed semantics.
46pub const TRUST_METADATA_SCHEMA_VERSION: u32 = 1;
47/// Version of the signed release-generation envelope used with trust metadata.
48pub const RELEASE_INDEX_LIFECYCLE_SCHEMA_VERSION: u32 = 1;
49/// Hard upper bound for one newline-delimited IPC message.
50pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
51
52/// Stable artifact id for the GNU (default) runtime build.
53pub const RUNTIME_ARTIFACT_ID: &str = "rill-runtime";
54/// Stable artifact id for the musl runtime build. The libc variant is part of
55/// the stable asset identity so gnu and musl builds of the same OS+arch do not
56/// collide in a v2 release index.
57pub const RUNTIME_ARTIFACT_ID_MUSL: &str = "rill-runtime-musl";
58/// Stable artifact id for the OpenWrt Performance Manager decision
59/// adapter. The adapter is a distinct artifact kind (``pm-adapter``)
60/// because it speaks the independent ``pm-rill-shadow`` v1 protocol, not
61/// the Rill Runtime IPC API.
62pub const PM_ADAPTER_ARTIFACT_ID: &str = "rill-pm-adapter";
63/// ``pm-rill-shadow`` protocol version advertised by released adapter
64/// binaries and required by ``pm-adapter`` index entries.
65pub const PM_ADAPTER_PROTOCOL_VERSION: u32 = 1;
66
67// ---------------------------------------------------------------------------
68// Stable IPC error codes
69// ---------------------------------------------------------------------------
70
71/// Stable IPC error code constants.
72///
73/// Every `RuntimeResponse::Error` / `RuntimeResponseV2::Error` `code` field
74/// produced by the runtime is one of the constants in this module. The codes
75/// are frozen for the entire 1.x cycle: existing codes are never renamed, and
76/// new codes may only be added (additive).
77///
78/// The runtime constructs error responses exclusively from these constants.
79/// Hosts and clients may switch on the string values; the constants are
80/// exported so that downstream Rust code does not have to inline string
81/// literals.
82pub mod error_code {
83    /// Request body was not valid protocol JSON.
84    pub const INVALID_JSON: &str = "invalidJson";
85    /// `requestId` was missing, empty, or longer than 128 characters.
86    pub const INVALID_REQUEST_ID: &str = "invalidRequestId";
87    /// `apiVersion` was outside `[MIN_RUNTIME_API_VERSION, RUNTIME_API_VERSION]`.
88    pub const INCOMPATIBLE_API_VERSION: &str = "incompatibleApiVersion";
89    /// `clientName` / `clientVersion` failed length or emptiness checks.
90    pub const INVALID_CLIENT_IDENTITY: &str = "invalidClientIdentity";
91    /// `Invoke` capability is not in the effective capability set.
92    pub const UNSUPPORTED_CAPABILITY: &str = "unsupportedCapability";
93    /// `Invoke` was issued but no handler is registered.
94    pub const NO_INVOKE_HANDLER: &str = "noInvokeHandler";
95    /// Handler exceeded the wall-clock deadline. Retryable.
96    pub const HANDLER_TIMEOUT: &str = "handlerTimeout";
97    /// Handler trapped (unreachable, out-of-bounds, stack overflow, …).
98    pub const HANDLER_TRAP: &str = "handlerTrap";
99    /// Handler output exceeded the host-side size limit.
100    pub const HANDLER_OUTPUT_TOO_LARGE: &str = "handlerOutputTooLarge";
101    /// Handler output was not valid JSON.
102    pub const HANDLER_INVALID_OUTPUT: &str = "handlerInvalidOutput";
103    /// Handler reported an internal error (covers all four WIT
104    /// `handler-error` variants on the wire for backwards compatibility).
105    pub const HANDLER_INTERNAL_ERROR: &str = "handlerInternalError";
106
107    /// All frozen error codes in alphabetical order.
108    ///
109    /// This slice is used by tests and by the runtime's error-code allowlist
110    /// check. Adding a new code requires appending to this slice; the order
111    /// is part of the frozen surface so test fixtures remain stable.
112    pub const FROZEN_CODES: &[&str] = &[
113        HANDLER_INTERNAL_ERROR,
114        HANDLER_INVALID_OUTPUT,
115        HANDLER_OUTPUT_TOO_LARGE,
116        HANDLER_TIMEOUT,
117        HANDLER_TRAP,
118        INCOMPATIBLE_API_VERSION,
119        INVALID_CLIENT_IDENTITY,
120        INVALID_JSON,
121        INVALID_REQUEST_ID,
122        NO_INVOKE_HANDLER,
123        UNSUPPORTED_CAPABILITY,
124    ];
125
126    /// Returns `true` if `code` is one of the frozen 1.x error codes.
127    pub fn is_frozen(code: &str) -> bool {
128        FROZEN_CODES.contains(&code)
129    }
130}
131
132// ---------------------------------------------------------------------------
133// Model pack manifest
134// ---------------------------------------------------------------------------
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137#[serde(rename_all = "camelCase", deny_unknown_fields)]
138pub struct ModelPackManifest {
139    pub format_version: u32,
140    pub id: String,
141    pub version: String,
142    pub runtime_api_version: u32,
143    pub min_runtime_version: String,
144    pub publisher_key_id: String,
145    pub capabilities: Vec<String>,
146}
147
148impl ModelPackManifest {
149    pub fn validate_shape(&self) -> Result<(), &'static str> {
150        if self.format_version != MODEL_PACK_FORMAT_VERSION {
151            return Err("unsupported model-pack format version");
152        }
153        if self.runtime_api_version != RUNTIME_API_VERSION {
154            return Err("unsupported runtime API version");
155        }
156        if self.id.is_empty() || self.id.len() > 96 {
157            return Err("invalid model-pack id");
158        }
159        if self.version.is_empty() || self.version.len() > 48 {
160            return Err("invalid model-pack version");
161        }
162        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
163            return Err("invalid publisher key id");
164        }
165        Self::validate_capabilities(&self.capabilities)?;
166        Ok(())
167    }
168
169    pub fn validate_capabilities(capabilities: &[String]) -> Result<(), &'static str> {
170        if capabilities.is_empty() || capabilities.len() > 32 {
171            return Err("invalid capabilities list");
172        }
173        if capabilities
174            .iter()
175            .any(|capability| capability.is_empty() || capability.len() > 96)
176        {
177            return Err("invalid capability string");
178        }
179        let mut seen = std::collections::HashSet::new();
180        if !capabilities
181            .iter()
182            .all(|capability| seen.insert(capability.clone()))
183        {
184            return Err("duplicate capability");
185        }
186        Ok(())
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Handler pack manifest
192// ---------------------------------------------------------------------------
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
195#[serde(rename_all = "camelCase", deny_unknown_fields)]
196pub struct HandlerPackManifest {
197    pub format_version: u32,
198    pub id: String,
199    pub version: String,
200    pub handler_api_version: u32,
201    pub min_runtime_version: String,
202    pub publisher_key_id: String,
203    pub capabilities: Vec<String>,
204    pub module_sha256: String,
205    pub module_size: u64,
206}
207
208impl HandlerPackManifest {
209    pub fn validate_shape(&self) -> Result<(), &'static str> {
210        if self.format_version != HANDLER_PACKAGE_FORMAT_VERSION {
211            return Err("unsupported handler-pack format version");
212        }
213        if self.handler_api_version != HANDLER_API_VERSION {
214            return Err("unsupported handler API version");
215        }
216        if self.id.is_empty() || self.id.len() > 96 {
217            return Err("invalid handler id");
218        }
219        if self.version.is_empty() || self.version.len() > 48 {
220            return Err("invalid handler version");
221        }
222        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
223            return Err("invalid handler publisher key id");
224        }
225        if self.min_runtime_version.is_empty() || self.min_runtime_version.len() > 48 {
226            return Err("invalid minimum runtime version");
227        }
228        ModelPackManifest::validate_capabilities(&self.capabilities)?;
229        if self.module_sha256.len() != 64
230            || !self
231                .module_sha256
232                .bytes()
233                .all(|byte| byte.is_ascii_hexdigit())
234        {
235            return Err("invalid module SHA-256");
236        }
237        if self.module_size == 0 || self.module_size > 4 * 1024 * 1024 {
238            return Err("invalid module size");
239        }
240        Ok(())
241    }
242}
243
244// ---------------------------------------------------------------------------
245// Release index
246// ---------------------------------------------------------------------------
247
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
249#[serde(rename_all = "camelCase")]
250pub enum ReleaseArtifactKind {
251    Runtime,
252    Model,
253    Handler,
254    #[serde(rename = "pm-adapter")]
255    PmAdapter,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
259#[serde(rename_all = "camelCase", deny_unknown_fields)]
260pub struct ReleaseArtifact {
261    pub kind: ReleaseArtifactKind,
262    pub id: String,
263    pub version: String,
264    #[serde(default)]
265    pub runtime_api_version: u32,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub target_os: Option<String>,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub target_arch: Option<String>,
270    /// The libc/ABI variant (``gnu`` or ``musl``) of a Linux target. Present
271    /// only on Linux runtime/adapter artifacts; non-Linux targets (macOS,
272    /// Windows, FreeBSD) omit it. Introduced in release-index schema v3 so
273    /// gnu and musl builds of the same OS+arch coexist unambiguously.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub target_libc: Option<String>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub handler_api_version: Option<u32>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub min_runtime_version: Option<String>,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub pm_adapter_protocol_version: Option<u32>,
282    pub url: String,
283    pub sha256: String,
284    pub size: u64,
285}
286
287impl ReleaseArtifact {
288    pub fn validate_shape(&self) -> Result<(), &'static str> {
289        if self.id.is_empty() || self.id.len() > 96 {
290            return Err("invalid artifact id");
291        }
292        if self.version.is_empty() || self.version.len() > 48 {
293            return Err("invalid artifact version");
294        }
295        if self.url.is_empty() || self.url.len() > 2048 {
296            return Err("invalid artifact URL");
297        }
298        if self.sha256.len() != 64 || !self.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
299            return Err("invalid artifact SHA-256");
300        }
301        if self.size == 0 || self.size > 128 * 1024 * 1024 {
302            return Err("invalid artifact size");
303        }
304        match self.kind {
305            ReleaseArtifactKind::Runtime => {
306                if self.runtime_api_version != RUNTIME_API_VERSION {
307                    return Err("unsupported artifact runtime API version");
308                }
309                // The artifact ``id`` is part of the stable asset identity. On
310                // Linux, the libc/ABI variant (gnu vs musl) is encoded in the
311                // ``id`` and recorded explicitly in ``target_libc`` so both
312                // builds of the same OS+arch coexist unambiguously in a v3
313                // index.
314                if (self.id != RUNTIME_ARTIFACT_ID && self.id != RUNTIME_ARTIFACT_ID_MUSL)
315                    || self.target_os.as_deref().is_none_or(str::is_empty)
316                    || self.target_arch.as_deref().is_none_or(str::is_empty)
317                {
318                    return Err("runtime artifact requires a target OS and architecture");
319                }
320                // On Linux the libc variant must be explicit (gnu or musl).
321                // Non-Linux targets must not carry a libc variant.
322                match self.target_os.as_deref() {
323                    Some("linux") => {
324                        let libc = self.target_libc.as_deref();
325                        let expected = if self.id == RUNTIME_ARTIFACT_ID_MUSL {
326                            Some("musl")
327                        } else {
328                            Some("gnu")
329                        };
330                        if libc != expected {
331                            return Err("runtime artifact libc variant does not match its id");
332                        }
333                    }
334                    _ => {
335                        if self.target_libc.is_some() {
336                            return Err("non-Linux runtime artifact must not carry a libc variant");
337                        }
338                    }
339                }
340                if self.handler_api_version.is_some() || self.min_runtime_version.is_some() {
341                    return Err("runtime artifact must not carry handler fields");
342                }
343            }
344            ReleaseArtifactKind::Model => {
345                if self.runtime_api_version != RUNTIME_API_VERSION {
346                    return Err("unsupported artifact runtime API version");
347                }
348                if self.target_os.is_some()
349                    || self.target_arch.is_some()
350                    || self.handler_api_version.is_some()
351                    || self.min_runtime_version.is_some()
352                {
353                    return Err("model artifact must be platform independent");
354                }
355            }
356            ReleaseArtifactKind::Handler => {
357                if self.runtime_api_version != RUNTIME_API_VERSION {
358                    return Err("unsupported artifact runtime API version");
359                }
360                if self.target_os.is_some() || self.target_arch.is_some() {
361                    return Err("handler artifact must be platform independent");
362                }
363                let handler_api = self
364                    .handler_api_version
365                    .ok_or("handler artifact requires handler API version")?;
366                if handler_api != HANDLER_API_VERSION {
367                    return Err("unsupported handler API version");
368                }
369                let min_runtime = self
370                    .min_runtime_version
371                    .as_deref()
372                    .ok_or("handler artifact requires minimum runtime version")?;
373                if min_runtime.is_empty() || min_runtime.len() > 48 {
374                    return Err("invalid minimum runtime version");
375                }
376            }
377            ReleaseArtifactKind::PmAdapter => {
378                // The PM adapter speaks the independent ``pm-rill-shadow``
379                // protocol, not the Rill Runtime IPC API, so
380                // ``runtimeApiVersion`` is not applicable and must remain
381                // unset (serde default 0).
382                if self.runtime_api_version != 0 {
383                    return Err("pm-adapter artifact must not set runtime API version");
384                }
385                if self.id != PM_ADAPTER_ARTIFACT_ID
386                    || self.target_os.as_deref().is_none_or(str::is_empty)
387                    || self.target_arch.as_deref().is_none_or(str::is_empty)
388                {
389                    return Err("pm-adapter artifact requires a target OS and architecture");
390                }
391                if self.handler_api_version.is_some() || self.min_runtime_version.is_some() {
392                    return Err("pm-adapter artifact must not carry handler fields");
393                }
394                if self.pm_adapter_protocol_version != Some(PM_ADAPTER_PROTOCOL_VERSION) {
395                    return Err("unsupported pm-adapter protocol version");
396                }
397            }
398        }
399        Ok(())
400    }
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
404#[serde(rename_all = "camelCase", deny_unknown_fields)]
405pub struct ReleaseIndexPayload {
406    pub schema_version: u32,
407    pub channel: String,
408    pub generated_at: String,
409    pub publisher_key_id: String,
410    pub artifacts: Vec<ReleaseArtifact>,
411}
412
413impl ReleaseIndexPayload {
414    pub fn validate_shape(&self) -> Result<(), &'static str> {
415        if self.schema_version != RELEASE_INDEX_SCHEMA_VERSION {
416            return Err("unsupported release-index schema");
417        }
418        if !matches!(self.channel.as_str(), "stable" | "candidate") {
419            return Err("unsupported release channel");
420        }
421        if self.generated_at.is_empty() || self.generated_at.len() > 64 {
422            return Err("invalid release-index timestamp");
423        }
424        if self.publisher_key_id.is_empty() || self.publisher_key_id.len() > 96 {
425            return Err("invalid release-index publisher");
426        }
427        if self.artifacts.is_empty() || self.artifacts.len() > 64 {
428            return Err("invalid release-index artifact count");
429        }
430        for artifact in &self.artifacts {
431            artifact.validate_shape()?;
432        }
433        Ok(())
434    }
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
438#[serde(rename_all = "camelCase", deny_unknown_fields)]
439pub struct SignedReleaseIndex {
440    pub payload: ReleaseIndexPayload,
441    /// Lowercase hexadecimal Ed25519 signature over canonical payload JSON.
442    pub signature: String,
443}
444
445/// A trusted publisher key and its lifecycle state.
446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
447#[serde(rename_all = "camelCase", deny_unknown_fields)]
448pub struct TrustKeyMetadataV1 {
449    pub key_id: String,
450    pub public_key_hex: String,
451    pub role: TrustKeyRole,
452    pub not_before_unix_ms: u64,
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub not_after_unix_ms: Option<u64>,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub revoked_at_unix_ms: Option<u64>,
457    #[serde(default)]
458    pub emergency_revoked: bool,
459}
460
461/// Rotation role for a trusted publisher key.
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463#[serde(rename_all = "camelCase")]
464pub enum TrustKeyRole {
465    Current,
466    Next,
467}
468
469impl TrustKeyMetadataV1 {
470    pub fn validate_shape(&self) -> Result<(), &'static str> {
471        if self.key_id.is_empty() || self.key_id.len() > 96 {
472            return Err("invalid trust key id");
473        }
474        if self.public_key_hex.len() != 64
475            || !self
476                .public_key_hex
477                .bytes()
478                .all(|byte| byte.is_ascii_hexdigit())
479        {
480            return Err("invalid trust public key");
481        }
482        if let Some(not_after) = self.not_after_unix_ms
483            && not_after <= self.not_before_unix_ms
484        {
485            return Err("trust key validity window is empty");
486        }
487        Ok(())
488    }
489
490    /// Whether this key may authenticate an artifact at `now_unix_ms`.
491    pub fn is_active_at(&self, now_unix_ms: u64) -> bool {
492        self.not_before_unix_ms <= now_unix_ms
493            && self
494                .not_after_unix_ms
495                .is_none_or(|not_after| now_unix_ms < not_after)
496            && self
497                .revoked_at_unix_ms
498                .is_none_or(|revoked_at| now_unix_ms < revoked_at)
499            && !self.emergency_revoked
500    }
501}
502
503/// External trust metadata for publisher key rotation.
504///
505/// The metadata is signed/distributed by the consumer's trust root. It is not
506/// embedded into release-index schema v3. `minimumReleaseGeneration` is the
507/// metadata publisher's release floor; consumers enforce their independent
508/// release floor through [`TrustVerificationFloorV1`].
509#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
510#[serde(rename_all = "camelCase", deny_unknown_fields)]
511pub struct TrustMetadataV1 {
512    pub schema_version: u32,
513    pub metadata_generation: u64,
514    pub minimum_release_generation: u64,
515    pub keys: Vec<TrustKeyMetadataV1>,
516}
517
518/// Monotonic state persisted by a consumer that opts into trust-metadata
519/// lifecycle verification.
520///
521/// RillML does not persist this value on behalf of a host. A consumer should
522/// atomically replace it only after accepting authenticated metadata. When a
523/// digest is present, the metadata bytes at the same generation are also
524/// pinned, so a same-generation content fork fails closed.
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
526#[serde(rename_all = "camelCase", deny_unknown_fields)]
527pub struct TrustVerificationFloorV1 {
528    pub minimum_metadata_generation: u64,
529    pub minimum_release_generation: u64,
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub metadata_digest: Option<String>,
532}
533
534impl TrustMetadataV1 {
535    pub fn validate_shape(&self) -> Result<(), &'static str> {
536        if self.schema_version != TRUST_METADATA_SCHEMA_VERSION {
537            return Err("unsupported trust metadata schema");
538        }
539        if self.keys.is_empty() || self.keys.len() > 64 {
540            return Err("invalid trust key count");
541        }
542        let mut ids = std::collections::BTreeSet::new();
543        for key in &self.keys {
544            key.validate_shape()?;
545            if !ids.insert(&key.key_id) {
546                return Err("duplicate trust key id");
547            }
548        }
549        if !self
550            .keys
551            .iter()
552            .any(|key| matches!(&key.role, TrustKeyRole::Current))
553        {
554            return Err("trust metadata has no current key");
555        }
556        Ok(())
557    }
558
559    /// Returns the active current/next keys at a point in time.
560    pub fn active_keys_at(
561        &self,
562        now_unix_ms: u64,
563    ) -> Result<Vec<&TrustKeyMetadataV1>, &'static str> {
564        self.validate_shape()?;
565        let active: Vec<_> = self
566            .keys
567            .iter()
568            .filter(|key| key.is_active_at(now_unix_ms))
569            .collect();
570        if !active
571            .iter()
572            .any(|key| matches!(&key.role, TrustKeyRole::Current))
573        {
574            return Err("trust metadata has no active current key");
575        }
576        Ok(active)
577    }
578}
579
580/// A signed v3 index plus a monotonic generation used for rollback protection.
581#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
582#[serde(rename_all = "camelCase", deny_unknown_fields)]
583pub struct SignedReleaseIndexWithGenerationV1 {
584    pub schema_version: u32,
585    pub release_generation: u64,
586    pub index: SignedReleaseIndex,
587    /// Lowercase hexadecimal signature over the lifecycle envelope fields and
588    /// the embedded signed index. This binds the generation to the publisher.
589    pub lifecycle_signature: String,
590}
591
592impl SignedReleaseIndexWithGenerationV1 {
593    pub fn validate_shape(&self) -> Result<(), &'static str> {
594        if self.schema_version != RELEASE_INDEX_LIFECYCLE_SCHEMA_VERSION {
595            return Err("unsupported release lifecycle schema");
596        }
597        if self.lifecycle_signature.len() != 128
598            || !self
599                .lifecycle_signature
600                .bytes()
601                .all(|byte| byte.is_ascii_hexdigit())
602        {
603            return Err("invalid release lifecycle signature");
604        }
605        Ok(())
606    }
607}
608
609// ---------------------------------------------------------------------------
610// IPC requests (shared by v1 and v2)
611// ---------------------------------------------------------------------------
612
613#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
614#[serde(
615    tag = "method",
616    rename_all = "camelCase",
617    rename_all_fields = "camelCase",
618    deny_unknown_fields
619)]
620pub enum RuntimeRequest {
621    Handshake {
622        request_id: String,
623        api_version: u32,
624        client_name: String,
625        client_version: String,
626    },
627    Health {
628        request_id: String,
629        api_version: u32,
630    },
631    Invoke {
632        request_id: String,
633        api_version: u32,
634        capability: String,
635        input: serde_json::Value,
636    },
637}
638
639impl RuntimeRequest {
640    pub fn request_id(&self) -> &str {
641        match self {
642            Self::Handshake { request_id, .. }
643            | Self::Health { request_id, .. }
644            | Self::Invoke { request_id, .. } => request_id,
645        }
646    }
647
648    pub fn api_version(&self) -> u32 {
649        match self {
650            Self::Handshake { api_version, .. }
651            | Self::Health { api_version, .. }
652            | Self::Invoke { api_version, .. } => *api_version,
653        }
654    }
655}
656
657// ---------------------------------------------------------------------------
658// IPC v1 responses (frozen since 0.5.0)
659// ---------------------------------------------------------------------------
660
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
662#[serde(
663    tag = "kind",
664    rename_all = "camelCase",
665    rename_all_fields = "camelCase",
666    deny_unknown_fields
667)]
668pub enum RuntimeResponse {
669    Handshake {
670        request_id: String,
671        api_version: u32,
672        runtime_version: String,
673        model_pack_id: String,
674        model_pack_version: String,
675        capabilities: Vec<String>,
676    },
677    Health {
678        request_id: String,
679        api_version: u32,
680        healthy: bool,
681        model_pack_id: String,
682        model_pack_version: String,
683    },
684    Result {
685        request_id: String,
686        api_version: u32,
687        output: serde_json::Value,
688    },
689    Error {
690        request_id: String,
691        api_version: u32,
692        code: String,
693        message: String,
694        retryable: bool,
695    },
696}
697
698// ---------------------------------------------------------------------------
699// IPC v2 responses (introduced in 0.7.0)
700// ---------------------------------------------------------------------------
701
702#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
703#[serde(
704    tag = "kind",
705    rename_all = "camelCase",
706    rename_all_fields = "camelCase",
707    deny_unknown_fields
708)]
709pub enum RuntimeResponseV2 {
710    Handshake {
711        request_id: String,
712        api_version: u32,
713        runtime_version: String,
714        model_pack_id: String,
715        model_pack_version: String,
716        capabilities: Vec<String>,
717        handler_id: String,
718        handler_version: String,
719        handler_api_version: u32,
720        effective_capabilities: Vec<String>,
721    },
722    Health {
723        request_id: String,
724        api_version: u32,
725        healthy: bool,
726        model_pack_id: String,
727        model_pack_version: String,
728    },
729    Result {
730        request_id: String,
731        api_version: u32,
732        output: serde_json::Value,
733    },
734    Error {
735        request_id: String,
736        api_version: u32,
737        code: String,
738        message: String,
739        retryable: bool,
740    },
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746
747    #[test]
748    fn protocol_v1_roundtrip_is_tagged_and_strict() {
749        let request = RuntimeRequest::Health {
750            request_id: "health-1".into(),
751            api_version: 1,
752        };
753        let json = serde_json::to_string(&request).unwrap();
754        assert!(json.contains("\"method\":\"health\""));
755        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
756        assert_eq!(restored, request);
757        assert!(
758            serde_json::from_str::<RuntimeRequest>(
759                r#"{"method":"health","requestId":"x","apiVersion":1,"extra":true}"#
760            )
761            .is_err()
762        );
763    }
764
765    #[test]
766    fn v1_handshake_fixture_is_stable() {
767        let request = RuntimeRequest::Handshake {
768            request_id: "fixture".into(),
769            api_version: 1,
770            client_name: "example-host".into(),
771            client_version: "0.6.10".into(),
772        };
773        assert_eq!(
774            serde_json::to_string(&request).unwrap(),
775            r#"{"method":"handshake","requestId":"fixture","apiVersion":1,"clientName":"example-host","clientVersion":"0.6.10"}"#
776        );
777    }
778
779    #[test]
780    fn v1_handshake_response_fixture_is_stable() {
781        let response = RuntimeResponse::Handshake {
782            request_id: "fixture".into(),
783            api_version: 1,
784            runtime_version: "0.6.0".into(),
785            model_pack_id: "rillml.example.default".into(),
786            model_pack_version: "0.6.0".into(),
787            capabilities: vec!["rillml.example".into()],
788        };
789        assert_eq!(
790            serde_json::to_string(&response).unwrap(),
791            r#"{"kind":"handshake","requestId":"fixture","apiVersion":1,"runtimeVersion":"0.6.0","modelPackId":"rillml.example.default","modelPackVersion":"0.6.0","capabilities":["rillml.example"]}"#
792        );
793    }
794
795    #[test]
796    fn v2_handshake_response_fixture_is_stable() {
797        let response = RuntimeResponseV2::Handshake {
798            request_id: "v2-fixture".into(),
799            api_version: 2,
800            runtime_version: "0.7.0".into(),
801            model_pack_id: "rillml.example.default".into(),
802            model_pack_version: "0.7.0".into(),
803            capabilities: vec!["rillml.example".into()],
804            handler_id: "org.example.handler".into(),
805            handler_version: "1.0.0".into(),
806            handler_api_version: 1,
807            effective_capabilities: vec!["rillml.example".into()],
808        };
809        let json = serde_json::to_string(&response).unwrap();
810        assert!(json.contains("\"handlerId\":\"org.example.handler\""));
811        assert!(json.contains("\"handlerApiVersion\":1"));
812        assert!(json.contains("\"effectiveCapabilities\":[\"rillml.example\"]"));
813        // Mutating the response produces a different JSON, proving the fixture
814        // is fully serialised and not relying on default values.
815        let mut bad = serde_json::from_str::<RuntimeResponseV2>(&json).unwrap();
816        if let RuntimeResponseV2::Handshake { handler_id, .. } = &mut bad {
817            handler_id.push('x');
818        }
819        let bad_json = serde_json::to_string(&bad).unwrap();
820        assert_ne!(bad_json, json);
821    }
822
823    #[test]
824    fn v1_response_rejects_handler_fields() {
825        let json = r#"{"kind":"handshake","requestId":"x","apiVersion":1,"runtimeVersion":"0.7.0","modelPackId":"m","modelPackVersion":"1","capabilities":["c"],"handlerId":"h"}"#;
826        assert!(serde_json::from_str::<RuntimeResponse>(json).is_err());
827    }
828
829    #[test]
830    fn invoke_roundtrip_preserves_capability_and_input() {
831        let request = RuntimeRequest::Invoke {
832            request_id: "invoke-1".into(),
833            api_version: 2,
834            capability: "rillml.example".into(),
835            input: serde_json::json!({"samples": []}),
836        };
837        let json = serde_json::to_string(&request).unwrap();
838        assert!(json.contains("\"method\":\"invoke\""));
839        assert!(json.contains("\"capability\":\"rillml.example\""));
840        let restored: RuntimeRequest = serde_json::from_str(&json).unwrap();
841        assert_eq!(restored, request);
842    }
843
844    #[test]
845    fn release_artifacts_enforce_platform_boundaries() {
846        let runtime = ReleaseArtifact {
847            kind: ReleaseArtifactKind::Runtime,
848            id: RUNTIME_ARTIFACT_ID.into(),
849            version: "0.7.0".into(),
850            runtime_api_version: RUNTIME_API_VERSION,
851            target_os: Some("macos".into()),
852            target_arch: Some("aarch64".into()),
853            target_libc: None,
854            handler_api_version: None,
855            min_runtime_version: None,
856            pm_adapter_protocol_version: None,
857            url: "https://example.invalid/rill-runtime".into(),
858            sha256: "ab".repeat(32),
859            size: 1024,
860        };
861        assert!(runtime.validate_shape().is_ok());
862
863        let mut model = runtime.clone();
864        model.kind = ReleaseArtifactKind::Model;
865        model.id = "rillml.example.default".into();
866        model.target_os = None;
867        model.target_arch = None;
868        assert!(model.validate_shape().is_ok());
869
870        let mut handler = runtime.clone();
871        handler.kind = ReleaseArtifactKind::Handler;
872        handler.id = "org.example.handler".into();
873        handler.target_os = None;
874        handler.target_arch = None;
875        handler.handler_api_version = Some(HANDLER_API_VERSION);
876        handler.min_runtime_version = Some("0.7.0".into());
877        assert!(handler.validate_shape().is_ok());
878
879        // Handler with platform fields is rejected.
880        handler.target_os = Some("linux".into());
881        assert!(handler.validate_shape().is_err());
882        handler.target_os = None;
883
884        // Handler without handler_api_version is rejected.
885        handler.handler_api_version = None;
886        assert!(handler.validate_shape().is_err());
887        handler.handler_api_version = Some(HANDLER_API_VERSION);
888
889        // Handler without min_runtime_version is rejected.
890        handler.min_runtime_version = None;
891        assert!(handler.validate_shape().is_err());
892    }
893
894    #[test]
895    fn pm_adapter_artifact_roundtrip_and_shape_validation() {
896        // The release index emits kebab-case ``pm-adapter`` (not camelCase
897        // ``pmAdapter``).
898        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}"#;
899        let artifact: ReleaseArtifact = serde_json::from_str(json).unwrap();
900        assert_eq!(artifact.kind, ReleaseArtifactKind::PmAdapter);
901        assert_eq!(
902            artifact.pm_adapter_protocol_version,
903            Some(PM_ADAPTER_PROTOCOL_VERSION)
904        );
905        assert!(artifact.validate_shape().is_ok());
906
907        // Unknown kind is still rejected.
908        assert!(
909            serde_json::from_str::<ReleaseArtifact>(&json.replace("pm-adapter", "pmAdapter"))
910                .is_err()
911        );
912
913        // Wrong protocol version is rejected.
914        let mut bad = artifact.clone();
915        bad.pm_adapter_protocol_version = Some(99);
916        assert!(bad.validate_shape().is_err());
917
918        // Handler fields are rejected on a pm-adapter.
919        let mut bad = artifact.clone();
920        bad.handler_api_version = Some(HANDLER_API_VERSION);
921        assert!(bad.validate_shape().is_err());
922
923        // Setting a runtime API version is rejected on a pm-adapter.
924        let mut bad = artifact.clone();
925        bad.runtime_api_version = RUNTIME_API_VERSION;
926        assert!(bad.validate_shape().is_err());
927
928        // Missing target platform is rejected.
929        let mut bad = artifact.clone();
930        bad.target_os = None;
931        assert!(bad.validate_shape().is_err());
932
933        // Wrong artifact id is rejected.
934        let mut bad = artifact.clone();
935        bad.id = "org.example.other".into();
936        assert!(bad.validate_shape().is_err());
937    }
938
939    #[test]
940    fn handler_manifest_validates_shape() {
941        let manifest = HandlerPackManifest {
942            format_version: HANDLER_PACKAGE_FORMAT_VERSION,
943            id: "org.example.handler".into(),
944            version: "1.0.0".into(),
945            handler_api_version: HANDLER_API_VERSION,
946            min_runtime_version: "0.7.0".into(),
947            publisher_key_id: "test-key".into(),
948            capabilities: vec!["org.example.predict".into()],
949            module_sha256: "ab".repeat(32),
950            module_size: 1024,
951        };
952        assert!(manifest.validate_shape().is_ok());
953
954        let mut bad = manifest.clone();
955        bad.format_version = 99;
956        assert!(bad.validate_shape().is_err());
957
958        let mut bad = manifest.clone();
959        bad.handler_api_version = 99;
960        assert!(bad.validate_shape().is_err());
961
962        let mut bad = manifest.clone();
963        bad.capabilities = vec![];
964        assert!(bad.validate_shape().is_err());
965
966        let mut bad = manifest.clone();
967        bad.module_sha256 = "short".into();
968        assert!(bad.validate_shape().is_err());
969
970        let mut bad = manifest.clone();
971        bad.module_size = 0;
972        assert!(bad.validate_shape().is_err());
973    }
974}