Skip to main content

rill_runtime/
stateful.rs

1//! Preview stateful Handler ABI v2 and IPC V3 runtime integration.
2//!
3//! The host owns persistence and only commits a handler's proposed next state
4//! after all bounds, JSON, schema-version and checksum checks succeed.
5
6use 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/// Metadata declared by a Preview ABI v2 handler.
21#[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/// Successful handler result. `next_state` is a proposal; the Runtime still
68/// validates it before making it current.
69#[derive(Debug, Clone, PartialEq)]
70pub struct StatefulHandlerResultV2 {
71    pub output: serde_json::Value,
72    pub next_state: Vec<u8>,
73}
74
75/// Stateful handler failure categories. All failures are fail-closed and
76/// leave the Runtime-owned state unchanged.
77#[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/// Typed handler error with bounded host-only detail.
94#[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
141/// Host abstraction implemented by sandboxed ABI v2 handlers and test
142/// doubles. It grants no filesystem, network, process, time or randomness;
143/// deterministic randomness is supplied only through `deterministic_seed`.
144pub 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/// Serializable Runtime-owned state snapshot. Restores verify all fields,
156/// state size, JSON validity and checksum before activation.
157#[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/// Construction contract for the V3 runtime.
193#[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/// Preview Runtime V3 engine for Stateful Handler ABI v2.
229#[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    /// Restore a previously validated Runtime-owned state atomically.
276    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    /// Handle one request at a caller-supplied clock value. No system clock is
297    /// read by the library.
298    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    /// Decode and handle one bounded IPC V3 JSON message. Malformed and
336    /// oversized messages are rejected before a handler can observe them.
337    /// The caller supplies the clock so replay and tests remain deterministic.
338    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}