Skip to main content

majordomus_cli/session/
state.rs

1//! Where an episode is in its life, and the transitions that move it there.
2//!
3//! # Why this is a type and not a convention
4//!
5//! The lifecycle used to be expressed as the presence and absence of files: an episode is
6//! open because `state/sessions-open/<key>.yaml` exists, and closed because it does not and
7//! a record does. That representation has no state it refuses to be in. It cannot say that
8//! a closed episode may not be closed again — which is how one episode in this repository
9//! came to have four immutable records — and it cannot say that a transition is legal
10//! without also performing it, which is how every check of the form "is this allowed" ended
11//! up written separately at each call site, in shell, three times, with the task guard ADR
12//! 0052 removed embedded in one of them.
13//!
14//! So the transitions are the contract and [`EpisodeState::may_move_to`] is the one place
15//! they are written down: the store asks before every change, and a caller that reads a
16//! terminal state never sees it move again. The same shape as
17//! [`crate::execution::ExecutionState`], for the same reason and deliberately in the same
18//! style — a reader who has met one has met the other.
19//!
20//! ```text
21//!   Opening ──► Open ──┬──► Detached ──┬──► Open        (resume)
22//!                      │               └──► Closed      (close, or recover)
23//!                      ├──► Open                        (checkpoint, repeated start: no move)
24//!                      └──► Closed                      (close, or recover)
25//! ```
26//!
27//! # What is not in the signature
28//!
29//! There is no task. [`EpisodeState::may_move_to`] takes a target state and nothing else;
30//! [`Transition`] carries no task field. This is the whole of ADR 0052 expressed as a type:
31//! the defect it removed was `outcome != active -> skip` on the path that writes an
32//! episode's artefacts, and in this model there is nowhere to put it. A task's outcome may
33//! change what a continuation record *says* — that belongs to the record's composition, not
34//! to the state machine — and it may never decide whether one is written.
35//!
36//! ```
37//! use majordomus_cli::session::{EpisodeState, Transition};
38//!
39//! // the whole machine, asked rather than drawn
40//! assert!(EpisodeState::Open.may_move_to(EpisodeState::Closed));
41//! assert!(!EpisodeState::Closed.may_move_to(EpisodeState::Open));
42//! assert_eq!(Transition::Recover.from_to(), (EpisodeState::Detached, EpisodeState::Closed));
43//! ```
44
45use schemars::JsonSchema;
46use serde::{Deserialize, Serialize};
47
48/// Where an episode is: minted, open, detached from the connection that held it, or closed
49/// into the immutable record.
50///
51/// ```
52/// use majordomus_cli::session::EpisodeState;
53///
54/// assert!(EpisodeState::Open.may_move_to(EpisodeState::Closed));
55/// assert!(!EpisodeState::Closed.may_move_to(EpisodeState::Open), "a record is immutable");
56/// assert!(EpisodeState::Open.may_move_to(EpisodeState::Open), "a resume is not a move");
57/// assert!(EpisodeState::Closed.is_final());
58/// ```
59#[derive(
60    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
61)]
62#[serde(rename_all = "snake_case")]
63pub enum EpisodeState {
64    /// An identity has been minted and nothing durable has been written yet. It exists so
65    /// that a crash between minting an id and writing the open record is a state the model
66    /// can name rather than an episode that never happened.
67    Opening,
68    /// The open record stands. The worker is here, or was recently.
69    Open,
70    /// The connection that held the episode went away, and the episode did not. A dropped
71    /// socket is the most ordinary thing that happens to a session; an episode that closed
72    /// itself on every disconnect would lose the work of every worker whose network
73    /// hiccuped. It is still resumable, and it is still recoverable if nobody resumes it.
74    Detached,
75    /// The immutable record has been published. Terminal, and terminal is the point: the
76    /// four records of `s-20260909152316-024f` all exist because nothing in the previous
77    /// representation could say this.
78    Closed,
79}
80
81impl EpisodeState {
82    /// May an episode in this state move to `target`?
83    ///
84    /// The whole transition table, and the only copy of it. Note what the signature does
85    /// not take: there is no task, no outcome and no policy. A transition of an episode is
86    /// a fact about the episode.
87    ///
88    /// ```
89    /// use majordomus_cli::session::EpisodeState::*;
90    ///
91    /// assert!(Opening.may_move_to(Open));
92    /// assert!(Open.may_move_to(Detached) && Detached.may_move_to(Open));
93    /// assert!(Open.may_move_to(Closed) && Detached.may_move_to(Closed));
94    ///
95    /// // an episode that never opened cannot be closed into a record of what it did
96    /// assert!(!Opening.may_move_to(Closed));
97    /// // and nothing leaves the terminal state
98    /// for target in [Opening, Open, Detached, Closed] {
99    ///     assert!(!Closed.may_move_to(target));
100    /// }
101    /// ```
102    pub fn may_move_to(self, target: EpisodeState) -> bool {
103        use EpisodeState::*;
104        match (self, target) {
105            (Closed, _) => false,
106            (Opening, Open) => true,
107            (Opening, _) => false,
108            (Open, Open) | (Open, Detached) | (Open, Closed) => true,
109            (Open, Opening) => false,
110            (Detached, Open) | (Detached, Closed) => true,
111            (Detached, Opening) | (Detached, Detached) => false,
112        }
113    }
114
115    /// Is this a state an episode never leaves? True of `closed` alone, which is what a
116    /// store asks before it writes and a reader asks before it waits for a change.
117    ///
118    /// ```
119    /// use majordomus_cli::session::EpisodeState;
120    /// assert!(EpisodeState::Closed.is_final());
121    /// assert!(!EpisodeState::Detached.is_final(), "a detached episode can still be resumed");
122    /// ```
123    pub fn is_final(self) -> bool {
124        matches!(self, EpisodeState::Closed)
125    }
126
127    /// The word as serialised, which is the word every projection of the machine carries
128    /// and the word a person reads in `session status`.
129    ///
130    /// ```
131    /// use majordomus_cli::session::EpisodeState;
132    /// assert_eq!(EpisodeState::Detached.as_str(), "detached");
133    /// assert_eq!(EpisodeState::Closed.as_str(), "closed");
134    /// ```
135    pub fn as_str(self) -> &'static str {
136        match self {
137            EpisodeState::Opening => "opening",
138            EpisodeState::Open => "open",
139            EpisodeState::Detached => "detached",
140            EpisodeState::Closed => "closed",
141        }
142    }
143
144    /// Every state, in the order a reader meets them. The projection of the machine is
145    /// built from this rather than from a list written a second time somewhere else.
146    pub const ALL: [EpisodeState; 4] = [
147        EpisodeState::Opening,
148        EpisodeState::Open,
149        EpisodeState::Detached,
150        EpisodeState::Closed,
151    ];
152}
153
154/// What moved an episode. The vocabulary the ADR 0052 audit needs: `open`, `resume`,
155/// `checkpoint`, `detach`, `close`, `recover`.
156///
157/// One of them does not change the state, and it is a transition anyway. A checkpoint
158/// leaves an open episode open — but it is one of the two events whose *absence* is what
159/// the audit was looking for, and an event that is modelled as "nothing happened" cannot
160/// be counted. The six days of silence were six days in which `session.started` and
161/// `session.closed` kept arriving and the two events that produce the records a worker
162/// resumes from did not. A vocabulary in which those two are not events is a vocabulary in
163/// which that outage is invisible.
164///
165/// ```
166/// use majordomus_cli::session::{EpisodeState, Transition};
167///
168/// // a checkpoint is a transition that does not move the state, and says so
169/// let c = Transition::Checkpoint;
170/// assert_eq!(c.from_to(), (EpisodeState::Open, EpisodeState::Open));
171/// assert!(!c.moves_state());
172///
173/// // a resume is canonically the return to a detached episode; a provider's start event
174/// // arriving at one that is already open is the same transition seen from `Open`, which
175/// // the machine allows without a move
176/// assert_eq!(Transition::Resume.from_to(), (EpisodeState::Detached, EpisodeState::Open));
177/// assert!(EpisodeState::Open.may_move_to(EpisodeState::Open));
178///
179/// // and every transition's own move is one the machine allows
180/// for t in Transition::ALL {
181///     let (from, to) = t.from_to();
182///     assert!(from.may_move_to(to), "{} is not a legal move", t.as_str());
183/// }
184/// ```
185#[derive(
186    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
187)]
188#[serde(rename_all = "snake_case")]
189pub enum Transition {
190    /// The episode begins: an id is minted and the open record is written.
191    Open,
192    /// The worker came back to a detached episode. A provider's start event also fires
193    /// again on a resume and on a compaction against an episode that is still open; that
194    /// is the same transition observed from `Open`, and the honest answer to "your episode
195    /// is already open" is to keep it rather than to open a second one.
196    Resume,
197    /// A progress note was recorded against the episode. An artefact of the episode and
198    /// not of a task (ADR 0052): a checkpoint outside a task records `task: none` and is a
199    /// normal record, not a refusal.
200    Checkpoint,
201    /// The connection went away without an end event.
202    Detach,
203    /// The worker ended the episode and the immutable record was published.
204    Close,
205    /// Nobody ended it and its last sign of life is older than the stranded threshold, so
206    /// maintenance closed it. The record says so: an episode closed this way is
207    /// `interrupted`, because a reader resuming from it needs to know it may be incomplete.
208    Recover,
209}
210
211impl Transition {
212    /// The states this transition runs between: where an episode must be for it to apply,
213    /// and where it is afterwards.
214    ///
215    /// ```
216    /// use majordomus_cli::session::{EpisodeState, Transition};
217    /// assert_eq!(Transition::Close.from_to(), (EpisodeState::Open, EpisodeState::Closed));
218    /// assert_eq!(Transition::Detach.from_to(), (EpisodeState::Open, EpisodeState::Detached));
219    /// ```
220    pub fn from_to(self) -> (EpisodeState, EpisodeState) {
221        use EpisodeState as S;
222        match self {
223            Transition::Open => (S::Opening, S::Open),
224            Transition::Resume => (S::Detached, S::Open),
225            Transition::Checkpoint => (S::Open, S::Open),
226            Transition::Detach => (S::Open, S::Detached),
227            Transition::Close => (S::Open, S::Closed),
228            Transition::Recover => (S::Detached, S::Closed),
229        }
230    }
231
232    /// Does this transition change the state, or is it an event inside one? A checkpoint
233    /// is the second kind, and it is a transition anyway: an event modelled as "nothing
234    /// happened" cannot be counted, and counting it is how a stopped writer becomes visible.
235    ///
236    /// ```
237    /// use majordomus_cli::session::Transition;
238    /// assert!(!Transition::Checkpoint.moves_state());
239    /// assert!(Transition::Close.moves_state());
240    /// ```
241    pub fn moves_state(self) -> bool {
242        let (from, to) = self.from_to();
243        from != to
244    }
245
246    /// The word as serialised, which is what a projection of the machine carries and what
247    /// a ledger event's name is read against.
248    ///
249    /// ```
250    /// use majordomus_cli::session::Transition;
251    /// assert_eq!(Transition::Recover.as_str(), "recover");
252    /// assert_eq!(Transition::Checkpoint.as_str(), "checkpoint");
253    /// ```
254    pub fn as_str(self) -> &'static str {
255        match self {
256            Transition::Open => "open",
257            Transition::Resume => "resume",
258            Transition::Checkpoint => "checkpoint",
259            Transition::Detach => "detach",
260            Transition::Close => "close",
261            Transition::Recover => "recover",
262        }
263    }
264
265    /// Every transition, in lifecycle order.
266    pub const ALL: [Transition; 6] = [
267        Transition::Open,
268        Transition::Resume,
269        Transition::Checkpoint,
270        Transition::Detach,
271        Transition::Close,
272        Transition::Recover,
273    ];
274}
275
276/// The state machine as data, so that every surface projects the same machine instead of
277/// drawing its own.
278///
279/// ```
280/// use majordomus_cli::session::Machine;
281///
282/// let m = Machine::describe();
283/// assert!(m.states.iter().filter(|s| s.terminal).count() == 1);
284/// assert!(m.independent_of.iter().any(|note| note.contains("task")));
285/// ```
286///
287/// This is what `session.machine` answers. It exists because the same diagram is currently
288/// redrawn in `docs/CONTINUITY.md`, in `lib/session.sh`'s comments and in the Cockpit, and
289/// three drawings of one machine drift — the `lib/session.sh` one still had the task guard
290/// in it after ADR 0052 removed it from the code.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
292pub struct Machine {
293    /// Every state, with what it means and whether it is terminal.
294    pub states: Vec<StateView>,
295    /// Every transition, with the states it runs between.
296    pub transitions: Vec<TransitionView>,
297    /// What the machine deliberately does not depend on, stated so that a reader does not
298    /// have to infer an absence. One entry today: task state.
299    pub independent_of: Vec<String>,
300}
301
302/// One state of the machine: its word, whether an episode ever leaves it, and where it may
303/// go from there.
304///
305/// ```
306/// use majordomus_cli::session::{EpisodeState, Machine, StateView};
307///
308/// let m = Machine::describe();
309/// let closed: &StateView =
310///     m.states.iter().find(|s| s.state == EpisodeState::Closed).expect("a state");
311/// assert!(closed.terminal && closed.may_move_to.is_empty());
312/// ```
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
314pub struct StateView {
315    /// The state's word.
316    pub state: EpisodeState,
317    /// Whether an episode ever leaves it.
318    pub terminal: bool,
319    /// The states an episode in this one may move to, in canonical order.
320    pub may_move_to: Vec<EpisodeState>,
321}
322
323/// One transition of the machine: its word, the states it runs between, and whether it
324/// moves the state at all.
325///
326/// ```
327/// use majordomus_cli::session::{Machine, Transition, TransitionView};
328///
329/// let m = Machine::describe();
330/// let close: &TransitionView = m
331///     .transitions
332///     .iter()
333///     .find(|t| t.transition == Transition::Close)
334///     .expect("a transition");
335/// assert!(close.moves_state);
336/// ```
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
338pub struct TransitionView {
339    /// The transition's word.
340    pub transition: Transition,
341    /// Where it starts.
342    pub from: EpisodeState,
343    /// Where it ends.
344    pub to: EpisodeState,
345    /// False for `resume` and `checkpoint`, which are events inside a state.
346    pub moves_state: bool,
347}
348
349impl Machine {
350    /// The machine, derived from the two enums above and from nothing else.
351    ///
352    /// ```
353    /// use majordomus_cli::session::{EpisodeState, Machine, Transition};
354    ///
355    /// let m = Machine::describe();
356    /// assert_eq!(m.states.len(), EpisodeState::ALL.len());
357    /// assert_eq!(m.transitions.len(), Transition::ALL.len());
358    /// assert!(m.states.iter().filter(|s| s.terminal).count() == 1, "one terminal state");
359    /// assert!(m.independent_of.iter().any(|s| s.contains("task")));
360    /// ```
361    pub fn describe() -> Self {
362        let states = EpisodeState::ALL
363            .iter()
364            .map(|state| StateView {
365                state: *state,
366                terminal: state.is_final(),
367                may_move_to: EpisodeState::ALL
368                    .iter()
369                    .copied()
370                    .filter(|t| state.may_move_to(*t))
371                    .collect(),
372            })
373            .collect();
374        let transitions = Transition::ALL
375            .iter()
376            .map(|t| {
377                let (from, to) = t.from_to();
378                TransitionView {
379                    transition: *t,
380                    from,
381                    to,
382                    moves_state: t.moves_state(),
383                }
384            })
385            .collect();
386        Machine {
387            states,
388            transitions,
389            independent_of: vec![
390                "task state: an episode's lifecycle does not depend on a task's outcome, \
391                 and may_move_to takes no task (ADR 0052)"
392                    .to_string(),
393            ],
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn the_terminal_state_is_terminal_from_everywhere() {
404        for target in EpisodeState::ALL {
405            assert!(
406                !EpisodeState::Closed.may_move_to(target),
407                "closed -> {} is reachable",
408                target.as_str()
409            );
410        }
411        assert_eq!(
412            EpisodeState::ALL
413                .iter()
414                .filter(|s| s.is_final())
415                .collect::<Vec<_>>()
416                .len(),
417            1
418        );
419    }
420
421    #[test]
422    fn every_state_except_the_terminal_one_reaches_closed() {
423        // The property that matters for recovery: an episode nobody ended must always be
424        // closable. `Opening` reaches it through `Open`.
425        assert!(EpisodeState::Open.may_move_to(EpisodeState::Closed));
426        assert!(EpisodeState::Detached.may_move_to(EpisodeState::Closed));
427        assert!(EpisodeState::Opening.may_move_to(EpisodeState::Open));
428    }
429
430    #[test]
431    fn the_machine_is_derived_and_carries_no_second_list() {
432        let m = Machine::describe();
433        let words: Vec<&str> = m.states.iter().map(|s| s.state.as_str()).collect();
434        assert_eq!(words, ["opening", "open", "detached", "closed"]);
435        for view in &m.states {
436            assert_eq!(view.terminal, view.may_move_to.is_empty());
437        }
438    }
439
440    #[test]
441    fn a_resume_and_a_checkpoint_are_events_and_not_moves() {
442        assert!(!Transition::Checkpoint.moves_state());
443        assert!(
444            Transition::Resume.moves_state(),
445            "from detached it is a move"
446        );
447        assert!(Transition::Close.moves_state());
448        assert!(Transition::Recover.moves_state());
449    }
450}