1use serde::{Deserialize, Serialize};
10
11pub const RUNTIME_API_VERSION_V3: u32 = 3;
13pub const MAX_REQUEST_ID_LEN_V3: usize = 128;
15pub const MAX_IDENTITY_NAME_LEN_V3: usize = 96;
17pub const MAX_IDENTITY_VERSION_LEN_V3: usize = 48;
19pub const MAX_CAPABILITY_LEN_V3: usize = 96;
21pub const MAX_DECISION_ID_LEN_V3: usize = 128;
23pub const FEATURE_SCHEMA_HASH_LEN_V3: usize = 64;
25pub const MAX_CAPABILITIES_V3: usize = 32;
27pub const MAX_ERROR_MESSAGE_LEN_V3: usize = 512;
29
30#[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 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#[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 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 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#[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#[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 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#[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#[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 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#[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#[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}