Skip to main content

codlet_core/state/
claim.rs

1//! Code-claim state machine (RFC-005).
2//!
3//! Pure, storage-free logic: given the result of an atomic conditional
4//! `UPDATE … WHERE … AND used_at IS NULL AND expires_at > :now`, classify the
5//! outcome. No I/O, no `async`. Tested exhaustively.
6
7/// Outcome of a `claim_code` attempt (RFC-005 §3).
8///
9/// Only `Won` may advance the host to session creation or any other
10/// side-effecting operation. `Lost` is definitive; there is no retry.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ClaimOutcome {
13    /// This caller won the atomic race: the conditional UPDATE changed exactly
14    /// one row. Proceed with session issuance and any host-side effects.
15    Won,
16    /// The conditional UPDATE changed zero rows: the code was already claimed,
17    /// revoked, or expired when this call ran. Do not proceed.
18    Lost,
19}
20
21/// Classify an atomic claim attempt from the affected-row count.
22///
23/// `changed` is the number of rows the conditional UPDATE reported modifying:
24///
25/// - `1` → [`ClaimOutcome::Won`]
26/// - `0` → [`ClaimOutcome::Lost`]
27/// - anything else → storage invariant violation; returns `Lost` conservatively.
28///   Adapters should log an internal error when `changed > 1`.
29#[must_use]
30pub fn classify_claim(changed: usize) -> ClaimOutcome {
31    if changed == 1 {
32        ClaimOutcome::Won
33    } else {
34        // changed == 0 (normal lost) or > 1 (invariant violation).
35        // Either way: do not proceed. RFC-005 §14.1: `changed > 1` is a store
36        // invariant violation and must be surfaced by the adapter as an error
37        // rather than silently returning Lost; this classifier handles it
38        // conservatively so even a misbehaving adapter cannot produce a Won.
39        ClaimOutcome::Lost
40    }
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46
47    #[test]
48    fn one_row_changed_wins() {
49        assert_eq!(classify_claim(1), ClaimOutcome::Won);
50    }
51
52    #[test]
53    fn zero_rows_lost() {
54        assert_eq!(classify_claim(0), ClaimOutcome::Lost);
55    }
56
57    #[test]
58    fn invariant_violation_returns_lost_conservatively() {
59        // >1 is a storage bug; we must never return Won.
60        for bad in [2usize, 100] {
61            assert_eq!(
62                classify_claim(bad),
63                ClaimOutcome::Lost,
64                "changed={bad} must be Lost"
65            );
66        }
67    }
68
69    #[test]
70    fn only_exactly_one_produces_won() {
71        // Property: the only way to get Won is changed == 1.
72        for n in 0usize..=10 {
73            let outcome = classify_claim(n);
74            if n == 1 {
75                assert_eq!(outcome, ClaimOutcome::Won);
76            } else {
77                assert_eq!(outcome, ClaimOutcome::Lost);
78            }
79        }
80    }
81}