codlet_core/state/token.rs
1//! Form-token consume state machine (RFC-007).
2//!
3//! This is a direct port of `zinnias_ciao_contracts::auth::classify_token_consume`
4//! and its six tests, lifted into codlet-core so the logic is a pure,
5//! storage-free primitive. The function signature and all invariants are
6//! preserved exactly; adapters supply the inputs from their query results.
7
8/// Outcome of a single-use form-token consume attempt (RFC-007 §3).
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum TokenConsumeOutcome {
11 /// This call won the atomic race (UPDATE changed exactly one row). Execute
12 /// the operation.
13 Proceed,
14 /// Token already consumed — idempotent replay. Return the prior result
15 /// reference if one was stored; do not re-execute the operation.
16 Replay,
17 /// Token not found, expired, or binding mismatch. Reject the request.
18 Invalid,
19}
20
21/// Classify a consume attempt from the atomic UPDATE and a follow-up SELECT.
22///
23/// `changed` is the affected-row count from:
24///
25/// ```sql
26/// UPDATE codlet_form_tokens
27/// SET consumed_at = :now
28/// WHERE lookup_key = :key
29/// AND purpose = :purpose
30/// AND subject = :subject -- binding
31/// AND expires_at > :now
32/// AND consumed_at IS NULL
33/// ```
34///
35/// When `changed == 0`, the follow-up SELECT provides:
36///
37/// - `found` — a row matching the lookup key + purpose + subject exists.
38/// - `already_consumed`— that row has `consumed_at IS NOT NULL`.
39/// - `binding_ok` — the row's bound resource matches the caller's.
40///
41/// The single rule that must never be violated (INV-6): **`changed == 0` never
42/// produces [`TokenConsumeOutcome::Proceed`]** (RFC-007 §5, §13.5,
43/// acceptance checklist item "changed == 0 never proceeds").
44#[must_use]
45pub fn classify_token_consume(
46 changed: usize,
47 found: bool,
48 already_consumed: bool,
49 binding_ok: bool,
50) -> TokenConsumeOutcome {
51 if changed == 1 {
52 return TokenConsumeOutcome::Proceed;
53 }
54 // changed == 0: the conditional UPDATE matched nothing — classify why.
55 if !found || !binding_ok {
56 return TokenConsumeOutcome::Invalid;
57 }
58 if already_consumed {
59 return TokenConsumeOutcome::Replay;
60 }
61 // Row exists, unconsumed, binding ok, but UPDATE still missed →
62 // the expiry guard fired. Treat as invalid.
63 TokenConsumeOutcome::Invalid
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 // The six tests from zinnias-ciao contracts/src/auth.rs, preserved verbatim
71 // as a compatibility / regression suite.
72
73 #[test]
74 fn consume_winner_proceeds() {
75 assert_eq!(
76 classify_token_consume(1, true, false, true),
77 TokenConsumeOutcome::Proceed
78 );
79 }
80
81 #[test]
82 fn consume_loser_of_race_sees_replay() {
83 // Concurrent double-submit: second call's UPDATE changes 0 rows because
84 // consumed_at is set. Must replay, not re-execute.
85 assert_eq!(
86 classify_token_consume(0, true, true, true),
87 TokenConsumeOutcome::Replay
88 );
89 }
90
91 #[test]
92 fn consume_unknown_token_is_invalid() {
93 assert_eq!(
94 classify_token_consume(0, false, false, false),
95 TokenConsumeOutcome::Invalid
96 );
97 }
98
99 #[test]
100 fn consume_binding_mismatch_is_invalid() {
101 // Right token, wrong bound_resource → rejection, not replay.
102 assert_eq!(
103 classify_token_consume(0, true, false, false),
104 TokenConsumeOutcome::Invalid
105 );
106 }
107
108 #[test]
109 fn consume_expired_unconsumed_is_invalid() {
110 // Found, unconsumed, binding ok, but UPDATE missed (expiry guard).
111 assert_eq!(
112 classify_token_consume(0, true, false, true),
113 TokenConsumeOutcome::Invalid
114 );
115 }
116
117 #[test]
118 fn consume_never_double_proceeds() {
119 // Exhaustive: across all changed==0 states, Proceed is impossible.
120 // Acceptance checklist RFC-007 §13.5: "changed == 0 never proceeds".
121 for found in [false, true] {
122 for consumed in [false, true] {
123 for binding in [false, true] {
124 assert_ne!(
125 classify_token_consume(0, found, consumed, binding),
126 TokenConsumeOutcome::Proceed,
127 "changed==0 must never proceed \
128 (found={found} consumed={consumed} binding={binding})"
129 );
130 }
131 }
132 }
133 }
134
135 // Additional codlet-specific tests.
136
137 #[test]
138 fn changed_greater_than_one_is_conservatively_invalid() {
139 // >1 is a storage invariant violation. We must never Proceed.
140 for bad in [2usize, 100] {
141 assert_ne!(
142 classify_token_consume(bad, true, false, true),
143 TokenConsumeOutcome::Proceed,
144 "changed={bad} must not Proceed"
145 );
146 }
147 }
148
149 #[test]
150 fn not_found_always_invalid_regardless_of_other_flags() {
151 for consumed in [false, true] {
152 for binding in [false, true] {
153 assert_eq!(
154 classify_token_consume(0, false, consumed, binding),
155 TokenConsumeOutcome::Invalid,
156 "not found must always be Invalid"
157 );
158 }
159 }
160 }
161}