Skip to main content

stygian_charon/challenge_feedback/
outcome.rs

1use serde::{Deserialize, Serialize};
2
3use crate::types::TargetClass;
4
5/// Normalised label for the outcome of a single acquisition attempt.
6///
7/// The taxonomy is intentionally small and stable so that policy
8/// planning, vendor classification (T89), and change-detection
9/// feeds (T88) can all agree on a shared vocabulary. Each variant
10/// carries a stable `snake_case` wire label and a per-outcome
11/// [`risk_delta`][Self::risk_delta] that the feedback loop adds to
12/// the next runtime policy's risk score (subject to the documented
13/// [`MAX_RISK_DELTA`][crate::challenge_feedback::MAX_RISK_DELTA] clamp).
14///
15/// # Example
16///
17/// ```
18/// use stygian_charon::challenge_feedback::ChallengeOutcome;
19///
20/// let outcome = ChallengeOutcome::HardChallenge;
21/// assert_eq!(outcome.label(), "hard_challenge");
22/// assert!(outcome.risk_delta() > 0.0);
23/// ```
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ChallengeOutcome {
27    /// The request returned successfully (2xx) with no challenge
28    /// artefact in the response body or headers.
29    Pass,
30    /// The request returned a soft challenge (e.g. Cloudflare
31    /// "Just a moment…" interstitial, `403` with a JS challenge
32    /// script, or a slow-down page) that the runner eventually
33    /// solved without raising execution mode.
34    SoftChallenge,
35    /// The request returned a hard challenge (e.g. a `cf-chl-bypass`
36    /// token, a `DataDome` interstitial, an Akamai Bot Manager
37    /// challenge page) that required a browser-stealth strategy.
38    HardChallenge,
39    /// The request was blocked outright (e.g. `403`/`429` with no
40    /// challenge artefact — IP-level or fingerprint-level
41    /// rejection).
42    Blocked,
43    /// The request was served a CAPTCHA (reCAPTCHA, hCaptcha,
44    /// `DataDome` `captcha-delivery`, etc.) that could not be
45    /// solved automatically.
46    Captcha,
47}
48
49impl ChallengeOutcome {
50    /// Stable, human-readable label for telemetry / JSON output.
51    ///
52    /// # Example
53    ///
54    /// ```
55    /// use stygian_charon::challenge_feedback::ChallengeOutcome;
56    ///
57    /// assert_eq!(ChallengeOutcome::Pass.label(), "pass");
58    /// assert_eq!(ChallengeOutcome::SoftChallenge.label(), "soft_challenge");
59    /// assert_eq!(ChallengeOutcome::HardChallenge.label(), "hard_challenge");
60    /// assert_eq!(ChallengeOutcome::Blocked.label(), "blocked");
61    /// assert_eq!(ChallengeOutcome::Captcha.label(), "captcha");
62    /// ```
63    #[must_use]
64    pub const fn label(self) -> &'static str {
65        match self {
66            Self::Pass => "pass",
67            Self::SoftChallenge => "soft_challenge",
68            Self::HardChallenge => "hard_challenge",
69            Self::Blocked => "blocked",
70            Self::Captcha => "captcha",
71        }
72    }
73
74    /// Per-outcome risk-score contribution before clamping.
75    ///
76    /// The values are bounded by
77    /// [`MAX_RISK_DELTA`][crate::challenge_feedback::MAX_RISK_DELTA]
78    /// so a single entry never overshoots the documented ceiling
79    /// on its own. [`Pass`][Self::Pass] carries a small **negative**
80    /// contribution to gently de-escalate after clean runs; every
81    /// other outcome contributes a non-negative amount.
82    ///
83    /// # Example
84    ///
85    /// ```
86    /// use stygian_charon::challenge_feedback::ChallengeOutcome;
87    ///
88    /// assert!(ChallengeOutcome::Pass.risk_delta() < 0.0);
89    /// assert!(ChallengeOutcome::SoftChallenge.risk_delta() > 0.0);
90    /// assert!(ChallengeOutcome::HardChallenge.risk_delta() > 0.0);
91    /// assert!(ChallengeOutcome::Blocked.risk_delta() > 0.0);
92    /// assert!(ChallengeOutcome::Captcha.risk_delta() > 0.0);
93    /// ```
94    #[must_use]
95    pub const fn risk_delta(self) -> f64 {
96        match self {
97            Self::Pass => -0.10,
98            Self::SoftChallenge => 0.05,
99            Self::HardChallenge => 0.15,
100            Self::Blocked | Self::Captcha => 0.20,
101        }
102    }
103
104    /// Per-`(outcome, target_class)` risk-score contribution.
105    ///
106    /// Different target classes interpret the same outcome
107    /// differently: an API hitting a Captcha is anomalous (the
108    /// runner should back off — sign flips to negative), while the
109    /// same Captcha on a Content site is a normal anti-bot posture
110    /// that warrants a higher risk score. The
111    /// [`TargetClass::Unknown`] fallback returns `0.0` because the
112    /// runner has no signal to act on.
113    ///
114    /// # Example
115    ///
116    /// ```
117    /// use stygian_charon::challenge_feedback::ChallengeOutcome;
118    /// use stygian_charon::types::TargetClass;
119    ///
120    /// // API + Captcha is anomalous — sign flips to negative.
121    /// let api_captcha = ChallengeOutcome::Captcha.risk_delta_for(TargetClass::Api);
122    /// assert!(api_captcha < 0.0);
123    ///
124    /// // ContentSite + Captcha is a normal anti-bot posture — positive.
125    /// let content_captcha = ChallengeOutcome::Captcha.risk_delta_for(TargetClass::ContentSite);
126    /// assert!(content_captcha > 0.0);
127    ///
128    /// // Unknown class has no signal.
129    /// let unknown = ChallengeOutcome::Captcha.risk_delta_for(TargetClass::Unknown);
130    /// assert!(unknown == 0.0);
131    /// ```
132    #[must_use]
133    pub const fn risk_delta_for(self, target_class: TargetClass) -> f64 {
134        match target_class {
135            // Machine-to-machine targets should always pass.
136            // Any challenge is anomalous — flip the sign so positive
137            // deltas (Captcha/Blocked/HardChallenge/SoftChallenge)
138            // become negative ("back off — this is unusual"). The
139            // incidental flip of Pass to positive is acceptable:
140            // an API recording Pass is suspicious, not the baseline.
141            TargetClass::Api => -self.risk_delta(),
142            // Content sites and high-security targets both treat
143            // challenges as expected signal — raw keeps the signal
144            // flowing upward (negative for Pass, positive for any
145            // challenge).
146            TargetClass::ContentSite | TargetClass::HighSecurity => self.risk_delta(),
147            // Unknown class has no signal to act on.
148            TargetClass::Unknown => 0.0,
149        }
150    }
151}
152
153#[cfg(test)]
154#[allow(
155    clippy::unwrap_used,
156    clippy::expect_used,
157    clippy::panic,
158    clippy::indexing_slicing
159)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn labels_are_stable() {
165        assert_eq!(ChallengeOutcome::Pass.label(), "pass");
166        assert_eq!(ChallengeOutcome::SoftChallenge.label(), "soft_challenge");
167        assert_eq!(ChallengeOutcome::HardChallenge.label(), "hard_challenge");
168        assert_eq!(ChallengeOutcome::Blocked.label(), "blocked");
169        assert_eq!(ChallengeOutcome::Captcha.label(), "captcha");
170    }
171
172    #[test]
173    fn risk_deltas_are_bounded() {
174        for outcome in [
175            ChallengeOutcome::Pass,
176            ChallengeOutcome::SoftChallenge,
177            ChallengeOutcome::HardChallenge,
178            ChallengeOutcome::Blocked,
179            ChallengeOutcome::Captcha,
180        ] {
181            let delta = outcome.risk_delta();
182            assert!(
183                (-0.20..=0.20).contains(&delta),
184                "delta out of bounded range: {delta} for {outcome:?}"
185            );
186        }
187    }
188
189    #[test]
190    fn serde_round_trip_is_stable() {
191        for outcome in [
192            ChallengeOutcome::Pass,
193            ChallengeOutcome::SoftChallenge,
194            ChallengeOutcome::HardChallenge,
195            ChallengeOutcome::Blocked,
196            ChallengeOutcome::Captcha,
197        ] {
198            let json = serde_json::to_string(&outcome).expect("serialize");
199            let back: ChallengeOutcome = serde_json::from_str(&json).expect("deserialize");
200            assert_eq!(outcome, back);
201            assert_eq!(json, format!("\"{}\"", outcome.label()));
202        }
203    }
204}