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        if let Err(e) =
364            self.journal
365                .record_step(flow_id, ATTEMPT_SEQ, &StepKey::new(ATTEMPT_KEY), &marker)
366        {
367            warn!(flow = %flow_id, error = %e, "attempt-counter record failed");
368        }
369
370        // Replay is fenced when the recorded code differs from what is running
371        // now: a divergence under a changed deploy downgrades fail→warn (§4.4).
372        let code_hash_fenced = match (existing.and_then(|f| f.code_hash), current_code_hash) {
373            (Some(recorded), Some(current)) => recorded != current,
374            _ => false,
375        };
376
377        // Seeded now, right after we confirmed via `acquire_lease` that we hold
378        // it: the monotonic reference point every subsequent `lease_lost` check
379        // measures elapsed real time against (architecture-spec §6).
380        let lease_monitor = LeaseHeartbeatMonitor::new();
381        let heartbeat = spawn_heartbeat(
382            Arc::clone(&self.journal),
383            flow_id.clone(),
384            self.holder.clone(),
385            self.config.lease_ttl,
386            lease_monitor.clone(),
387        );
388
389        Ok(self.new_handle(
390            flow_id.clone(),
391            status,
392            code_hash_fenced,
393            heartbeat,
394            lease_monitor,
395            false,
396        ))
397    }
398
399    /// Assemble a [`FlowHandle`] over this manager's shared engine/journal/clock.
400    /// `replay_only` marks a completed-flow re-entry (pure replay: every step is
401    /// substituted, no effect fires) — the handle is born already `completed` so
402    /// dropping it is quiet and a front-end `exit_flow` is a harmless re-stamp.
403    /// `lease_monitor` is unused by a replay-only handle (it never runs a live
404    /// step, so `lease_lost` is never consulted), but every path constructs one
405    /// so the field is never `Option`.
406    fn new_handle(
407        &self,
408        flow_id: FlowId,
409        entry_status: FlowStatus,
410        code_hash_fenced: bool,
411        heartbeat: Option<HeartbeatHandle>,
412        lease_monitor: LeaseHeartbeatMonitor,
413        replay_only: bool,
414    ) -> FlowHandle {
415        FlowHandle {
416            engine: Arc::clone(&self.engine),
417            journal: Arc::clone(&self.journal),
418            clock: Arc::clone(&self.clock),
419            holder: self.holder.clone(),
420            flow_id,
421            seq: 0,
422            code_hash_fenced,
423            replay_abandoned: false,
424            replay_only,
425            completed: replay_only,
426            entry_status,
427            heartbeat,
428            lease_ttl: self.config.lease_ttl,
429            lease_monitor,
430        }
431    }
432}
433
434/// A single running flow. Owns the step cursor (`seq`), so it is used by one
435/// task via `&mut`; the manager stays `&self`-concurrent for many flows.
436///
437/// Dropping a handle without [`complete`](Self::complete) leaves the flow
438/// `running` with its lease — exactly the crash shape recovery resumes.
439#[expect(
440    clippy::struct_excessive_bools,
441    reason = "each flag is an independent per-handle replay/lease predicate, not a \
442              packed state enum: code_hash_fenced, replay_abandoned, replay_only, completed"
443)]
444pub struct FlowHandle {
445    engine: Arc<Engine>,
446    journal: Arc<dyn Journal>,
447    clock: Arc<dyn Clock>,
448    /// This handle's lease holder id — checked before every live step so a
449    /// handle that has lost its lease fences (KEEL-E030) instead of running the
450    /// effect a second executor may already be running (double-fire defense).
451    holder: ProcessId,
452    flow_id: FlowId,
453    seq: u64,
454    code_hash_fenced: bool,
455    replay_abandoned: bool,
456    /// A completed-flow re-entry (pure replay): every step is substituted from
457    /// the journal and no effect fires; a step with no record is a divergence.
458    replay_only: bool,
459    /// The flow's status when this handle was opened (`completed` for a pure
460    /// replay handle, `running`/`failed` for a live entry) — the front end reads
461    /// it to distinguish "resumed" from "already finished".
462    entry_status: FlowStatus,
463    completed: bool,
464    /// The lease-renewal thread; stopped and joined when the handle drops so a
465    /// completed or crashed flow stops heart-beating.
466    heartbeat: Option<HeartbeatHandle>,
467    /// This handle's lease TTL, for [`FlowHandle::lease_lost`]'s monotonic
468    /// freshness check.
469    lease_ttl: Duration,
470    /// Monotonic view of our own last successful lease renewal, updated by the
471    /// heartbeat thread — see [`LeaseHeartbeatMonitor`].
472    lease_monitor: LeaseHeartbeatMonitor,
473}
474
475impl core::fmt::Debug for FlowHandle {
476    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
477        f.debug_struct("FlowHandle")
478            .field("flow_id", &self.flow_id)
479            .field("seq", &self.seq)
480            .field("entry_status", &self.entry_status)
481            .field("replay_only", &self.replay_only)
482            .field("completed", &self.completed)
483            .finish_non_exhaustive()
484    }
485}
486
487/// What resolving a step against the journal decided.
488enum StepPlan {
489    /// Run the effect live and journal it (fresh progress or a re-executed
490    /// crashed step).
491    Live,
492    /// Substitute this recorded outcome without invoking the effect.
493    Replay(StepOutcome),
494    /// The recorded key at this seq differs from the current one: nondeterminism.
495    Diverged { recorded: StepKey },
496}
497
498/// The context of a `warn`/`branch` divergence recovery, grouped so the
499/// re-execution helper stays within argument bounds.
500struct Divergence<'a> {
501    seq: u64,
502    recorded: &'a StepKey,
503    observed: &'a StepKey,
504    mode: &'static str,
505    preserve: bool,
506}
507
508impl FlowHandle {
509    /// This flow's storage id.
510    #[must_use]
511    pub fn flow_id(&self) -> &FlowId {
512        &self.flow_id
513    }
514
515    /// The flow's status when this handle was opened. `completed` means this is a
516    /// pure-replay handle (a rerun of an already-finished flow); `running` or
517    /// `failed` means a live entry/resume. The front end reads this to tell the
518    /// user "resumed" vs. "already completed".
519    #[must_use]
520    pub fn entry_status(&self) -> FlowStatus {
521        self.entry_status
522    }
523
524    /// Whether this handle is a completed-flow pure replay — every step is
525    /// substituted from the journal and no effect will fire.
526    #[must_use]
527    pub fn is_replay_only(&self) -> bool {
528        self.replay_only
529    }
530
531    fn now(&self) -> i64 {
532        self.clock.now_ms()
533    }
534
535    /// Whether this handle has definitively lost its lease. Two independent
536    /// checks (architecture-spec §6: generous TTLs absorb cross-machine clock
537    /// skew; heartbeats judge freshness on a *monotonic* clock):
538    ///
539    /// - **Cross-process arbitration** (journal wall clock, read-only here): has
540    ///   another holder's `acquire_lease` overwritten `lease_holder`? That CAS
541    ///   already did the wall-clock expiry comparison against the journal's own
542    ///   record when it stole the row, so re-deriving it from *our* wall clock
543    ///   here would be redundant — and exactly the redundant read that lets a
544    ///   local NTP step spuriously flip the verdict.
545    /// - **Local freshness** (monotonic, never wall clock): has our own
546    ///   heartbeat actually renewed within `lease_ttl` of *real elapsed time*?
547    ///   [`LeaseHeartbeatMonitor`] tracks this with [`Instant`], which cannot
548    ///   jump backwards or forwards under an NTP correction the way comparing
549    ///   two `SystemTime` readings can — so a wall-clock step on this process
550    ///   can neither spuriously expire a healthy lease nor paper over a
551    ///   genuinely starved heartbeat.
552    ///
553    /// A missing flow row or a journal read error is *not* treated as loss
554    /// (resilience-first: at-least-once still holds via the `running` marker),
555    /// so a transient hiccup does not stall a legitimately-held flow.
556    fn lease_lost(&self) -> bool {
557        let still_recorded_holder = match self.journal.get_flow(&self.flow_id) {
558            Ok(Some(flow)) => flow.lease_holder.as_ref() == Some(&self.holder),
559            Ok(None) | Err(_) => true,
560        };
561        !still_recorded_holder || self.lease_monitor.elapsed() >= self.lease_ttl
562    }
563
564    /// Decide how the step at `seq` (with `key`) resolves against the journal.
565    fn plan_step(&self, seq: u64, key: &StepKey) -> StepPlan {
566        if self.replay_abandoned {
567            return StepPlan::Live;
568        }
569        match self.journal.step_at(&self.flow_id, seq) {
570            Ok(None) => StepPlan::Live,
571            Ok(Some((recorded_key, outcome))) => {
572                if recorded_key == *key {
573                    // A crashed-mid-step record (`running`) is re-executed live;
574                    // any terminal record is substituted.
575                    match outcome.status {
576                        StepStatus::Running => StepPlan::Live,
577                        StepStatus::Ok | StepStatus::Error => StepPlan::Replay(outcome),
578                    }
579                } else {
580                    StepPlan::Diverged {
581                        recorded: recorded_key,
582                    }
583                }
584            }
585            Err(e) => {
586                // Resilience first: a read failure degrades to a live attempt
587                // rather than stalling the flow (at-least-once still holds — a
588                // re-record on resume corrects the journal).
589                warn!(flow = %self.flow_id, seq, error = %e, "step_at failed; executing live");
590                StepPlan::Live
591            }
592        }
593    }
594
595    /// The `(target)#(args_hash)` key identifying a step, matching
596    /// `steps.step_key` and the golden fixtures.
597    fn step_key(request: &Request) -> StepKey {
598        StepKey::new(format!(
599            "{}#{}",
600            request.target,
601            request.args_hash.as_deref().unwrap_or("-")
602        ))
603    }
604
605    /// Execute one journaled step: an intercepted effect wrapped in the Tier 1
606    /// engine (retries/timeout/breaker are attempts of this one step). On
607    /// replay the recorded outcome is substituted and `effect` is never called.
608    pub async fn execute_step<F>(&mut self, request: &Request, effect: F) -> Outcome
609    where
610        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
611    {
612        self.execute_step_with_idempotency_key(request, None, effect)
613            .await
614    }
615
616    /// [`execute_step`](Self::execute_step), carrying the idempotency key the
617    /// adapter minted and injected for this call (contracts/adapter-pack.md
618    /// "Idempotency-key injection", rule 3). The key is journaled in the step's
619    /// `running` record, so a resume that re-executes a crashed step can read it
620    /// back ([`recorded_idempotency_key`](Self::recorded_idempotency_key)) and
621    /// inject the SAME key — making the at-least-once re-execution deduplicable
622    /// on the provider side. The key never feeds the step key (`args_hash` is
623    /// unchanged by injection), so replay matching is unaffected.
624    pub async fn execute_step_with_idempotency_key<F>(
625        &mut self,
626        request: &Request,
627        idempotency_key: Option<&str>,
628        effect: F,
629    ) -> Outcome
630    where
631        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
632    {
633        self.seq += 1;
634        let seq = self.seq;
635        let key = Self::step_key(request);
636        let plan = self.plan_step(seq, &key);
637        // A completed-flow replay never runs an effect: substitute a recorded
638        // step, and treat a missing/diverging record as nondeterminism (the code
639        // grew or changed a step since the flow completed) rather than firing it.
640        if self.replay_only {
641            return match plan {
642                StepPlan::Replay(outcome) => replay_outcome(&self.flow_id, seq, &outcome),
643                _ => replay_miss_outcome(&self.flow_id, seq, &key),
644            };
645        }
646        match plan {
647            StepPlan::Replay(outcome) => replay_outcome(&self.flow_id, seq, &outcome),
648            StepPlan::Diverged { recorded } => {
649                self.on_divergence(seq, &recorded, &key, request, idempotency_key, effect)
650                    .await
651            }
652            StepPlan::Live => {
653                self.run_live(
654                    seq,
655                    &key,
656                    StepKind::Effect,
657                    request,
658                    idempotency_key,
659                    effect,
660                )
661                .await
662            }
663        }
664    }
665
666    /// The idempotency key recorded for the flow's NEXT step, when that record
667    /// is a crashed (`running`) step under the same `step_key` — the
668    /// resume-reuse read of contracts/adapter-pack.md "Idempotency-key
669    /// injection" rule 3. Adapters call this *before* executing an injectable
670    /// step: `Some(key)` means a previous run crashed mid-step after minting
671    /// `key`, and re-executing with the SAME key lets the provider deduplicate
672    /// the at-least-once re-execution. `None` — mint fresh — on a fresh step, a
673    /// terminal record (the step will be substituted, no key is sent), a
674    /// diverging key, an abandoned replay, a pure-replay handle, or a journal
675    /// read failure (resilience first).
676    #[must_use]
677    pub fn recorded_idempotency_key(&self, step_key: &str) -> Option<String> {
678        if self.replay_abandoned || self.replay_only {
679            return None;
680        }
681        let key = StepKey::new(step_key);
682        match self.journal.step_at(&self.flow_id, self.seq + 1) {
683            Ok(Some((recorded, outcome)))
684                if recorded == key && outcome.status == StepStatus::Running =>
685            {
686                outcome
687                    .payload
688                    .as_deref()
689                    .and_then(decode_payload)
690                    .and_then(|v| {
691                        v.get(IDEMPOTENCY_KEY_FIELD)
692                            .and_then(Value::as_str)
693                            .map(str::to_owned)
694                    })
695            }
696            _ => None,
697        }
698    }
699
700    /// The `flows.on_nondeterminism` response effective for this handle: the
701    /// configured one, except a `code_hash` mismatch downgrades `fail`→`warn`
702    /// (a deploy that changed the code is expected to diverge; §4.4).
703    fn effective_response(&self) -> NondeterminismResponse {
704        let configured = self.engine.nondeterminism_response();
705        if self.code_hash_fenced && configured == NondeterminismResponse::Fail {
706            NondeterminismResponse::Warn
707        } else {
708            configured
709        }
710    }
711
712    /// Apply the nondeterminism policy to a `(seq, step_key)` divergence (§4.4).
713    async fn on_divergence<F>(
714        &mut self,
715        seq: u64,
716        recorded: &StepKey,
717        observed: &StepKey,
718        request: &Request,
719        idempotency_key: Option<&str>,
720        effect: F,
721    ) -> Outcome
722    where
723        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
724    {
725        let (mode, preserve) = match self.effective_response() {
726            // Halt with a precise diagnostic; the caller fails the flow.
727            NondeterminismResponse::Fail => {
728                return diverged_outcome(&self.flow_id, seq, recorded, observed);
729            }
730            // Continue live from the divergence point, journaling a marker.
731            NondeterminismResponse::Warn => ("warn", false),
732            // Abandon replay for a fresh attempt, preserving the old records.
733            NondeterminismResponse::Branch => ("branch", true),
734        };
735        let div = Divergence {
736            seq,
737            recorded,
738            observed,
739            mode,
740            preserve,
741        };
742        self.branch_and_continue(div, request, idempotency_key, effect)
743            .await
744    }
745
746    /// Journal a branch `marker` and re-execute the divergent step live, then
747    /// stay live for the rest of the flow.
748    async fn branch_and_continue<F>(
749        &mut self,
750        div: Divergence<'_>,
751        request: &Request,
752        idempotency_key: Option<&str>,
753        effect: F,
754    ) -> Outcome
755    where
756        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
757    {
758        self.journal_branch_marker(&div);
759        let live_seq = self.seq;
760        self.run_live(
761            live_seq,
762            div.observed,
763            StepKind::Effect,
764            request,
765            idempotency_key,
766            effect,
767        )
768        .await
769    }
770
771    /// Journal the branch marker, abandon replay, and advance the cursor to the
772    /// live-continuation seq (left in `self.seq`). With `preserve` (`branch`),
773    /// the marker + continuation go to a high seq lane so the abandoned run's
774    /// records survive for audit; otherwise (`warn`) they continue in place.
775    /// Shared by the effect and value (time/random) re-execution paths.
776    fn journal_branch_marker(&mut self, div: &Divergence<'_>) {
777        warn!(
778            flow = %self.flow_id, seq = div.seq, mode = div.mode,
779            expected = %div.recorded, observed = %div.observed,
780            "flow nondeterminism; abandoning replay"
781        );
782        self.replay_abandoned = true;
783        let marker_seq = if div.preserve {
784            BRANCH_SEQ_BASE + div.seq
785        } else {
786            div.seq
787        };
788        let now = self.now();
789        self.record(
790            marker_seq,
791            &StepKey::new(format!("flow:branch:{}", div.mode)),
792            &StepOutcome {
793                kind: StepKind::Marker,
794                attempt: 0,
795                status: StepStatus::Ok,
796                payload: encode_payload(&json!({
797                    "mode": div.mode,
798                    "expected": div.recorded.as_str(),
799                    "observed": div.observed.as_str(),
800                })),
801                error_class: None,
802                started_at: now,
803                ended_at: Some(now),
804            },
805        );
806        // The divergent step re-executes in the slot after its marker;
807        // subsequent steps continue from there (replay is now abandoned).
808        self.seq = marker_seq + 1;
809    }
810
811    /// Journal (or replay) a virtualized clock read under the front-end-supplied
812    /// `key` (the module-docs convention, e.g. `py:time.time#-`). On replay the
813    /// recorded value is substituted so a resumed flow sees the same time; live,
814    /// `now_ms` is recorded and returned (spec §4.4).
815    ///
816    /// # Errors
817    /// `KEEL-E031` if this step diverges from the journal under `fail`.
818    pub fn journal_time(&mut self, key: &str, now_ms: i64) -> Result<i64, KeelError> {
819        let bytes = encode_payload(&json!(now_ms)).unwrap_or_default();
820        let recorded = self.resolve_value_step(key, StepKind::Time, bytes)?;
821        Ok(decode_payload(&recorded)
822            .and_then(|v| v.as_i64())
823            .unwrap_or(now_ms))
824    }
825
826    /// Journal (or replay) a virtualized random draw under the front-end-supplied
827    /// `key` (e.g. `py:random.random#-`). On replay the recorded bytes are
828    /// substituted; live, `bytes` are recorded and returned.
829    ///
830    /// # Errors
831    /// `KEEL-E031` if this step diverges from the journal under `fail`.
832    pub fn journal_random(&mut self, key: &str, bytes: Vec<u8>) -> Result<Vec<u8>, KeelError> {
833        self.resolve_value_step(key, StepKind::Random, bytes)
834    }
835
836    /// The shared replay/live machinery for a pure value step (time/random):
837    /// no side effect, so its recorded payload bytes are the whole outcome.
838    fn resolve_value_step(
839        &mut self,
840        key_str: &str,
841        kind: StepKind,
842        live_bytes: Vec<u8>,
843    ) -> Result<Vec<u8>, KeelError> {
844        self.seq += 1;
845        let seq = self.seq;
846        let key = StepKey::new(key_str);
847        let plan = self.plan_step(seq, &key);
848        // Pure replay: substitute the recorded value; never re-draw/re-read.
849        if self.replay_only {
850            return match plan {
851                StepPlan::Replay(outcome) => Ok(outcome.payload.unwrap_or_default()),
852                _ => Err(replay_miss_error(&self.flow_id, seq, &key)),
853            };
854        }
855        match plan {
856            StepPlan::Replay(outcome) => Ok(outcome.payload.unwrap_or_default()),
857            StepPlan::Live => {
858                self.record_value(seq, &key, kind, &live_bytes);
859                Ok(live_bytes)
860            }
861            StepPlan::Diverged { recorded } => {
862                let (mode, preserve) = match self.effective_response() {
863                    NondeterminismResponse::Fail => {
864                        return Err(diverged_error(&self.flow_id, seq, &recorded, &key));
865                    }
866                    NondeterminismResponse::Warn => ("warn", false),
867                    NondeterminismResponse::Branch => ("branch", true),
868                };
869                let div = Divergence {
870                    seq,
871                    recorded: &recorded,
872                    observed: &key,
873                    mode,
874                    preserve,
875                };
876                self.journal_branch_marker(&div);
877                let live_seq = self.seq;
878                self.record_value(live_seq, &key, kind, &live_bytes);
879                Ok(live_bytes)
880            }
881        }
882    }
883
884    /// Record a pure value step (time/random): instantaneous, terminal `ok`.
885    fn record_value(&self, seq: u64, key: &StepKey, kind: StepKind, bytes: &[u8]) {
886        let now = self.now();
887        self.record(
888            seq,
889            key,
890            &StepOutcome {
891                kind,
892                attempt: 0,
893                status: StepStatus::Ok,
894                payload: Some(bytes.to_vec()),
895                error_class: None,
896                started_at: now,
897                ended_at: Some(now),
898            },
899        );
900    }
901
902    /// Journal a `running` step, run the effect through the engine, and record
903    /// the terminal outcome *before* returning it (at-least-once honesty).
904    /// An adapter-injected `idempotency_key` is journaled IN the `running`
905    /// record (payload `{"idempotency_key": …}`), so a resume that re-executes
906    /// this step after a crash reads the same key back
907    /// ([`recorded_idempotency_key`](Self::recorded_idempotency_key)); the
908    /// terminal record replaces the payload with the outcome as before.
909    async fn run_live<F>(
910        &mut self,
911        seq: u64,
912        key: &StepKey,
913        kind: StepKind,
914        request: &Request,
915        idempotency_key: Option<&str>,
916        effect: F,
917    ) -> Outcome
918    where
919        F: AsyncFnMut(u32) -> keel_core_api::AttemptResult,
920    {
921        // Lease fence: before firing a live effect, confirm we still hold the
922        // lease. If it lapsed (heartbeat starved, clock jump) another process
923        // may have stolen it and be resuming this flow concurrently — running
924        // the effect now would double-fire it (charges, emails, LLM spend) and
925        // interleave step records. Fail the step loudly (KEEL-E030) instead of
926        // silently double-executing. A journal read error is resilience-first
927        // (proceed): only a definitive loss fences.
928        if self.lease_lost() {
929            warn!(flow = %self.flow_id, seq, "lease lost before live step; refusing to double-execute (KEEL-E030)");
930            return lease_lost_outcome(&self.flow_id, seq);
931        }
932        let started_at = self.now();
933        self.record(
934            seq,
935            key,
936            &StepOutcome {
937                kind,
938                attempt: 0,
939                status: StepStatus::Running,
940                payload: idempotency_key
941                    .and_then(|k| encode_payload(&json!({ (IDEMPOTENCY_KEY_FIELD): k }))),
942                error_class: None,
943                started_at,
944                ended_at: None,
945            },
946        );
947
948        let outcome = self.engine.execute(request, effect).await;
949
950        let ended_at = self.now();
951        let (status, payload, error_class) = if outcome.result == "ok" {
952            (
953                StepStatus::Ok,
954                outcome.payload.as_ref().and_then(encode_payload),
955                None,
956            )
957        } else {
958            (
959                StepStatus::Error,
960                None,
961                outcome.error.as_ref().map(|e| e.class),
962            )
963        };
964        self.record(
965            seq,
966            key,
967            &StepOutcome {
968                kind,
969                attempt: outcome.attempts,
970                status,
971                payload,
972                error_class,
973                started_at,
974                ended_at: Some(ended_at),
975            },
976        );
977        outcome
978    }
979
980    /// Record a step, degrading a journal failure to a `warn!`. A lost record
981    /// costs replay dedup, never correctness: the `running` marker (or its
982    /// absence) makes resume re-execute the step, so the effect runs
983    /// at-least-once regardless.
984    fn record(&self, seq: u64, key: &StepKey, outcome: &StepOutcome) {
985        if let Err(e) = self.journal.record_step(&self.flow_id, seq, key, outcome) {
986            warn!(flow = %self.flow_id, seq, error = %e, "record_step failed; step not journaled");
987        }
988    }
989
990    /// Move the flow to a terminal status on scope exit. Idempotent-ish: a
991    /// second call re-stamps the status — except a `completed` flow is immutable
992    /// at the journal (`complete_flow` refuses to demote it), so re-running a
993    /// finished flow can never flip it to `failed`/`dead`.
994    pub fn complete(&mut self, status: FlowStatus) {
995        if let Err(e) = self.journal.complete_flow(&self.flow_id, status) {
996            warn!(flow = %self.flow_id, error = %e, "complete_flow failed");
997        }
998        self.completed = true;
999    }
1000
1001    /// Mark the flow `completed` (the success scope exit).
1002    pub fn complete_success(&mut self) {
1003        self.complete(FlowStatus::Completed);
1004    }
1005
1006    /// Mark the flow `failed` (a non-retryable failure exit).
1007    pub fn complete_failed(&mut self) {
1008        self.complete(FlowStatus::Failed);
1009    }
1010}
1011
1012impl Drop for FlowHandle {
1013    fn drop(&mut self) {
1014        // Stop and join the lease-renewal thread (HeartbeatHandle::drop).
1015        self.heartbeat = None;
1016        if !self.completed {
1017            // A handle dropped without complete() models a crash: the flow stays
1018            // `running` with its lease and is resumed after the lease expires.
1019            debug!(flow = %self.flow_id, "flow handle dropped uncompleted; left running for recovery");
1020        }
1021    }
1022}
1023
1024/// The monotonic timestamp of this handle's last successful lease renewal,
1025/// shared between the heartbeat thread (writer, [`Self::mark_renewed`]) and the
1026/// owning [`FlowHandle`] (reader, [`Self::elapsed`]). Built on [`Instant`], the
1027/// standard library's guaranteed-non-decreasing clock — unlike [`SystemTime`]
1028/// (used for every *stored* journal timestamp), it cannot be stepped backwards
1029/// by an NTP correction or a manual clock change, which is exactly the
1030/// property architecture-spec §6 asks lease heartbeats to have.
1031///
1032/// [`SystemTime`]: std::time::SystemTime
1033#[derive(Debug, Clone)]
1034struct LeaseHeartbeatMonitor(Arc<Mutex<Instant>>);
1035
1036impl LeaseHeartbeatMonitor {
1037    /// Starts "fresh" as of now — used both when a lease is first acquired
1038    /// (before the heartbeat thread has ticked even once) and for the unused
1039    /// monitor a replay-only handle is handed.
1040    fn new() -> Self {
1041        Self(Arc::new(Mutex::new(Instant::now())))
1042    }
1043
1044    /// Record a successful renewal at the current instant.
1045    fn mark_renewed(&self) {
1046        let mut at = self
1047            .0
1048            .lock()
1049            .expect("lease heartbeat monitor lock poisoned");
1050        *at = Instant::now();
1051    }
1052
1053    /// Real time elapsed since the last successful renewal (or since
1054    /// construction, if none has happened yet).
1055    fn elapsed(&self) -> Duration {
1056        self.0
1057            .lock()
1058            .expect("lease heartbeat monitor lock poisoned")
1059            .elapsed()
1060    }
1061}
1062
1063/// A running lease-renewal thread. Dropping this signals the thread to stop (by
1064/// disconnecting the channel) and joins it.
1065struct HeartbeatHandle {
1066    stop: Option<std::sync::mpsc::Sender<()>>,
1067    thread: Option<std::thread::JoinHandle<()>>,
1068}
1069
1070impl Drop for HeartbeatHandle {
1071    fn drop(&mut self) {
1072        // Dropping the sender disconnects the channel, waking the thread's
1073        // `recv_timeout` immediately so it exits without waiting out the period.
1074        drop(self.stop.take());
1075        if let Some(thread) = self.thread.take() {
1076            let _ = thread.join();
1077        }
1078    }
1079}
1080
1081/// Spawn the lease-renewal heartbeat on a dedicated **OS thread** (not a tokio
1082/// task). Under the synchronous front-end bindings the ambient runtime is a
1083/// current-thread runtime that only makes progress while a `block_on` is in
1084/// flight, so a tokio-task heartbeat starves exactly when it is needed — during
1085/// a long blocking step or pure-caller compute between steps — letting the lease
1086/// lapse while the flow is still alive. A std thread renews on real wall-clock
1087/// time regardless of whether any runtime is being polled. Renews every `ttl/2`
1088/// against the journal (which does its own wall-clock CAS — see
1089/// [`FlowHandle::lease_lost`]); every successful renewal also marks `monitor`,
1090/// the *monotonic* freshness signal the owning handle consults. A lost lease
1091/// (`Ok(false)`) is logged loudly (the step-level fence is what actually
1092/// prevents double execution).
1093fn spawn_heartbeat(
1094    journal: Arc<dyn Journal>,
1095    flow: FlowId,
1096    holder: ProcessId,
1097    ttl: Duration,
1098    monitor: LeaseHeartbeatMonitor,
1099) -> Option<HeartbeatHandle> {
1100    use std::sync::mpsc::{RecvTimeoutError, channel};
1101
1102    let period = (ttl / 2).max(Duration::from_millis(1));
1103    let (stop, rx) = channel::<()>();
1104    let thread = std::thread::Builder::new()
1105        .name(String::from("keel-lease-heartbeat"))
1106        .spawn(move || {
1107            // Loop until the sender is dropped/sent (handle closing) —
1108            // `recv_timeout` then yields `Ok`/`Disconnected` and the `while let`
1109            // ends; a `Timeout` is a renewal tick.
1110            while let Err(RecvTimeoutError::Timeout) = rx.recv_timeout(period) {
1111                match journal.acquire_lease(&flow, &holder, ttl) {
1112                    Ok(true) => monitor.mark_renewed(),
1113                    Ok(false) => {
1114                        warn!(flow = %flow, "lease heartbeat: lease lost to another holder");
1115                    }
1116                    Err(e) => {
1117                        warn!(flow = %flow, error = %e, "lease heartbeat renewal failed");
1118                    }
1119                }
1120            }
1121        })
1122        .ok()?;
1123    Some(HeartbeatHandle {
1124        stop: Some(stop),
1125        thread: Some(thread),
1126    })
1127}
1128
1129/// A configuration/internal `KEEL-E040`.
1130fn internal(message: String) -> KeelError {
1131    KeelError {
1132        code: ErrorCode::Internal,
1133        message,
1134    }
1135}
1136
1137/// Self-describing schema tag stamped into every step payload blob, honoring
1138/// journal.sql's "MessagePack, schema-tagged" contract (`steps.payload`) and
1139/// mirroring the persistent cache's `keel.cache/v1` convention so one journal.db
1140/// uses one payload-tagging discipline.
1141const STEP_PAYLOAD_SCHEMA: &str = "keel.step/v1";
1142
1143/// The schema-tagged step-payload envelope, written by reference (no clone).
1144#[derive(serde::Serialize)]
1145struct StepPayloadRef<'a> {
1146    schema: &'a str,
1147    payload: &'a Value,
1148}
1149
1150/// The owned form read back before its tag is verified.
1151#[derive(serde::Deserialize)]
1152struct StepPayloadOwned {
1153    schema: String,
1154    payload: Value,
1155}
1156
1157/// MessagePack-encode a step payload with its schema tag (journal.sql:
1158/// `steps.payload` is "MessagePack, schema-tagged").
1159fn encode_payload(value: &Value) -> Option<Vec<u8>> {
1160    rmp_serde::to_vec_named(&StepPayloadRef {
1161        schema: STEP_PAYLOAD_SCHEMA,
1162        payload: value,
1163    })
1164    .ok()
1165}
1166
1167/// Decode a step payload. Prefers the schema-tagged envelope; falls back to a
1168/// bare value so journals written before the tag existed — including the golden
1169/// fixtures in `conformance/fixtures/journal/` — still replay (the tag is
1170/// introduced without a breaking on-disk migration, which is exactly what
1171/// "versioned, self-describing" buys).
1172fn decode_payload(bytes: &[u8]) -> Option<Value> {
1173    if let Ok(envelope) = rmp_serde::from_slice::<StepPayloadOwned>(bytes)
1174        && envelope.schema == STEP_PAYLOAD_SCHEMA
1175    {
1176        return Some(envelope.payload);
1177    }
1178    rmp_serde::from_slice(bytes).ok()
1179}
1180
1181/// A synthetic trace id for a step outcome the manager mints (replay /
1182/// divergence), distinct from the engine's `t-NNNNNN` live ids.
1183fn step_trace(flow: &FlowId, seq: u64) -> String {
1184    format!("flow-{flow}-s{seq}")
1185}
1186
1187/// Reconstruct an [`Outcome`] from a recorded step — the replay substitution.
1188fn replay_outcome(flow: &FlowId, seq: u64, step: &StepOutcome) -> Outcome {
1189    let mut outcome = base_outcome(step_trace(flow, seq));
1190    outcome.attempts = step.attempt;
1191    match step.status {
1192        StepStatus::Ok | StepStatus::Running => {
1193            outcome.result = String::from("ok");
1194            outcome.payload = step.payload.as_deref().and_then(decode_payload);
1195        }
1196        StepStatus::Error => {
1197            outcome.error = Some(OutcomeError {
1198                code: ErrorCode::NonRetryableError,
1199                class: step.error_class.unwrap_or(ErrorClass::Other),
1200                http_status: None,
1201                message: String::from("replayed failed step"),
1202                original: None,
1203            });
1204        }
1205    }
1206    outcome
1207}
1208
1209/// The precise expected-vs-actual diagnostic for a `(seq, step_key)` divergence
1210/// (spec §4.4), shared by the outcome and error surfaces.
1211fn divergence_message(flow: &FlowId, seq: u64, recorded: &StepKey, observed: &StepKey) -> String {
1212    format!("flow {flow} diverged at step {seq}: expected {recorded}, got {observed} (KEEL-E031)")
1213}
1214
1215/// The KEEL-E031 outcome for a divergence on an effect step (`execute_step`).
1216fn diverged_outcome(flow: &FlowId, seq: u64, recorded: &StepKey, observed: &StepKey) -> Outcome {
1217    let mut outcome = base_outcome(step_trace(flow, seq));
1218    outcome.error = Some(OutcomeError {
1219        code: ErrorCode::FlowNondeterminism,
1220        class: ErrorClass::Other,
1221        http_status: None,
1222        message: divergence_message(flow, seq, recorded, observed),
1223        original: None,
1224    });
1225    outcome
1226}
1227
1228/// The KEEL-E030 outcome for a live step whose lease was lost to another holder
1229/// — the step is refused rather than double-executed.
1230fn lease_lost_outcome(flow: &FlowId, seq: u64) -> Outcome {
1231    let mut outcome = base_outcome(step_trace(flow, seq));
1232    outcome.error = Some(OutcomeError {
1233        code: ErrorCode::FlowLeaseHeld,
1234        class: ErrorClass::Other,
1235        http_status: None,
1236        message: format!(
1237            "flow {flow} lost its lease before step {seq}; another holder may be resuming it \
1238             (KEEL-E030). Refusing to run the effect to avoid double execution."
1239        ),
1240        original: None,
1241    });
1242    outcome
1243}
1244
1245/// The KEEL-E031 error for a divergence on a value step (`journal_time` /
1246/// `journal_random`), which return `Result` rather than an `Outcome`.
1247fn diverged_error(flow: &FlowId, seq: u64, recorded: &StepKey, observed: &StepKey) -> KeelError {
1248    KeelError {
1249        code: ErrorCode::FlowNondeterminism,
1250        message: divergence_message(flow, seq, recorded, observed),
1251    }
1252}
1253
1254/// A completed-flow replay reached step `seq` (`observed`) with no matching
1255/// recorded outcome — the code grew or reordered a step since the flow finished.
1256/// Refused as nondeterminism (KEEL-E031) so no effect fires on a replay handle.
1257fn replay_miss_message(flow: &FlowId, seq: u64, observed: &StepKey) -> String {
1258    format!(
1259        "flow {flow} replay reached unrecorded step {seq} ({observed}); \
1260         the completed flow's code changed (KEEL-E031)"
1261    )
1262}
1263
1264/// The KEEL-E031 outcome for a replay-only miss on an effect step.
1265fn replay_miss_outcome(flow: &FlowId, seq: u64, observed: &StepKey) -> Outcome {
1266    let mut outcome = base_outcome(step_trace(flow, seq));
1267    outcome.error = Some(OutcomeError {
1268        code: ErrorCode::FlowNondeterminism,
1269        class: ErrorClass::Other,
1270        http_status: None,
1271        message: replay_miss_message(flow, seq, observed),
1272        original: None,
1273    });
1274    outcome
1275}
1276
1277/// The KEEL-E031 error for a replay-only miss on a value step.
1278fn replay_miss_error(flow: &FlowId, seq: u64, observed: &StepKey) -> KeelError {
1279    KeelError {
1280        code: ErrorCode::FlowNondeterminism,
1281        message: replay_miss_message(flow, seq, observed),
1282    }
1283}
1284
1285/// A fresh error/replay [`Outcome`] shell with the shared envelope defaults.
1286fn base_outcome(trace_id: String) -> Outcome {
1287    Outcome {
1288        v: ENVELOPE_VERSION,
1289        result: String::from("error"),
1290        payload: None,
1291        error: None,
1292        attempts: 0,
1293        from_cache: false,
1294        waits_ms: Vec::new(),
1295        throttled: false,
1296        throttle_wait_ms: 0,
1297        breaker: keel_core_api::BreakerState::Closed,
1298        trace_id,
1299    }
1300}
1301
1302#[cfg(test)]
1303mod tests {
1304    use super::{LeaseHeartbeatMonitor, decode_payload, encode_payload};
1305    use core::time::Duration;
1306    use serde_json::json;
1307
1308    /// `LeaseHeartbeatMonitor` measures real elapsed time (architecture-spec
1309    /// §6's monotonic heartbeat), not a value anyone can inject — so this is
1310    /// necessarily a real-clock test with small real sleeps, the same
1311    /// precedent `the_heartbeat_renews_the_lease_on_a_real_clock` sets in
1312    /// `crates/keel-core/tests/flows.rs`.
1313    #[test]
1314    fn lease_heartbeat_monitor_tracks_real_elapsed_time_since_the_last_renewal() {
1315        let monitor = LeaseHeartbeatMonitor::new();
1316        assert!(
1317            monitor.elapsed() < Duration::from_millis(50),
1318            "freshly constructed: elapsed should be ~0"
1319        );
1320
1321        std::thread::sleep(Duration::from_millis(60));
1322        assert!(
1323            monitor.elapsed() >= Duration::from_millis(60),
1324            "elapsed grows with real time when nothing renews"
1325        );
1326
1327        monitor.mark_renewed();
1328        assert!(
1329            monitor.elapsed() < Duration::from_millis(50),
1330            "a renewal resets elapsed back to ~0"
1331        );
1332    }
1333
1334    #[test]
1335    fn payload_round_trips_through_the_schema_tag() {
1336        let value = json!({ "rows": 120, "nested": [1, 2, 3], "ok": true });
1337        let bytes = encode_payload(&value).expect("encodes");
1338        assert_eq!(decode_payload(&bytes), Some(value));
1339    }
1340
1341    #[test]
1342    fn legacy_bare_messagepack_still_decodes() {
1343        // Journals written before the tag (and the golden fixtures) stored the
1344        // bare value; the decoder must still read them (no breaking migration).
1345        let map = json!({ "rows": 120 });
1346        let bare_map = rmp_serde::to_vec_named(&map).expect("bare encodes");
1347        assert_eq!(decode_payload(&bare_map), Some(map));
1348
1349        // The fixtures' bare uint32 virtualized-time value (0xCE6A518600).
1350        let num = json!(1_783_727_616u64);
1351        let bare_num = rmp_serde::to_vec_named(&num).expect("bare encodes");
1352        assert_eq!(bare_num, vec![0xCE, 0x6A, 0x51, 0x86, 0x00]);
1353        assert_eq!(decode_payload(&bare_num), Some(num));
1354    }
1355}