Skip to main content

zeph_durable/
step.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The durable step primitive and its typestate.
5//!
6//! A *step* is the unit of durable progress: [`DurableContext::step`](crate::DurableContext::step)
7//! runs an operation closure, journals its result, and on a later resume returns the journaled
8//! result instead of re-running the closure. This module defines the types that describe and carry a
9//! step:
10//!
11//! - [`StepDescriptor`] — the *what* of a step: its name, [`EffectClass`], ambiguity policy, and an
12//!   opaque operation fingerprint. Its constructors enforce the construction-time ambiguity rule
13//!   (FR-DE-09): a destructive, security-relevant, money-moving, or custom guarded step that omits
14//!   an [`OnAmbiguous`] policy is rejected with [`DurableError::AmbiguityPolicyRequired`].
15//! - [`StepHandle`] — handed to the operation closure so it can forward the step's
16//!   [`IdempotencyKey`] to an external service as an `Idempotency-Key` header for boundary dedup.
17//! - [`StepError`] — the closure's failure channel: any error type the closure produces is wrapped
18//!   here without coupling the Layer-0 crate to a consumer's error enum (INV-1).
19//! - [`StepOutcome`] — the `Live` / `Replayed` typestate that lets a consumer suppress
20//!   already-emitted side effects (e.g. re-printing assistant output) on replay.
21//! - [`DurableStep`] — the recorded result of a step: its id, idempotency key, and outcome.
22//!
23//! The payload codec is JSON: a step value is serialized to bytes, length-checked, then handed to
24//! the journal where the backend AEAD-seals it. The bytes are opaque to the journal — the durable
25//! layer never inspects a domain type (INV-1).
26
27use std::fmt;
28use std::marker::PhantomData;
29
30use bytes::Bytes;
31use serde::Serialize;
32use serde::de::DeserializeOwned;
33
34use crate::effect::{EffectClass, EffectIntentSubClass, OnAmbiguous};
35use crate::error::DurableError;
36use crate::ids::{IdempotencyKey, StepId};
37
38/// Wire-format version stamped on every sealed step payload.
39///
40/// Stored in the `payload_version` journal column so a future codec change can be detected and
41/// migrated rather than silently misread.
42pub(crate) const PAYLOAD_VERSION: u8 = 1;
43
44/// The error channel for a step's operation closure.
45///
46/// The durable layer is Layer-0 infrastructure and must not depend on any consumer's error enum
47/// (INV-1), so a closure reports failure through this opaque wrapper. Construct it from any boxable
48/// error (or a message) with [`StepError::new`]; the wrapped error stays reachable through
49/// [`DurableError::StepFailed`]'s source.
50///
51/// # Examples
52///
53/// ```
54/// use zeph_durable::StepError;
55///
56/// // From a message:
57/// let _ = StepError::new("provider returned 503");
58/// // From a concrete error:
59/// let io = std::io::Error::other("disk full");
60/// let _ = StepError::new(io);
61/// ```
62pub struct StepError(Box<dyn std::error::Error + Send + Sync>);
63
64impl StepError {
65    /// Wrap any boxable error (including a `&str` or `String` message) as a step failure.
66    #[must_use]
67    pub fn new(source: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> Self {
68        Self(source.into())
69    }
70
71    /// Consume the wrapper, returning the boxed source error.
72    pub(crate) fn into_inner(self) -> Box<dyn std::error::Error + Send + Sync> {
73        self.0
74    }
75}
76
77impl fmt::Debug for StepError {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.debug_tuple("StepError").field(&self.0).finish()
80    }
81}
82
83impl fmt::Display for StepError {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        fmt::Display::fmt(&self.0, f)
86    }
87}
88
89/// The description of a step: its identity, effect contract, and ambiguity policy.
90///
91/// A descriptor is *what* a step is, independent of *when* it runs. The durable layer derives the
92/// step's [`IdempotencyKey`] and replay-divergence fingerprint from the descriptor, so the same
93/// program point must build an equal descriptor on every run — a structurally different descriptor
94/// at a given [`StepId`] is a [`DurableError::ReplayDivergence`].
95///
96/// Build a descriptor through the effect-specific constructors; the
97/// [`exactly_once_guarded`](StepDescriptor::exactly_once_guarded) constructor enforces the
98/// construction-time ambiguity rule (FR-DE-09).
99///
100/// # Examples
101///
102/// ```
103/// use zeph_durable::{EffectIntentSubClass, OnAmbiguous, StepDescriptor};
104///
105/// // A read-only step is idempotent and needs no ambiguity policy.
106/// let read = StepDescriptor::idempotent("read_file", b"tool:read_file:/etc/hosts".to_vec());
107///
108/// // A destructive guarded step MUST declare its ambiguity policy or construction fails.
109/// let delete = StepDescriptor::exactly_once_guarded(
110///     "delete_file",
111///     EffectIntentSubClass::Destructive,
112///     Some(OnAmbiguous::Fail),
113///     b"tool:delete_file:/tmp/x".to_vec(),
114/// );
115/// assert!(delete.is_ok());
116///
117/// let unsafe_delete = StepDescriptor::exactly_once_guarded(
118///     "delete_file",
119///     EffectIntentSubClass::Destructive,
120///     None,
121///     b"tool:delete_file:/tmp/x".to_vec(),
122/// );
123/// assert!(unsafe_delete.is_err(), "a destructive guarded step needs an explicit policy");
124/// ```
125#[derive(Debug, Clone)]
126pub struct StepDescriptor {
127    name: &'static str,
128    effect: EffectClass,
129    on_ambiguous: Option<OnAmbiguous>,
130    op_fingerprint: Bytes,
131}
132
133impl StepDescriptor {
134    /// Describe an [`EffectClass::Idempotent`] step (pure or naturally repeatable).
135    ///
136    /// A replayed idempotent step returns its journaled result and never re-invokes the closure
137    /// (INV-10). No ambiguity policy applies.
138    #[must_use]
139    pub fn idempotent(name: &'static str, op_fingerprint: impl Into<Bytes>) -> Self {
140        Self {
141            name,
142            effect: EffectClass::Idempotent,
143            on_ambiguous: None,
144            op_fingerprint: op_fingerprint.into(),
145        }
146    }
147
148    /// Describe an [`EffectClass::AtLeastOnce`] step (safe to repeat under an ambiguous replay).
149    #[must_use]
150    pub fn at_least_once(name: &'static str, op_fingerprint: impl Into<Bytes>) -> Self {
151        Self {
152            name,
153            effect: EffectClass::AtLeastOnce,
154            on_ambiguous: None,
155            op_fingerprint: op_fingerprint.into(),
156        }
157    }
158
159    /// Describe an [`EffectClass::ExactlyOnceGuarded`] step, enforcing the ambiguity-policy rule.
160    ///
161    /// The `sub_class` refines what the effect does; the resulting [`OnAmbiguous`] policy decides
162    /// what happens if a crash leaves the step in the ambiguous window. Only
163    /// [`EffectIntentSubClass::CostBearingOrBoundaryIdempotent`] has a safe default
164    /// ([`OnAmbiguous::Skip`]); every other sub-class requires an explicit policy.
165    ///
166    /// # Errors
167    ///
168    /// Returns [`DurableError::AmbiguityPolicyRequired`] when `sub_class` requires an explicit
169    /// policy ([`EffectIntentSubClass::requires_explicit_policy`]) but `on_ambiguous` is `None`.
170    pub fn exactly_once_guarded(
171        name: &'static str,
172        sub_class: EffectIntentSubClass,
173        on_ambiguous: Option<OnAmbiguous>,
174        op_fingerprint: impl Into<Bytes>,
175    ) -> Result<Self, DurableError> {
176        let resolved = match on_ambiguous {
177            Some(policy) => policy,
178            None if sub_class.requires_explicit_policy() => {
179                return Err(DurableError::AmbiguityPolicyRequired { step: name });
180            }
181            // Only the cost-bearing / boundary-idempotent sub-class reaches here: a paid call the
182            // external boundary deduplicates by idempotency key is safe to skip on ambiguity.
183            None => OnAmbiguous::Skip,
184        };
185        Ok(Self {
186            name,
187            effect: EffectClass::ExactlyOnceGuarded,
188            on_ambiguous: Some(resolved),
189            op_fingerprint: op_fingerprint.into(),
190        })
191    }
192
193    /// The step's stable name (used in spans, audit records, and error messages).
194    #[must_use]
195    pub fn name(&self) -> &'static str {
196        self.name
197    }
198
199    /// The step's effect class.
200    #[must_use]
201    pub fn effect(&self) -> EffectClass {
202        self.effect
203    }
204
205    /// The resolved ambiguity policy, `Some` only for a guarded step.
206    #[must_use]
207    pub fn on_ambiguous(&self) -> Option<OnAmbiguous> {
208        self.on_ambiguous
209    }
210
211    /// The opaque, non-secret operation fingerprint (INV-6).
212    #[must_use]
213    pub fn op_fingerprint(&self) -> &Bytes {
214        &self.op_fingerprint
215    }
216
217    /// The length-delimited structural fingerprint fed to [`IdempotencyKey::derive`].
218    ///
219    /// Folding `name` and `effect` in (each length-prefixed, the variable `op_fingerprint` last)
220    /// makes the derived idempotency key the step's structural identity: changing any of them at a
221    /// given [`StepId`] changes the key, which the replay cursor detects as a divergence (INV-3).
222    /// The framing is injective so distinct descriptors never collide.
223    pub(crate) fn fingerprint_input(&self) -> Vec<u8> {
224        let effect = self.effect.as_str();
225        let mut input =
226            Vec::with_capacity(4 + self.name.len() + 4 + effect.len() + self.op_fingerprint.len());
227        input.extend_from_slice(&u32_len(self.name.len()).to_le_bytes());
228        input.extend_from_slice(self.name.as_bytes());
229        input.extend_from_slice(&u32_len(effect.len()).to_le_bytes());
230        input.extend_from_slice(effect.as_bytes());
231        input.extend_from_slice(&self.op_fingerprint);
232        input
233    }
234}
235
236/// A length cast that saturates rather than wrapping — fingerprint inputs are tiny, but the cast
237/// must never silently truncate a (pathological) oversized field into a colliding length prefix.
238fn u32_len(len: usize) -> u32 {
239    u32::try_from(len).unwrap_or(u32::MAX)
240}
241
242/// A handle passed to a step's operation closure.
243///
244/// Its purpose is boundary deduplication: the closure reads [`StepHandle::idempotency_key`] and
245/// forwards it to an external service (e.g. as an `Idempotency-Key` header) so a re-issued call
246/// after an ambiguous crash is deduplicated at the boundary. The handle is `Copy` and carries no
247/// secret material.
248///
249/// # Examples
250///
251/// ```
252/// use zeph_durable::{ExecutionId, IdempotencyKey, StepHandle, StepId};
253///
254/// # fn demo(handle: StepHandle) {
255/// // The closure can thread the idempotency key into an outbound request.
256/// let _header_value = handle.idempotency_key();
257/// let _which_step = handle.step_id();
258/// # }
259/// ```
260#[derive(Debug, Clone, Copy)]
261pub struct StepHandle {
262    step_id: StepId,
263    idempotency_key: IdempotencyKey,
264}
265
266impl StepHandle {
267    /// Construct a handle for the closure (crate-internal; built by the durable context).
268    pub(crate) fn new(step_id: StepId, idempotency_key: IdempotencyKey) -> Self {
269        Self {
270            step_id,
271            idempotency_key,
272        }
273    }
274
275    /// The step's deterministic position within its execution.
276    #[must_use]
277    pub fn step_id(&self) -> StepId {
278        self.step_id
279    }
280
281    /// The step's idempotency key, suitable for forwarding to an external boundary for dedup.
282    #[must_use]
283    pub fn idempotency_key(&self) -> IdempotencyKey {
284        self.idempotency_key
285    }
286}
287
288/// Whether a step's value came from a live run or from the journal.
289///
290/// Both variants carry the same `T`; the discriminator lets a consumer suppress already-emitted
291/// side effects on replay (the spec's `RuntimeLayer` double-print suppression) without the durable
292/// layer knowing what those side effects are.
293///
294/// # Examples
295///
296/// ```
297/// use zeph_durable::StepOutcome;
298///
299/// let live = StepOutcome::Live(7);
300/// assert!(!live.was_replayed());
301/// assert_eq!(*live.get(), 7);
302/// assert_eq!(live.into_inner(), 7);
303/// ```
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum StepOutcome<T> {
306    /// The operation closure ran on this execution and produced the value.
307    Live(T),
308    /// The value was returned from the journal; the closure was not invoked.
309    Replayed(T),
310}
311
312impl<T> StepOutcome<T> {
313    /// Whether the value was replayed from the journal rather than freshly computed.
314    #[must_use]
315    pub fn was_replayed(&self) -> bool {
316        matches!(self, Self::Replayed(_))
317    }
318
319    /// Borrow the contained value regardless of provenance.
320    #[must_use]
321    pub fn get(&self) -> &T {
322        match self {
323            Self::Live(value) | Self::Replayed(value) => value,
324        }
325    }
326
327    /// Consume the outcome and return the contained value.
328    #[must_use]
329    pub fn into_inner(self) -> T {
330        match self {
331            Self::Live(value) | Self::Replayed(value) => value,
332        }
333    }
334}
335
336/// The recorded result of a [`DurableContext::step`](crate::DurableContext::step) call.
337///
338/// Bundles the step's deterministic identity (its [`StepId`] and [`IdempotencyKey`]) with the
339/// [`StepOutcome`]. Most callers want only the value
340/// ([`DurableContext::step`](crate::DurableContext::step) returns it directly); take a
341/// `DurableStep` when the id, the key, or the live/replayed distinction matters.
342///
343/// `T` is recorded only as a type witness — the value lives inside the [`StepOutcome`].
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub struct DurableStep<T> {
346    step_id: StepId,
347    idempotency_key: IdempotencyKey,
348    outcome: StepOutcome<T>,
349    _marker: PhantomData<fn() -> T>,
350}
351
352impl<T> DurableStep<T> {
353    /// Build a record for a freshly-executed step.
354    pub(crate) fn live(step_id: StepId, idempotency_key: IdempotencyKey, value: T) -> Self {
355        Self {
356            step_id,
357            idempotency_key,
358            outcome: StepOutcome::Live(value),
359            _marker: PhantomData,
360        }
361    }
362
363    /// Build a record for a step whose value was replayed from the journal.
364    pub(crate) fn replayed(step_id: StepId, idempotency_key: IdempotencyKey, value: T) -> Self {
365        Self {
366            step_id,
367            idempotency_key,
368            outcome: StepOutcome::Replayed(value),
369            _marker: PhantomData,
370        }
371    }
372
373    /// The step's deterministic position within its execution.
374    #[must_use]
375    pub fn step_id(&self) -> StepId {
376        self.step_id
377    }
378
379    /// The step's idempotency key.
380    #[must_use]
381    pub fn idempotency_key(&self) -> IdempotencyKey {
382        self.idempotency_key
383    }
384
385    /// Whether the value was replayed from the journal.
386    #[must_use]
387    pub fn was_replayed(&self) -> bool {
388        self.outcome.was_replayed()
389    }
390
391    /// Borrow the step's value.
392    #[must_use]
393    pub fn value(&self) -> &T {
394        self.outcome.get()
395    }
396
397    /// Borrow the full outcome (value plus live/replayed provenance).
398    #[must_use]
399    pub fn outcome(&self) -> &StepOutcome<T> {
400        &self.outcome
401    }
402
403    /// Consume the record and return just the value.
404    #[must_use]
405    pub fn into_value(self) -> T {
406        self.outcome.into_inner()
407    }
408
409    /// Consume the record and return the full outcome.
410    #[must_use]
411    pub fn into_outcome(self) -> StepOutcome<T> {
412        self.outcome
413    }
414}
415
416/// Serialize a step value into journal bytes (the codec is JSON; the journal seals these opaquely).
417///
418/// # Errors
419///
420/// Returns [`DurableError::Serialize`] if the value cannot be serialized.
421pub(crate) fn serialize_result<T: Serialize>(
422    value: &T,
423    step: &'static str,
424) -> Result<Bytes, DurableError> {
425    serde_json::to_vec(value)
426        .map(Bytes::from)
427        .map_err(|_| DurableError::Serialize { step })
428}
429
430/// Deserialize journaled bytes back into a step value.
431///
432/// # Errors
433///
434/// Returns [`DurableError::Decode`] if the bytes cannot be decoded into `T`.
435pub(crate) fn deserialize_result<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, DurableError> {
436    serde_json::from_slice(bytes).map_err(|_| DurableError::Decode {
437        context: "step result payload could not be deserialized into its type",
438    })
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::ids::ExecutionId;
445    use std::assert_matches;
446
447    #[test]
448    fn guarded_destructive_requires_explicit_policy() {
449        let err = StepDescriptor::exactly_once_guarded(
450            "delete",
451            EffectIntentSubClass::Destructive,
452            None,
453            b"op".to_vec(),
454        )
455        .unwrap_err();
456        assert_matches!(
457            err,
458            DurableError::AmbiguityPolicyRequired { step: "delete" }
459        );
460    }
461
462    #[test]
463    fn guarded_cost_bearing_defaults_to_skip() {
464        let desc = StepDescriptor::exactly_once_guarded(
465            "llm_call",
466            EffectIntentSubClass::CostBearingOrBoundaryIdempotent,
467            None,
468            b"op".to_vec(),
469        )
470        .unwrap();
471        assert_eq!(desc.on_ambiguous(), Some(OnAmbiguous::Skip));
472        assert_eq!(desc.effect(), EffectClass::ExactlyOnceGuarded);
473    }
474
475    #[test]
476    fn guarded_explicit_policy_overrides_default() {
477        let desc = StepDescriptor::exactly_once_guarded(
478            "llm_call",
479            EffectIntentSubClass::CostBearingOrBoundaryIdempotent,
480            Some(OnAmbiguous::Rerun),
481            b"op".to_vec(),
482        )
483        .unwrap();
484        assert_eq!(desc.on_ambiguous(), Some(OnAmbiguous::Rerun));
485    }
486
487    #[test]
488    fn non_guarded_descriptors_have_no_policy() {
489        assert_eq!(
490            StepDescriptor::idempotent("read", b"op".to_vec()).on_ambiguous(),
491            None
492        );
493        assert_eq!(
494            StepDescriptor::at_least_once("enqueue", b"op".to_vec()).on_ambiguous(),
495            None
496        );
497    }
498
499    #[test]
500    fn fingerprint_input_is_injective_across_descriptor_fields() {
501        let base = StepDescriptor::idempotent("a", b"x".to_vec()).fingerprint_input();
502        // A different name with a fingerprint that would naively concatenate to the same bytes must
503        // still differ thanks to the length framing.
504        let shifted = StepDescriptor::idempotent("ax", b"".to_vec()).fingerprint_input();
505        assert_ne!(base, shifted);
506        // A different effect class changes the fingerprint even with identical name + op bytes.
507        let other_effect = StepDescriptor::at_least_once("a", b"x".to_vec()).fingerprint_input();
508        assert_ne!(base, other_effect);
509    }
510
511    #[test]
512    fn fingerprint_drives_idempotency_key_divergence() {
513        let exec = ExecutionId::new();
514        let step = StepId::new(0);
515        let a = IdempotencyKey::derive(
516            exec,
517            step,
518            &StepDescriptor::idempotent("a", b"x".to_vec()).fingerprint_input(),
519        );
520        let b = IdempotencyKey::derive(
521            exec,
522            step,
523            &StepDescriptor::idempotent("b", b"x".to_vec()).fingerprint_input(),
524        );
525        assert_ne!(
526            a, b,
527            "a different descriptor derives a different idempotency key"
528        );
529    }
530
531    #[test]
532    fn step_outcome_and_durable_step_accessors() {
533        let key = IdempotencyKey::derive(ExecutionId::new(), StepId::new(2), b"op");
534        let live = DurableStep::live(StepId::new(2), key, 41_u32);
535        assert_eq!(live.step_id(), StepId::new(2));
536        assert_eq!(live.idempotency_key(), key);
537        assert!(!live.was_replayed());
538        assert_eq!(*live.value(), 41);
539        assert_matches!(live.outcome(), StepOutcome::Live(41));
540        assert_eq!(live.into_value(), 41);
541
542        let replayed = DurableStep::replayed(StepId::new(3), key, 7_u32);
543        assert!(replayed.was_replayed());
544        assert_matches!(replayed.into_outcome(), StepOutcome::Replayed(7));
545    }
546
547    #[test]
548    fn payload_codec_round_trips() {
549        let bytes = serialize_result(&vec![1_u32, 2, 3], "step").unwrap();
550        let back: Vec<u32> = deserialize_result(&bytes).unwrap();
551        assert_eq!(back, vec![1, 2, 3]);
552    }
553
554    #[test]
555    fn deserialize_fails_closed_on_garbage() {
556        let err = deserialize_result::<u32>(b"not json").unwrap_err();
557        assert_matches!(err, DurableError::Decode { .. });
558    }
559
560    #[test]
561    fn step_error_wraps_message_and_concrete_error() {
562        assert_eq!(StepError::new("boom").to_string(), "boom");
563        let io = std::io::Error::other("disk full");
564        assert!(StepError::new(io).to_string().contains("disk full"));
565    }
566}