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::{ExecutionId, 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 /// A control entry's row-level HMAC (INV-8) did not verify against a recomputed value: the row
66 /// was forged, relocated to a different step/execution, or is missing its HMAC even though the
67 /// backend is keyed. Fails closed like [`ReplayIntegrity`](Self::ReplayIntegrity), but for
68 /// HMAC-authenticated control entries (`EffectIntent`) rather than AEAD-sealed payloads.
69 #[error("control-entry integrity check failed: row HMAC did not authenticate")]
70 ControlIntegrity,
71
72 /// An execution exceeded the hard per-execution step cap and was aborted rather than allowed to
73 /// grow unboundedly.
74 #[error("execution exceeded the step cap of {cap} steps")]
75 StepCapExceeded {
76 /// The configured hard step cap.
77 cap: u32,
78 },
79
80 /// AEAD payload encryption was disabled (`encrypt_payload = false`) for a deployment where it
81 /// is mandatory — a non-local backend or a shared database (INV-8). The DB-file trust boundary
82 /// does not hold in multi-client environments, so this fails closed at startup.
83 #[error(
84 "AEAD payload encryption is required for the '{context}' deployment and cannot be disabled"
85 )]
86 EncryptionRequired {
87 /// A non-sensitive label for the deployment that mandates encryption (e.g. `"restate"` or
88 /// `"shared-database"`).
89 context: &'static str,
90 },
91
92 /// A journal entry of a kind whose persistence is provided by a higher layer not yet wired into
93 /// this backend revision. Promise, timer, and checkpoint entries land with the promise/timer and
94 /// retention layers; until then the backend fails closed rather than silently dropping the
95 /// entry's kind-specific state.
96 #[error("journal persistence for '{kind}' entries is not available in this backend revision")]
97 UnsupportedEntryKind {
98 /// The `entry_kind` tag of the entry whose persistence is deferred.
99 kind: &'static str,
100 },
101
102 /// A journal storage operation failed at the database layer (connection, migration, or query).
103 ///
104 /// The static `op` names the failing operation; the underlying database error is attached as
105 /// the error source. Per INV-5 the `Display` message carries only the operation name — the
106 /// boxed source never contains plaintext payloads, since every bind is ciphertext, a hash, or a
107 /// non-secret descriptor.
108 #[error("durable storage operation '{op}' failed")]
109 Storage {
110 /// The static name of the failing operation (e.g. `"append"`, `"finalize"`, `"open"`).
111 op: &'static str,
112 /// The underlying database error.
113 #[source]
114 source: Box<dyn std::error::Error + Send + Sync>,
115 },
116
117 /// A step's operation closure returned an error on a fresh execution. The step did not complete,
118 /// so no `StepResult` is journaled; on a later resume the step re-runs (or, for a guarded effect,
119 /// its [`OnAmbiguous`](crate::OnAmbiguous) policy applies). The closure's own error is attached
120 /// as the source.
121 #[error("step '{step}' operation failed")]
122 StepFailed {
123 /// The name of the step whose operation closure failed.
124 step: &'static str,
125 /// The closure's underlying error.
126 #[source]
127 source: Box<dyn std::error::Error + Send + Sync>,
128 },
129
130 /// A guarded step resumed inside the ambiguous window (an `EffectIntent` is journaled but no
131 /// `StepResult`) and its policy is [`OnAmbiguous::Fail`](crate::OnAmbiguous::Fail): the layer
132 /// refuses to guess whether the irreversible effect fired and surfaces the decision to the
133 /// operator instead of re-running or skipping it.
134 #[error("step {step_id} resumed in the ambiguous window and its on_ambiguous policy is 'fail'")]
135 AmbiguousEffect {
136 /// The step caught in the ambiguous window.
137 step_id: StepId,
138 },
139
140 /// A step result could not be serialized into journal bytes before sealing. The step's value is
141 /// the consumer's serializable type, so this indicates a faulty `Serialize` implementation; it
142 /// fails closed rather than journaling a partial payload. Per INV-5 only the step name is named.
143 #[error("step '{step}' result could not be serialized for the journal")]
144 Serialize {
145 /// The name of the step whose result failed to serialize.
146 step: &'static str,
147 },
148
149 /// A promise resolution referenced a promise that has no `durable_promises` row — either never
150 /// created, or pruned. Fails closed rather than silently succeeding. Per INV-5 the raw
151 /// `PromiseId` is semi-sensitive and is therefore not embedded in the message.
152 #[error("promise resolution failed: no such promise")]
153 UnknownPromise,
154
155 /// A promise resolution presented a resolver token that did not match the stored hash (INV-9).
156 /// The comparison is constant-time, and neither the presented token nor the raw `PromiseId`
157 /// appears in the message (INV-5). The pending promise is left untouched.
158 #[error("promise resolution rejected: resolver token did not authenticate")]
159 PromiseRejected,
160
161 /// [`crate::backend::LocalBackend::open_execution_exclusive`] found another process already
162 /// holding the execution's advisory lock (INV-15, #6122).
163 ///
164 /// Two processes deriving the same `ExecutionId` (e.g. two CLI instances pointed at the same
165 /// `memory.sqlite_path` and the same `ConversationId`) can no longer both drive it
166 /// concurrently: the second process gets this error instead of silently racing the first into
167 /// `ReplayDivergence`/`ReplayIntegrity` failures. Distinct from those two variants so callers
168 /// (and operators reading logs) can tell "another live process owns this execution" apart from
169 /// "the journal itself is corrupt or was tampered with".
170 #[error("execution {execution_id} is already open in another process (pid {holder_pid})")]
171 ExecutionLocked {
172 /// The execution whose lock is already held.
173 execution_id: ExecutionId,
174 /// PID of the process currently holding the lock, or `0` if it could not be determined.
175 holder_pid: u32,
176 },
177}
178
179impl DurableError {
180 /// Wrap a database-layer failure as a [`DurableError::Storage`] for the named operation.
181 ///
182 /// Used at every `zeph-db` call site so storage failures carry a stable, greppable operation
183 /// label while the original error remains reachable via [`std::error::Error::source`].
184 pub(crate) fn storage(
185 op: &'static str,
186 source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
187 ) -> Self {
188 Self::Storage {
189 op,
190 source: source.into(),
191 }
192 }
193
194 /// Wrap a step operation closure's failure as a [`DurableError::StepFailed`].
195 ///
196 /// Keeps the originating error reachable via [`std::error::Error::source`] while the `Display`
197 /// line stays metadata-only (INV-5).
198 pub(crate) fn step_failed(step: &'static str, source: crate::step::StepError) -> Self {
199 Self::StepFailed {
200 step,
201 source: source.into_inner(),
202 }
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn messages_are_metadata_only() {
212 let err = DurableError::PayloadTooLarge {
213 size: 2_000_000,
214 max: 1_048_576,
215 };
216 let rendered = err.to_string();
217 assert!(rendered.contains("2000000"));
218 assert!(rendered.contains("1048576"));
219 }
220
221 #[test]
222 fn replay_divergence_reports_step() {
223 let err = DurableError::ReplayDivergence {
224 step_id: StepId::new(12),
225 };
226 assert!(err.to_string().contains("step 12"));
227 }
228
229 #[test]
230 fn storage_message_names_op_but_not_the_source_detail() {
231 let inner = std::io::Error::other("secret-bind-value");
232 let err = DurableError::storage("append", inner);
233 let rendered = err.to_string();
234 assert!(rendered.contains("append"));
235 // The top-line message is metadata-only: the source detail is reachable via `source()`,
236 // never inlined into Display (INV-5).
237 assert!(!rendered.contains("secret-bind-value"));
238 assert!(std::error::Error::source(&err).is_some());
239 }
240
241 #[test]
242 fn step_failed_names_step_but_not_the_source_detail() {
243 let err = DurableError::step_failed(
244 "transfer_funds",
245 crate::step::StepError::new("secret-operation-detail"),
246 );
247 let rendered = err.to_string();
248 assert!(rendered.contains("transfer_funds"));
249 assert!(!rendered.contains("secret-operation-detail"));
250 assert!(std::error::Error::source(&err).is_some());
251 }
252
253 #[test]
254 fn ambiguous_and_serialize_messages_are_metadata_only() {
255 let ambiguous = DurableError::AmbiguousEffect {
256 step_id: StepId::new(4),
257 };
258 assert!(ambiguous.to_string().contains("step 4"));
259
260 let serialize = DurableError::Serialize { step: "persist" };
261 assert!(serialize.to_string().contains("persist"));
262 }
263
264 #[test]
265 fn execution_locked_names_execution_and_holder_pid() {
266 let execution_id = ExecutionId::new();
267 let err = DurableError::ExecutionLocked {
268 execution_id,
269 holder_pid: 4242,
270 };
271 let rendered = err.to_string();
272 assert!(rendered.contains(&execution_id.to_string()));
273 assert!(rendered.contains("4242"));
274 }
275}