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/// Client or runtime identity carried explicitly by V3.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "camelCase", deny_unknown_fields)]
33pub struct IdentityV3 {
34    pub name: String,
35    pub version: String,
36}
37
38impl IdentityV3 {
39    /// Validate bounded identity fields.
40    pub fn validate(&self) -> Result<(), ProtocolV3Error> {
41        if self.name.is_empty() || self.name.len() > MAX_IDENTITY_NAME_LEN_V3 {
42            return Err(ProtocolV3Error::InvalidClientIdentity);
43        }
44        if self.version.is_empty() || self.version.len() > MAX_IDENTITY_VERSION_LEN_V3 {
45            return Err(ProtocolV3Error::InvalidClientIdentity);
46        }
47        Ok(())
48    }
49}
50
51/// V3 request envelope. Every stateful call carries the generations and
52/// feature schema against which the caller made its decision.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54#[serde(rename_all = "camelCase", deny_unknown_fields)]
55pub struct EnvelopeV3 {
56    pub request_id: String,
57    pub api_version: u32,
58    pub client_identity: IdentityV3,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub capability: Option<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub deadline_unix_ms: Option<u64>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub feature_schema_hash: Option<String>,
65    pub model_generation: u64,
66    pub state_generation: u64,
67    pub payload_limit: u32,
68    pub request: RuntimeRequestV3,
69}
70
71impl EnvelopeV3 {
72    /// Validate shape, bounded strings, generation requirements and encoded
73    /// message size. Deadline expiry is checked by the runtime because the
74    /// protocol crate does not read a clock.
75    pub fn validate(&self) -> Result<(), ProtocolV3Error> {
76        if self.request_id.is_empty() || self.request_id.len() > MAX_REQUEST_ID_LEN_V3 {
77            return Err(ProtocolV3Error::InvalidRequestId);
78        }
79        if self.api_version != RUNTIME_API_VERSION_V3 {
80            return Err(ProtocolV3Error::IncompatibleApiVersion);
81        }
82        self.client_identity.validate()?;
83        if self.payload_limit == 0 || self.payload_limit as usize > crate::MAX_MESSAGE_BYTES {
84            return Err(ProtocolV3Error::InvalidPayloadLimit);
85        }
86        let is_control = matches!(
87            self.request,
88            RuntimeRequestV3::Handshake {} | RuntimeRequestV3::Health {}
89        );
90        if is_control {
91            if self.capability.is_some() {
92                return Err(ProtocolV3Error::UnexpectedCapability);
93            }
94        } else {
95            let capability = self
96                .capability
97                .as_deref()
98                .ok_or(ProtocolV3Error::MissingCapability)?;
99            if capability.is_empty() || capability.len() > MAX_CAPABILITY_LEN_V3 {
100                return Err(ProtocolV3Error::InvalidCapability);
101            }
102            validate_schema_hash(
103                self.feature_schema_hash
104                    .as_deref()
105                    .ok_or(ProtocolV3Error::MissingFeatureSchemaHash)?,
106            )?;
107        }
108        if let RuntimeRequestV3::Feedback {
109            decision_id,
110            reward,
111            ..
112        } = &self.request
113        {
114            if decision_id.is_empty() || decision_id.len() > MAX_DECISION_ID_LEN_V3 {
115                return Err(ProtocolV3Error::InvalidDecisionId);
116            }
117            if !reward.is_finite() {
118                return Err(ProtocolV3Error::NonFiniteReward);
119            }
120        }
121        let encoded = serde_json::to_vec(self).map_err(|_| ProtocolV3Error::InvalidJson)?;
122        if encoded.len() > crate::MAX_MESSAGE_BYTES || encoded.len() > self.payload_limit as usize {
123            return Err(ProtocolV3Error::PayloadTooLarge);
124        }
125        Ok(())
126    }
127
128    /// Whether the request deadline has elapsed at a caller-provided time.
129    pub fn is_expired_at(&self, now_unix_ms: u64) -> bool {
130        self.deadline_unix_ms
131            .is_some_and(|deadline| now_unix_ms > deadline)
132    }
133}
134
135/// Independent V3 method set. Payloads remain business-neutral JSON.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137#[serde(
138    tag = "method",
139    rename_all = "camelCase",
140    rename_all_fields = "camelCase",
141    deny_unknown_fields
142)]
143pub enum RuntimeRequestV3 {
144    Handshake {},
145    Health {},
146    Observe {
147        event: serde_json::Value,
148    },
149    Decide {
150        context: serde_json::Value,
151        #[serde(default, skip_serializing_if = "Option::is_none")]
152        deterministic_seed: Option<u64>,
153    },
154    Feedback {
155        decision_id: String,
156        selected_arm: u32,
157        reward: f64,
158        outcome_time_ms: u64,
159        generation: u64,
160    },
161    Inspect {},
162    Snapshot {},
163    Reset {
164        expected_state_generation: u64,
165    },
166}
167
168/// V3 response envelope.
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170#[serde(rename_all = "camelCase", deny_unknown_fields)]
171pub struct RuntimeResponseV3 {
172    pub request_id: String,
173    pub api_version: u32,
174    pub runtime_identity: IdentityV3,
175    pub model_generation: u64,
176    pub state_generation: u64,
177    pub response: RuntimeResponseBodyV3,
178}
179
180impl RuntimeResponseV3 {
181    /// Validate all bounded response fields and the encoded size.
182    pub fn validate(&self) -> Result<(), ProtocolV3Error> {
183        if self.request_id.is_empty() || self.request_id.len() > MAX_REQUEST_ID_LEN_V3 {
184            return Err(ProtocolV3Error::InvalidRequestId);
185        }
186        if self.api_version != RUNTIME_API_VERSION_V3 {
187            return Err(ProtocolV3Error::IncompatibleApiVersion);
188        }
189        self.runtime_identity.validate()?;
190        match &self.response {
191            RuntimeResponseBodyV3::Handshake { capabilities, .. } => {
192                validate_capabilities(capabilities)?;
193            }
194            RuntimeResponseBodyV3::Error { error } => error.validate()?,
195            _ => {}
196        }
197        let encoded = serde_json::to_vec(self).map_err(|_| ProtocolV3Error::InvalidJson)?;
198        if encoded.len() > crate::MAX_MESSAGE_BYTES {
199            return Err(ProtocolV3Error::PayloadTooLarge);
200        }
201        Ok(())
202    }
203}
204
205/// V3 response payloads.
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
207#[serde(
208    tag = "kind",
209    rename_all = "camelCase",
210    rename_all_fields = "camelCase",
211    deny_unknown_fields
212)]
213pub enum RuntimeResponseBodyV3 {
214    Handshake {
215        capabilities: Vec<String>,
216        feature_schema_hash: String,
217        handler_api_version: u32,
218    },
219    Health {
220        healthy: bool,
221    },
222    Result {
223        output: serde_json::Value,
224    },
225    Inspection {
226        summary: serde_json::Value,
227    },
228    Snapshot {
229        state_schema_version: u32,
230        state_checksum: String,
231        state: String,
232    },
233    Reset {
234        reset: bool,
235    },
236    Error {
237        error: RuntimeErrorV3,
238    },
239}
240
241/// V3 error object. Code semantics are versioned with V3 and do not alter the
242/// frozen v1/v2 error-code allowlist.
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
244#[serde(rename_all = "camelCase", deny_unknown_fields)]
245pub struct RuntimeErrorV3 {
246    pub code: RuntimeErrorCodeV3,
247    pub message: String,
248    pub retryable: bool,
249}
250
251impl RuntimeErrorV3 {
252    /// Construct an error with the canonical retryability for its code.
253    pub fn new(code: RuntimeErrorCodeV3, message: impl Into<String>) -> Self {
254        Self {
255            retryable: code.is_retryable(),
256            code,
257            message: message.into(),
258        }
259    }
260
261    pub fn validate(&self) -> Result<(), ProtocolV3Error> {
262        if self.message.is_empty() || self.message.len() > MAX_ERROR_MESSAGE_LEN_V3 {
263            return Err(ProtocolV3Error::InvalidErrorMessage);
264        }
265        if self.retryable != self.code.is_retryable() {
266            return Err(ProtocolV3Error::InvalidRetryability);
267        }
268        Ok(())
269    }
270}
271
272/// Exhaustive V3 error code set.
273#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
274#[serde(rename_all = "camelCase")]
275pub enum RuntimeErrorCodeV3 {
276    InvalidJson,
277    InvalidRequestId,
278    InvalidClientIdentity,
279    IncompatibleApiVersion,
280    InvalidEnvelope,
281    PayloadTooLarge,
282    UnsupportedCapability,
283    StateMismatch,
284    ExpiredRequest,
285    IncompatibleGeneration,
286    DuplicateFeedback,
287    HandlerTimeout,
288    HandlerTrap,
289    HandlerOutputTooLarge,
290    HandlerInvalidOutput,
291    InvalidState,
292    Internal,
293}
294
295impl RuntimeErrorCodeV3 {
296    pub const fn is_retryable(self) -> bool {
297        matches!(
298            self,
299            Self::StateMismatch | Self::HandlerTimeout | Self::Internal
300        )
301    }
302}
303
304/// Shape validation failures before runtime execution.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306#[non_exhaustive]
307pub enum ProtocolV3Error {
308    InvalidJson,
309    InvalidRequestId,
310    InvalidClientIdentity,
311    IncompatibleApiVersion,
312    InvalidPayloadLimit,
313    PayloadTooLarge,
314    MissingCapability,
315    UnexpectedCapability,
316    InvalidCapability,
317    MissingFeatureSchemaHash,
318    InvalidFeatureSchemaHash,
319    InvalidDecisionId,
320    NonFiniteReward,
321    InvalidCapabilities,
322    InvalidErrorMessage,
323    InvalidRetryability,
324}
325
326impl std::fmt::Display for ProtocolV3Error {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        write!(
329            f,
330            "{}",
331            match self {
332                Self::InvalidJson => "invalid JSON",
333                Self::InvalidRequestId => "invalid request id",
334                Self::InvalidClientIdentity => "invalid client identity",
335                Self::IncompatibleApiVersion => "incompatible API version",
336                Self::InvalidPayloadLimit => "invalid payload limit",
337                Self::PayloadTooLarge => "payload too large",
338                Self::MissingCapability => "missing capability",
339                Self::UnexpectedCapability => "unexpected capability",
340                Self::InvalidCapability => "invalid capability",
341                Self::MissingFeatureSchemaHash => "missing feature schema hash",
342                Self::InvalidFeatureSchemaHash => "invalid feature schema hash",
343                Self::InvalidDecisionId => "invalid decision id",
344                Self::NonFiniteReward => "reward must be finite",
345                Self::InvalidCapabilities => "invalid capabilities",
346                Self::InvalidErrorMessage => "invalid error message",
347                Self::InvalidRetryability => "retryable flag does not match error code",
348            }
349        )
350    }
351}
352
353impl std::error::Error for ProtocolV3Error {}
354
355fn validate_schema_hash(hash: &str) -> Result<(), ProtocolV3Error> {
356    if hash.len() != FEATURE_SCHEMA_HASH_LEN_V3
357        || !hash
358            .bytes()
359            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
360    {
361        return Err(ProtocolV3Error::InvalidFeatureSchemaHash);
362    }
363    Ok(())
364}
365
366fn validate_capabilities(capabilities: &[String]) -> Result<(), ProtocolV3Error> {
367    if capabilities.is_empty() || capabilities.len() > MAX_CAPABILITIES_V3 {
368        return Err(ProtocolV3Error::InvalidCapabilities);
369    }
370    let mut seen = std::collections::BTreeSet::new();
371    if capabilities.iter().any(|capability| {
372        capability.is_empty()
373            || capability.len() > MAX_CAPABILITY_LEN_V3
374            || !seen.insert(capability)
375    }) {
376        return Err(ProtocolV3Error::InvalidCapabilities);
377    }
378    Ok(())
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    fn decide() -> EnvelopeV3 {
386        EnvelopeV3 {
387            request_id: "decision-1".into(),
388            api_version: RUNTIME_API_VERSION_V3,
389            client_identity: IdentityV3 {
390                name: "example-host".into(),
391                version: "1.0.0".into(),
392            },
393            capability: Some("org.example.route.decide".into()),
394            deadline_unix_ms: Some(10_000),
395            feature_schema_hash: Some("ab".repeat(32)),
396            model_generation: 7,
397            state_generation: 9,
398            payload_limit: crate::MAX_MESSAGE_BYTES as u32,
399            request: RuntimeRequestV3::Decide {
400                context: serde_json::json!({"features": [1.0, 2.0]}),
401                deterministic_seed: Some(42),
402            },
403        }
404    }
405
406    #[test]
407    fn decide_envelope_roundtrips_and_validates() {
408        let envelope = decide();
409        envelope.validate().unwrap();
410        let json = serde_json::to_string(&envelope).unwrap();
411        let restored: EnvelopeV3 = serde_json::from_str(&json).unwrap();
412        assert_eq!(restored, envelope);
413    }
414
415    #[test]
416    fn v3_rejects_unknown_fields() {
417        let mut value = serde_json::to_value(decide()).unwrap();
418        value["unknown"] = serde_json::json!(true);
419        assert!(serde_json::from_value::<EnvelopeV3>(value).is_err());
420    }
421
422    #[test]
423    fn v3_rejects_bad_hash_and_expired_deadline() {
424        let mut envelope = decide();
425        envelope.feature_schema_hash = Some("ABC".into());
426        assert_eq!(
427            envelope.validate(),
428            Err(ProtocolV3Error::InvalidFeatureSchemaHash)
429        );
430        envelope.feature_schema_hash = Some("ab".repeat(32));
431        assert!(!envelope.is_expired_at(10_000));
432        assert!(envelope.is_expired_at(10_001));
433    }
434
435    #[test]
436    fn v3_rejects_payload_over_declared_limit() {
437        let mut envelope = decide();
438        envelope.payload_limit = 128;
439        assert_eq!(envelope.validate(), Err(ProtocolV3Error::PayloadTooLarge));
440    }
441
442    #[test]
443    fn v3_error_retryability_is_canonical() {
444        assert!(RuntimeErrorV3::new(RuntimeErrorCodeV3::HandlerTimeout, "timeout").retryable);
445        assert!(!RuntimeErrorV3::new(RuntimeErrorCodeV3::DuplicateFeedback, "duplicate").retryable);
446        let invalid = RuntimeErrorV3 {
447            code: RuntimeErrorCodeV3::HandlerTimeout,
448            message: "timeout".into(),
449            retryable: false,
450        };
451        assert_eq!(
452            invalid.validate(),
453            Err(ProtocolV3Error::InvalidRetryability)
454        );
455    }
456}