Skip to main content

gwk_domain/
fsm.rs

1//! The four public state machines, as data.
2//!
3//! Each FSM is an enum plus a fixed `EDGES` table — the table IS the contract.
4//! Terminality is derived (a state with no outgoing edge is terminal), and the
5//! invariant suite in `tests/` walks these tables exhaustively, so an edit here
6//! is a contract change by construction, never a silent drift.
7//!
8//! Wire values are the snake_case variant names, exactly.
9
10/// A state machine whose full shape is compile-time data.
11pub trait StateMachine: Copy + Eq + std::fmt::Debug + Sized + 'static {
12    /// Every state, in declaration order.
13    const STATES: &'static [Self];
14    /// Every legal `(from, to)` edge.
15    const EDGES: &'static [(Self, Self)];
16    /// The escape set: states an executor may enter WITHOUT fabricating
17    /// progress (cancellation / failure / unknown paths). The reachability
18    /// invariant requires every non-terminal to reach a terminal using only
19    /// edges INTO this set. Empty = plain totality (the command FSM, where a
20    /// sweep legitimately drives forward).
21    const ESCAPE: &'static [Self];
22
23    /// Terminal = no outgoing edges. Derived from [`Self::EDGES`], so the
24    /// table cannot disagree with it.
25    fn is_terminal(self) -> bool {
26        !Self::EDGES.iter().any(|(from, _)| *from == self)
27    }
28
29    fn can_transition(from: Self, to: Self) -> bool {
30        Self::EDGES.contains(&(from, to))
31    }
32}
33
34macro_rules! edges {
35    ($ty:ident: $($from:ident -> $to:ident),+ $(,)?) => {
36        &[$(($ty::$from, $ty::$to)),+]
37    };
38}
39
40/// Tracker-visible work item lifecycle (A2A-semantically-verbatim, snake-normalized).
41#[derive(
42    Debug,
43    Clone,
44    Copy,
45    PartialEq,
46    Eq,
47    PartialOrd,
48    Ord,
49    Hash,
50    serde::Serialize,
51    serde::Deserialize,
52    specta::Type,
53)]
54#[serde(rename_all = "snake_case")]
55pub enum TaskState {
56    Submitted,
57    Working,
58    InputRequired,
59    Completed,
60    Failed,
61    Canceled,
62}
63
64impl StateMachine for TaskState {
65    const STATES: &'static [Self] = &[
66        Self::Submitted,
67        Self::Working,
68        Self::InputRequired,
69        Self::Completed,
70        Self::Failed,
71        Self::Canceled,
72    ];
73    const EDGES: &'static [(Self, Self)] = edges![TaskState:
74        Submitted -> Working,
75        Submitted -> Canceled,
76        Working -> InputRequired,
77        Working -> Completed,
78        Working -> Failed,
79        Working -> Canceled,
80        InputRequired -> Working,
81        InputRequired -> Canceled,
82    ];
83    const ESCAPE: &'static [Self] = &[Self::Canceled, Self::Failed];
84}
85
86/// One engine execution of a task. `unknown` is a first-class terminal — an
87/// attempt whose real outcome cannot be determined is never mapped to `failed`.
88#[derive(
89    Debug,
90    Clone,
91    Copy,
92    PartialEq,
93    Eq,
94    PartialOrd,
95    Ord,
96    Hash,
97    serde::Serialize,
98    serde::Deserialize,
99    specta::Type,
100)]
101#[serde(rename_all = "snake_case")]
102pub enum AttemptState {
103    Queued,
104    Leased,
105    Starting,
106    Running,
107    Blocked,
108    Canceling,
109    Canceled,
110    Failed,
111    Unknown,
112    Succeeded,
113}
114
115impl StateMachine for AttemptState {
116    const STATES: &'static [Self] = &[
117        Self::Queued,
118        Self::Leased,
119        Self::Starting,
120        Self::Running,
121        Self::Blocked,
122        Self::Canceling,
123        Self::Canceled,
124        Self::Failed,
125        Self::Unknown,
126        Self::Succeeded,
127    ];
128    const EDGES: &'static [(Self, Self)] = edges![AttemptState:
129        Queued -> Leased,
130        Queued -> Canceled,
131        Leased -> Starting,
132        Leased -> Canceled,
133        Starting -> Running,
134        Starting -> Failed,
135        Starting -> Unknown,
136        Starting -> Canceling,
137        // running <-> blocked is a receipted CAS flip with exactly ONE legal
138        // writer, the liveness producer (enforced in `transition::apply`).
139        Running -> Blocked,
140        Running -> Canceling,
141        Running -> Failed,
142        Running -> Unknown,
143        Running -> Succeeded,
144        Blocked -> Running,
145        Blocked -> Canceling,
146        Blocked -> Failed,
147        Blocked -> Unknown,
148        Canceling -> Canceled,
149        Canceling -> Unknown,
150    ];
151    const ESCAPE: &'static [Self] = &[Self::Canceling, Self::Canceled, Self::Failed, Self::Unknown];
152}
153
154/// Governed inter-party message delivery.
155#[derive(
156    Debug,
157    Clone,
158    Copy,
159    PartialEq,
160    Eq,
161    PartialOrd,
162    Ord,
163    Hash,
164    serde::Serialize,
165    serde::Deserialize,
166    specta::Type,
167)]
168#[serde(rename_all = "snake_case")]
169pub enum MessageState {
170    Accepted,
171    Delivered,
172    Acknowledged,
173    Applied,
174    Rejected,
175    DeadLetter,
176}
177
178impl StateMachine for MessageState {
179    const STATES: &'static [Self] = &[
180        Self::Accepted,
181        Self::Delivered,
182        Self::Acknowledged,
183        Self::Applied,
184        Self::Rejected,
185        Self::DeadLetter,
186    ];
187    const EDGES: &'static [(Self, Self)] = edges![MessageState:
188        Accepted -> Delivered,
189        Accepted -> DeadLetter,
190        Delivered -> Acknowledged,
191        Delivered -> DeadLetter,
192        Acknowledged -> Applied,
193        Acknowledged -> Rejected,
194        Acknowledged -> DeadLetter,
195    ];
196    const ESCAPE: &'static [Self] = &[Self::DeadLetter];
197}
198
199/// A stop/kill command's progress spine. The terminal claims only that
200/// verification RAN — the result lives in [`Outcome`], written atomically with
201/// the terminal transition.
202#[derive(
203    Debug,
204    Clone,
205    Copy,
206    PartialEq,
207    Eq,
208    PartialOrd,
209    Ord,
210    Hash,
211    serde::Serialize,
212    serde::Deserialize,
213    specta::Type,
214)]
215#[serde(rename_all = "snake_case")]
216pub enum CommandState {
217    Issued,
218    Targeted,
219    Signaled,
220    VerificationComplete,
221}
222
223impl StateMachine for CommandState {
224    const STATES: &'static [Self] = &[
225        Self::Issued,
226        Self::Targeted,
227        Self::Signaled,
228        Self::VerificationComplete,
229    ];
230    const EDGES: &'static [(Self, Self)] = edges![CommandState:
231        Issued -> Targeted,
232        Targeted -> Signaled,
233        Signaled -> VerificationComplete,
234    ];
235    // Plain totality: a stop sweep legitimately drives the spine forward, so
236    // there is no separate escape set (seam-10).
237    const ESCAPE: &'static [Self] = &[];
238}
239
240/// A command's verified result — a plain column, NOT an FSM state. Present iff
241/// the command reached `verification_complete`; written in the same transaction.
242#[derive(
243    Debug,
244    Clone,
245    Copy,
246    PartialEq,
247    Eq,
248    PartialOrd,
249    Ord,
250    Hash,
251    serde::Serialize,
252    serde::Deserialize,
253    specta::Type,
254)]
255#[serde(rename_all = "snake_case")]
256pub enum Outcome {
257    Clean,
258    Partial,
259    Unknown,
260}
261
262/// A gate's verdict — a CLOSED set (the gate `kind` is open).
263#[derive(
264    Debug,
265    Clone,
266    Copy,
267    PartialEq,
268    Eq,
269    PartialOrd,
270    Ord,
271    Hash,
272    serde::Serialize,
273    serde::Deserialize,
274    specta::Type,
275)]
276#[serde(rename_all = "snake_case")]
277pub enum GateVerdict {
278    Pending,
279    Pass,
280    Fail,
281    Partial,
282}
283
284/// A lease's status set (not one of the four public FSMs — expiry is
285/// time-driven, release is holder-driven).
286#[derive(
287    Debug,
288    Clone,
289    Copy,
290    PartialEq,
291    Eq,
292    PartialOrd,
293    Ord,
294    Hash,
295    serde::Serialize,
296    serde::Deserialize,
297    specta::Type,
298)]
299#[serde(rename_all = "snake_case")]
300pub enum LeaseState {
301    Held,
302    Released,
303    Expired,
304}
305
306/// Lease sharing mode.
307#[derive(
308    Debug,
309    Clone,
310    Copy,
311    PartialEq,
312    Eq,
313    PartialOrd,
314    Ord,
315    Hash,
316    serde::Serialize,
317    serde::Deserialize,
318    specta::Type,
319)]
320#[serde(rename_all = "snake_case")]
321pub enum LeaseMode {
322    Exclusive,
323    Shared,
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn terminality_is_derived_from_edges() {
332        assert!(TaskState::Completed.is_terminal());
333        assert!(!TaskState::Submitted.is_terminal());
334        assert!(AttemptState::Unknown.is_terminal());
335        assert!(!AttemptState::Canceling.is_terminal());
336        assert!(MessageState::DeadLetter.is_terminal());
337        assert!(CommandState::VerificationComplete.is_terminal());
338    }
339
340    #[test]
341    fn wire_values_are_snake_case() {
342        assert_eq!(
343            serde_json::to_value(TaskState::InputRequired).expect("serialize"),
344            serde_json::json!("input_required")
345        );
346        assert_eq!(
347            serde_json::to_value(CommandState::VerificationComplete).expect("serialize"),
348            serde_json::json!("verification_complete")
349        );
350        assert_eq!(
351            serde_json::to_value(MessageState::DeadLetter).expect("serialize"),
352            serde_json::json!("dead_letter")
353        );
354    }
355}