Skip to main content

ash_core/
errors.rs

1//! Error types for ASH protocol.
2//!
3//! This module provides structured error types with:
4//! - Stable error codes for programmatic handling
5//! - HTTP status code mappings for API responses
6//! - Human-readable error messages
7//!
8//! ## Error Codes (v2.3.4 - Unique HTTP Status Codes)
9//!
10//! ASH uses unique HTTP status codes in the 450-499 range for precise error identification.
11//! Every error code maps to a unique HTTP status code for unambiguous monitoring and retry logic.
12//!
13//! | Code | HTTP Status | Meaning |
14//! |------|-------------|---------|
15//! | `CTX_NOT_FOUND` | 450 | Context ID not found in store |
16//! | `CTX_EXPIRED` | 451 | Context has expired |
17//! | `CTX_ALREADY_USED` | 452 | Context was already consumed (replay) |
18//! | `PROOF_INVALID` | 460 | Proof verification failed |
19//! | `BINDING_MISMATCH` | 461 | Request endpoint doesn't match context |
20//! | `SCOPE_MISMATCH` | 473 | Scope hash mismatch |
21//! | `CHAIN_BROKEN` | 474 | Chain verification failed |
22//! | `SCOPED_FIELD_MISSING` | 475 | Required scoped field missing |
23//! | `TIMESTAMP_INVALID` | 482 | Invalid timestamp format |
24//! | `PROOF_MISSING` | 483 | Required X-ASH-Proof header missing |
25//! | `CANONICALIZATION_ERROR` | 484 | Payload cannot be canonicalized |
26//! | `VALIDATION_ERROR` | 485 | Input validation failure |
27//! | `MODE_VIOLATION` | 486 | Security mode requirements not met |
28//! | `UNSUPPORTED_CONTENT_TYPE` | 415 | Content type not supported |
29//! | `INTERNAL_ERROR` | 500 | Internal server error |
30//!
31//! ## Example
32//!
33//! ```rust
34//! use ash_core::{AshError, AshErrorCode};
35//!
36//! fn verify_request() -> Result<(), AshError> {
37//!     // Return an error with code and message
38//!     Err(AshError::new(
39//!         AshErrorCode::ProofInvalid,
40//!         "Proof does not match expected value"
41//!     ))
42//! }
43//!
44//! match verify_request() {
45//!     Ok(_) => println!("Valid!"),
46//!     Err(e) => {
47//!         println!("Error: {} (HTTP {})", e.message(), e.code().http_status());
48//!     }
49//! }
50//! ```
51
52use serde::{Deserialize, Serialize};
53use std::collections::BTreeMap;
54use std::fmt;
55
56/// Error codes for ASH protocol.
57///
58/// These codes are stable and should not change between versions.
59///
60/// ## Standard Error Codes (per ASH specification v2.3.4)
61///
62/// | Error Code | HTTP Status | Description |
63/// |------------|-------------|-------------|
64/// | `ASH_CTX_NOT_FOUND` | 450 | Context ID not found in store |
65/// | `ASH_CTX_EXPIRED` | 451 | Context has expired |
66/// | `ASH_CTX_ALREADY_USED` | 452 | Context was already consumed (replay) |
67/// | `ASH_PROOF_INVALID` | 460 | Proof verification failed |
68/// | `ASH_BINDING_MISMATCH` | 461 | Request endpoint doesn't match context |
69/// | `ASH_SCOPE_MISMATCH` | 473 | Scope hash mismatch |
70/// | `ASH_CHAIN_BROKEN` | 474 | Chain verification failed |
71/// | `ASH_TIMESTAMP_INVALID` | 482 | Invalid timestamp format |
72/// | `ASH_PROOF_MISSING` | 483 | Required X-ASH-Proof header missing |
73/// | `ASH_SCOPED_FIELD_MISSING` | 475 | Required scoped field missing |
74/// | `ASH_CANONICALIZATION_ERROR` | 484 | Payload cannot be canonicalized |
75/// | `ASH_VALIDATION_ERROR` | 485 | Input validation failure |
76/// | `ASH_MODE_VIOLATION` | 486 | Security mode requirements not met |
77/// | `ASH_UNSUPPORTED_CONTENT_TYPE` | 415 | Content type not supported |
78/// | `ASH_INTERNAL_ERROR` | 500 | Internal server error |
79///
80/// Every error code has a unique HTTP status code for unambiguous identification.
81///
82/// ## Serde Serialization (CR-001)
83///
84/// Error codes serialize with the `ASH_` prefix per the ASH specification:
85/// `CtxNotFound` serializes as `"ASH_CTX_NOT_FOUND"`, not `"CTX_NOT_FOUND"`.
86/// This ensures cross-SDK interoperability when error codes are transmitted as JSON.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum AshErrorCode {
89    /// Context not found in store
90    CtxNotFound,
91    /// Context has expired
92    CtxExpired,
93    /// Context was already consumed (replay detected)
94    CtxAlreadyUsed,
95    /// Binding does not match expected endpoint
96    BindingMismatch,
97    /// Required proof not provided
98    ProofMissing,
99    /// Proof does not match expected value
100    ProofInvalid,
101    /// Payload cannot be canonicalized
102    CanonicalizationError,
103    /// General validation error (input validation failures)
104    /// Spec: ASH_VALIDATION_ERROR (HTTP 485)
105    ValidationError,
106    /// Mode requirements not met
107    ModeViolation,
108    /// Content type not supported
109    UnsupportedContentType,
110    /// Scope hash mismatch (v2.2+)
111    ScopeMismatch,
112    /// Chain verification failed (v2.3+)
113    ChainBroken,
114    /// Internal server error (RNG failure, etc.)
115    InternalError,
116    /// Timestamp validation failed (SEC-005)
117    TimestampInvalid,
118    /// Required scoped field missing (SEC-006)
119    ScopedFieldMissing,
120}
121
122/// CR-001: Custom Serialize implementation to produce spec-compliant ASH_ prefixed strings.
123/// `#[serde(rename_all = "SCREAMING_SNAKE_CASE")]` would produce `CTX_NOT_FOUND` without
124/// the required `ASH_` prefix, causing cross-SDK deserialization failures.
125impl Serialize for AshErrorCode {
126    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
127        serializer.serialize_str(self.as_str())
128    }
129}
130
131impl<'de> Deserialize<'de> for AshErrorCode {
132    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
133        let s = String::deserialize(deserializer)?;
134        match s.as_str() {
135            "ASH_CTX_NOT_FOUND" => Ok(AshErrorCode::CtxNotFound),
136            "ASH_CTX_EXPIRED" => Ok(AshErrorCode::CtxExpired),
137            "ASH_CTX_ALREADY_USED" => Ok(AshErrorCode::CtxAlreadyUsed),
138            "ASH_BINDING_MISMATCH" => Ok(AshErrorCode::BindingMismatch),
139            "ASH_PROOF_MISSING" => Ok(AshErrorCode::ProofMissing),
140            "ASH_PROOF_INVALID" => Ok(AshErrorCode::ProofInvalid),
141            "ASH_CANONICALIZATION_ERROR" => Ok(AshErrorCode::CanonicalizationError),
142            "ASH_VALIDATION_ERROR" => Ok(AshErrorCode::ValidationError),
143            "ASH_MODE_VIOLATION" => Ok(AshErrorCode::ModeViolation),
144            "ASH_UNSUPPORTED_CONTENT_TYPE" => Ok(AshErrorCode::UnsupportedContentType),
145            "ASH_SCOPE_MISMATCH" => Ok(AshErrorCode::ScopeMismatch),
146            "ASH_CHAIN_BROKEN" => Ok(AshErrorCode::ChainBroken),
147            "ASH_INTERNAL_ERROR" => Ok(AshErrorCode::InternalError),
148            "ASH_TIMESTAMP_INVALID" => Ok(AshErrorCode::TimestampInvalid),
149            "ASH_SCOPED_FIELD_MISSING" => Ok(AshErrorCode::ScopedFieldMissing),
150            _ => Err(serde::de::Error::unknown_variant(
151                &s,
152                &[
153                    "ASH_CTX_NOT_FOUND", "ASH_CTX_EXPIRED", "ASH_CTX_ALREADY_USED",
154                    "ASH_BINDING_MISMATCH", "ASH_PROOF_MISSING", "ASH_PROOF_INVALID",
155                    "ASH_CANONICALIZATION_ERROR", "ASH_VALIDATION_ERROR", "ASH_MODE_VIOLATION",
156                    "ASH_UNSUPPORTED_CONTENT_TYPE", "ASH_SCOPE_MISMATCH", "ASH_CHAIN_BROKEN",
157                    "ASH_INTERNAL_ERROR", "ASH_TIMESTAMP_INVALID", "ASH_SCOPED_FIELD_MISSING",
158                ],
159            )),
160        }
161    }
162}
163
164impl AshErrorCode {
165    /// Get the recommended HTTP status code for this error.
166    ///
167    /// v2.3.5: Every error code has a unique HTTP status code for unambiguous identification.
168    /// ASH-specific errors use the 450-486 range. Standard HTTP codes (415, 500) are used
169    /// only where a single ASH error maps to a well-known HTTP semantic.
170    pub fn http_status(&self) -> u16 {
171        match self {
172            // Context errors (450-459)
173            AshErrorCode::CtxNotFound => 450,
174            AshErrorCode::CtxExpired => 451,
175            AshErrorCode::CtxAlreadyUsed => 452,
176            // Seal/Proof errors (460-469)
177            AshErrorCode::ProofInvalid => 460,
178            // Binding errors (461)
179            AshErrorCode::BindingMismatch => 461,
180            // Verification errors (473-479)
181            AshErrorCode::ScopeMismatch => 473,
182            AshErrorCode::ChainBroken => 474,
183            AshErrorCode::ScopedFieldMissing => 475,
184            // Format/Protocol errors (480-489)
185            AshErrorCode::TimestampInvalid => 482,
186            AshErrorCode::ProofMissing => 483,
187            AshErrorCode::CanonicalizationError => 484,
188            AshErrorCode::ValidationError => 485,
189            AshErrorCode::ModeViolation => 486,
190            // Standard HTTP codes (unique, 1:1 mapping)
191            AshErrorCode::UnsupportedContentType => 415,
192            AshErrorCode::InternalError => 500,
193        }
194    }
195
196    /// Whether this error code is retryable.
197    ///
198    /// Retryable errors are transient conditions that may resolve on retry:
199    /// - `TimestampInvalid` — clock skew may resolve after sync
200    /// - `InternalError` — transient server failure
201    ///
202    /// All other errors are permanent (wrong proof, missing fields, etc.)
203    /// and retrying with the same inputs will always produce the same error.
204    pub fn retryable(&self) -> bool {
205        matches!(
206            self,
207            AshErrorCode::TimestampInvalid | AshErrorCode::InternalError
208        )
209    }
210
211    /// Get the error code as a string.
212    ///
213    /// Returns the error code string per ASH specification v2.3.4.
214    pub fn as_str(&self) -> &'static str {
215        match self {
216            AshErrorCode::CtxNotFound => "ASH_CTX_NOT_FOUND",
217            AshErrorCode::CtxExpired => "ASH_CTX_EXPIRED",
218            AshErrorCode::CtxAlreadyUsed => "ASH_CTX_ALREADY_USED",
219            AshErrorCode::BindingMismatch => "ASH_BINDING_MISMATCH",
220            AshErrorCode::ProofMissing => "ASH_PROOF_MISSING",
221            AshErrorCode::ProofInvalid => "ASH_PROOF_INVALID",
222            AshErrorCode::CanonicalizationError => "ASH_CANONICALIZATION_ERROR",
223            AshErrorCode::ValidationError => "ASH_VALIDATION_ERROR",
224            AshErrorCode::ModeViolation => "ASH_MODE_VIOLATION",
225            AshErrorCode::UnsupportedContentType => "ASH_UNSUPPORTED_CONTENT_TYPE",
226            AshErrorCode::ScopeMismatch => "ASH_SCOPE_MISMATCH",
227            AshErrorCode::ChainBroken => "ASH_CHAIN_BROKEN",
228            AshErrorCode::InternalError => "ASH_INTERNAL_ERROR",
229            AshErrorCode::TimestampInvalid => "ASH_TIMESTAMP_INVALID",
230            AshErrorCode::ScopedFieldMissing => "ASH_SCOPED_FIELD_MISSING",
231        }
232    }
233}
234
235impl fmt::Display for AshErrorCode {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        write!(f, "{}", self.as_str())
238    }
239}
240
241/// Internal diagnostic reason for errors.
242///
243/// This provides granular error classification for debugging and observability
244/// without changing wire-level behavior. The wire code (`AshErrorCode`) and
245/// HTTP status remain conformance-locked; `InternalReason` adds precision
246/// for logs and diagnostics only.
247///
248/// ## Reconciliation
249///
250/// | InternalReason | WireCode | http_status |
251/// |----------------|----------|-------------|
252/// | `HdrMissing` | `ASH_VALIDATION_ERROR` | 485 |
253/// | `HdrMultiValue` | `ASH_VALIDATION_ERROR` | 485 |
254/// | `HdrInvalidChars` | `ASH_VALIDATION_ERROR` | 485 |
255/// | `TsParse` | `ASH_TIMESTAMP_INVALID` | 482 |
256/// | `TsSkew` | `ASH_TIMESTAMP_INVALID` | 482 |
257/// | `TsLeadingZeros` | `ASH_TIMESTAMP_INVALID` | 482 |
258/// | `TsOverflow` | `ASH_TIMESTAMP_INVALID` | 482 |
259/// | `NonceTooShort` | `ASH_VALIDATION_ERROR` | 485 |
260/// | `NonceTooLong` | `ASH_VALIDATION_ERROR` | 485 |
261/// | `NonceInvalidChars` | `ASH_VALIDATION_ERROR` | 485 |
262/// | `General` | (varies) | (varies) |
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
264pub enum InternalReason {
265    // Header extraction
266    /// Required header is missing
267    HdrMissing,
268    /// Header has multiple values where single is required
269    HdrMultiValue,
270    /// Header contains control characters or newlines
271    HdrInvalidChars,
272
273    // Timestamp validation
274    /// Timestamp could not be parsed as integer
275    TsParse,
276    /// Timestamp outside allowed clock skew
277    TsSkew,
278    /// Timestamp has leading zeros
279    TsLeadingZeros,
280    /// Timestamp exceeds maximum bounds
281    TsOverflow,
282
283    // Nonce validation
284    /// Nonce is shorter than minimum required length
285    NonceTooShort,
286    /// Nonce exceeds maximum allowed length
287    NonceTooLong,
288    /// Nonce contains non-hexadecimal characters
289    NonceInvalidChars,
290
291    /// General/unspecified reason (backward compat for existing error paths)
292    General,
293}
294
295impl fmt::Display for InternalReason {
296    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297        match self {
298            InternalReason::HdrMissing => write!(f, "HDR_MISSING"),
299            InternalReason::HdrMultiValue => write!(f, "HDR_MULTI_VALUE"),
300            InternalReason::HdrInvalidChars => write!(f, "HDR_INVALID_CHARS"),
301            InternalReason::TsParse => write!(f, "TS_PARSE"),
302            InternalReason::TsSkew => write!(f, "TS_SKEW"),
303            InternalReason::TsLeadingZeros => write!(f, "TS_LEADING_ZEROS"),
304            InternalReason::TsOverflow => write!(f, "TS_OVERFLOW"),
305            InternalReason::NonceTooShort => write!(f, "NONCE_TOO_SHORT"),
306            InternalReason::NonceTooLong => write!(f, "NONCE_TOO_LONG"),
307            InternalReason::NonceInvalidChars => write!(f, "NONCE_INVALID_CHARS"),
308            InternalReason::General => write!(f, "GENERAL"),
309        }
310    }
311}
312
313/// Main error type for ASH operations.
314///
315/// Error messages are designed to be safe for logging and client responses.
316/// They never contain sensitive data like payloads, proofs, or canonical strings.
317///
318/// ## Two-Layer Error Model
319///
320/// - **Wire layer** (`code` / `http_status` / `message`): Conformance-locked.
321///   These values are tested by the 134-vector conformance suite and must not change.
322/// - **Diagnostic layer** (`reason` / `details`): Internal only.
323///   Provides granular classification for logging and debugging without affecting
324///   wire behavior.
325#[derive(Debug, Clone)]
326pub struct AshError {
327    /// Error code (wire-level, conformance-locked)
328    code: AshErrorCode,
329    /// Human-readable message (safe for logging)
330    message: String,
331    /// Internal diagnostic reason (not exposed on wire)
332    reason: InternalReason,
333    /// Optional diagnostic details (not exposed on wire, must not contain secrets)
334    details: Option<BTreeMap<&'static str, String>>,
335}
336
337impl AshError {
338    /// Create a new AshError with General reason (backward compatible).
339    pub fn new(code: AshErrorCode, message: impl Into<String>) -> Self {
340        Self {
341            code,
342            message: message.into(),
343            reason: InternalReason::General,
344            details: None,
345        }
346    }
347
348    /// Create a new AshError with a specific internal reason.
349    pub fn with_reason(code: AshErrorCode, reason: InternalReason, message: impl Into<String>) -> Self {
350        Self {
351            code,
352            message: message.into(),
353            reason,
354            details: None,
355        }
356    }
357
358    /// Add a diagnostic detail (builder pattern). Must not contain secrets.
359    pub fn with_detail(mut self, key: &'static str, value: impl Into<String>) -> Self {
360        let map = self.details.get_or_insert_with(BTreeMap::new);
361        map.insert(key, value.into());
362        self
363    }
364
365    /// Get the error code.
366    pub fn code(&self) -> AshErrorCode {
367        self.code
368    }
369
370    /// Get the error message.
371    pub fn message(&self) -> &str {
372        &self.message
373    }
374
375    /// Get the recommended HTTP status code.
376    pub fn http_status(&self) -> u16 {
377        self.code.http_status()
378    }
379
380    /// Get the internal diagnostic reason.
381    pub fn reason(&self) -> InternalReason {
382        self.reason
383    }
384
385    /// Get the diagnostic details (if any).
386    pub fn details(&self) -> Option<&BTreeMap<&'static str, String>> {
387        self.details.as_ref()
388    }
389
390    /// Whether this error is retryable.
391    ///
392    /// Delegates to `AshErrorCode::retryable()`. SDKs can pass this
393    /// through to clients without implementing their own retry logic.
394    pub fn retryable(&self) -> bool {
395        self.code.retryable()
396    }
397}
398
399impl fmt::Display for AshError {
400    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401        write!(f, "{}: {}", self.code, self.message)
402    }
403}
404
405impl std::error::Error for AshError {}
406
407/// Convenience functions for creating common errors.
408impl AshError {
409    /// Context not found.
410    pub fn ctx_not_found() -> Self {
411        Self::new(AshErrorCode::CtxNotFound, "Context not found")
412    }
413
414    /// Context expired.
415    pub fn ctx_expired() -> Self {
416        Self::new(AshErrorCode::CtxExpired, "Context has expired")
417    }
418
419    /// Context already used (replay detected).
420    pub fn ctx_already_used() -> Self {
421        Self::new(AshErrorCode::CtxAlreadyUsed, "Context already consumed")
422    }
423
424    /// Binding mismatch.
425    pub fn binding_mismatch() -> Self {
426        Self::new(
427            AshErrorCode::BindingMismatch,
428            "Binding does not match endpoint",
429        )
430    }
431
432    /// Proof missing.
433    pub fn proof_missing() -> Self {
434        Self::new(AshErrorCode::ProofMissing, "Required proof not provided")
435    }
436
437    /// Proof invalid.
438    pub fn proof_invalid() -> Self {
439        Self::new(AshErrorCode::ProofInvalid, "Proof verification failed")
440    }
441
442    /// Canonicalization error.
443    ///
444    /// PT-002: Uses a fixed message to prevent caller-provided data from leaking
445    /// into error messages. All canonicalization failures produce the same generic
446    /// message regardless of the specific failure reason.
447    pub fn canonicalization_error() -> Self {
448        Self::new(
449            AshErrorCode::CanonicalizationError,
450            "Failed to canonicalize payload",
451        )
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn test_error_code_http_status() {
461        // Context errors (450-459)
462        assert_eq!(AshErrorCode::CtxNotFound.http_status(), 450);
463        assert_eq!(AshErrorCode::CtxExpired.http_status(), 451);
464        assert_eq!(AshErrorCode::CtxAlreadyUsed.http_status(), 452);
465        // Seal/Proof errors (460-469)
466        assert_eq!(AshErrorCode::ProofInvalid.http_status(), 460);
467        // Binding errors (461)
468        assert_eq!(AshErrorCode::BindingMismatch.http_status(), 461);
469        // Verification errors (473-479)
470        assert_eq!(AshErrorCode::ScopeMismatch.http_status(), 473);
471        assert_eq!(AshErrorCode::ChainBroken.http_status(), 474);
472        assert_eq!(AshErrorCode::ScopedFieldMissing.http_status(), 475);
473        // Format/Protocol errors (480-489)
474        assert_eq!(AshErrorCode::TimestampInvalid.http_status(), 482);
475        assert_eq!(AshErrorCode::ProofMissing.http_status(), 483);
476        assert_eq!(AshErrorCode::CanonicalizationError.http_status(), 484);
477        assert_eq!(AshErrorCode::ValidationError.http_status(), 485);
478        assert_eq!(AshErrorCode::ModeViolation.http_status(), 486);
479        // Standard HTTP codes (unique, 1:1 mapping)
480        assert_eq!(AshErrorCode::UnsupportedContentType.http_status(), 415);
481        assert_eq!(AshErrorCode::InternalError.http_status(), 500);
482    }
483
484    #[test]
485    fn test_error_code_as_str() {
486        assert_eq!(AshErrorCode::CtxNotFound.as_str(), "ASH_CTX_NOT_FOUND");
487        assert_eq!(AshErrorCode::CtxAlreadyUsed.as_str(), "ASH_CTX_ALREADY_USED");
488    }
489
490    #[test]
491    fn test_error_display() {
492        let err = AshError::ctx_not_found();
493        assert_eq!(err.to_string(), "ASH_CTX_NOT_FOUND: Context not found");
494    }
495
496    #[test]
497    fn test_error_convenience_functions() {
498        assert_eq!(
499            AshError::ctx_not_found().code(),
500            AshErrorCode::CtxNotFound
501        );
502        assert_eq!(
503            AshError::ctx_expired().code(),
504            AshErrorCode::CtxExpired
505        );
506        assert_eq!(
507            AshError::ctx_already_used().code(),
508            AshErrorCode::CtxAlreadyUsed
509        );
510    }
511
512    // CR-001: Verify serde serialization produces spec-compliant ASH_ prefixed strings
513    #[test]
514    fn test_error_code_serde_serialization() {
515        // Serialize: should produce ASH_ prefixed strings
516        let serialized = serde_json::to_string(&AshErrorCode::CtxNotFound).unwrap();
517        assert_eq!(serialized, r#""ASH_CTX_NOT_FOUND""#);
518
519        let serialized = serde_json::to_string(&AshErrorCode::ValidationError).unwrap();
520        assert_eq!(serialized, r#""ASH_VALIDATION_ERROR""#);
521
522        let serialized = serde_json::to_string(&AshErrorCode::ScopedFieldMissing).unwrap();
523        assert_eq!(serialized, r#""ASH_SCOPED_FIELD_MISSING""#);
524    }
525
526    #[test]
527    fn test_error_code_serde_deserialization() {
528        // Deserialize: should accept ASH_ prefixed strings
529        let code: AshErrorCode = serde_json::from_str(r#""ASH_CTX_NOT_FOUND""#).unwrap();
530        assert_eq!(code, AshErrorCode::CtxNotFound);
531
532        let code: AshErrorCode = serde_json::from_str(r#""ASH_PROOF_INVALID""#).unwrap();
533        assert_eq!(code, AshErrorCode::ProofInvalid);
534
535        let code: AshErrorCode = serde_json::from_str(r#""ASH_INTERNAL_ERROR""#).unwrap();
536        assert_eq!(code, AshErrorCode::InternalError);
537    }
538
539    #[test]
540    fn test_error_code_serde_roundtrip_all_variants() {
541        // Every variant must roundtrip through serde correctly
542        let all_codes = [
543            AshErrorCode::CtxNotFound,
544            AshErrorCode::CtxExpired,
545            AshErrorCode::CtxAlreadyUsed,
546            AshErrorCode::BindingMismatch,
547            AshErrorCode::ProofMissing,
548            AshErrorCode::ProofInvalid,
549            AshErrorCode::CanonicalizationError,
550            AshErrorCode::ValidationError,
551            AshErrorCode::ModeViolation,
552            AshErrorCode::UnsupportedContentType,
553            AshErrorCode::ScopeMismatch,
554            AshErrorCode::ChainBroken,
555            AshErrorCode::InternalError,
556            AshErrorCode::TimestampInvalid,
557            AshErrorCode::ScopedFieldMissing,
558        ];
559
560        for code in &all_codes {
561            let serialized = serde_json::to_string(code).unwrap();
562            // Verify ASH_ prefix is present
563            assert!(serialized.contains("ASH_"), "Missing ASH_ prefix for {:?}: {}", code, serialized);
564            // Verify roundtrip
565            let deserialized: AshErrorCode = serde_json::from_str(&serialized).unwrap();
566            assert_eq!(*code, deserialized, "Roundtrip failed for {:?}", code);
567            // Verify as_str() matches serialized value (minus quotes)
568            let expected = format!("\"{}\"", code.as_str());
569            assert_eq!(serialized, expected, "Serde output doesn't match as_str() for {:?}", code);
570        }
571    }
572
573    #[test]
574    fn test_retryable_timestamp_invalid() {
575        assert!(AshErrorCode::TimestampInvalid.retryable());
576    }
577
578    #[test]
579    fn test_retryable_internal_error() {
580        assert!(AshErrorCode::InternalError.retryable());
581    }
582
583    #[test]
584    fn test_not_retryable_proof_invalid() {
585        assert!(!AshErrorCode::ProofInvalid.retryable());
586    }
587
588    #[test]
589    fn test_not_retryable_validation_error() {
590        assert!(!AshErrorCode::ValidationError.retryable());
591    }
592
593    #[test]
594    fn test_not_retryable_all_permanent_codes() {
595        let permanent = [
596            AshErrorCode::CtxNotFound,
597            AshErrorCode::CtxExpired,
598            AshErrorCode::CtxAlreadyUsed,
599            AshErrorCode::ProofInvalid,
600            AshErrorCode::BindingMismatch,
601            AshErrorCode::ScopeMismatch,
602            AshErrorCode::ChainBroken,
603            AshErrorCode::ScopedFieldMissing,
604            AshErrorCode::ProofMissing,
605            AshErrorCode::CanonicalizationError,
606            AshErrorCode::ValidationError,
607            AshErrorCode::ModeViolation,
608            AshErrorCode::UnsupportedContentType,
609        ];
610        for code in &permanent {
611            assert!(!code.retryable(), "{:?} should not be retryable", code);
612        }
613    }
614
615    #[test]
616    fn test_ash_error_retryable_delegates() {
617        let retryable = AshError::new(AshErrorCode::TimestampInvalid, "skew");
618        assert!(retryable.retryable());
619
620        let permanent = AshError::new(AshErrorCode::ProofInvalid, "bad proof");
621        assert!(!permanent.retryable());
622    }
623
624    #[test]
625    fn test_error_code_serde_rejects_invalid() {
626        // Invalid error code strings should fail to deserialize
627        let result: Result<AshErrorCode, _> = serde_json::from_str(r#""INVALID_CODE""#);
628        assert!(result.is_err());
629
630        // Without ASH_ prefix should fail
631        let result: Result<AshErrorCode, _> = serde_json::from_str(r#""CTX_NOT_FOUND""#);
632        assert!(result.is_err());
633    }
634}