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}