Skip to main content

pacta_contract/
lib.rs

1//! The isolated core contract for Pacta.
2//!
3//! Pacta operates on three axioms:
4//! 1. Registry is Lifecycle (no business logic, no retry/delay logic).
5//! 2. Execution is Middleware.
6//! 3. This contract has no dependency on other workspace crates.
7
8#![forbid(unsafe_code)]
9#![warn(missing_docs)]
10
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14/// A durable obligation, generated from a Signal, ready to be executed.
15/// Note the deliberate absence of `attempts`, `delay`, and `priority`.
16///
17/// Construct through [`Pact::new`]; the fields stay public for reading. The type is
18/// `#[non_exhaustive]` so it can gain a field in a later minor release without a
19/// breaking change.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[non_exhaustive]
22pub struct Pact {
23    /// Stable identifier for this pact.
24    pub id: Uuid,
25    /// Logical docket from which the pact can be claimed.
26    pub docket: String,
27    /// Application-defined pact kind.
28    pub kind: String,
29    /// Business data required to fulfill the pact.
30    pub clause: Vec<u8>,
31}
32
33impl Pact {
34    /// Build a pact from its identifier, docket, kind, and clause.
35    #[must_use]
36    pub fn new(id: Uuid, docket: String, kind: String, clause: Vec<u8>) -> Self {
37        Self {
38            id,
39            docket,
40            kind,
41            clause,
42        }
43    }
44}
45
46/// A retainer: the authority token a registry issues with a claim and validates
47/// when settling it. Authority is registry-validated — a forged identifier does not
48/// match an issued claim — not proven by the type system. Construct via
49/// [`Retainer::new`] and read the identifier via [`Retainer::id`]. Derives
50/// `PartialEq`/`Eq`/`Hash` so a durable backend can index lease state by holder
51/// identity — the orphan rule makes providing these the contract's responsibility.
52#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
53pub struct Retainer(Uuid);
54
55impl Retainer {
56    /// Mint a retainer from an identifier. A registry issues tokens through this.
57    #[must_use]
58    pub fn new(id: Uuid) -> Self {
59        Self(id)
60    }
61
62    /// The retainer's identifier, which a registry validates on settlement.
63    #[must_use]
64    pub fn id(&self) -> Uuid {
65        self.0
66    }
67}
68
69/// A point in time as milliseconds since an epoch the runtime chooses. This is a
70/// pure value: the core names time but never reads it. There is deliberately no
71/// `now` constructor — a runtime obtains the current time and injects it, keeping
72/// lease decisions deterministic and testable.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
74pub struct Timestamp(u64);
75
76impl Timestamp {
77    /// Build a timestamp from milliseconds since the runtime's chosen epoch.
78    #[must_use]
79    pub fn from_millis(millis: u64) -> Self {
80        Self(millis)
81    }
82
83    /// The milliseconds since the runtime's chosen epoch.
84    #[must_use]
85    pub fn as_millis(self) -> u64 {
86        self.0
87    }
88
89    /// The timestamp `millis` milliseconds after this one, saturating at the maximum.
90    #[must_use]
91    pub fn plus_millis(self, millis: u64) -> Self {
92        Self(self.0.saturating_add(millis))
93    }
94}
95
96/// A claimed pact and the retainer required to settle it.
97///
98/// Construct through [`Claim::new`]; the fields stay public for reading. The type is
99/// `#[non_exhaustive]` so it can gain a field in a later minor release without a
100/// breaking change.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct Claim {
104    /// Pact claimed for execution.
105    pub pact: Pact,
106    /// Authority required to heartbeat, fulfill, or breach the claim.
107    pub retainer: Retainer,
108    /// When the claim's lease expires. After this the pact may be lapsed and
109    /// reclaimed unless the holder heartbeats first.
110    pub lease_expiry: Timestamp,
111}
112
113impl Claim {
114    /// Build a claim from a pact, the settling retainer, and the lease expiry.
115    #[must_use]
116    pub fn new(pact: Pact, retainer: Retainer, lease_expiry: Timestamp) -> Self {
117        Self {
118            pact,
119            retainer,
120            lease_expiry,
121        }
122    }
123}
124
125// The lease identity must be usable as a durable-backend key; removing the derives
126// fails this build rather than silently regressing the backend contract.
127const _: fn() = || {
128    fn assert_key<T: Eq + std::hash::Hash>() {}
129    assert_key::<Retainer>();
130};
131
132/// The lifecycle outcome an execution produces for a claimed pact.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum Outcome {
135    /// The pact was fulfilled successfully.
136    Fulfilled,
137    /// The pact could not be fulfilled and must be breached.
138    Breached,
139}
140
141/// The lifecycle conclusion applied to a claim, currently a fulfilled or breached
142/// [`Outcome`].
143pub type Settlement = Outcome;
144
145/// The pure lifecycle state machine every `Registry` backend composes over.
146///
147/// This is the single source of the pact lifecycle *semantics* — the claim-eligibility
148/// predicate, the state transitions, the current-holder authority check, and the lease
149/// arithmetic. A backend owns its own storage and mints its own retainer (a fencing
150/// value); it delegates every eligibility decision and transition here, so the semantics
151/// are defined once and cannot drift between backends (or between a synchronous and a
152/// future asynchronous binding).
153///
154/// It is colorless and sans-I/O: it reads no clock (time is an injected parameter),
155/// performs no I/O, and mints nothing non-deterministic (the retainer is supplied by the
156/// caller). Named `lifecycle` to distinguish it from the executor step-driver
157/// [`kernel`], which is a different pure machine.
158pub mod lifecycle {
159    use crate::{Retainer, Timestamp};
160
161    /// A pact's position in its claim lifecycle: the pure state a backend stores per
162    /// pact. The backend owns where it lives; this owns what it means.
163    #[derive(Debug, Clone, PartialEq, Eq)]
164    pub enum State {
165        /// Never claimed, or freshly seeded: immediately claimable.
166        Available,
167        /// Held under a lease by `retainer` until `expiry`. Claimable again only once
168        /// the lease has lapsed (`expiry < now`), which rotates authority away.
169        Held {
170            /// The current holder's authority token.
171            retainer: Retainer,
172            /// When the lease expires.
173            expiry: Timestamp,
174        },
175        /// Released non-terminally: claimable again only at or after `reclaimable_at`.
176        Deferred {
177            /// The instant at or after which the pact may be reclaimed.
178            reclaimable_at: Timestamp,
179        },
180        /// Concluded (fulfilled or breached): never claimable again.
181        Settled,
182    }
183
184    /// A transition was attempted by something that is not the state's current holder —
185    /// a stale retainer, or a state (available, deferred, settled) with no holder at all.
186    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
187    pub struct NotCurrentHolder;
188
189    impl std::fmt::Display for NotCurrentHolder {
190        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191            write!(f, "retainer is not the current holder of this pact")
192        }
193    }
194
195    impl std::error::Error for NotCurrentHolder {}
196
197    /// The lease expiry for a claim taken at `now` for `lease_millis` — the single
198    /// source of the lease arithmetic.
199    #[must_use]
200    pub fn lease_expiry(now: Timestamp, lease_millis: u64) -> Timestamp {
201        now.plus_millis(lease_millis)
202    }
203
204    /// Whether a pact in `state` may be claimed at `now`: the eligibility invariant.
205    /// `Available` always; a `Held` lease that has lapsed; a `Deferred` pact at or past
206    /// its instant; never a `Settled` one. Only positive, unambiguous eligibility.
207    #[must_use]
208    pub fn is_claimable(state: &State, now: Timestamp) -> bool {
209        match state {
210            State::Available => true,
211            State::Held { expiry, .. } => *expiry < now,
212            State::Deferred { reclaimable_at } => *reclaimable_at <= now,
213            State::Settled => false,
214        }
215    }
216
217    /// The state a successful claim produces: `Held` by `retainer` until the lease
218    /// expiry for `now`/`lease_millis`. The backend mints `retainer` and passes it in.
219    #[must_use]
220    pub fn on_claim(retainer: &Retainer, now: Timestamp, lease_millis: u64) -> State {
221        State::Held {
222            retainer: retainer.clone(),
223            expiry: lease_expiry(now, lease_millis),
224        }
225    }
226
227    /// The state a heartbeat produces: the lease extended to the expiry for
228    /// `now`/`lease_millis`, provided `retainer` currently holds `state` and the lease
229    /// has not already lapsed. A lapsed lease is not revived — the holder must re-claim.
230    pub fn on_heartbeat(
231        state: &State,
232        retainer: &Retainer,
233        now: Timestamp,
234        lease_millis: u64,
235    ) -> Result<State, NotCurrentHolder> {
236        match state {
237            State::Held {
238                retainer: held,
239                expiry,
240            } if held == retainer && *expiry >= now => Ok(State::Held {
241                retainer: retainer.clone(),
242                expiry: lease_expiry(now, lease_millis),
243            }),
244            _ => Err(NotCurrentHolder),
245        }
246    }
247
248    /// The state a settlement produces: `Settled`, provided `retainer` currently holds
249    /// `state`. Fulfill and breach share this — the lifecycle state records that the
250    /// obligation concluded, not which outcome concluded it.
251    pub fn on_settle(state: &State, retainer: &Retainer) -> Result<State, NotCurrentHolder> {
252        if is_current_holder(state, retainer) {
253            Ok(State::Settled)
254        } else {
255            Err(NotCurrentHolder)
256        }
257    }
258
259    /// The state a release produces: `Deferred` until `reclaimable_at`, provided
260    /// `retainer` currently holds `state`. Non-terminal; rotates authority away.
261    pub fn on_release(
262        state: &State,
263        retainer: &Retainer,
264        reclaimable_at: Timestamp,
265    ) -> Result<State, NotCurrentHolder> {
266        if is_current_holder(state, retainer) {
267            Ok(State::Deferred { reclaimable_at })
268        } else {
269            Err(NotCurrentHolder)
270        }
271    }
272
273    fn is_current_holder(state: &State, retainer: &Retainer) -> bool {
274        matches!(state, State::Held { retainer: held, .. } if held == retainer)
275    }
276
277    #[cfg(test)]
278    mod tests {
279        use super::*;
280        use uuid::Uuid;
281
282        fn retainer() -> Retainer {
283            Retainer::new(Uuid::new_v4())
284        }
285
286        #[test]
287        fn eligibility_covers_each_state() {
288            let now = Timestamp::from_millis(100);
289            assert!(is_claimable(&State::Available, now));
290            // A held lease is claimable only once lapsed.
291            assert!(!is_claimable(
292                &State::Held {
293                    retainer: retainer(),
294                    expiry: Timestamp::from_millis(101)
295                },
296                now
297            ));
298            assert!(is_claimable(
299                &State::Held {
300                    retainer: retainer(),
301                    expiry: Timestamp::from_millis(99)
302                },
303                now
304            ));
305            // A deferred pact is claimable at or past its instant.
306            assert!(!is_claimable(
307                &State::Deferred {
308                    reclaimable_at: Timestamp::from_millis(101)
309                },
310                now
311            ));
312            assert!(is_claimable(
313                &State::Deferred {
314                    reclaimable_at: Timestamp::from_millis(100)
315                },
316                now
317            ));
318            assert!(!is_claimable(&State::Settled, now));
319        }
320
321        #[test]
322        fn transitions_require_the_current_holder() {
323            let holder = retainer();
324            let held = State::Held {
325                retainer: holder.clone(),
326                expiry: Timestamp::from_millis(200),
327            };
328            let stranger = retainer();
329
330            assert_eq!(on_settle(&held, &stranger), Err(NotCurrentHolder));
331            assert_eq!(
332                on_release(&held, &stranger, Timestamp::from_millis(0)),
333                Err(NotCurrentHolder)
334            );
335            assert_eq!(on_settle(&State::Settled, &holder), Err(NotCurrentHolder));
336
337            assert_eq!(on_settle(&held, &holder), Ok(State::Settled));
338            assert_eq!(
339                on_release(&held, &holder, Timestamp::from_millis(500)),
340                Ok(State::Deferred {
341                    reclaimable_at: Timestamp::from_millis(500)
342                })
343            );
344        }
345
346        #[test]
347        fn heartbeat_refreshes_but_does_not_revive_a_lapsed_lease() {
348            let holder = retainer();
349            let held = State::Held {
350                retainer: holder.clone(),
351                expiry: Timestamp::from_millis(200),
352            };
353            // Live lease refreshes.
354            assert_eq!(
355                on_heartbeat(&held, &holder, Timestamp::from_millis(150), 100),
356                Ok(State::Held {
357                    retainer: holder.clone(),
358                    expiry: Timestamp::from_millis(250)
359                })
360            );
361            // Lapsed lease is not revived.
362            assert_eq!(
363                on_heartbeat(&held, &holder, Timestamp::from_millis(201), 100),
364                Err(NotCurrentHolder)
365            );
366        }
367    }
368}
369
370/// A pure kernel transition decision — a [`lifecycle`] `on_X` — passed to the transition port
371/// [`Registry::apply`] (and its async twin). It is `Send + Sync` so the async binding's `apply`
372/// future stays `Send` across `.await`; the same type is used by both bindings, so the port is
373/// literally one shape.
374pub type Transition<'a> = dyn Fn(&lifecycle::State) -> Result<lifecycle::State, lifecycle::NotCurrentHolder>
375    + Send
376    + Sync
377    + 'a;
378
379/// The asynchronous binding of the [`Registry`] contract, available behind the `async` feature.
380/// [`AsyncRegistry`] is the same five-op contract over the same [`Transition`] port, made async;
381/// [`apply_via_cas`] is the optional compare-and-set helper. A consumer that does not enable `async`
382/// compiles none of it.
383#[cfg(feature = "async")]
384mod async_registry;
385#[cfg(feature = "async")]
386pub use async_registry::{AsyncRegistry, apply_via_cas};
387
388/// The Registry manages the lifecycle of Pacts. It is a pure state machine.
389///
390/// Time is injected: [`claim`](Registry::claim) and
391/// [`heartbeat`](Registry::heartbeat) take the current time as a parameter, and the
392/// registry reads no ambient clock. Settlement takes no time because a rotated
393/// retainer already tells a stale holder apart from the current one.
394pub trait Registry: Send + Sync {
395    /// Error returned by the registry implementation.
396    type Error: std::error::Error;
397
398    /// Claim a pact for execution from one of the requested dockets, using `now`
399    /// to set the new lease and to reclaim any pact whose lease already expired
400    /// without settlement — a lapse, realized through this normal claim path.
401    /// Reclaiming rotates the retainer, so the prior holder can no longer settle.
402    fn claim(&self, dockets: &[&str], now: Timestamp) -> Result<Option<Claim>, Self::Error>;
403
404    /// The backend's lease duration in milliseconds, used by [`heartbeat`](Registry::heartbeat)
405    /// to compute the extended lease. Lease sizing is the backend's; the contract supplies the
406    /// mechanism, not a constant.
407    fn lease_millis(&self) -> u64;
408
409    /// Apply a lifecycle transition to the pact held by `retainer`, within the backend's own
410    /// atomic scope. `transition` is the pure kernel decision (a [`lifecycle`] `on_X`): the
411    /// backend loads the held state, computes the next state through `transition`, and applies
412    /// it atomically — it never decides the transition itself, so the lifecycle semantics stay
413    /// single-sourced in the kernel and cannot drift. A transition applied against a pact the
414    /// retainer no longer holds resolves to a not-current-holder error (the kernel's
415    /// [`NotCurrentHolder`](lifecycle::NotCurrentHolder) surfaces through `transition`). This is
416    /// the one transition port; the four transition operations below are provided over it.
417    ///
418    /// The backend owns *how* the scope is made atomic (a lock, a transaction, a native
419    /// conditional write, or compare-and-set); the contract mandates no concurrency-control
420    /// mechanism.
421    fn apply(&self, retainer: &Retainer, transition: &Transition<'_>) -> Result<(), Self::Error>;
422
423    /// Extend the retainer's lease using `now`. A heartbeat presented after the
424    /// lease already expired is rejected: the holder must claim again rather than
425    /// revive a lapsed lease, so two holders never both hold settlement authority.
426    fn heartbeat(&self, retainer: &Retainer, now: Timestamp) -> Result<(), Self::Error> {
427        let lease = self.lease_millis();
428        self.apply(retainer, &|state| {
429            lifecycle::on_heartbeat(state, retainer, now, lease)
430        })
431    }
432
433    /// Mark the pact as successfully fulfilled. Rejected when the retainer is not
434    /// the current holder.
435    fn fulfill(&self, retainer: &Retainer) -> Result<(), Self::Error> {
436        self.apply(retainer, &|state| lifecycle::on_settle(state, retainer))
437    }
438
439    /// Mark the pact as breached. Rejected when the retainer is not the current
440    /// holder. Shares the settlement transition with [`fulfill`](Registry::fulfill) — the
441    /// lifecycle records that the obligation concluded, not which outcome concluded it.
442    fn breach(&self, retainer: &Retainer) -> Result<(), Self::Error> {
443        self.apply(retainer, &|state| lifecycle::on_settle(state, retainer))
444    }
445
446    /// Release the claim without concluding the obligation, making the pact
447    /// reclaimable again only at or after `reclaimable_at`.
448    ///
449    /// This is **non-terminal**: unlike [`fulfill`](Registry::fulfill) and
450    /// [`breach`](Registry::breach), it settles nothing — the pact is left to be
451    /// attempted again. The registry computes no delay: `reclaimable_at` is a
452    /// consumer-supplied instant, honored exactly as the injected `now` is honored
453    /// (compared, never computed), so backoff policy stays with the caller and `Pact`
454    /// carries no delay. A `reclaimable_at` at or before now makes the pact immediately
455    /// claimable, as a voluntary lapse. Release rotates authority like a lapse, so the
456    /// prior retainer can no longer settle or heartbeat. Rejected when the retainer is
457    /// not the current holder.
458    fn release(&self, retainer: &Retainer, reclaimable_at: Timestamp) -> Result<(), Self::Error> {
459        self.apply(retainer, &|state| {
460            lifecycle::on_release(state, retainer, reclaimable_at)
461        })
462    }
463}
464
465/// The sans-I/O lifecycle kernel.
466///
467/// The kernel is a pure state machine: it decides the next [`Directive`](kernel::Directive)
468/// from its state and absorbs [`Notice`](kernel::Notice) reports a runtime feeds
469/// back. It performs no I/O
470/// and exposes no `async fn`, so it commits to no runtime shape. It encodes the
471/// lifecycle decision table only — it adds no orchestration behavior.
472///
473/// # Advanced surface
474///
475/// This is the **advanced** tier of Pacta's public API: lower stability intent than
476/// the recommended surface (its API may evolve as the runtime story settles), though
477/// it stays a supported, governed core surface — not unsupported or slated for
478/// removal. Most consumers should compose with the `Driver` runtime (or the `pacta`
479/// facade) and never touch the kernel. Reach for it only to build a custom runtime;
480/// it is reached through `pacta-contract` directly, never through the `pacta` facade.
481///
482/// # Driving the kernel
483///
484/// A runtime drives one lifecycle step by looping: ask [`poll`](kernel::Kernel::poll)
485/// for the next [`Directive`](kernel::Directive), perform it, report the outcome back with
486/// [`on_event`](kernel::Kernel::on_event), and repeat until [`result`](kernel::Kernel::result)
487/// yields a terminal [`StepResult`](kernel::StepResult). The kernel decides *what*;
488/// the runtime performs it and injects time — the kernel reads no clock.
489///
490/// ```
491/// use pacta_contract::kernel::{Directive, Kernel, Notice, StepResult};
492/// use pacta_contract::{Claim, Outcome, Pact, Retainer, Timestamp};
493///
494/// let mut kernel = Kernel::new();
495/// let mut available = Some(Claim::new(
496///     Pact::new(Default::default(), "demo".into(), "demo".into(), Vec::new()),
497///     Retainer::new(Default::default()),
498///     Timestamp::from_millis(0),
499/// ));
500///
501/// let result = loop {
502///     if let Some(result) = kernel.result() {
503///         break result;
504///     }
505///     match kernel.poll() {
506///         Directive::Claim => kernel.on_event(Notice::Claimed(available.take())),
507///         Directive::Execute(_pact) => kernel.on_event(Notice::Executed(Outcome::Fulfilled)),
508///         Directive::Settle(_retainer, _outcome) => kernel.on_event(Notice::Settled),
509///         Directive::Idle => break StepResult::Idle,
510///         _ => unreachable!("driver handles every current kernel directive"),
511///     }
512/// };
513///
514/// assert_eq!(result, StepResult::Settled(Outcome::Fulfilled));
515/// ```
516pub mod kernel {
517    use crate::{Claim, Outcome, Pact, Retainer};
518
519    /// An instruction the kernel issues for a runtime to perform.
520    ///
521    /// `#[non_exhaustive]`: this advanced-tier protocol may gain directives, so a
522    /// runtime's match must carry a wildcard arm.
523    #[derive(Debug, Clone)]
524    #[non_exhaustive]
525    pub enum Directive {
526        /// Claim a pact from the runtime's configured dockets.
527        Claim,
528        /// Execute the given claimed pact.
529        Execute(Pact),
530        /// Settle the claim identified by the retainer with the decided outcome.
531        Settle(Retainer, Outcome),
532        /// Nothing remains to be performed for this step.
533        Idle,
534    }
535
536    /// A report a runtime feeds back after performing a [`Directive`].
537    ///
538    /// `#[non_exhaustive]`: this advanced-tier protocol may gain notices, so a
539    /// consumer's match must carry a wildcard arm.
540    #[derive(Debug, Clone)]
541    #[non_exhaustive]
542    pub enum Notice {
543        /// Result of a claim: a claim if one was available, else none.
544        Claimed(Option<Claim>),
545        /// An execution produced a lifecycle outcome.
546        Executed(Outcome),
547        /// The execution infrastructure failed to run the pact — the executor
548        /// produced no outcome. The kernel fabricates no outcome for this notice: it
549        /// settles nothing and reaches an unsettled terminal, leaving the claim to
550        /// lapse and be reclaimed, while the runtime surfaces the error to its caller.
551        ExecutionFailed,
552        /// A settlement was persisted.
553        Settled,
554    }
555
556    /// The terminal result of one lifecycle step.
557    ///
558    /// `#[non_exhaustive]`: this advanced-tier protocol may gain results, so a
559    /// consumer's match must carry a wildcard arm.
560    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
561    #[non_exhaustive]
562    pub enum StepResult {
563        /// No pact was available to claim.
564        Idle,
565        /// The claim was settled with this outcome.
566        Settled(Outcome),
567        /// Execution produced no outcome (an infrastructure failure), so nothing was
568        /// settled. The claim is left held-but-unsettled to lapse and be reclaimed;
569        /// the kernel fabricates no `Outcome` from the absence of one.
570        Unsettled,
571    }
572
573    #[derive(Debug)]
574    enum Phase {
575        Claiming,
576        Executing {
577            pact: Pact,
578            retainer: Retainer,
579        },
580        Settling {
581            retainer: Retainer,
582            outcome: Outcome,
583        },
584        DoneIdle,
585        DoneSettled(Outcome),
586        DoneUnsettled,
587    }
588
589    /// The pure lifecycle state machine for a single step.
590    #[derive(Debug)]
591    pub struct Kernel {
592        phase: Phase,
593    }
594
595    impl Kernel {
596        /// Start a fresh lifecycle step.
597        #[must_use]
598        pub fn new() -> Self {
599            Self {
600                phase: Phase::Claiming,
601            }
602        }
603
604        /// Decide the next directive from the current state.
605        #[must_use]
606        pub fn poll(&self) -> Directive {
607            match &self.phase {
608                Phase::Claiming => Directive::Claim,
609                Phase::Executing { pact, .. } => Directive::Execute(pact.clone()),
610                Phase::Settling { retainer, outcome } => {
611                    Directive::Settle(retainer.clone(), *outcome)
612                }
613                Phase::DoneIdle | Phase::DoneSettled(_) | Phase::DoneUnsettled => Directive::Idle,
614            }
615        }
616
617        /// Absorb a runtime report, advancing the lifecycle.
618        pub fn on_event(&mut self, notice: Notice) {
619            let phase = std::mem::replace(&mut self.phase, Phase::DoneIdle);
620            self.phase = match (phase, notice) {
621                (Phase::Claiming, Notice::Claimed(Some(claim))) => Phase::Executing {
622                    pact: claim.pact,
623                    retainer: claim.retainer,
624                },
625                (Phase::Claiming, Notice::Claimed(None)) => Phase::DoneIdle,
626                (Phase::Executing { retainer, .. }, Notice::Executed(outcome)) => {
627                    Phase::Settling { retainer, outcome }
628                }
629                (Phase::Executing { .. }, Notice::ExecutionFailed) => Phase::DoneUnsettled,
630                (Phase::Settling { outcome, .. }, Notice::Settled) => Phase::DoneSettled(outcome),
631                (other, _) => other,
632            };
633        }
634
635        /// The terminal result once the step reaches a terminal state, else `None`.
636        #[must_use]
637        pub fn result(&self) -> Option<StepResult> {
638            match &self.phase {
639                Phase::DoneIdle => Some(StepResult::Idle),
640                Phase::DoneSettled(outcome) => Some(StepResult::Settled(*outcome)),
641                Phase::DoneUnsettled => Some(StepResult::Unsettled),
642                _ => None,
643            }
644        }
645    }
646
647    impl Default for Kernel {
648        fn default() -> Self {
649            Self::new()
650        }
651    }
652
653    #[cfg(test)]
654    mod tests {
655        use super::*;
656        use crate::Timestamp;
657        use uuid::Uuid;
658
659        fn claim() -> Claim {
660            Claim::new(
661                Pact::new(
662                    Uuid::new_v4(),
663                    "default".to_string(),
664                    "example".to_string(),
665                    Vec::new(),
666                ),
667                Retainer::new(Uuid::new_v4()),
668                Timestamp::from_millis(0),
669            )
670        }
671
672        fn drive(
673            kernel: &mut Kernel,
674            execution: Result<Outcome, ()>,
675            claimable: bool,
676        ) -> StepResult {
677            loop {
678                if let Some(result) = kernel.result() {
679                    return result;
680                }
681                match kernel.poll() {
682                    Directive::Claim => {
683                        let notice = if claimable {
684                            Notice::Claimed(Some(claim()))
685                        } else {
686                            Notice::Claimed(None)
687                        };
688                        kernel.on_event(notice);
689                    }
690                    Directive::Execute(_) => kernel.on_event(match execution {
691                        Ok(outcome) => Notice::Executed(outcome),
692                        Err(()) => Notice::ExecutionFailed,
693                    }),
694                    Directive::Settle(_, _) => kernel.on_event(Notice::Settled),
695                    Directive::Idle => return StepResult::Idle,
696                }
697            }
698        }
699
700        #[test]
701        fn fulfilled_execution_settles_fulfilled() {
702            let mut kernel = Kernel::new();
703            assert_eq!(
704                drive(&mut kernel, Ok(Outcome::Fulfilled), true),
705                StepResult::Settled(Outcome::Fulfilled)
706            );
707        }
708
709        #[test]
710        fn breached_execution_settles_breached() {
711            let mut kernel = Kernel::new();
712            assert_eq!(
713                drive(&mut kernel, Ok(Outcome::Breached), true),
714                StepResult::Settled(Outcome::Breached)
715            );
716        }
717
718        #[test]
719        fn infrastructure_error_is_unsettled() {
720            let mut kernel = Kernel::new();
721            assert_eq!(drive(&mut kernel, Err(()), true), StepResult::Unsettled);
722        }
723
724        #[test]
725        fn empty_claim_is_idle() {
726            let mut kernel = Kernel::new();
727            assert_eq!(
728                drive(&mut kernel, Ok(Outcome::Fulfilled), false),
729                StepResult::Idle
730            );
731        }
732    }
733}