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}