nixfleet_state_machine/rollout/transitions/
reverted.rs

1//! `Reverted` source state. Some host fired `rollback-and-halt`;
2//! rollout is past the point where new hosts dispatch. Exits via
3//! `SuccessorOpened`, `RetentionExpired`, or `OperatorClearance`.
4//!
5//! `OperatorClearance` wiring out of scope for Phase 10 per brief ยง9.3;
6//! the variant returns `Unimplemented` here so a future wiring step
7//! must explicitly address the semantic.
8
9use chrono::{DateTime, Utc};
10
11use super::{illegal, transition_effect};
12use crate::rollout::effect::RolloutEffect;
13use crate::rollout::error::RolloutTransitionError;
14use crate::rollout::event::RolloutEvent;
15use crate::rollout::state::{RolloutRecord, RolloutState};
16
17pub(super) fn step(
18    mut record: RolloutRecord,
19    event: RolloutEvent,
20    _now: DateTime<Utc>,
21) -> Result<(RolloutRecord, Vec<RolloutEffect>), RolloutTransitionError> {
22    match event {
23        RolloutEvent::SuccessorOpened { at, .. } => {
24            record.state = RolloutState::Superseded;
25            record.superseded_at = Some(at);
26            let effects = vec![transition_effect(
27                &record,
28                RolloutState::Reverted,
29                RolloutState::Superseded,
30                at,
31            )];
32            Ok((record, effects))
33        }
34        RolloutEvent::RetentionExpired { at, .. } => {
35            record.state = RolloutState::Pruned;
36            let effects = vec![transition_effect(
37                &record,
38                RolloutState::Reverted,
39                RolloutState::Pruned,
40                at,
41            )];
42            Ok((record, effects))
43        }
44        RolloutEvent::OperatorClearance { .. } => Err(RolloutTransitionError::Unimplemented {
45            from: RolloutState::Reverted,
46            event: event.kind(),
47        }),
48        // Late host events are noise from a state that's already
49        // terminal-for-ordering.
50        RolloutEvent::HostStateChanged { .. }
51        | RolloutEvent::HostJoined { .. }
52        | RolloutEvent::WaveAdvanced { .. }
53        | RolloutEvent::RolloutTerminal { .. } => Ok((record, Vec::new())),
54        RolloutEvent::RolloutOpened { .. } => Err(illegal(
55            RolloutState::Reverted,
56            &event,
57            record.rollout_id.clone(),
58        )),
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use chrono::TimeZone;
66
67    fn t0() -> DateTime<Utc> {
68        Utc.with_ymd_and_hms(2026, 5, 16, 1, 0, 0).unwrap()
69    }
70
71    fn reverted_record() -> RolloutRecord {
72        RolloutRecord {
73            rollout_id: "r1".into(),
74            channel: "stable".into(),
75            target_ref: "ref-1".into(),
76            state: RolloutState::Reverted,
77            current_wave: 0,
78            opened_event_log_seq: None,
79            last_transition_event_log_seq: None,
80            opened_at: t0(),
81            terminal_at: None,
82            superseded_at: None,
83        }
84    }
85
86    #[test]
87    fn successor_opened_transitions_to_superseded() {
88        let event = RolloutEvent::SuccessorOpened {
89            superseded_rollout_id: "r1".into(),
90            successor_rollout_id: "r2".into(),
91            at: t0(),
92        };
93        let (record, _) = step(reverted_record(), event, t0()).unwrap();
94        assert_eq!(record.state, RolloutState::Superseded);
95    }
96
97    #[test]
98    fn retention_expired_transitions_to_pruned() {
99        let event = RolloutEvent::RetentionExpired {
100            rollout_id: "r1".into(),
101            at: t0(),
102        };
103        let (record, _) = step(reverted_record(), event, t0()).unwrap();
104        assert_eq!(record.state, RolloutState::Pruned);
105    }
106}