turnframe_test/explore/model.rs
1//! What a domain must tell the explorer about its own transitions.
2
3use turnframe_core::error::DomainRejection;
4use turnframe_core::flow::WorkflowDefinition;
5
6/// The result of applying one candidate command to one state (spec §8.5).
7///
8/// A transition is either applied — producing the next state and the events the
9/// executor would commit — or refused by the domain. A refusal carries the
10/// [`DomainRejection`] the real executor would return, so the explorer can check
11/// it against [`WorkflowDefinition::validate_command`].
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum SimulatedTransition<S, E> {
14 /// The command was applied.
15 Applied {
16 /// State after the command, or `None` when the case ceased to exist.
17 state: Option<S>,
18 /// Events the executor would commit.
19 events: Vec<E>,
20 },
21 /// The domain refused the command; the input state is unchanged.
22 Rejected(DomainRejection),
23}
24
25impl<S, E> SimulatedTransition<S, E> {
26 /// A transition that produced a new state.
27 #[must_use]
28 pub fn applied(state: S, events: Vec<E>) -> Self {
29 Self::Applied {
30 state: Some(state),
31 events,
32 }
33 }
34
35 /// A transition that removed the case, which
36 /// [`explore`](crate::explore::explore) reports as
37 /// [`TransitionRemovesCase`](crate::explore::ExplorationViolationKind::TransitionRemovesCase).
38 ///
39 /// A case's identity outlives its content, so a domain whose working
40 /// document is consumed on success moves the case to a terminal *status*
41 /// instead of dropping it, the way the traveler sample's `Deleted` does.
42 /// This constructor exists so that a model ported from a delete-on-completion
43 /// design produces a reported violation rather than a shape the explorer
44 /// cannot express — and so the check itself can be falsified.
45 #[must_use]
46 pub fn removed(events: Vec<E>) -> Self {
47 Self::Applied {
48 state: None,
49 events,
50 }
51 }
52
53 /// A refusal.
54 #[must_use]
55 pub fn rejected(rejection: DomainRejection) -> Self {
56 Self::Rejected(rejection)
57 }
58
59 /// Returns `true` when the command was applied.
60 #[must_use]
61 pub fn is_applied(&self) -> bool {
62 matches!(self, Self::Applied { .. })
63 }
64
65 /// The state after the command, when the command applied and the case still
66 /// exists.
67 #[must_use]
68 pub fn next_state(&self) -> Option<&S> {
69 match self {
70 Self::Applied { state, .. } => state.as_ref(),
71 Self::Rejected(_) => None,
72 }
73 }
74
75 /// The events the command would commit; empty for a refusal.
76 #[must_use]
77 pub fn events(&self) -> &[E] {
78 match self {
79 Self::Applied { events, .. } => events,
80 Self::Rejected(_) => &[],
81 }
82 }
83
84 /// The rejection, when the command was refused.
85 #[must_use]
86 pub fn rejection(&self) -> Option<&DomainRejection> {
87 match self {
88 Self::Rejected(rejection) => Some(rejection),
89 Self::Applied { .. } => None,
90 }
91 }
92}
93
94/// The transition model of a workflow, used for bounded exploration (spec §8.5).
95///
96/// A [`WorkflowDefinition`] says how state *projects*; a `WorkflowModel` says
97/// how state *moves*. Keeping them apart means the explorer never has to run an
98/// executor, a store or a clock.
99///
100/// Implementations must be deterministic and side-effect free: the explorer
101/// deduplicates states by their canonical JSON, so a model that mints a fresh
102/// identifier on every call turns a small workflow into an infinite one. Derive
103/// identifiers from the state instead (for example, the n-th line always gets
104/// the n-th identifier of a fixed table).
105pub trait WorkflowModel<W: WorkflowDefinition> {
106 /// The states exploration starts from. `None` means "the case does not
107 /// exist yet", which is where most workflows begin — and it never means
108 /// "the case is over", because a case's identity outlives its content.
109 fn initial_states(&self) -> Vec<Option<W::State>>;
110
111 /// Commands worth trying in this state, in a stable order. Include commands
112 /// you expect to be refused: the explorer checks that a refusal changes
113 /// nothing.
114 fn candidate_commands(&self, state: Option<&W::State>) -> Vec<W::Command>;
115
116 /// Applies one candidate command purely.
117 fn simulate(
118 &self,
119 state: Option<&W::State>,
120 command: &W::Command,
121 ) -> SimulatedTransition<W::State, W::Event>;
122
123 /// Outcomes the workflow claims it can reach. The explorer reports every
124 /// declared outcome no reachable state projects. The default is empty,
125 /// which disables the check.
126 fn declared_outcomes(&self) -> Vec<W::Outcome> {
127 Vec::new()
128 }
129}