Skip to main content

agent_effects_store/
state.rs

1//! The effect state machine.
2//!
3//! [`EffectStatus::apply`] is the single source of truth for which
4//! transitions are legal. Stores and the runtime never change a status
5//! without going through it.
6
7use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11/// Where an effect is in its lifecycle.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum EffectStatus {
16    /// Recorded and waiting to run, either for the first time or for a
17    /// scheduled retry.
18    Pending,
19    /// Waiting for a human or policy decision before it may run.
20    AwaitingApproval,
21    /// An attempt is in flight under a lease.
22    Executing,
23    /// An attempt may or may not have applied.
24    Unknown,
25    /// Checking the remote system for the effect's outcome.
26    Verifying,
27    /// The effect applied. Final, unless it is later compensated.
28    Committed,
29    /// The effect definitely did not apply and will not be retried. Terminal.
30    Failed,
31    /// A precondition or approval decision refused the effect before it ran.
32    /// Terminal.
33    Rejected,
34    /// The runtime cannot resolve the outcome on its own; an operator must.
35    NeedsIntervention,
36    /// A committed effect is being undone. Attempts are retried; the
37    /// compensation must be idempotent.
38    Compensating,
39    /// The effect was undone. Terminal.
40    Compensated,
41    /// Undoing the effect failed for good; an operator must finish it or
42    /// order a retry.
43    CompensationFailed,
44}
45
46/// Every status, for exhaustive tests and store migrations.
47pub const ALL_STATUSES: [EffectStatus; 12] = [
48    EffectStatus::Pending,
49    EffectStatus::AwaitingApproval,
50    EffectStatus::Executing,
51    EffectStatus::Unknown,
52    EffectStatus::Verifying,
53    EffectStatus::Committed,
54    EffectStatus::Failed,
55    EffectStatus::Rejected,
56    EffectStatus::NeedsIntervention,
57    EffectStatus::Compensating,
58    EffectStatus::Compensated,
59    EffectStatus::CompensationFailed,
60];
61
62impl EffectStatus {
63    /// Whether no further transition is possible.
64    ///
65    /// `Committed` is not terminal: compensation can start from it.
66    pub const fn is_terminal(self) -> bool {
67        matches!(self, Self::Failed | Self::Rejected | Self::Compensated)
68    }
69
70    /// Whether nothing is left to do without a new request: the effect
71    /// committed, failed, was rejected or was undone. Only settled records
72    /// may be pruned ([`PruneQuery`](crate::PruneQuery)).
73    ///
74    /// Unlike [`Self::is_terminal`] this includes `Committed`, whose
75    /// compensation would be a new request. `CompensationFailed` is not
76    /// settled: it waits for an operator.
77    pub const fn is_settled(self) -> bool {
78        matches!(
79            self,
80            Self::Committed | Self::Failed | Self::Rejected | Self::Compensated
81        )
82    }
83
84    /// Whether the effect may have changed the outside world without the
85    /// runtime knowing the result, so it must not simply be started again.
86    pub const fn is_in_doubt(self) -> bool {
87        matches!(
88            self,
89            Self::Executing | Self::Unknown | Self::Verifying | Self::NeedsIntervention
90        )
91    }
92
93    /// The stable storage representation.
94    pub const fn as_str(self) -> &'static str {
95        match self {
96            Self::Pending => "pending",
97            Self::AwaitingApproval => "awaiting_approval",
98            Self::Executing => "executing",
99            Self::Unknown => "unknown",
100            Self::Verifying => "verifying",
101            Self::Committed => "committed",
102            Self::Failed => "failed",
103            Self::Rejected => "rejected",
104            Self::NeedsIntervention => "needs_intervention",
105            Self::Compensating => "compensating",
106            Self::Compensated => "compensated",
107            Self::CompensationFailed => "compensation_failed",
108        }
109    }
110
111    /// Parses the storage representation produced by [`Self::as_str`].
112    pub fn parse(s: &str) -> Option<Self> {
113        ALL_STATUSES.into_iter().find(|status| status.as_str() == s)
114    }
115
116    /// The status after `event`, or an error if the event is not legal here.
117    ///
118    /// # Errors
119    ///
120    /// Returns [`InvalidTransition`] for any pair not in the transition table.
121    pub const fn apply(self, event: Transition) -> Result<Self, InvalidTransition> {
122        use EffectStatus as S;
123        use Transition as T;
124
125        let next = match (self, event) {
126            (S::Pending, T::RequestApproval) => S::AwaitingApproval,
127            (S::AwaitingApproval, T::Approve) => S::Pending,
128            (S::AwaitingApproval, T::Deny) | (S::Pending, T::PreconditionRejected) => S::Rejected,
129
130            (S::Pending, T::StartAttempt) => S::Executing,
131
132            (S::Executing, T::Succeeded)
133            | (S::Verifying, T::VerificationConfirmed)
134            | (S::Unknown | S::NeedsIntervention, T::ResolvedApplied) => S::Committed,
135
136            (S::Executing, T::FailedDefinitively)
137            | (S::Verifying, T::VerificationNotApplied)
138            | (S::Unknown | S::NeedsIntervention, T::ResolvedNotApplied) => S::Failed,
139
140            (S::Executing | S::Verifying | S::Unknown, T::ScheduleRetry)
141            | (S::Unknown | S::NeedsIntervention, T::ResolvedRetry) => S::Pending,
142
143            (S::Executing | S::Verifying, T::OutcomeUnknown | T::LeaseExpired) => S::Unknown,
144
145            (S::Executing | S::Unknown, T::StartVerification) => S::Verifying,
146
147            (S::Unknown, T::Escalate) | (S::Verifying, T::VerificationConflict) => {
148                S::NeedsIntervention
149            }
150
151            (S::Committed, T::StartCompensation)
152            | (S::Compensating, T::ScheduleCompensationRetry | T::StartCompensationRetry)
153            | (S::CompensationFailed, T::ResolvedRetry) => S::Compensating,
154            (S::Compensating, T::CompensationSucceeded)
155            | (S::CompensationFailed, T::ResolvedCompensated) => S::Compensated,
156            (S::Compensating, T::CompensationFailed) => S::CompensationFailed,
157
158            _ => {
159                return Err(InvalidTransition { from: self, event });
160            }
161        };
162        Ok(next)
163    }
164}
165
166impl fmt::Display for EffectStatus {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        f.write_str(self.as_str())
169    }
170}
171
172/// Something that happens to an effect and may change its status.
173///
174/// Each variant is also an audit event; see [`Transition::as_str`].
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177#[non_exhaustive]
178pub enum Transition {
179    /// Policy requires approval before the first attempt.
180    RequestApproval,
181    /// Approval was granted.
182    Approve,
183    /// Approval was refused.
184    Deny,
185    /// A precondition permanently refused the effect.
186    PreconditionRejected,
187    /// An attempt begins. Persisted before the action is invoked.
188    StartAttempt,
189    /// The action succeeded and no verification is configured.
190    Succeeded,
191    /// The action failed and the effect definitely did not apply, with no
192    /// retry left or allowed.
193    FailedDefinitively,
194    /// Another attempt is scheduled, because the effect definitely did not
195    /// apply or re-executing it is known to be safe.
196    ScheduleRetry,
197    /// The attempt ended without a definitive answer.
198    OutcomeUnknown,
199    /// The lease holder disappeared mid-attempt (recovery).
200    LeaseExpired,
201    /// Verification begins, after a success or to reconcile an unknown outcome.
202    StartVerification,
203    /// Verification found the effect applied.
204    VerificationConfirmed,
205    /// Verification found, authoritatively, that the effect did not apply,
206    /// and it will not be retried.
207    VerificationNotApplied,
208    /// Verification found remote state that contradicts the effect.
209    VerificationConflict,
210    /// The runtime gave up resolving an unknown outcome on its own.
211    Escalate,
212    /// An operator confirmed the effect applied.
213    ResolvedApplied,
214    /// An operator confirmed the effect did not apply.
215    ResolvedNotApplied,
216    /// An operator asserted it is safe to run the effect again, or to try
217    /// its failed compensation again.
218    ResolvedRetry,
219    /// Undoing a committed effect begins. Persisted before the first
220    /// compensation attempt.
221    StartCompensation,
222    /// Another compensation attempt is scheduled, after a failed attempt.
223    ScheduleCompensationRetry,
224    /// Another compensation attempt begins: a scheduled retry, or a rerun
225    /// of an attempt a crash interrupted. Persisted before it runs.
226    StartCompensationRetry,
227    /// The compensation succeeded.
228    CompensationSucceeded,
229    /// The compensation failed for good.
230    CompensationFailed,
231    /// An operator undid the effect by hand.
232    ResolvedCompensated,
233}
234
235/// Every transition, for exhaustive tests.
236pub const ALL_TRANSITIONS: [Transition; 24] = [
237    Transition::RequestApproval,
238    Transition::Approve,
239    Transition::Deny,
240    Transition::PreconditionRejected,
241    Transition::StartAttempt,
242    Transition::Succeeded,
243    Transition::FailedDefinitively,
244    Transition::ScheduleRetry,
245    Transition::OutcomeUnknown,
246    Transition::LeaseExpired,
247    Transition::StartVerification,
248    Transition::VerificationConfirmed,
249    Transition::VerificationNotApplied,
250    Transition::VerificationConflict,
251    Transition::Escalate,
252    Transition::ResolvedApplied,
253    Transition::ResolvedNotApplied,
254    Transition::ResolvedRetry,
255    Transition::StartCompensation,
256    Transition::ScheduleCompensationRetry,
257    Transition::StartCompensationRetry,
258    Transition::CompensationSucceeded,
259    Transition::CompensationFailed,
260    Transition::ResolvedCompensated,
261];
262
263impl Transition {
264    /// Parses the audit-event name produced by [`Self::as_str`].
265    pub fn parse(s: &str) -> Option<Self> {
266        ALL_TRANSITIONS.into_iter().find(|t| t.as_str() == s)
267    }
268
269    /// The stable audit-event name, e.g. `effect.attempt_started`.
270    pub const fn as_str(self) -> &'static str {
271        match self {
272            Self::RequestApproval => "effect.approval_requested",
273            Self::Approve => "effect.approved",
274            Self::Deny => "effect.denied",
275            Self::PreconditionRejected => "effect.precondition_rejected",
276            Self::StartAttempt => "effect.attempt_started",
277            Self::Succeeded => "effect.succeeded",
278            Self::FailedDefinitively => "effect.failed",
279            Self::ScheduleRetry => "effect.retry_scheduled",
280            Self::OutcomeUnknown => "effect.outcome_unknown",
281            Self::LeaseExpired => "effect.lease_expired",
282            Self::StartVerification => "effect.verification_started",
283            Self::VerificationConfirmed => "effect.verification_confirmed",
284            Self::VerificationNotApplied => "effect.verification_not_applied",
285            Self::VerificationConflict => "effect.verification_conflict",
286            Self::Escalate => "effect.escalated",
287            Self::ResolvedApplied => "effect.resolved_applied",
288            Self::ResolvedNotApplied => "effect.resolved_not_applied",
289            Self::ResolvedRetry => "effect.resolved_retry",
290            Self::StartCompensation => "effect.compensation_started",
291            Self::ScheduleCompensationRetry => "effect.compensation_retry_scheduled",
292            Self::StartCompensationRetry => "effect.compensation_retry_started",
293            Self::CompensationSucceeded => "effect.compensated",
294            Self::CompensationFailed => "effect.compensation_failed",
295            Self::ResolvedCompensated => "effect.resolved_compensated",
296        }
297    }
298}
299
300impl fmt::Display for Transition {
301    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302        f.write_str(self.as_str())
303    }
304}
305
306/// A transition that is not legal from the current status.
307#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
308#[error("transition {event} is not allowed from status {from}")]
309pub struct InvalidTransition {
310    /// The status the effect was in.
311    pub from: EffectStatus,
312    /// The rejected transition.
313    pub event: Transition,
314}
315
316#[cfg(test)]
317mod tests {
318    use std::collections::{HashSet, VecDeque};
319
320    use proptest::prelude::*;
321
322    use super::*;
323
324    fn successors(status: EffectStatus) -> impl Iterator<Item = EffectStatus> {
325        ALL_TRANSITIONS
326            .into_iter()
327            .filter_map(move |t| status.apply(t).ok())
328    }
329
330    fn reachable_from(start: EffectStatus) -> HashSet<EffectStatus> {
331        let mut seen = HashSet::from([start]);
332        let mut queue = VecDeque::from([start]);
333        while let Some(status) = queue.pop_front() {
334            for next in successors(status) {
335                if seen.insert(next) {
336                    queue.push_back(next);
337                }
338            }
339        }
340        seen
341    }
342
343    fn sources_of(target: EffectStatus) -> HashSet<(EffectStatus, Transition)> {
344        ALL_STATUSES
345            .into_iter()
346            .flat_map(|s| ALL_TRANSITIONS.into_iter().map(move |t| (s, t)))
347            .filter(|(s, t)| s.apply(*t) == Ok(target))
348            .collect()
349    }
350
351    #[test]
352    fn compensation_starts_only_from_committed() {
353        let sources = sources_of(EffectStatus::Compensating);
354        let starts: HashSet<_> = sources
355            .iter()
356            .filter(|(from, _)| *from != EffectStatus::Compensating)
357            .copied()
358            .collect();
359        assert_eq!(
360            starts,
361            HashSet::from([
362                (EffectStatus::Committed, Transition::StartCompensation),
363                (EffectStatus::CompensationFailed, Transition::ResolvedRetry),
364            ])
365        );
366        let exits_of_committed: HashSet<_> = ALL_TRANSITIONS
367            .into_iter()
368            .filter(|t| EffectStatus::Committed.apply(*t).is_ok())
369            .collect();
370        assert_eq!(
371            exits_of_committed,
372            HashSet::from([Transition::StartCompensation])
373        );
374    }
375
376    #[test]
377    fn compensated_requires_evidence() {
378        let events: HashSet<Transition> = sources_of(EffectStatus::Compensated)
379            .into_iter()
380            .map(|(_, t)| t)
381            .collect();
382        assert_eq!(
383            events,
384            HashSet::from([
385                Transition::CompensationSucceeded,
386                Transition::ResolvedCompensated
387            ])
388        );
389    }
390
391    #[test]
392    fn terminal_states_have_no_exits() {
393        for status in ALL_STATUSES.into_iter().filter(|s| s.is_terminal()) {
394            assert_eq!(successors(status).count(), 0, "{status} has an exit");
395        }
396    }
397
398    #[test]
399    fn every_state_is_reachable_from_pending() {
400        let reachable = reachable_from(EffectStatus::Pending);
401        for status in ALL_STATUSES {
402            assert!(reachable.contains(&status), "{status} is unreachable");
403        }
404    }
405
406    #[test]
407    fn every_state_can_settle() {
408        for status in ALL_STATUSES {
409            assert!(
410                reachable_from(status).iter().any(|s| s.is_terminal()),
411                "{status} can never reach a terminal state"
412            );
413        }
414    }
415
416    #[test]
417    fn only_pending_starts_an_attempt() {
418        let sources = sources_of(EffectStatus::Executing);
419        assert_eq!(
420            sources,
421            HashSet::from([(EffectStatus::Pending, Transition::StartAttempt)])
422        );
423    }
424
425    #[test]
426    fn in_doubt_states_never_restart_without_a_retry_decision() {
427        // Leaving doubt for Pending must always be an explicit decision that
428        // re-executing is safe, never a side effect of some other event.
429        for status in ALL_STATUSES.into_iter().filter(|s| s.is_in_doubt()) {
430            for t in ALL_TRANSITIONS {
431                if status.apply(t) == Ok(EffectStatus::Pending) {
432                    assert!(
433                        matches!(t, Transition::ScheduleRetry | Transition::ResolvedRetry),
434                        "{status} --{t}--> pending"
435                    );
436                }
437            }
438        }
439    }
440
441    #[test]
442    fn committed_requires_evidence() {
443        let events: HashSet<Transition> = sources_of(EffectStatus::Committed)
444            .into_iter()
445            .map(|(_, t)| t)
446            .collect();
447        assert_eq!(
448            events,
449            HashSet::from([
450                Transition::Succeeded,
451                Transition::VerificationConfirmed,
452                Transition::ResolvedApplied,
453            ])
454        );
455    }
456
457    #[test]
458    fn a_lost_lease_never_counts_as_failure() {
459        for status in ALL_STATUSES {
460            if let Ok(next) = status.apply(Transition::LeaseExpired) {
461                assert_eq!(next, EffectStatus::Unknown);
462            }
463        }
464    }
465
466    #[test]
467    fn storage_representation_round_trips_and_matches_serde() {
468        for status in ALL_STATUSES {
469            assert_eq!(EffectStatus::parse(status.as_str()), Some(status));
470            assert_eq!(
471                serde_json::to_string(&status).unwrap(),
472                format!("\"{}\"", status.as_str())
473            );
474        }
475        let names: HashSet<_> = ALL_TRANSITIONS.iter().map(|t| t.as_str()).collect();
476        assert_eq!(names.len(), ALL_TRANSITIONS.len());
477        for transition in ALL_TRANSITIONS {
478            assert_eq!(Transition::parse(transition.as_str()), Some(transition));
479        }
480    }
481
482    fn transition() -> impl Strategy<Value = Transition> {
483        proptest::sample::select(ALL_TRANSITIONS.to_vec())
484    }
485
486    proptest! {
487        #[test]
488        fn random_histories_respect_invariants(events in prop::collection::vec(transition(), 0..64)) {
489            let mut status = EffectStatus::Pending;
490            for event in events {
491                match status.apply(event) {
492                    Ok(next) => {
493                        prop_assert!(!status.is_terminal());
494                        if status == EffectStatus::Committed {
495                            prop_assert_ne!(next, EffectStatus::Executing);
496                        }
497                        status = next;
498                    }
499                    Err(err) => {
500                        prop_assert_eq!(err.from, status);
501                    }
502                }
503            }
504        }
505    }
506}