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}