1use std::sync::{Arc, Mutex};
7
8use rill_handler_api::v2::{
9 HANDLER_API_VERSION, MAX_EVENT_BYTES, MAX_OUTPUT_BYTES, MAX_STATE_BYTES,
10};
11use rill_runtime_protocol::v3::{
12 EnvelopeV3, IdentityV3, RUNTIME_API_VERSION_V3, RuntimeErrorCodeV3, RuntimeErrorV3,
13 RuntimeRequestV3, RuntimeResponseBodyV3, RuntimeResponseV3,
14};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18const MAX_HANDLER_DETAIL_BYTES_V2: usize = 4 * 1024;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct StatefulHandlerMetadataV2 {
23 pub id: String,
24 pub version: String,
25 pub api_version: u32,
26 pub capabilities: Vec<String>,
27 pub state_schema_version: u32,
28}
29
30impl StatefulHandlerMetadataV2 {
31 fn validate(&self) -> Result<(), StatefulHandlerErrorV2> {
32 if self.id.is_empty() || self.id.len() > rill_handler_api::MAX_HANDLER_ID_LEN {
33 return Err(StatefulHandlerErrorV2::new(
34 StatefulHandlerErrorKindV2::MetadataMismatch,
35 ));
36 }
37 if self.version.is_empty()
38 || self.version.len() > rill_handler_api::MAX_HANDLER_VERSION_LEN
39 || self.api_version != HANDLER_API_VERSION
40 || self.state_schema_version == 0
41 {
42 return Err(StatefulHandlerErrorV2::new(
43 StatefulHandlerErrorKindV2::MetadataMismatch,
44 ));
45 }
46 if self.capabilities.is_empty()
47 || self.capabilities.len() > rill_handler_api::MAX_CAPABILITIES
48 {
49 return Err(StatefulHandlerErrorV2::new(
50 StatefulHandlerErrorKindV2::MetadataMismatch,
51 ));
52 }
53 let mut seen = std::collections::BTreeSet::new();
54 if self.capabilities.iter().any(|capability| {
55 capability.is_empty()
56 || capability.len() > rill_handler_api::MAX_CAPABILITY_LEN
57 || !seen.insert(capability)
58 }) {
59 return Err(StatefulHandlerErrorV2::new(
60 StatefulHandlerErrorKindV2::MetadataMismatch,
61 ));
62 }
63 Ok(())
64 }
65}
66
67#[derive(Debug, Clone, PartialEq)]
70pub struct StatefulHandlerResultV2 {
71 pub output: serde_json::Value,
72 pub next_state: Vec<u8>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78#[non_exhaustive]
79pub enum StatefulHandlerErrorKindV2 {
80 InvalidModel,
81 InvalidEvent,
82 InvalidState,
83 IncompatibleVersion,
84 DuplicateFeedback,
85 Timeout,
86 Trap,
87 OutputTooLarge,
88 InvalidOutput,
89 MetadataMismatch,
90 Internal,
91}
92
93#[derive(Debug, Clone)]
95pub struct StatefulHandlerErrorV2 {
96 kind: StatefulHandlerErrorKindV2,
97 detail: Option<String>,
98}
99
100impl StatefulHandlerErrorV2 {
101 pub const fn new(kind: StatefulHandlerErrorKindV2) -> Self {
102 Self { kind, detail: None }
103 }
104
105 pub fn with_detail(kind: StatefulHandlerErrorKindV2, detail: impl Into<String>) -> Self {
106 let mut detail = detail.into();
107 if detail.len() > MAX_HANDLER_DETAIL_BYTES_V2 {
108 let mut end = MAX_HANDLER_DETAIL_BYTES_V2;
109 while end > 0 && !detail.is_char_boundary(end) {
110 end -= 1;
111 }
112 detail.truncate(end);
113 }
114 Self {
115 kind,
116 detail: Some(detail),
117 }
118 }
119
120 pub const fn kind(&self) -> StatefulHandlerErrorKindV2 {
121 self.kind
122 }
123
124 pub fn detail(&self) -> Option<&str> {
125 self.detail.as_deref()
126 }
127}
128
129impl std::fmt::Display for StatefulHandlerErrorV2 {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 write!(f, "stateful handler {:?}", self.kind)?;
132 if let Some(detail) = &self.detail {
133 write!(f, ": {detail}")?;
134 }
135 Ok(())
136 }
137}
138
139impl std::error::Error for StatefulHandlerErrorV2 {}
140
141pub trait StatefulHandlerV2: Send + Sync + std::fmt::Debug {
145 fn metadata(&self) -> &StatefulHandlerMetadataV2;
146
147 fn handle(
148 &self,
149 event_json: &[u8],
150 current_state: &[u8],
151 deterministic_seed: Option<u64>,
152 ) -> Result<StatefulHandlerResultV2, StatefulHandlerErrorV2>;
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
158#[serde(rename_all = "camelCase", deny_unknown_fields)]
159pub struct StatefulStateSnapshotV2 {
160 pub state_schema_version: u32,
161 pub state_generation: u64,
162 pub state: Vec<u8>,
163 pub checksum_sha256: String,
164}
165
166impl StatefulStateSnapshotV2 {
167 pub fn new(state_schema_version: u32, state_generation: u64, state: Vec<u8>) -> Self {
168 let checksum_sha256 = state_checksum(&state);
169 Self {
170 state_schema_version,
171 state_generation,
172 state,
173 checksum_sha256,
174 }
175 }
176
177 pub fn validate(&self, expected_schema_version: u32) -> Result<(), StatefulHandlerErrorV2> {
178 validate_state_bytes(
179 &self.state,
180 self.state_schema_version,
181 expected_schema_version,
182 )?;
183 if self.checksum_sha256 != state_checksum(&self.state) {
184 return Err(StatefulHandlerErrorV2::new(
185 StatefulHandlerErrorKindV2::InvalidState,
186 ));
187 }
188 Ok(())
189 }
190}
191
192#[derive(Debug, Clone)]
194#[non_exhaustive]
195pub struct StatefulRuntimeConfigV3 {
196 pub runtime_identity: IdentityV3,
197 pub model_generation: u64,
198 pub initial_state_generation: u64,
199 pub feature_schema_hash: String,
200 pub capabilities: Vec<String>,
201 pub initial_state: Vec<u8>,
202}
203
204impl StatefulRuntimeConfigV3 {
205 pub fn new(
206 runtime_identity: IdentityV3,
207 model_generation: u64,
208 feature_schema_hash: String,
209 capabilities: Vec<String>,
210 initial_state: Vec<u8>,
211 ) -> Self {
212 Self {
213 runtime_identity,
214 model_generation,
215 initial_state_generation: 0,
216 feature_schema_hash,
217 capabilities,
218 initial_state,
219 }
220 }
221}
222
223#[derive(Debug, Clone)]
224struct RuntimeStateV3 {
225 snapshot: StatefulStateSnapshotV2,
226}
227
228#[derive(Debug)]
230pub struct StatefulRuntimeEngineV3 {
231 config: StatefulRuntimeConfigV3,
232 metadata: StatefulHandlerMetadataV2,
233 handler: Arc<dyn StatefulHandlerV2>,
234 state: Mutex<RuntimeStateV3>,
235}
236
237impl StatefulRuntimeEngineV3 {
238 pub fn new(
239 config: StatefulRuntimeConfigV3,
240 handler: Arc<dyn StatefulHandlerV2>,
241 ) -> Result<Self, StatefulHandlerErrorV2> {
242 let metadata = handler.metadata().clone();
243 metadata.validate()?;
244 config.runtime_identity.validate().map_err(|error| {
245 StatefulHandlerErrorV2::with_detail(
246 StatefulHandlerErrorKindV2::InvalidModel,
247 error.to_string(),
248 )
249 })?;
250 validate_feature_schema_hash(&config.feature_schema_hash)?;
251 validate_capabilities(&config.capabilities)?;
252 if config.capabilities != metadata.capabilities {
253 return Err(StatefulHandlerErrorV2::new(
254 StatefulHandlerErrorKindV2::MetadataMismatch,
255 ));
256 }
257 validate_state_bytes(
258 &config.initial_state,
259 metadata.state_schema_version,
260 metadata.state_schema_version,
261 )?;
262 let snapshot = StatefulStateSnapshotV2::new(
263 metadata.state_schema_version,
264 config.initial_state_generation,
265 config.initial_state.clone(),
266 );
267 Ok(Self {
268 config,
269 metadata,
270 handler,
271 state: Mutex::new(RuntimeStateV3 { snapshot }),
272 })
273 }
274
275 pub fn restore_snapshot(
277 &self,
278 snapshot: StatefulStateSnapshotV2,
279 ) -> Result<(), StatefulHandlerErrorV2> {
280 snapshot.validate(self.metadata.state_schema_version)?;
281 let mut state = self
282 .state
283 .lock()
284 .map_err(|_| StatefulHandlerErrorV2::new(StatefulHandlerErrorKindV2::Internal))?;
285 state.snapshot = snapshot;
286 Ok(())
287 }
288
289 pub fn snapshot(&self) -> Result<StatefulStateSnapshotV2, StatefulHandlerErrorV2> {
290 self.state
291 .lock()
292 .map(|state| state.snapshot.clone())
293 .map_err(|_| StatefulHandlerErrorV2::new(StatefulHandlerErrorKindV2::Internal))
294 }
295
296 pub fn handle_at(&self, envelope: EnvelopeV3, now_unix_ms: u64) -> RuntimeResponseV3 {
299 if let Err(error) = envelope.validate() {
300 return self.error_response(
301 envelope.request_id,
302 RuntimeErrorCodeV3::InvalidEnvelope,
303 error.to_string(),
304 self.current_generation(),
305 );
306 }
307 if envelope.is_expired_at(now_unix_ms) {
308 return self.error_response(
309 envelope.request_id,
310 RuntimeErrorCodeV3::ExpiredRequest,
311 "request deadline has expired",
312 self.current_generation(),
313 );
314 }
315
316 match envelope.request.clone() {
317 RuntimeRequestV3::Handshake {} => self.response(
318 envelope.request_id,
319 self.current_generation(),
320 RuntimeResponseBodyV3::Handshake {
321 capabilities: self.config.capabilities.clone(),
322 feature_schema_hash: self.config.feature_schema_hash.clone(),
323 handler_api_version: HANDLER_API_VERSION,
324 },
325 ),
326 RuntimeRequestV3::Health {} => self.response(
327 envelope.request_id,
328 self.current_generation(),
329 RuntimeResponseBodyV3::Health { healthy: true },
330 ),
331 request => self.handle_stateful(envelope, request),
332 }
333 }
334
335 pub fn handle_json_at(&self, message: &[u8], now_unix_ms: u64) -> RuntimeResponseV3 {
339 if message.len() > rill_runtime_protocol::MAX_MESSAGE_BYTES {
340 return self.error_response(
341 "invalid-request".into(),
342 RuntimeErrorCodeV3::PayloadTooLarge,
343 "request exceeds the IPC message limit",
344 self.current_generation(),
345 );
346 }
347 let envelope = match serde_json::from_slice::<EnvelopeV3>(message) {
348 Ok(envelope) => envelope,
349 Err(_) => {
350 return self.error_response(
351 "invalid-request".into(),
352 RuntimeErrorCodeV3::InvalidJson,
353 "request is not valid IPC V3 JSON",
354 self.current_generation(),
355 );
356 }
357 };
358 self.handle_at(envelope, now_unix_ms)
359 }
360
361 fn handle_stateful(
362 &self,
363 envelope: EnvelopeV3,
364 request: RuntimeRequestV3,
365 ) -> RuntimeResponseV3 {
366 let request_id = envelope.request_id;
367 let capability = envelope.capability.unwrap_or_default();
368 if !self
369 .config
370 .capabilities
371 .iter()
372 .any(|item| item == &capability)
373 {
374 return self.error_response(
375 request_id,
376 RuntimeErrorCodeV3::UnsupportedCapability,
377 "capability is not in the effective set",
378 self.current_generation(),
379 );
380 }
381 if envelope.feature_schema_hash.as_deref() != Some(self.config.feature_schema_hash.as_str())
382 {
383 return self.error_response(
384 request_id,
385 RuntimeErrorCodeV3::StateMismatch,
386 "feature schema hash does not match",
387 self.current_generation(),
388 );
389 }
390 if envelope.model_generation != self.config.model_generation {
391 return self.error_response(
392 request_id,
393 RuntimeErrorCodeV3::IncompatibleGeneration,
394 "model generation does not match",
395 self.current_generation(),
396 );
397 }
398 if let RuntimeRequestV3::Feedback { generation, .. } = &request
399 && *generation != self.config.model_generation
400 {
401 return self.error_response(
402 request_id,
403 RuntimeErrorCodeV3::IncompatibleGeneration,
404 "feedback generation does not match",
405 self.current_generation(),
406 );
407 }
408
409 let mut state = match self.state.lock() {
410 Ok(state) => state,
411 Err(_) => {
412 return self.error_response(
413 request_id,
414 RuntimeErrorCodeV3::Internal,
415 "runtime state lock is poisoned",
416 0,
417 );
418 }
419 };
420 if envelope.state_generation != state.snapshot.state_generation {
421 return self.error_response(
422 request_id,
423 RuntimeErrorCodeV3::StateMismatch,
424 "state generation does not match",
425 state.snapshot.state_generation,
426 );
427 }
428
429 if let RuntimeRequestV3::Snapshot {} = request {
430 return self.response(
431 request_id,
432 state.snapshot.state_generation,
433 RuntimeResponseBodyV3::Snapshot {
434 state_schema_version: state.snapshot.state_schema_version,
435 state_checksum: state.snapshot.checksum_sha256.clone(),
436 state: hex::encode(&state.snapshot.state),
437 },
438 );
439 }
440 if let RuntimeRequestV3::Reset {
441 expected_state_generation,
442 } = request
443 {
444 if expected_state_generation != state.snapshot.state_generation {
445 return self.error_response(
446 request_id,
447 RuntimeErrorCodeV3::StateMismatch,
448 "reset generation does not match",
449 state.snapshot.state_generation,
450 );
451 }
452 let Some(next_generation) = state.snapshot.state_generation.checked_add(1) else {
453 return self.error_response(
454 request_id,
455 RuntimeErrorCodeV3::InvalidState,
456 "state generation overflow",
457 state.snapshot.state_generation,
458 );
459 };
460 state.snapshot = StatefulStateSnapshotV2::new(
461 self.metadata.state_schema_version,
462 next_generation,
463 self.config.initial_state.clone(),
464 );
465 return self.response(
466 request_id,
467 next_generation,
468 RuntimeResponseBodyV3::Reset { reset: true },
469 );
470 }
471
472 let deterministic_seed = match &request {
473 RuntimeRequestV3::Decide {
474 deterministic_seed, ..
475 } => *deterministic_seed,
476 _ => None,
477 };
478 let event_json = match serde_json::to_vec(&request) {
479 Ok(bytes) if bytes.len() <= MAX_EVENT_BYTES => bytes,
480 Ok(_) => {
481 return self.error_response(
482 request_id,
483 RuntimeErrorCodeV3::PayloadTooLarge,
484 "event exceeds handler limit",
485 state.snapshot.state_generation,
486 );
487 }
488 Err(_) => {
489 return self.error_response(
490 request_id,
491 RuntimeErrorCodeV3::InvalidJson,
492 "event could not be encoded",
493 state.snapshot.state_generation,
494 );
495 }
496 };
497
498 let result =
499 match self
500 .handler
501 .handle(&event_json, &state.snapshot.state, deterministic_seed)
502 {
503 Ok(result) => result,
504 Err(error) => {
505 let (code, message) = map_handler_error(error.kind());
506 return self.error_response(
507 request_id,
508 code,
509 message,
510 state.snapshot.state_generation,
511 );
512 }
513 };
514 let output_bytes = match serde_json::to_vec(&result.output) {
515 Ok(bytes) => bytes,
516 Err(_) => {
517 return self.error_response(
518 request_id,
519 RuntimeErrorCodeV3::HandlerInvalidOutput,
520 "handler output was not valid JSON",
521 state.snapshot.state_generation,
522 );
523 }
524 };
525 if output_bytes.len() > MAX_OUTPUT_BYTES {
526 return self.error_response(
527 request_id,
528 RuntimeErrorCodeV3::HandlerOutputTooLarge,
529 "handler output exceeded the size limit",
530 state.snapshot.state_generation,
531 );
532 }
533 if validate_state_bytes(
534 &result.next_state,
535 self.metadata.state_schema_version,
536 self.metadata.state_schema_version,
537 )
538 .is_err()
539 {
540 return self.error_response(
541 request_id,
542 RuntimeErrorCodeV3::InvalidState,
543 "handler returned invalid next state",
544 state.snapshot.state_generation,
545 );
546 }
547 let Some(next_generation) = state.snapshot.state_generation.checked_add(1) else {
548 return self.error_response(
549 request_id,
550 RuntimeErrorCodeV3::InvalidState,
551 "state generation overflow",
552 state.snapshot.state_generation,
553 );
554 };
555 state.snapshot = StatefulStateSnapshotV2::new(
556 self.metadata.state_schema_version,
557 next_generation,
558 result.next_state,
559 );
560 self.response(
561 request_id,
562 next_generation,
563 match request {
564 RuntimeRequestV3::Inspect {} => RuntimeResponseBodyV3::Inspection {
565 summary: result.output,
566 },
567 _ => RuntimeResponseBodyV3::Result {
568 output: result.output,
569 },
570 },
571 )
572 }
573
574 fn current_generation(&self) -> u64 {
575 self.state
576 .lock()
577 .map(|state| state.snapshot.state_generation)
578 .unwrap_or(0)
579 }
580
581 fn response(
582 &self,
583 request_id: String,
584 state_generation: u64,
585 response: RuntimeResponseBodyV3,
586 ) -> RuntimeResponseV3 {
587 RuntimeResponseV3 {
588 request_id,
589 api_version: RUNTIME_API_VERSION_V3,
590 runtime_identity: self.config.runtime_identity.clone(),
591 model_generation: self.config.model_generation,
592 state_generation,
593 response,
594 }
595 }
596
597 fn error_response(
598 &self,
599 request_id: String,
600 code: RuntimeErrorCodeV3,
601 message: impl Into<String>,
602 state_generation: u64,
603 ) -> RuntimeResponseV3 {
604 self.response(
605 if request_id.is_empty() {
606 "invalid-request".into()
607 } else {
608 request_id
609 },
610 state_generation,
611 RuntimeResponseBodyV3::Error {
612 error: RuntimeErrorV3::new(code, message),
613 },
614 )
615 }
616}
617
618fn validate_state_bytes(
619 state: &[u8],
620 actual_schema_version: u32,
621 expected_schema_version: u32,
622) -> Result<(), StatefulHandlerErrorV2> {
623 if actual_schema_version == 0 || actual_schema_version != expected_schema_version {
624 return Err(StatefulHandlerErrorV2::new(
625 StatefulHandlerErrorKindV2::IncompatibleVersion,
626 ));
627 }
628 if state.len() > MAX_STATE_BYTES || serde_json::from_slice::<serde_json::Value>(state).is_err()
629 {
630 return Err(StatefulHandlerErrorV2::new(
631 StatefulHandlerErrorKindV2::InvalidState,
632 ));
633 }
634 Ok(())
635}
636
637fn validate_feature_schema_hash(hash: &str) -> Result<(), StatefulHandlerErrorV2> {
638 if hash.len() != 64
639 || !hash
640 .bytes()
641 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
642 {
643 return Err(StatefulHandlerErrorV2::new(
644 StatefulHandlerErrorKindV2::InvalidModel,
645 ));
646 }
647 Ok(())
648}
649
650fn validate_capabilities(capabilities: &[String]) -> Result<(), StatefulHandlerErrorV2> {
651 if capabilities.is_empty() || capabilities.len() > rill_handler_api::MAX_CAPABILITIES {
652 return Err(StatefulHandlerErrorV2::new(
653 StatefulHandlerErrorKindV2::InvalidModel,
654 ));
655 }
656 let mut seen = std::collections::BTreeSet::new();
657 if capabilities.iter().any(|capability| {
658 capability.is_empty()
659 || capability.len() > rill_handler_api::MAX_CAPABILITY_LEN
660 || !seen.insert(capability)
661 }) {
662 return Err(StatefulHandlerErrorV2::new(
663 StatefulHandlerErrorKindV2::InvalidModel,
664 ));
665 }
666 Ok(())
667}
668
669fn state_checksum(state: &[u8]) -> String {
670 hex::encode(Sha256::digest(state))
671}
672
673fn map_handler_error(kind: StatefulHandlerErrorKindV2) -> (RuntimeErrorCodeV3, &'static str) {
674 match kind {
675 StatefulHandlerErrorKindV2::InvalidEvent => (
676 RuntimeErrorCodeV3::InvalidEnvelope,
677 "handler rejected the event",
678 ),
679 StatefulHandlerErrorKindV2::InvalidState
680 | StatefulHandlerErrorKindV2::IncompatibleVersion => (
681 RuntimeErrorCodeV3::InvalidState,
682 "handler rejected the current state",
683 ),
684 StatefulHandlerErrorKindV2::DuplicateFeedback => (
685 RuntimeErrorCodeV3::DuplicateFeedback,
686 "feedback was already applied",
687 ),
688 StatefulHandlerErrorKindV2::Timeout => (
689 RuntimeErrorCodeV3::HandlerTimeout,
690 "handler exceeded the wall-clock deadline",
691 ),
692 StatefulHandlerErrorKindV2::Trap => (RuntimeErrorCodeV3::HandlerTrap, "handler trapped"),
693 StatefulHandlerErrorKindV2::OutputTooLarge => (
694 RuntimeErrorCodeV3::HandlerOutputTooLarge,
695 "handler output exceeded the size limit",
696 ),
697 StatefulHandlerErrorKindV2::InvalidOutput => (
698 RuntimeErrorCodeV3::HandlerInvalidOutput,
699 "handler output was not valid JSON",
700 ),
701 StatefulHandlerErrorKindV2::InvalidModel
702 | StatefulHandlerErrorKindV2::MetadataMismatch
703 | StatefulHandlerErrorKindV2::Internal => {
704 (RuntimeErrorCodeV3::Internal, "internal runtime error")
705 }
706 }
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 #[derive(Debug)]
714 struct TestHandler {
715 metadata: StatefulHandlerMetadataV2,
716 mode: StatefulHandlerErrorKindV2,
717 }
718
719 impl StatefulHandlerV2 for TestHandler {
720 fn metadata(&self) -> &StatefulHandlerMetadataV2 {
721 &self.metadata
722 }
723
724 fn handle(
725 &self,
726 _event_json: &[u8],
727 current_state: &[u8],
728 _deterministic_seed: Option<u64>,
729 ) -> Result<StatefulHandlerResultV2, StatefulHandlerErrorV2> {
730 match self.mode {
731 StatefulHandlerErrorKindV2::Internal => {
732 let mut value: serde_json::Value =
733 serde_json::from_slice(current_state).unwrap();
734 value["count"] = serde_json::json!(value["count"].as_u64().unwrap_or(0) + 1);
735 Ok(StatefulHandlerResultV2 {
736 output: value.clone(),
737 next_state: serde_json::to_vec(&value).unwrap(),
738 })
739 }
740 StatefulHandlerErrorKindV2::InvalidState => Ok(StatefulHandlerResultV2 {
741 output: serde_json::json!({"ignored": true}),
742 next_state: b"not-json".to_vec(),
743 }),
744 StatefulHandlerErrorKindV2::OutputTooLarge => Ok(StatefulHandlerResultV2 {
745 output: serde_json::json!({"data": "x".repeat(MAX_OUTPUT_BYTES + 1)}),
746 next_state: current_state.to_vec(),
747 }),
748 other => Err(StatefulHandlerErrorV2::new(other)),
749 }
750 }
751 }
752
753 fn engine(mode: StatefulHandlerErrorKindV2) -> StatefulRuntimeEngineV3 {
754 let metadata = StatefulHandlerMetadataV2 {
755 id: "org.example.stateful".into(),
756 version: "2.0.0".into(),
757 api_version: HANDLER_API_VERSION,
758 capabilities: vec!["org.example.decide".into()],
759 state_schema_version: 1,
760 };
761 let config = StatefulRuntimeConfigV3::new(
762 IdentityV3 {
763 name: "rill-runtime".into(),
764 version: "1.0.0".into(),
765 },
766 7,
767 "ab".repeat(32),
768 metadata.capabilities.clone(),
769 br#"{"count":0}"#.to_vec(),
770 );
771 StatefulRuntimeEngineV3::new(config, Arc::new(TestHandler { metadata, mode })).unwrap()
772 }
773
774 fn decide(state_generation: u64) -> EnvelopeV3 {
775 EnvelopeV3 {
776 request_id: "d1".into(),
777 api_version: RUNTIME_API_VERSION_V3,
778 client_identity: IdentityV3 {
779 name: "host".into(),
780 version: "1".into(),
781 },
782 capability: Some("org.example.decide".into()),
783 deadline_unix_ms: Some(100),
784 feature_schema_hash: Some("ab".repeat(32)),
785 model_generation: 7,
786 state_generation,
787 payload_limit: rill_runtime_protocol::MAX_MESSAGE_BYTES as u32,
788 request: RuntimeRequestV3::Decide {
789 context: serde_json::json!({"features": [1.0]}),
790 deterministic_seed: Some(42),
791 },
792 }
793 }
794
795 #[test]
796 fn state_update_is_atomic_and_increments_generation() {
797 let engine = engine(StatefulHandlerErrorKindV2::Internal);
798 let response = engine.handle_at(decide(0), 100);
799 assert!(matches!(
800 response.response,
801 RuntimeResponseBodyV3::Result { .. }
802 ));
803 assert_eq!(response.state_generation, 1);
804 assert_eq!(engine.snapshot().unwrap().state, br#"{"count":1}"#);
805 }
806
807 #[test]
808 fn invalid_next_state_is_fail_closed() {
809 let engine = engine(StatefulHandlerErrorKindV2::InvalidState);
810 let before = engine.snapshot().unwrap();
811 let response = engine.handle_at(decide(0), 100);
812 assert!(matches!(
813 response.response,
814 RuntimeResponseBodyV3::Error { error }
815 if error.code == RuntimeErrorCodeV3::InvalidState
816 ));
817 assert_eq!(engine.snapshot().unwrap(), before);
818 }
819
820 #[test]
821 fn timeout_trap_and_oversize_leave_state_unchanged() {
822 for mode in [
823 StatefulHandlerErrorKindV2::Timeout,
824 StatefulHandlerErrorKindV2::Trap,
825 StatefulHandlerErrorKindV2::OutputTooLarge,
826 ] {
827 let engine = engine(mode);
828 let before = engine.snapshot().unwrap();
829 let response = engine.handle_at(decide(0), 100);
830 assert!(matches!(
831 response.response,
832 RuntimeResponseBodyV3::Error { .. }
833 ));
834 assert_eq!(engine.snapshot().unwrap(), before, "mode={mode:?}");
835 }
836 }
837
838 #[test]
839 fn stale_generation_and_expired_request_are_rejected() {
840 let engine = engine(StatefulHandlerErrorKindV2::Internal);
841 let stale = engine.handle_at(decide(1), 100);
842 assert!(matches!(
843 stale.response,
844 RuntimeResponseBodyV3::Error { error }
845 if error.code == RuntimeErrorCodeV3::StateMismatch
846 ));
847 let expired = engine.handle_at(decide(0), 101);
848 assert!(matches!(
849 expired.response,
850 RuntimeResponseBodyV3::Error { error }
851 if error.code == RuntimeErrorCodeV3::ExpiredRequest
852 ));
853 }
854
855 #[test]
856 fn corrupt_snapshot_checksum_is_rejected_without_mutation() {
857 let engine = engine(StatefulHandlerErrorKindV2::Internal);
858 let before = engine.snapshot().unwrap();
859 let mut corrupt = before.clone();
860 corrupt.checksum_sha256 = "00".repeat(32);
861 assert!(engine.restore_snapshot(corrupt).is_err());
862 assert_eq!(engine.snapshot().unwrap(), before);
863 }
864
865 #[test]
866 fn json_entrypoint_rejects_malformed_and_oversized_messages() {
867 let engine = engine(StatefulHandlerErrorKindV2::Internal);
868 let before = engine.snapshot().unwrap();
869
870 let malformed = engine.handle_json_at(b"{", 100);
871 assert!(matches!(
872 malformed.response,
873 RuntimeResponseBodyV3::Error { error }
874 if error.code == RuntimeErrorCodeV3::InvalidJson
875 ));
876
877 let oversized = vec![b' '; rill_runtime_protocol::MAX_MESSAGE_BYTES + 1];
878 let oversized = engine.handle_json_at(&oversized, 100);
879 assert!(matches!(
880 oversized.response,
881 RuntimeResponseBodyV3::Error { error }
882 if error.code == RuntimeErrorCodeV3::PayloadTooLarge
883 ));
884 assert_eq!(engine.snapshot().unwrap(), before);
885 }
886}