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}