Skip to main content

stygian_charon/challenge_feedback/
mod.rs

1//! Challenge-aware policy feedback loop (T83, T110).
2//!
3//! ## What this module does
4//!
5//! Captures the **challenge outcome** of an acquisition attempt and
6//! feeds it back into the next policy planning cycle. Anti-bot vendors
7//! escalate their posture as they observe more challenges (more
8//! captchas, harder JS proofs, longer interstitials). A naïve
9//! scraper that replays the same strategy over and over teaches the
10//! vendor to escalate, eventually locking the scraper out.
11//!
12//! [`ChallengeMemory`] keeps a short-horizon record of the **last
13//! observed outcome** per [`EngineKey`] with a TTL and a
14//! max-entries cap (LRU eviction). [`adjust_runtime_policy`] (and
15//! [`build_runtime_policy_with_memory`]) consume the memory to
16//! nudge the risk score up (when the last outcome was adversarial)
17//! or down (when the last outcome was a clean pass).
18//!
19//! ## Why engine-keyed memory? (T110)
20//!
21//! The primary key of [`ChallengeMemory`] is the **engine** —
22//! `(engine, version, target_class, tls_profile)` — **not** the
23//! URL. A self-healing patch recorded against one URL on one
24//! engine heals every URL on that engine: a captcha workaround
25//! learned on `example.com/cloudflare/page1` is immediately
26//! applied to `example.com/cloudflare/page2` and to every other
27//! Cloudflare-fronted URL the runner sees. The URL is kept on each
28//! entry only as a secondary debugging index (see
29//! [`ChallengeMemoryEntry::last_observed_url`]) — it is **not** a
30//! primary key. The principle being encoded is the platform-keyed
31//! memory instinct from the source guide: *"the durable asset is
32//! not the record you extracted, it is the route you learned to
33//! it."*
34//!
35//! All four fields of [`EngineKey`] participate in the
36//! equivalence, hash, and ordering so re-keying a vendor version
37//! (`bot-manager-v3` → `bot-manager-v4`) or changing the TLS
38//! profile (`chrome136` → `firefox130`) deliberately produces a
39//! fresh memory slot. The four guard tests in
40//! [`ChallengeMemory`] exercise this property:
41//!
42//! - `same_engine_different_url_propagates_patch`
43//! - `same_engine_different_target_class_keeps_separate_memory`
44//! - `same_engine_different_tls_profile_keeps_separate_memory`
45//! - `engine_key_round_trips_through_display_fromstr_and_serde`
46//!
47//! ## Why a clamp?
48//!
49//! Influence bounds are **critical** for this module. A feedback
50//! loop that can shift the risk score arbitrarily would amplify
51//! noise: a single transient captcha would cascade into a full
52//! browser-stealth escalation that the site is not actually
53//! demanding. To prevent runaway strategy escalation, every per-key
54//! adjustment is **clamped to** [`MAX_RISK_DELTA`] (a documented,
55//! conservative `0.20` ceiling) and the final risk score is
56//! re-clamped to `[0.0, 1.0]` after the adjustment. Callers can
57//! tighten the clamp with [`ChallengeFeedbackPolicy::with_max_delta`]
58//! but cannot raise it above [`MAX_RISK_DELTA`].
59//!
60//! ## Backing store
61//!
62//! The LRU+TTL store is **shared** with the existing investigation
63//! report cache
64//! ([`crate::cache::MemoryInvestigationCache`]). It is exposed here
65//! as the crate-private LRU+TTL store
66//! helper so the challenge memory and the investigation cache
67//! share eviction + expiry semantics and we do not introduce a
68//! parallel "second cache store" with its own semantics.
69//!
70//! ## Feature flag
71//!
72//! The module is **default-on** (the `caching` feature is now part
73//! of `stygian-charon`'s default feature set, so the
74//! LRU+TTL store is always available). No new feature gate is
75//! introduced.
76//!
77//! # Example
78//!
79//! ```
80//! use stygian_charon::challenge_feedback::{
81//!     ChallengeMemory, ChallengeOutcome, EngineKey, adjust_runtime_policy, MAX_RISK_DELTA,
82//! };
83//! use stygian_charon::types::{
84//!     ExecutionMode, RuntimePolicy, SessionMode, TargetClass, TelemetryLevel,
85//! };
86//! use stygian_charon::vendor_classifier::VendorId;
87//! use std::collections::BTreeMap;
88//! use std::num::NonZeroUsize;
89//!
90//! let memory = ChallengeMemory::with_default_ttl(NonZeroUsize::new(64).expect("non-zero"));
91//! let key = EngineKey {
92//!     engine: VendorId::Cloudflare,
93//!     version: None,
94//!     target_class: TargetClass::ContentSite,
95//!     tls_profile: None,
96//! };
97//! memory.record(&key, Some("https://example.com/a"), ChallengeOutcome::Captcha);
98//!
99//! let policy = RuntimePolicy {
100//!     execution_mode: ExecutionMode::Http,
101//!     session_mode: SessionMode::Stateless,
102//!     telemetry_level: TelemetryLevel::Standard,
103//!     rate_limit_rps: 3.0,
104//!     max_retries: 2,
105//!     backoff_base_ms: 250,
106//!     enable_warmup: false,
107//!     enforce_webrtc_proxy_only: false,
108//!     sticky_session_ttl_secs: None,
109//!     required_stygian_features: Vec::new(),
110//!     config_hints: BTreeMap::new(),
111//!     risk_score: 0.20,
112//! };
113//!
114//! let adjusted = adjust_runtime_policy(&policy, &memory, &key);
115//! assert!(adjusted.risk_score >= policy.risk_score);
116//! assert!(adjusted.risk_score <= policy.risk_score + MAX_RISK_DELTA);
117//! ```
118
119mod key;
120mod memory;
121mod outcome;
122mod policy;
123
124pub use key::{EngineKey, EngineKeyParseError};
125pub use memory::{
126    ChallengeMemory, ChallengeMemoryEntry, DEFAULT_CHALLENGE_CAPACITY, DEFAULT_CHALLENGE_TTL,
127    engine_memory_key,
128};
129pub use outcome::ChallengeOutcome;
130pub use policy::{
131    ChallengeFeedbackPolicy, MAX_RISK_DELTA, adjust_runtime_policy,
132    build_runtime_policy_with_memory, memory_adjustment_for,
133};