Skip to main content

keelrun_core/
flow.rs

1//! Tier 2: the durable flow manager (architecture spec §4.3–4.4).
2//!
3//! A *flow* is a function whose intercepted effects are journaled as it runs, so
4//! that a crashed run can be re-executed from the top and each already-completed
5//! effect is *substituted* from the journal instead of re-invoked. This is the
6//! DBOS-style coordinator-free recovery model: no scheduler service exists, a
7//! process just scans for incomplete flows with expired leases and resumes them.
8//!
9//! Two durability boundaries meet here and must not contaminate each other
10//! (spec §4.3): retries *within* a step are the Tier 1 engine's business
11//! (attempts of one step, journaled as that step's `attempt` count);
12//! re-execution *of the flow* is this manager's business. So a
13//! [`FlowHandle::execute_step`] wraps [`Engine::execute`] — the step gets full
14//! Tier 1 resilience — and journals the single post-retry outcome.
15//!
16//! At-least-once honesty: a live step is journaled `running` *before* the effect
17//! runs and its terminal outcome recorded *before* the result is released, so a
18//! crash between those points leaves a `running` step that resume re-executes.
19//!
20//! # The async `execute_step` bridge (front-end binding concern)
21//!
22//! [`FlowHandle::execute_step`] is `async`, and both the synchronous AND
23//! asynchronous intercepted-call paths in the front-end bindings route through
24//! it while a flow is open (an earlier v0.1 restriction refused an async
25//! intercepted call with KEEL-E005 rather than let it silently downgrade to
26//! Tier 1 on the bare [`Engine`]; that refusal is retired now that the bridge is
27//! real — see `contracts/error-codes.json`'s retired async-in-flow case and
28//! `docs/ccr/0002-async-steps-and-idempotency-injection.md`).
29//!
30//! `&mut self` is what makes a single call to `execute_step` exclusive, but a
31//! flow handle is reachable from *multiple* concurrent async tasks in a
32//! language runtime (Python's `asyncio.gather`, Node's `Promise.all`) — so each
33//! binding wraps its open handle in an **async** mutex (never a blocking one)
34//! that a call acquires with the moral equivalent of `.lock().await` before
35//! touching the handle, and holds for the *entire* step: `seq` is claimed the
36//! instant `execute_step_with_idempotency_key` is entered (its very first
37//! statement, before any `.await`), and the guard is not released until the
38//! terminal outcome is recorded. This is what makes concurrent awaited effects
39//! **serialize in await order** — normative for every binding, spelled out in
40//! conformance/README.md's "Async steps inside a flow" section: a step's
41//! position in the journal is fixed by the order its call *reaches* the handle,
42//! never by completion order, so replay reproduces the exact same `(seq,
43//! step_key)` sequence deterministically. See `crates/keel-py/src/lib.rs`'s
44//! `execute_async` (the reference implementation) for the binding-side half of
45//! this contract; `FlowHandle` itself contributes only the `&mut self` exclusion
46//! and the at-entry `seq` claim above — it holds no lock and knows nothing about
47//! any particular language runtime.
48//!
49//! Replay correctness is checked, not assumed (spec §4.4): the `(seq, step_key)`
50//! encountered on replay must match the journal. A `seq` recorded under a
51//! *different* key is nondeterminism ([`ErrorCode::FlowNondeterminism`],
52//! KEEL-E031), handled per the `flows.on_nondeterminism` policy.
53//!
54//! # Re-entering a completed flow is pure replay
55//!
56//! A rerun of a flow that already reached `completed` is a *pure replay*: no
57//! lease is taken, no flow-level attempt is consumed, no heartbeat runs, and no
58//! effect ever fires — every step is substituted from the journal and the
59//! function reconstructs its result from the recorded values. This is what makes
60//! `keel run` on a finished pipeline instant and side-effect-free, and it is why
61//! re-entry must never surface a lease error (a completed flow is not `running`,
62//! so a naive `acquire_lease WHERE status='running'` would wrongly report
63//! KEEL-E030). A replay-only handle reaching a step with no recorded outcome is
64//! itself a divergence (the code grew a step since completion) and is refused
65//! with KEEL-E031 rather than run live.
66//!
67//! # Step-key convention (who names steps)
68//!
69//! Step keys are *front-end supplied*, never minted by this manager, so the key
70//! a live run records and the key a replay observes are produced by the same
71//! code path. Effect steps key on the request as `"(target)#(args_hash)"`
72//! ([`FlowHandle::step_key`]). Virtualized value steps (time/random) take an
73//! explicit caller key following the same `"<callable>#<args_hash>"` shape with
74//! the front end's language prefix and `-` for a niladic read — e.g. the Python
75//! front end and the golden fixtures both use `py:time.time#-`,
76//! `py:time.time_ns#-`, `py:random.random#-`. Keeping the convention in the
77//! caller means the fixtures, the live front end, and replay all agree on one
78//! key per read.
79
80use core::time::Duration;
81use std::sync::{Arc, Mutex};
82use std::time::Instant;
83
84use keel_core_api::policy::NondeterminismResponse;
85use keel_core_api::{
86    ENVELOPE_VERSION, ErrorClass, ErrorCode, KeelError, Outcome, OutcomeError, Request,
87};
88use keel_journal::{
89    Clock, FlowId, FlowStatus, Journal, NewFlow, ProcessId, StepKey, StepKind, StepOutcome,
90    StepStatus,
91};
92use serde_json::{Value, json};
93use tracing::{debug, warn};
94
95/// Where a `branch`-mode fresh attempt writes its steps, high above any real
96/// step count so the abandoned run's records (seqs `1..`) are preserved for
97/// audit (spec §4.4).
98const BRANCH_SEQ_BASE: u64 = 1_000_000;
99
100/// The reserved step slot holding the flow-level resume counter. Real steps are
101/// numbered from 1, so seq 0 never collides with them.
102const ATTEMPT_SEQ: u64 = 0;
103/// The step key of the reserved resume-counter marker.
104const ATTEMPT_KEY: &str = "flow:attempt";
105/// The field carrying an adapter-injected idempotency key inside a `running`
106/// step record's schema-tagged payload (contracts/adapter-pack.md
107/// "Idempotency-key injection" rule 3: the minted key is journaled with the
108/// step, and a resume that re-executes a crashed step injects the SAME key).
109const IDEMPOTENCY_KEY_FIELD: &str = "idempotency_key";
110
111use crate::engine::Engine;
112
113/// How a flow is identified and fenced. Identity is
114/// `(entrypoint, args_hash, explicit_key?)` (spec §4.3); `code_hash` fences
115/// replay across deploys (spec §4.4).
116#[derive(Debug, Clone)]
117pub struct FlowDescriptor {
118    /// The flow's entrypoint, e.g. `"py:pipeline.ingest:main"`.
119    pub entrypoint: String,
120    /// Hash of the flow's arguments; part of its identity.
121    pub args_hash: String,
122    /// An optional explicit identity key, disambiguating two runs that share an
123    /// entrypoint and args (spec §4.3).
124    pub explicit_key: Option<String>,
125    /// Hash of the flow's code, used to fence replay across deploys.
126    pub code_hash: Option<String>,
127}
128
129impl FlowDescriptor {
130    /// The deterministic storage key for this identity. Deterministic (no ULID
131    /// clock/random draw) so a rerun with the same identity keys the same flow
132    /// row — which is what makes resume-on-rerun work through the idempotent
133    /// [`Journal::begin_flow`].
134    #[must_use]
135    pub fn flow_id(&self) -> FlowId {
136        FlowId::new(format!(
137            "{}#{}#{}",
138            self.entrypoint,
139            self.args_hash,
140            self.explicit_key.as_deref().unwrap_or("")
141        ))
142    }
143}
144
145/// Tuning for a [`FlowManager`]: lease lifetime and the flow-level attempt cap
146/// (spec §4.3 leases; the cap turns a poison flow `dead`).
147#[derive(Debug, Clone, Copy)]
148pub struct FlowConfig {
149    /// How long an acquired lease is valid before another process may steal it.
150    pub lease_ttl: Duration,
151    /// Maximum flow-level (re-)execution attempts before the flow is marked
152    /// `dead` and refused (KEEL-E032). Distinct from Tier 1 step attempts.
153    pub max_attempts: u32,
154}
155
156impl Default for FlowConfig {
157    fn default() -> Self {
158        Self {
159            lease_ttl: Duration::from_secs(30),
160            max_attempts: 3,
161        }
162    }
163}
164
165/// The Tier 2 flow manager: opens and resumes durable flows over a [`Journal`],
166/// running each step's effect through the Tier 1 [`Engine`]. One per process,
167/// `&self`-concurrent; hands out a `&mut` [`FlowHandle`] per running flow.
168pub struct FlowManager {
169    engine: Arc<Engine>,
170    journal: Arc<dyn Journal>,
171    clock: Arc<dyn Clock>,
172    holder: ProcessId,
173    config: FlowConfig,
174}
175
176impl core::fmt::Debug for FlowManager {
177    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
178        f.debug_struct("FlowManager")
179            .field("holder", &self.holder)
180            .field("config", &self.config)
181            .finish_non_exhaustive()
182    }
183}
184
185impl FlowManager {
186    /// A manager with the default [`FlowConfig`].
187    #[must_use]
188    pub fn new(
189        engine: Arc<Engine>,
190        journal: Arc<dyn Journal>,
191        clock: Arc<dyn Clock>,
192        holder: ProcessId,
193    ) -> Self {
194        Self::with_config(engine, journal, clock, holder, FlowConfig::default())
195    }
196
197    /// A manager with explicit tuning.
198    #[must_use]
199    pub fn with_config(
200        engine: Arc<Engine>,
201        journal: Arc<dyn Journal>,
202        clock: Arc<dyn Clock>,
203        holder: ProcessId,
204        config: FlowConfig,
205    ) -> Self {
206        Self {
207            engine,
208            journal,
209            clock,
210            holder,
211            config,
212        }
213    }
214
215    /// Enter (begin or resume) the flow with this identity: open the row
216    /// (idempotent), take the lease, and hand back a handle in replay-or-live
217    /// mode. `Err` when the lease is held by a live holder (KEEL-E030) or the
218    /// flow is already `dead` (KEEL-E032).
219    ///
220    /// # Errors
221    /// - `KEEL-E030` if another holder's lease is still valid.
222    /// - `KEEL-E032` if the flow is `dead` (never auto-resumed).
223    /// - `KEEL-E040` if the journal cannot open the flow row.
224    pub fn enter_flow(&self, desc: &FlowDescriptor) -> Result<FlowHandle, KeelError> {
225        self.enter(
226            &desc.flow_id(),
227            &desc.entrypoint,
228            &desc.args_hash,
229            desc.code_hash.as_deref(),
230        )
231    }
232
233    /// Resume a flow the recovery scan surfaced ([`Journal::incomplete_flows`]),
234    /// fencing against the currently-deployed `code_hash`.
235    ///
236    /// # Errors
237    /// As [`enter_flow`](Self::enter_flow).
238    pub fn resume_flow(
239        &self,
240        flow: &keel_journal::FlowDescriptor,
241        current_code_hash: Option<&str>,
242    ) -> Result<FlowHandle, KeelError> {
243        self.enter(
244            &flow.flow_id,
245            &flow.entrypoint,
246            &flow.args_hash,
247            current_code_hash,
248        )
249    }
250
251    /// Flow-level attempt cap (distinct from Tier 1 step attempts): each
252    /// resume of a not-yet-completed flow consumes one attempt from the
253    /// reserved seq-0 counter; exceeding the cap marks the flow dead. Returns
254    /// the new attempt number, or `Err` if the cap was exceeded.
255    fn bump_attempt_or_kill(&self, flow_id: &FlowId, entrypoint: &str) -> Result<u32, KeelError> {
256        let prior = self
257            .journal
258            .step_at(flow_id, ATTEMPT_SEQ)
259            .map_err(|e| internal(format!("attempt lookup failed: {e}")))?
260            .map_or(0, |(_, o)| o.attempt);
261        let attempt = prior.saturating_add(1);
262        // A second-or-later attempt is a *recovery*: the flow did not run to
263        // completion in one process lifetime (crash, lease loss, deliberate
264        // resume). First entries (attempt == 1) are ordinary starts, not
265        // recoveries — only re-entries count toward the §4.5 metric.
266        if attempt >= 2 {
267            crate::metrics::record_flow_resume(entrypoint);
268        }
269        if attempt > self.config.max_attempts {
270            self.journal
271                .complete_flow(flow_id, FlowStatus::Dead)
272                .map_err(|e| internal(format!("mark-dead failed: {e}")))?;
273            return Err(KeelError {
274                code: ErrorCode::FlowDead,
275                message: format!(
276                    "flow {flow_id} exceeded its {} attempt cap; marked dead (KEEL-E032)",
277                    self.config.max_attempts
278                ),
279            });
280        }
281        Ok(attempt)
282    }
283
284    fn enter(
285        &self,
286        flow_id: &FlowId,
287        entrypoint: &str,
288        args_hash: &str,
289        current_code_hash: Option<&str>,
290    ) -> Result<FlowHandle, KeelError> {
291        self.journal
292            .begin_flow(&NewFlow {
293                flow_id: flow_id.clone(),
294                entrypoint: entrypoint.to_owned(),
295                args_hash: args_hash.to_owned(),
296                code_hash: current_code_hash.map(str::to_owned),
297            })
298            .map_err(|e| internal(format!("begin_flow failed: {e}")))?;
299
300        let existing = self
301            .journal
302            .get_flow(flow_id)
303            .map_err(|e| internal(format!("get_flow failed: {e}")))?;
304        let status = existing.as_ref().map_or(FlowStatus::Running, |f| f.status);
305
306        // A dead flow is never auto-resumed (spec §4.3).
307        if status == FlowStatus::Dead {
308            return Err(KeelError {
309                code: ErrorCode::FlowDead,
310                message: format!("flow {flow_id} is dead; refusing to resume (KEEL-E032)"),
311            });
312        }
313
314        // Re-entering a completed flow is PURE REPLAY (see module docs): hand
315        // back a replay-only handle that substitutes every step, takes no lease,
316        // spawns no heartbeat, and consumes no attempt. A naive lease acquire
317        // here would fail (a completed flow is not `running`) and wrongly report
318        // KEEL-E030; instead the rerun reconstructs its result from the journal.
319        if status == FlowStatus::Completed {
320            return Ok(self.new_handle(
321                flow_id.clone(),
322                status,
323                false,
324                None,
325                LeaseHeartbeatMonitor::new(),
326                true,
327            ));
328        }
329
330        let attempt = self.bump_attempt_or_kill(flow_id, entrypoint)?;
331
332        // A cleanly-failed flow is put back to `running` before re-leasing so a
333        // resume can proceed (and so a re-crash lands it in the recovery scan).
334        if status == FlowStatus::Failed {
335            self.journal
336                .complete_flow(flow_id, FlowStatus::Running)
337                .map_err(|e| internal(format!("reset-to-running failed: {e}")))?;
338        }
339
340        let acquired = self
341            .journal
342            .acquire_lease(flow_id, &self.holder, self.config.lease_ttl)
343            .map_err(|e| internal(format!("acquire_lease failed: {e}")))?;
344        if !acquired {
345            return Err(KeelError {
346                code: ErrorCode::FlowLeaseHeld,
347                message: format!(
348                    "flow {flow_id} is leased by another holder; not resuming (KEEL-E030)"
349                ),
350            });
351        }
352
353        let now = self.clock.now_ms();
354        let marker = StepOutcome {
355            kind: StepKind::Marker,
356            attempt,
357            status: StepStatus::Ok,
358            payload: None,
359            error_class: None,
360            started_at: now,
361            ended_at: Some(now),
362        };
363        // NOTE: a failure here returns `Err` (issue #14) *after* `acquire_lease`
364        // above already succeeded, but before the heartbeat that would renew it
365        // exists. `Journal` has no explicit lease-release op — the lease is only
366        // ever cleared by `complete_flow` — so this orphans it for up to
367        // `lease_ttl`. That's an existing, self-healing failure mode (the same
368        // shape a crash between `acquire_lease` and heartbeat spawn already has:
369        // `incomplete_flows(lease_expired=true)` reclaims it once the TTL lapses),
370        // not a new correctness bug; it's called out here only because propagating
371        // this write loudly (rather than the old `warn!`-and-continue) is what
372        // makes the orphan possible in the first place.
373        self.journal
374            .record_step(flow_id, ATTEMPT_SEQ, &StepKey::new(ATTEMPT_KEY), &marker)
375            .map_err(|e| internal(format!("attempt-counter record failed: {e}")))?;
376
377        // Replay is fenced when the recorded code differs from what is running
378        // now: a divergence under a changed deploy downgrades fail→warn (§4.4).
379        let code_hash_fenced = match (existing.and_then(|f| f.code_hash), current_code_hash) {
380            (Some(recorded), Some(current)) => recorded != current,
381            _ => false,
382        };
383
384        // Seeded now, right after we confirmed via `acquire_lease` that we hold
385        // it: the monotonic reference point every subsequent `lease_lost` check
386        // measures elapsed real time against (architecture-spec §6).
387        let lease_monitor = LeaseHeartbeatMonitor::new();
388        let heartbeat = spawn_heartbeat(
389            Arc::clone(&self.journal),
390            flow_id.clone(),
391            self.holder.clone(),
392            self.config.lease_ttl,
393            lease_monitor.clone(),
394        );
395
396        Ok(self.new_handle(
397            flow_id.clone(),
398            status,
399            code_hash_fenced,
400            heartbeat,
401            lease_monitor,
402            false,
403        ))
404    }
405
406    /// Assemble a [`FlowHandle`] over this manager's shared engine/journal/clock.
407    /// `replay_only` marks a completed-flow re-entry (pure replay: every step is
408    /// substituted, no effect fires) — the handle is born already `completed` so
409    /// dropping it is quiet and a front-end `exit_flow` is a harmless re-stamp.
410    /// `lease_monitor` is unused by a replay-only handle (it never runs a live
411    /// step, so `lease_lost` is never consulted), but every path constructs one
412    /// so the field is never `Option`.
413    fn new_handle(
414        &self,
415        flow_id: FlowId,
416        entry_status: FlowStatus,
417        code_hash_fenced: bool,
418        heartbeat: Option<HeartbeatHandle>,
419        lease_monitor: LeaseHeartbeatMonitor,
420        replay_only: bool,
421    ) -> FlowHandle {
422        FlowHandle {
423            engine: Arc::clone(&self.engine),
424            journal: Arc::clone(&self.journal),
425            clock: Arc::clone(&self.clock),
426            holder: self.holder.clone(),
427            flow_id,
428            seq: 0,
429            code_hash_fenced,
430            replay_abandoned: false,
431            replay_only,
432            completed: replay_only,
433            entry_status,
434            heartbeat,
435            lease_ttl: self.config.lease_ttl,
436            lease_monitor,
437        }
438    }
439}
440
441/// A single running flow. Owns the step cursor (`seq`), so it is used by one
442/// task via `&mut`; the manager stays `&self`-concurrent for many flows.
443///
444/// Dropping a handle without [`complete`](Self::complete) leaves the flow
445/// `running` with its lease — exactly the crash shape recovery resumes.
446#[expect(
447    clippy::struct_excessive_bools,
448    reason = "each flag is an independent per-handle replay/lease predicate, not a \
449              packed state enum: code_hash_fenced, replay_abandoned, replay_only, completed"
450)]
451pub struct FlowHandle {
452    engine: Arc<Engine>,
453    journal: Arc<dyn Journal>,
454    clock: Arc<dyn Clock>,
455    /// This handle's lease holder id — checked before every live step so a
456    /// handle that has lost its lease fences (KEEL-E030) instead of running the
457    /// effect a second executor may already be running (double-fire defense).
458    holder: ProcessId,
459    flow_id: FlowId,
460    seq: u64,
461    code_hash_fenced: bool,
462    replay_abandoned: bool,
463    /// A completed-flow re-entry (pure replay): every step is substituted from
464    /// the journal and no effect fires; a step with no record is a divergence.
465    replay_only: bool,
466    /// The flow's status when this handle was opened (`completed` for a pure
467    /// replay handle, `running`/`failed` for a live entry) — the front end reads
468    /// it to distinguish "resumed" from "already finished".
469    entry_status: FlowStatus,
470    completed: bool,
471    /// The lease-renewal thread; stopped and joined when the handle drops so a
472    /// completed or crashed flow stops heart-beating.
473    heartbeat: Option<HeartbeatHandle>,
474    /// This handle's lease TTL, for [`FlowHandle::lease_lost`]'s monotonic
475    /// freshness check.
476    lease_ttl: Duration,
477    /// Monotonic view of our own last successful lease renewal, updated by the
478    /// heartbeat thread — see [`LeaseHeartbeatMonitor`].
479    lease_monitor: LeaseHeartbeatMonitor,
480}
481
482impl core::fmt::Debug for FlowHandle {
483    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
484        f.debug_struct("FlowHandle")
485            .field("flow_id", &self.flow_id)
486            .field("seq", &self.seq)
487            .field("entry_status", &self.entry_status)
488            .field("replay_only", &self.replay_only)
489            .field("completed", &self.completed)
490            .finish_non_exhaustive()
491    }
492}
493
494/// What resolving a step against the journal decided.
495enum StepPlan {
496    /// Run the effect live and journal it (fresh progress or a re-executed
497    /// crashed step).
498    Live,
499    /// Substitute this recorded outcome without invoking the effect.
500    Replay(StepOutcome),
501    /// The recorded key at this seq differs from the current one: nondeterminism.
502    Diverged { recorded: StepKey },
503}
504
505/// The context of a `warn`/`branch` divergence recovery, grouped so the
506/// re-execution helper stays within argument bounds.
507struct Divergence<'a> {
508    seq: u64,
509    recorded: &'a StepKey,
510    observed: &'a StepKey,
511    mode: &'static str,
512    preserve: bool,
513}
514
515impl FlowHandle {
516    /// This flow's storage id.
517    #[must_use]
518    pub fn flow_id(&self) -> &FlowId {
519        &self.flow_id
520    }
521
522    /// The flow's status when this handle was opened. `completed` means this is a
523    /// pure-replay handle (a rerun of an already-finished flow); `running` or
524    /// `failed` means a live entry/resume. The front end reads this to tell the
525    /// user "resumed" vs. "already completed".
526    #[must_use]
527    pub fn entry_status(&self) -> FlowStatus {
528        self.entry_status
529    }
530
531    /// Whether this handle is a completed-flow pure replay — every step is
532    /// substituted from the journal and no effect will fire.
533    #[must_use]
534    pub fn is_replay_only(&self) -> bool {
535        self.replay_only
536    }
537
538    fn now(&self) -> i64 {
539        self.clock.now_ms()
540    }
541
542    /// Whether this handle has definitively lost its lease. Two independent
543    /// checks (architecture-spec §6: generous TTLs absorb cross-machine clock
544    /// skew; heartbeats judge freshness on a *monotonic* clock):
545    ///
546    /// - **Cross-process arbitration** (journal wall clock, read-only here): has
547    ///   another holder's `acquire_lease` overwritten `lease_holder`? That CAS
548    ///   already did the wall-clock expiry comparison against the journal's own
549    ///   record when it stole the row, so re-deriving it from *our* wall clock
550    ///   here would be redundant — and exactly the redundant read that lets a
551    ///   local NTP step spuriously flip the verdict.
552    /// - **Local freshness** (monotonic, never wall clock): has our own
553    ///   heartbeat actually renewed within `lease_ttl` of *real elapsed time*?
554    ///   [`LeaseHeartbeatMonitor`] tracks this with [`Instant`], which cannot
555    ///   jump backwards or forwards under an NTP correction the way comparing
556    ///   two `SystemTime` readings can — so a wall-clock step on this process
557    ///   can neither spuriously expire a healthy lease nor paper over a
558    ///   genuinely starved heartbeat.
559    ///
560    /// A missing flow row or a journal read error is *not* treated as loss
561    /// (resilience-first: at-least-once still holds via the `running` marker),
562    /// so a transient hiccup does not stall a legitimately-held flow.
563    fn lease_lost(&self) -> bool {
564        let still_recorded_holder = match self.journal.get_flow(&self.flow_id) {
565            Ok(Some(flow)) => flow.lease_holder.as_ref() == Some(&self.holder),
566            Ok(None) | Err(_) => true,
567        };
568        !still_recorded_holder || self.lease_monitor.elapsed() >= self.lease_ttl
569    }
570
571    /// Decide how the step at `seq` (with `key`) resolves against the journal.
572    fn plan_step(&self, seq: u64, key: &StepKey) -> StepPlan {
573        if self.replay_abandoned {
574            return StepPlan::Live;
575        }
576        match self.journal.step_at(&self.flow_id, seq) {
577            Ok(None) => StepPlan::Live,
578            Ok(Some((recorded_key, outcome))) => {
579                if recorded_key == *key {
580                    // A crashed-mid-step record (`running`) is re-executed live;
581                    // any terminal record is substituted.
582                    match outcome.status {
583                        StepStatus::Running => StepPlan::Live,
584                        StepStatus::Ok | StepStatus::Error => StepPlan::Replay(outcome),
585                    }
586                } else {
587                    StepPlan::Diverged {
588                        recorded: recorded_key,
589                    }
590                }
591            }
592            Err(e) => {
593                // Resilience first: a read failure degrades to a live attempt
594                // rather than stalling the flow (at-least-once still holds — a
595                // re-record on resume corrects the journal).
596                warn!(flow = %self.flow_id, seq, error = %e, "step_at failed; executing live");
597                StepPlan::Live
598            }
599        }
600    }
601
602    /// The `(target)#(args_hash)` key identifying a step, matching
603    /// `steps.step_key` and the golden fixtures.
604    fn step_key(request: &Request) -> StepKey {
605        StepKey::new(format!(
606            "{}#{}",
607            request.target,
608            request.args_hash.as_deref().unwrap_or("-")
609        ))
610    }
611
612    /// Execute one journaled step: an intercepted effect wrapped in the Tier 1
613    /// engine (retries/timeout/breaker are attempts of this one step). On
614    /// replay the recorded outcome is substituted and `effect` is never called.
615    pub async fn execute_step<F>(&mut self, request: &Request, effect: F) -> Outcome
616    where
617        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
618    {
619        self.execute_step_with_idempotency_key(request, None, effect)
620            .await
621    }
622
623    /// [`execute_step`](Self::execute_step), carrying the idempotency key the
624    /// adapter minted and injected for this call (contracts/adapter-pack.md
625    /// "Idempotency-key injection", rule 3). The key is journaled in the step's
626    /// `running` record, so a resume that re-executes a crashed step can read it
627    /// back ([`recorded_idempotency_key`](Self::recorded_idempotency_key)) and
628    /// inject the SAME key — making the at-least-once re-execution deduplicable
629    /// on the provider side. The key never feeds the step key (`args_hash` is
630    /// unchanged by injection), so replay matching is unaffected.
631    pub async fn execute_step_with_idempotency_key<F>(
632        &mut self,
633        request: &Request,
634        idempotency_key: Option<&str>,
635        effect: F,
636    ) -> Outcome
637    where
638        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
639    {
640        self.seq += 1;
641        let seq = self.seq;
642        let key = Self::step_key(request);
643        let plan = self.plan_step(seq, &key);
644        // A completed-flow replay never runs an effect: substitute a recorded
645        // step, and treat a missing/diverging record as nondeterminism (the code
646        // grew or changed a step since the flow completed) rather than firing it.
647        if self.replay_only {
648            return match plan {
649                StepPlan::Replay(outcome) => replay_outcome(&self.flow_id, seq, &outcome),
650                _ => replay_miss_outcome(&self.flow_id, seq, &key),
651            };
652        }
653        match plan {
654            StepPlan::Replay(outcome) => replay_outcome(&self.flow_id, seq, &outcome),
655            StepPlan::Diverged { recorded } => {
656                self.on_divergence(seq, &recorded, &key, request, idempotency_key, effect)
657                    .await
658            }
659            StepPlan::Live => {
660                self.run_live(
661                    seq,
662                    &key,
663                    StepKind::Effect,
664                    request,
665                    idempotency_key,
666                    effect,
667                )
668                .await
669            }
670        }
671    }
672
673    /// The idempotency key recorded for the flow's NEXT step, when that record
674    /// is a crashed (`running`) step under the same `step_key` — the
675    /// resume-reuse read of contracts/adapter-pack.md "Idempotency-key
676    /// injection" rule 3. Adapters call this *before* executing an injectable
677    /// step: `Some(key)` means a previous run crashed mid-step after minting
678    /// `key`, and re-executing with the SAME key lets the provider deduplicate
679    /// the at-least-once re-execution. `None` — mint fresh — on a fresh step, a
680    /// terminal record (the step will be substituted, no key is sent), a
681    /// diverging key, an abandoned replay, a pure-replay handle, or a journal
682    /// read failure (resilience first).
683    #[must_use]
684    pub fn recorded_idempotency_key(&self, step_key: &str) -> Option<String> {
685        if self.replay_abandoned || self.replay_only {
686            return None;
687        }
688        let key = StepKey::new(step_key);
689        match self.journal.step_at(&self.flow_id, self.seq + 1) {
690            Ok(Some((recorded, outcome)))
691                if recorded == key && outcome.status == StepStatus::Running =>
692            {
693                outcome
694                    .payload
695                    .as_deref()
696                    .and_then(decode_payload)
697                    .and_then(|v| {
698                        v.get(IDEMPOTENCY_KEY_FIELD)
699                            .and_then(Value::as_str)
700                            .map(str::to_owned)
701                    })
702            }
703            _ => None,
704        }
705    }
706
707    /// The `flows.on_nondeterminism` response effective for this handle: the
708    /// configured one, except a `code_hash` mismatch downgrades `fail`→`warn`
709    /// (a deploy that changed the code is expected to diverge; §4.4).
710    fn effective_response(&self) -> NondeterminismResponse {
711        let configured = self.engine.nondeterminism_response();
712        if self.code_hash_fenced && configured == NondeterminismResponse::Fail {
713            NondeterminismResponse::Warn
714        } else {
715            configured
716        }
717    }
718
719    /// Apply the nondeterminism policy to a `(seq, step_key)` divergence (§4.4).
720    async fn on_divergence<F>(
721        &mut self,
722        seq: u64,
723        recorded: &StepKey,
724        observed: &StepKey,
725        request: &Request,
726        idempotency_key: Option<&str>,
727        effect: F,
728    ) -> Outcome
729    where
730        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
731    {
732        let (mode, preserve) = match self.effective_response() {
733            // Halt with a precise diagnostic; the caller fails the flow.
734            NondeterminismResponse::Fail => {
735                return diverged_outcome(&self.flow_id, seq, recorded, observed);
736            }
737            // Continue live from the divergence point, journaling a marker.
738            NondeterminismResponse::Warn => ("warn", false),
739            // Abandon replay for a fresh attempt, preserving the old records.
740            NondeterminismResponse::Branch => ("branch", true),
741        };
742        let div = Divergence {
743            seq,
744            recorded,
745            observed,
746            mode,
747            preserve,
748        };
749        self.branch_and_continue(div, request, idempotency_key, effect)
750            .await
751    }
752
753    /// Journal a branch `marker` and re-execute the divergent step live, then
754    /// stay live for the rest of the flow.
755    async fn branch_and_continue<F>(
756        &mut self,
757        div: Divergence<'_>,
758        request: &Request,
759        idempotency_key: Option<&str>,
760        effect: F,
761    ) -> Outcome
762    where
763        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
764    {
765        self.journal_branch_marker(&div);
766        let live_seq = self.seq;
767        self.run_live(
768            live_seq,
769            div.observed,
770            StepKind::Effect,
771            request,
772            idempotency_key,
773            effect,
774        )
775        .await
776    }
777
778    /// Journal the branch marker, abandon replay, and advance the cursor to the
779    /// live-continuation seq (left in `self.seq`). With `preserve` (`branch`),
780    /// the marker + continuation go to a high seq lane so the abandoned run's
781    /// records survive for audit; otherwise (`warn`) they continue in place.
782    /// Shared by the effect and value (time/random) re-execution paths.
783    fn journal_branch_marker(&mut self, div: &Divergence<'_>) {
784        warn!(
785            flow = %self.flow_id, seq = div.seq, mode = div.mode,
786            expected = %div.recorded, observed = %div.observed,
787            "flow nondeterminism; abandoning replay"
788        );
789        self.replay_abandoned = true;
790        let marker_seq = if div.preserve {
791            BRANCH_SEQ_BASE + div.seq
792        } else {
793            div.seq
794        };
795        let now = self.now();
796        self.record(
797            marker_seq,
798            &StepKey::new(format!("flow:branch:{}", div.mode)),
799            &StepOutcome {
800                kind: StepKind::Marker,
801                attempt: 0,
802                status: StepStatus::Ok,
803                payload: encode_payload(&json!({
804                    "mode": div.mode,
805                    "expected": div.recorded.as_str(),
806                    "observed": div.observed.as_str(),
807                })),
808                error_class: None,
809                started_at: now,
810                ended_at: Some(now),
811            },
812        );
813        // The divergent step re-executes in the slot after its marker;
814        // subsequent steps continue from there (replay is now abandoned).
815        self.seq = marker_seq + 1;
816    }
817
818    /// Journal (or replay) a virtualized clock read under the front-end-supplied
819    /// `key` (the module-docs convention, e.g. `py:time.time#-`). On replay the
820    /// recorded value is substituted so a resumed flow sees the same time; live,
821    /// `now_ms` is recorded and returned (spec §4.4).
822    ///
823    /// # Errors
824    /// `KEEL-E031` if this step diverges from the journal under `fail`.
825    pub fn journal_time(&mut self, key: &str, now_ms: i64) -> Result<i64, KeelError> {
826        let bytes = encode_payload(&json!(now_ms)).unwrap_or_default();
827        let recorded = self.resolve_value_step(key, StepKind::Time, bytes)?;
828        Ok(decode_payload(&recorded)
829            .and_then(|v| v.as_i64())
830            .unwrap_or(now_ms))
831    }
832
833    /// Journal (or replay) a virtualized random draw under the front-end-supplied
834    /// `key` (e.g. `py:random.random#-`). On replay the recorded bytes are
835    /// substituted; live, `bytes` are recorded and returned.
836    ///
837    /// # Errors
838    /// `KEEL-E031` if this step diverges from the journal under `fail`.
839    pub fn journal_random(&mut self, key: &str, bytes: Vec<u8>) -> Result<Vec<u8>, KeelError> {
840        self.resolve_value_step(key, StepKind::Random, bytes)
841    }
842
843    /// The shared replay/live machinery for a pure value step (time/random):
844    /// no side effect, so its recorded payload bytes are the whole outcome.
845    fn resolve_value_step(
846        &mut self,
847        key_str: &str,
848        kind: StepKind,
849        live_bytes: Vec<u8>,
850    ) -> Result<Vec<u8>, KeelError> {
851        self.seq += 1;
852        let seq = self.seq;
853        let key = StepKey::new(key_str);
854        let plan = self.plan_step(seq, &key);
855        // Pure replay: substitute the recorded value; never re-draw/re-read.
856        if self.replay_only {
857            return match plan {
858                StepPlan::Replay(outcome) => Ok(outcome.payload.unwrap_or_default()),
859                _ => Err(replay_miss_error(&self.flow_id, seq, &key)),
860            };
861        }
862        match plan {
863            StepPlan::Replay(outcome) => Ok(outcome.payload.unwrap_or_default()),
864            StepPlan::Live => {
865                self.record_value(seq, &key, kind, &live_bytes);
866                Ok(live_bytes)
867            }
868            StepPlan::Diverged { recorded } => {
869                let (mode, preserve) = match self.effective_response() {
870                    NondeterminismResponse::Fail => {
871                        return Err(diverged_error(&self.flow_id, seq, &recorded, &key));
872                    }
873                    NondeterminismResponse::Warn => ("warn", false),
874                    NondeterminismResponse::Branch => ("branch", true),
875                };
876                let div = Divergence {
877                    seq,
878                    recorded: &recorded,
879                    observed: &key,
880                    mode,
881                    preserve,
882                };
883                self.journal_branch_marker(&div);
884                let live_seq = self.seq;
885                self.record_value(live_seq, &key, kind, &live_bytes);
886                Ok(live_bytes)
887            }
888        }
889    }
890
891    /// Record a pure value step (time/random): instantaneous, terminal `ok`.
892    fn record_value(&self, seq: u64, key: &StepKey, kind: StepKind, bytes: &[u8]) {
893        let now = self.now();
894        self.record(
895            seq,
896            key,
897            &StepOutcome {
898                kind,
899                attempt: 0,
900                status: StepStatus::Ok,
901                payload: Some(bytes.to_vec()),
902                error_class: None,
903                started_at: now,
904                ended_at: Some(now),
905            },
906        );
907    }
908
909    /// Journal a `running` step, run the effect through the engine, and record
910    /// the terminal outcome *before* returning it (at-least-once honesty).
911    /// An adapter-injected `idempotency_key` is journaled IN the `running`
912    /// record (payload `{"idempotency_key": …}`), so a resume that re-executes
913    /// this step after a crash reads the same key back
914    /// ([`recorded_idempotency_key`](Self::recorded_idempotency_key)); the
915    /// terminal record replaces the payload with the outcome as before.
916    async fn run_live<F>(
917        &mut self,
918        seq: u64,
919        key: &StepKey,
920        kind: StepKind,
921        request: &Request,
922        idempotency_key: Option<&str>,
923        effect: F,
924    ) -> Outcome
925    where
926        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
927    {
928        // Lease fence: before firing a live effect, confirm we still hold the
929        // lease. If it lapsed (heartbeat starved, clock jump) another process
930        // may have stolen it and be resuming this flow concurrently — running
931        // the effect now would double-fire it (charges, emails, LLM spend) and
932        // interleave step records. Fail the step loudly (KEEL-E030) instead of
933        // silently double-executing. A journal read error is resilience-first
934        // (proceed): only a definitive loss fences.
935        if self.lease_lost() {
936            warn!(flow = %self.flow_id, seq, "lease lost before live step; refusing to double-execute (KEEL-E030)");
937            return lease_lost_outcome(&self.flow_id, seq);
938        }
939        let started_at = self.now();
940        self.record(
941            seq,
942            key,
943            &StepOutcome {
944                kind,
945                attempt: 0,
946                status: StepStatus::Running,
947                payload: idempotency_key
948                    .and_then(|k| encode_payload(&json!({ (IDEMPOTENCY_KEY_FIELD): k }))),
949                error_class: None,
950                started_at,
951                ended_at: None,
952            },
953        );
954
955        let outcome = self.engine.execute(request, effect).await;
956
957        let ended_at = self.now();
958        let (status, payload, error_class) = if outcome.result == "ok" {
959            (
960                StepStatus::Ok,
961                outcome.payload.as_ref().and_then(encode_payload),
962                None,
963            )
964        } else {
965            (
966                StepStatus::Error,
967                None,
968                outcome.error.as_ref().map(|e| e.class),
969            )
970        };
971        self.record(
972            seq,
973            key,
974            &StepOutcome {
975                kind,
976                attempt: outcome.attempts,
977                status,
978                payload,
979                error_class,
980                started_at,
981                ended_at: Some(ended_at),
982            },
983        );
984        outcome
985    }
986
987    /// Record a step, degrading a journal failure to a `warn!`. A lost record
988    /// costs replay dedup, never correctness: the `running` marker (or its
989    /// absence) makes resume re-execute the step, so the effect runs
990    /// at-least-once regardless.
991    fn record(&self, seq: u64, key: &StepKey, outcome: &StepOutcome) {
992        if let Err(e) = self.journal.record_step(&self.flow_id, seq, key, outcome) {
993            warn!(flow = %self.flow_id, seq, error = %e, "record_step failed; step not journaled");
994        }
995    }
996
997    /// Move the flow to a terminal status on scope exit. Idempotent-ish: a
998    /// second call re-stamps the status — except a `completed` flow is immutable
999    /// at the journal (`complete_flow` refuses to demote it), so re-running a
1000    /// finished flow can never flip it to `failed`/`dead`.
1001    ///
1002    /// Unlike [`Self::record`], a failed write here is NOT degraded to a
1003    /// `warn!` — it is returned as a `KEEL-E040` [`KeelError`] (issue #14: a
1004    /// same-process foreign SQLite connection opened and closed against a live
1005    /// journal.db can trigger a WAL-checkpoint-on-close race that silently
1006    /// drops a still-open core's pending native writes; see
1007    /// `docs/superpowers/ledgers/agent-first-class/ws5-task-3-report.md` for
1008    /// the discovery narrative). Losing the terminal-status write is a
1009    /// correctness bug (the journal can permanently disagree with the caller
1010    /// about how the flow ended), so it must reach the caller loudly instead
1011    /// of vanishing into a log line. `self.completed` is set regardless of
1012    /// success or failure: a completed attempt is "done" from the handle's
1013    /// perspective either way, and `Drop` should not also complain about it.
1014    pub fn complete(&mut self, status: FlowStatus) -> Result<(), KeelError> {
1015        let result = self
1016            .journal
1017            .complete_flow(&self.flow_id, status)
1018            .map_err(|e| internal(format!("complete_flow failed: {e}")));
1019        self.completed = true;
1020        result
1021    }
1022
1023    /// Mark the flow `completed` (the success scope exit).
1024    pub fn complete_success(&mut self) -> Result<(), KeelError> {
1025        self.complete(FlowStatus::Completed)
1026    }
1027
1028    /// Mark the flow `failed` (a non-retryable failure exit).
1029    pub fn complete_failed(&mut self) -> Result<(), KeelError> {
1030        self.complete(FlowStatus::Failed)
1031    }
1032}
1033
1034impl Drop for FlowHandle {
1035    fn drop(&mut self) {
1036        // Stop and join the lease-renewal thread (HeartbeatHandle::drop).
1037        self.heartbeat = None;
1038        if !self.completed {
1039            // A handle dropped without complete() models a crash: the flow stays
1040            // `running` with its lease and is resumed after the lease expires.
1041            debug!(flow = %self.flow_id, "flow handle dropped uncompleted; left running for recovery");
1042        }
1043    }
1044}
1045
1046/// The monotonic timestamp of this handle's last successful lease renewal,
1047/// shared between the heartbeat thread (writer, [`Self::mark_renewed`]) and the
1048/// owning [`FlowHandle`] (reader, [`Self::elapsed`]). Built on [`Instant`], the
1049/// standard library's guaranteed-non-decreasing clock — unlike [`SystemTime`]
1050/// (used for every *stored* journal timestamp), it cannot be stepped backwards
1051/// by an NTP correction or a manual clock change, which is exactly the
1052/// property architecture-spec §6 asks lease heartbeats to have.
1053///
1054/// [`SystemTime`]: std::time::SystemTime
1055#[derive(Debug, Clone)]
1056struct LeaseHeartbeatMonitor(Arc<Mutex<Instant>>);
1057
1058impl LeaseHeartbeatMonitor {
1059    /// Starts "fresh" as of now — used both when a lease is first acquired
1060    /// (before the heartbeat thread has ticked even once) and for the unused
1061    /// monitor a replay-only handle is handed.
1062    fn new() -> Self {
1063        Self(Arc::new(Mutex::new(Instant::now())))
1064    }
1065
1066    /// Record a successful renewal at the current instant.
1067    fn mark_renewed(&self) {
1068        let mut at = self
1069            .0
1070            .lock()
1071            .expect("lease heartbeat monitor lock poisoned");
1072        *at = Instant::now();
1073    }
1074
1075    /// Real time elapsed since the last successful renewal (or since
1076    /// construction, if none has happened yet).
1077    fn elapsed(&self) -> Duration {
1078        self.0
1079            .lock()
1080            .expect("lease heartbeat monitor lock poisoned")
1081            .elapsed()
1082    }
1083}
1084
1085/// A running lease-renewal thread. Dropping this signals the thread to stop (by
1086/// disconnecting the channel) and joins it.
1087struct HeartbeatHandle {
1088    stop: Option<std::sync::mpsc::Sender<()>>,
1089    thread: Option<std::thread::JoinHandle<()>>,
1090}
1091
1092impl Drop for HeartbeatHandle {
1093    fn drop(&mut self) {
1094        // Dropping the sender disconnects the channel, waking the thread's
1095        // `recv_timeout` immediately so it exits without waiting out the period.
1096        drop(self.stop.take());
1097        if let Some(thread) = self.thread.take() {
1098            let _ = thread.join();
1099        }
1100    }
1101}
1102
1103/// Spawn the lease-renewal heartbeat on a dedicated **OS thread** (not a tokio
1104/// task). Under the synchronous front-end bindings the ambient runtime is a
1105/// current-thread runtime that only makes progress while a `block_on` is in
1106/// flight, so a tokio-task heartbeat starves exactly when it is needed — during
1107/// a long blocking step or pure-caller compute between steps — letting the lease
1108/// lapse while the flow is still alive. A std thread renews on real wall-clock
1109/// time regardless of whether any runtime is being polled. Renews every `ttl/2`
1110/// against the journal (which does its own wall-clock CAS — see
1111/// [`FlowHandle::lease_lost`]); every successful renewal also marks `monitor`,
1112/// the *monotonic* freshness signal the owning handle consults. A lost lease
1113/// (`Ok(false)`) is logged loudly (the step-level fence is what actually
1114/// prevents double execution).
1115fn spawn_heartbeat(
1116    journal: Arc<dyn Journal>,
1117    flow: FlowId,
1118    holder: ProcessId,
1119    ttl: Duration,
1120    monitor: LeaseHeartbeatMonitor,
1121) -> Option<HeartbeatHandle> {
1122    use std::sync::mpsc::{RecvTimeoutError, channel};
1123
1124    let period = (ttl / 2).max(Duration::from_millis(1));
1125    let (stop, rx) = channel::<()>();
1126    let thread = std::thread::Builder::new()
1127        .name(String::from("keel-lease-heartbeat"))
1128        .spawn(move || {
1129            // Loop until the sender is dropped/sent (handle closing) —
1130            // `recv_timeout` then yields `Ok`/`Disconnected` and the `while let`
1131            // ends; a `Timeout` is a renewal tick.
1132            while let Err(RecvTimeoutError::Timeout) = rx.recv_timeout(period) {
1133                match journal.acquire_lease(&flow, &holder, ttl) {
1134                    Ok(true) => monitor.mark_renewed(),
1135                    Ok(false) => {
1136                        warn!(flow = %flow, "lease heartbeat: lease lost to another holder");
1137                    }
1138                    Err(e) => {
1139                        warn!(flow = %flow, error = %e, "lease heartbeat renewal failed");
1140                    }
1141                }
1142            }
1143        })
1144        .ok()?;
1145    Some(HeartbeatHandle {
1146        stop: Some(stop),
1147        thread: Some(thread),
1148    })
1149}
1150
1151/// A configuration/internal `KEEL-E040`.
1152fn internal(message: String) -> KeelError {
1153    KeelError {
1154        code: ErrorCode::Internal,
1155        message,
1156    }
1157}
1158
1159/// Self-describing schema tag stamped into every step payload blob, honoring
1160/// journal.sql's "MessagePack, schema-tagged" contract (`steps.payload`) and
1161/// mirroring the persistent cache's `keel.cache/v1` convention so one journal.db
1162/// uses one payload-tagging discipline.
1163const STEP_PAYLOAD_SCHEMA: &str = "keel.step/v1";
1164
1165/// The schema-tagged step-payload envelope, written by reference (no clone).
1166#[derive(serde::Serialize)]
1167struct StepPayloadRef<'a> {
1168    schema: &'a str,
1169    payload: &'a Value,
1170}
1171
1172/// The owned form read back before its tag is verified.
1173#[derive(serde::Deserialize)]
1174struct StepPayloadOwned {
1175    schema: String,
1176    payload: Value,
1177}
1178
1179/// MessagePack-encode a step payload with its schema tag (journal.sql:
1180/// `steps.payload` is "MessagePack, schema-tagged").
1181fn encode_payload(value: &Value) -> Option<Vec<u8>> {
1182    rmp_serde::to_vec_named(&StepPayloadRef {
1183        schema: STEP_PAYLOAD_SCHEMA,
1184        payload: value,
1185    })
1186    .ok()
1187}
1188
1189/// Decode a step payload. Prefers the schema-tagged envelope; falls back to a
1190/// bare value so journals written before the tag existed — including the golden
1191/// fixtures in `conformance/fixtures/journal/` — still replay (the tag is
1192/// introduced without a breaking on-disk migration, which is exactly what
1193/// "versioned, self-describing" buys).
1194pub fn decode_payload(bytes: &[u8]) -> Option<Value> {
1195    if let Ok(envelope) = rmp_serde::from_slice::<StepPayloadOwned>(bytes)
1196        && envelope.schema == STEP_PAYLOAD_SCHEMA
1197    {
1198        return Some(envelope.payload);
1199    }
1200    rmp_serde::from_slice(bytes).ok()
1201}
1202
1203/// A synthetic trace id for a step outcome the manager mints (replay /
1204/// divergence), distinct from the engine's `t-NNNNNN` live ids.
1205fn step_trace(flow: &FlowId, seq: u64) -> String {
1206    format!("flow-{flow}-s{seq}")
1207}
1208
1209/// Reconstruct an [`Outcome`] from a recorded step — the replay substitution.
1210fn replay_outcome(flow: &FlowId, seq: u64, step: &StepOutcome) -> Outcome {
1211    let mut outcome = base_outcome(step_trace(flow, seq));
1212    outcome.attempts = step.attempt;
1213    match step.status {
1214        StepStatus::Ok | StepStatus::Running => {
1215            outcome.result = String::from("ok");
1216            outcome.payload = step.payload.as_deref().and_then(decode_payload);
1217        }
1218        StepStatus::Error => {
1219            outcome.error = Some(OutcomeError {
1220                code: ErrorCode::NonRetryableError,
1221                class: step.error_class.unwrap_or(ErrorClass::Other),
1222                http_status: None,
1223                message: String::from("replayed failed step"),
1224                original: None,
1225            });
1226        }
1227    }
1228    outcome
1229}
1230
1231/// The precise expected-vs-actual diagnostic for a `(seq, step_key)` divergence
1232/// (spec §4.4), shared by the outcome and error surfaces.
1233fn divergence_message(flow: &FlowId, seq: u64, recorded: &StepKey, observed: &StepKey) -> String {
1234    format!("flow {flow} diverged at step {seq}: expected {recorded}, got {observed} (KEEL-E031)")
1235}
1236
1237/// The KEEL-E031 outcome for a divergence on an effect step (`execute_step`).
1238fn diverged_outcome(flow: &FlowId, seq: u64, recorded: &StepKey, observed: &StepKey) -> Outcome {
1239    let mut outcome = base_outcome(step_trace(flow, seq));
1240    outcome.error = Some(OutcomeError {
1241        code: ErrorCode::FlowNondeterminism,
1242        class: ErrorClass::Other,
1243        http_status: None,
1244        message: divergence_message(flow, seq, recorded, observed),
1245        original: None,
1246    });
1247    outcome
1248}
1249
1250/// The KEEL-E030 outcome for a live step whose lease was lost to another holder
1251/// — the step is refused rather than double-executed.
1252fn lease_lost_outcome(flow: &FlowId, seq: u64) -> Outcome {
1253    let mut outcome = base_outcome(step_trace(flow, seq));
1254    outcome.error = Some(OutcomeError {
1255        code: ErrorCode::FlowLeaseHeld,
1256        class: ErrorClass::Other,
1257        http_status: None,
1258        message: format!(
1259            "flow {flow} lost its lease before step {seq}; another holder may be resuming it \
1260             (KEEL-E030). Refusing to run the effect to avoid double execution."
1261        ),
1262        original: None,
1263    });
1264    outcome
1265}
1266
1267/// The KEEL-E031 error for a divergence on a value step (`journal_time` /
1268/// `journal_random`), which return `Result` rather than an `Outcome`.
1269fn diverged_error(flow: &FlowId, seq: u64, recorded: &StepKey, observed: &StepKey) -> KeelError {
1270    KeelError {
1271        code: ErrorCode::FlowNondeterminism,
1272        message: divergence_message(flow, seq, recorded, observed),
1273    }
1274}
1275
1276/// A completed-flow replay reached step `seq` (`observed`) with no matching
1277/// recorded outcome — the code grew or reordered a step since the flow finished.
1278/// Refused as nondeterminism (KEEL-E031) so no effect fires on a replay handle.
1279fn replay_miss_message(flow: &FlowId, seq: u64, observed: &StepKey) -> String {
1280    format!(
1281        "flow {flow} replay reached unrecorded step {seq} ({observed}); \
1282         the completed flow's code changed (KEEL-E031)"
1283    )
1284}
1285
1286/// The KEEL-E031 outcome for a replay-only miss on an effect step.
1287fn replay_miss_outcome(flow: &FlowId, seq: u64, observed: &StepKey) -> Outcome {
1288    let mut outcome = base_outcome(step_trace(flow, seq));
1289    outcome.error = Some(OutcomeError {
1290        code: ErrorCode::FlowNondeterminism,
1291        class: ErrorClass::Other,
1292        http_status: None,
1293        message: replay_miss_message(flow, seq, observed),
1294        original: None,
1295    });
1296    outcome
1297}
1298
1299/// The KEEL-E031 error for a replay-only miss on a value step.
1300fn replay_miss_error(flow: &FlowId, seq: u64, observed: &StepKey) -> KeelError {
1301    KeelError {
1302        code: ErrorCode::FlowNondeterminism,
1303        message: replay_miss_message(flow, seq, observed),
1304    }
1305}
1306
1307/// A fresh error/replay [`Outcome`] shell with the shared envelope defaults.
1308fn base_outcome(trace_id: String) -> Outcome {
1309    Outcome {
1310        v: ENVELOPE_VERSION,
1311        result: String::from("error"),
1312        payload: None,
1313        error: None,
1314        attempts: 0,
1315        from_cache: false,
1316        waits_ms: Vec::new(),
1317        throttled: false,
1318        throttle_wait_ms: 0,
1319        breaker: keel_core_api::BreakerState::Closed,
1320        trace_id,
1321    }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use super::{LeaseHeartbeatMonitor, decode_payload, encode_payload};
1327    use core::time::Duration;
1328    use serde_json::json;
1329
1330    /// `LeaseHeartbeatMonitor` measures real elapsed time (architecture-spec
1331    /// §6's monotonic heartbeat), not a value anyone can inject — so this is
1332    /// necessarily a real-clock test with small real sleeps, the same
1333    /// precedent `the_heartbeat_renews_the_lease_on_a_real_clock` sets in
1334    /// `crates/keel-core/tests/flows.rs`.
1335    #[test]
1336    fn lease_heartbeat_monitor_tracks_real_elapsed_time_since_the_last_renewal() {
1337        let monitor = LeaseHeartbeatMonitor::new();
1338        assert!(
1339            monitor.elapsed() < Duration::from_millis(50),
1340            "freshly constructed: elapsed should be ~0"
1341        );
1342
1343        std::thread::sleep(Duration::from_millis(60));
1344        assert!(
1345            monitor.elapsed() >= Duration::from_millis(60),
1346            "elapsed grows with real time when nothing renews"
1347        );
1348
1349        monitor.mark_renewed();
1350        assert!(
1351            monitor.elapsed() < Duration::from_millis(50),
1352            "a renewal resets elapsed back to ~0"
1353        );
1354    }
1355
1356    #[test]
1357    fn payload_round_trips_through_the_schema_tag() {
1358        let value = json!({ "rows": 120, "nested": [1, 2, 3], "ok": true });
1359        let bytes = encode_payload(&value).expect("encodes");
1360        assert_eq!(decode_payload(&bytes), Some(value));
1361    }
1362
1363    #[test]
1364    fn legacy_bare_messagepack_still_decodes() {
1365        // Journals written before the tag (and the golden fixtures) stored the
1366        // bare value; the decoder must still read them (no breaking migration).
1367        let map = json!({ "rows": 120 });
1368        let bare_map = rmp_serde::to_vec_named(&map).expect("bare encodes");
1369        assert_eq!(decode_payload(&bare_map), Some(map));
1370
1371        // The fixtures' bare uint32 virtualized-time value (0xCE6A518600).
1372        let num = json!(1_783_727_616u64);
1373        let bare_num = rmp_serde::to_vec_named(&num).expect("bare encodes");
1374        assert_eq!(bare_num, vec![0xCE, 0x6A, 0x51, 0x86, 0x00]);
1375        assert_eq!(decode_payload(&bare_num), Some(num));
1376    }
1377}