stygian_charon/token_lifecycle/invalidation.rs
1//! Structured invalidation reasons for token lifecycle failures (T91).
2
3use serde::{Deserialize, Serialize};
4
5use crate::token_lifecycle::contract::ChallengeClass;
6use crate::vendor_classifier::VendorId;
7
8/// Coarse-grained kind tag for [`InvalidationReason`].
9///
10/// The kind is the **stable, wire-level enum** (`snake_case`)
11/// that downstream consumers (alerting, dashboards, audit log
12/// routers) can switch on without inspecting the full
13/// [`InvalidationReason`]. The full reason carries the
14/// diagnostic context (vendor family, challenge class,
15/// supplied vs. expected values); the kind is the routing key.
16///
17/// # Example
18///
19/// ```
20/// use stygian_charon::token_lifecycle::InvalidationKind;
21///
22/// let k = InvalidationKind::NonceReplayed;
23/// assert_eq!(k.label(), "nonce_replayed");
24/// ```
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum InvalidationKind {
28 /// Token TTL elapsed. The
29 /// [`InvalidationReason::Expired`][crate::token_lifecycle::InvalidationReason::Expired]
30 /// variant carries the vendor family, challenge class,
31 /// observed age, and the TTL that was crossed.
32 Expired,
33 /// Submission's nonce does not match the contract's nonce.
34 /// The
35 /// [`InvalidationReason::NonceMismatch`][crate::token_lifecycle::InvalidationReason::NonceMismatch]
36 /// variant carries both nonces.
37 NonceMismatch,
38 /// A single-use nonce was observed more than once. The
39 /// [`InvalidationReason::NonceReplayed`][crate::token_lifecycle::InvalidationReason::NonceReplayed]
40 /// variant carries the observation count from the nonce
41 /// book.
42 NonceReplayed,
43 /// Submission's session id does not match the contract's
44 /// `bound_session`.
45 SessionBindingMiss,
46 /// No contract was supplied alongside the token. The
47 /// validator returns this when the caller bypasses the
48 /// contract path.
49 ContractMissing,
50 /// The token's challenge class is not applicable for the
51 /// supplied vendor family (e.g. `Cloudflare` is asked to
52 /// validate a `ProofOfWork` token — Cloudflare does not
53 /// issue `PoW` tokens).
54 NotApplicable,
55}
56
57impl InvalidationKind {
58 /// Stable, lower-case wire label.
59 ///
60 /// # Example
61 ///
62 /// ```
63 /// use stygian_charon::token_lifecycle::InvalidationKind;
64 ///
65 /// assert_eq!(InvalidationKind::Expired.label(), "expired");
66 /// assert_eq!(InvalidationKind::NonceMismatch.label(), "nonce_mismatch");
67 /// assert_eq!(InvalidationKind::NonceReplayed.label(), "nonce_replayed");
68 /// assert_eq!(InvalidationKind::SessionBindingMiss.label(), "session_binding_miss");
69 /// assert_eq!(InvalidationKind::ContractMissing.label(), "contract_missing");
70 /// assert_eq!(InvalidationKind::NotApplicable.label(), "not_applicable");
71 /// ```
72 #[must_use]
73 pub const fn label(self) -> &'static str {
74 match self {
75 Self::Expired => "expired",
76 Self::NonceMismatch => "nonce_mismatch",
77 Self::NonceReplayed => "nonce_replayed",
78 Self::SessionBindingMiss => "session_binding_miss",
79 Self::ContractMissing => "contract_missing",
80 Self::NotApplicable => "not_applicable",
81 }
82 }
83}
84
85/// Structured reason a [`TokenValidator`][crate::token_lifecycle::TokenValidator]
86/// rejected a token submission.
87///
88/// Every variant carries the **vendor family** and
89/// **challenge class** so the diagnostic payload can route the
90/// invalidation to the per-family audit log without inspecting
91/// the rest of the report. The kind tag is the routing key
92/// (see [`InvalidationKind`]); the variant embeds the
93/// diagnostic context (expected vs. observed values, age,
94/// observation count, etc.).
95///
96/// # Example
97///
98/// ```
99/// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationKind, InvalidationReason};
100/// use stygian_charon::vendor_classifier::VendorId;
101///
102/// let reason = InvalidationReason::Expired {
103/// vendor: VendorId::Cloudflare,
104/// challenge_class: ChallengeClass::Interstitial,
105/// age_secs: 1900,
106/// ttl_secs: 1800,
107/// };
108/// assert_eq!(reason.kind(), InvalidationKind::Expired);
109/// assert_eq!(reason.vendor_family(), VendorId::Cloudflare);
110/// ```
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(tag = "kind", rename_all = "snake_case")]
113pub enum InvalidationReason {
114 /// Token TTL elapsed before submission.
115 Expired {
116 /// Vendor family that issued the token.
117 vendor: VendorId,
118 /// Challenge class the token is bound to.
119 challenge_class: ChallengeClass,
120 /// Observed age at submission time, in seconds.
121 age_secs: u64,
122 /// TTL the contract was issued with, in seconds.
123 ttl_secs: u64,
124 },
125 /// Submission's nonce does not match the contract's nonce.
126 NonceMismatch {
127 /// Vendor family the contract was issued for.
128 vendor: VendorId,
129 /// Challenge class the contract is bound to.
130 challenge_class: ChallengeClass,
131 /// Nonce the contract was issued with.
132 expected: String,
133 /// Nonce the submission carried.
134 observed: String,
135 },
136 /// A single-use nonce was observed more than once.
137 NonceReplayed {
138 /// Vendor family the contract was issued for.
139 vendor: VendorId,
140 /// Challenge class the contract is bound to.
141 challenge_class: ChallengeClass,
142 /// Number of times the nonce has now been observed
143 /// (capped at `u32::MAX` for monotonic counters).
144 observation_count: u32,
145 },
146 /// Submission's session id does not match the contract's
147 /// `bound_session`.
148 SessionBindingMiss {
149 /// Vendor family the contract was issued for.
150 vendor: VendorId,
151 /// Challenge class the contract is bound to.
152 challenge_class: ChallengeClass,
153 /// Session id the contract was bound to (`None` when
154 /// the contract carried no binding).
155 expected: Option<String>,
156 /// Session id the submission carried.
157 observed: Option<String>,
158 },
159 /// No contract was supplied alongside the token.
160 ContractMissing {
161 /// Vendor family the token was nominally issued for
162 /// (when the caller had partial evidence).
163 vendor: VendorId,
164 /// Challenge class the token was nominally bound to.
165 challenge_class: ChallengeClass,
166 },
167 /// The token's challenge class is not applicable for the
168 /// supplied vendor family.
169 NotApplicable {
170 /// Vendor family the contract was issued for.
171 vendor: VendorId,
172 /// Challenge class the contract claimed to cover.
173 challenge_class: ChallengeClass,
174 },
175}
176
177impl InvalidationReason {
178 /// Routing kind for this invalidation.
179 ///
180 /// # Example
181 ///
182 /// ```
183 /// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationKind, InvalidationReason};
184 /// use stygian_charon::vendor_classifier::VendorId;
185 ///
186 /// let reason = InvalidationReason::NonceReplayed {
187 /// vendor: VendorId::Akamai,
188 /// challenge_class: ChallengeClass::ProofOfWork,
189 /// observation_count: 2,
190 /// };
191 /// assert_eq!(reason.kind(), InvalidationKind::NonceReplayed);
192 /// ```
193 #[must_use]
194 pub const fn kind(&self) -> InvalidationKind {
195 match self {
196 Self::Expired { .. } => InvalidationKind::Expired,
197 Self::NonceMismatch { .. } => InvalidationKind::NonceMismatch,
198 Self::NonceReplayed { .. } => InvalidationKind::NonceReplayed,
199 Self::SessionBindingMiss { .. } => InvalidationKind::SessionBindingMiss,
200 Self::ContractMissing { .. } => InvalidationKind::ContractMissing,
201 Self::NotApplicable { .. } => InvalidationKind::NotApplicable,
202 }
203 }
204
205 /// Vendor family the invalidation is attributed to.
206 ///
207 /// # Example
208 ///
209 /// ```
210 /// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationReason};
211 /// use stygian_charon::vendor_classifier::VendorId;
212 ///
213 /// let reason = InvalidationReason::NonceMismatch {
214 /// vendor: VendorId::PerimeterX,
215 /// challenge_class: ChallengeClass::IntegrityCheck,
216 /// expected: "n".to_string(),
217 /// observed: "m".to_string(),
218 /// };
219 /// assert_eq!(reason.vendor_family(), VendorId::PerimeterX);
220 /// ```
221 #[must_use]
222 pub const fn vendor_family(&self) -> VendorId {
223 match self {
224 Self::Expired { vendor, .. }
225 | Self::NonceMismatch { vendor, .. }
226 | Self::NonceReplayed { vendor, .. }
227 | Self::SessionBindingMiss { vendor, .. }
228 | Self::ContractMissing { vendor, .. }
229 | Self::NotApplicable { vendor, .. } => *vendor,
230 }
231 }
232
233 /// Challenge class the invalidation is attributed to.
234 ///
235 /// # Example
236 ///
237 /// ```
238 /// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationReason};
239 /// use stygian_charon::vendor_classifier::VendorId;
240 ///
241 /// let reason = InvalidationReason::SessionBindingMiss {
242 /// vendor: VendorId::DataDome,
243 /// challenge_class: ChallengeClass::Captcha,
244 /// expected: Some("s1".to_string()),
245 /// observed: Some("s2".to_string()),
246 /// };
247 /// assert_eq!(reason.challenge_class(), ChallengeClass::Captcha);
248 /// ```
249 #[must_use]
250 pub const fn challenge_class(&self) -> ChallengeClass {
251 match self {
252 Self::Expired {
253 challenge_class, ..
254 }
255 | Self::NonceMismatch {
256 challenge_class, ..
257 }
258 | Self::NonceReplayed {
259 challenge_class, ..
260 }
261 | Self::SessionBindingMiss {
262 challenge_class, ..
263 }
264 | Self::ContractMissing {
265 challenge_class, ..
266 }
267 | Self::NotApplicable {
268 challenge_class, ..
269 } => *challenge_class,
270 }
271 }
272
273 /// Stable wire label — equivalent to
274 /// [`kind().label()`][InvalidationKind::label]. Useful for
275 /// the JSON field that the diagnostic payload exposes.
276 ///
277 /// # Example
278 ///
279 /// ```
280 /// use stygian_charon::token_lifecycle::{ChallengeClass, InvalidationReason};
281 /// use stygian_charon::vendor_classifier::VendorId;
282 ///
283 /// let reason = InvalidationReason::ContractMissing {
284 /// vendor: VendorId::Unknown,
285 /// challenge_class: ChallengeClass::Unknown,
286 /// };
287 /// assert_eq!(reason.label(), "contract_missing");
288 /// ```
289 #[must_use]
290 pub const fn label(&self) -> &'static str {
291 self.kind().label()
292 }
293}
294
295#[cfg(test)]
296#[allow(
297 clippy::unwrap_used,
298 clippy::expect_used,
299 clippy::panic,
300 clippy::indexing_slicing
301)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn invalidation_kind_labels_are_stable() {
307 for (kind, label) in [
308 (InvalidationKind::Expired, "expired"),
309 (InvalidationKind::NonceMismatch, "nonce_mismatch"),
310 (InvalidationKind::NonceReplayed, "nonce_replayed"),
311 (InvalidationKind::SessionBindingMiss, "session_binding_miss"),
312 (InvalidationKind::ContractMissing, "contract_missing"),
313 (InvalidationKind::NotApplicable, "not_applicable"),
314 ] {
315 assert_eq!(kind.label(), label);
316 }
317 }
318
319 #[test]
320 fn invalidation_reason_kind_vendor_and_class_match_variant() {
321 let expired = InvalidationReason::Expired {
322 vendor: VendorId::Cloudflare,
323 challenge_class: ChallengeClass::Interstitial,
324 age_secs: 100,
325 ttl_secs: 60,
326 };
327 assert_eq!(expired.kind(), InvalidationKind::Expired);
328 assert_eq!(expired.vendor_family(), VendorId::Cloudflare);
329 assert_eq!(expired.challenge_class(), ChallengeClass::Interstitial);
330 assert_eq!(expired.label(), "expired");
331
332 let mismatch = InvalidationReason::NonceMismatch {
333 vendor: VendorId::PerimeterX,
334 challenge_class: ChallengeClass::IntegrityCheck,
335 expected: "a".to_string(),
336 observed: "b".to_string(),
337 };
338 assert_eq!(mismatch.kind(), InvalidationKind::NonceMismatch);
339 assert_eq!(mismatch.vendor_family(), VendorId::PerimeterX);
340 assert_eq!(mismatch.label(), "nonce_mismatch");
341
342 let replayed = InvalidationReason::NonceReplayed {
343 vendor: VendorId::Akamai,
344 challenge_class: ChallengeClass::ProofOfWork,
345 observation_count: 3,
346 };
347 assert_eq!(replayed.kind(), InvalidationKind::NonceReplayed);
348 assert_eq!(replayed.vendor_family(), VendorId::Akamai);
349 assert_eq!(replayed.label(), "nonce_replayed");
350
351 let binding = InvalidationReason::SessionBindingMiss {
352 vendor: VendorId::DataDome,
353 challenge_class: ChallengeClass::Captcha,
354 expected: Some("s1".to_string()),
355 observed: Some("s2".to_string()),
356 };
357 assert_eq!(binding.kind(), InvalidationKind::SessionBindingMiss);
358 assert_eq!(binding.vendor_family(), VendorId::DataDome);
359 assert_eq!(binding.label(), "session_binding_miss");
360
361 let missing = InvalidationReason::ContractMissing {
362 vendor: VendorId::Unknown,
363 challenge_class: ChallengeClass::Unknown,
364 };
365 assert_eq!(missing.kind(), InvalidationKind::ContractMissing);
366 assert_eq!(missing.vendor_family(), VendorId::Unknown);
367 assert_eq!(missing.label(), "contract_missing");
368
369 let not_applicable = InvalidationReason::NotApplicable {
370 vendor: VendorId::Cloudflare,
371 challenge_class: ChallengeClass::ProofOfWork,
372 };
373 assert_eq!(not_applicable.kind(), InvalidationKind::NotApplicable);
374 assert_eq!(not_applicable.vendor_family(), VendorId::Cloudflare);
375 assert_eq!(not_applicable.label(), "not_applicable");
376 }
377
378 #[test]
379 fn invalidation_reason_serializes_with_tag() {
380 let reason = InvalidationReason::NonceReplayed {
381 vendor: VendorId::Akamai,
382 challenge_class: ChallengeClass::ProofOfWork,
383 observation_count: 2,
384 };
385 let json = serde_json::to_string(&reason).expect("serialize");
386 assert!(json.contains("\"kind\":\"nonce_replayed\""));
387 assert!(json.contains("\"vendor\":\"akamai\""));
388 assert!(json.contains("\"observation_count\":2"));
389 }
390}