Skip to main content

zeph_durable/
handle.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The `&self` durable execution context.
5//!
6//! [`DurableContext`] is the front door to durable execution: a consumer wraps each unit of work in
7//! [`step`](DurableContext::step) (or a [`parallel`](DurableContext::parallel) batch) and the
8//! context journals it, replays it on resume, and enforces the exactly-once contract. The entire
9//! surface is `&self` — step ids come from an [`AtomicU32`], so concurrent steps under a single
10//! shared context are sound without a mutable borrow (system-invariants §10).
11//!
12//! # Deterministic step ids (INV-2)
13//!
14//! A step id is assigned the moment a step is *started*, in program order, via `fetch_add`. A
15//! [`ParallelScope`] assigns each child's id eagerly when the child future is constructed (before
16//! any of them is polled), so a parallel batch's ids are fixed by argument order and are independent
17//! of completion order. The same program therefore re-derives the same ids on replay.
18//!
19//! # Replay and the divergence guard (INV-3)
20//!
21//! On resume the program re-runs and each step consults the `ReplayCursor`:
22//!
23//! - a committed result replays without invoking the closure (INV-10);
24//! - an intent-only entry means the *ambiguous window* — the step's
25//!   [`OnAmbiguous`] policy decides (and a mandatory audit record is emitted, FR-DE-10);
26//! - nothing journaled means run fresh.
27//!
28//! Before replaying a result the context compares the journaled step's [`IdempotencyKey`] — the
29//! step's structural fingerprint — against the key derived from the *current* descriptor. A mismatch
30//! is a [`DurableError::ReplayDivergence`]: the journal is marked `aborted` and replay is disabled
31//! so the execution restarts fresh, never returning a result for a structurally different step.
32//!
33//! # Exactly-once and the ambiguous window (FR-DE-04, INV-13)
34//!
35//! A guarded step commits its `EffectIntent` (acknowledged) *before* the closure runs and its
36//! `StepResult` (acknowledged) *after*. If a replay divergence forces a fresh run, a guarded effect
37//! that already committed a result is recognized by its idempotency key (a point lookup) and its
38//! journaled value is returned rather than re-firing the effect.
39
40use std::fmt::Write as _;
41use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
42use std::sync::{Arc, Mutex};
43use std::time::{Duration, SystemTime};
44
45use rand::Rng as _;
46use serde::Serialize;
47use serde::de::DeserializeOwned;
48use tokio::task::JoinSet;
49use tracing::Instrument as _;
50use zeroize::Zeroizing;
51
52use crate::backend::local::now_unix_millis;
53use crate::backend::{DurableBackendEnum, ExecutionBackend as _};
54use crate::config::DurableConfig;
55use crate::effect::{EffectClass, OnAmbiguous};
56use crate::error::DurableError;
57use crate::ids::{ExecutionId, ExecutionKind, IdempotencyKey, PromiseId, StepId, TimerId};
58use crate::journal::{EntryKind, ExecutionStatus, Journal as _, JournalEntry};
59use crate::promise::{DurablePromise, RESOLVER_TOKEN_LEN, resolver_token_hash};
60use crate::replay::{DEFAULT_SEGMENT_STEPS, ReplayCursor, StepReplay};
61use crate::retention::step_cap_thresholds;
62use crate::step::{
63    DurableStep, PAYLOAD_VERSION, StepDescriptor, StepError, StepHandle, deserialize_result,
64    serialize_result,
65};
66use crate::waiters::wait_on_notify_or_poll;
67use crate::writer::JournalWriterHandle;
68
69/// The `&self` durable execution context handed to a consumer's program.
70///
71/// Construct it with [`DurableContext::new`] from a shared backend (the read path) and a
72/// [`JournalWriterHandle`] (the write path) — both bound to the same `durable.db`. A fresh
73/// execution opens with `is_resume = false`; a resumed one with `is_resume = true`, which activates
74/// the `ReplayCursor`.
75#[derive(Debug)]
76pub struct DurableContext {
77    execution_id: ExecutionId,
78    kind: ExecutionKind,
79    next_step: AtomicU32,
80    diverged: AtomicBool,
81    is_resume: bool,
82    cursor: ReplayCursor,
83    backend: Arc<DurableBackendEnum>,
84    writer: JournalWriterHandle,
85    max_steps_per_execution: u32,
86    max_payload_bytes: u64,
87    /// Soft step-cap threshold (90% of the cap): the first step at or past it folds a checkpoint.
88    soft_step_cap: u32,
89    /// Database fallback poll interval for parked promises and timers.
90    poll_interval: Duration,
91    /// Above this many concurrently-parked promises, awaits fall back to pure polling.
92    max_parked_promises: u32,
93    /// Fires the soft-cap checkpoint fold exactly once per execution.
94    checkpoint_requested: AtomicBool,
95    /// Tracks the spawned background checkpoint-fold task(s) so they are abortable and drainable.
96    fold_tasks: Mutex<JoinSet<()>>,
97}
98
99impl DurableContext {
100    /// Build a context for an execution.
101    ///
102    /// `backend` is the shared read path (the `ReplayCursor` reads segments through it) and must
103    /// be the same `durable.db` instance the `writer` commits to. `is_resume` activates replay:
104    /// pass `true` when reopening an execution that already has journal entries (as
105    /// [`LocalBackend::open_execution`](crate::LocalBackend::open_execution) reports), `false` for a
106    /// brand-new execution.
107    #[must_use]
108    pub fn new(
109        execution_id: ExecutionId,
110        kind: ExecutionKind,
111        is_resume: bool,
112        backend: Arc<DurableBackendEnum>,
113        writer: JournalWriterHandle,
114        config: &DurableConfig,
115    ) -> Self {
116        let cursor = ReplayCursor::new(backend.clone(), execution_id, DEFAULT_SEGMENT_STEPS);
117        let (soft_step_cap, _hard) = step_cap_thresholds(config.max_steps_per_execution);
118        Self {
119            execution_id,
120            kind,
121            next_step: AtomicU32::new(0),
122            diverged: AtomicBool::new(false),
123            is_resume,
124            cursor,
125            backend,
126            writer,
127            max_steps_per_execution: config.max_steps_per_execution,
128            max_payload_bytes: config.max_payload_bytes,
129            soft_step_cap,
130            poll_interval: Duration::from_secs(config.promise_poll_interval_secs.max(1)),
131            max_parked_promises: config.max_parked_promises,
132            checkpoint_requested: AtomicBool::new(false),
133            fold_tasks: Mutex::new(JoinSet::new()),
134        }
135    }
136
137    /// The execution this context drives.
138    #[must_use]
139    pub fn execution_id(&self) -> ExecutionId {
140        self.execution_id
141    }
142
143    /// The execution's category.
144    #[must_use]
145    pub fn kind(&self) -> ExecutionKind {
146        self.kind
147    }
148
149    /// Run a durable step, returning just its value.
150    ///
151    /// On a fresh execution the operation `op` runs and its result is journaled; on replay the
152    /// journaled result is returned and `op` is never invoked (INV-10). The closure receives a
153    /// [`StepHandle`] carrying the step's [`IdempotencyKey`] for boundary deduplication.
154    ///
155    /// Use [`step_recorded`](DurableContext::step_recorded) when the live/replayed distinction or the
156    /// step id matters.
157    ///
158    /// # Errors
159    ///
160    /// - [`DurableError::StepFailed`] if `op` returns an error on a fresh run.
161    /// - [`DurableError::ReplayDivergence`] if the journaled step at this position has a different
162    ///   structural fingerprint (INV-3).
163    /// - [`DurableError::AmbiguousEffect`] for an [`OnAmbiguous::Fail`] guarded step caught in the
164    ///   ambiguous window.
165    /// - [`DurableError::Serialize`] / [`DurableError::Decode`] on a payload codec failure, or a
166    ///   storage error from the journal.
167    ///
168    /// # Examples
169    ///
170    /// ```no_run
171    /// # async fn run(ctx: &zeph_durable::DurableContext) -> Result<(), zeph_durable::DurableError> {
172    /// use zeph_durable::StepDescriptor;
173    ///
174    /// let lines: usize = ctx
175    ///     .step(StepDescriptor::idempotent("count", b"tool:count".to_vec()), |_handle| async {
176    ///         Ok(42)
177    ///     })
178    ///     .await?;
179    /// assert_eq!(lines, 42);
180    /// # Ok(()) }
181    /// ```
182    #[tracing::instrument(
183        name = "durable.context.step",
184        skip_all,
185        fields(execution_id = %self.execution_id.as_uuid(), step_name = desc.name())
186    )]
187    pub async fn step<T, F, Fut>(&self, desc: StepDescriptor, op: F) -> Result<T, DurableError>
188    where
189        T: Serialize + DeserializeOwned + Send,
190        F: FnOnce(StepHandle) -> Fut + Send,
191        Fut: Future<Output = Result<T, StepError>> + Send,
192    {
193        let step_id = self.assign_step_id();
194        self.run_step_at(step_id, desc, op)
195            .await
196            .map(DurableStep::into_value)
197    }
198
199    /// Run a durable step, returning the full [`DurableStep`] record (id, key, and outcome).
200    ///
201    /// # Errors
202    ///
203    /// Identical to [`step`](DurableContext::step).
204    #[tracing::instrument(
205        name = "durable.context.step_recorded",
206        skip_all,
207        fields(execution_id = %self.execution_id.as_uuid(), step_name = desc.name())
208    )]
209    pub async fn step_recorded<T, F, Fut>(
210        &self,
211        desc: StepDescriptor,
212        op: F,
213    ) -> Result<DurableStep<T>, DurableError>
214    where
215        T: Serialize + DeserializeOwned + Send,
216        F: FnOnce(StepHandle) -> Fut + Send,
217        Fut: Future<Output = Result<T, StepError>> + Send,
218    {
219        let step_id = self.assign_step_id();
220        self.run_step_at(step_id, desc, op).await
221    }
222
223    /// Open a [`ParallelScope`] whose children receive contiguous, eagerly-assigned step ids.
224    ///
225    /// Construct the children synchronously (e.g. with a `Vec` or `.map(...).collect()`), then drive
226    /// them with `join_all`/`try_join_all`; their ids are fixed by construction order and are stable
227    /// across replay regardless of completion order (INV-2).
228    #[must_use]
229    pub fn parallel(&self) -> ParallelScope<'_> {
230        ParallelScope { ctx: self }
231    }
232
233    /// Create a durable promise resolved out of band by an operator or A2A reply (FR-DE-05).
234    ///
235    /// The promise occupies a deterministic program position, so a resumed execution re-derives the
236    /// same [`PromiseId`] and re-attaches to the pending row rather than minting an orphan. A *fresh*
237    /// promise carries its 32-byte resolver token — hand it to the resolving channel via
238    /// [`DurablePromise::resolver_token`]; a *resumed* promise carries none (the original token was
239    /// delivered before the crash). Await the result with [`await_promise`](Self::await_promise).
240    ///
241    /// # Errors
242    ///
243    /// - [`DurableError::StepCapExceeded`] if the promise would exceed the per-execution step cap.
244    /// - A storage error if the promise row cannot be read or inserted.
245    #[tracing::instrument(
246        name = "durable.context.promise",
247        skip_all,
248        fields(execution_id = %self.execution_id.as_uuid())
249    )]
250    pub async fn promise<T>(&self) -> Result<DurablePromise<T>, DurableError> {
251        let step_id = self.checked_step_id().await?;
252        let promise_id = PromiseId::derive(self.execution_id, step_id);
253
254        // Replay/resume: a row at this position means the promise was already created in a prior run.
255        if self.backend.promise_state(promise_id).await?.is_some() {
256            return Ok(DurablePromise::resumed(promise_id));
257        }
258
259        let mut token = Zeroizing::new([0u8; RESOLVER_TOKEN_LEN]);
260        rand::rng().fill_bytes(&mut *token);
261        let hash = resolver_token_hash(promise_id, self.execution_id, &token);
262        self.backend
263            .insert_promise(
264                promise_id,
265                self.execution_id,
266                *hash.as_bytes(),
267                now_unix_millis(),
268            )
269            .await?;
270        Ok(DurablePromise::fresh(promise_id, token))
271    }
272
273    /// Claim the one-time out-of-band notification for a replayed promise result.
274    ///
275    /// Returns `true` for the first caller (fire the side effects) and `false` on every later call
276    /// (suppress — already claimed). The claim is a single conditional `UPDATE` on the promise's
277    /// `notified_at` column keyed by its [`PromiseId`]; it does **not** allocate a durable step id, so
278    /// — unlike wrapping the notice in [`step`](Self::step) — it has zero interaction with the INV-2
279    /// step-id counter and can never cause a [`DurableError::ReplayDivergence`], regardless of how many
280    /// times the parent restarts (#6027).
281    ///
282    /// Because [`PromiseId::derive`] is deterministic across runs (a resumed execution re-derives the
283    /// same id at the same program position), the claim converges on one row for the fresh run and
284    /// every replay.
285    ///
286    /// # Errors
287    ///
288    /// Returns [`DurableError::Storage`] on a database error.
289    pub async fn claim_promise_notification(&self, id: PromiseId) -> Result<bool, DurableError> {
290        self.backend
291            .claim_promise_notification(id, now_unix_millis())
292            .await
293    }
294
295    /// Await a durable promise's resolved value, parking until it is resolved.
296    ///
297    /// Returns immediately if the promise is already resolved (the common replay case). Otherwise it
298    /// parks on an in-process notify keyed by the promise id and falls back to a database poll every
299    /// `promise_poll_interval_secs`; above `max_parked_promises` concurrent waiters it polls without
300    /// parking. A resolution committed by [`DurableHandle::resolve`](crate::DurableHandle::resolve)
301    /// wakes the waiter at once.
302    ///
303    /// # Errors
304    ///
305    /// - [`DurableError::UnknownPromise`] if the promise row is missing (e.g. pruned).
306    /// - A decode/integrity error if the resolved payload cannot be opened into `T`.
307    pub async fn await_promise<T: DeserializeOwned>(
308        &self,
309        promise: DurablePromise<T>,
310    ) -> Result<T, DurableError> {
311        let id = promise.id();
312        let key = id.as_uuid();
313        let cap = usize::try_from(self.max_parked_promises).unwrap_or(usize::MAX);
314        let span = tracing::info_span!("durable.promise.await", promise_id = %key);
315        wait_on_notify_or_poll(
316            self.backend.promise_waiters(),
317            key,
318            Some(cap),
319            || self.poll_interval,
320            || self.take_resolved_promise::<T>(id),
321            || self.take_resolved_promise::<T>(id),
322        )
323        .instrument(span)
324        .await
325    }
326
327    /// Read a promise's state and, if resolved, open and decode its value — without parking.
328    ///
329    /// Unlike [`await_promise`](Self::await_promise), this performs a single backend read and
330    /// returns immediately regardless of resolution state: `Ok(None)` means the promise row
331    /// exists but has not been resolved yet. Callers on an interactive path (e.g. a resumed
332    /// parent deciding whether to replay a journaled subagent result or spawn a fresh one) use
333    /// this to avoid an unbounded park on a promise that may never resolve (INV-9: a resumed
334    /// promise's resolver token is unrecoverable, so nothing can ever resolve it if the
335    /// original resolver is gone).
336    ///
337    /// # Errors
338    ///
339    /// - [`DurableError::UnknownPromise`] if the promise row is missing (e.g. pruned).
340    /// - A decode/integrity error if the resolved payload cannot be opened into `T`.
341    #[tracing::instrument(name = "durable.context.take_resolved_promise", skip_all, fields(promise_id = %id.as_uuid()))]
342    pub async fn take_resolved_promise<T: DeserializeOwned>(
343        &self,
344        id: PromiseId,
345    ) -> Result<Option<T>, DurableError> {
346        let record = self
347            .backend
348            .promise_state(id)
349            .await?
350            .ok_or(DurableError::UnknownPromise)?;
351        if !record.resolved {
352            return Ok(None);
353        }
354        let sealed = record.payload.ok_or(DurableError::Decode {
355            context: "resolved promise is missing its payload",
356        })?;
357        let plaintext = self
358            .backend
359            .open_promise_payload(id, record.execution_id, &sealed)?;
360        deserialize_result(&plaintext).map(Some)
361    }
362
363    /// Durably sleep until `due`, surviving a process restart (FR-DE-06).
364    ///
365    /// Arms a `durable_timers` row at a deterministic position (so a resume re-attaches to it),
366    /// then parks until the instant arrives — firing the timer itself when due, or returning at once
367    /// if a restart finds it already fired or past due. The
368    /// [`DurableTimerService`](crate::DurableTimerService), when running, fires due timers and wakes
369    /// the waiter; without it, this loop still makes progress on its own.
370    ///
371    /// # Errors
372    ///
373    /// - [`DurableError::StepCapExceeded`] if the timer would exceed the per-execution step cap.
374    /// - A storage error if the timer cannot be armed or its state read.
375    #[tracing::instrument(
376        name = "durable.context.sleep_until",
377        skip_all,
378        fields(execution_id = %self.execution_id.as_uuid())
379    )]
380    pub async fn sleep_until(&self, due: SystemTime) -> Result<(), DurableError> {
381        let step_id = self.checked_step_id().await?;
382        let timer_id = TimerId::derive(self.execution_id, step_id);
383        let due_ms = system_time_to_millis(due);
384
385        match self.backend.timer_state(timer_id).await? {
386            // Fired during downtime (or earlier in this run) → return immediately (FR-DE-06).
387            Some((_, true)) => return Ok(()),
388            // Already armed in a prior run: re-attach without re-arming.
389            Some((_, false)) => {}
390            // First execution at this position: arm it.
391            None => {
392                self.backend
393                    .arm_timer(timer_id, self.execution_id, due_ms, now_unix_millis())
394                    .await?;
395            }
396        }
397
398        let key = timer_id.as_uuid();
399        wait_on_notify_or_poll(
400            self.backend.timer_waiters(),
401            key,
402            None,
403            || {
404                let remaining =
405                    u64::try_from(due_ms.saturating_sub(now_unix_millis())).unwrap_or(u64::MAX);
406                self.poll_interval.min(Duration::from_millis(remaining))
407            },
408            // Cheap, clock-only pre-register check: no database read (the common case while
409            // parked and not yet due).
410            || self.check_timer_due(timer_id, due_ms),
411            || async move {
412                // Race-closing recheck after registering: a database read catches a resolution
413                // (e.g. by DurableTimerService) that lands in the window between the clock check
414                // above and the wait below, even if our own clock has not yet reached `due_ms`.
415                if let Some(value) = self.check_timer_due(timer_id, due_ms).await? {
416                    return Ok(Some(value));
417                }
418                if matches!(self.backend.timer_state(timer_id).await?, Some((_, true))) {
419                    return Ok(Some(()));
420                }
421                Ok(None)
422            },
423        )
424        .await
425    }
426
427    /// If `due_ms` has passed, fire the timer (idempotent) and report it resolved; otherwise `None`.
428    ///
429    /// A purely clock-based check with no database read on the not-yet-due path — the cheap
430    /// pre-registration check in [`sleep_until`](Self::sleep_until)'s wait loop.
431    async fn check_timer_due(
432        &self,
433        timer_id: TimerId,
434        due_ms: i64,
435    ) -> Result<Option<()>, DurableError> {
436        if now_unix_millis() >= due_ms {
437            // Due: fire it (idempotent — the service may race us; `WHERE fired = 0` dedups).
438            self.backend.mark_timer_fired(timer_id).await?;
439            return Ok(Some(()));
440        }
441        Ok(None)
442    }
443
444    /// Build an out-of-band [`DurableHandle`](crate::DurableHandle) over this context's backend.
445    ///
446    /// The handle is the operator/A2A resolution surface; it MUST NOT be exposed to an LLM tool
447    /// (INV-9). It shares the same backend, so a resolution it commits wakes an
448    /// [`await_promise`](Self::await_promise) parked on the same process at once.
449    #[must_use]
450    pub fn resolver_handle(&self) -> crate::promise::DurableHandle {
451        crate::promise::DurableHandle::new(self.backend.clone())
452    }
453
454    /// Transition this execution to a terminal status (FR-DE / retention section).
455    ///
456    /// Consumers call this on the two production terminal transitions the journal itself never
457    /// observes: `Completed` when the unit of work this execution represents finishes
458    /// successfully, and `Failed` on an unrecoverable (non-retryable) error. `Aborted` is reserved
459    /// for the replay-divergence guard ([`DurableError::ReplayDivergence`]) and is set internally,
460    /// not by consumers.
461    ///
462    /// Idempotent: calling this more than once, or racing it against an internal `Aborted`
463    /// transition, is safe — only the first call to observe the execution as `running` applies
464    /// (see [`crate::journal::Journal::finalize`]). The retention sweep only reclaims a finalized execution after
465    /// its configured TTL, so a finalized-then-reopened execution (e.g. a resumed conversation)
466    /// automatically un-finalizes back to `running` on the next [`DurableContext::new`] with
467    /// `is_resume = true`, protecting it from a stale `finalized_at`.
468    ///
469    /// # Errors
470    ///
471    /// Returns a storage error if the transition cannot be committed.
472    ///
473    /// # Examples
474    ///
475    /// ```no_run
476    /// # async fn run(ctx: &zeph_durable::DurableContext) -> Result<(), zeph_durable::DurableError> {
477    /// use zeph_durable::ExecutionStatus;
478    ///
479    /// ctx.finalize(ExecutionStatus::Completed).await?;
480    /// # Ok(()) }
481    /// ```
482    #[tracing::instrument(
483        name = "durable.context.finalize",
484        skip(self),
485        fields(execution_id = %self.execution_id.as_uuid(), status = status.as_str())
486    )]
487    pub async fn finalize(&self, status: ExecutionStatus) -> Result<(), DurableError> {
488        self.backend.finalize(self.execution_id, status).await
489    }
490
491    /// Await any in-flight background checkpoint folds — a turn-boundary / test barrier.
492    ///
493    /// The soft step-cap fold runs on a spawned task so it never blocks step dispatch; call this at
494    /// a turn boundary to ensure the journal is compacted before the next phase observes it.
495    #[tracing::instrument(name = "durable.context.drain_background", skip_all, fields(execution_id = %self.execution_id.as_uuid()))]
496    pub async fn drain_background(&self) {
497        let mut set = {
498            let mut guard = self
499                .fold_tasks
500                .lock()
501                .unwrap_or_else(std::sync::PoisonError::into_inner);
502            std::mem::take(&mut *guard)
503        };
504        while set.join_next().await.is_some() {}
505    }
506
507    /// Assign the next deterministic step id (INV-2).
508    fn assign_step_id(&self) -> StepId {
509        StepId::new(self.next_step.fetch_add(1, Ordering::Relaxed))
510    }
511
512    /// Assign the next step id, rejecting it if it exceeds the per-execution step cap.
513    async fn checked_step_id(&self) -> Result<StepId, DurableError> {
514        let step_id = self.assign_step_id();
515        self.enforce_step_cap(step_id).await?;
516        Ok(step_id)
517    }
518
519    /// Reject `step_id` if it exceeds the per-execution step cap (`0` means uncapped).
520    ///
521    /// A hard-cap rejection finalizes the execution as `Aborted` (best-effort, its own failure
522    /// only logs) — the retention spec describes the hard cap as aborting the execution, so this
523    /// is the one terminal transition the durable crate fully owns and can finalize internally,
524    /// rather than leaving the execution `running` forever with no reclamation path (#6251 critic
525    /// S2). Idempotent per [`Journal::finalize`]'s `running`-only guard, so a caller that keeps
526    /// calling `step`/`promise`/`sleep_until` after the cap trips (each hitting this same path)
527    /// only finalizes once.
528    async fn enforce_step_cap(&self, step_id: StepId) -> Result<(), DurableError> {
529        if self.max_steps_per_execution != 0 && step_id.value() >= self.max_steps_per_execution {
530            if let Err(error) = self
531                .backend
532                .finalize(self.execution_id, ExecutionStatus::Aborted)
533                .await
534            {
535                tracing::warn!(%error, "failed to mark step-cap-exceeded execution aborted");
536            }
537            return Err(DurableError::StepCapExceeded {
538                cap: self.max_steps_per_execution,
539            });
540        }
541        Ok(())
542    }
543
544    /// Fold a checkpoint on a background task the first time a step crosses the soft cap.
545    ///
546    /// Compaction NEVER runs on the dispatch hot path (spec NEVER), so the fold is spawned and
547    /// tracked in `fold_tasks` (drainable via [`drain_background`](Self::drain_background), aborted
548    /// on drop). It fires exactly once per execution; the hard cap aborts any execution that keeps
549    /// growing past it.
550    fn maybe_checkpoint(&self, step_id: StepId) {
551        if step_id.value() < self.soft_step_cap {
552            return;
553        }
554        if self.checkpoint_requested.swap(true, Ordering::AcqRel) {
555            return;
556        }
557        let backend = self.backend.clone();
558        let execution_id = self.execution_id;
559        let up_to = self.soft_step_cap;
560        let mut guard = self
561            .fold_tasks
562            .lock()
563            .unwrap_or_else(std::sync::PoisonError::into_inner);
564        guard.spawn(async move {
565            match backend.checkpoint_fold(execution_id, up_to).await {
566                Ok(folded) => {
567                    tracing::info!(
568                        execution_id = %execution_id.as_uuid(),
569                        folded,
570                        "durable checkpoint fold compacted the idempotent prefix"
571                    );
572                }
573                Err(error) => {
574                    tracing::warn!(%error, "durable checkpoint fold failed");
575                }
576            }
577        });
578    }
579
580    /// Whether replay is currently consulted (a resume that has not diverged).
581    fn replay_active(&self) -> bool {
582        self.is_resume && !self.diverged.load(Ordering::Acquire)
583    }
584
585    /// The core step state machine shared by the sequential and parallel entry points.
586    async fn run_step_at<T, F, Fut>(
587        &self,
588        step_id: StepId,
589        desc: StepDescriptor,
590        op: F,
591    ) -> Result<DurableStep<T>, DurableError>
592    where
593        T: Serialize + DeserializeOwned + Send,
594        F: FnOnce(StepHandle) -> Fut + Send,
595        Fut: Future<Output = Result<T, StepError>> + Send,
596    {
597        self.enforce_step_cap(step_id).await?;
598        // Soft cap (90%): fold a checkpoint on a background task, once per execution.
599        self.maybe_checkpoint(step_id);
600        let effect = desc.effect();
601        let idem_key =
602            IdempotencyKey::derive(self.execution_id, step_id, &desc.fingerprint_input());
603
604        let span = tracing::info_span!(
605            "durable.step.run",
606            step_id = step_id.value(),
607            effect_class = effect.as_str(),
608            replayed = tracing::field::Empty,
609        );
610        async move {
611            // 1) Sequential replay: consult the cursor for this position.
612            if self.replay_active() {
613                match self.cursor.lookup(step_id).await? {
614                    StepReplay::Result(entry) => {
615                        self.check_divergence(step_id, idem_key, &entry).await?;
616                        let value = replay_value::<T>(step_id, effect, &entry)?;
617                        tracing::Span::current().record("replayed", true);
618                        return Ok(DurableStep::replayed(step_id, idem_key, value));
619                    }
620                    StepReplay::IntentOnly(entry) => {
621                        self.check_divergence(step_id, idem_key, &entry).await?;
622                        return self.resolve_ambiguous(step_id, idem_key, &desc, op).await;
623                    }
624                    StepReplay::Fresh => {}
625                }
626            }
627
628            // 2) INV-13: a guarded effect that already committed a result must not re-fire, even on a
629            // fresh run that follows a divergence. A point lookup by idempotency key catches it.
630            if effect == EffectClass::ExactlyOnceGuarded
631                && let Some(entry) = self
632                    .backend
633                    .lookup_committed_result(self.execution_id, idem_key)
634                    .await?
635            {
636                let value = replay_value::<T>(step_id, effect, &entry)?;
637                tracing::Span::current().record("replayed", true);
638                return Ok(DurableStep::replayed(step_id, idem_key, value));
639            }
640
641            // 3) Fresh execution of the step.
642            tracing::Span::current().record("replayed", false);
643            if effect == EffectClass::ExactlyOnceGuarded {
644                // FR-DE-04: the intent is committed and ACKed before the effect fires.
645                let intent = self.intent_entry(step_id, idem_key, effect);
646                self.append_acked_degrading(intent, desc.name()).await?;
647            }
648            let value = self.run_op(op, step_id, idem_key, desc.name()).await?;
649            // Serialize before the journal await so no `&T` is held across it (that would force a
650            // `T: Sync` bound the consumer's value need not satisfy); the owned value moves into the
651            // returned record afterward.
652            let payload = serialize_result(&value, desc.name())?;
653            self.journal_result(payload, step_id, idem_key, effect, desc.name())
654                .await?;
655            Ok(DurableStep::live(step_id, idem_key, value))
656        }
657        .instrument(span)
658        .await
659    }
660
661    /// Compare a journaled step's fingerprint against the current descriptor's; abort on mismatch.
662    #[tracing::instrument(name = "durable.context.check_divergence", skip_all, fields(step_id = step_id.value()))]
663    async fn check_divergence(
664        &self,
665        step_id: StepId,
666        expected: IdempotencyKey,
667        entry: &JournalEntry,
668    ) -> Result<(), DurableError> {
669        // The idempotency key folds in the descriptor name, effect, and op fingerprint, so equality
670        // is the one-BLAKE3-compare fingerprint check the divergence guard requires (INV-3).
671        if entry.entry.idempotency_key() == Some(expected) {
672            return Ok(());
673        }
674        self.on_divergence(step_id).await;
675        Err(DurableError::ReplayDivergence { step_id })
676    }
677
678    /// Mark the execution aborted and disable replay so it restarts fresh (FR-DE-03).
679    #[tracing::instrument(name = "durable.context.on_divergence", skip_all, fields(step_id = step_id.value(), execution_id = %self.execution_id.as_uuid()))]
680    async fn on_divergence(&self, step_id: StepId) {
681        self.diverged.store(true, Ordering::Release);
682        tracing::warn!(
683            execution_id = %self.execution_id.as_uuid(),
684            step_id = step_id.value(),
685            "replay divergence detected; marking journal aborted and restarting fresh"
686        );
687        if let Err(error) = self
688            .backend
689            .finalize(self.execution_id, ExecutionStatus::Aborted)
690            .await
691        {
692            tracing::warn!(%error, "failed to mark diverged execution aborted");
693        }
694    }
695
696    /// Apply the [`OnAmbiguous`] policy for a guarded step resumed in the ambiguous window.
697    #[tracing::instrument(name = "durable.context.resolve_ambiguous", skip_all, fields(step_id = step_id.value(), execution_id = %self.execution_id.as_uuid()))]
698    async fn resolve_ambiguous<T, F, Fut>(
699        &self,
700        step_id: StepId,
701        idem_key: IdempotencyKey,
702        desc: &StepDescriptor,
703        op: F,
704    ) -> Result<DurableStep<T>, DurableError>
705    where
706        T: Serialize + DeserializeOwned + Send,
707        F: FnOnce(StepHandle) -> Fut + Send,
708        Fut: Future<Output = Result<T, StepError>> + Send,
709    {
710        let effect = desc.effect();
711        let policy = desc.on_ambiguous().unwrap_or(OnAmbiguous::Fail);
712        self.emit_ambiguous_audit(step_id, effect, idem_key, policy);
713        match policy {
714            // Fail: refuse to guess; surface the irreversible-effect uncertainty to the operator.
715            OnAmbiguous::Fail => Err(DurableError::AmbiguousEffect { step_id }),
716            // Skip re-runs the closure trusting the boundary to deduplicate the re-issued effect by
717            // its idempotency key; Rerun re-runs it assuming the effect never fired. At this layer
718            // both re-execute (the intent already exists, so it is not re-journaled) and the audit
719            // record above distinguishes which policy was applied.
720            OnAmbiguous::Skip | OnAmbiguous::Rerun => {
721                let value = self.run_op(op, step_id, idem_key, desc.name()).await?;
722                let payload = serialize_result(&value, desc.name())?;
723                self.journal_result(payload, step_id, idem_key, effect, desc.name())
724                    .await?;
725                Ok(DurableStep::live(step_id, idem_key, value))
726            }
727        }
728    }
729
730    /// Invoke the operation closure, mapping its failure to [`DurableError::StepFailed`].
731    #[tracing::instrument(name = "durable.context.run_op", skip_all, fields(step_id = step_id.value(), step_name = name))]
732    async fn run_op<T, F, Fut>(
733        &self,
734        op: F,
735        step_id: StepId,
736        idem_key: IdempotencyKey,
737        name: &'static str,
738    ) -> Result<T, DurableError>
739    where
740        F: FnOnce(StepHandle) -> Fut + Send,
741        Fut: Future<Output = Result<T, StepError>> + Send,
742    {
743        let handle = StepHandle::new(step_id, idem_key);
744        op(handle)
745            .await
746            .map_err(|err| DurableError::step_failed(name, err))
747    }
748
749    /// Journal a step's already-serialized result with the durability class its effect requires.
750    #[tracing::instrument(name = "durable.context.journal_result", skip_all, fields(step_id = step_id.value(), effect_class = effect.as_str(), step_name = name))]
751    async fn journal_result(
752        &self,
753        payload: bytes::Bytes,
754        step_id: StepId,
755        idem_key: IdempotencyKey,
756        effect: EffectClass,
757        name: &'static str,
758    ) -> Result<(), DurableError> {
759        // INV-11 write-side guard: reject an oversized payload before it reaches the writer.
760        crate::cipher::ensure_payload_within_limit(payload.len(), self.max_payload_bytes)?;
761        let entry = JournalEntry {
762            seq: None,
763            execution_id: self.execution_id,
764            kind: self.kind,
765            step_id,
766            entry: EntryKind::StepResult {
767                idempotency_key: idem_key,
768                payload,
769                effect,
770                payload_version: PAYLOAD_VERSION,
771            },
772            created_at_ms: now_unix_millis(),
773        };
774        match effect {
775            // Exactly-once results are ACKed so durability-on-return holds (FR-DE-04).
776            EffectClass::ExactlyOnceGuarded => self.append_acked_degrading(entry, name).await,
777            // Buffered results group-commit; a crash before the flush simply re-runs the step.
778            EffectClass::Idempotent | EffectClass::AtLeastOnce => {
779                self.writer.append_buffered(entry);
780                Ok(())
781            }
782        }
783    }
784
785    /// Build an `EffectIntent` entry for a guarded step.
786    fn intent_entry(
787        &self,
788        step_id: StepId,
789        idem_key: IdempotencyKey,
790        effect: EffectClass,
791    ) -> JournalEntry {
792        JournalEntry {
793            seq: None,
794            execution_id: self.execution_id,
795            kind: self.kind,
796            step_id,
797            entry: EntryKind::EffectIntent {
798                idempotency_key: idem_key,
799                effect,
800                // The backend is the HMAC keyholder and stamps the row HMAC itself when configured.
801                hmac: None,
802            },
803            created_at_ms: now_unix_millis(),
804        }
805    }
806
807    /// ACK an append, degrading to non-durable mode on a writer timeout (INV-12) rather than failing.
808    #[tracing::instrument(name = "durable.context.append_acked_degrading", skip_all, fields(step_name = name))]
809    async fn append_acked_degrading(
810        &self,
811        entry: JournalEntry,
812        name: &'static str,
813    ) -> Result<(), DurableError> {
814        match self.writer.append_acked(entry).await {
815            Ok(_) => Ok(()),
816            Err(DurableError::JournalUnavailable) => {
817                tracing::warn!(
818                    step = name,
819                    "journal writer unavailable; this step degrades to non-durable mode"
820                );
821                metrics::counter!("durable.journal.writer.degraded_appends_total").increment(1);
822                Ok(())
823            }
824            Err(error) => Err(error),
825        }
826    }
827
828    /// Emit the mandatory structured audit record for an ambiguous-window resolution (FR-DE-10).
829    fn emit_ambiguous_audit(
830        &self,
831        step_id: StepId,
832        effect: EffectClass,
833        idem_key: IdempotencyKey,
834        policy: OnAmbiguous,
835    ) {
836        tracing::warn!(
837            target: "durable.audit",
838            execution_id = %self.execution_id.as_uuid(),
839            step_id = step_id.value(),
840            effect_class = effect.as_str(),
841            idem_key = %idem_key_hex8(idem_key),
842            on_ambiguous = policy.as_str(),
843            "durable step resumed in the ambiguous window; applying on_ambiguous policy"
844        );
845    }
846}
847
848/// A handle for spawning durable steps with eagerly-assigned, contiguous step ids.
849///
850/// Returned by [`DurableContext::parallel`]. Each [`step`](ParallelScope::step) call assigns its id
851/// synchronously, *before* returning the future, so building a batch of children fixes their ids in
852/// construction order — completion order is then irrelevant (INV-2).
853#[derive(Debug, Clone, Copy)]
854pub struct ParallelScope<'a> {
855    ctx: &'a DurableContext,
856}
857
858impl<'a> ParallelScope<'a> {
859    /// Construct a durable step future with its id assigned eagerly.
860    ///
861    /// The returned future runs (or replays) the step when awaited; its [`StepId`] is already fixed.
862    /// Collect the futures synchronously, then await them concurrently.
863    ///
864    /// # Errors
865    ///
866    /// The awaited future fails for the same reasons as [`DurableContext::step`].
867    pub fn step<T, F, Fut>(
868        &self,
869        desc: StepDescriptor,
870        op: F,
871    ) -> impl Future<Output = Result<DurableStep<T>, DurableError>> + Send + 'a
872    where
873        T: Serialize + DeserializeOwned + Send + 'a,
874        F: FnOnce(StepHandle) -> Fut + Send + 'a,
875        Fut: Future<Output = Result<T, StepError>> + Send + 'a,
876    {
877        let step_id = self.ctx.assign_step_id();
878        let ctx = self.ctx;
879        async move { ctx.run_step_at(step_id, desc, op).await }
880    }
881}
882
883/// Extract and decode a replayed step's value from its journaled `StepResult`.
884fn replay_value<T: DeserializeOwned>(
885    step_id: StepId,
886    effect: EffectClass,
887    entry: &JournalEntry,
888) -> Result<T, DurableError> {
889    let _span = tracing::info_span!(
890        "durable.step.replay",
891        step_id = step_id.value(),
892        effect_class = effect.as_str(),
893    )
894    .entered();
895    match &entry.entry {
896        EntryKind::StepResult { payload, .. } => deserialize_result(payload),
897        _ => Err(DurableError::Decode {
898            context: "replayed entry is not a step result",
899        }),
900    }
901}
902
903/// Convert a [`SystemTime`] to Unix epoch milliseconds, clamped into `i64` and never panicking.
904///
905/// A pre-epoch instant clamps to `0`; an overflowing one clamps to [`i64::MAX`].
906fn system_time_to_millis(time: SystemTime) -> i64 {
907    time.duration_since(SystemTime::UNIX_EPOCH)
908        .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
909}
910
911/// Hex-encode the first 8 bytes of an idempotency key for an audit record.
912///
913/// The key is a BLAKE3 hash, not secret material; the spec redaction rule shows only its first 8
914/// bytes in CLI/audit output (INV-5).
915fn idem_key_hex8(key: IdempotencyKey) -> String {
916    let mut out = String::with_capacity(16);
917    for byte in &key.as_bytes()[..8] {
918        let _ = write!(out, "{byte:02x}");
919    }
920    out
921}
922
923#[cfg(all(test, feature = "sqlite"))]
924mod tests {
925    use std::assert_matches;
926
927    use super::*;
928    use crate::backend::local::LocalBackend;
929    use crate::config::DurableConfig;
930    use crate::effect::EffectIntentSubClass;
931    use crate::timer::DurableTimerService;
932    use crate::writer::JournalWriter;
933    use std::pin::Pin;
934    use std::sync::atomic::AtomicU32;
935    use tokio::task::JoinHandle;
936
937    /// A type-erased durable-step future, so a heterogeneous batch of step closures can share one
938    /// `Vec` for `join_all` (distinct closures otherwise produce distinct opaque future types).
939    type StepFut<'a> =
940        Pin<Box<dyn Future<Output = Result<DurableStep<u32>, DurableError>> + Send + 'a>>;
941
942    fn fast_config() -> DurableConfig {
943        DurableConfig {
944            journal_flush_interval_ms: 5,
945            journal_ack_timeout_ms: 2000,
946            ..DurableConfig::default()
947        }
948    }
949
950    /// A running context over a fresh in-memory backend, with the writer task spawned.
951    struct Harness {
952        ctx: DurableContext,
953        backend: Arc<LocalBackend>,
954        writer_task: JoinHandle<()>,
955        handle: JournalWriterHandle,
956    }
957
958    impl Harness {
959        async fn open(exec: ExecutionId, is_resume: bool) -> Self {
960            let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
961            local.init().await.unwrap();
962            local
963                .open_execution(exec, ExecutionKind::AgentTurn)
964                .await
965                .unwrap();
966            let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
967            let writer_task = tokio::spawn(writer.run());
968            let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
969            let ctx = DurableContext::new(
970                exec,
971                ExecutionKind::AgentTurn,
972                is_resume,
973                backend,
974                handle.clone(),
975                &fast_config(),
976            );
977            Self {
978                ctx,
979                backend: local,
980                writer_task,
981                handle,
982            }
983        }
984
985        /// Reopen over the *same* backing journal to drive a resume run.
986        fn resume(&self) -> DurableContext {
987            let backend = Arc::new(DurableBackendEnum::Local(self.backend.clone()));
988            DurableContext::new(
989                self.ctx.execution_id,
990                ExecutionKind::AgentTurn,
991                true,
992                backend,
993                self.handle.clone(),
994                &fast_config(),
995            )
996        }
997
998        async fn shutdown(self) {
999            // Some tests build a second context (a resume / fresh run) that clones the writer
1000            // handle; that clone keeps the channel open, so the writer never stops on its own.
1001            // Abort it directly rather than awaiting a graceful drain (data was already flushed
1002            // before any assertion that needed it).
1003            self.writer_task.abort();
1004            let _ = self.writer_task.await;
1005        }
1006    }
1007
1008    #[tokio::test]
1009    async fn fresh_step_runs_op_and_journals_result() {
1010        // FR-DE-01: a fresh step records its result in the journal.
1011        let exec = ExecutionId::new();
1012        let h = Harness::open(exec, false).await;
1013        let value: u32 = h
1014            .ctx
1015            .step(
1016                StepDescriptor::idempotent("count", b"op".to_vec()),
1017                |_| async { Ok(7) },
1018            )
1019            .await
1020            .unwrap();
1021        assert_eq!(value, 7);
1022        h.handle.flush().await.unwrap();
1023
1024        let entries = h.backend.read_execution(exec).await.unwrap();
1025        assert_eq!(entries.len(), 1);
1026        assert_matches!(entries[0].entry, EntryKind::StepResult { .. });
1027        h.shutdown().await;
1028    }
1029
1030    #[tokio::test]
1031    async fn replayed_idempotent_step_skips_op() {
1032        // INV-10 / FR-DE-02: a replayed idempotent step returns the journaled value without re-running.
1033        let exec = ExecutionId::new();
1034        let h = Harness::open(exec, false).await;
1035        let desc = || StepDescriptor::idempotent("count", b"op".to_vec());
1036        let first: u32 = h.ctx.step(desc(), |_| async { Ok(11) }).await.unwrap();
1037        assert_eq!(first, 11);
1038        h.handle.flush().await.unwrap();
1039
1040        let resumed = h.resume();
1041        let ran_again = Arc::new(AtomicU32::new(0));
1042        let counter = ran_again.clone();
1043        let replayed: u32 = resumed
1044            .step(desc(), move |_| {
1045                let counter = counter.clone();
1046                async move {
1047                    counter.fetch_add(1, Ordering::SeqCst);
1048                    Ok(999)
1049                }
1050            })
1051            .await
1052            .unwrap();
1053        assert_eq!(
1054            replayed, 11,
1055            "the journaled value is returned, not the new one"
1056        );
1057        assert_eq!(
1058            ran_again.load(Ordering::SeqCst),
1059            0,
1060            "the operation closure must not run on replay"
1061        );
1062        h.shutdown().await;
1063    }
1064
1065    #[tokio::test]
1066    async fn guarded_step_commits_intent_before_result() {
1067        // FR-DE-04: an EffectIntent is committed before op, a StepResult after.
1068        let exec = ExecutionId::new();
1069        let h = Harness::open(exec, false).await;
1070        let desc = StepDescriptor::exactly_once_guarded(
1071            "charge",
1072            EffectIntentSubClass::CostBearingOrBoundaryIdempotent,
1073            Some(OnAmbiguous::Skip),
1074            b"op".to_vec(),
1075        )
1076        .unwrap();
1077        let _: u32 = h.ctx.step(desc, |_| async { Ok(5) }).await.unwrap();
1078        h.handle.flush().await.unwrap();
1079
1080        let entries = h.backend.read_execution(exec).await.unwrap();
1081        let kinds: Vec<_> = entries.iter().map(|e| e.entry.tag()).collect();
1082        assert_eq!(
1083            kinds,
1084            vec!["effect_intent", "step_result"],
1085            "intent is journaled before the result"
1086        );
1087        h.shutdown().await;
1088    }
1089
1090    #[tokio::test]
1091    async fn replay_divergence_on_fingerprint_mismatch() {
1092        // INV-3 / FR-DE-03: a structurally different step at the same id aborts and restarts fresh.
1093        let exec = ExecutionId::new();
1094        let h = Harness::open(exec, false).await;
1095        let _: u32 = h
1096            .ctx
1097            .step(
1098                StepDescriptor::idempotent("count", b"v1".to_vec()),
1099                |_| async { Ok(1) },
1100            )
1101            .await
1102            .unwrap();
1103        h.handle.flush().await.unwrap();
1104
1105        let resumed = h.resume();
1106        // Same step position, different op fingerprint → different structural fingerprint.
1107        let err = resumed
1108            .step::<u32, _, _>(
1109                StepDescriptor::idempotent("count", b"v2".to_vec()),
1110                |_| async { Ok(2) },
1111            )
1112            .await
1113            .unwrap_err();
1114        assert_matches!(err, DurableError::ReplayDivergence { .. });
1115
1116        let (status,): (String,) = zeph_db::query_as(zeph_db::sql!(
1117            "SELECT status FROM durable_executions WHERE execution_id = ?"
1118        ))
1119        .bind(exec.as_uuid().to_string())
1120        .fetch_one(h.backend.pool())
1121        .await
1122        .unwrap();
1123        assert_eq!(status, "aborted", "the diverged journal is marked aborted");
1124        h.shutdown().await;
1125    }
1126
1127    #[tokio::test]
1128    async fn ambiguous_window_fail_policy_surfaces_error() {
1129        // FR-DE-14 path: an intent without a result, policy = Fail, must not re-fire.
1130        let exec = ExecutionId::new();
1131        let h = Harness::open(exec, false).await;
1132        let step_id = StepId::new(0);
1133        let idem = IdempotencyKey::derive(
1134            exec,
1135            step_id,
1136            &StepDescriptor::exactly_once_guarded(
1137                "delete",
1138                EffectIntentSubClass::Destructive,
1139                Some(OnAmbiguous::Fail),
1140                b"op".to_vec(),
1141            )
1142            .unwrap()
1143            .fingerprint_input(),
1144        );
1145        // Seed only the intent (the crash happened before the result committed).
1146        h.backend
1147            .append(JournalEntry {
1148                seq: None,
1149                execution_id: exec,
1150                kind: ExecutionKind::AgentTurn,
1151                step_id,
1152                entry: EntryKind::EffectIntent {
1153                    idempotency_key: idem,
1154                    effect: EffectClass::ExactlyOnceGuarded,
1155                    hmac: None,
1156                },
1157                created_at_ms: 0,
1158            })
1159            .await
1160            .unwrap();
1161
1162        let resumed = h.resume();
1163        let ran = Arc::new(AtomicU32::new(0));
1164        let counter = ran.clone();
1165        let err = resumed
1166            .step::<u32, _, _>(
1167                StepDescriptor::exactly_once_guarded(
1168                    "delete",
1169                    EffectIntentSubClass::Destructive,
1170                    Some(OnAmbiguous::Fail),
1171                    b"op".to_vec(),
1172                )
1173                .unwrap(),
1174                move |_| {
1175                    let counter = counter.clone();
1176                    async move {
1177                        counter.fetch_add(1, Ordering::SeqCst);
1178                        Ok(1)
1179                    }
1180                },
1181            )
1182            .await
1183            .unwrap_err();
1184        assert_matches!(err, DurableError::AmbiguousEffect { .. });
1185        assert_eq!(
1186            ran.load(Ordering::SeqCst),
1187            0,
1188            "a fail-policy ambiguous step must not re-fire the effect"
1189        );
1190        h.shutdown().await;
1191    }
1192
1193    #[tokio::test]
1194    async fn inv13_committed_guarded_result_is_not_refired() {
1195        // INV-13: on a fresh run after divergence, an already-committed guarded result is returned
1196        // via its idempotency key without re-firing.
1197        let exec = ExecutionId::new();
1198        let h = Harness::open(exec, false).await;
1199        let desc = || {
1200            StepDescriptor::exactly_once_guarded(
1201                "transfer",
1202                EffectIntentSubClass::MoneyMoving,
1203                Some(OnAmbiguous::Fail),
1204                b"op".to_vec(),
1205            )
1206            .unwrap()
1207        };
1208        let first: u32 = h.ctx.step(desc(), |_| async { Ok(500) }).await.unwrap();
1209        assert_eq!(first, 500);
1210        h.handle.flush().await.unwrap();
1211
1212        // A "fresh run after divergence": replay is OFF, but the guarded point lookup must still find
1213        // the committed result. Build a non-resume context over the same journal.
1214        let backend = Arc::new(DurableBackendEnum::Local(h.backend.clone()));
1215        let fresh = DurableContext::new(
1216            exec,
1217            ExecutionKind::AgentTurn,
1218            false,
1219            backend,
1220            h.handle.clone(),
1221            &fast_config(),
1222        );
1223        let ran = Arc::new(AtomicU32::new(0));
1224        let counter = ran.clone();
1225        let value: u32 = fresh
1226            .step(desc(), move |_| {
1227                let counter = counter.clone();
1228                async move {
1229                    counter.fetch_add(1, Ordering::SeqCst);
1230                    Ok(0)
1231                }
1232            })
1233            .await
1234            .unwrap();
1235        assert_eq!(value, 500, "the pre-committed guarded result is returned");
1236        assert_eq!(
1237            ran.load(Ordering::SeqCst),
1238            0,
1239            "the guarded effect must not re-fire"
1240        );
1241        h.shutdown().await;
1242    }
1243
1244    #[tokio::test]
1245    async fn parallel_step_ids_are_completion_order_independent() {
1246        // INV-2: a parallel batch with shuffled completion order yields deterministic step ids.
1247        let exec = ExecutionId::new();
1248        let h = Harness::open(exec, false).await;
1249        let scope = h.ctx.parallel();
1250        // Construct children in argument order; each gets its id eagerly at the `scope.step` call
1251        // (before any future is polled). Box them so the differently-typed closures share one Vec.
1252        let futures: Vec<StepFut> = vec![
1253            Box::pin(scope.step::<u32, _, _>(
1254                StepDescriptor::idempotent("a", b"a".to_vec()),
1255                |handle: StepHandle| async move {
1256                    // Finishes last despite being constructed first.
1257                    tokio::time::sleep(std::time::Duration::from_millis(30)).await;
1258                    Ok(handle.step_id().value())
1259                },
1260            )),
1261            Box::pin(scope.step::<u32, _, _>(
1262                StepDescriptor::idempotent("b", b"b".to_vec()),
1263                |handle: StepHandle| async move {
1264                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1265                    Ok(handle.step_id().value())
1266                },
1267            )),
1268            Box::pin(scope.step::<u32, _, _>(
1269                StepDescriptor::idempotent("c", b"c".to_vec()),
1270                |handle: StepHandle| async move { Ok(handle.step_id().value()) },
1271            )),
1272        ];
1273        let results = futures::future::try_join_all(futures).await.unwrap();
1274        let ids: Vec<u32> = results
1275            .iter()
1276            .map(DurableStep::step_id)
1277            .map(StepId::value)
1278            .collect();
1279        // Each child observed the id assigned at construction, regardless of completion order.
1280        assert_eq!(ids, vec![0, 1, 2]);
1281        h.shutdown().await;
1282    }
1283
1284    #[tokio::test]
1285    async fn concurrent_steps_under_shared_ref_are_sound() {
1286        // System-invariants §10: concurrent step() calls under a single &self assign unique ids and
1287        // all journal successfully.
1288        let exec = ExecutionId::new();
1289        let h = Harness::open(exec, false).await;
1290        let scope = h.ctx.parallel();
1291        let futures: Vec<StepFut> = (0..16)
1292            .map(|i| {
1293                Box::pin(scope.step::<u32, _, _>(
1294                    StepDescriptor::idempotent("worker", format!("op:{i}").into_bytes()),
1295                    move |handle: StepHandle| async move { Ok(handle.step_id().value()) },
1296                )) as StepFut
1297            })
1298            .collect();
1299        let results = futures::future::try_join_all(futures).await.unwrap();
1300        let mut ids: Vec<u32> = results
1301            .iter()
1302            .map(DurableStep::step_id)
1303            .map(StepId::value)
1304            .collect();
1305        ids.sort_unstable();
1306        ids.dedup();
1307        assert_eq!(ids.len(), 16, "all 16 concurrent steps got unique ids");
1308        h.handle.flush().await.unwrap();
1309        assert_eq!(h.backend.read_execution(exec).await.unwrap().len(), 16);
1310        h.shutdown().await;
1311    }
1312
1313    #[tokio::test]
1314    async fn op_failure_surfaces_as_step_failed_without_journaling() {
1315        let exec = ExecutionId::new();
1316        let h = Harness::open(exec, false).await;
1317        let err = h
1318            .ctx
1319            .step::<u32, _, _>(
1320                StepDescriptor::idempotent("boom", b"op".to_vec()),
1321                |_| async { Err(StepError::new("op exploded")) },
1322            )
1323            .await
1324            .unwrap_err();
1325        assert_matches!(err, DurableError::StepFailed { step: "boom", .. });
1326        h.handle.flush().await.unwrap();
1327        assert!(
1328            h.backend.read_execution(exec).await.unwrap().is_empty(),
1329            "a failed step journals no result"
1330        );
1331        h.shutdown().await;
1332    }
1333
1334    #[tokio::test]
1335    async fn finalize_transitions_the_execution_to_the_given_status() {
1336        // #6251: DurableContext::finalize is the ergonomic entry point production consumers use to
1337        // reach the terminal transition the journal itself never observes.
1338        let exec = ExecutionId::new();
1339        let h = Harness::open(exec, false).await;
1340        h.ctx
1341            .finalize(ExecutionStatus::Completed)
1342            .await
1343            .expect("finalize succeeds");
1344
1345        let (status,): (String,) = zeph_db::query_as(zeph_db::sql!(
1346            "SELECT status FROM durable_executions WHERE execution_id = ?"
1347        ))
1348        .bind(exec.as_uuid().to_string())
1349        .fetch_one(h.backend.pool())
1350        .await
1351        .unwrap();
1352        assert_eq!(status, "completed");
1353        h.shutdown().await;
1354    }
1355
1356    /// Build a fresh context over a shared in-memory backend with a custom config (e.g. a small cap).
1357    fn context_with(
1358        local: &Arc<LocalBackend>,
1359        handle: &JournalWriterHandle,
1360        exec: ExecutionId,
1361        is_resume: bool,
1362        config: &DurableConfig,
1363    ) -> DurableContext {
1364        let dispatch = Arc::new(DurableBackendEnum::Local(local.clone()));
1365        DurableContext::new(
1366            exec,
1367            ExecutionKind::AgentTurn,
1368            is_resume,
1369            dispatch,
1370            handle.clone(),
1371            config,
1372        )
1373    }
1374
1375    #[tokio::test]
1376    async fn promise_resolves_with_token_and_await_returns_value() {
1377        // FR-DE-05: a promise resolves only via its token; resolution wakes the parked await.
1378        let exec = ExecutionId::new();
1379        let h = Harness::open(exec, false).await;
1380        let promise = h.ctx.promise::<u32>().await.unwrap();
1381        assert!(!promise.is_resumed());
1382        let token = *promise
1383            .resolver_token()
1384            .expect("fresh promise carries a token");
1385        let id = promise.id();
1386        let resolver = h.ctx.resolver_handle();
1387
1388        let (awaited, ack) = tokio::join!(h.ctx.await_promise::<u32>(promise), async {
1389            tokio::time::sleep(Duration::from_millis(25)).await;
1390            resolver.resolve(id, &token, 1234u32).await
1391        });
1392        ack.unwrap();
1393        assert_eq!(
1394            awaited.unwrap(),
1395            1234,
1396            "the awaiter receives the resolved value"
1397        );
1398        h.shutdown().await;
1399    }
1400
1401    #[tokio::test]
1402    async fn wrong_resolver_token_is_rejected_but_correct_one_resolves() {
1403        // INV-9: resolution requires the matching token; a wrong token is rejected (constant-time).
1404        let exec = ExecutionId::new();
1405        let h = Harness::open(exec, false).await;
1406        let promise = h.ctx.promise::<String>().await.unwrap();
1407        let id = promise.id();
1408        let token = *promise.resolver_token().unwrap();
1409        let resolver = h.ctx.resolver_handle();
1410
1411        // The LLM, lacking the token, cannot resolve: a guessed token is rejected and leaves the
1412        // promise pending.
1413        let mut wrong = token;
1414        wrong[0] ^= 0xFF;
1415        assert_matches!(
1416            resolver.resolve(id, &wrong, "forged".to_string()).await,
1417            Err(DurableError::PromiseRejected)
1418        );
1419        assert!(
1420            !h.backend.promise_state(id).await.unwrap().unwrap().resolved,
1421            "a rejected resolution must not resolve the promise"
1422        );
1423
1424        // The genuine token resolves it.
1425        resolver
1426            .resolve(id, &token, "ok".to_string())
1427            .await
1428            .unwrap();
1429        assert!(h.backend.promise_state(id).await.unwrap().unwrap().resolved);
1430
1431        // Resolving an unknown promise fails closed.
1432        assert_matches!(
1433            resolver
1434                .resolve(PromiseId::new(), &token, "x".to_string())
1435                .await,
1436            Err(DurableError::UnknownPromise)
1437        );
1438        h.shutdown().await;
1439    }
1440
1441    #[tokio::test]
1442    async fn resumed_promise_awaits_the_resolved_value() {
1443        // A promise created and resolved before a crash is re-attached on resume and awaited.
1444        let exec = ExecutionId::new();
1445        let h = Harness::open(exec, false).await;
1446        let promise = h.ctx.promise::<u32>().await.unwrap();
1447        let id = promise.id();
1448        let token = *promise.resolver_token().unwrap();
1449        h.ctx
1450            .resolver_handle()
1451            .resolve(id, &token, 77u32)
1452            .await
1453            .unwrap();
1454
1455        // Resume: promise() at the same position returns a token-less, resumed handle.
1456        let resumed = h.resume();
1457        let promise2 = resumed.promise::<u32>().await.unwrap();
1458        assert!(promise2.is_resumed());
1459        assert_eq!(
1460            promise2.id(),
1461            id,
1462            "the resumed promise re-derives the same id"
1463        );
1464        assert_eq!(resumed.await_promise::<u32>(promise2).await.unwrap(), 77);
1465        h.shutdown().await;
1466    }
1467
1468    #[tokio::test]
1469    async fn sleep_until_returns_when_the_instant_passes() {
1470        let exec = ExecutionId::new();
1471        let h = Harness::open(exec, false).await;
1472        // A near-future instant: the context fires its own timer when due (no service needed).
1473        let due = SystemTime::now() + Duration::from_millis(40);
1474        tokio::time::timeout(Duration::from_secs(2), h.ctx.sleep_until(due))
1475            .await
1476            .expect("sleep_until completes before the test timeout")
1477            .expect("sleep_until succeeds");
1478        h.shutdown().await;
1479    }
1480
1481    #[tokio::test]
1482    async fn sleep_until_wakes_on_concurrent_fire_before_due() {
1483        // An external actor (e.g. DurableTimerService, or a concurrent process) marking the timer
1484        // fired while sleep_until is already parked wakes it at once via the in-process notify,
1485        // rather than waiting out the poll interval or the timer's own due instant.
1486        let exec = ExecutionId::new();
1487        let h = Harness::open(exec, false).await;
1488        // Far enough out that only the notify wakes it, not sleep_until's own due-clock check.
1489        let due = SystemTime::now() + Duration::from_secs(30);
1490        let timer_id = TimerId::derive(exec, StepId::new(0));
1491
1492        let (result, marked) = tokio::join!(
1493            tokio::time::timeout(Duration::from_secs(2), h.ctx.sleep_until(due)),
1494            async {
1495                // Give sleep_until time to arm the timer and register on the notify first.
1496                tokio::time::sleep(Duration::from_millis(25)).await;
1497                h.backend.mark_timer_fired(timer_id).await
1498            }
1499        );
1500        assert!(marked.unwrap(), "the timer transitions to fired");
1501        result
1502            .expect("sleep_until wakes on the concurrent fire before the test timeout")
1503            .expect("sleep_until succeeds");
1504        h.shutdown().await;
1505    }
1506
1507    #[tokio::test]
1508    async fn sleep_until_past_due_returns_immediately_on_resume() {
1509        // FR-DE-06: a timer whose instant elapsed during downtime fires at once on resume.
1510        let exec = ExecutionId::new();
1511        let h = Harness::open(exec, false).await;
1512        // Arm a long-past timer at the position sleep_until will re-derive on resume (step 0).
1513        let timer = TimerId::derive(exec, StepId::new(0));
1514        h.backend.arm_timer(timer, exec, 1_000, 0).await.unwrap();
1515
1516        // The timer service fires the past-due timer on its first poll.
1517        let service = DurableTimerService::new(
1518            Arc::new(DurableBackendEnum::Local(h.backend.clone())),
1519            Duration::from_millis(5),
1520        );
1521        service.fire_due().await;
1522        assert_eq!(
1523            h.backend.timer_state(timer).await.unwrap(),
1524            Some((1_000, true))
1525        );
1526
1527        // A resumed sleep_until at the same position returns immediately (already fired).
1528        let resumed = h.resume();
1529        tokio::time::timeout(
1530            Duration::from_millis(200),
1531            resumed.sleep_until(SystemTime::now() + Duration::from_hours(1)),
1532        )
1533        .await
1534        .expect("resumed sleep_until returns immediately")
1535        .unwrap();
1536        h.shutdown().await;
1537    }
1538
1539    #[tokio::test]
1540    async fn soft_cap_triggers_checkpoint_fold_and_replay_skips_folded_steps() {
1541        // Soft cap (90% of 10 = 9): the step at id 9 folds the idempotent prefix [0..9).
1542        let exec = ExecutionId::new();
1543        let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
1544        local.init().await.unwrap();
1545        local
1546            .open_execution(exec, ExecutionKind::AgentTurn)
1547            .await
1548            .unwrap();
1549        let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
1550        let task = tokio::spawn(writer.run());
1551        let config = DurableConfig {
1552            max_steps_per_execution: 10,
1553            ..fast_config()
1554        };
1555        let ctx = context_with(&local, &handle, exec, false, &config);
1556
1557        let desc = |i: u32| StepDescriptor::idempotent("s", format!("op:{i}").into_bytes());
1558        // Steps 0..=8 run and are committed before the soft-cap step triggers the fold.
1559        for i in 0..9 {
1560            let v: u32 = ctx
1561                .step(desc(i), move |_| async move { Ok(i) })
1562                .await
1563                .unwrap();
1564            assert_eq!(v, i);
1565        }
1566        handle.flush().await.unwrap();
1567        // Step id 9 crosses the soft cap and spawns the background fold of [0..9).
1568        ctx.step::<u32, _, _>(desc(9), |_| async { Ok(9) })
1569            .await
1570            .unwrap();
1571        ctx.drain_background().await;
1572        handle.flush().await.unwrap();
1573
1574        // The folded prefix is compacted into a single checkpoint; steps 9 survives as a row.
1575        let entries = local.read_execution(exec).await.unwrap();
1576        let checkpoints = entries
1577            .iter()
1578            .filter(|e| matches!(e.entry, EntryKind::Checkpoint { .. }))
1579            .count();
1580        assert_eq!(checkpoints, 1, "the soft cap folded one checkpoint");
1581        let surviving: Vec<u32> = entries
1582            .iter()
1583            .filter(|e| matches!(e.entry, EntryKind::StepResult { .. }))
1584            .map(|e| e.step_id.value())
1585            .collect();
1586        assert_eq!(surviving, vec![9], "only the post-fold step row survives");
1587
1588        // Resume: the folded steps replay from the checkpoint without re-running their ops.
1589        let resumed = context_with(&local, &handle, exec, true, &config);
1590        let reran = Arc::new(AtomicU32::new(0));
1591        for i in 0..9 {
1592            let counter = reran.clone();
1593            let v: u32 = resumed
1594                .step(desc(i), move |_| {
1595                    let counter = counter.clone();
1596                    async move {
1597                        counter.fetch_add(1, Ordering::SeqCst);
1598                        Ok(999)
1599                    }
1600                })
1601                .await
1602                .unwrap();
1603            assert_eq!(v, i, "folded step {i} replays its journaled value");
1604        }
1605        assert_eq!(
1606            reran.load(Ordering::SeqCst),
1607            0,
1608            "no folded operation closure re-ran on replay"
1609        );
1610
1611        drop(ctx);
1612        drop(resumed);
1613        drop(handle);
1614        task.await.unwrap();
1615    }
1616
1617    #[tokio::test]
1618    async fn step_cap_is_enforced() {
1619        let exec = ExecutionId::new();
1620        let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
1621        local.init().await.unwrap();
1622        local
1623            .open_execution(exec, ExecutionKind::AgentTurn)
1624            .await
1625            .unwrap();
1626        let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
1627        let task = tokio::spawn(writer.run());
1628        let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
1629        let ctx = DurableContext::new(
1630            exec,
1631            ExecutionKind::AgentTurn,
1632            false,
1633            backend,
1634            handle.clone(),
1635            &DurableConfig {
1636                max_steps_per_execution: 1,
1637                ..fast_config()
1638            },
1639        );
1640        // Step id 0 is allowed; step id 1 exceeds the cap of 1.
1641        ctx.step::<u32, _, _>(
1642            StepDescriptor::idempotent("ok", b"op".to_vec()),
1643            |_| async { Ok(0) },
1644        )
1645        .await
1646        .unwrap();
1647        let err = ctx
1648            .step::<u32, _, _>(
1649                StepDescriptor::idempotent("over", b"op".to_vec()),
1650                |_| async { Ok(0) },
1651            )
1652            .await
1653            .unwrap_err();
1654        assert_matches!(err, DurableError::StepCapExceeded { cap: 1 });
1655
1656        // #6251 critic S2: the hard cap is the one terminal transition the durable crate fully
1657        // owns — it must finalize the execution as Aborted itself rather than leaving it
1658        // `running` forever with no reclamation path.
1659        let (status, finalized_at): (String, Option<i64>) = zeph_db::query_as(zeph_db::sql!(
1660            "SELECT status, finalized_at FROM durable_executions WHERE execution_id = ?"
1661        ))
1662        .bind(exec.as_uuid().to_string())
1663        .fetch_one(local.pool())
1664        .await
1665        .unwrap();
1666        assert_eq!(
1667            status, "aborted",
1668            "a step-cap-exceeded execution must finalize as aborted"
1669        );
1670        assert!(
1671            finalized_at.is_some(),
1672            "finalize must stamp finalized_at too, not just flip status — otherwise the \
1673             retention sweep (gated on finalized_at, not status alone) still can't reclaim it"
1674        );
1675
1676        drop(ctx);
1677        drop(handle);
1678        task.await.unwrap();
1679    }
1680
1681    #[tokio::test]
1682    async fn step_cap_is_enforced_via_checked_step_id() {
1683        // `promise`/`timer` route through `checked_step_id` rather than `run_step_at`; assert both
1684        // call sites share the same enforcement after the dedup refactor (#6082).
1685        let exec = ExecutionId::new();
1686        let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
1687        local.init().await.unwrap();
1688        local
1689            .open_execution(exec, ExecutionKind::AgentTurn)
1690            .await
1691            .unwrap();
1692        let (writer, handle) = JournalWriter::new(local.clone(), &fast_config());
1693        let task = tokio::spawn(writer.run());
1694        let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
1695        let ctx = DurableContext::new(
1696            exec,
1697            ExecutionKind::AgentTurn,
1698            false,
1699            backend,
1700            handle.clone(),
1701            &DurableConfig {
1702                max_steps_per_execution: 1,
1703                ..fast_config()
1704            },
1705        );
1706        // Step id 0 is allowed; step id 1 exceeds the cap of 1.
1707        ctx.promise::<u32>().await.unwrap();
1708        let err = ctx.promise::<u32>().await.unwrap_err();
1709        assert_matches!(err, DurableError::StepCapExceeded { cap: 1 });
1710        drop(ctx);
1711        drop(handle);
1712        task.await.unwrap();
1713    }
1714}