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
30pub const PREVIEW_CHANNEL_V3: &str = "preview";
32
33#[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#[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 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#[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 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 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#[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#[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 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#[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#[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#[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 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#[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#[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}