captchaforge 0.2.21

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.2.21] - 2026-05-11

### Real-human mouse trajectory replay (2026-05-11, round 23)

`mouse_move_bezier` (the only mouse-move helper since 0.1) dispatches a cubic Bézier path sampled at 8–20 evenly-spaced points with a random control polygon. Modern bot detectors (Akamai, DataDome, PerimeterX/HUMAN) train classifiers on per-pointer-event features that distinguish parametric curves from biomechanical motion: inter-event time distribution, trajectory curvature, two-stage velocity profiles (ballistic + corrective), and event density. A parametric Bézier with ease-in-out has a single smooth bell-shaped speed curve and monotone curvature — that's a fingerprint.

This release ships a small library of trajectories whose per-event features sit inside the human envelope, plus a replay helper that rotates/scales them onto a real `(x0, y0) → (x1, y1)` request.

#### Added
- **`captchaforge::mouse_traces`** — new top-level module with:
  - `Trace` / `TracePoint` — `(x_norm, y_norm, dt_ms)` triples in a normalised `(0,0) → (1,0)` start→end frame.
  - 6 hand-encoded bundled trajectories spanning the legitimate distribution: `direct_fast` (~370ms, two-stage velocity with mid-motion dip), `slow_careful` (~750ms, three corrective sub-movements + tremor), `arc_above` and `arc_below` (~470ms, natural elbow-pivot arcs), `overshoot_correct` (~580ms, snap-past-then-correct), `two_phase_pause` (~960ms, includes a 280ms mid-motion pause).
  - `pick_trace(rng)` — uniform random selection.
  - `trace_by_name(name)` — exact lookup.
  - `replay_trace(page, x0, y0, x1, y1, trace, jitter_max_px)` — rotates/scales onto the request, dispatches one `Input.dispatchMouseEvent` per point with the recorded inter-event delay and ±`jitter` per-axis pixel jitter so back-to-back replays don't produce byte-identical event streams.
  - `project_point(x_norm, y_norm, jitter, x0, y0, x1, y1)` — pure rotation+scale+translate, doctested.
  - `trace_duration_ms(trace)` — sum of recorded gaps; useful for budget calculations.
- **`captchaforge::behavior::mouse_move_human`** — picks a random bundled trace and replays it. Drop-in alternative to `mouse_move_bezier` that takes the same `(page, x0, y0, x1, y1)` shape; per-call cost ranges 250–1500ms depending on the picked trace.

#### Changed
- `TurnstileInteractiveSolver` warmup and approach phases now use `mouse_move_human` instead of `mouse_move_bezier`. Turnstile's risk model is one of the more aggressive parametric-curve fingerprinters; the trace-replay path produces an event distribution that doesn't match Bézier sampling.

#### Verified
- 386 → 403 lib tests (`+17`): 14 trace-module unit tests covering shape contracts (start at origin, end near target, monotonic-x except overshoot, arc orientation, pause presence, duration envelope) + project_point invariants (start lands at start, end lands at end, perpendicular rotation correctness, diagonal moves, degenerate zero-distance), plus 3 helper tests (pick_trace distribution coverage, builtin count constant, trace_by_name unknown returns None).
- 31 → 36 doctest (`+5`): `builtin_traces`, `trace_by_name`, `project_point` × 2, `trace_duration_ms`. All green.
- Clippy clean with `-D warnings`.

#### Notes
- The bundled trace shapes were synthesised, not literally recorded — what matters for fingerprint resistance is that they don't sample like a parametric Bézier. Each trace has been hand-tuned to exhibit one or more biomechanical signatures: two-stage velocity profile, non-monotone jerk, non-uniform sample density, or mid-motion pauses.
- Existing `mouse_move_bezier` callers continue working unchanged; trace replay is opt-in via the new helper.

## [0.2.20] - 2026-05-11

### reCAPTCHA v2 — dedicated audio-fallback solver (2026-05-11, round 22)

Adds `RecaptchaAudioSolver` under `src/solver/vendors/recaptcha_audio.rs`. The generic `AudioCaptchaSolver` already had reCAPTCHA-aware selectors but called `page.find_element` directly — and reCAPTCHA's audio widget lives inside the cross-origin `api2/bframe` iframe where the main-frame `DOM.querySelector` never sees it. The new dedicated solver walks frames via the shared `crate::frame` helpers (the same layer Turnstile-interactive and Behavioral use), so the bframe-internal controls are actually reachable.

#### Added
- **`src/solver/vendors/recaptcha_audio.rs`** — `RecaptchaAudioSolver` with:
  - Bframe-aware control discovery (`#recaptcha-audio-button`, `#audio-source`, `#audio-response`, `#recaptcha-verify-button`) via `frame::find_element_centre_in_frames`, so the cross-origin iframe origin is respected.
  - Passive-token short-circuit — if `g-recaptcha-response` is already populated when the solver runs, returns `SolveMethod::AutoPass` without touching the widget.
  - Bounded retry loop (`MAX_AUDIO_RETRIES = 2`) for the multi-clip flow reCAPTCHA sometimes serves after a correct first answer.
  - Deterministic rate-limit detection — `RATE_LIMIT_PHRASES` matched case-insensitively across all frame bodies. Returns failure with solution literal `"recaptcha:rate_limited"` so callers can branch on it.
  - Realistic mouse + keyboard via `crate::behavior::{mouse_move_bezier, click_realistic, type_realistic}` — bots that paste via `set value` get caught by reCAPTCHA's input-event timing model.
  - Configurable STT endpoint (`with_stt_endpoint`) defaulting to a local Whisper-compatible API. HTTP body is `audio/mpeg`; transcript is trimmed.
- **Pure helpers** (doctested):
  - `clean_transcript(s)` — lowercases, strips punctuation, collapses whitespace. STT often returns "Hello, World." but the response field is space-separated tokens.
  - `is_rate_limited(text)` — substring-matches reCAPTCHA's "automated queries" / "try again later" / "unusual traffic" phrases.
  - `looks_like_audio_url(s)` — guards against `data:`, `blob:`, relative URLs, and `//`-protocol-relative tricks before fetching.
- Chain wiring — `RecaptchaAudioSolver::new()` is added to `default_chain()` between `VlmCaptchaSolver` and `AudioCaptchaSolver`. For a `RecaptchaV2` detection, the bframe-aware path now runs BEFORE the page-only fallback.

#### Changed
- Default chain grew from 16 → 17 solvers. The order assertion test was renamed `default_chain_has_seventeen_solvers_in_documented_order` and updated to include the new entry at index 14.
- Six pre-existing vendor doctests (`akamai_interstitial`, `arkose`, `aws_waf`, `datadome`, `perimeterx`, `geetest` × 2) used `use captchaforge::solver::<vendor>::...` import paths that broke when the 0.2.19 vendors collation moved them under `solver::vendors::<vendor>`. Doctests updated to the new paths; all 31 doctests now pass (was 24 + 7 failing).

#### Verified
- 370 → 386 lib tests (`+16` from new solver — 8 unit tests + 5 helper tests + 3 selector/contract tests). All green.
- Clippy clean with `-D warnings`.
- New solver's `supports()` returns true for `RecaptchaV2` and `AudioCaptcha`; false for HCaptcha, Turnstile, and all vendor-dedicated kinds.

## [0.2.19] - 2026-05-11

### Vendors collation — `src/solver/vendors/` namespace (2026-05-11, round 21)

Eight vendor-dedicated solvers shipped over 0.2.10 → 0.2.18 (Cloudflare interstitial, Akamai, DataDome, PerimeterX, GeeTest, AWS WAF, Arkose, Turnstile interactive). Mixed in the flat `src/solver/` directory alongside cross-cutting generic solvers (audio, behavioral, math_captcha, pow, slider, vlm, wait_for_token), the layout had become hard to scan. This release moves vendor solvers under their own `vendors/` subdirectory while preserving the public re-export surface byte-for-byte.

#### Changed
- **`src/solver/vendors/`** — new subdirectory containing the 8 vendor-dedicated solvers (`akamai_interstitial.rs`, `arkose.rs`, `aws_waf.rs`, `cloudflare_interstitial.rs`, `datadome.rs`, `geetest.rs`, `perimeterx.rs`, `turnstile_interactive.rs`).
- `vendors/mod.rs` re-exports every vendor symbol; `src/solver/mod.rs` re-exports the entire `vendors::*` set so existing `use captchaforge::solver::ArkoseSolver` paths continue to work without change. Pure file-move + re-export refactor; no logic delta.
- README-style "Adding a new vendor" runbook now lives in `vendors/mod.rs` doc comment so the next vendor lands in the right place by default.

#### Verified
- 370 → 370 lib tests (no regression; every test that referenced a vendor solver continues to pass through the re-exports). Clippy clean.

## [0.2.18] - 2026-05-11

### ArkoseSolver — Arkose Labs / FunCaptcha dedicated handler (2026-05-11, round 20)

Arkose Labs (still trades the FunCaptcha brand for the consumer-facing puzzle) is the dominant rotation/orientation challenge vendor. Used by Twitter/X, EA, Roblox, LinkedIn for at-scale anti-abuse. Detection landed in 0.2.0's community pack but no dedicated solver; all Arkose sites fell through to VLM/slider — wrong shape for the silent-enforcement case (most common with proper stealth) and unable to surface the verification-token cleanly.

#### Added
- **`solver::arkose::ArkoseSolver`.** Polls page for Arkose state via JS probe — distinguishes `passed` (verification-token / fc-token populated, classifier validates), `puzzle` (challenge iframe rendered — yields to VLM via screenshot+failure for the rotation/match-this-image solve), `interstitial` (SDK loaded, no token yet — keep waiting), `unknown`. Reads token from hidden inputs OR window globals (handles both new SDK and legacy integrations).
- **`solver::arkose::is_valid_arkose_token`.** Pure validator. Two accepted shapes: new SDK 3-tuple (`<id>.<epoch>.<sig>` with epoch in [2020, 2100)) OR legacy fc-token pipe-separated parameters (must contain `|r=<region>` segment). Permissive on segment encoding since Arkose has rotated; firm on structural skeleton. Doctest covers both shapes.
- **Default chain now ships 16 solvers** (was 15). `ArkoseSolver` slots between `AwsWafSolver` and `MathCaptchaSolver`.

#### Changed
- 357 → 370 lib tests (+13). Clippy clean across `--all-targets`.

## [0.2.17] - 2026-05-11

### AwsWafSolver — AWS WAF Captcha cookie-pass + iv/context capture (2026-05-11, round 19)

AWS WAF's CAPTCHA action protects a substantial slice of AWS-hosted SaaS (Shopify, Lyft, Yelp, etc. via the AWS WAF JS Integration SDK). Detection landed in the 0.2.0 community rule pack but no dedicated solver existed; sites fell through to the generic slider, which doesn't see the cookie-pass case (the most common one with proper stealth) and which couldn't surface the `iv` + `context` primitives that custom verification flows need.

#### Added
- **`solver::aws_waf::AwsWafSolver`.** Polls page for AWS WAF state via JS probe — distinguishes `passed` (cookie set, classifier validates), `passed_with_iv` (SDK has staged `iv` + `context` but cookie hasn't landed yet — surfaces them as JSON solution payload for callers driving custom verification), `puzzle` (visible challenge iframe — yields to slider/VLM via screenshot+failure), `interstitial` (SDK loaded, no cookie yet — keep waiting), `unknown`.
- **`solver::aws_waf::is_valid_aws_waf_token`.** Pure validator. Enforces AWS's documented `<dotted>.<seg>:<expiry>:<sig>` shape: ≥40 chars total, ≥4 dot-separated segments before the first colon (no empty segment), numeric expiry in [2020, 2100), non-empty signature tail. Permissive on segment-byte encoding since AWS rotates between base64 and base64url; firm on the structural skeleton because that's held since GA in 2022. Doctest covers the structural cases.
- **Default chain now ships 15 solvers** (was 14). `AwsWafSolver` slots between `GeeTestSolver` and `MathCaptchaSolver`.

#### Changed
- 343 → 357 lib tests (+14). Clippy clean across `--all-targets`.

## [0.2.16] - 2026-05-11

### GeeTestSolver — v3 + v4 token-watch (2026-05-11, round 18)

GeeTest is the dominant CN bot-management captcha vendor (~3% top-1M globally, far higher inside China). Two on-the-wire protocols coexist (v3 = `gt+challenge` triple, v4 = `captchaId` quad), and a single solver that doesn't distinguish them either misses passes or false-positives. This release ships a version-aware token-watch that returns success on the right shape per protocol.

#### Added
- **`solver::geetest::GeeTestSolver`.** Polls page for v3 success triple (`geetest_challenge` / `geetest_validate` / `geetest_seccode`) OR v4 success quad (`lot_number` / `pass_token` / `gen_time` / `captcha_output`). Reads from hidden inputs OR window globals OR documented callback payloads. Returns success with the full token bundle as JSON in the `solution` field so downstream verification can use it directly. Yields cleanly to slider/VLM for the actual interaction (we don't reinvent that).
- **`solver::geetest::is_valid_v3_triple`** + **`is_valid_v4_quad`.** Pure validators. v3: each field non-empty AND seccode contains pipe OR ≥32 hex chars. v4: each field non-empty AND `gen_time` parses as a unix epoch in 2020–2100. Doctests demonstrate both.
- **`solver::geetest::GeeTestProtocol { V3, V4 }`** public enum for callers that want to introspect which protocol the page is using.
- **Default chain now ships 14 solvers** (was 13). `GeeTestSolver` slots between `PerimeterXSolver` and `MathCaptchaSolver`.

#### Changed
- 329 → 343 lib tests (+14). Clippy clean across `--all-targets`.

## [0.2.15] - 2026-05-11

### PerimeterXSolver — dedicated PX / HUMAN Security handler (2026-05-11, round 17)

PerimeterX (rebranded HUMAN Bot Defender, 2022) protects ~5% of top-1M sites. Detection landed in the 0.2.0 community rule pack, but every PX-protected site fell through to the generic slider — wrong shape for PX's distinctive press-and-hold widget and cookie-pass interstitial. This release ships dedicated handling for the cookie-pass case and clean yield-to-VLM signaling for press-and-hold.

#### Added
- **`solver::perimeterx::PerimeterXSolver`.** Polls page for PX state via JS probe — distinguishes `passed` (cookie set, classifier validates), `press_and_hold` (yields to VLM via screenshot+failure when `#px-captcha` widget present — we don't ship dedicated press-and-hold geometry yet), `interstitial` (script loaded, no cookie yet — keep waiting), `blocked` (PX block page with reference code), `unknown`. Returns success when `_px3` (v3) or `_pxhd` (legacy device-history) cookie carries a real-shape session token.
- **`solver::perimeterx::classify_px_cookie`** + **`PxCookie { Missing, Pending, Valid }`.** Pure classifier — Missing (no cookie), Pending (<30 chars or no separator), Valid (≥30 chars and contains `:` or `_` separator). Permissive on inner-segment shape since PX has rotated payload format twice in three years; firm on length floor since that's held across all observed revisions. Doctest covers all three cases.
- **Default chain now ships 13 solvers** (was 12). `PerimeterXSolver` slots between `DataDomeSolver` and `MathCaptchaSolver`.

#### Changed
- 317 → 329 lib tests (+12). Clippy clean across `--all-targets`.

## [0.2.14] - 2026-05-11

### DataDomeSolver — dedicated DataDome cookie-pass + interstitial handler (2026-05-11, round 16)

DataDome is the largest pure-play bot-management vendor outside Cloudflare and Akamai (~7% of top-1M sites per BuiltWith Q1 2026). Detection landed in 0.2.0's community rule pack, but every DataDome-protected site fell through to the generic slider solver — the wrong shape when DataDome's protection is the cookie-pass / interstitial JS challenge (the slider only appears as the third-tier escalation). This release ships dedicated handling for the cookie-pass and interstitial cases.

#### Added
- **`solver::datadome::DataDomeSolver`.** Polls page for DataDome state via JS probe — distinguishes `passed` (cookie set, classifier validates), `slider` (yields to `SliderCaptchaSolver` via screenshot+failure when `captcha-delivery.com` iframe present), `interstitial` (script loaded, no cookie yet — keep waiting), `blocked` (block page), `unknown`. Returns success when the `datadome` cookie carries the long-form base64-ish session token.
- **`solver::datadome::classify_cookie`** + **`DataDomeCookie { Missing, Pending, Valid }`.** Pure classifier — Missing (no cookie), Pending (placeholder / sensor not yet POSTed — short or >50% tildes), Valid (≥80 chars, ≤50% tildes). Public so callers can replay-and-classify outside the chain. Doctest covers all three cases.
- **Default chain now ships 12 solvers** (was 11). `DataDomeSolver` slots between `AkamaiInterstitialSolver` and `MathCaptchaSolver`.

#### Changed
- 307 → 317 lib tests (+10). Clippy clean across `--all-targets`.

## [0.2.13] - 2026-05-11

### AkamaiInterstitialSolver — dedicated Akamai Bot Manager handler (2026-05-11, round 15)

Akamai Bot Manager (ABM) protects ~30% of the Fortune-500 web footprint. Captchaforge previously only detected Akamai (via the bundled community rule) but had no dedicated solver — every Akamai-protected site fell through to behavioural simulation, which is the wrong shape (Akamai isn't a click-the-checkbox vendor; it's a sensor-data interstitial). This release ships a dedicated solver for the same pattern that already worked for Cloudflare's "Just a moment" page.

#### Added
- **`solver::akamai_interstitial::AkamaiInterstitialSolver`.** Polls the page for Akamai signals (sensor-script tags, `bmak` global, "Press & Hold" challenge text) and the `_abck` cookie. When the cookie's third hyphen-segment flips to `-1`, harvests cookies and returns success. Suspicious states (positive flag) and Akamai's own "Access Denied / Reference #" block page return failure so the chain falls through.
- **`solver::akamai_interstitial::classify_abck`** + **`AbckValidity { Pending, Valid, Suspicious }`.** Pure classifier exposed publicly so callers can replay-then-classify outside the chain (e.g. session-warming flows that only want to know if the cookie is good). Doctest demonstrates the four cases.
- **Default chain now ships 11 solvers** (was 10). `AkamaiInterstitialSolver` slots between `CloudflareInterstitialSolver` and `MathCaptchaSolver` so any Akamai-shaped detection routes through it before generic fallbacks.

#### Changed
- 295 → 307 lib tests (+12). Clippy clean across `--all-targets`.

## [0.2.12] - 2026-05-11

### Bench oracle wiring — every Observation row carries the page-state verdict (2026-05-11, round 14)

The bench's `success` column reports "outcome matched expectation" — useful, but says nothing about *why* the outcome was what it was. Was the solver successful AND the page actually advanced? Was the solver successful but the captcha was still on screen (recycled — green-checkmark lie)? This release plumbs the 0.2.10 oracle's verdict into every bench row so the audit trail is complete.

#### Added
- **`Observation.verified_outcome: Option<String>`.** The chain's `verified_outcome` (one of `"advanced"` / `"recycled"` / `"hard_block"` / `"silent_fail"` / `"unknown"`, or `None` when verify_outcome was off / detection failed before any solver ran). Serialised in JSON reports with `skip_serializing_if = "Option::is_none"` so old reports stay parseable but new ones surface the verdict.
- **`real_waf` suite populates `verified_outcome` from `result.verified_outcome`.** The chain runs with `verify_outcome: true` by default, so every real_waf row now reports the oracle's view alongside the expectation match.
- **All 7 other suites** (accuracy, consistency, detection, solve, stress, throughput, regression) initialised the field as `None` so old behaviour is preserved; opt-in plumbing per-suite as those suites grow oracle-aware.

#### Changed
- 30 → 30 int test bins green (none broken). Workspace clippy clean.

## [0.2.11] - 2026-05-11

### TurnstileInteractiveSolver — dedicated managed-Turnstile solver (2026-05-11, round 13)

Cloudflare Turnstile is the most-deployed captcha on the public internet (~12M sites per BuiltWith Q1 2026). The generic `BehavioralCaptchaSolver` already attempts a checkbox click for it, but the production iframe is cross-origin (so `iframe.contentDocument` is null), and Cloudflare's risk model penalises clicks that arrive without warmup. Real-world solve rate on managed Turnstile was therefore nowhere near "legendary." This release ships a dedicated solver that closes those gaps.

#### Added
- **`solver::turnstile_interactive::TurnstileInteractiveSolver`.** Dedicated managed-Turnstile solver. Locates the cross-origin widget by reading the iframe's `getBoundingClientRect` from the *parent* document (which we always have access to) and applying the stable in-iframe checkbox offset (`CHECKBOX_OFFSET_X = 28`, `CHECKBOX_OFFSET_Y = 32`). Pre-click warmup: two bezier mouse meanders with 120–380ms gaps so Cloudflare's risk model sees activity that doesn't immediately resolve to the checkbox. Approach with overshoot+correction, jittered press hold, then poll for the `cf-turnstile-response` token. When Cloudflare escalates to a managed-challenge sub-iframe (`challenge-platform/scripts/`), the solver short-circuits with a screenshot so VLM gets a turn rather than spinning blindly. 6 unit tests pin the iframe selector, checkbox offsets, support gating (Turnstile only — never claims hCaptcha or other vendors), and SolveConfig wiring. Public constants: `TURNSTILE_IFRAME_SELECTOR`, `CHECKBOX_OFFSET_X`, `CHECKBOX_OFFSET_Y`.
- **Default chain now ships 10 solvers** (was 9). `TurnstileInteractiveSolver` slots between `SliderCaptchaSolver` and `BehavioralCaptchaSolver`, so Turnstile-specific logic always wins for `DetectedCaptcha::Turnstile` while every other vendor still falls through to the generic behavioural path.

#### Changed
- 289 → 295 lib tests (+6). Clippy clean across `--all-targets`.

## [0.2.10] - 2026-05-11

### Outcome-verification oracle + stealth profiles + token-replay oracle (2026-05-11, round 12)

A token returned by a solver is a *claim*, not a *proof*. This release closes that gap on three fronts: page-state verification (did the page actually advance?), browser fingerprint coherence (does the UA agree with the platform/GPU/screen?), and token replay (does the verify endpoint accept the token?). Wafrift-quality bar: trust nothing, verify everything.

#### Added
- **`solver::oracle` — outcome-verification oracle.** `PageSnapshot { url, title, body_excerpt, captcha_present, cookie_names }` and `classify(before, after) -> OutcomeClassification` (Advanced / Recycled / HardBlock / SilentFail / Unknown). Pure classifier — testable without CDP. `take_snapshot(page)` is the CDP-bound counterpart. 11 unit tests + 3 doctests covering the full transition matrix. `BLOCK_PHRASES` constant exposes the curated WAF block-marker list.
- **`ChainConfig::verify_outcome` (default `true`).** Chain now snapshots page state before each solver attempt and re-snapshots after each claimed-success result. When the oracle classifies the outcome as HardBlock / Recycled / SilentFail, `success` is overwritten to `false` and the failure-path side effects fire (telemetry, pattern store) so naive callers can't be fooled by a green-checkmark token that didn't actually advance the page. Eliminates the entire "solver lied" failure mode.
- **`CaptchaSolveResult.verified_outcome: Option<OutcomeClassification>`.** Every result now carries the oracle's verdict. `Some(Advanced)` is the only value that *proves* the page advanced; every other variant downgrades a token to "claimed but unverified". `None` only when the chain ran with `verify_outcome` off.
- **`stealth_profiles` — named browser fingerprints.** `StealthProfile` with 5 coherent variants: `ChromeWindowsStable`, `ChromeMacStable`, `EdgeWindowsStable`, `FirefoxLinux`, `ChromeAndroid`. Each pins a coherent (UA, platform, brands, GPU vendor/renderer, screen, languages, hardwareConcurrency, deviceMemory) tuple — UA-vs-platform mismatches are *more* suspicious than vanilla headless, so the profiles never cross-pollinate. `apply_stealth_profile(page, profile)` injects via CDP `addScriptToEvaluateOnNewDocument` after `apply_stealth`. 7 unit tests including the cross-coherence guard (Edge profile must NOT list Google Chrome brand).
- **`solver::token_oracle` — token-replay validator.** `TokenValidator` POSTs the solved token to a configured verify endpoint and classifies the response (`Accepted` / `Rejected` / `Inconclusive`). Catches demo-sitekey tokens, expired tokens, IP-rep rejections, origin-bound tokens — every "we have a token" failure mode that page-state oracle alone misses. Builder API: `with_field` / `with_encoding` (FormUrlEncoded / Json / BearerHeader) / `with_method` (Post / Get) / `with_timeout`. `classify_response(status, body)` exposed as a pure function for synthetic tests. 7 unit tests.
- **`Config.verify_outcome: Option<bool>` (TOML).** Override `ChainConfig.verify_outcome` from `.captchaforge.toml`. Defaults to chain default (`true`).

#### Changed
- 274 → 289 lib tests (+15), 18 → 21 doctests (+3).
- `MathCaptchaSolver::supports`, `PowCaptchaSolver::supports`, `SliderCaptchaSolver::supports`, `WaitForTokenSolver::supports` continue to apply the per-vendor name guards added in 0.2.9; verify_outcome now closes the residual gap where a name-permitted vendor responds differently than expected.

### Architecture sweep + community rule pack (2026-05-11)

#### Added
- **Per-detector module split.** `detect.rs` (1442 LOC) → `src/detect/` with one file per built-in detector (`turnstile.rs`, `recaptcha.rs`, `hcaptcha.rs`, `image_captcha.rs`, `audio_captcha.rs`, `pow_captcha.rs`, `slider_captcha.rs`, `canvas_captcha.rs`, `multi_step.rs`, `shadow_dom.rs`, `challenge_page.rs`). New captcha types ship as one self-contained file.
- **`CaptchaProvider` trait + `ProviderRegistry`.** Bundles a detector with its recommended `SolveMethod` ordering. Built-in providers wired via macro. `ProviderRegistry::with_built_in_rules()` returns built-in providers + bundled community rule pack, priority-sorted.
- **TOML rule layer (`detect::rules`).** Community-extensible captcha detectors as data files. Selector / window-global / script-src triggers compile to deterministic, escaped JS probes. Bundled `rules/community.toml` covers AWS WAF Captcha, DataDome, Akamai Bot Manager, PerimeterX/HUMAN, Arkose/FunCaptcha, Geetest v3+v4, Friendly Captcha, MTCaptcha, WordPress math captchas — 10 vendors with zero Rust per vendor.
- **`SolverTelemetry` trait + `SolveEvent`.** One event per solver attempt with outcome (`Success` / `Failure` / `Error` / `Timeout`), time, confidence, domain, captcha kind, method. Default `NoopTelemetry`. Cache hits also fire as Success with `solver: "TokenCache"` so dashboards distinguish cache-served from solved-from-scratch. Install via `chain.with_telemetry(Arc::new(...))`.
- **`TokenCache` with TTL.** Per-`(domain, captcha_type)` solved-token cache (60s default). Chain checks before solving; cache hit returns immediately and reports through telemetry. `chain.cached_solution(info)` exposes the standalone short-circuit for callers without a Page.
- **`PatternStore` persistence.** `save_to_path` / `load_from_path` (atomic JSON via temp+rename), `merge` (cross-instance share, higher `sample_count` wins), `load_or_default` (graceful first-boot path).
- **`auto_solve(page)` top-level convenience.** One-call detect + solve with the bundled provider registry + default chain.
- **CLI binary (`captchaforge`).** Subcommands `inspect-rules` / `detect <url>` / `solve <url>` with `--rules <file>` for additive extra TOML rule packs. Lives in `cli/` workspace member.
- **Per-rule HTML fixtures.** Every bundled rule has positive + negative HTML fixtures under `tests/rule_fixtures/<vendor>/`. The `community_rules` integration test asserts each rule fires on positive, rejects negative, and doesn't cross-fire on any other vendor's negative.
- **Chain ↔ cache ↔ telemetry integration tests.** Verify cache hit fires exactly one Success event with `solver: "TokenCache"`, miss fires nothing, domain isolation works, builder methods install passed instances.

#### Changed
- **`DetectedCaptcha` is `#[non_exhaustive]`.** Adding vendors is no longer a breaking change for downstream `match`. New `Custom(String)` variant lets TOML rules surface vendor identifiers without enum edits.

#### Fixed
- **Custom captchas now route through provider hints.** Previously every solver's `supports()` returned false for `DetectedCaptcha::Custom`, so all 10 TOML-detected vendors detected successfully but got no solver attempt — chain silently returned unsolved. Chain now consults the installed `ProviderRegistry` for Custom kinds and filters solvers by the provider's `recommended_solver_methods`. Install via `chain.with_provider_registry(...)` (`auto_solve` does this for you).

### Legendary push 3: name-routing + 6 new WAFs + CF interstitial (2026-05-11, round 11)

#### Architecture
- **`CaptchaProvider::recommended_solver_names`** trait method (default
  empty). Chain prefers name-keyed routing over method-keyed when set,
  eliminating collisions between solvers sharing a `SolveMethod`
  (Math, Pow, Slider, Behavioral all = `BehavioralBypass`).
- **Provider routing for built-in kinds** — chain consults the
  `ProviderRegistry` for `Turnstile` / `RecaptchaV2` etc. when the
  provider declares names. Lets dedicated vendor solvers
  (`CloudflareInterstitialSolver`) win over generics.
- **`ProviderRule::solver_names`** TOML field. Vendors declare
  preferred solvers by name in `community.toml`; RuleDetector exposes
  them via the new trait method.

#### New solvers
- **`CloudflareInterstitialSolver`** — handles the "Just a moment..."
  5-second JS challenge that fronts CF-protected sites. Distinct from
  the Turnstile widget. Passive wait + cf_clearance cookie capture.
  With stealth, the challenge passes; without, falls through cleanly.

#### New WAF rules (community.toml)
- **Imperva Incapsula** — JS interstitial → routes via
  CloudflareInterstitialSolver pattern.
- **Kasada** — x-kpsdk-ct token challenge → WaitForToken + Interstitial
  fallback.
- **F5 Distributed Cloud Bot Defense** (formerly Shape Security) →
  CloudflareInterstitialSolver pattern.
- **Yandex SmartCaptcha** — slider + image-grid → Slider + VLM.
- **Tencent Captcha** — slider + click-image → Slider + VLM.
- **KeyCaptcha** — drag-puzzle → Slider.

Each has positive + negative HTML fixtures under
`tests/rule_fixtures/<vendor>/` and asserts no cross-firing on other
vendors' negatives.

#### Slider solver coverage expanded
- Selectors added for Yandex / Tencent / KeyCaptcha.
- `supports()` extended to claim those vendor names.

#### Default chain order
- Nine solvers: WaitForToken → CloudflareInterstitial → Math → Pow →
  Slider → Behavioral → VLM → Audio → ThirdParty.

### Legendary push 2: slider, race, warmup, tier-2 stealth (2026-05-11, round 9)

#### Added
- **`solver::SliderCaptchaSolver`** — generic gap-detection + drag
  solver covering GeeTest v3+v4, DataDome, PerimeterX/HUMAN, AWS WAF
  Captcha, Akamai's slider variant, and homegrown sliders. Detects
  gap via canvas pixel-column brightness delta; drags with bezier
  trajectory + overshoot+correction.
- **`solver::RacingSolver`** — wraps N inner solvers and races them
  in parallel via FuturesUnordered, returning the first valid token.
  Cuts p99 latency, routes around third-party outages. Critical for
  the "0.00001% failure" goal.
- **`captchaforge::warmup`** module + `warm_session(page, duration)`.
  Runs natural mouse meander / scroll / hover for a budget of ms
  before captcha solving so reCAPTCHA v3 / Turnstile passive /
  hCaptcha invisible see a session that looks like a real visitor.
- **Tier-2 stealth** — canvas fingerprint noise (toDataURL +
  getImageData alpha-bit jitter), AudioContext fingerprint noise
  (getChannelData ±1e-7 sample noise), WebRTC IP leak prevention
  (RTCPeerConnection setLocalDescription SDP IP rewrite),
  navigator.hardwareConcurrency capped at 8, navigator.deviceMemory
  pinned to 8GB.

#### Changed
- **`SolveMethod` is `#[non_exhaustive]`.** New `SolveMethod::AutoPass`
  variant for the WaitForToken path. Future additions won't break
  downstream `match`.
- **Default chain order** — eight solvers now: WaitForToken → Math
  → Pow → Slider → Behavioral → VLM → Audio → ThirdParty.

### Legendary push: HTTPS bench, no automation flags, math + PoW solvers (2026-05-11, round 8)

#### Added
- **`solver::MathCaptchaSolver`** — parses math-captcha questions
  ("3 + 5 = ?", "what is two plus four", "12 ÷ 4") from page text and
  types the answer. Handles digit numbers, word numbers (zero-twenty),
  and word operators (plus/minus/times/divided by). Free, ~50ms.
  Restricted to math-flavoured Custom names + PowCaptcha + Image/Canvas
  so it doesn't claim slider/click captchas.
- **`solver::PowCaptchaSolver`** — solves ALTCHA / Friendly Captcha /
  MCaptcha / Cap.dev. Computes SHA-256 PoW in-page via SubtleCrypto
  for ALTCHA (active) or polls the widget's own worker output for
  Friendly / MCaptcha / Cap.dev. No third-party API, no LLM.
- **HTTPS bench server (rcgen self-signed TLS).** Bench fixtures now
  load from `https://localhost:<port>` so vendors that refuse
  non-HTTPS origins (modern Cloudflare / hCaptcha / Google) can
  actually issue tokens against test sitekeys. PREREQUISITE for
  honest auto-pass measurement.
- **Bench chromium config strips automation signals.** Removed
  `--enable-automation` (it was never explicit but chromiumoxide
  defaults add it), added `--exclude-switches=enable-automation`,
  `--disable-blink-features=AutomationControlled`, and the
  `--ignore-certificate-errors` family for the self-signed bench
  TLS.

#### Changed
- **Default chain order** — seven solvers now:
  WaitForToken → Math → Pow → Behavioral → VLM → Audio → ThirdParty.
  Cheapest solvers run first.
- **`Config::build_chain()`** mirrors the same order.

### Stealth module + honest bench results (2026-05-11, round 7)

#### Added
- **`captchaforge::stealth` module** + `apply_stealth(page)` helper.
  Injects CDP `Page.addScriptToEvaluateOnNewDocument` overrides
  for `navigator.webdriver`, `navigator.plugins`,
  `navigator.languages`, `Notification.permission`, `window.chrome`,
  `WebGLRenderingContext.getParameter` (vendor + renderer), and
  `HTMLIFrameElement.contentWindow.chrome`. Without these, modern
  WAFs (Cloudflare, Akamai, PerimeterX/HUMAN, DataDome) detect
  headless chromium *trivially* via `navigator.webdriver === true`
  and refuse to issue a passive-pass token regardless of mouse
  cleverness.
- **PRODUCTION.md "Stealth — required for production" section** —
  documents the surface, when to call `apply_stealth`, and the
  things stealth does NOT cover (UA string, TLS fingerprint, IP
  reputation) that operators must handle separately.
- **`auto_solve()` calls `apply_stealth` defensively** before the
  detection step. Production deployments should still call it
  themselves immediately after `Browser::new_page`, before the
  first navigation — that's the only way to catch first-page
  detection probes.
- **Bench `BrowserPool::reset_page` reapplies stealth** so every
  bench iteration starts with fresh stealth overrides — without
  this, the real-WAF suite reports 0% success on auto-pass
  fixtures purely because Cloudflare/hCaptcha/Google detect headless
  chromium and refuse the passive-pass token.
- **Bench `real_waf` suite uses a 12-second `WaitForTokenSolver`
  budget** (vs 3s default) — production passive Cloudflare can take
  5-10s to issue the token.

### WaitForToken passive-pass solver + honest real-WAF fixtures (2026-05-11, round 6)

#### Added
- **`solver::WaitForTokenSolver`** — first solver in the default
  chain. Polls common vendor response fields (`cf-turnstile-response`,
  `g-recaptcha-response`, `h-captcha-response`, plus invisible
  reCAPTCHA's `grecaptcha.getResponse()` and Friendly Captcha /
  MTCaptcha fields) and returns the token when the vendor populates
  it. Handles the most common production case — passive-pass widgets
  (Turnstile passive mode, reCAPTCHA v3, hCaptcha invisible) — without
  ever clicking, screenshotting, or calling a paid API. 3-second
  default max-wait when used as the chain's lead solver; if the field
  stays empty the chain falls through to behavioural / VLM /
  third-party as before. Cheapest possible solve path.
- **`SolveMethod::AutoPass`** + `#[non_exhaustive]` on the enum so
  future additions don't break downstream `match`. Telemetry events
  from the new solver carry this method tag.
- **`REAL_TURNSTILE_BLOCK` fixture** (Cloudflare always-block test
  sitekey `2x00000000000000000000AB`). Bench expects FAILURE — proves
  the chain doesn't fake-pass against a sitekey that issues no token.
- **`REAL_TURNSTILE_INTERACTIVE` fixture** (Cloudflare force-interactive
  test sitekey `3x00000000000000000000FF`). Honest gap measurement for
  the behavioural solver — passive-pass cannot help here, and any
  success% > 0 means real interactive solving worked.
- **`real_waf` suite gained per-fixture `Expectation`** (AutoPass /
  AlwaysBlock / ForceInteractive). The reported `success` column is
  "outcome matched expectation" — solving a block fixture is an
  INTEGRITY FAILURE, NOT a green row.

#### Changed
- **`CaptchaSolverChain::default_chain()` order**:
  `WaitForToken (3s) → Behavioral → VLM → Audio → ThirdParty`. Five
  solvers; existing tests updated. The cheap solver runs first so
  passive-pass widgets short-circuit the chain before any expensive
  layer fires.
- **`Config::build_chain()`** mirrors the same order so
  `auto_solve()` and the CLI inherit the change.

### Production-ready: bench rewire, real-WAF proof, retry, metrics, security warn, PRODUCTION.md (2026-05-11, round 5)

#### Added
- **`real_waf` bench suite (opt-in)** — drives the production
  `Config::default().build_chain()` against the real
  `challenges.cloudflare.com`, `js.hcaptcha.com`, and
  `google.com/recaptcha/api.js` scripts using each vendor's published
  test sitekey. Asserts the chain returns a token that's actually
  shaped like a real one (rejects the local-fixture `XXXX.DUMMY.TOKEN`
  stubs). Skipped by default; pass `--with-network` (or `--suite real-waf`)
  to enable. Three new fixtures: `REAL_TURNSTILE`, `REAL_HCAPTCHA`,
  `REAL_RECAPTCHA_V2`.
- **`solver::CacheStats` + `TokenCache::stats()` / `reset_stats()`** —
  atomic hit/miss/expired/put/invalidate counters and a `hit_rate()`
  helper. Production deployments scrape `stats()` each minute to plot
  cache effectiveness; a high `expired_misses : hits` ratio is the
  signal that TTL is shorter than the typical re-visit window.
- **Transient-error retry + exponential backoff in `ThirdPartyCaptchaSolver`** —
  3 attempts with 250ms→500ms→1s→2s backoff (capped 4s) on `submit_task`
  and per-poll HTTP. Distinct from the terminal-API-error fail-fast
  already shipped: terminal codes (bad key, zero balance, banned IP)
  short-circuit on first hit; transient HTTP / 5xx retries to recover
  from network blips. New tests cover all four retry-state-machine
  branches.
- **`Config::warn_on_inline_secrets(path)`** — `Config::load_from_path`
  emits a `tracing::warn!` when `[third_party] api_key` is set inline,
  recommending `CAPTCHAFORGE_THIRDPARTY_API_KEY` env var instead.
  Stops accidental secret commits to git alongside config.
- **`PRODUCTION.md` deployment guide** — 200+ lines covering
  pre-launch sanity checks, the three configuration layers, telemetry
  sink choice + alert thresholds, token-cache lifecycle gotchas,
  pattern-store persistence patterns, third-party adapter operational
  notes, browser lifecycle / crash recovery, security guidance, and
  upgrade procedure.

#### Changed
- **Bench `solve` / `accuracy` / `throughput` suites rewired to
  `Config::default().build_chain()`.** Previously instantiated
  `BehavioralCaptchaSolver` etc. directly, bypassing the cache,
  provider registry, third-party solver, and telemetry — so the bench
  measured the individual solvers, not the production pipeline.
  Now reports the chain's actual winning method per fixture; cache
  short-circuit is part of the measured path. The `solver` column is
  the chain's `r.method` instead of a hand-coded label.
- **Bench `--suite real-waf` (CLI variant) + `--with-network` flag**
  added so `--suite all --with-network` runs the network-dependent
  proof end-to-end. Without `--with-network`, `--suite all` skips
  `real_waf` so CI offline doesn't fail.
- **Default fixture count: 34 → 37** (added 3 real-WAF fixtures).
  `fixtures_count_is_34` test renamed to `fixtures_count_is_37`.

#### Fixed
- **CLI `cmd_solve` no longer duplicates chain construction** —
  delegates to `Config::build_chain()`, single source of truth shared
  with `auto_solve`.

### Tier-A discovery, cache-cookies bridge, fail-fast third-party (2026-05-11, round 4)

#### Added
- **`Config::build_chain()`** — single canonical chain constructor: chain config + token cache + provider registry + Behavioral + VLM (env/TOML-aware) + Audio + Third-party. Used by both `auto_solve()` and the CLI's `solve` subcommand so they share the same wiring.
- **`TokenCache::put_full(.., cookies)`** + `CachedToken::cookies()`. Cache entries now carry both the solved token AND the cookies captured at the original solve. Cache hits replay the trusted session via `CaptchaSolveResult::cookies` so the next request rides the WAF/vendor's session — without this a hit returned only the token and the next navigation re-triggered the captcha. Back-compat `put` / `put_with_ttl` continue to work (cookies field stays empty).
- **Terminal-error fail-fast in `ThirdPartyCaptchaSolver::poll_result`.** Maps the documented 2captcha error codes (`ERROR_KEY_DOES_NOT_EXIST`, `ERROR_ZERO_BALANCE`, `IP_BANNED`, `ERROR_GOOGLEKEY`, `ERROR_CAPTCHA_UNSOLVABLE`, etc.) to immediate `Err` instead of polling 30 more times — saves up to 150s of wall-clock per doomed task and avoids submitting-without-results for paid services.

#### Changed
- **`captchaforge::auto_solve(page)` discovers Tier-A config.** Calls `Config::discover()` and builds the chain via `build_chain()`, so a user's `.captchaforge.toml` reaches the one-call API automatically. With no config file present, `discover()` returns defaults — the no-config install gets the same chain shape as before.
- **CLI `cmd_solve` builds the chain via `Config::build_chain()`** instead of duplicating the wiring logic. Single source of truth for chain construction across binaries.
- **Chain success path stores cookies in cache** via `cache.put_full(.., r.cookies.clone())` so the cache-hit replay path actually has cookies to replay.
- **`solver/mod.rs` docstring + `ARCHITECTURE.md` ASCII diagram** refreshed to show the four-solver chain (Behavioral / VLM / Audio / Third-party / Human-fallback) and the Tier-A config layer.

### Third-party adapters, EMA pattern store, env + Tier-A config (2026-05-11, round 3)

#### Added
- **`ThirdPartyCaptchaSolver` + `ThirdPartyService`.** Implements `SolveMethod::ThirdPartyService` against the 2captcha-compatible HTTP protocol used by 2captcha, CapMonster, and CapSolver — every TOML-bundled vendor that recommends `ThirdPartyService` now actually has a solver to land on. Constructors: `two_captcha()`, `cap_monster()`, `cap_solver()`, `custom_endpoint(base_url)`. API key via `with_api_key(...)` or `CAPTCHAFORGE_THIRDPARTY_API_KEY` env var. Wired into `CaptchaSolverChain::default_chain()` as the fourth solver; `supports()` returns false without an API key, so a no-key install sees no behaviour change. Supported shapes: Turnstile, reCAPTCHA v2/v3, hCaptcha, DataDome, Arkose/FunCaptcha, GeeTest v3+v4 (AWS WAF needs `iv`+`context` not in the standard `CaptchaInfo` so it is intentionally skipped). On success injects the token into the page (`cf-turnstile-response` / `g-recaptcha-response` / `h-captcha-response`) and captures session cookies.
- **`captchaforge::config::Config` (Tier-A operational config).** TOML-driven config layer for timeouts, cache TTL, VLM endpoint/model, third-party service selection. Compiled defaults → `.captchaforge.toml` → CLI flags. `Config::discover()` walks `$CAPTCHAFORGE_CONFIG` / `./.captchaforge.toml` / `./captchaforge.toml` / `$XDG_CONFIG_HOME/captchaforge/config.toml`. Materialisers: `chain_config()`, `solve_config()`, `build_token_cache()`, `build_vlm_solver()`, `build_third_party_solver()`. `deny_unknown_fields` so typos in keys surface as parse errors instead of silent ignores.
- **CLI `--config <file>` flag.** Override the discovery search path explicitly; otherwise discovery runs. `cmd_solve` now builds the chain via the loaded config (cache TTL, VLM endpoint, third-party service all reachable from the shell).
- **`.captchaforge.toml.example`.** Bundled schema reference with every Tier-A field commented + defaults inline.
- **`CAPTCHAFORGE_VLM_ENDPOINT` + `CAPTCHAFORGE_VLM_MODEL` env vars.** `VlmCaptchaSolver::new()` reads them once at construction time. Builder methods (`with_endpoint` / `with_model`) still beat env. Empty env values fall through to compiled defaults. New `pub(crate) new_with_env(closure)` overload makes precedence testable without `unsafe` env mutation (the crate is `#![forbid(unsafe_code)]`).
- **`TokenCache::ttl()` accessor.** Exposes the configured default TTL so the config layer can verify it round-trips and tests can assert the wiring.

#### Changed
- **`PatternStore` switched from cumulative-mean to bounded-α EMA.** Old `α = 1/n` made vendor migrations register slowly: 100 historical wins drowned out 30 fresh failures, so the chain kept routing to a method that no longer worked. New behaviour: `α = 1/n` for the first 10 samples (bootstrap matches old semantics), then `α = 0.2` so a flipped vendor surfaces as the new winner within ~10 alternating samples. `CaptchaPattern` now tracks per-method stats in a `methods: HashMap<String, MethodStat>` field; `best_method` is the per-method EMA winner with ties broken by faster `avg_solve_time`. Ships with regression tests that prove (a) bootstrap matches cumulative-mean, (b) EMA flips winner within 10 samples after a vendor migration, (c) ties resolve to the faster method.
- **Built-in solvers' `supports()` accept TOML-rule (`Custom(_)`) kinds.** Behavioral / VLM can attempt any visible challenge — without this opt-in the provider-driven routing for `Custom` had nothing to land on once `ordered_solvers_for_custom` was tightened to honour `supports()`. Audio stays narrow (RecaptchaV2 + AudioCaptcha only) since it depends on the accessibility-audio button.
- **`ordered_solvers_for_custom` now filters on `supports(kind)`.** Previously matched purely on method name, which would route Custom captchas to the third-party solver even without an API key — wasting one round-trip per attempt. The combined filter is now `s.method() == recommended && s.supports(kind)`.
- **CLI `cmd_solve` builds the chain via the loaded config** instead of `default_chain()`, so cache TTL / VLM endpoint / third-party service overrides reach the runtime.

#### Fixed
- **Default chain previously had three solvers with the protocol-recommended `ThirdPartyService` method matching nothing.** Adding `ThirdPartyCaptchaSolver` plugs the gap; `default_chain_has_four_solvers_in_documented_order` pins the order.

### Cookies, polish, local proof (2026-05-11, round 2)

#### Added
- **`captchaforge::cookies` module + cookie capture.** All three built-in solvers (Behavioral, VLM, Audio) now capture session cookies on successful solve and surface them in `CaptchaSolveResult.cookies`. Replay via `cookies::apply_to_page(&page, &result.cookies)` to preserve the WAF/vendor's trusted session across navigations and avoid re-triggering the captcha. New `CapturedCookie` struct with `is_expired_now` / `keep_persistent_alive` helpers.
- **`examples/architecture_proof.rs`** — local-runnable markdown report covering TokenCache hit/miss latency, ProviderRegistry::find_by_kind latency, RuleDetector probe generation cost, and per-rule HTML fixture match latency. Browser-free, runs in well under a second after a release build. Use to spot hot-path regressions; the round-1 numbers were 55ns / 27ns / 12ns / 0.76µs / 0.02–0.07µs per match.

#### Changed
- **README "Strategies" section + ARCHITECTURE.md ASCII diagram** updated to reflect the post-sweep architecture (cache layer, provider routing, telemetry, cookie capture).

### Added
- Property-based testing: 40+ proptest properties across detect, solver, frame, and behavior modules
- Snapshot testing: insta-powered JSON snapshots for all key data structures
- Fuzz testing: 4 fuzz targets (fuzz_detect, fuzz_solve_config, fuzz_selector, fuzz_pattern_key)
- Chaos tests: Resilience testing for extreme inputs, NaN/Inf handling, empty strings
- GitHub Actions CI/CD: ci.yml, coverage.yml, bench.yml, fuzz.yml
- Comprehensive documentation: ARCHITECTURE.md, TESTING.md, BENCHMARKING.md, CONTRIBUTING.md, CHANGELOG.md
- Benchmark suite expansion: 35+ self-hosted CAPTCHA fixtures, 10 benchmark suites, 6 output formats

### Changed
- Test organization refactored into 5-tier pyramid: unit -> property -> snapshot -> integration -> chaos

## [0.2.1] - 2025-05-09

### Added
- CaptchaSolveResult.screenshot field for human-in-the-loop review
- VlmCaptchaSolver.with_endpoint() and .with_model() builder methods
- SolveConfig struct with all formerly-hardcoded timeouts
- CDP frame evaluation module (src/frame.rs) for cross-origin iframe piercing
- verify_token_in_frames() to prevent false-positive success claims
- BehavioralCaptchaSolver now verifies tokens before returning success
- AudioCaptchaSolver now verifies g-recaptcha-response post-submission
- ImageCaptcha and AudioCaptcha detection arms in detect.js
- 8 real-browser integration tests

### Fixed
- Removed 6 credential-leaking println! statements from scanner hot path
- Replaced naive split("://") with url::Url parsing in extract_domain
- Fixed lock poisoning in PatternStore with unwrap_or_else(|e| e.into_inner())
- Turnstile, Audio, reCAPTCHA v3 now verify tokens via verify_token_in_frames()

## [0.2.0] - 2025-05-08

### Added
- CaptchaSolverChain with ordered_solvers() and PatternStore historical success tracking
- BehavioralCaptchaSolver with Bezier mouse curves and realistic timing
- VlmCaptchaSolver with Ollama-compatible multimodal LLM integration
- AudioCaptchaSolver with STT endpoint support
- CaptchaPattern and PatternStore for per-domain success memory

### Changed
- Detection engine expanded to support hCaptcha, generic image/audio CAPTCHAs

## [0.1.0] - 2025-05-07

### Added
- Initial release: Turnstile and reCAPTCHA v2/v3 detection
- Basic CaptchaSolver trait
- captcha_detect back-compat re-export