Skip to main content

stygian_charon/challenge_feedback/
outcome.rs

1use serde::{Deserialize, Serialize};
2
3/// Normalised label for the outcome of a single acquisition attempt.
4///
5/// The taxonomy is intentionally small and stable so that policy
6/// planning, vendor classification (T89), and change-detection
7/// feeds (T88) can all agree on a shared vocabulary. Each variant
8/// carries a stable `snake_case` wire label and a per-outcome
9/// [`risk_delta`][Self::risk_delta] that the feedback loop adds to
10/// the next runtime policy's risk score (subject to the documented
11/// [`MAX_RISK_DELTA`][crate::challenge_feedback::MAX_RISK_DELTA] clamp).
12///
13/// # Example
14///
15/// ```
16/// use stygian_charon::challenge_feedback::ChallengeOutcome;
17///
18/// let outcome = ChallengeOutcome::HardChallenge;
19/// assert_eq!(outcome.label(), "hard_challenge");
20/// assert!(outcome.risk_delta() > 0.0);
21/// ```
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ChallengeOutcome {
25    /// The request returned successfully (2xx) with no challenge
26    /// artefact in the response body or headers.
27    Pass,
28    /// The request returned a soft challenge (e.g. Cloudflare
29    /// "Just a moment…" interstitial, `403` with a JS challenge
30    /// script, or a slow-down page) that the runner eventually
31    /// solved without raising execution mode.
32    SoftChallenge,
33    /// The request returned a hard challenge (e.g. a `cf-chl-bypass`
34    /// token, a `DataDome` interstitial, an Akamai Bot Manager
35    /// challenge page) that required a browser-stealth strategy.
36    HardChallenge,
37    /// The request was blocked outright (e.g. `403`/`429` with no
38    /// challenge artefact — IP-level or fingerprint-level
39    /// rejection).
40    Blocked,
41    /// The request was served a CAPTCHA (reCAPTCHA, hCaptcha,
42    /// `DataDome` `captcha-delivery`, etc.) that could not be
43    /// solved automatically.
44    Captcha,
45}
46
47impl ChallengeOutcome {
48    /// Stable, human-readable label for telemetry / JSON output.
49    ///
50    /// # Example
51    ///
52    /// ```
53    /// use stygian_charon::challenge_feedback::ChallengeOutcome;
54    ///
55    /// assert_eq!(ChallengeOutcome::Pass.label(), "pass");
56    /// assert_eq!(ChallengeOutcome::SoftChallenge.label(), "soft_challenge");
57    /// assert_eq!(ChallengeOutcome::HardChallenge.label(), "hard_challenge");
58    /// assert_eq!(ChallengeOutcome::Blocked.label(), "blocked");
59    /// assert_eq!(ChallengeOutcome::Captcha.label(), "captcha");
60    /// ```
61    #[must_use]
62    pub const fn label(self) -> &'static str {
63        match self {
64            Self::Pass => "pass",
65            Self::SoftChallenge => "soft_challenge",
66            Self::HardChallenge => "hard_challenge",
67            Self::Blocked => "blocked",
68            Self::Captcha => "captcha",
69        }
70    }
71
72    /// Per-outcome risk-score contribution before clamping.
73    ///
74    /// The values are bounded by
75    /// [`MAX_RISK_DELTA`][crate::challenge_feedback::MAX_RISK_DELTA]
76    /// so a single entry never overshoots the documented ceiling
77    /// on its own. [`Pass`][Self::Pass] carries a small **negative**
78    /// contribution to gently de-escalate after clean runs; every
79    /// other outcome contributes a non-negative amount.
80    ///
81    /// # Example
82    ///
83    /// ```
84    /// use stygian_charon::challenge_feedback::ChallengeOutcome;
85    ///
86    /// assert!(ChallengeOutcome::Pass.risk_delta() < 0.0);
87    /// assert!(ChallengeOutcome::SoftChallenge.risk_delta() > 0.0);
88    /// assert!(ChallengeOutcome::HardChallenge.risk_delta() > 0.0);
89    /// assert!(ChallengeOutcome::Blocked.risk_delta() > 0.0);
90    /// assert!(ChallengeOutcome::Captcha.risk_delta() > 0.0);
91    /// ```
92    #[must_use]
93    pub const fn risk_delta(self) -> f64 {
94        match self {
95            Self::Pass => -0.10,
96            Self::SoftChallenge => 0.05,
97            Self::HardChallenge => 0.15,
98            Self::Blocked | Self::Captcha => 0.20,
99        }
100    }
101}
102
103#[cfg(test)]
104#[allow(
105    clippy::unwrap_used,
106    clippy::expect_used,
107    clippy::panic,
108    clippy::indexing_slicing
109)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn labels_are_stable() {
115        assert_eq!(ChallengeOutcome::Pass.label(), "pass");
116        assert_eq!(ChallengeOutcome::SoftChallenge.label(), "soft_challenge");
117        assert_eq!(ChallengeOutcome::HardChallenge.label(), "hard_challenge");
118        assert_eq!(ChallengeOutcome::Blocked.label(), "blocked");
119        assert_eq!(ChallengeOutcome::Captcha.label(), "captcha");
120    }
121
122    #[test]
123    fn risk_deltas_are_bounded() {
124        for outcome in [
125            ChallengeOutcome::Pass,
126            ChallengeOutcome::SoftChallenge,
127            ChallengeOutcome::HardChallenge,
128            ChallengeOutcome::Blocked,
129            ChallengeOutcome::Captcha,
130        ] {
131            let delta = outcome.risk_delta();
132            assert!(
133                (-0.20..=0.20).contains(&delta),
134                "delta out of bounded range: {delta} for {outcome:?}"
135            );
136        }
137    }
138
139    #[test]
140    fn serde_round_trip_is_stable() {
141        for outcome in [
142            ChallengeOutcome::Pass,
143            ChallengeOutcome::SoftChallenge,
144            ChallengeOutcome::HardChallenge,
145            ChallengeOutcome::Blocked,
146            ChallengeOutcome::Captcha,
147        ] {
148            let json = serde_json::to_string(&outcome).expect("serialize");
149            let back: ChallengeOutcome = serde_json::from_str(&json).expect("deserialize");
150            assert_eq!(outcome, back);
151            assert_eq!(json, format!("\"{}\"", outcome.label()));
152        }
153    }
154}