Skip to main content

zeph_durable/
error.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The crate-wide error type.
5//!
6//! [`DurableError`] never carries payload bytes or resolver tokens in its messages (INV-5): every
7//! variant reports metadata only, so an error can be logged without leaking sealed content.
8
9use crate::ids::StepId;
10
11/// An error raised by the durable execution layer.
12///
13/// The enum is `#[non_exhaustive]`: follow-up issues add variants as runtime behavior lands, and
14/// downstream `match` expressions must keep a wildcard arm.
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum DurableError {
18    /// The replayed step's descriptor fingerprint did not match the fingerprint journaled for this
19    /// [`StepId`] (INV-3). The execution is discarded and restarted fresh rather than returning a
20    /// result for a structurally different step.
21    #[error("replay divergence at step {step_id}: journaled descriptor fingerprint mismatch")]
22    ReplayDivergence {
23        /// The step whose fingerprint diverged.
24        step_id: StepId,
25    },
26
27    /// A destructive or security-relevant `ExactlyOnceGuarded` step was constructed without an
28    /// explicit ambiguity policy. The safety decision must be made at the call site, not deferred
29    /// to a runtime default.
30    #[error("step '{step}' requires an explicit on_ambiguous policy for its effect class")]
31    AmbiguityPolicyRequired {
32        /// The name of the offending step descriptor.
33        step: &'static str,
34    },
35
36    /// The journal writer did not acknowledge an append within the configured timeout, or is
37    /// otherwise unreachable. The calling path degrades to non-durable mode rather than hanging
38    /// (INV-12).
39    #[error("journal writer unavailable: append was not acknowledged in time")]
40    JournalUnavailable,
41
42    /// A payload exceeded the configured `max_payload_bytes` limit. Enforced on both append and
43    /// read; it fails closed and never panics (INV-11).
44    #[error("payload of {size} bytes exceeds the {max}-byte limit")]
45    PayloadTooLarge {
46        /// The size of the offending payload, in bytes.
47        size: u64,
48        /// The configured maximum payload size, in bytes.
49        max: u64,
50    },
51
52    /// A journal entry could not be decoded: corrupt, truncated, or written under an unknown wire
53    /// format version. Fails closed.
54    #[error("failed to decode journal entry: {context}")]
55    Decode {
56        /// A non-sensitive description of the decode failure.
57        context: &'static str,
58    },
59
60    /// AEAD authentication failed when opening a sealed payload: the entry was forged, moved to a
61    /// different step, or replayed under a different execution. Fails closed.
62    #[error("replay integrity check failed: sealed payload did not authenticate")]
63    ReplayIntegrity,
64
65    /// An execution exceeded the hard per-execution step cap and was aborted rather than allowed to
66    /// grow unboundedly.
67    #[error("execution exceeded the step cap of {cap} steps")]
68    StepCapExceeded {
69        /// The configured hard step cap.
70        cap: u32,
71    },
72
73    /// AEAD payload encryption was disabled (`encrypt_payload = false`) for a deployment where it
74    /// is mandatory — a non-local backend or a shared database (INV-8). The DB-file trust boundary
75    /// does not hold in multi-client environments, so this fails closed at startup.
76    #[error(
77        "AEAD payload encryption is required for the '{context}' deployment and cannot be disabled"
78    )]
79    EncryptionRequired {
80        /// A non-sensitive label for the deployment that mandates encryption (e.g. `"restate"` or
81        /// `"shared-database"`).
82        context: &'static str,
83    },
84
85    /// A journal entry of a kind whose persistence is provided by a higher layer not yet wired into
86    /// this backend revision. Promise, timer, and checkpoint entries land with the promise/timer and
87    /// retention layers; until then the backend fails closed rather than silently dropping the
88    /// entry's kind-specific state.
89    #[error("journal persistence for '{kind}' entries is not available in this backend revision")]
90    UnsupportedEntryKind {
91        /// The `entry_kind` tag of the entry whose persistence is deferred.
92        kind: &'static str,
93    },
94
95    /// A journal storage operation failed at the database layer (connection, migration, or query).
96    ///
97    /// The static `op` names the failing operation; the underlying database error is attached as
98    /// the error source. Per INV-5 the `Display` message carries only the operation name — the
99    /// boxed source never contains plaintext payloads, since every bind is ciphertext, a hash, or a
100    /// non-secret descriptor.
101    #[error("durable storage operation '{op}' failed")]
102    Storage {
103        /// The static name of the failing operation (e.g. `"append"`, `"finalize"`, `"open"`).
104        op: &'static str,
105        /// The underlying database error.
106        #[source]
107        source: Box<dyn std::error::Error + Send + Sync>,
108    },
109
110    /// A step's operation closure returned an error on a fresh execution. The step did not complete,
111    /// so no `StepResult` is journaled; on a later resume the step re-runs (or, for a guarded effect,
112    /// its [`OnAmbiguous`](crate::OnAmbiguous) policy applies). The closure's own error is attached
113    /// as the source.
114    #[error("step '{step}' operation failed")]
115    StepFailed {
116        /// The name of the step whose operation closure failed.
117        step: &'static str,
118        /// The closure's underlying error.
119        #[source]
120        source: Box<dyn std::error::Error + Send + Sync>,
121    },
122
123    /// A guarded step resumed inside the ambiguous window (an `EffectIntent` is journaled but no
124    /// `StepResult`) and its policy is [`OnAmbiguous::Fail`](crate::OnAmbiguous::Fail): the layer
125    /// refuses to guess whether the irreversible effect fired and surfaces the decision to the
126    /// operator instead of re-running or skipping it.
127    #[error("step {step_id} resumed in the ambiguous window and its on_ambiguous policy is 'fail'")]
128    AmbiguousEffect {
129        /// The step caught in the ambiguous window.
130        step_id: StepId,
131    },
132
133    /// A step result could not be serialized into journal bytes before sealing. The step's value is
134    /// the consumer's serializable type, so this indicates a faulty `Serialize` implementation; it
135    /// fails closed rather than journaling a partial payload. Per INV-5 only the step name is named.
136    #[error("step '{step}' result could not be serialized for the journal")]
137    Serialize {
138        /// The name of the step whose result failed to serialize.
139        step: &'static str,
140    },
141
142    /// A promise resolution referenced a promise that has no `durable_promises` row — either never
143    /// created, or pruned. Fails closed rather than silently succeeding. Per INV-5 the raw
144    /// `PromiseId` is semi-sensitive and is therefore not embedded in the message.
145    #[error("promise resolution failed: no such promise")]
146    UnknownPromise,
147
148    /// A promise resolution presented a resolver token that did not match the stored hash (INV-9).
149    /// The comparison is constant-time, and neither the presented token nor the raw `PromiseId`
150    /// appears in the message (INV-5). The pending promise is left untouched.
151    #[error("promise resolution rejected: resolver token did not authenticate")]
152    PromiseRejected,
153}
154
155impl DurableError {
156    /// Wrap a database-layer failure as a [`DurableError::Storage`] for the named operation.
157    ///
158    /// Used at every `zeph-db` call site so storage failures carry a stable, greppable operation
159    /// label while the original error remains reachable via [`std::error::Error::source`].
160    pub(crate) fn storage(
161        op: &'static str,
162        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
163    ) -> Self {
164        Self::Storage {
165            op,
166            source: source.into(),
167        }
168    }
169
170    /// Wrap a step operation closure's failure as a [`DurableError::StepFailed`].
171    ///
172    /// Keeps the originating error reachable via [`std::error::Error::source`] while the `Display`
173    /// line stays metadata-only (INV-5).
174    pub(crate) fn step_failed(step: &'static str, source: crate::step::StepError) -> Self {
175        Self::StepFailed {
176            step,
177            source: source.into_inner(),
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn messages_are_metadata_only() {
188        let err = DurableError::PayloadTooLarge {
189            size: 2_000_000,
190            max: 1_048_576,
191        };
192        let rendered = err.to_string();
193        assert!(rendered.contains("2000000"));
194        assert!(rendered.contains("1048576"));
195    }
196
197    #[test]
198    fn replay_divergence_reports_step() {
199        let err = DurableError::ReplayDivergence {
200            step_id: StepId::new(12),
201        };
202        assert!(err.to_string().contains("step 12"));
203    }
204
205    #[test]
206    fn storage_message_names_op_but_not_the_source_detail() {
207        let inner = std::io::Error::other("secret-bind-value");
208        let err = DurableError::storage("append", inner);
209        let rendered = err.to_string();
210        assert!(rendered.contains("append"));
211        // The top-line message is metadata-only: the source detail is reachable via `source()`,
212        // never inlined into Display (INV-5).
213        assert!(!rendered.contains("secret-bind-value"));
214        assert!(std::error::Error::source(&err).is_some());
215    }
216
217    #[test]
218    fn step_failed_names_step_but_not_the_source_detail() {
219        let err = DurableError::step_failed(
220            "transfer_funds",
221            crate::step::StepError::new("secret-operation-detail"),
222        );
223        let rendered = err.to_string();
224        assert!(rendered.contains("transfer_funds"));
225        assert!(!rendered.contains("secret-operation-detail"));
226        assert!(std::error::Error::source(&err).is_some());
227    }
228
229    #[test]
230    fn ambiguous_and_serialize_messages_are_metadata_only() {
231        let ambiguous = DurableError::AmbiguousEffect {
232            step_id: StepId::new(4),
233        };
234        assert!(ambiguous.to_string().contains("step 4"));
235
236        let serialize = DurableError::Serialize { step: "persist" };
237        assert!(serialize.to_string().contains("persist"));
238    }
239}