Skip to main content

oxide_batch_core/
state.rs

1//! Bounded, versioned checkpoint and execution-context values.
2
3use std::error::Error;
4use std::fmt;
5use std::num::{NonZeroU32, NonZeroUsize};
6
7use serde_json::{Map, Number, Value};
8use sha2::{Digest, Sha256};
9
10const FORMAT_VERSION: u16 = 1;
11const MAX_SCHEMA_ID_BYTES: usize = 128;
12const DEFAULT_MAXIMUM_BYTES: usize = 64 * 1024;
13const DEFAULT_MAXIMUM_DEPTH: usize = 16;
14const MAXIMUM_BYTES: usize = 1024 * 1024;
15const MAXIMUM_DEPTH: usize = 64;
16/// The most directed upgrades one decode may apply.
17///
18/// Every declared edge strictly increases the version and no version repeats,
19/// so a chain cannot exceed the declared edge count. The ceiling bounds a
20/// codec that declares an unreasonable number of edges.
21const MAX_UPGRADE_CHAIN: usize = 64;
22
23/// The durable state category being encoded or decoded.
24#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
25#[non_exhaustive]
26pub enum DurableStateKind {
27    /// A reader position committed at a chunk boundary.
28    Checkpoint,
29    /// Application restart state scoped to an execution.
30    ExecutionContext,
31    /// Component-owned, namespaced durable state (M6 `ItemStream` contract).
32    ComponentState,
33}
34
35impl DurableStateKind {
36    const fn format(self) -> &'static str {
37        match self {
38            Self::Checkpoint => "oxide-batch.checkpoint",
39            Self::ExecutionContext => "oxide-batch.execution-context",
40            Self::ComponentState => "oxide-batch.component-state",
41        }
42    }
43}
44
45impl fmt::Display for DurableStateKind {
46    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47        formatter.write_str(match self {
48            Self::Checkpoint => "checkpoint",
49            Self::ExecutionContext => "execution context",
50            Self::ComponentState => "component state",
51        })
52    }
53}
54
55/// A validated application-owned durable-state schema identifier.
56#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
57pub struct StateSchemaId(String);
58
59impl StateSchemaId {
60    /// Validates a stable schema identifier.
61    ///
62    /// # Errors
63    ///
64    /// Returns a redacted [`StateError`] when the identifier is empty, exceeds
65    /// 128 UTF-8 bytes, has surrounding whitespace, or contains a control
66    /// character.
67    pub fn new(value: impl Into<String>) -> Result<Self, StateError> {
68        let value = value.into();
69        if value.is_empty() {
70            return Err(StateError::EmptySchemaId);
71        }
72        if value.len() > MAX_SCHEMA_ID_BYTES {
73            return Err(StateError::SchemaIdTooLong {
74                max_bytes: MAX_SCHEMA_ID_BYTES,
75            });
76        }
77        if value.trim() != value {
78            return Err(StateError::SchemaIdHasSurroundingWhitespace);
79        }
80        if value.chars().any(char::is_control) {
81            return Err(StateError::SchemaIdContainsControl);
82        }
83        Ok(Self(value))
84    }
85
86    /// Borrows the validated identifier.
87    #[must_use]
88    pub fn as_str(&self) -> &str {
89        &self.0
90    }
91}
92
93impl fmt::Debug for StateSchemaId {
94    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95        formatter.write_str("StateSchemaId(<redacted>)")
96    }
97}
98
99impl fmt::Display for StateSchemaId {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        formatter.write_str(self.as_str())
102    }
103}
104
105/// A nonzero application schema version.
106#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
107pub struct StateSchemaVersion(NonZeroU32);
108
109impl StateSchemaVersion {
110    /// Constructs a nonzero schema version.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`StateError::ZeroSchemaVersion`] when `value` is zero.
115    pub fn new(value: u32) -> Result<Self, StateError> {
116        NonZeroU32::new(value)
117            .map(Self)
118            .ok_or(StateError::ZeroSchemaVersion)
119    }
120
121    /// Returns the numeric schema version.
122    #[must_use]
123    pub const fn get(self) -> u32 {
124        self.0.get()
125    }
126}
127
128/// Resource bounds checked before application payload decoding.
129#[derive(Clone, Copy, Debug, Eq, PartialEq)]
130pub struct StateLimits {
131    maximum_bytes: NonZeroUsize,
132    maximum_depth: NonZeroUsize,
133}
134
135impl StateLimits {
136    /// Validates explicit byte and JSON-depth limits.
137    ///
138    /// The hard ceilings match the accepted `PostgreSQL` metadata model. Smaller
139    /// limits may be selected per definition.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`StateError::InvalidByteLimit`] or
144    /// [`StateError::InvalidDepthLimit`] for zero or values above the hard
145    /// ceiling.
146    pub fn new(maximum_bytes: usize, maximum_depth: usize) -> Result<Self, StateError> {
147        if maximum_bytes == 0 || maximum_bytes > MAXIMUM_BYTES {
148            return Err(StateError::InvalidByteLimit {
149                maximum: MAXIMUM_BYTES,
150            });
151        }
152        if maximum_depth == 0 || maximum_depth > MAXIMUM_DEPTH {
153            return Err(StateError::InvalidDepthLimit {
154                maximum: MAXIMUM_DEPTH,
155            });
156        }
157        let Some(maximum_bytes) = NonZeroUsize::new(maximum_bytes) else {
158            return Err(StateError::InvalidByteLimit {
159                maximum: MAXIMUM_BYTES,
160            });
161        };
162        let Some(maximum_depth) = NonZeroUsize::new(maximum_depth) else {
163            return Err(StateError::InvalidDepthLimit {
164                maximum: MAXIMUM_DEPTH,
165            });
166        };
167        Ok(Self {
168            maximum_bytes,
169            maximum_depth,
170        })
171    }
172
173    /// Returns the serialized-envelope byte limit.
174    #[must_use]
175    pub const fn maximum_bytes(self) -> usize {
176        self.maximum_bytes.get()
177    }
178
179    /// Returns the maximum JSON nesting depth, including the envelope root.
180    #[must_use]
181    pub const fn maximum_depth(self) -> usize {
182        self.maximum_depth.get()
183    }
184}
185
186impl Default for StateLimits {
187    fn default() -> Self {
188        Self {
189            maximum_bytes: NonZeroUsize::new(DEFAULT_MAXIMUM_BYTES).unwrap_or(NonZeroUsize::MIN),
190            maximum_depth: NonZeroUsize::new(DEFAULT_MAXIMUM_DEPTH).unwrap_or(NonZeroUsize::MIN),
191        }
192    }
193}
194
195/// One directed application-schema upgrade a codec declares.
196///
197/// An upgrade rewrites a JSON-object payload recorded at [`from`](Self::from)
198/// into the shape [`to`](Self::to) expects. The framework, not the codec,
199/// selects and applies the edges, so a codec never inspects a recorded version
200/// to decide what an older payload meant.
201///
202/// Edges strictly increase the version and at most one edge may leave any
203/// version, which is what makes a resolved chain deterministic and bounded.
204#[derive(Clone, Copy)]
205pub struct StateSchemaUpgrade {
206    from: StateSchemaVersion,
207    to: StateSchemaVersion,
208    apply: fn(&[u8]) -> Result<Vec<u8>, StateCodecError>,
209}
210
211impl StateSchemaUpgrade {
212    /// Declares a directed upgrade between two application schema versions.
213    ///
214    /// `apply` must be deterministic: the same payload bytes must always
215    /// produce the same result, because a restart replays the same chain over
216    /// the same durable bytes.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`StateError::NonIncreasingUpgrade`] when `to` does not exceed
221    /// `from`, which would let a chain loop or move backwards.
222    pub fn new(
223        from: StateSchemaVersion,
224        to: StateSchemaVersion,
225        apply: fn(&[u8]) -> Result<Vec<u8>, StateCodecError>,
226    ) -> Result<Self, StateError> {
227        if to <= from {
228            return Err(StateError::NonIncreasingUpgrade { from, to });
229        }
230        Ok(Self { from, to, apply })
231    }
232
233    /// Returns the version this upgrade reads.
234    #[must_use]
235    pub const fn from(&self) -> StateSchemaVersion {
236        self.from
237    }
238
239    /// Returns the version this upgrade produces.
240    #[must_use]
241    pub const fn to(&self) -> StateSchemaVersion {
242        self.to
243    }
244}
245
246impl fmt::Debug for StateSchemaUpgrade {
247    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
248        formatter
249            .debug_struct("StateSchemaUpgrade")
250            .field("from", &self.from)
251            .field("to", &self.to)
252            .finish_non_exhaustive()
253    }
254}
255
256/// Serializer-neutral application codec for one durable-state schema.
257///
258/// Payloads are JSON objects represented as bytes so the public contract does
259/// not expose a particular serializer's types. A codec may use Serde, manual
260/// JSON handling, or another implementation internally.
261///
262/// A codec declares its current version and the directed upgrades it can
263/// apply. The framework accepts an equal or older recorded version, walks one
264/// bounded deterministic chain of declared upgrades up to the current version,
265/// and only then calls [`decode`](Self::decode). A codec therefore parses
266/// exactly one shape, and a recorded version newer than the current one is
267/// rejected rather than truncated, defaulted, or reinterpreted.
268pub trait VersionedStateCodec<T>: Send + Sync {
269    /// Returns the stable schema identifier.
270    fn schema_id(&self) -> &StateSchemaId;
271
272    /// Returns the version emitted by [`encode`](Self::encode).
273    fn current_version(&self) -> StateSchemaVersion;
274
275    /// Declares the directed upgrades this codec can apply.
276    ///
277    /// The default suits a codec whose schema has only ever had one version.
278    /// A codec that has published an older version returns the edges that
279    /// reach the current one; a recorded version with no path to the current
280    /// version is rejected rather than guessed at.
281    fn upgrades(&self) -> &[StateSchemaUpgrade] {
282        &[]
283    }
284
285    /// Encodes the current typed value as one JSON object.
286    ///
287    /// # Errors
288    ///
289    /// Returns a value-redacted codec classification.
290    fn encode(&self, value: &T) -> Result<Vec<u8>, StateCodecError>;
291
292    /// Decodes one JSON-object payload already at
293    /// [`current_version`](Self::current_version).
294    ///
295    /// # Errors
296    ///
297    /// Returns [`StateCodecError::InvalidPayload`] when the payload does not
298    /// satisfy the current schema.
299    fn decode(&self, payload: &[u8]) -> Result<T, StateCodecError>;
300}
301
302/// Stable, payload-redacted failures returned by an application codec.
303#[derive(Clone, Copy, Debug, Eq, PartialEq)]
304#[non_exhaustive]
305pub enum StateCodecError {
306    /// The payload does not satisfy the selected schema.
307    InvalidPayload,
308    /// The codec has no directed upgrade path from the selected version.
309    UnsupportedSchemaVersion,
310}
311
312impl fmt::Display for StateCodecError {
313    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
314        formatter.write_str(match self {
315            Self::InvalidPayload => "durable state payload is invalid",
316            Self::UnsupportedSchemaVersion => "durable state schema version is unsupported",
317        })
318    }
319}
320
321impl Error for StateCodecError {}
322
323#[derive(Clone, Eq, PartialEq)]
324struct VersionedState {
325    schema_id: StateSchemaId,
326    schema_version: StateSchemaVersion,
327    payload: Value,
328    encoded_bytes: usize,
329}
330
331impl VersionedState {
332    fn encode<T>(
333        kind: DurableStateKind,
334        value: &T,
335        codec: &(impl VersionedStateCodec<T> + ?Sized),
336        limits: StateLimits,
337    ) -> Result<Self, StateError> {
338        let payload_bytes = codec.encode(value).map_err(StateError::Codec)?;
339        let payload: Value =
340            serde_json::from_slice(&payload_bytes).map_err(|_| StateError::InvalidPayload)?;
341        if !payload.is_object() {
342            return Err(StateError::PayloadNotObject);
343        }
344        Self::from_parts(
345            kind,
346            codec.schema_id().clone(),
347            codec.current_version(),
348            payload,
349            limits,
350        )
351    }
352
353    fn from_json(
354        kind: DurableStateKind,
355        bytes: &[u8],
356        limits: StateLimits,
357    ) -> Result<Self, StateError> {
358        if bytes.len() > limits.maximum_bytes() {
359            return Err(StateError::TooLarge {
360                kind,
361                max_bytes: limits.maximum_bytes(),
362            });
363        }
364        let value: Value =
365            serde_json::from_slice(bytes).map_err(|_| StateError::Malformed { kind })?;
366        if json_depth(&value) > limits.maximum_depth() {
367            return Err(StateError::TooDeep {
368                kind,
369                max_depth: limits.maximum_depth(),
370            });
371        }
372        let object = value.as_object().ok_or(StateError::Malformed { kind })?;
373        let format = object
374            .get("format")
375            .and_then(Value::as_str)
376            .ok_or(StateError::Malformed { kind })?;
377        if format != kind.format() {
378            return Err(StateError::FormatMismatch { kind });
379        }
380        let format_version = object
381            .get("format_version")
382            .and_then(Value::as_u64)
383            .and_then(|version| u16::try_from(version).ok())
384            .ok_or(StateError::Malformed { kind })?;
385        if format_version != FORMAT_VERSION {
386            return Err(StateError::UnsupportedFormatVersion {
387                kind,
388                version: format_version,
389            });
390        }
391        let schema_id = object
392            .get("schema")
393            .and_then(Value::as_str)
394            .ok_or(StateError::Malformed { kind })?;
395        let schema_id = StateSchemaId::new(schema_id)?;
396        let schema_version = object
397            .get("schema_version")
398            .and_then(Value::as_u64)
399            .and_then(|version| u32::try_from(version).ok())
400            .ok_or(StateError::Malformed { kind })?;
401        let schema_version = StateSchemaVersion::new(schema_version)?;
402        let payload = object
403            .get("payload")
404            .cloned()
405            .ok_or(StateError::Malformed { kind })?;
406        if !payload.is_object() {
407            return Err(StateError::PayloadNotObject);
408        }
409        Ok(Self {
410            schema_id,
411            schema_version,
412            payload,
413            encoded_bytes: bytes.len(),
414        })
415    }
416
417    fn from_parts(
418        kind: DurableStateKind,
419        schema_id: StateSchemaId,
420        schema_version: StateSchemaVersion,
421        payload: Value,
422        limits: StateLimits,
423    ) -> Result<Self, StateError> {
424        let envelope = envelope(kind, &schema_id, schema_version, payload.clone());
425        let bytes = serde_json::to_vec(&envelope).map_err(|_| StateError::Malformed { kind })?;
426        if bytes.len() > limits.maximum_bytes() {
427            return Err(StateError::TooLarge {
428                kind,
429                max_bytes: limits.maximum_bytes(),
430            });
431        }
432        if json_depth(&envelope) > limits.maximum_depth() {
433            return Err(StateError::TooDeep {
434                kind,
435                max_depth: limits.maximum_depth(),
436            });
437        }
438        Ok(Self {
439            schema_id,
440            schema_version,
441            payload,
442            encoded_bytes: bytes.len(),
443        })
444    }
445
446    fn decode<T>(
447        &self,
448        kind: DurableStateKind,
449        codec: &(impl VersionedStateCodec<T> + ?Sized),
450    ) -> Result<T, StateError> {
451        if &self.schema_id != codec.schema_id() {
452            return Err(StateError::SchemaMismatch { kind });
453        }
454        let current = codec.current_version();
455        if self.schema_version > current {
456            return Err(StateError::UnsupportedSchemaVersion {
457                kind,
458                found: self.schema_version,
459                current,
460            });
461        }
462        let payload =
463            serde_json::to_vec(&self.payload).map_err(|_| StateError::Malformed { kind })?;
464        let payload = upgrade_schema_chain(kind, self.schema_version, codec, payload)?;
465        codec.decode(&payload).map_err(StateError::Codec)
466    }
467
468    fn to_json(&self, kind: DurableStateKind) -> Result<Vec<u8>, StateError> {
469        serde_json::to_vec(&envelope(
470            kind,
471            &self.schema_id,
472            self.schema_version,
473            self.payload.clone(),
474        ))
475        .map_err(|_| StateError::Malformed { kind })
476    }
477
478    fn payload_json(&self, kind: DurableStateKind) -> Result<Vec<u8>, StateError> {
479        serde_json::to_vec(&self.payload).map_err(|_| StateError::Malformed { kind })
480    }
481}
482
483/// Walks the declared upgrade edges from `recorded` to the codec's current
484/// version.
485///
486/// Each step takes the single edge leaving the position reached so far. Two
487/// edges leaving one version would make the result depend on declaration
488/// order, so that is rejected rather than resolved.
489///
490/// `pub(crate)` rather than private: this is the one schema-upgrade-chain
491/// algorithm in the crate. The M6 component-state envelope
492/// ([`crate::component_state`]) delegates its application-schema axis to this
493/// exact function rather than re-implementing the walk, so [`Checkpoint`],
494/// [`ExecutionContext`], and component state share one migration algorithm.
495pub(crate) fn upgrade_schema_chain<T>(
496    kind: DurableStateKind,
497    recorded: StateSchemaVersion,
498    codec: &(impl VersionedStateCodec<T> + ?Sized),
499    mut payload: Vec<u8>,
500) -> Result<Vec<u8>, StateError> {
501    let current = codec.current_version();
502    let upgrades = codec.upgrades();
503    let mut version = recorded;
504    let mut applied = 0_usize;
505    while version < current {
506        let mut edges = upgrades.iter().filter(|upgrade| upgrade.from == version);
507        let edge = edges.next().ok_or(StateError::NoUpgradePath {
508            kind,
509            found: version,
510            current,
511        })?;
512        if edges.next().is_some() {
513            return Err(StateError::AmbiguousUpgrade {
514                kind,
515                from: version,
516            });
517        }
518        if edge.to > current {
519            return Err(StateError::UpgradeOvershootsCurrent {
520                kind,
521                to: edge.to,
522                current,
523            });
524        }
525        applied += 1;
526        if applied > MAX_UPGRADE_CHAIN {
527            return Err(StateError::UpgradeChainTooLong {
528                kind,
529                max_upgrades: MAX_UPGRADE_CHAIN,
530            });
531        }
532        payload = (edge.apply)(&payload).map_err(StateError::Codec)?;
533        check_upgraded(kind, &payload)?;
534        version = edge.to;
535    }
536    Ok(payload)
537}
538
539fn envelope(
540    kind: DurableStateKind,
541    schema_id: &StateSchemaId,
542    schema_version: StateSchemaVersion,
543    payload: Value,
544) -> Value {
545    let mut object = Map::new();
546    object.insert(
547        String::from("format"),
548        Value::String(String::from(kind.format())),
549    );
550    object.insert(
551        String::from("format_version"),
552        Value::Number(Number::from(FORMAT_VERSION)),
553    );
554    object.insert(
555        String::from("schema"),
556        Value::String(String::from(schema_id.as_str())),
557    );
558    object.insert(
559        String::from("schema_version"),
560        Value::Number(Number::from(schema_version.get())),
561    );
562    object.insert(String::from("payload"), payload);
563    Value::Object(object)
564}
565
566/// Holds a payload produced by a declared upgrade to the envelope's own shape
567/// and to the durable hard ceilings.
568///
569/// An upgrade is application code running between two framework checks, so a
570/// transform that returns a non-object, invalid JSON, or an unbounded payload
571/// fails here as a typed state error rather than reaching the codec. The
572/// ceilings rather than the configured limits apply: this value is an
573/// intermediate that is never persisted, and the bytes that are persisted come
574/// from `encode`, which is checked against the configured limits.
575fn check_upgraded(kind: DurableStateKind, payload: &[u8]) -> Result<(), StateError> {
576    if payload.len() > MAXIMUM_BYTES {
577        return Err(StateError::TooLarge {
578            kind,
579            max_bytes: MAXIMUM_BYTES,
580        });
581    }
582    let value: Value = serde_json::from_slice(payload)
583        .map_err(|_| StateError::UpgradeProducedInvalidJson { kind })?;
584    if !value.is_object() {
585        return Err(StateError::PayloadNotObject);
586    }
587    if json_depth(&value) > MAXIMUM_DEPTH {
588        return Err(StateError::TooDeep {
589            kind,
590            max_depth: MAXIMUM_DEPTH,
591        });
592    }
593    Ok(())
594}
595
596/// Computes the maximum JSON nesting depth of `value`, including its root.
597///
598/// `pub(crate)` so the M6 component-state envelope ([`crate::component_state`])
599/// can apply the identical depth bound to its own payload without a second
600/// depth-counting implementation.
601pub(crate) fn json_depth(value: &Value) -> usize {
602    match value {
603        Value::Array(values) => 1 + values.iter().map(json_depth).max().unwrap_or_default(),
604        Value::Object(values) => 1 + values.values().map(json_depth).max().unwrap_or_default(),
605        _ => 1,
606    }
607}
608
609macro_rules! durable_state {
610    ($name:ident, $kind:expr, $docs:literal) => {
611        #[doc = $docs]
612        #[derive(Clone, Eq, PartialEq)]
613        pub struct $name(VersionedState);
614
615        impl $name {
616            /// Encodes a current typed value with explicit resource limits.
617            ///
618            /// # Errors
619            ///
620            /// Returns a redacted codec, schema, JSON-shape, size, or depth
621            /// failure.
622            pub fn encode<T>(
623                value: &T,
624                codec: &(impl VersionedStateCodec<T> + ?Sized),
625                limits: StateLimits,
626            ) -> Result<Self, StateError> {
627                VersionedState::encode($kind, value, codec, limits).map(Self)
628            }
629
630            /// Validates a serialized framework envelope before retaining it.
631            ///
632            /// # Errors
633            ///
634            /// Returns a redacted format, schema, shape, size, or depth
635            /// failure.
636            pub fn from_json(bytes: &[u8], limits: StateLimits) -> Result<Self, StateError> {
637                VersionedState::from_json($kind, bytes, limits).map(Self)
638            }
639
640            /// Decodes or upgrades the retained payload through `codec`.
641            ///
642            /// # Errors
643            ///
644            /// Returns a redacted schema-compatibility or codec failure.
645            pub fn decode<T>(
646                &self,
647                codec: &(impl VersionedStateCodec<T> + ?Sized),
648            ) -> Result<T, StateError> {
649                self.0.decode($kind, codec)
650            }
651
652            /// Returns the framework envelope format version.
653            #[must_use]
654            pub const fn format_version(&self) -> u16 {
655                FORMAT_VERSION
656            }
657
658            /// Borrows the validated application schema identifier.
659            #[must_use]
660            pub const fn schema_id(&self) -> &StateSchemaId {
661                &self.0.schema_id
662            }
663
664            /// Returns the retained application schema version.
665            #[must_use]
666            pub const fn schema_version(&self) -> StateSchemaVersion {
667                self.0.schema_version
668            }
669
670            /// Returns the validated serialized-envelope byte size.
671            #[must_use]
672            pub const fn encoded_len(&self) -> usize {
673                self.0.encoded_bytes
674            }
675
676            /// Serializes the complete framework envelope.
677            ///
678            /// # Errors
679            ///
680            /// Returns a redacted format failure if the retained JSON value
681            /// cannot be serialized.
682            pub fn to_json(&self) -> Result<Vec<u8>, StateError> {
683                self.0.to_json($kind)
684            }
685
686            /// Serializes only the application payload for an authorized
687            /// persistence adapter.
688            ///
689            /// This value can contain sensitive restart data and must not be
690            /// logged or exported as telemetry.
691            ///
692            /// # Errors
693            ///
694            /// Returns a redacted format failure if the retained JSON value
695            /// cannot be serialized.
696            pub fn payload_json(&self) -> Result<Vec<u8>, StateError> {
697                self.0.payload_json($kind)
698            }
699        }
700
701        impl fmt::Debug for $name {
702            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
703                formatter
704                    .debug_struct(stringify!($name))
705                    .field("format_version", &FORMAT_VERSION)
706                    .field("schema_version", &self.schema_version())
707                    .field("encoded_bytes", &self.encoded_len())
708                    .field("payload", &"<redacted>")
709                    .finish()
710            }
711        }
712    };
713}
714
715durable_state!(
716    Checkpoint,
717    DurableStateKind::Checkpoint,
718    "A bounded, versioned reader position committed with a chunk."
719);
720durable_state!(
721    ExecutionContext,
722    DurableStateKind::ExecutionContext,
723    "Bounded, versioned application restart state committed with a chunk."
724);
725
726impl Checkpoint {
727    /// Returns the framework digest identifying this checkpoint generation.
728    ///
729    /// Retry keys are derived from the generation, so the runtime and every
730    /// durable adapter must agree on this exact derivation.
731    #[must_use]
732    pub fn generation_digest(&self) -> [u8; 32] {
733        self.to_json()
734            .map_or([0; 32], |bytes| Sha256::digest(&bytes).into())
735    }
736}
737
738/// Stable, value-redacted durable-state validation failure.
739#[derive(Clone, Debug, Eq, PartialEq)]
740#[non_exhaustive]
741pub enum StateError {
742    /// A schema identifier was empty.
743    EmptySchemaId,
744    /// A schema identifier exceeded its UTF-8 byte limit.
745    SchemaIdTooLong {
746        /// Maximum accepted UTF-8 bytes.
747        max_bytes: usize,
748    },
749    /// A schema identifier had surrounding whitespace.
750    SchemaIdHasSurroundingWhitespace,
751    /// A schema identifier contained a control character.
752    SchemaIdContainsControl,
753    /// A schema version was zero.
754    ZeroSchemaVersion,
755    /// A configured byte limit was zero or above the durable hard ceiling.
756    InvalidByteLimit {
757        /// Largest configurable byte limit.
758        maximum: usize,
759    },
760    /// A configured depth limit was zero or above the durable hard ceiling.
761    InvalidDepthLimit {
762        /// Largest configurable JSON depth.
763        maximum: usize,
764    },
765    /// A serialized value exceeded its configured byte limit.
766    TooLarge {
767        /// Durable state category.
768        kind: DurableStateKind,
769        /// Configured maximum bytes.
770        max_bytes: usize,
771    },
772    /// A serialized value exceeded its configured JSON depth.
773    TooDeep {
774        /// Durable state category.
775        kind: DurableStateKind,
776        /// Configured maximum depth.
777        max_depth: usize,
778    },
779    /// The bytes were not a valid framework envelope.
780    Malformed {
781        /// Durable state category.
782        kind: DurableStateKind,
783    },
784    /// The envelope belongs to the other durable-state category.
785    FormatMismatch {
786        /// Expected durable state category.
787        kind: DurableStateKind,
788    },
789    /// The framework envelope format version is unsupported.
790    UnsupportedFormatVersion {
791        /// Durable state category.
792        kind: DurableStateKind,
793        /// Version observed in durable data.
794        version: u16,
795    },
796    /// The envelope schema does not match the selected codec.
797    SchemaMismatch {
798        /// Durable state category.
799        kind: DurableStateKind,
800    },
801    /// A declared upgrade did not strictly increase the schema version.
802    NonIncreasingUpgrade {
803        /// Version the rejected edge reads.
804        from: StateSchemaVersion,
805        /// Version the rejected edge claims to produce.
806        to: StateSchemaVersion,
807    },
808    /// The codec declares no directed upgrade reaching its current version.
809    NoUpgradePath {
810        /// Durable state category.
811        kind: DurableStateKind,
812        /// Version the chain stalled at.
813        found: StateSchemaVersion,
814        /// Current version supported by the selected codec.
815        current: StateSchemaVersion,
816    },
817    /// Two declared upgrades leave the same version, so the chain is not
818    /// deterministic.
819    AmbiguousUpgrade {
820        /// Durable state category.
821        kind: DurableStateKind,
822        /// Version left by more than one declared upgrade.
823        from: StateSchemaVersion,
824    },
825    /// A declared upgrade produces a version past the codec's current version.
826    UpgradeOvershootsCurrent {
827        /// Durable state category.
828        kind: DurableStateKind,
829        /// Version the rejected edge produces.
830        to: StateSchemaVersion,
831        /// Current version supported by the selected codec.
832        current: StateSchemaVersion,
833    },
834    /// The resolved upgrade chain exceeded its bound.
835    UpgradeChainTooLong {
836        /// Durable state category.
837        kind: DurableStateKind,
838        /// Most upgrades one decode may apply.
839        max_upgrades: usize,
840    },
841    /// A declared upgrade returned bytes that are not valid JSON.
842    UpgradeProducedInvalidJson {
843        /// Durable state category.
844        kind: DurableStateKind,
845    },
846    /// Durable data was produced by a newer application schema.
847    UnsupportedSchemaVersion {
848        /// Durable state category.
849        kind: DurableStateKind,
850        /// Version observed in durable data.
851        found: StateSchemaVersion,
852        /// Current version supported by the selected codec.
853        current: StateSchemaVersion,
854    },
855    /// The application payload was not valid JSON.
856    InvalidPayload,
857    /// The application payload was valid JSON but not an object.
858    PayloadNotObject,
859    /// The application codec rejected the payload without exposing its value.
860    Codec(StateCodecError),
861}
862
863impl fmt::Display for StateError {
864    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
865        match self {
866            Self::EmptySchemaId => formatter.write_str("state schema identifier must not be empty"),
867            Self::SchemaIdTooLong { max_bytes } => {
868                write!(
869                    formatter,
870                    "state schema identifier exceeds {max_bytes} UTF-8 bytes"
871                )
872            }
873            Self::SchemaIdHasSurroundingWhitespace => {
874                formatter.write_str("state schema identifier has surrounding whitespace")
875            }
876            Self::SchemaIdContainsControl => {
877                formatter.write_str("state schema identifier contains a control character")
878            }
879            Self::ZeroSchemaVersion => formatter.write_str("state schema version must be nonzero"),
880            Self::InvalidByteLimit { maximum } => {
881                write!(
882                    formatter,
883                    "state byte limit must be between 1 and {maximum}"
884                )
885            }
886            Self::InvalidDepthLimit { maximum } => {
887                write!(
888                    formatter,
889                    "state depth limit must be between 1 and {maximum}"
890                )
891            }
892            Self::TooLarge { kind, max_bytes } => {
893                write!(formatter, "{kind} exceeds {max_bytes} bytes")
894            }
895            Self::TooDeep { kind, max_depth } => {
896                write!(formatter, "{kind} exceeds JSON depth {max_depth}")
897            }
898            Self::Malformed { kind } => write!(formatter, "{kind} is malformed"),
899            Self::FormatMismatch { kind } => {
900                write!(formatter, "durable state is not a {kind}")
901            }
902            Self::UnsupportedFormatVersion { kind, .. } => {
903                write!(formatter, "{kind} format version is unsupported")
904            }
905            Self::SchemaMismatch { kind } => {
906                write!(formatter, "{kind} schema does not match the component")
907            }
908            Self::NonIncreasingUpgrade { .. } => {
909                formatter.write_str("state schema upgrade must increase the version")
910            }
911            Self::NoUpgradePath { kind, .. } => {
912                write!(formatter, "{kind} schema version has no upgrade path")
913            }
914            Self::AmbiguousUpgrade { kind, .. } => {
915                write!(formatter, "{kind} schema upgrade is ambiguous")
916            }
917            Self::UpgradeOvershootsCurrent { kind, .. } => {
918                write!(
919                    formatter,
920                    "{kind} schema upgrade passes the current version"
921                )
922            }
923            Self::UpgradeChainTooLong { kind, max_upgrades } => {
924                write!(
925                    formatter,
926                    "{kind} schema upgrade chain exceeds {max_upgrades} upgrades"
927                )
928            }
929            Self::UpgradeProducedInvalidJson { kind } => {
930                write!(formatter, "{kind} schema upgrade produced invalid JSON")
931            }
932            Self::UnsupportedSchemaVersion { kind, .. } => {
933                write!(formatter, "{kind} schema version is unsupported")
934            }
935            Self::InvalidPayload => formatter.write_str("durable state payload is not valid JSON"),
936            Self::PayloadNotObject => {
937                formatter.write_str("durable state payload must be a JSON object")
938            }
939            Self::Codec(error) => error.fmt(formatter),
940        }
941    }
942}
943
944impl Error for StateError {
945    fn source(&self) -> Option<&(dyn Error + 'static)> {
946        match self {
947            Self::Codec(error) => Some(error),
948            _ => None,
949        }
950    }
951}