Skip to main content

gatehouse_runtime_protocol/lifecycle/
state.rs

1//! Pure lease transitions, not a journal or evidence verifier.
2//! The caller must validate evidence and persist before dispatching effects.
3use crate::{digest, Invalid};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "kebab-case")]
8pub enum Phase {
9    PendingCreate,
10    UnresolvedNoId,
11    KnownCreated,
12    Starting,
13    Running,
14    CheckingHealth,
15    Retained,
16    Stopping,
17    Stopped,
18    Removing,
19    AwaitingAbsence,
20    ReconcilingRemove,
21    VerifiedAbsent,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct State {
27    phase: Phase,
28    object_id: Option<String>,
29}
30
31/// Events describe already verified facts or an intent to persist. They are not
32/// deserializable adapter messages and do not themselves authenticate evidence.
33#[derive(Debug)]
34pub enum Event {
35    Created(String),
36    BeginStart,
37    RunningVerified,
38    BeginHealth,
39    HealthVerified,
40    Failed,
41    CleanupRunningVerified,
42    CleanupTerminalVerified,
43    StoppedVerified,
44    BeginRemove,
45    RemoveClaimed,
46    AbsenceVerified,
47    OperatorCleanupRunningVerified,
48    OperatorCleanupTerminalVerified,
49    OperatorAbsenceVerified,
50}
51
52impl State {
53    pub fn pending_create() -> Self {
54        Self {
55            phase: Phase::PendingCreate,
56            object_id: None,
57        }
58    }
59
60    pub fn phase(&self) -> Phase {
61        self.phase
62    }
63    pub fn object_id(&self) -> Option<&str> {
64        self.object_id.as_deref()
65    }
66
67    pub fn validate(&self) -> Result<(), Invalid> {
68        match (&self.phase, &self.object_id) {
69            (Phase::PendingCreate | Phase::UnresolvedNoId, None) => Ok(()),
70            (Phase::PendingCreate | Phase::UnresolvedNoId, Some(_)) | (_, None) => {
71                Err(Invalid("invalid lifecycle state identity"))
72            }
73            (_, Some(id)) => digest(id),
74        }
75    }
76
77    pub fn blocks_acquisition(&self) -> bool {
78        self.validate().is_err() || self.phase != Phase::VerifiedAbsent
79    }
80
81    /// Recovery never replays an operation and never infers an unknown ID.
82    pub fn recover(&self) -> Result<Self, Invalid> {
83        self.validate()?;
84        let mut next = self.clone();
85        next.phase = match self.phase {
86            Phase::PendingCreate | Phase::UnresolvedNoId => Phase::UnresolvedNoId,
87            Phase::Removing | Phase::AwaitingAbsence | Phase::ReconcilingRemove => {
88                Phase::ReconcilingRemove
89            }
90            Phase::VerifiedAbsent => Phase::VerifiedAbsent,
91            _ => Phase::Retained,
92        };
93        Ok(next)
94    }
95
96    pub fn transition(&self, event: Event) -> Result<Self, Invalid> {
97        self.validate()?;
98        let mut next = self.clone();
99        next.phase = match (self.phase, event) {
100            (Phase::PendingCreate, Event::Created(id)) => {
101                digest(&id)?;
102                next.object_id = Some(id);
103                Phase::KnownCreated
104            }
105            (Phase::PendingCreate, Event::Failed) => Phase::UnresolvedNoId,
106            (Phase::KnownCreated, Event::BeginStart) => Phase::Starting,
107            (Phase::Starting, Event::RunningVerified) => Phase::Running,
108            (Phase::Running, Event::BeginHealth) => Phase::CheckingHealth,
109            (Phase::CheckingHealth, Event::HealthVerified) => Phase::Running,
110            (
111                Phase::KnownCreated | Phase::Running | Phase::Retained,
112                Event::CleanupRunningVerified,
113            ) => Phase::Stopping,
114            (
115                Phase::KnownCreated | Phase::Running | Phase::Retained,
116                Event::CleanupTerminalVerified,
117            ) => Phase::Stopped,
118            (Phase::ReconcilingRemove, Event::OperatorCleanupRunningVerified) => Phase::Stopping,
119            (Phase::ReconcilingRemove, Event::OperatorCleanupTerminalVerified) => Phase::Stopped,
120            (Phase::Retained, Event::OperatorAbsenceVerified) => Phase::VerifiedAbsent,
121            (Phase::Stopping, Event::StoppedVerified) => Phase::Stopped,
122            (Phase::Stopped, Event::BeginRemove) => Phase::Removing,
123            (Phase::Removing, Event::RemoveClaimed) => Phase::AwaitingAbsence,
124            (Phase::AwaitingAbsence | Phase::ReconcilingRemove, Event::AbsenceVerified) => {
125                Phase::VerifiedAbsent
126            }
127            (
128                Phase::Removing | Phase::AwaitingAbsence | Phase::ReconcilingRemove,
129                Event::Failed,
130            ) => Phase::ReconcilingRemove,
131            (
132                Phase::KnownCreated
133                | Phase::Starting
134                | Phase::Running
135                | Phase::CheckingHealth
136                | Phase::Retained
137                | Phase::Stopping
138                | Phase::Stopped,
139                Event::Failed,
140            ) => Phase::Retained,
141            _ => return Err(Invalid("invalid lifecycle transition")),
142        };
143        next.validate()?;
144        Ok(next)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    fn created() -> State {
152        State::pending_create()
153            .transition(Event::Created("a".repeat(64)))
154            .unwrap()
155    }
156
157    #[test]
158    fn lost_create_response_blocks_reacquisition_and_replay() {
159        let state = State::pending_create().recover().unwrap();
160        assert_eq!(state.phase(), Phase::UnresolvedNoId);
161        assert!(state.blocks_acquisition());
162        assert!(state.transition(Event::Created("b".repeat(64))).is_err());
163        assert!(state.transition(Event::BeginStart).is_err());
164        assert!(state.transition(Event::AbsenceVerified).is_err());
165    }
166
167    #[test]
168    fn cleanup_requires_stop_and_independent_absence() {
169        let running = created()
170            .transition(Event::BeginStart)
171            .unwrap()
172            .transition(Event::RunningVerified)
173            .unwrap();
174        assert!(running.transition(Event::BeginRemove).is_err());
175        let removed = running
176            .transition(Event::CleanupRunningVerified)
177            .unwrap()
178            .transition(Event::StoppedVerified)
179            .unwrap()
180            .transition(Event::BeginRemove)
181            .unwrap()
182            .transition(Event::RemoveClaimed)
183            .unwrap();
184        assert!(removed.blocks_acquisition());
185        let absent = removed.transition(Event::AbsenceVerified).unwrap();
186        assert!(!absent.blocks_acquisition());
187        assert!(absent.transition(Event::BeginStart).is_err());
188    }
189
190    #[test]
191    fn crash_with_known_id_retains_identity_and_cannot_restart() {
192        for phase in [
193            Phase::KnownCreated,
194            Phase::Starting,
195            Phase::Running,
196            Phase::CheckingHealth,
197            Phase::Stopping,
198            Phase::Stopped,
199        ] {
200            let state = State {
201                phase,
202                object_id: Some("c".repeat(64)),
203            };
204            let recovered = state.recover().unwrap();
205            assert_eq!(recovered.phase(), Phase::Retained);
206            assert_eq!(recovered.object_id(), state.object_id());
207            assert!(recovered.blocks_acquisition());
208            assert!(recovered.transition(Event::BeginStart).is_err());
209            assert!(recovered.transition(Event::AbsenceVerified).is_err());
210        }
211    }
212
213    #[test]
214    fn corrupt_state_and_partial_ids_never_unlock_or_transition() {
215        for state in [
216            State {
217                phase: Phase::VerifiedAbsent,
218                object_id: None,
219            },
220            State {
221                phase: Phase::PendingCreate,
222                object_id: Some("d".repeat(64)),
223            },
224            State {
225                phase: Phase::Running,
226                object_id: Some("short".into()),
227            },
228        ] {
229            assert!(state.blocks_acquisition());
230            assert!(state.recover().is_err());
231            assert!(state.transition(Event::BeginRemove).is_err());
232        }
233        assert!(State::pending_create()
234            .transition(Event::Created("abc".into()))
235            .is_err());
236    }
237
238    #[test]
239    fn failed_health_retains_id_without_cleanup_or_retry() {
240        let state = created()
241            .transition(Event::BeginStart)
242            .unwrap()
243            .transition(Event::RunningVerified)
244            .unwrap()
245            .transition(Event::BeginHealth)
246            .unwrap()
247            .transition(Event::Failed)
248            .unwrap();
249        assert_eq!(state.phase(), Phase::Retained);
250        assert!(state.object_id().is_some());
251        assert!(state.transition(Event::BeginHealth).is_err());
252        assert!(state.transition(Event::BeginRemove).is_err());
253    }
254    #[test]
255    fn every_post_remove_crash_window_reconciles_without_second_remove() {
256        // Removing is durable before dispatch, so it also covers a crash before
257        // sending and a lost HTTP response. AwaitingAbsence covers response and
258        // inspection windows; neither recovery may replay the mutation.
259        for phase in [
260            Phase::Removing,
261            Phase::AwaitingAbsence,
262            Phase::ReconcilingRemove,
263        ] {
264            let state = State {
265                phase,
266                object_id: Some("d".repeat(64)),
267            };
268            let recovered = state.recover().unwrap();
269            assert_eq!(recovered.phase(), Phase::ReconcilingRemove);
270            assert_eq!(recovered.object_id(), state.object_id());
271            assert!(recovered.blocks_acquisition());
272            assert!(recovered.transition(Event::BeginRemove).is_err());
273            assert!(recovered.transition(Event::RemoveClaimed).is_err());
274            assert!(recovered.transition(Event::RunningVerified).is_err());
275            assert!(recovered
276                .transition(Event::CleanupTerminalVerified)
277                .is_err());
278            let failed = recovered.transition(Event::Failed).unwrap();
279            assert_eq!(failed.phase(), Phase::ReconcilingRemove);
280            let absent = failed.transition(Event::AbsenceVerified).unwrap();
281            assert!(!absent.blocks_acquisition());
282            assert_eq!(absent.recover().unwrap(), absent);
283        }
284    }
285    #[test]
286    fn present_reconciliation_requires_new_operator_authorization() {
287        for phase in [Phase::Removing, Phase::AwaitingAbsence] {
288            let state = State {
289                phase,
290                object_id: Some("e".repeat(64)),
291            }
292            .recover()
293            .unwrap();
294            assert!(state.transition(Event::BeginRemove).is_err());
295            assert!(state.transition(Event::CleanupRunningVerified).is_err());
296            let stopped = state
297                .transition(Event::OperatorCleanupRunningVerified)
298                .unwrap()
299                .transition(Event::StoppedVerified)
300                .unwrap();
301            let absent = stopped
302                .transition(Event::BeginRemove)
303                .unwrap()
304                .transition(Event::RemoveClaimed)
305                .unwrap()
306                .transition(Event::AbsenceVerified)
307                .unwrap();
308            assert!(!absent.blocks_acquisition());
309            assert_eq!(absent.object_id(), state.object_id());
310            let terminal = state
311                .transition(Event::OperatorCleanupTerminalVerified)
312                .unwrap();
313            assert_eq!(terminal.phase(), Phase::Stopped);
314        }
315    }
316
317    #[test]
318    fn retained_absence_needs_operator_evidence_and_never_a_remove_claim() {
319        let retained = created().recover().unwrap();
320        assert!(retained.transition(Event::AbsenceVerified).is_err());
321        assert!(retained.transition(Event::RemoveClaimed).is_err());
322        let absent = retained.transition(Event::OperatorAbsenceVerified).unwrap();
323        assert!(!absent.blocks_acquisition());
324        assert!(State::pending_create()
325            .transition(Event::OperatorAbsenceVerified)
326            .is_err());
327        assert!(State::pending_create()
328            .recover()
329            .unwrap()
330            .transition(Event::OperatorAbsenceVerified)
331            .is_err());
332    }
333}