Skip to main content

rill_runtime_protocol/
v3.rs

1//! Preview Runtime IPC v3.
2//!
3//! V3 deliberately uses an envelope and payload types that are independent
4//! from the frozen v1/v2 wire schemas. Hosts must opt in by sending
5//! [`crate::v3::RUNTIME_API_VERSION_V3`]; the legacy [`crate::RUNTIME_API_VERSION`]
6//! remains `2` so existing model manifests and clients retain their exact
7//! meaning.
8
9use serde::{Deserialize, Serialize};
10
11/// Runtime IPC version used by this module.
12pub const RUNTIME_API_VERSION_V3: u32 = 3;
13/// Maximum request id length.
14pub const MAX_REQUEST_ID_LEN_V3: usize = 128;
15/// Maximum identity name length.
16pub const MAX_IDENTITY_NAME_LEN_V3: usize = 96;
17/// Maximum identity version length.
18pub const MAX_IDENTITY_VERSION_LEN_V3: usize = 48;
19/// Maximum capability length.
20pub const MAX_CAPABILITY_LEN_V3: usize = 96;
21/// Maximum decision id length.
22pub const MAX_DECISION_ID_LEN_V3: usize = 128;
23/// Maximum feature-schema hash length (lower-case SHA-256 hex).
24pub const FEATURE_SCHEMA_HASH_LEN_V3: usize = 64;
25/// Maximum number of capabilities carried in a response.
26pub const MAX_CAPABILITIES_V3: usize = 32;
27/// Maximum error message length.
28pub const MAX_ERROR_MESSAGE_LEN_V3: usize = 512;
29
30/// Stable machine-readable marker for the opt-in v3 executable channel.
31pub const PREVIEW_CHANNEL_V3: &str = "preview";
32
33/// Bounded runtime policy shared by preview and stable v3 consumers.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct ResourceProfileV1 {
37    pub max_ipc_frame_bytes: u32,
38    pub max_model_state_bytes: u32,
39    pub max_snapshot_bytes: u32,
40    pub max_handler_package_bytes: u32,
41    pub max_model_pack_bytes: u32,
42    pub max_features: u32,
43    pub max_pending_decisions: u32,
44    pub max_completed_decisions: u32,
45    pub max_diagnostic_records: u32,
46    pub request_deadline_ms: u64,
47    pub shutdown_deadline_ms: u64,
48    pub snapshot_deadline_ms: u64,
49    pub restart_backoff_ms: u64,
50}
51
52impl Default for ResourceProfileV1 {
53    fn default() -> Self {
54        Self {
55            max_ipc_frame_bytes: crate::MAX_MESSAGE_BYTES as u32,
56            max_model_state_bytes: 256 * 1024,
57            max_snapshot_bytes: 512 * 1024,
58            max_handler_package_bytes: 4 * 1024 * 1024,
59            max_model_pack_bytes: 128 * 1024 * 1024,
60            max_features: 100_000,
61            max_pending_decisions: 1_024,
62            max_completed_decisions: 4_096,
63            max_diagnostic_records: 256,
64            request_deadline_ms: 5_000,
65            shutdown_deadline_ms: 2_000,
66            snapshot_deadline_ms: 2_000,
67            restart_backoff_ms: 100,
68        }
69    }
70}
71
72impl ResourceProfileV1 {
73    pub fn validate(&self) -> Result<(), &'static str> {
74        if self.max_ipc_frame_bytes == 0
75            || self.max_ipc_frame_bytes as usize > crate::MAX_MESSAGE_BYTES
76            || self.max_model_state_bytes == 0
77            || self.max_snapshot_bytes == 0
78            || self.max_handler_package_bytes == 0
79            || self.max_model_pack_bytes == 0
80            || self.max_features == 0
81            || self.max_pending_decisions == 0
82            || self.max_completed_decisions == 0
83            || self.max_diagnostic_records == 0
84            || self.request_deadline_ms == 0
85            || self.shutdown_deadline_ms == 0
86            || self.snapshot_deadline_ms == 0
87        {
88            return Err("resource profile contains a zero limit");
89        }
90        if self.max_completed_decisions < self.max_pending_decisions {
91            return Err("completed decision history must hold pending capacity");
92        }
93        Ok(())
94    }
95}
96
97/// Client or runtime identity carried explicitly by V3.
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99#[serde(rename_all = "camelCase", deny_unknown_fields)]
100pub struct IdentityV3 {
101    pub name: String,
102    pub version: String,
103}
104
105impl IdentityV3 {
106    /// Validate bounded identity fields.
107    pub fn validate(&self) -> Result<(), ProtocolV3Error> {
108        if self.name.is_empty() || self.name.len() > MAX_IDENTITY_NAME_LEN_V3 {
109            return Err(ProtocolV3Error::InvalidClientIdentity);
110        }
111        if self.version.is_empty() || self.version.len() > MAX_IDENTITY_VERSION_LEN_V3 {
112            return Err(ProtocolV3Error::InvalidClientIdentity);
113        }
114        Ok(())
115    }
116}
117
118/// V3 request envelope. Every stateful call carries the generations and
119/// feature schema against which the caller made its decision.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122pub struct EnvelopeV3 {
123    pub request_id: String,
124    pub api_version: u32,
125    pub client_identity: IdentityV3,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub capability: Option<String>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub deadline_unix_ms: Option<u64>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub feature_schema_hash: Option<String>,
132    pub model_generation: u64,
133    pub state_generation: u64,
134    pub payload_limit: u32,
135    pub request: RuntimeRequestV3,
136}
137
138impl EnvelopeV3 {
139    /// Validate shape, bounded strings, generation requirements and encoded
140    /// message size. Deadline expiry is checked by the runtime because the
141    /// protocol crate does not read a clock.
142    pub fn validate(&self) -> Result<(), ProtocolV3Error> {
143        if self.request_id.is_empty() || self.request_id.len() > MAX_REQUEST_ID_LEN_V3 {
144            return Err(ProtocolV3Error::InvalidRequestId);
145        }
146        if self.api_version != RUNTIME_API_VERSION_V3 {
147            return Err(ProtocolV3Error::IncompatibleApiVersion);
148        }
149        self.client_identity.validate()?;
150        if self.payload_limit == 0 || self.payload_limit as usize > crate::MAX_MESSAGE_BYTES {
151            return Err(ProtocolV3Error::InvalidPayloadLimit);
152        }
153        let is_control = matches!(
154            self.request,
155            RuntimeRequestV3::Handshake {} | RuntimeRequestV3::Health {}
156        );
157        if is_control {
158            if self.capability.is_some() {
159                return Err(ProtocolV3Error::UnexpectedCapability);
160            }
161        } else {
162            let capability = self
163                .capability
164                .as_deref()
165                .ok_or(ProtocolV3Error::MissingCapability)?;
166            if capability.is_empty() || capability.len() > MAX_CAPABILITY_LEN_V3 {
167                return Err(ProtocolV3Error::InvalidCapability);
168            }
169            validate_schema_hash(
170                self.feature_schema_hash
171                    .as_deref()
172                    .ok_or(ProtocolV3Error::MissingFeatureSchemaHash)?,
173            )?;
174        }
175        if let RuntimeRequestV3::Feedback {
176            decision_id,
177            reward,
178            ..
179        } = &self.request
180        {
181            if decision_id.is_empty() || decision_id.len() > MAX_DECISION_ID_LEN_V3 {
182                return Err(ProtocolV3Error::InvalidDecisionId);
183            }
184            if !reward.is_finite() {
185                return Err(ProtocolV3Error::NonFiniteReward);
186            }
187        }
188        let encoded = serde_json::to_vec(self).map_err(|_| ProtocolV3Error::InvalidJson)?;
189        if encoded.len() > crate::MAX_MESSAGE_BYTES || encoded.len() > self.payload_limit as usize {
190            return Err(ProtocolV3Error::PayloadTooLarge);
191        }
192        Ok(())
193    }
194
195    /// Whether the request deadline has elapsed at a caller-provided time.
196    pub fn is_expired_at(&self, now_unix_ms: u64) -> bool {
197        self.deadline_unix_ms
198            .is_some_and(|deadline| now_unix_ms > deadline)
199    }
200}
201
202/// Independent V3 method set. Payloads remain business-neutral JSON.
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
204#[serde(
205    tag = "method",
206    rename_all = "camelCase",
207    rename_all_fields = "camelCase",
208    deny_unknown_fields
209)]
210pub enum RuntimeRequestV3 {
211    Handshake {},
212    Health {},
213    Observe {
214        event: serde_json::Value,
215    },
216    Decide {
217        context: serde_json::Value,
218        #[serde(default, skip_serializing_if = "Option::is_none")]
219        deterministic_seed: Option<u64>,
220    },
221    Feedback {
222        decision_id: String,
223        selected_arm: u32,
224        reward: f64,
225        outcome_time_ms: u64,
226        generation: u64,
227    },
228    Inspect {},
229    Snapshot {},
230    Reset {
231        expected_state_generation: u64,
232    },
233}
234
235/// V3 response envelope.
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
237#[serde(rename_all = "camelCase", deny_unknown_fields)]
238pub struct RuntimeResponseV3 {
239    pub request_id: String,
240    pub api_version: u32,
241    pub runtime_identity: IdentityV3,
242    pub model_generation: u64,
243    pub state_generation: u64,
244    pub response: RuntimeResponseBodyV3,
245}
246
247impl RuntimeResponseV3 {
248    /// Validate all bounded response fields and the encoded size.
249    pub fn validate(&self) -> Result<(), ProtocolV3Error> {
250        if self.request_id.is_empty() || self.request_id.len() > MAX_REQUEST_ID_LEN_V3 {
251            return Err(ProtocolV3Error::InvalidRequestId);
252        }
253        if self.api_version != RUNTIME_API_VERSION_V3 {
254            return Err(ProtocolV3Error::IncompatibleApiVersion);
255        }
256        self.runtime_identity.validate()?;
257        match &self.response {
258            RuntimeResponseBodyV3::Handshake { capabilities, .. } => {
259                validate_capabilities(capabilities)?;
260            }
261            RuntimeResponseBodyV3::Error { error } => error.validate()?,
262            _ => {}
263        }
264        let encoded = serde_json::to_vec(self).map_err(|_| ProtocolV3Error::InvalidJson)?;
265        if encoded.len() > crate::MAX_MESSAGE_BYTES {
266            return Err(ProtocolV3Error::PayloadTooLarge);
267        }
268        Ok(())
269    }
270}
271
272/// V3 response payloads.
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
274#[serde(
275    tag = "kind",
276    rename_all = "camelCase",
277    rename_all_fields = "camelCase",
278    deny_unknown_fields
279)]
280pub enum RuntimeResponseBodyV3 {
281    Handshake {
282        capabilities: Vec<String>,
283        feature_schema_hash: String,
284        handler_api_version: u32,
285    },
286    Health {
287        healthy: bool,
288    },
289    Result {
290        output: serde_json::Value,
291    },
292    Inspection {
293        summary: serde_json::Value,
294    },
295    Snapshot {
296        state_schema_version: u32,
297        state_checksum: String,
298        state: String,
299    },
300    Reset {
301        reset: bool,
302    },
303    Error {
304        error: RuntimeErrorV3,
305    },
306}
307
308/// Additive response surface for the opt-in Preview subprocess. The original
309/// `RuntimeResponseV3` remains frozen; this type carries channel, health and
310/// decision metadata without changing its public enum variants.
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312#[serde(rename_all = "camelCase", deny_unknown_fields)]
313pub struct RuntimeResponseV3Preview {
314    pub request_id: String,
315    pub api_version: u32,
316    pub runtime_identity: IdentityV3,
317    pub model_generation: u64,
318    pub state_generation: u64,
319    pub response: RuntimeResponseBodyV3Preview,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
323#[serde(
324    tag = "kind",
325    rename_all = "camelCase",
326    rename_all_fields = "camelCase",
327    deny_unknown_fields
328)]
329pub enum RuntimeResponseBodyV3Preview {
330    Handshake {
331        capabilities: Vec<String>,
332        feature_schema_hash: String,
333        handler_api_version: u32,
334        channel: String,
335    },
336    Health {
337        healthy: bool,
338        status: String,
339        #[serde(default, skip_serializing_if = "Vec::is_empty")]
340        reason_codes: Vec<String>,
341    },
342    Result {
343        output: serde_json::Value,
344        #[serde(default, skip_serializing_if = "Option::is_none")]
345        decision_id: Option<String>,
346        #[serde(default, skip_serializing_if = "Option::is_none")]
347        decision_generation: Option<u64>,
348    },
349    Inspection {
350        summary: serde_json::Value,
351    },
352    Snapshot {
353        state_schema_version: u32,
354        state_checksum: String,
355        state: String,
356    },
357    Reset {
358        reset: bool,
359    },
360    Error {
361        error: RuntimeErrorV3Preview,
362    },
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
366#[serde(rename_all = "camelCase", deny_unknown_fields)]
367pub struct RuntimeErrorV3Preview {
368    pub code: PreviewErrorCodeV3,
369    pub message: String,
370    pub retryable: bool,
371}
372
373#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
374#[serde(rename_all = "camelCase")]
375pub enum PreviewErrorCodeV3 {
376    InvalidJson,
377    InvalidEnvelope,
378    PayloadTooLarge,
379    UnsupportedCapability,
380    StateMismatch,
381    ExpiredRequest,
382    IncompatibleGeneration,
383    DuplicateDecision,
384    DuplicateFeedback,
385    UnknownDecision,
386    StaleFeedback,
387    CapacityExceeded,
388    HandlerTimeout,
389    HandlerTrap,
390    HandlerOutputTooLarge,
391    HandlerInvalidOutput,
392    InvalidState,
393    Internal,
394}
395
396impl PreviewErrorCodeV3 {
397    pub const fn is_retryable(self) -> bool {
398        matches!(
399            self,
400            Self::StateMismatch
401                | Self::ExpiredRequest
402                | Self::HandlerTimeout
403                | Self::CapacityExceeded
404                | Self::Internal
405        )
406    }
407}
408
409/// V3 error object. Code semantics are versioned with V3 and do not alter the
410/// frozen v1/v2 error-code allowlist.
411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
412#[serde(rename_all = "camelCase", deny_unknown_fields)]
413pub struct RuntimeErrorV3 {
414    pub code: RuntimeErrorCodeV3,
415    pub message: String,
416    pub retryable: bool,
417}
418
419impl RuntimeErrorV3 {
420    /// Construct an error with the canonical retryability for its code.
421    pub fn new(code: RuntimeErrorCodeV3, message: impl Into<String>) -> Self {
422        Self {
423            retryable: code.is_retryable(),
424            code,
425            message: message.into(),
426        }
427    }
428
429    pub fn validate(&self) -> Result<(), ProtocolV3Error> {
430        if self.message.is_empty() || self.message.len() > MAX_ERROR_MESSAGE_LEN_V3 {
431            return Err(ProtocolV3Error::InvalidErrorMessage);
432        }
433        if self.retryable != self.code.is_retryable() {
434            return Err(ProtocolV3Error::InvalidRetryability);
435        }
436        Ok(())
437    }
438}
439
440/// Exhaustive V3 error code set.
441#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
442#[serde(rename_all = "camelCase")]
443pub enum RuntimeErrorCodeV3 {
444    InvalidJson,
445    InvalidRequestId,
446    InvalidClientIdentity,
447    IncompatibleApiVersion,
448    InvalidEnvelope,
449    PayloadTooLarge,
450    UnsupportedCapability,
451    StateMismatch,
452    ExpiredRequest,
453    IncompatibleGeneration,
454    DuplicateFeedback,
455    HandlerTimeout,
456    HandlerTrap,
457    HandlerOutputTooLarge,
458    HandlerInvalidOutput,
459    InvalidState,
460    Internal,
461}
462
463impl RuntimeErrorCodeV3 {
464    pub const fn is_retryable(self) -> bool {
465        matches!(
466            self,
467            Self::StateMismatch | Self::HandlerTimeout | Self::Internal
468        )
469    }
470}
471
472/// Shape validation failures before runtime execution.
473#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474#[non_exhaustive]
475pub enum ProtocolV3Error {
476    InvalidJson,
477    InvalidRequestId,
478    InvalidClientIdentity,
479    IncompatibleApiVersion,
480    InvalidPayloadLimit,
481    PayloadTooLarge,
482    MissingCapability,
483    UnexpectedCapability,
484    InvalidCapability,
485    MissingFeatureSchemaHash,
486    InvalidFeatureSchemaHash,
487    InvalidDecisionId,
488    NonFiniteReward,
489    InvalidCapabilities,
490    InvalidErrorMessage,
491    InvalidRetryability,
492}
493
494impl std::fmt::Display for ProtocolV3Error {
495    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496        write!(
497            f,
498            "{}",
499            match self {
500                Self::InvalidJson => "invalid JSON",
501                Self::InvalidRequestId => "invalid request id",
502                Self::InvalidClientIdentity => "invalid client identity",
503                Self::IncompatibleApiVersion => "incompatible API version",
504                Self::InvalidPayloadLimit => "invalid payload limit",
505                Self::PayloadTooLarge => "payload too large",
506                Self::MissingCapability => "missing capability",
507                Self::UnexpectedCapability => "unexpected capability",
508                Self::InvalidCapability => "invalid capability",
509                Self::MissingFeatureSchemaHash => "missing feature schema hash",
510                Self::InvalidFeatureSchemaHash => "invalid feature schema hash",
511                Self::InvalidDecisionId => "invalid decision id",
512                Self::NonFiniteReward => "reward must be finite",
513                Self::InvalidCapabilities => "invalid capabilities",
514                Self::InvalidErrorMessage => "invalid error message",
515                Self::InvalidRetryability => "retryable flag does not match error code",
516            }
517        )
518    }
519}
520
521impl std::error::Error for ProtocolV3Error {}
522
523fn validate_schema_hash(hash: &str) -> Result<(), ProtocolV3Error> {
524    if hash.len() != FEATURE_SCHEMA_HASH_LEN_V3
525        || !hash
526            .bytes()
527            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
528    {
529        return Err(ProtocolV3Error::InvalidFeatureSchemaHash);
530    }
531    Ok(())
532}
533
534fn validate_capabilities(capabilities: &[String]) -> Result<(), ProtocolV3Error> {
535    if capabilities.is_empty() || capabilities.len() > MAX_CAPABILITIES_V3 {
536        return Err(ProtocolV3Error::InvalidCapabilities);
537    }
538    let mut seen = std::collections::BTreeSet::new();
539    if capabilities.iter().any(|capability| {
540        capability.is_empty()
541            || capability.len() > MAX_CAPABILITY_LEN_V3
542            || !seen.insert(capability)
543    }) {
544        return Err(ProtocolV3Error::InvalidCapabilities);
545    }
546    Ok(())
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    fn decide() -> EnvelopeV3 {
554        EnvelopeV3 {
555            request_id: "decision-1".into(),
556            api_version: RUNTIME_API_VERSION_V3,
557            client_identity: IdentityV3 {
558                name: "example-host".into(),
559                version: "1.0.0".into(),
560            },
561            capability: Some("org.example.route.decide".into()),
562            deadline_unix_ms: Some(10_000),
563            feature_schema_hash: Some("ab".repeat(32)),
564            model_generation: 7,
565            state_generation: 9,
566            payload_limit: crate::MAX_MESSAGE_BYTES as u32,
567            request: RuntimeRequestV3::Decide {
568                context: serde_json::json!({"features": [1.0, 2.0]}),
569                deterministic_seed: Some(42),
570            },
571        }
572    }
573
574    #[test]
575    fn decide_envelope_roundtrips_and_validates() {
576        let envelope = decide();
577        envelope.validate().unwrap();
578        let json = serde_json::to_string(&envelope).unwrap();
579        let restored: EnvelopeV3 = serde_json::from_str(&json).unwrap();
580        assert_eq!(restored, envelope);
581    }
582
583    #[test]
584    fn v3_rejects_unknown_fields() {
585        let mut value = serde_json::to_value(decide()).unwrap();
586        value["unknown"] = serde_json::json!(true);
587        assert!(serde_json::from_value::<EnvelopeV3>(value).is_err());
588    }
589
590    #[test]
591    fn v3_rejects_bad_hash_and_expired_deadline() {
592        let mut envelope = decide();
593        envelope.feature_schema_hash = Some("ABC".into());
594        assert_eq!(
595            envelope.validate(),
596            Err(ProtocolV3Error::InvalidFeatureSchemaHash)
597        );
598        envelope.feature_schema_hash = Some("ab".repeat(32));
599        assert!(!envelope.is_expired_at(10_000));
600        assert!(envelope.is_expired_at(10_001));
601    }
602
603    #[test]
604    fn v3_rejects_payload_over_declared_limit() {
605        let mut envelope = decide();
606        envelope.payload_limit = 128;
607        assert_eq!(envelope.validate(), Err(ProtocolV3Error::PayloadTooLarge));
608    }
609
610    #[test]
611    fn v3_error_retryability_is_canonical() {
612        assert!(RuntimeErrorV3::new(RuntimeErrorCodeV3::HandlerTimeout, "timeout").retryable);
613        assert!(!RuntimeErrorV3::new(RuntimeErrorCodeV3::DuplicateFeedback, "duplicate").retryable);
614        let invalid = RuntimeErrorV3 {
615            code: RuntimeErrorCodeV3::HandlerTimeout,
616            message: "timeout".into(),
617            retryable: false,
618        };
619        assert_eq!(
620            invalid.validate(),
621            Err(ProtocolV3Error::InvalidRetryability)
622        );
623    }
624}