Skip to main content

agent_effects/
runtime.rs

1//! The effect runtime.
2//!
3//! [`Runtime::effect`] starts a builder; running it inserts or finds the
4//! effect's record, then either reports its settled outcome or takes the
5//! execution lease and moves it forward (design ยง7, "Re-attaching").
6
7use std::collections::hash_map::RandomState;
8use std::fmt::Display;
9use std::future::Future;
10use std::hash::BuildHasher;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU32, Ordering};
13use std::time::{Duration, SystemTime};
14
15use serde::Serialize;
16use serde::de::DeserializeOwned;
17use serde_json::{Value, json};
18use tracing::{Instrument, Span, debug, field, info_span, warn};
19
20use crate::approval::{ApprovalDecision, ApprovalProvider, ApprovalRequest, ErasedApproval};
21use crate::clock::{Clock, SystemClock};
22use crate::effect::{
23    EffectBuilder, EffectContext, EffectFailure, EffectOutcome, EffectSpec, Precondition,
24};
25use crate::error::RuntimeError;
26use crate::failure::{Disposition, FailureClass};
27#[cfg(feature = "fault-injection")]
28use crate::fault::FaultInjector;
29use crate::fault::FaultPoint;
30use crate::fingerprint::fingerprint;
31use crate::handler::{
32    CompensationSubmission, EffectHandler, Handler, Registered, Registry, Resume, Submission,
33};
34use crate::id::{EffectId, EffectName, WorkerId};
35use crate::kind::EffectKind;
36use crate::observer::{EffectObserver, Observation};
37use crate::policy::{RiskPolicy, UnknownPlan};
38use crate::redaction::{Field, Redactor};
39use crate::retention::RetentionPolicy;
40use crate::retry::RetryPolicy;
41use crate::state::{EffectStatus, Transition};
42use crate::store::{
43    EffectRecord, EffectStore, ErrorRecord, Lease, NewEffect, StoreError, TransitionRequest,
44};
45use crate::verification::{NotFoundReading, Verification, VerificationMode, Verifier};
46
47/// How many times one call re-reads the record after losing a lease race
48/// before reporting the effect as in progress.
49const MAX_ROUNDS: usize = 4;
50
51/// Executes effects against a store. Cheap to clone; clones share state.
52pub struct Runtime<S> {
53    inner: Arc<Inner<S>>,
54}
55
56struct Inner<S> {
57    store: S,
58    clock: Arc<dyn Clock>,
59    worker: WorkerId,
60    lease_ttl: Duration,
61    retry: RetryPolicy,
62    handlers: Registry<S>,
63    approval: Option<Arc<dyn ErasedApproval>>,
64    policy: RiskPolicy,
65    redactor: Option<Arc<dyn Redactor>>,
66    observers: Vec<Arc<dyn EffectObserver>>,
67    retention: RetentionPolicy,
68    #[cfg(feature = "fault-injection")]
69    faults: Option<Arc<FaultInjector>>,
70}
71
72impl<S> Clone for Runtime<S> {
73    fn clone(&self) -> Self {
74        Self {
75            inner: Arc::clone(&self.inner),
76        }
77    }
78}
79
80/// Configures a [`Runtime`].
81#[must_use]
82pub struct RuntimeBuilder<S> {
83    store: S,
84    clock: Arc<dyn Clock>,
85    worker: Option<WorkerId>,
86    lease_ttl: Duration,
87    retry: RetryPolicy,
88    handlers: Registry<S>,
89    approval: Option<Arc<dyn ErasedApproval>>,
90    policy: RiskPolicy,
91    redactor: Option<Arc<dyn Redactor>>,
92    observers: Vec<Arc<dyn EffectObserver>>,
93    retention: RetentionPolicy,
94    #[cfg(feature = "fault-injection")]
95    faults: Option<Arc<FaultInjector>>,
96}
97
98impl<S: EffectStore> RuntimeBuilder<S> {
99    /// How long settled records are kept before [`Runtime::prune`] (and
100    /// every round of [`Runtime::run_recovery`]) deletes them; see
101    /// [`retention`](crate::retention). Defaults to keeping them forever.
102    pub fn retention(mut self, policy: RetentionPolicy) -> Self {
103        self.retention = policy;
104        self
105    }
106
107    /// Tells `observer` about every effect recorded and every transition
108    /// stored, for metrics; see [`observer`](crate::observer). May be called
109    /// more than once.
110    pub fn observer(mut self, observer: impl EffectObserver) -> Self {
111        self.observers.push(Arc::new(observer));
112        self
113    }
114
115    /// Rewrites every input, output, audit payload and error message before
116    /// it is stored; see [`redaction`](crate::redaction).
117    pub fn redactor(mut self, redactor: impl Redactor) -> Self {
118        self.redactor = Some(Arc::new(redactor));
119        self
120    }
121
122    /// Adds requirements by risk level and effect kind to every effect; see
123    /// [`RiskPolicy`]. Requirements only accumulate on top of each effect's
124    /// own settings.
125    pub fn risk_policy(mut self, policy: RiskPolicy) -> Self {
126        self.policy = policy;
127        self
128    }
129
130    /// Asks `provider` to decide on effects that require approval; see
131    /// [`approval`](crate::approval). Without one, such effects wait for an
132    /// operator's [`Runtime::approve`] or [`Runtime::deny`].
133    pub fn approval_provider(mut self, provider: impl ApprovalProvider) -> Self {
134        self.approval = Some(Arc::new(provider));
135        self
136    }
137
138    /// Registers a durable handler under its [`EffectHandler::NAME`], so
139    /// [`Runtime::submit`] can run it and [`Runtime::recover`] can finish
140    /// its effects without a caller.
141    ///
142    /// # Panics
143    ///
144    /// If a handler is already registered under the same name.
145    pub fn register<H: EffectHandler>(mut self, handler: Handler<H>) -> Self {
146        assert!(
147            !self.handlers.contains_key(H::NAME),
148            "a handler is already registered for effect `{}`",
149            H::NAME
150        );
151        self.handlers.insert(H::NAME, Registered::new(handler));
152        self
153    }
154
155    /// The time source for leases and schedules. Defaults to the system
156    /// clock. Tests with paused Tokio time want
157    /// [`TokioClock`](crate::clock::TokioClock).
158    pub fn clock(mut self, clock: impl Clock) -> Self {
159        self.clock = Arc::new(clock);
160        self
161    }
162
163    /// This runtime's identity as a lease holder. Defaults to a random id.
164    /// Must be unique among runtimes sharing a store.
165    pub fn worker_id(mut self, worker: WorkerId) -> Self {
166        self.worker = Some(worker);
167        self
168    }
169
170    /// How long a lease lasts without renewal. The runtime renews it every
171    /// third of this while it works on an effect, including while it waits
172    /// between retries. If a worker dies, others wait this long before
173    /// treating its in-flight effect as unknown. Defaults to 30 seconds;
174    /// values under 3 ms are raised to 3 ms.
175    pub fn lease_ttl(mut self, ttl: Duration) -> Self {
176        self.lease_ttl = ttl.max(Duration::from_millis(3));
177        self
178    }
179
180    /// The retry policy for effects that do not set their own. Defaults to
181    /// [`RetryPolicy::default`]: 5 attempts, 1 s to 30 s exponential backoff
182    /// with jitter.
183    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
184        self.retry = policy;
185        self
186    }
187
188    /// Simulates crashes at the injector's armed points. For tests; see
189    /// [`fault`](crate::fault).
190    #[cfg(feature = "fault-injection")]
191    pub fn fault_injector(mut self, injector: Arc<FaultInjector>) -> Self {
192        self.faults = Some(injector);
193        self
194    }
195
196    /// Builds the runtime.
197    pub fn build(self) -> Runtime<S> {
198        Runtime {
199            inner: Arc::new(Inner {
200                store: self.store,
201                clock: self.clock,
202                worker: self.worker.unwrap_or_else(WorkerId::random),
203                lease_ttl: self.lease_ttl,
204                retry: self.retry,
205                handlers: self.handlers,
206                approval: self.approval,
207                policy: self.policy,
208                redactor: self.redactor,
209                observers: self.observers,
210                retention: self.retention,
211                #[cfg(feature = "fault-injection")]
212                faults: self.faults,
213            }),
214        }
215    }
216}
217
218/// Why moving an effect forward stopped early.
219pub(crate) enum Interrupt {
220    /// Our lease expired or was taken over; re-read the record.
221    LeaseLost,
222    Error(RuntimeError),
223}
224
225impl From<StoreError> for Interrupt {
226    fn from(error: StoreError) -> Self {
227        match error {
228            StoreError::LeaseLost => Self::LeaseLost,
229            other => Self::Error(other.into()),
230        }
231    }
232}
233
234impl<S: EffectStore> Runtime<S> {
235    /// A runtime with default settings.
236    pub fn new(store: S) -> Self {
237        Self::builder(store).build()
238    }
239
240    /// Starts configuring a runtime.
241    pub fn builder(store: S) -> RuntimeBuilder<S> {
242        RuntimeBuilder {
243            store,
244            clock: Arc::new(SystemClock),
245            worker: None,
246            lease_ttl: Duration::from_secs(30),
247            retry: RetryPolicy::default(),
248            handlers: Registry::new(),
249            approval: None,
250            policy: RiskPolicy::default(),
251            redactor: None,
252            observers: Vec::new(),
253            retention: RetentionPolicy::KEEP_ALL,
254            #[cfg(feature = "fault-injection")]
255            faults: None,
256        }
257    }
258
259    /// Describes an effect: `name` is its type (e.g. `payment.charge`),
260    /// `key` identifies this occurrence (e.g. the order id). Every call with
261    /// the same name and key refers to the same effect.
262    pub fn effect(&self, name: impl Into<String>, key: impl Display) -> EffectBuilder<S> {
263        EffectBuilder::new(self.clone(), name.into(), key.to_string())
264    }
265
266    /// Runs a registered handler's effect with `input`, or attaches to an
267    /// earlier run with the same `key`. Await the returned [`Submission`].
268    ///
269    /// Behaves like [`EffectBuilder::run`], with the handler's properties.
270    /// The input is stored in full, so recovery can finish the effect if
271    /// this process dies.
272    pub fn submit<H: EffectHandler>(
273        &self,
274        key: impl Display,
275        input: H::Input,
276    ) -> Submission<'_, S, H> {
277        Submission::new(self, key, input)
278    }
279
280    /// Undoes a registered, [compensable](Handler::compensable) handler's
281    /// effect. Await the returned submission. See
282    /// [`compensation`](crate::compensation).
283    pub fn compensate<H: EffectHandler>(
284        &self,
285        key: impl Display,
286    ) -> CompensationSubmission<'_, S, H> {
287        CompensationSubmission::new(self, key)
288    }
289
290    pub(crate) fn handler<H: EffectHandler>(&self) -> Option<Handler<H>> {
291        self.inner.handlers.get(H::NAME)?.typed::<H>()
292    }
293
294    pub(crate) fn resumer(&self, name: &str) -> Option<Resume<S>> {
295        self.inner.handlers.get(name).map(|r| Arc::clone(&r.resume))
296    }
297
298    pub(crate) fn enforce_contract(
299        &self,
300        record: &EffectRecord,
301        mut requested: crate::store::ExecutionContract,
302        risk: crate::RiskLevel,
303    ) -> Result<(), RuntimeError> {
304        let required = self.inner.policy.requirements(risk, requested.kind);
305        requested.require_approval |= required.approval;
306        requested.automatic_retry &= !required.no_automatic_retry;
307        check_contract(record, &requested)
308    }
309
310    /// The underlying store.
311    pub fn store(&self) -> &S {
312        &self.inner.store
313    }
314
315    /// This runtime's lease-holder identity.
316    pub fn worker_id(&self) -> &WorkerId {
317        &self.inner.worker
318    }
319
320    /// Waits until nobody is working on effect `id`, or `timeout` passes, and
321    /// reports where it stands. For a caller that got
322    /// [`EffectOutcome::InProgress`].
323    ///
324    /// Returns `InProgress` if the effect is still being worked on at the
325    /// deadline, or if it is unsettled and nobody holds it (for example a
326    /// worker crashed while waiting to retry). Running the effect again then
327    /// takes it over.
328    ///
329    /// # Errors
330    ///
331    /// [`RuntimeError::Store`] if the store fails or has no such effect, and
332    /// [`RuntimeError::Output`] if a committed output does not deserialize
333    /// into `T`.
334    pub async fn wait<T: DeserializeOwned>(
335        &self,
336        id: EffectId,
337        timeout: Duration,
338    ) -> Result<EffectOutcome<T>, RuntimeError> {
339        self.wait_with_output_schema(id, timeout, crate::PayloadSchema::default())
340            .await
341    }
342
343    /// Like [`Self::wait`], with a version-aware output schema and optional
344    /// migration. Unsupported or missing outputs leave committed state intact.
345    ///
346    /// # Errors
347    ///
348    /// As for [`Self::wait`], plus payload version or migration errors.
349    pub async fn wait_with_output_schema<T: DeserializeOwned>(
350        &self,
351        id: EffectId,
352        timeout: Duration,
353        schema: crate::PayloadSchema,
354    ) -> Result<EffectOutcome<T>, RuntimeError> {
355        let deadline = tokio::time::Instant::now() + timeout;
356        let mut pause = Duration::from_millis(10);
357        loop {
358            let record = self
359                .store()
360                .get(id)
361                .await?
362                .ok_or(StoreError::NotFound(id))?;
363            let busy = !settled(record.status) && record.live_lease_owner(self.now()).is_some();
364            let now = tokio::time::Instant::now();
365            if !busy {
366                return report(&record, None, &schema);
367            }
368            if now >= deadline {
369                return Ok(EffectOutcome::InProgress { id });
370            }
371            tokio::time::sleep(pause.min(deadline - now)).await;
372            pause = (pause * 2).min(Duration::from_millis(250));
373        }
374    }
375
376    pub(crate) fn default_retry(&self) -> RetryPolicy {
377        self.inner.retry
378    }
379
380    async fn preserve_late_result(&self, observation: crate::store::LateObservation) {
381        let (effect_id, attempt, lease_epoch) = (
382            observation.effect_id,
383            observation.attempt,
384            observation.lease_epoch,
385        );
386        if let Err(error) = self.store().append_late_observation(observation).await {
387            warn!(effect.id = %effect_id, attempt, lease_epoch, error = %error,
388                "could not preserve supplementary late-result evidence");
389        }
390    }
391
392    pub(crate) fn now(&self) -> SystemTime {
393        self.inner.clock.now()
394    }
395
396    pub(crate) fn retention(&self) -> RetentionPolicy {
397        self.inner.retention
398    }
399
400    pub(crate) fn lease_ttl(&self) -> Duration {
401        self.inner.lease_ttl
402    }
403
404    /// Awaits `future`, renewing `lease` every third of its TTL. Stops with
405    /// [`Interrupt::LeaseLost`] if the lease is lost meanwhile.
406    pub(crate) async fn with_lease<Fut: Future>(
407        &self,
408        lease: &Lease,
409        future: Fut,
410    ) -> Result<Fut::Output, Interrupt> {
411        let ttl = self.inner.lease_ttl;
412        tokio::pin!(future);
413        loop {
414            tokio::select! {
415                output = &mut future => return Ok(output),
416                () = tokio::time::sleep(ttl / 3) => {
417                    match self.store().renew_lease(lease, self.now(), ttl).await {
418                        Ok(_) => {}
419                        Err(StoreError::LeaseLost) => return Err(Interrupt::LeaseLost),
420                        Err(e) => warn!(error = %e, "lease renewal failed; will retry"),
421                    }
422                }
423            }
424        }
425    }
426
427    /// Applies `transition` under `lease`, attributing it to `actor`.
428    pub(crate) async fn transition_leased(
429        &self,
430        record: &EffectRecord,
431        lease: &Lease,
432        actor: Option<&str>,
433        transition: Transition,
434        customize: impl FnOnce(&mut TransitionRequest),
435    ) -> Result<EffectRecord, Interrupt> {
436        let mut request = TransitionRequest::new(record, Some(lease), transition, self.now());
437        request.actor = actor.map(str::to_owned);
438        customize(&mut request);
439        let record = self.commit_transition(record, request).await?;
440        debug!(%transition, status = %record.status, "effect transition");
441        Ok(record)
442    }
443
444    /// The one way a transition is written: redact, store, then tell the
445    /// observers. `before` is the record the request was built from.
446    pub(crate) async fn commit_transition(
447        &self,
448        before: &EffectRecord,
449        mut request: TransitionRequest,
450    ) -> Result<EffectRecord, StoreError> {
451        self.redact_request(&mut request, &before.key.name);
452        let transition = request.transition;
453        let after = self.store().transition(request).await?;
454        if !self.inner.observers.is_empty() {
455            let observation = Observation {
456                record: &after,
457                transition,
458                from: before.status,
459                to: after.status,
460                in_previous_status: after
461                    .updated_at
462                    .duration_since(before.updated_at)
463                    .unwrap_or_default(),
464                since_created: after
465                    .updated_at
466                    .duration_since(after.created_at)
467                    .unwrap_or_default(),
468            };
469            self.notify(|observer| observer.on_transition(&observation));
470        }
471        Ok(after)
472    }
473
474    /// Calls every observer, containing panics: the write already happened.
475    fn notify(&self, call: impl Fn(&dyn EffectObserver)) {
476        for observer in &self.inner.observers {
477            let observer: &dyn EffectObserver = observer.as_ref();
478            if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| call(observer))).is_err() {
479                warn!("an effect observer panicked; ignoring it");
480            }
481        }
482    }
483
484    /// Applies the redactor, if any, to one value about to be stored.
485    pub(crate) fn redact(&self, field: Field, effect: &EffectName, value: &mut Value) {
486        if let Some(redactor) = &self.inner.redactor {
487            redactor.redact(field, effect, value);
488        }
489    }
490
491    /// Applies the redactor to everything a transition is about to store.
492    pub(crate) fn redact_request(&self, request: &mut TransitionRequest, effect: &EffectName) {
493        if self.inner.redactor.is_none() {
494            return;
495        }
496        if let Some(output) = request.output.as_mut() {
497            self.redact(Field::Output, effect, output);
498        }
499        if let Some(payload) = request.payload.as_mut() {
500            self.redact(Field::AuditPayload, effect, payload);
501        }
502        if let Some(error) = request.error.as_mut() {
503            let mut message = Value::String(std::mem::take(&mut error.message));
504            self.redact(Field::ErrorMessage, effect, &mut message);
505            error.message = match message {
506                Value::String(text) => text,
507                other => other.to_string(),
508            };
509        }
510    }
511
512    /// A point where a crash can be injected; a no-op without the
513    /// `fault-injection` feature.
514    #[cfg_attr(not(feature = "fault-injection"), allow(clippy::unused_self))]
515    pub(crate) fn checkpoint(&self, point: FaultPoint) {
516        #[cfg(feature = "fault-injection")]
517        if let Some(faults) = &self.inner.faults {
518            faults.reach(point);
519        }
520        #[cfg(not(feature = "fault-injection"))]
521        let _ = point;
522    }
523
524    pub(crate) async fn execute<T, F, Fut, V>(
525        &self,
526        mut spec: EffectSpec,
527        action: F,
528        verifier: V,
529    ) -> Result<EffectOutcome<T>, RuntimeError>
530    where
531        T: Serialize + DeserializeOwned + Send + 'static,
532        F: Fn(EffectContext) -> Fut + Send + Sync + 'static,
533        Fut: Future<Output = Result<T, EffectFailure>> + Send + 'static,
534        V: Verifier<T>,
535    {
536        // The risk policy only adds requirements; see `RiskPolicy`.
537        let required = self
538            .inner
539            .policy
540            .requirements(spec.risk, spec.capabilities.kind);
541        if required.verification && spec.capabilities.verification == VerificationMode::None {
542            return Err(RuntimeError::PolicyViolation {
543                key: spec.key.to_string(),
544                requirement: "verification",
545            });
546        }
547        spec.require_approval |= required.approval;
548        spec.automatic_retry &= !required.no_automatic_retry;
549
550        // Redact before fingerprinting: secrets are not part of an effect's
551        // identity, and never stored, not even hashed. A resumed effect
552        // brings its stored fingerprint, and its input is already redacted.
553        if !spec.input_stored
554            && let Some(input) = spec.input.as_mut()
555        {
556            self.redact(Field::Input, &spec.key.name, input);
557            spec.fingerprint = Some(fingerprint(input));
558        }
559
560        let span = info_span!(
561            "agent_effect.execute",
562            effect.name = %spec.key.name,
563            effect.logical_key = %spec.key.key,
564            effect.kind = spec.capabilities.kind.as_str(),
565            effect.risk_level = %spec.risk,
566            effect.id = field::Empty,
567            effect.status = field::Empty,
568            effect.attempt = field::Empty,
569        );
570        if spec.capabilities.unknown_always_escalates() {
571            span.in_scope(|| {
572                warn!(
573                    "effect is neither idempotent nor verifiable: \
574                     any unknown outcome will need an operator"
575                );
576            });
577        }
578        // Run on a spawned task so that dropping the caller's future cannot
579        // abort an attempt between invoking the action and recording it.
580        let runtime = self.clone();
581        let task = tokio::spawn(
582            async move { runtime.drive(spec, action, verifier).await }.instrument(span),
583        );
584        task.await
585            .unwrap_or_else(|e| Err(RuntimeError::Internal(e.to_string())))
586    }
587
588    async fn drive<T, F, Fut, V>(
589        &self,
590        spec: EffectSpec,
591        action: F,
592        verifier: V,
593    ) -> Result<EffectOutcome<T>, RuntimeError>
594    where
595        T: Serialize + DeserializeOwned + Send + 'static,
596        F: Fn(EffectContext) -> Fut + Send + Sync + 'static,
597        Fut: Future<Output = Result<T, EffectFailure>> + Send + 'static,
598        V: Verifier<T>,
599    {
600        self.checkpoint(FaultPoint::BeforeInsert);
601        let store = self.store();
602        let inserted = store
603            .insert_or_get(NewEffect {
604                id: EffectId::new(),
605                key: spec.key.clone(),
606                kind: spec.capabilities.kind,
607                execution_contract: Some(spec.contract()),
608                input_schema_version: spec.input_schema_version,
609                input: spec.input.clone(),
610                input_fingerprint: spec.fingerprint.clone(),
611                created_by: spec.actor.clone(),
612                now: self.now(),
613            })
614            .await?;
615        let mut record = inserted.record;
616        if inserted.inserted {
617            self.notify(|observer| observer.on_created(&record));
618        }
619        Span::current().record("effect.id", field::display(record.id));
620        self.checkpoint(FaultPoint::AfterInsert);
621        if !inserted.inserted {
622            check_matches(&record, &spec)?;
623        }
624
625        // Verification checks this call has made, across every attempt and
626        // lease it goes through: the budget is per call.
627        let checks_made = AtomicU32::new(0);
628        for _ in 0..MAX_ROUNDS {
629            if let Some(outcome) = observe(&record, &spec.output_schema)? {
630                return Ok(outcome);
631            }
632            let lease = match store
633                .acquire_lease(
634                    record.id,
635                    self.worker_id(),
636                    self.now(),
637                    self.inner.lease_ttl,
638                )
639                .await
640            {
641                Ok(lease) => lease,
642                Err(StoreError::LeaseHeld { .. }) => {
643                    return Ok(EffectOutcome::InProgress { id: record.id });
644                }
645                Err(e) => return Err(e.into()),
646            };
647            // Re-read under the lease: the record may have moved on since.
648            let current = store
649                .get(record.id)
650                .await?
651                .ok_or(StoreError::NotFound(record.id))?;
652            let driver = Driver {
653                rt: self,
654                spec: &spec,
655                action: &action,
656                verifier: &verifier,
657                lease: &lease,
658                checks_made: &checks_made,
659            };
660            let advanced = driver.advance(current).await;
661            if let Err(e) = store.release_lease(&lease).await {
662                warn!(error = %e, "could not release lease; it will expire");
663            }
664            match advanced {
665                Ok((settled, output)) => {
666                    Span::current().record("effect.status", settled.status.as_str());
667                    return report(&settled, output, &spec.output_schema);
668                }
669                Err(Interrupt::LeaseLost) => {
670                    warn!("lease lost mid-effect; re-reading the record");
671                    record = store
672                        .get(record.id)
673                        .await?
674                        .ok_or(StoreError::NotFound(record.id))?;
675                }
676                Err(Interrupt::Error(e)) => return Err(e),
677            }
678        }
679        Ok(EffectOutcome::InProgress { id: record.id })
680    }
681}
682
683/// The outcome to report without acting, or `None` if this call should try
684/// to take the lease and move the effect forward.
685///
686/// Whether a lease is still live is left to `acquire_lease`: a store may
687/// judge it by its own clock (`PostgresStore` does), and this worker's
688/// clock could disagree.
689fn observe<T: DeserializeOwned>(
690    record: &EffectRecord,
691    schema: &crate::PayloadSchema,
692) -> Result<Option<EffectOutcome<T>>, RuntimeError> {
693    match record.status {
694        status if settled(status) => report(record, None, schema).map(Some),
695        EffectStatus::Pending
696        | EffectStatus::AwaitingApproval
697        | EffectStatus::Executing
698        | EffectStatus::Verifying
699        | EffectStatus::Unknown => Ok(None),
700        _ => Ok(Some(EffectOutcome::InProgress { id: record.id })),
701    }
702}
703
704/// One call's work on one leased effect.
705struct Driver<'a, S, F, V> {
706    rt: &'a Runtime<S>,
707    spec: &'a EffectSpec,
708    action: &'a F,
709    verifier: &'a V,
710    lease: &'a Lease,
711    /// Verification checks made so far in this call.
712    checks_made: &'a AtomicU32,
713}
714
715impl<S: EffectStore, F, V> Driver<'_, S, F, V> {
716    /// Moves the effect forward until it settles or needs something this
717    /// call cannot do. Returns the record and, if this call produced one, the
718    /// output to report.
719    async fn advance<T, Fut>(
720        &self,
721        mut record: EffectRecord,
722    ) -> Result<(EffectRecord, Option<T>), Interrupt>
723    where
724        T: Serialize + Send + 'static,
725        F: Fn(EffectContext) -> Fut,
726        Fut: Future<Output = Result<T, EffectFailure>> + Send + 'static,
727        V: Verifier<T>,
728    {
729        let mut output = None;
730        let mut verification_exhausted = false;
731        loop {
732            match record.status {
733                EffectStatus::Pending => {
734                    if let Some(at) = record.next_attempt_at {
735                        self.sleep_until(at).await?;
736                    }
737                    if record.attempt_count == 0
738                        && let Some(reason) = self.check_precondition(&record).await?
739                    {
740                        record = self
741                            .transition(&record, Transition::PreconditionRejected, |r| {
742                                r.error = Some(reason);
743                            })
744                            .await?;
745                        continue;
746                    }
747                    if record.attempt_count == 0 && self.spec.require_approval && !record.approved {
748                        record = self
749                            .transition(&record, Transition::RequestApproval, |_| {})
750                            .await?;
751                        self.rt.checkpoint(FaultPoint::AfterApprovalRequested);
752                        continue;
753                    }
754                    record = self
755                        .transition(&record, Transition::StartAttempt, |r| {
756                            r.payload = Some(json!({ "worker": self.rt.worker_id() }));
757                        })
758                        .await?;
759                    Span::current().record("effect.attempt", record.attempt_count);
760                    self.rt.checkpoint(FaultPoint::AfterAttemptPersisted);
761                    let (next, produced, exhausted) = self.attempt(record).await?;
762                    record = next;
763                    if produced.is_some() {
764                        output = produced;
765                    }
766                    verification_exhausted = exhausted;
767                }
768                // The previous holder's lease expired mid-attempt or
769                // mid-verification. Whatever it was doing may have happened.
770                EffectStatus::Executing | EffectStatus::Verifying => {
771                    record = self
772                        .transition(&record, Transition::LeaseExpired, |_| {})
773                        .await?;
774                }
775                // Ask the provider; an approval loops back to `Pending`, where
776                // the precondition is checked again before the attempt.
777                EffectStatus::AwaitingApproval => match self.ask_approval(&record).await? {
778                    ApprovalDecision::Approved { by } => {
779                        record = self
780                            .transition(&record, Transition::Approve, |r| r.actor = Some(by))
781                            .await?;
782                    }
783                    ApprovalDecision::Denied { by, reason } => {
784                        record = self
785                            .transition(&record, Transition::Deny, |r| {
786                                r.actor = Some(by);
787                                r.error = Some(ErrorRecord {
788                                    class: None,
789                                    message: reason,
790                                });
791                            })
792                            .await?;
793                    }
794                    ApprovalDecision::Deferred => break,
795                },
796                EffectStatus::Unknown => match self.spec.capabilities.unknown_plan() {
797                    UnknownPlan::Verify if !verification_exhausted => {
798                        let (next, verified, exhausted) = self.verify(record, None).await?;
799                        record = next;
800                        output = verified.or(output);
801                        verification_exhausted = exhausted;
802                    }
803                    // Still unknown after every check this call may make;
804                    // a later call or recovery tries again.
805                    UnknownPlan::Verify => break,
806                    UnknownPlan::Reexecute if self.may_retry(record.attempt_count) => {
807                        record = self
808                            .schedule_retry(&record, FailureClass::Ambiguous, None)
809                            .await?;
810                    }
811                    UnknownPlan::Reexecute | UnknownPlan::Escalate => {
812                        record = self
813                            .transition(&record, Transition::Escalate, |_| {})
814                            .await?;
815                    }
816                },
817                _ => break,
818            }
819        }
820        Ok((record, output))
821    }
822
823    /// Runs the action once and records what happened. The flag reports
824    /// that the postcondition checks ran out while inconclusive.
825    async fn attempt<T, Fut>(
826        &self,
827        record: EffectRecord,
828    ) -> Result<(EffectRecord, Option<T>, bool), Interrupt>
829    where
830        T: Serialize + Send + 'static,
831        F: Fn(EffectContext) -> Fut,
832        Fut: Future<Output = Result<T, EffectFailure>> + Send + 'static,
833        V: Verifier<T>,
834    {
835        // An in-flight action may outlive its lease. Keep its eventual
836        // outcome as supplementary evidence, never as an authoritative commit.
837        let mut task = tokio::spawn((self.action)(context(&record)));
838        self.rt.checkpoint(FaultPoint::AfterActionStarted);
839        let waited = match self.spec.attempt_timeout {
840            None => self.leased(&mut task).await,
841            Some(limit) => self
842                .leased(tokio::time::timeout(limit, &mut task))
843                .await
844                .map(|timed| {
845                    timed.unwrap_or_else(|_| {
846                        task.abort();
847                        Ok(Err(EffectFailure::ambiguous(format!(
848                            "attempt timed out after {limit:?}"
849                        ))))
850                    })
851                }),
852        };
853        let joined = match waited {
854            Ok(joined) => joined,
855            Err(interrupt) => {
856                let runtime = self.rt.clone();
857                let mut observation = crate::store::LateObservation::new(
858                    &record,
859                    self.lease,
860                    crate::store::LateOutcome::Interrupted,
861                    self.rt.now(),
862                );
863                tokio::spawn(async move {
864                    let joined = task.await;
865                    observation.outcome = late_outcome(&joined);
866                    observation.observed_at = runtime.now();
867                    runtime.preserve_late_result(observation).await;
868                });
869                return Err(interrupt);
870            }
871        };
872        self.rt.checkpoint(FaultPoint::AfterActionReturned);
873
874        let observation = crate::store::LateObservation::new(
875            &record,
876            self.lease,
877            late_outcome(&joined),
878            self.rt.now(),
879        );
880        let advanced = self.record_result(record, joined).await;
881        if advanced.is_err() {
882            self.rt.preserve_late_result(observation).await;
883        }
884        advanced
885    }
886
887    async fn record_result<T>(
888        &self,
889        record: EffectRecord,
890        joined: Result<Result<T, EffectFailure>, tokio::task::JoinError>,
891    ) -> Result<(EffectRecord, Option<T>, bool), Interrupt>
892    where
893        T: Serialize + Send + 'static,
894        V: Verifier<T>,
895    {
896        match joined {
897            Ok(Ok(value)) if self.spec.capabilities.verification != VerificationMode::None => {
898                let (record, verified, exhausted) =
899                    self.verify(record, Some(output_json(&value))).await?;
900                let output = match record.status {
901                    EffectStatus::Committed => verified.or(Some(value)),
902                    _ => None,
903                };
904                Ok((record, output, exhausted))
905            }
906            Ok(Ok(value)) => {
907                let (output, payload) = output_json(&value);
908                let record = self
909                    .transition(&record, Transition::Succeeded, |r| {
910                        r.output = output;
911                        r.payload = payload;
912                    })
913                    .await?;
914                Ok((record, Some(value), false))
915            }
916            Ok(Err(failure)) => {
917                debug!(%failure, "action failed");
918                let class = failure.class();
919                let scheduling_class =
920                    failure
921                        .retry_after()
922                        .map_or(class, |delay| FailureClass::RateLimited {
923                            retry_after: Some(delay),
924                        });
925                let error = failure.to_record();
926                let record = match class.disposition() {
927                    Disposition::Retry if self.may_retry(record.attempt_count) => {
928                        self.schedule_retry(&record, scheduling_class, Some(error))
929                            .await?
930                    }
931                    // This attempt definitely failed, but an earlier one may
932                    // have applied the effect: that is not "Failed". Look
933                    // again if the effect can be verified, else escalate.
934                    Disposition::Retry | Disposition::Fail
935                        if record.may_have_applied && record.kind != EffectKind::Read =>
936                    {
937                        let unknown = self
938                            .transition(&record, Transition::OutcomeUnknown, |r| {
939                                r.error = Some(error);
940                                r.payload =
941                                    Some(json!({ "earlier_attempt_may_have_applied": true }));
942                            })
943                            .await?;
944                        if self.spec.capabilities.unknown_plan() == UnknownPlan::Verify {
945                            unknown
946                        } else {
947                            self.transition(&unknown, Transition::Escalate, |_| {})
948                                .await?
949                        }
950                    }
951                    Disposition::Retry | Disposition::Fail => {
952                        self.transition(&record, Transition::FailedDefinitively, |r| {
953                            r.error = Some(error);
954                        })
955                        .await?
956                    }
957                    Disposition::Unknown => {
958                        let not_before = failure
959                            .retry_after()
960                            .and_then(|delay| self.rt.now().checked_add(delay));
961                        self.transition(&record, Transition::OutcomeUnknown, |r| {
962                            r.error = Some(error);
963                            r.next_attempt_at = not_before;
964                        })
965                        .await?
966                    }
967                };
968                Ok((record, None, false))
969            }
970            Err(join_error) => {
971                // The action may have sent its request before panicking.
972                let record = self
973                    .transition(&record, Transition::OutcomeUnknown, |r| {
974                        r.error = Some(ErrorRecord {
975                            class: Some(FailureClass::Ambiguous),
976                            message: format!("action did not complete: {join_error}"),
977                        });
978                    })
979                    .await?;
980                Ok((record, None, false))
981            }
982        }
983    }
984
985    /// Asks the remote system what happened, starting from `Executing`
986    /// (after a success, whose output JSON and audit note are `succeeded`)
987    /// or `Unknown`.
988    ///
989    /// Ends in `Committed`, `Failed`, `Pending` (re-run scheduled: the effect
990    /// verifiably did not apply), `NeedsIntervention` (conflict) or `Unknown`.
991    /// The flag reports that the checks ran out while inconclusive.
992    async fn verify<T>(
993        &self,
994        record: EffectRecord,
995        succeeded: Option<(Option<Value>, Option<Value>)>,
996    ) -> Result<(EffectRecord, Option<T>, bool), Interrupt>
997    where
998        T: Serialize + Send + 'static,
999        V: Verifier<T>,
1000    {
1001        let mut record = self
1002            .transition(&record, Transition::StartVerification, |r| {
1003                if let Some((output, payload)) = succeeded {
1004                    (r.output, r.payload) = (output, payload);
1005                }
1006            })
1007            .await?;
1008        self.rt.checkpoint(FaultPoint::AfterVerificationStarted);
1009        let mode = self.spec.capabilities.verification;
1010        let mut checks = 0;
1011        let mut last_problem = String::from("no check completed");
1012        // Settle delays count from the end of the attempt: a slow request
1013        // may write just before it returns. Both times come from the store's
1014        // clock (`updated_at` was just stamped by `StartVerification`), and
1015        // the time since is measured locally, so this worker's clock never
1016        // meets the store's.
1017        let verifying_since = record.updated_at;
1018        let ended_before = record.attempt_ended_at.map_or(Duration::ZERO, |ended| {
1019            verifying_since.duration_since(ended).unwrap_or_default()
1020        });
1021        let started = tokio::time::Instant::now();
1022
1023        while self.take_check() {
1024            let check = checks;
1025            checks += 1;
1026            let Some(future) = self.verifier.check(context(&record)) else {
1027                break;
1028            };
1029            let found = match self.leased(tokio::spawn(future)).await? {
1030                Ok(Ok(found)) => found,
1031                Ok(Err(failure)) => {
1032                    last_problem = format!("check failed: {failure}");
1033                    Verification::Inconclusive
1034                }
1035                Err(join_error) => {
1036                    last_problem = format!("check did not complete: {join_error}");
1037                    Verification::Inconclusive
1038                }
1039            };
1040            match found {
1041                Verification::Confirmed(value) => {
1042                    let (output, payload) = output_json(&value);
1043                    record = self
1044                        .transition(&record, Transition::VerificationConfirmed, |r| {
1045                            r.output = output;
1046                            r.payload = payload;
1047                        })
1048                        .await?;
1049                    return Ok((record, Some(value), false));
1050                }
1051                Verification::Conflict { details } => {
1052                    record = self
1053                        .transition(&record, Transition::VerificationConflict, |r| {
1054                            r.error = Some(ErrorRecord {
1055                                class: None,
1056                                message: details,
1057                            });
1058                        })
1059                        .await?;
1060                    return Ok((record, None, false));
1061                }
1062                Verification::NotApplied => {
1063                    match mode.read_not_found(ended_before + started.elapsed()) {
1064                        NotFoundReading::NotApplied => {
1065                            return Ok((self.not_applied(&record).await?, None, false));
1066                        }
1067                        NotFoundReading::TooEarly { wait } => {
1068                            last_problem = "not visible yet within the settle delay".into();
1069                            if self.has_checks_left() {
1070                                self.sleep(wait).await?;
1071                            }
1072                        }
1073                    }
1074                }
1075                Verification::Inconclusive => {
1076                    if last_problem == "no check completed" {
1077                        last_problem = "remote system could not tell".into();
1078                    }
1079                    if self.has_checks_left() {
1080                        let delay =
1081                            self.retry()
1082                                .delay(check, FailureClass::Transient, jitter_sample());
1083                        self.sleep(delay).await?;
1084                    }
1085                }
1086            }
1087        }
1088
1089        if checks == 0 {
1090            last_problem = "this call's checks were already spent".into();
1091        }
1092        record = self
1093            .transition(&record, Transition::OutcomeUnknown, |r| {
1094                r.error = Some(ErrorRecord {
1095                    class: Some(FailureClass::Ambiguous),
1096                    message: format!(
1097                        "verification inconclusive after {checks} checks: {last_problem}"
1098                    ),
1099                });
1100            })
1101            .await?;
1102        Ok((record, None, true))
1103    }
1104
1105    /// Takes one of this call's verification checks (`max_attempts` per
1106    /// call, across every attempt it makes), if any are left.
1107    fn take_check(&self) -> bool {
1108        self.checks_made.fetch_add(1, Ordering::Relaxed) < self.retry().max_attempts.max(1)
1109    }
1110
1111    fn has_checks_left(&self) -> bool {
1112        self.checks_made.load(Ordering::Relaxed) < self.retry().max_attempts.max(1)
1113    }
1114
1115    /// Records a trusted "not applied": re-run if the budget allows, else
1116    /// `Failed`.
1117    async fn not_applied(&self, record: &EffectRecord) -> Result<EffectRecord, Interrupt> {
1118        let error = ErrorRecord {
1119            class: None,
1120            message: "verification found that the effect did not apply".into(),
1121        };
1122        if self.may_retry(record.attempt_count) {
1123            self.schedule_retry(record, FailureClass::Transient, Some(error))
1124                .await
1125        } else {
1126            self.transition(record, Transition::VerificationNotApplied, |r| {
1127                r.error = Some(error);
1128            })
1129            .await
1130        }
1131    }
1132
1133    /// Evaluates the precondition. Returns the reason to reject, if any.
1134    async fn check_precondition(
1135        &self,
1136        record: &EffectRecord,
1137    ) -> Result<Option<ErrorRecord>, Interrupt> {
1138        let Some(precondition) = &self.spec.precondition else {
1139            return Ok(None);
1140        };
1141        let max_checks = self.retry().max_attempts.max(1);
1142        let mut last_reason = String::new();
1143        for check in 1..=max_checks {
1144            let rejection = |message: String| {
1145                Some(ErrorRecord {
1146                    class: None,
1147                    message,
1148                })
1149            };
1150            match self
1151                .leased(tokio::spawn(precondition(context(record))))
1152                .await?
1153            {
1154                Ok(Precondition::Satisfied) => return Ok(None),
1155                Ok(Precondition::Rejected { reason }) => return Ok(rejection(reason)),
1156                Ok(Precondition::RetryLater { after, reason }) => {
1157                    last_reason = reason;
1158                    if check < max_checks {
1159                        self.sleep(after).await?;
1160                    }
1161                }
1162                // A broken check must not let the effect through.
1163                Err(join_error) => {
1164                    return Ok(rejection(format!(
1165                        "precondition check did not complete: {join_error}"
1166                    )));
1167                }
1168            }
1169        }
1170        Ok(Some(ErrorRecord {
1171            class: None,
1172            message: format!("precondition not satisfied after {max_checks} checks: {last_reason}"),
1173        }))
1174    }
1175
1176    /// Records that the next attempt waits for a backoff delay. The wait
1177    /// itself happens when the loop next sees the record as `Pending`, so a
1178    /// crash during it leaves a record that is safe to resume.
1179    async fn schedule_retry(
1180        &self,
1181        record: &EffectRecord,
1182        class: FailureClass,
1183        error: Option<ErrorRecord>,
1184    ) -> Result<EffectRecord, Interrupt> {
1185        let retry = record.attempt_count.saturating_sub(1);
1186        let delay = self.retry().delay(retry, class, jitter_sample());
1187        let at =
1188            (self.rt.now() + delay).max(record.next_attempt_at.unwrap_or(SystemTime::UNIX_EPOCH));
1189        debug!(?delay, ?class, "retry scheduled");
1190        self.transition(record, Transition::ScheduleRetry, |r| {
1191            r.next_attempt_at = Some(at);
1192            r.error = error;
1193            r.payload =
1194                Some(json!({ "delay_ms": u64::try_from(delay.as_millis()).unwrap_or(u64::MAX) }));
1195        })
1196        .await
1197    }
1198
1199    /// Asks the runtime's approval provider, on its own task and under the
1200    /// lease. No provider, or a provider that panics, defers.
1201    async fn ask_approval(&self, record: &EffectRecord) -> Result<ApprovalDecision, Interrupt> {
1202        let Some(provider) = self.rt.inner.approval.clone() else {
1203            return Ok(ApprovalDecision::Deferred);
1204        };
1205        let request = ApprovalRequest {
1206            effect_id: record.id,
1207            key: record.key.clone(),
1208            kind: record.kind,
1209            risk: self.spec.risk,
1210            input: record.input.clone(),
1211            requested_by: record.created_by.clone(),
1212        };
1213        let task = tokio::spawn(async move { provider.request_boxed(request).await });
1214        Ok(self.leased(task).await?.unwrap_or_else(|e| {
1215            warn!(error = %e, "approval provider failed; deferring");
1216            ApprovalDecision::Deferred
1217        }))
1218    }
1219
1220    async fn sleep_until(&self, at: SystemTime) -> Result<(), Interrupt> {
1221        let wait = at.duration_since(self.rt.now()).unwrap_or_default();
1222        self.sleep(wait).await
1223    }
1224
1225    async fn sleep(&self, duration: Duration) -> Result<(), Interrupt> {
1226        if duration.is_zero() {
1227            return Ok(());
1228        }
1229        self.leased(tokio::time::sleep(duration)).await
1230    }
1231
1232    async fn leased<Fut: Future>(&self, future: Fut) -> Result<Fut::Output, Interrupt> {
1233        self.rt.with_lease(self.lease, future).await
1234    }
1235
1236    async fn transition(
1237        &self,
1238        record: &EffectRecord,
1239        transition: Transition,
1240        customize: impl FnOnce(&mut TransitionRequest),
1241    ) -> Result<EffectRecord, Interrupt> {
1242        self.rt
1243            .transition_leased(
1244                record,
1245                self.lease,
1246                self.spec.actor.as_deref(),
1247                transition,
1248                |request| {
1249                    customize(request);
1250                    if request.output.is_some() {
1251                        request.output_schema_version = Some(self.spec.output_schema.version());
1252                    }
1253                },
1254            )
1255            .await
1256    }
1257
1258    fn retry(&self) -> &RetryPolicy {
1259        &self.spec.retry
1260    }
1261
1262    /// Whether the runtime may run the effect again on its own after
1263    /// `attempts`: within budget, and not forbidden by the risk policy.
1264    fn may_retry(&self, attempts: u32) -> bool {
1265        self.spec.automatic_retry && self.retry().allows_another(attempts)
1266    }
1267}
1268
1269fn late_outcome<T>(
1270    joined: &Result<Result<T, EffectFailure>, tokio::task::JoinError>,
1271) -> crate::store::LateOutcome {
1272    use crate::store::LateOutcome;
1273    match joined {
1274        Ok(Ok(_)) => LateOutcome::Succeeded,
1275        Ok(Err(_)) => LateOutcome::Failed,
1276        Err(_) => LateOutcome::Interrupted,
1277    }
1278}
1279
1280fn context(record: &EffectRecord) -> EffectContext {
1281    EffectContext {
1282        id: record.id,
1283        key: record.key.clone(),
1284        attempt: record.attempt_count,
1285    }
1286}
1287
1288/// An output as JSON, or a note for the audit trail if it cannot be stored.
1289/// The effect applied either way: failing to store its output must not make
1290/// it look failed, and the caller still gets the value.
1291fn output_json<T: Serialize>(value: &T) -> (Option<Value>, Option<Value>) {
1292    match serde_json::to_value(value) {
1293        Ok(output) => (Some(output), None),
1294        Err(e) => (None, Some(json!({ "output_not_stored": e.to_string() }))),
1295    }
1296}
1297
1298/// A uniform sample in `[0, 1)` for jitter, from the standard library's
1299/// per-instance random hash keys.
1300pub(crate) fn jitter_sample() -> f64 {
1301    let bits = RandomState::new().hash_one(0_u8) >> 11;
1302    #[allow(clippy::cast_precision_loss)]
1303    let sample = bits as f64 / (1_u64 << 53) as f64;
1304    sample
1305}
1306
1307/// Statuses a new call reports as they are, without acting.
1308fn settled(status: EffectStatus) -> bool {
1309    matches!(
1310        status,
1311        EffectStatus::Committed
1312            | EffectStatus::Failed
1313            | EffectStatus::Rejected
1314            | EffectStatus::NeedsIntervention
1315            | EffectStatus::Compensated
1316            | EffectStatus::CompensationFailed
1317    )
1318}
1319
1320fn check_matches(record: &EffectRecord, spec: &EffectSpec) -> Result<(), RuntimeError> {
1321    if record.input_schema_version != spec.input_schema_version {
1322        return Err(RuntimeError::InputSchemaMismatch {
1323            id: record.id,
1324            stored: record.input_schema_version,
1325            requested: spec.input_schema_version,
1326        });
1327    }
1328    if record.kind != spec.capabilities.kind {
1329        return Err(RuntimeError::KindMismatch {
1330            id: record.id,
1331            stored: record.kind,
1332            requested: spec.capabilities.kind,
1333        });
1334    }
1335    if record.input_fingerprint != spec.fingerprint {
1336        return Err(RuntimeError::InputMismatch { id: record.id });
1337    }
1338    check_contract(record, &spec.contract())
1339}
1340
1341fn check_contract(
1342    record: &EffectRecord,
1343    requested: &crate::store::ExecutionContract,
1344) -> Result<(), RuntimeError> {
1345    match &record.execution_contract {
1346        Some(stored) if !stored.permits(requested) => {
1347            return Err(RuntimeError::ContractMismatch { id: record.id });
1348        }
1349        None if !record.status.is_settled() => {
1350            return Err(RuntimeError::LegacyContract { id: record.id });
1351        }
1352        _ => {}
1353    }
1354    Ok(())
1355}
1356
1357/// The outcome a record represents. `fresh` is this call's output,
1358/// preferred over the stored copy.
1359fn report<T: DeserializeOwned>(
1360    record: &EffectRecord,
1361    fresh: Option<T>,
1362    schema: &crate::PayloadSchema,
1363) -> Result<EffectOutcome<T>, RuntimeError> {
1364    let id = record.id;
1365    Ok(match record.status {
1366        EffectStatus::Committed => {
1367            if let Some(value) = fresh {
1368                EffectOutcome::Committed(value)
1369            } else {
1370                let stored = record
1371                    .output
1372                    .clone()
1373                    .ok_or(RuntimeError::OutputUnavailable { id })?;
1374                let migrated = schema
1375                    .decode(record.output_schema_version, stored)
1376                    .map_err(|source| RuntimeError::OutputPayload { id, source })?;
1377                EffectOutcome::Committed(
1378                    serde_json::from_value(migrated)
1379                        .map_err(|source| RuntimeError::Output { id, source })?,
1380                )
1381            }
1382        }
1383        EffectStatus::Failed => EffectOutcome::Failed(last_error(record)),
1384        EffectStatus::Rejected => EffectOutcome::Rejected(last_error(record)),
1385        // A failed compensation also waits for an operator.
1386        EffectStatus::NeedsIntervention | EffectStatus::CompensationFailed => {
1387            EffectOutcome::NeedsIntervention { id }
1388        }
1389        EffectStatus::Unknown => EffectOutcome::Unknown { id },
1390        EffectStatus::Compensated => EffectOutcome::Compensated { id },
1391        EffectStatus::AwaitingApproval => EffectOutcome::AwaitingApproval { id },
1392        _ => EffectOutcome::InProgress { id },
1393    })
1394}
1395
1396pub(crate) fn last_error(record: &EffectRecord) -> ErrorRecord {
1397    record.last_error.clone().unwrap_or_else(|| ErrorRecord {
1398        class: None,
1399        message: "no error was recorded".into(),
1400    })
1401}