Skip to main content

bones_core/crdt/
state.rs

1//! Epoch+Phase state CRDT for work item lifecycle.
2//!
3//! The lifecycle state (Open/Doing/Done/Archived) uses a non-standard
4//! (epoch, phase) CRDT that handles concurrent close/reopen correctly:
5//! if agent A closes an item while agent B reopens it, the higher epoch wins.
6//!
7//! # Phase Ranking
8//!
9//! Phases have a total ordering:
10//!   Open(0) < Doing(1) < Done(2) < Archived(3)
11//!
12//! # Merge Rules
13//!
14//! Given two `EpochPhaseState` values `a` and `b`:
15//!   1. If `a.epoch != b.epoch`: the one with higher epoch wins entirely.
16//!   2. If `a.epoch == b.epoch`: the one with higher phase rank wins.
17//!
18//! This satisfies the semilattice laws (commutative, associative, idempotent).
19//!
20//! # Reopen Semantics
21//!
22//! To reopen an item, increment the epoch and set phase to Open.
23//! This guarantees the reopen wins against any concurrent operations
24//! in the previous epoch.
25
26use serde::{Deserialize, Serialize};
27use std::fmt;
28
29// ---------------------------------------------------------------------------
30// Phase enum
31// ---------------------------------------------------------------------------
32
33/// Work item lifecycle phase with total ordering by rank.
34///
35/// The discriminant values define the rank for merge comparison.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38#[repr(u8)]
39pub enum Phase {
40    Open = 0,
41    Doing = 1,
42    Done = 2,
43    Archived = 3,
44}
45
46impl Phase {
47    /// Return the numeric rank of this phase.
48    #[must_use]
49    pub const fn rank(self) -> u8 {
50        self as u8
51    }
52
53    /// Return the phase name as a string slice.
54    #[must_use]
55    pub const fn as_str(self) -> &'static str {
56        match self {
57            Self::Open => "open",
58            Self::Doing => "doing",
59            Self::Done => "done",
60            Self::Archived => "archived",
61        }
62    }
63
64    /// All phases in rank order.
65    pub const ALL: [Self; 4] = [Self::Open, Self::Doing, Self::Done, Self::Archived];
66}
67
68impl PartialOrd for Phase {
69    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
70        Some(self.cmp(other))
71    }
72}
73
74impl Ord for Phase {
75    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
76        self.rank().cmp(&other.rank())
77    }
78}
79
80impl fmt::Display for Phase {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86impl std::str::FromStr for Phase {
87    type Err = String;
88
89    fn from_str(s: &str) -> Result<Self, Self::Err> {
90        match s {
91            "open" => Ok(Self::Open),
92            "doing" => Ok(Self::Doing),
93            "done" => Ok(Self::Done),
94            "archived" => Ok(Self::Archived),
95            _ => Err(format!("unknown phase: {s}")),
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// EpochPhaseState
102// ---------------------------------------------------------------------------
103
104/// CRDT state combining an epoch counter with a lifecycle phase.
105///
106/// The epoch increases on reopen operations, ensuring that reopens
107/// always win against concurrent operations in earlier epochs.
108#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
109pub struct EpochPhaseState {
110    /// Monotonically increasing epoch counter. Incremented on reopen.
111    pub epoch: u64,
112    /// Current lifecycle phase within this epoch.
113    pub phase: Phase,
114}
115
116impl EpochPhaseState {
117    /// Create a new state at epoch 0, phase Open.
118    #[must_use]
119    pub const fn new() -> Self {
120        Self {
121            epoch: 0,
122            phase: Phase::Open,
123        }
124    }
125
126    /// Create a state with specific epoch and phase.
127    #[must_use]
128    pub const fn with(epoch: u64, phase: Phase) -> Self {
129        Self { epoch, phase }
130    }
131
132    /// Advance to a new phase within the current epoch.
133    ///
134    /// Returns `Err` if the target phase has a lower rank than the current
135    /// phase (within the same epoch, phases only move forward).
136    ///
137    /// # Errors
138    ///
139    /// Returns [`StateError::InvalidTransition`] if the target phase has
140    /// a rank equal to or lower than the current phase.
141    pub fn advance(&mut self, target: Phase) -> Result<(), StateError> {
142        if target <= self.phase {
143            return Err(StateError::InvalidTransition {
144                from: self.phase,
145                to: target,
146                epoch: self.epoch,
147            });
148        }
149        self.phase = target;
150        Ok(())
151    }
152
153    /// Reopen the item: increment epoch and set phase to Open.
154    ///
155    /// This is valid from any phase. The epoch increment ensures
156    /// this reopen wins against concurrent operations in the old epoch.
157    pub const fn reopen(&mut self) {
158        self.epoch += 1;
159        self.phase = Phase::Open;
160    }
161
162    /// Merge two states, producing the semilattice join (least upper bound).
163    ///
164    /// Rules:
165    /// 1. Higher epoch wins entirely.
166    /// 2. Same epoch: higher phase rank wins.
167    pub fn merge(&mut self, other: &Self) {
168        match self.epoch.cmp(&other.epoch) {
169            std::cmp::Ordering::Less => {
170                // Other has higher epoch — take it entirely
171                self.epoch = other.epoch;
172                self.phase = other.phase;
173            }
174            std::cmp::Ordering::Equal => {
175                // Same epoch — take higher phase
176                if other.phase > self.phase {
177                    self.phase = other.phase;
178                }
179            }
180            std::cmp::Ordering::Greater => {
181                // We have higher epoch — keep ours
182            }
183        }
184    }
185}
186
187impl Default for EpochPhaseState {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193impl fmt::Display for EpochPhaseState {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        write!(f, "epoch={} phase={}", self.epoch, self.phase)
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Error type
201// ---------------------------------------------------------------------------
202
203/// Error for invalid state transitions.
204#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
205pub enum StateError {
206    #[error("invalid transition from {from} to {to} in epoch {epoch}")]
207    InvalidTransition { from: Phase, to: Phase, epoch: u64 },
208}
209
210// ---------------------------------------------------------------------------
211// Tests
212// ---------------------------------------------------------------------------
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    // === Phase ordering ===
219
220    #[test]
221    fn phase_ranking() {
222        assert!(Phase::Open < Phase::Doing);
223        assert!(Phase::Doing < Phase::Done);
224        assert!(Phase::Done < Phase::Archived);
225    }
226
227    #[test]
228    fn phase_rank_values() {
229        assert_eq!(Phase::Open.rank(), 0);
230        assert_eq!(Phase::Doing.rank(), 1);
231        assert_eq!(Phase::Done.rank(), 2);
232        assert_eq!(Phase::Archived.rank(), 3);
233    }
234
235    #[test]
236    fn phase_display_and_parse() {
237        for phase in Phase::ALL {
238            let s = phase.to_string();
239            let parsed: Phase = s.parse().unwrap();
240            assert_eq!(phase, parsed);
241        }
242    }
243
244    // === EpochPhaseState basics ===
245
246    #[test]
247    fn new_state_is_epoch_0_open() {
248        let s = EpochPhaseState::new();
249        assert_eq!(s.epoch, 0);
250        assert_eq!(s.phase, Phase::Open);
251    }
252
253    #[test]
254    fn advance_forward() {
255        let mut s = EpochPhaseState::new();
256        s.advance(Phase::Doing).unwrap();
257        assert_eq!(s.phase, Phase::Doing);
258        s.advance(Phase::Done).unwrap();
259        assert_eq!(s.phase, Phase::Done);
260        s.advance(Phase::Archived).unwrap();
261        assert_eq!(s.phase, Phase::Archived);
262    }
263
264    #[test]
265    fn advance_backward_fails() {
266        let mut s = EpochPhaseState::with(0, Phase::Done);
267        let err = s.advance(Phase::Doing).unwrap_err();
268        assert!(matches!(err, StateError::InvalidTransition { .. }));
269    }
270
271    #[test]
272    fn advance_same_phase_fails() {
273        let mut s = EpochPhaseState::with(0, Phase::Doing);
274        let err = s.advance(Phase::Doing).unwrap_err();
275        assert!(matches!(err, StateError::InvalidTransition { .. }));
276    }
277
278    // === Reopen ===
279
280    #[test]
281    fn reopen_increments_epoch() {
282        let mut s = EpochPhaseState::with(0, Phase::Done);
283        s.reopen();
284        assert_eq!(s.epoch, 1);
285        assert_eq!(s.phase, Phase::Open);
286    }
287
288    #[test]
289    fn reopen_from_archived() {
290        let mut s = EpochPhaseState::with(0, Phase::Archived);
291        s.reopen();
292        assert_eq!(s.epoch, 1);
293        assert_eq!(s.phase, Phase::Open);
294    }
295
296    #[test]
297    fn multiple_reopens_monotonic_epochs() {
298        let mut s = EpochPhaseState::new();
299        for expected_epoch in 1..=5 {
300            s.advance(Phase::Done).unwrap_or(()); // may fail if already past Done
301            s.reopen();
302            assert_eq!(s.epoch, expected_epoch);
303            assert_eq!(s.phase, Phase::Open);
304        }
305    }
306
307    // === Merge: same epoch ===
308
309    #[test]
310    fn merge_same_epoch_higher_phase_wins() {
311        let mut a = EpochPhaseState::with(0, Phase::Open);
312        let b = EpochPhaseState::with(0, Phase::Done);
313        a.merge(&b);
314        assert_eq!(a.phase, Phase::Done);
315        assert_eq!(a.epoch, 0);
316    }
317
318    #[test]
319    fn merge_same_epoch_lower_phase_no_change() {
320        let mut a = EpochPhaseState::with(0, Phase::Done);
321        let b = EpochPhaseState::with(0, Phase::Open);
322        a.merge(&b);
323        assert_eq!(a.phase, Phase::Done);
324    }
325
326    #[test]
327    fn merge_same_epoch_same_phase_idempotent() {
328        let mut a = EpochPhaseState::with(0, Phase::Doing);
329        let b = EpochPhaseState::with(0, Phase::Doing);
330        a.merge(&b);
331        assert_eq!(a.phase, Phase::Doing);
332        assert_eq!(a.epoch, 0);
333    }
334
335    // === Merge: different epochs ===
336
337    #[test]
338    fn merge_higher_epoch_wins() {
339        let mut a = EpochPhaseState::with(1, Phase::Open);
340        let b = EpochPhaseState::with(2, Phase::Doing);
341        a.merge(&b);
342        assert_eq!(a.epoch, 2);
343        assert_eq!(a.phase, Phase::Doing);
344    }
345
346    #[test]
347    fn merge_lower_epoch_no_change() {
348        let mut a = EpochPhaseState::with(3, Phase::Doing);
349        let b = EpochPhaseState::with(1, Phase::Archived);
350        a.merge(&b);
351        assert_eq!(a.epoch, 3);
352        assert_eq!(a.phase, Phase::Doing);
353    }
354
355    // === Concurrent close/reopen ===
356
357    #[test]
358    fn concurrent_close_and_reopen_reopen_wins() {
359        // Agent A closes: epoch 0, Done
360        let close = EpochPhaseState::with(0, Phase::Done);
361        // Agent B reopens: epoch 1, Open
362        let reopen = EpochPhaseState::with(1, Phase::Open);
363
364        // Merge order 1: close into reopen
365        let mut m1 = close.clone();
366        m1.merge(&reopen);
367        assert_eq!(m1.epoch, 1);
368        assert_eq!(m1.phase, Phase::Open);
369
370        // Merge order 2: reopen into close
371        let mut m2 = reopen.clone();
372        m2.merge(&close);
373        assert_eq!(m2.epoch, 1);
374        assert_eq!(m2.phase, Phase::Open);
375
376        // Both orderings produce same result
377        assert_eq!(m1, m2);
378    }
379
380    // === Semilattice properties ===
381
382    #[test]
383    fn semilattice_commutative() {
384        let cases = vec![
385            (
386                EpochPhaseState::with(0, Phase::Open),
387                EpochPhaseState::with(0, Phase::Done),
388            ),
389            (
390                EpochPhaseState::with(1, Phase::Doing),
391                EpochPhaseState::with(0, Phase::Archived),
392            ),
393            (
394                EpochPhaseState::with(2, Phase::Open),
395                EpochPhaseState::with(2, Phase::Doing),
396            ),
397        ];
398        for (a, b) in cases {
399            let mut ab = a.clone();
400            ab.merge(&b);
401            let mut ba = b.clone();
402            ba.merge(&a);
403            assert_eq!(ab, ba, "commutative failed for {a:?} and {b:?}");
404        }
405    }
406
407    #[test]
408    fn semilattice_associative() {
409        let a = EpochPhaseState::with(1, Phase::Open);
410        let b = EpochPhaseState::with(0, Phase::Done);
411        let c = EpochPhaseState::with(1, Phase::Doing);
412
413        // (a merge b) merge c
414        let mut left = a.clone();
415        left.merge(&b);
416        left.merge(&c);
417
418        // a merge (b merge c)
419        let mut bc = b.clone();
420        bc.merge(&c);
421        let mut right = a.clone();
422        right.merge(&bc);
423
424        assert_eq!(left, right);
425    }
426
427    #[test]
428    fn semilattice_idempotent() {
429        let a = EpochPhaseState::with(2, Phase::Doing);
430        let mut m = a.clone();
431        m.merge(&a);
432        assert_eq!(m, a);
433    }
434
435    // === Edge cases ===
436
437    #[test]
438    fn merge_default_with_default() {
439        let mut a = EpochPhaseState::default();
440        let b = EpochPhaseState::default();
441        a.merge(&b);
442        assert_eq!(a, EpochPhaseState::new());
443    }
444
445    #[test]
446    fn display() {
447        let s = EpochPhaseState::with(3, Phase::Done);
448        assert_eq!(s.to_string(), "epoch=3 phase=done");
449    }
450
451    #[test]
452    fn serde_roundtrip() {
453        let s = EpochPhaseState::with(5, Phase::Archived);
454        let json = serde_json::to_string(&s).unwrap();
455        let deserialized: EpochPhaseState = serde_json::from_str(&json).unwrap();
456        assert_eq!(s, deserialized);
457    }
458
459    #[test]
460    fn phase_serde_roundtrip() {
461        for phase in Phase::ALL {
462            let json = serde_json::to_string(&phase).unwrap();
463            let deserialized: Phase = serde_json::from_str(&json).unwrap();
464            assert_eq!(phase, deserialized);
465        }
466    }
467}