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