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}