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}