Skip to main content

zeph_durable/
effect.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The per-step side-effect contract.
5//!
6//! [`EffectClass`] declares how a step's side effect behaves under replay. It is the foundation of
7//! the exactly-once machinery: the replay cursor uses it to decide whether a journaled result may
8//! be returned without re-running the operation.
9//!
10//! For [`EffectClass::ExactlyOnceGuarded`] steps the contract is sharper: an
11//! [`EffectIntentSubClass`] further classifies *what kind* of side effect the step performs, and an
12//! [`OnAmbiguous`] policy decides what to do when a crash leaves the journal in the *ambiguous
13//! window* — an `EffectIntent` committed, but no `StepResult`, so it is unknown whether the external
14//! effect actually fired. The combination is enforced at construction time
15//! ([`crate::StepDescriptor`]): a destructive, security-relevant, money-moving, or custom guarded
16//! step that omits an explicit [`OnAmbiguous`] is rejected with
17//! [`DurableError::AmbiguityPolicyRequired`](crate::DurableError::AmbiguityPolicyRequired), forcing
18//! the safety decision to the call site rather than a silent runtime default (FR-DE-09).
19
20/// How a step's side effect behaves under replay.
21///
22/// This classification is recorded with every step result so the replay cursor can reason about
23/// re-execution safety without inspecting the payload.
24///
25/// # Examples
26///
27/// ```
28/// use zeph_durable::EffectClass;
29///
30/// // A pure or naturally-idempotent step is safe to skip on replay.
31/// assert_eq!(EffectClass::Idempotent.as_str(), "idempotent");
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum EffectClass {
35    /// The operation is pure or naturally idempotent: a replayed step returns the journaled result
36    /// and never invokes the operation closure again (INV-10).
37    Idempotent,
38    /// The operation tolerates being run more than once. On an ambiguous replay it may be re-run
39    /// without correctness loss, accepting at-least-once delivery.
40    AtLeastOnce,
41    /// The operation must run exactly once. It is fenced by an [`crate::IdempotencyKey`] and an
42    /// explicit ambiguity policy; a replayed guarded step never re-fires a committed effect.
43    ExactlyOnceGuarded,
44}
45
46impl EffectClass {
47    /// Return the canonical lower-snake-case string used in the `effect_class` journal column.
48    #[must_use]
49    pub fn as_str(self) -> &'static str {
50        match self {
51            Self::Idempotent => "idempotent",
52            Self::AtLeastOnce => "at_least_once",
53            Self::ExactlyOnceGuarded => "exactly_once_guarded",
54        }
55    }
56
57    /// Parse the canonical `effect_class` column string back into an [`EffectClass`].
58    ///
59    /// Returns `None` for an unrecognized tag so a corrupt journal row fails closed rather than
60    /// defaulting to a weaker effect class.
61    pub(crate) fn from_tag(tag: &str) -> Option<Self> {
62        match tag {
63            "idempotent" => Some(Self::Idempotent),
64            "at_least_once" => Some(Self::AtLeastOnce),
65            "exactly_once_guarded" => Some(Self::ExactlyOnceGuarded),
66            _ => None,
67        }
68    }
69}
70
71/// What an [`EffectClass::ExactlyOnceGuarded`] step actually does, refining the ambiguity policy.
72///
73/// The sub-class drives the construction-time policy rule: a guarded step whose effect is
74/// destructive, security-relevant, money-moving, or custom MUST carry an explicit [`OnAmbiguous`]
75/// (FR-DE-09); only a cost-bearing / boundary-idempotent effect gets a safe default
76/// ([`OnAmbiguous::Skip`]). The sub-class is consumed when the descriptor is built and never stored
77/// on the journal row — the persisted classification is the coarser [`EffectClass`].
78///
79/// # Examples
80///
81/// ```
82/// use zeph_durable::EffectIntentSubClass;
83///
84/// // A paid LLM call carrying a provider idempotency header is safe to skip on an ambiguous replay.
85/// assert!(!EffectIntentSubClass::CostBearingOrBoundaryIdempotent.requires_explicit_policy());
86/// // A fund transfer must declare its ambiguity policy explicitly.
87/// assert!(EffectIntentSubClass::MoneyMoving.requires_explicit_policy());
88/// ```
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub enum EffectIntentSubClass {
91    /// A paid or rate-limited boundary effect that the external service deduplicates by
92    /// idempotency key (e.g. a paid LLM call). Default ambiguity policy: [`OnAmbiguous::Skip`].
93    CostBearingOrBoundaryIdempotent,
94    /// An irreversible mutation (file delete, record drop). Requires an explicit policy.
95    Destructive,
96    /// A permission or credential mutation. Requires an explicit policy.
97    SecurityRelevant,
98    /// A financial transfer. Requires an explicit policy.
99    MoneyMoving,
100    /// A caller-defined effect with no built-in default. Requires an explicit policy.
101    Custom,
102}
103
104impl EffectIntentSubClass {
105    /// Whether a guarded step of this sub-class MUST be given an explicit [`OnAmbiguous`] policy.
106    ///
107    /// Only [`EffectIntentSubClass::CostBearingOrBoundaryIdempotent`] has a safe default; every
108    /// other sub-class forces the decision to the call site.
109    #[must_use]
110    pub fn requires_explicit_policy(self) -> bool {
111        !matches!(self, Self::CostBearingOrBoundaryIdempotent)
112    }
113
114    /// Return the canonical lower-snake-case string for diagnostics and audit records.
115    #[must_use]
116    pub fn as_str(self) -> &'static str {
117        match self {
118            Self::CostBearingOrBoundaryIdempotent => "cost_bearing_or_boundary_idempotent",
119            Self::Destructive => "destructive",
120            Self::SecurityRelevant => "security_relevant",
121            Self::MoneyMoving => "money_moving",
122            Self::Custom => "custom",
123        }
124    }
125}
126
127/// What to do when a guarded step resumes inside the *ambiguous window*.
128///
129/// The ambiguous window is the gap between committing the `EffectIntent` and committing the
130/// `StepResult`: on resume the journal proves the effect was *about to* fire, but not whether it
131/// did. The policy resolves that uncertainty. Every resolution emits a mandatory structured audit
132/// record (FR-DE-10).
133///
134/// # Examples
135///
136/// ```
137/// use zeph_durable::OnAmbiguous;
138///
139/// assert_eq!(OnAmbiguous::Skip.as_str(), "skip");
140/// ```
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
142pub enum OnAmbiguous {
143    /// Assume the effect happened. Safe for cost-bearing / boundary-idempotent effects, where the
144    /// external service deduplicates the re-issued operation by its idempotency key, so re-running
145    /// the closure cannot double-apply the effect (it is deduplicated at the boundary).
146    Skip,
147    /// Surface the ambiguity to the operator with [`DurableError::AmbiguousEffect`]. The required
148    /// choice for destructive and security-relevant effects, where guessing is unacceptable.
149    ///
150    /// [`DurableError::AmbiguousEffect`]: crate::DurableError::AmbiguousEffect
151    Fail,
152    /// Assume the effect did *not* happen and re-run the closure. For effects misclassified as
153    /// guarded that are in fact safe to repeat.
154    Rerun,
155}
156
157impl OnAmbiguous {
158    /// Return the canonical lower-snake-case string used in the mandatory audit record.
159    #[must_use]
160    pub fn as_str(self) -> &'static str {
161        match self {
162            Self::Skip => "skip",
163            Self::Fail => "fail",
164            Self::Rerun => "rerun",
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn effect_class_as_str_is_stable() {
175        assert_eq!(EffectClass::Idempotent.as_str(), "idempotent");
176        assert_eq!(EffectClass::AtLeastOnce.as_str(), "at_least_once");
177        assert_eq!(
178            EffectClass::ExactlyOnceGuarded.as_str(),
179            "exactly_once_guarded"
180        );
181    }
182
183    #[test]
184    fn only_cost_bearing_subclass_has_a_default_policy() {
185        assert!(!EffectIntentSubClass::CostBearingOrBoundaryIdempotent.requires_explicit_policy());
186        for sub in [
187            EffectIntentSubClass::Destructive,
188            EffectIntentSubClass::SecurityRelevant,
189            EffectIntentSubClass::MoneyMoving,
190            EffectIntentSubClass::Custom,
191        ] {
192            assert!(
193                sub.requires_explicit_policy(),
194                "{} must require an explicit ambiguity policy",
195                sub.as_str()
196            );
197        }
198    }
199
200    #[test]
201    fn subclass_and_policy_strings_are_stable() {
202        assert_eq!(
203            EffectIntentSubClass::CostBearingOrBoundaryIdempotent.as_str(),
204            "cost_bearing_or_boundary_idempotent"
205        );
206        assert_eq!(EffectIntentSubClass::Destructive.as_str(), "destructive");
207        assert_eq!(OnAmbiguous::Skip.as_str(), "skip");
208        assert_eq!(OnAmbiguous::Fail.as_str(), "fail");
209        assert_eq!(OnAmbiguous::Rerun.as_str(), "rerun");
210    }
211}