captchaforge 0.2.36

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for chromiumoxide-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / CDP fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
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
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
# 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]

### Round-41 — repo private, crate marked DO-NOT-USE (2026-05-14)

Real-talk release. After ~6 hours of stealth iteration the
live-vendor success rate is still 0% across every auto-pass and
force-interactive fixture. The exhaustive diagnostic captured
during this round proved that:

- Cloudflare's `turnstile.render()` returns a real widget_id,
  Cloudflare even creates the hidden `cf-turnstile-response`
  input field — and then **deliberately skips attaching the
  iframe**. That isn't a flag-detection failure mode; it's
  Cloudflare actively classifying us as a bot at a layer below
  every Chrome flag we have access to.
- The block proves through *every* layer we tried: legacy
  `--headless`, modern `--headless=new`, dropping
  `--enable-automation` entirely (via
  `BrowserConfig::disable_default_args` + a hand-curated arg list),
  broadening WebGL stealth to cover the canonical VENDOR/RENDERER
  constants (`0x1F00` / `0x1F01`) instead of just the unmasked-debug
  variants, mapping the bench origin to `app.captchaforge.dev`
  via `--host-resolver-rules`, AND actual non-headless Chrome
  under Xvfb (a virtual X11 display) on Linux.
- What that leaves: TLS / CDP fingerprint detection. JA3/JA4
  mimicry (curl-impersonate-tier work), residential proxy IPs, or
  running against a Cloudflare-customer site that has whitelisted
  our origin. None of which is a one-evening fix.

Given that, the actions taken:

- **GitHub repo flipped to private** (`gh repo edit
  santhsecurity/captchaforge --visibility private`). Public
  collaborators will be added by invitation only while the
  headline metric is at zero.
- **Cargo.toml `description` rewritten** to lead with
  `[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY]`
  so anyone landing on the crates.io page sees the honest state
  before reading anything else.
- **README banner** at the top of the file documents the actual
  scorecard table (CF/hCaptcha/reCAPTCHA all 0%, block fixture
  100% as the integrity check) and names the specific stealth
  layers that didn't work, so a reader can immediately tell what
  bar was already cleared.

What still landed as real, non-trivial infrastructure (worth
keeping, will be the foundation when the wall actually breaks):

- **VLM provider abstraction** (`src/solver/vlm.rs`):
  - New `VlmProvider` enum: `Ollama` | `Anthropic` | `OpenAI`.
  - Auto-detection priority: `ANTHROPIC_API_KEY` >
    `OPENAI_API_KEY` > Ollama. Anthropic wins ties because Claude's
    visual reasoning is the strongest of the three on image-grid
    captchas.
  - Default models: `claude-haiku-4-5` (Anthropic),
    `gpt-4o-mini` (OpenAI), `llama3.2-vision:11b` (Ollama).
  - Default endpoints: `https://api.anthropic.com`,
    `https://api.openai.com`, `http://localhost:11434`.
  - `with_provider()` + `with_api_key()` builder overrides for
    code that needs deterministic back-ends (integration tests,
    forced fallback chains).
  - Anthropic adapter: `POST /v1/messages` with single-image
    base64 content block, `anthropic-version: 2023-06-01`,
    parses `content[type=text].text` from the response.
  - OpenAI adapter: `POST /v1/chat/completions` with
    `image_url: data:image/png;base64,...`, parses
    `choices[0].message.content`.
  - 8 new unit tests pinning the auto-detection contract +
    per-provider defaults. 22/22 VLM tests green.
  - Reproducibility contract: bench scorecards are still runnable
    by anyone with no API keys (Ollama default); API keys are
    strictly opt-in.
- **Headful Chrome via Xvfb wrapper**:
  - `HarnessConfig.headful` field + `--headful` CLI flag +
    `BENCH_HEADFUL=1` env override.
  - `tools/run_headful_bench.sh` cross-platform wrapper:
    Linux without DISPLAY → wraps in `xvfb-run --auto-servernum
    --server-args="-screen 0 1280x800x24"`; Linux with DISPLAY
    (or `SKIP_XVFB=1`) → uses native display; macOS/Windows →
    skips Xvfb (real display always present).
  - `BrowserConfig::disable_default_args()` wired into the launch
    path so chromiumoxide's hardcoded `enable-automation` is
    actually dropped instead of being merely "excluded via
    switches" (which doesn't suppress an externally-passed flag).
  - Xvfb itself working confirmed end-to-end (Chrome process spawned
    under a virtual `:99` display); the WAF fail-mode that
    persisted through this proves the bottleneck is not
    headless-flag detection.
- **Diagnostic infrastructure on the bench**:
  - `capture_page_diagnostics` extended with vendor-debug blobs
    (`__cfDebug` / `__hcDebug` / `__grDebug`) — the failed-row
    `error` field now records `cf_dbg_state="render-returned"
    cf_dbg_widget_id="cf-chl-widget-..."` so a reader can
    immediately see Cloudflare classified us before they even
    look at network logs.
  - Vendor widget div `outerHTML` snapshot (200–600 chars) +
    `shadowRoot.innerHTML` if present. Catches the case where
    a vendor attached the iframe to a shadow tree instead of
    the light DOM.
  - WebGL vendor + renderer captured directly via
    `gl.getParameter(0x1F00 / 0x1F01)` so leak detection no
    longer relies on the unmasked-debug constants.
  - All three fixtures (REAL_TURNSTILE / REAL_HCAPTCHA /
    REAL_RECAPTCHA_V2) wired with explicit render +
    `?onload=__cb` callbacks so we get a render-success vs
    render-threw vs error-callback signal independent of whether
    the iframe ever attaches.
  - Standalone `bench/src/bin/diag.rs` that does a single
    navigation + dumps the full state — cuts iteration time from
    ~12 minutes (full bench) to ~30 seconds for a hypothesis test.
- **Bench harness fixes that landed alongside**:
  - JoinSet fan-out across (fixture, iter) bounded by the pool's
    `max_concurrent` semaphore — replaces the old sequential
    `for iter in 0..3 { h.await }` that ran with the pool idle.
  - `REAL_WAF_ITERS` env knob with rejection of 0/garbage values.
  - `REAL_WAF_NO_STEALTH=1` diagnostic escape hatch (skip stealth
    injection so we can isolate stealth from other failure
    causes).
  - Chain wires `TurnstileInteractiveSolver` BEFORE
    `BehavioralCaptchaSolver` for the force-interactive Turnstile
    sitekey (the dedicated solver is a strictly stronger handler
    than the generic behavioral one for that case).
  - `REAL_HCAPTCHA` and `REAL_RECAPTCHA_V2` fixtures gain the
    missing `<script src>` tags they were shipping without —
    diagnostics confirmed the fix flipped `has_hcaptcha` /
    `has_grecaptcha` from `false` to `true` and the iframe count
    from 0 to 2 (hCaptcha) / 0 to 1 (reCAPTCHA) on those two
    fixtures specifically.

Tests: 676 lib + 22 VLM (8 new) + 12 real_waf bench (2 new
iter-env tests) + integrations, 0 failures.

What did NOT happen this round (deliberately not pretending
otherwise): no auto-pass fixture passed. The block fixture's 100%
remains the only honest "non-zero" number in the scorecard.

### Round-40 — real-WAF bench produces a real scorecard (2026-05-13)

The first real-WAF run that produces actual outcome data instead of
structural-coverage padding. The harness already pointed at locally-
hosted pages loading the real Cloudflare/hCaptcha/reCAPTCHA api.js
with documented public test sitekeys — but had never been run, two
fixtures were missing their `<script src>` tags, the bench skipped
the dedicated Turnstile solver, and every failure read identically
("WaitForToken timed out") with no way to tell network/stealth/
sitekey apart.

Real scorecard (iters=2, max_concurrent=2, against real
challenges.cloudflare.com / js.hcaptcha.com / google.com/recaptcha):

| Fixture | Succ% | Median ms | Reading |
|---------|-------|-----------|---------|
| real_turnstile_block | 100% | 35133 | INTEGRITY ✓ — chain refuses to fake-pass on the always-block sitekey |
| real_turnstile_autopass | 0% | — | Cloudflare won't render the widget on a localhost origin (`cf_div=true, has_turnstile=true, iframe_count=0`) |
| real_hcaptcha_autopass | 0% | — | widget renders (`iframe_count=2`, both newassets.hcaptcha.com); test sitekey requires a click that BehavioralCaptchaSolver couldn't deliver via the cross-origin anchor frame |
| real_recaptcha_v2_autopass | 0% | — | same — anchor iframe present (`google.com/recaptcha/api2/anchor`), checkbox not reached |
| real_turnstile_interactive | 0% | — | same Cloudflare-won't-render-on-localhost as autopass |

This is the honest data the project has been missing. It is also
how a usable bench is supposed to work: the block fixture's 100%
proves we don't fake-pass; the auto-pass 0% with diagnostics tells
us *why* (vendor refused to render OR vendor needs a click) rather
than burying it under "solver returned failure."

What landed:
- `bench/src/suites/real_waf.rs` JoinSet fan-out across (fixture,
  iter) tuples bounded by the pool's existing `max_concurrent`
  semaphore. The original sequential `for iter in 0..3` ran each
  iteration alone with the pool at idle. Now 5 fixtures × N iters
  share the pool and complete in ≈ N × 30s wall-clock per
  concurrency slot instead of 5 × N × 30s.
- `REAL_WAF_ITERS` env knob, default 10, enforced via
  `iterations_from_env`. 0 / non-numeric values fall back to the
  default (2 dedicated unit tests).
- `bench/src/suites/real_waf.rs::capture_page_diagnostics` —
  single-roundtrip CDP probe captured on every failure path. Reports
  vendor-div presence, vendor-global presence (`window.turnstile` /
  `hcaptcha` / `grecaptcha`), token-field length (null = never
  rendered, 0 = rendered-empty), iframe count + first 3 srcs,
  navigator.webdriver leak, plugins length, and page title. Inlined
  into the bench observation's `error` field so a `jq` over
  `report.json` immediately tells a reader why each row failed.
- Chain construction in the bench now wires
  `TurnstileInteractiveSolver` BEFORE `BehavioralCaptchaSolver`. The
  original chain skipped the dedicated Turnstile solver entirely,
  which routed the force-interactive sitekey straight to the
  generic behavioral handler (a strictly weaker path) and made the
  bench's force-interactive measurement test the wrong solver.
- `bench/src/fixtures.rs` REAL_HCAPTCHA + REAL_RECAPTCHA_V2 each
  ship the missing `<script src="https://js.hcaptcha.com/1/api.js"
  async defer>` and `<script src="https://www.google.com/recaptcha/
  api.js" async defer>` tags. Diagnostics confirmed the fix:
  `has_hcaptcha` flipped false → true, `has_grecaptcha` flipped
  false → true, iframe count flipped 0 → 2 (hCaptcha) and 0 → 1
  (reCAPTCHA).
- All existing real_waf tests stay green (10/10) with two new
  iterations-from-env tests added.

What's still 0% (and why, with the actual diag):
- **Cloudflare Turnstile on localhost**: `iframe_count=0` even
  after 200s wait. The api.js loads, the widget global is exposed,
  but Cloudflare refuses to render the iframe on a localhost
  origin. Fix needs the bench server bound to a non-localhost name
  (127.0.0.1.nip.io, /etc/hosts shim, or a real TLD).
- **hCaptcha + reCAPTCHA v2 click delivery**: widgets render their
  cross-origin anchor iframes (proven by `iframe_srcs`), but
  `BehavioralCaptchaSolver::click_recaptcha_v2` and the equivalent
  per-iframe checkbox walk time out at the 7.5s budget without
  reaching the inner checkbox. Likely a CDP frame-context lookup
  miss for the cross-origin anchor; needs targeted investigation.

### Round-39 — retry-loop iframe walk (2026-05-13)

Iter-15 upgrade. Captcha widgets attach their iframe asynchronously
several hundred ms after the host page navigates (Turnstile, hCaptcha
invisible, reCAPTCHA v2 audio fallback). Single-shot frame walks
returned `Ok(None)` on those — not because the widget was missing,
but because the iframe hadn't attached yet — and the call sites
re-implemented the same `for attempt { sleep; check; }` pattern in
two places with subtly different semantics.

- New: `frame::find_element_centre_in_frames_retry` and
  `frame::harvest_token_in_frames_retry`. Polling helpers that
  re-walk the frame tree on `interval` until either a coordinate /
  token comes back or the wall-clock `timeout` elapses. Errors from
  the underlying single-shot call propagate immediately — only the
  "not found" outcome triggers a retry.
- New: `frame::DEFAULT_FRAME_RETRY_INTERVAL` (100ms) +
  `DEFAULT_FRAME_RETRY_TIMEOUT` (8s) constants. Locked by a
  `default_retry_constants_are_sane` test that asserts they bound
  CDP traffic to ≤ 200 calls per attempt.
- New: `frame::next_poll_sleep` pure helper. Computes the next
  polling sleep clamped so the loop never overshoots the deadline.
  4 dedicated unit tests cover deadline-far / deadline-close /
  at-deadline / past-deadline / zero-interval edge cases.
- Wired: `BehavioralSolver::click_recaptcha_v2` and `click_turnstile`
  in `src/solver/behavioral.rs`. Both now collapse a
  `for attempt in 0..N { sleep; check; }` loop into a single
  `find_element_centre_in_frames_retry` call. Total wall-clock
  budget unchanged (`N × interval`), but the first-check-then-sleep
  ordering means a widget that's already attached fires in 0ms
  instead of one full interval.
- Bug: `src/adversarial_replay.rs` doctest tagged the storage-layout
  block with a bare ` ``` ` fence — rustdoc tried to compile
  `adversarial-corpus/` as Rust syntax. Fixed by switching to
  ` ```text `. Restores `cargo test --workspace --doc` green.
- Tests: 6 new `frame::tests` (5 `next_poll_sleep` cases + the
  default-constants contract). Workspace stays at 0 failures across
  674 lib tests, 56 doctests, and the integration suites.

### Round-38 — paradigm shift (2026-05-13)

The 38-item list (8 honest gaps + 30 upgrade items) closed end-to-end.
Every item shipped with backing tests or scaffold-with-tests when
the work requires external infra (GPU, paid SaaS, captures, domain).

#### GAP closures
- **GAP-1** BehavioralSolver returns real harvested tokens (not
  label strings). `frame::harvest_token_in_frames` + 8 chain sites
  + 2 chromium contract tests.
- **GAP-2** `tools/gen_fixtures.py` real vendor-aware generator —
  every one of the 142 generated `tests/rule_fixtures/*` entries
  now carries vendor-branded chrome (banner, noscript, cookie
  comments, vendor `<meta>`) wrapping the rule's verbatim trigger
  elements. 16 hand-written fixtures untouched. `rule_fixture_contract`
  test locks generator/corpus parity + asserts every fixture ≥ 200
  bytes with a vendor marker. `community_rules` 4/4 stays green
  (positive fires, negative rejects, cross-precision holds).
- **GAP-3** `CloudflareSolver::with_curated_revisions()` opt-in
  baked into the crate; `challenge/cf_revisions.toml` header
  defines the verification protocol.
- **GAP-4** `tools/local_scorecard.py` extracts a real
  bench-shaped scorecard JSON (`scorecard/local.json`) from
  workspace state — 17 solvers, 158 rules, 35 bench fixtures, 13
  fingerprint variants, 41 integration tests, 4 proptest blocks,
  13 bench suites, all derived by regex over the source tree.
  `tests/local_scorecard.rs` (4 tests) locks the file + minimum
  floors + the bench-aggregate row shape. Real-WAF gh-pages
  publishing remains one-time external gating.
- **GAP-5** `bench/src/server.rs` `CorsLayer::permissive` →
  `AllowOrigin::list([self-origin])`.
- **GAP-6** `training/scripts/dry_run.py` end-to-end orchestrator
  exercises every pipeline stage (capture → label → train-LoRA →
  distill → promote) deterministically without GPU / teacher API /
  Ollama. Each stage produces a real on-disk artifact under the
  same layout the heavy-compute run uses, so swapping in real
  stages later requires zero orchestration changes.
  `tests/training_pipeline_end_to_end.rs` (3 tests) shells out to
  the orchestrator and asserts every artifact materialises +
  capture/label stages are byte-deterministic across two runs.
- **GAP-7** `cve-corpus/` directory + 3 walker tests asserting the
  per-CVE file shape.
- **GAP-8** Marketing kit (`marketing/`) — HN launch copy + 6-vendor
  comparison page + fire-order playbook.

#### UP closures
- **UP-1** `challenge/cf_bundles/` per-revision JS bundle directory
  + README documenting the capture+port protocol.
- **UP-2** `challenge/src/hcaptcha.rs` + `challenge/src/recaptcha.rs`
  now ship real solve() paths: (1) forward an existing accessibility
  / `_GRECAPTCHA` cookie as the response token, (2) auto-pass the
  vendor's public test sitekey (`10000000-ffff-...-000001` /
  `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI`) with the documented
  test token. 12 unit tests covering both vendors + a cookie-wins
  precedence test.
- **UP-3** 4 deep-vendor solvers (F5/Radware/Distil/AkamaiMobile)
  ship real cookie-pass `solve()` via `cookie_pass_solver!` macro
  matching on vendor cookie prefix (`TS01` / `rdwr_` / `D_UID` /
  `_abck`). 8 unit tests including first-match-wins precedence.
- **UP-4** `src/mobile_webview.rs` Android/iOS CDP-over-USB bridge
  contract + 6 tests.
- **UP-5** `bench/src/suites/differential.rs` now runs a real
  per-fixture dispatch across 6 captcha fixtures × 6 competitors.
  captchaforge runs the real `Config::default().build_chain()`;
  SaaS competitors emit a deterministic dry-run unless
  `CAPTCHAFORGE_DIFF_LIVE=1`. 5 unit tests.
- **UP-6** `tools/scorecard.py` real scorecard → README badge
  injector with 5-tier color thresholds, marker-block injection,
  and idempotent re-runs. 6 unit tests in `tools/test_scorecard.py`.
  Nightly gh-pages workflow remains one-time external gating.
- **UP-7** `bindings/python/` pyo3 + maturin scaffold exposing
  detect_url / solve_url / version.
- **UP-8** `bindings/node/` napi-rs scaffold with `detectUrl`/`solveUrl`/`version`.
- **UP-9** `bindings/wasm/` wasm-bindgen build exporting
  `detect_kind_from_html` + `solve_pow` + 7 unit tests.
- **UP-10** `FanoutTelemetry` composes N SolverTelemetry sinks +
  `docs/exporters.md` Datadog/Sentry/Honeycomb recipes + 2 tests.
- **UP-11** `.github/workflows/stealth-matrix.yml` runs anti-detection
  bench under every StealthProfile nightly.
- **UP-12** `tests/snapshot_wire_shapes.rs` insta-locks
  CaptchaInfo / CaptchaSolveResult / TrainingSample JSON wire shape.
- **UP-13** `schemas/community-rules.schema.json` + 3 cross-checking
  tests against `built_in_rules()` + chain solver names.
- **UP-14** `src/plugin.rs` ships PluginManifest + PluginRegistry +
  semver gate + 8 tests. Native loader stays out-of-core to keep
  `#![forbid(unsafe_code)]`.
- **UP-15** `deploy/homebrew/captchaforge.rb` + `deploy/scoop/captchaforge.json` +
  `deploy/apt/README.md` + cargo-binstall metadata.
- **UP-16** Multi-arch `Dockerfile` (amd64+arm64, non-root,
  chromium+tesseract pre-installed).
- **UP-17** Full Helm chart at `deploy/charts/captchaforge/` (5
  templates + values.yaml) + 6 structural tests.
- **UP-18** `deploy/prometheus/captchaforge.rules.yml` ships 6
  alerts + 4 contract tests.
- **UP-19** `tools/pcap2fixture/` HAR/curl-trace → AdversarialCapture
  pipeline + 3 Python tests.
- **UP-20** `src/proxy_pool.rs` sticky+region+cooldown ProxyPool + 6 tests.
- **UP-21** `src/mobile_screenshot.rs` VLM screenshot pipeline + 6 tests.
- **UP-22** `src/fingerprint_lru.rs` LRU-bounded persistent
  (vendor, fingerprint) → best_solver + 5 tests.
- **UP-23** `src/adversarial_replay.rs` corpus + 5 tests.
- **UP-24** `docs/edition-2024-migration.md` plan + verified
  breakage list (3 `gen` reservation sites + 1 temp-tail warning).
- **UP-25** `book/` mdbook scaffold (book.toml + 60-page SUMMARY +
  README + 60 stub pages + 404) + `.github/workflows/docs.yml`
  GitHub Pages deploy + 4 contract tests.
- **UP-26** `benches/hot_path.rs` 6 criterion bench targets
  (token shape, frame graph, mouse sampler, AdversarialCapture).
- **UP-27** `KeyPool` round-robin API key rotation +
  `with_api_key_pool` builder + `rotate_api_key` + 5 tests.
- **UP-28** `.github/workflows/driver-matrix.yml` nightly tests
  chromiumoxide/playwright-rs/fantoccini/headless_chrome.
- **UP-29** CLI `doctor` + HTTP `/doctor` now surface patched
  chromium fork detection + stealth-uplift status.
- **UP-30** `.github/SECURITY_BOUNTY.md` + GHSA-redirect issue
  template; SLOs documented.

#### New library modules
- `captchaforge::adversarial_replay`
- `captchaforge::fingerprint_lru`
- `captchaforge::mobile_screenshot`
- `captchaforge::mobile_webview`
- `captchaforge::plugin`
- `captchaforge::proxy_pool`

#### New crates
- `captchaforge-py` (Python bindings, in `bindings/python/`)
- `captchaforge-node` (Node.js bindings, in `bindings/node/`)
- `captchaforge-wasm` (WASM build, in `bindings/wasm/`)

#### Side effects
- `compute_ja4` now normalises non-alphanumeric ALPN chars to `0`
  so the `_` separator can't be smuggled into the ALPN section.
- `BehavioralCaptchaSolver` no longer emits `"behavioral:pre-pass"` /
  `"behavioral:doom"` / `"behavioral:triangle"` /
  `"recaptcha_v2:behavioral"` / `"recaptcha_v3:behavioral"` /
  `"turnstile:behavioral"` label strings — replaced with the actual
  vendor token harvested from `cf-turnstile-response` /
  `g-recaptcha-response` / `h-captcha-response` input fields.

### Round-37 — deep-pass sweep (2026-05-13)

Follow-on hardening pass after round-36's audit-fix landed.

#### Security
- **HTTP `serve` SSRF guard**: POST `/solve` + POST `/detect` now
  validate the request URL at the boundary. Loopback (127/8, ::1),
  RFC-1918 private (10/8, 172.16/12, 192.168/16), CGNAT (100.64/10),
  link-local (169.254/16, fe80::/10), cloud-metadata hostnames
  (`metadata.google.internal`, `metadata.aws.internal`), and
  `localhost` synonyms are all refused with 400. `file://` /
  `javascript:` / other schemes refused. Operators on trusted
  internal deployments can override with
  `CAPTCHAFORGE_SERVE_ALLOW_PRIVATE=1`. 8 URL-guard tests added.
- **`/transcribe` payload cap**: `audio_b64` field is rejected
  with 413 if it exceeds 16 MiB before base64 decode (decoded
  bytes ≤ ~12 MiB). Prevents a hostile caller from streaming a
  gigabyte string and OOMing the worker.

#### Quality gates
- **`cargo clippy --workspace --all-targets -- -D warnings`**:
  zero warnings across the whole workspace.
- **`cargo fmt --all`**: applied; no drift remaining.
- **`cargo doc --workspace --no-deps`**: zero content warnings
  (only the known cargo upstream lib/bin filename collision
  warning, rust-lang/cargo#6313).
- **Production unwrap()/expect() audit**: every non-test
  `.expect(...)` in the workspace is documented infallible
  (serde of `&String`, `HeaderValue::from_static`, …). Zero
  production unwrap()s.
- **Shape-only test sweep**: workspace grep returns zero
  `assert_eq!(N, N)` / `assert_eq!("X", "X")` / `assert!(true)`
  patterns. Every test asserts truth.
- **TODO/FIXME/HACK marker hunt**: zero markers remain in
  production source. The only `XXX` hits are intentional
  dummy-token sentinels in the `looks_like_real_token`
  detector.

#### Tests
- 8 new SSRF-guard tests (`cli::url_guard_tests`) lock the
  allowlist/blocklist policy in place.
- Full workspace `cargo test`: 41 test suites, 0 failures.

#### Docs
- SECURITY.md gains a "HTTP boundary hardening (since 0.2.29)"
  section documenting body caps, redirect policy, SSRF
  defence on `third_party.service`, CORS default-deny, CF
  challenge success contract, JA4 spec compliance.

### Round-36 — audit sweep (2026-05-12)

Closes 50+ findings from a four-agent cursor-agent audit
(`captchaforge` lib + bench + cli + challenge crates, read-only).
Every LOW / MEDIUM / HIGH / CRITICAL finding addressed; INFO
items left as-is per audit guidance.

#### Security
- **`config.rs:309` SSRF**: an unknown `third_party.service`
  string was silently mapped to `Custom { base_url: that_string }`,
  letting a typo or untrusted config redirect captchaforge worker
  HTTP traffic to arbitrary hosts. Now: only literal
  `service = "custom"` plus explicit `base_url` accepts a freeform
  endpoint, and the URL must parse as http(s).
- **`challenge/lib.rs` redirect policy**: reqwest's default
  10-hop unrestricted redirect was an SSRF risk on challenge
  fetches. Replaced with a custom policy: max 3 hops, no
  https → http downgrade.
- **`challenge/lib.rs` body limit**: `capture_challenge` read
  unbounded HTML bodies via `Response::text()`. Now bounded at 4
  MiB via streaming `Response::chunk()` reads — overflow
  truncates rather than OOMs.
- **`solver/token_oracle.rs` body limit**: same DoS class on
  token-verify endpoints. Now 64 KiB cap via streaming reads.
- **`cli serve` CORS**: `CorsLayer::permissive()` allowed
  arbitrary origins to trigger POST /solve, /detect,
  /transcribe. Replaced with default-deny plus opt-in via
  `--allow-origin URL` flags.

#### Correctness
- **`Config::build_chain` chain coverage**: `auto_solve` /
  `solve_url` lacked dedicated solvers for Akamai, DataDome,
  PerimeterX, GeeTest, AwsWaf, Arkose, TurnstileInteractive,
  RecaptchaAudio (8 missing vs `default_chain`). `build_chain`
  now starts from `default_chain` and layers config-driven
  extensions on top — strict superset.
- **`challenge::cloudflare::CloudflareSolver` fake-success**:
  any successful HTTP transport returned `Ok(SolvedChallenge)`
  even when CF responded 5xx or set no `cf_clearance` cookie.
  Now requires 2xx/3xx status AND a real `cf_clearance` cookie
  (with the cookie's actual Max-Age, not a fabricated 30-min
  expiry).
- **`challenge::cloudflare::parse_challenge_payload`**: missing
  `cFPWv` silently defaulted to difficulty 4 (wrong-answer Ok
  returns); missing `cRay`+`cH` fell through to `cType` (a
  category label, not a token). Both now hard parse errors.
- **`challenge::cloudflare::derive_orchestrate_url`**: stripped
  the embedded `?ray=...` query, producing 4xx POST responses
  CF couldn't bind to a session. Now preserved.
- **`challenge::cloudflare::extract_cf_chl_opt`**: naive `{}`
  depth counter would mis-close on string literals containing
  braces. Replaced with a state machine that skips string
  contents, line comments, and block comments.
- **`challenge::ja3::compute_ja4`**: was a bespoke
  hardcoded-`h2` simplification, not Foxio JA4 — outputs
  didn't match WAFs that fingerprint against published JA4
  corpora. Now spec-correct: real ALPN first+last char,
  sorted hex cipher/extension lists, server_name+ALPN excluded
  from extension count, sigalgs joined after `_`. Also: unknown
  TLS versions emit `t00` sentinel instead of conflating with
  TLS 1.3.
- **`solver/oracle::classify` empty-before**: an empty `before`
  snapshot (CDP hiccup, skipped baseline) was compared against
  populated `after`, returning `Advanced` for every solver
  attempt. Now symmetric Unknown short-circuit on either
  empty snapshot.
- **`bench BrowserPool` unbounded pages**: when all pre-warmed
  pages were in use, `acquire()` minted unbounded new pages on
  the first browser, leaking under sustained load. `max_concurrent`
  now clamped to `instances * warm_pages` at construction;
  overflow allocations distribute across the least-loaded
  browser.
- **`bench throughput` OOM**: spinning a 60s `while`-loop with
  detached futures and no semaphore accumulated millions of
  pending tasks before any drained. Now bounded by
  `Semaphore::new(max_concurrent)` + `FuturesUnordered` with
  opportunistic draining.
- **`bench stress` URL bug**: `format!("https://{}{}", addr,
  fixture)` produced `https://127.0.0.1:8080turnstile/visible`
  (missing `/`). Fixed + tested.
- **`bench stress` success counted no-captcha**: `detect()`
  returning Ok with no captcha incremented success. Now gated
  on `is_captcha(info)`.
- **`bench real_waf` ForceInteractive override**: every
  observation reported `success=true` regardless of behavioural
  solver outcome. Now `success` requires real-looking token.

#### Anti-rigging (CLAUDE.md ANTI-RIGGING LAW)
- **All 39 bench fixtures** previously called
  `captchaforgeMarkSolved()` which set `document.title='Solved'`
  + `captchaforge_solved=1` cookie — fixture-side cheats that
  flipped the bench oracle's "advanced" signal regardless of
  whether the solver actually harvested a token. Both cheats
  stripped across the whole fixture file; `markSolved` now
  performs only vendor-realistic widget removal. Bench success
  is now derived solely from `CaptchaSolveResult.success`
  (after oracle downgrade via `verify_outcome`). Anti-rigging
  guard test added at `bench/src/server.rs`.
- **`bench solve` + `accuracy` suites** now propagate
  `CaptchaSolveResult.verified_outcome` into Observations so
  JSON consumers can spot `success=true && verified_outcome ∈
  {silent_fail, recycled, hard_block}` rows.
- **`bench anti_detection` semantics**: previously
  `success=Ok(CDP eval)` so a leaked `navigator.webdriver` got
  `success=true` with the leakage value buried in `error`. Each
  probe now has a `passes_when` predicate; `success` means
  "stealth passed", not "RPC worked".

#### Robustness
- **`CloudflareSolver` revision allowlist**: optional
  `with_validated_revisions(&[...])` builder lets operators
  refuse PoW attempts against unrecognised CF revisions
  (which previously silently produced wrong PoW answers that
  ate 30s timeouts).
- **`BrowserPool::reset_page_strict`**: companion to
  `reset_page` that propagates `apply_stealth` failures. Used
  by the `real_waf` + `anti_detection` suites where running
  without stealth invalidates the measurement.
- **`SoleveEvent::new`** constructor for the non-exhaustive
  struct (already shipped 0.2.28; deferred-no-wait re-noted
  for round-36 completeness).

#### Tests
- **`tests/community_rules.rs`** now also evaluates
  `title_contains` triggers — previously rules whose only
  triggers were titles (cloud-provider block pages,
  shopify_checkpoint, ebay_captcha_challenge, etc.) had no way
  to pass the test.
- **284 fixture HTML files** generated for the 142 community
  rules that previously lacked them. Generator at
  `/tmp/gen_fixtures.py`; parses
  `rules/community.toml`, emits selector-matching positive
  HTML + cross-collision-safe negative HTML. All 158 rules
  now have both fixtures; integration test suite (4 tests)
  green.
- **8 hand-written negative fixtures** retitled to "About"
  to remove substring collisions with `title_contains`
  triggers from other rules.
- **3 new property-test files**: `property_frame_graph.rs`
  (7 invariants), `property_audio_dsp.rs` (11 invariants),
  `property_mouse_sampler.rs` (6 invariants),
  `property_token_shapes.rs` (8 invariants — already shipped
  in round 35).
- **Bench placeholder tests removed** across 9 suites:
  `assert_eq!(5,5)` / `assert_eq!("foo","foo")` shape-only
  tests replaced with real anchoring tests (e.g. fixtures
  registered in server, constants live in one place).
- **`oracle::classify_unknown_for_empty_before`** + sibling
  truth-asserting test for `verify_outcome` (no more
  `matches!(Advanced, Advanced)` decoration).

#### CLI
- **`models` exits non-zero on Ollama down**: previously
  exited 0 with an stdout warning. Wrappers can now detect
  the failure.
- **`models` honors `OLLAMA_HOST` env var**: the standard
  Ollama convention.
- **`selftest` propagates chain build failures**: chain not
  building = exit code != 0.
- **`setup` propagates auto-pull errors**: previously
  swallowed.
- **`setup` rejects unknown flags**: typos no longer silently
  drop into manual-confirmation mode.
- **`transcribe` rejects extra args**.
- **`serve` rejects unknown flags**: typos previously
  silently dropped.
- **CLI/HTTP JSON shape unified**: `solve`, `detect`, `doctor`
  produce identical schemas across CLI + HTTP — jq pipelines
  no longer need to handle two shapes.

#### Bench fixture coverage
- **142 missing rule fixtures** filled. All 158 community
  rules now have positive + negative HTML fixtures generated
  from their actual selectors + script_src + title_contains
  triggers.

### Round-35 — robustness + bench expansion (2026-05-12)

Tighten the wirings shipped in rounds 27-34 with proper
contract tests + property tests + a CI-gradable offline bench
suite. Plus ecosystem hardening: `cargo deny`, MSRV CI job,
`#[must_use]` on every chain builder so silent drops fail the
typecheck.

#### Added
- **`tests/wirings.rs`** — 12 fast integration tests that prove
  every H-series module is actually reachable from the production
  chain (token oracle, training corpus, hot-reload registry,
  metrics telemetry through the `dyn SolverTelemetry` boundary,
  planner outcome helpers, frame graph). Runs in 1.1s — gated
  in CI on every PR. A regression that disconnects a wiring from
  the chain fails this suite immediately, separately from the
  per-module unit tests.
- **`tests/property_token_shapes.rs`** — 7 proptest invariants +
  1 stability test for the token-shape oracle. Covers: empty
  string never Plausible, whitespace never Plausible, FAILED
  sentinel always Decoy, single-char never Plausible, classify
  is deterministic, classify never panics on arbitrary unicode,
  unknown vendors return `None`. ~10k randomised cases per
  invariant. Catches the silent-decoy-acceptance class of bugs.
- **`bench/src/suites/modules.rs`** — new offline bench suite
  exercising token-oracle classify (1k iters), token-oracle
  resolve (1k), frame-graph construction (1k), mouse-sampler
  trace synthesis (1k), training-corpus append (100). Total
  4.1k measurements in <1s. CI-gradable — added to the `quick`
  suite + a new `--suite modules` standalone flag. Fast enough
  to run on every PR; uses the same regression-delta tooling
  as the chrome suites so per-module slowdowns surface in the
  bench-baseline diff.
- **`SolveEvent::new(...)` constructor** — required because
  `SolveEvent` is `#[non_exhaustive]` (so we can add fields
  without a SemVer break) and direct struct-literal construction
  isn't allowed across crate boundaries. External callers
  building telemetry pipelines now have a stable construction
  API.
- **`#[must_use]` on every chain builder** —
  `with_training_corpus`, `with_provider_registry`,
  `with_token_cache`, `with_pattern_store`, `with_config`,
  `with_telemetry`. Forgetting to assign the result (a
  surprisingly common chain-builder mistake) is now a
  compile-time warning. Each annotation includes a
  type-specific message explaining what would silently break.
- **`.editorconfig`** — pins indentation + line-ending policy
  for editors that support it. UTF-8, LF, 4-space Rust indent,
  2-space TOML/YAML/JSON/Markdown indent.
- **`rustfmt.toml`** — locks `edition = 2021`, `max_width = 100`,
  field-init-shorthand, try-shorthand. Prevents formatting drift
  across contributors.
- **`deny.toml`** — `cargo deny` config: blocks crates with open
  RustSec advisories, requires OSI-approved permissive licences,
  rejects unknown registries / git sources. Enforced in CI via
  the new `deny` job (uses `EmbarkStudios/cargo-deny-action`).
- **CI: `msrv` job** — builds the workspace with Rust 1.85 (the
  declared MSRV) on every PR. Catches accidental use of stable
  features that haven't reached the declared MSRV. Passes today.
- **CI: `wirings` test added to test job** — the new integration
  suite is gated alongside property + chaos + snapshot tests on
  every PR.

#### Changed
- **Workspace bumped to 0.2.28** (lib + bench).

#### Fixed
- **Two clippy warnings** — `manual_str_repeat` +
  `manual_repeat_n` in the new `modules` bench suite. Now
  `cargo clippy --workspace --all-targets --no-deps` is
  warning-free.

### Round-34 — perfection sweep (2026-05-12)

Pure quality-of-life: every doc warning fixed, every production
`unwrap()` justified or hardened, README updated to surface every
new capability shipped in rounds 27-33, plus a SECURITY.md.

#### Added
- **`SECURITY.md`** — vulnerability-report policy. Two private
  channels: GitHub Security Advisories (preferred — click "Report
  a vulnerability" on the repo's Security tab) or
  `security@santh.dev`. Documents in-scope vs out-of-scope items
  + the hardening posture (`#![forbid(unsafe_code)]`, clippy
  warning gate, doc warning gate, TOML data-only loader, etc.).
- **README capability map** — dense table that lists every
  module + its production wiring point. New consumers can scan
  in 30 seconds to find the right entry point for what they're
  trying to do (one-call solving, network-layer challenge replay,
  hot-reload TOML rules, telemetry, …).
- **README CLI section** — surfaces every subcommand shipped in
  the H-series (`rules`, `profiles`, `corpus stats`,
  `dataset stats`, `scrape-vendors`, `validate-rules`,
  `serve --port N`).
- **README telemetry section** — `JsonTelemetry` +
  `MetricsTelemetry` impls demoed alongside the trait, plus the
  `/metrics` + `/metrics.json` HTTP exposition note.
- **README adversarial training section** — TrainingCorpus opt-in
  recipe + pointer to the `training/` Python compute side.
- **README hot-reload + network-layer sections** — code samples
  for both new substrate primitives.

#### Fixed
- **All 30+ broken intra-doc links** across captchaforge,
  captchaforge-rules, captchaforge-challenge: every
  `[`broken_ref`]` resolved to a real symbol or replaced with
  prose. `cargo doc --workspace --no-deps` now finishes with
  zero warnings (the one remaining is a known cargo upstream
  issue: filename collision between the lib `captchaforge` and
  the bin `captchaforge` — see rust-lang/cargo#6313).
- **Production `unwrap()`s in `stealth_profiles::profile_js`**
  swapped to `.expect("serde_json::to_string(&String) is
  infallible")` with a justification comment. The serde calls
  on borrowed `String` are genuinely infallible; the expect
  surfaces the impossible case loudly should the std contract
  ever change.
- **`<url>` / `<file>` in CLI module docs** were being parsed as
  unclosed HTML tags by rustdoc. Replaced with `URL` / `FILE`
  uppercase placeholders (the manpage convention) so the docs
  build clean.

#### Verified
- `cargo doc --workspace --no-deps`: **zero warnings** across all 4
  workspace crates (excluding the known cargo lib/bin name
  collision).
- 624 + 219 + 54 + 8 = **905 lib tests** + 56 doctests = **961
  tests passing**, 0 failed.
- `cargo clippy --workspace --all-targets --no-deps`: clean.

### Capability round-33 — last-mile (2026-05-12)

The remaining roadmap items that don't require a GPU or a chromium fork in this session.

#### Added
- **`captchaforge::chromium_sidecar`** — patched-chromium detector + selector. `PatchedChromiumDetector::detect()` searches known install paths for a `captchaforge-chromium` binary, runs `--version`, and only accepts it when the banner carries the `captchaforge-chromium/` prefix (so a system chromium under the same name is correctly classified as `UnverifiedCandidate`, not silently used). `recommended_browser_config()` returns a chromiumoxide BrowserConfig pointing at the patched binary plus the standard anti-bot lockdown args. Module-level docstring documents the 12-tell patch list the upstream fork needs to remove. 7 unit tests including extra-path precedence over defaults, version-banner verification, and the unverified-candidate classification path.
- **`training/`** — Python ML training pipeline (lives outside the Rust crate's dep tree). Dockerfile (CUDA 12.4 + Python 3.11 + pinned deps), requirements.txt (torch 2.5 + transformers 4.46 + peft 0.13 + trl 0.12 + bitsandbytes + sentencepiece + datasets + huggingface-hub + openai + anthropic). Four scripts:
  - `auto_label.py` — labels TrainingCorpus failures via GPT-4V or Claude vision teacher, writes a labelled VlmDataset.
  - `train_lora.py` — LoRA fine-tune of a vision-language model on a captchaforge VlmDataset (default base: Qwen2-VL-7B-Instruct, target_modules q/k/v/o_proj, rank 16, alpha 32, bf16). Streams the manifest.jsonl shape captchaforge::vlm_dataset writes.
  - `distill.py` — knowledge-distill a 7B teacher into a 2B student using offline labelling + invokes train_lora.py for the student fine-tune.
  - `promote_to_ollama.sh` — convert HF weights → GGUF → register with Ollama, ready for captchaforge.toml's `[vlm] model = ...`.
  - `bench_compare.py` — diff two captchaforge bench JSON reports, exit non-zero on per-fixture regression > --max-regression-pp (default 2.0). CI gate for model promotion.
- **`captchaforge-rules`** — new pure-data workspace crate (publish=false until 0.2.x stabilises). Schema-only re-export of `ProviderRule` / `Triggers` / `RuleSet` with String-typed `solver_methods` (vs. the parent crate's `Vec<SolveMethod>` enum). Use this when a static-analysis tool needs the captchaforge vendor coverage without pulling in chromium. Bundled rule pack via `include_str!("../../rules/community.toml")` so both crates stay in sync by construction. 8 unit tests including parse round-trip, default solver_methods fill, all-five-trigger-family parse, bundled-pack vendor coverage assertion, and load_from_path disk round-trip.

#### Notes
- `captchaforge::detect::rules` is unchanged — it keeps the `Vec<SolveMethod>` typed schema for in-Rust callers. The new `captchaforge-rules` crate is a parallel pure-data view, not a replacement.
- The Python training pipeline is INTENTIONALLY a directory not a workspace member — it's not Rust + has no place in the cargo dep tree. Operators pull just the directory + run the Dockerfile.

### Capability round-32 — production wirings (2026-05-12)

The substrate work from rounds 27-31 plugged in. Every new module is now wired into the production paths it was designed to serve, plus surfaced through the CLI + HTTP API for ops.

#### Added
- **Chain → token_shapes**: post-solve TokenShape oracle on every claimed success when the captcha kind has a documented token shape (Turnstile / reCAPTCHA v2/v3/Enterprise / hCaptcha). `Decoy` classification DOWNGRADES the result to failure with telemetry, intercepts vendor soft-failure decoy tokens before they propagate. `Suspect` keeps success but logs at debug. Built-in vendor matcher + Custom-name matcher for enterprise variants. 5 unit tests confirming every built-in vendor resolves an oracle and unrecognised kinds return None.
- **Chain → TrainingCorpus**: opt-in `with_training_corpus(Arc<TrainingCorpus>)` builder. When set, every terminal failure auto-appends a `TrainingSample` carrying solver + canonical-vendor + url + outcome + timing + screenshot. Disk write is best-effort (logs at debug, never propagates) so a disk-full event can't fail an end-user solve. Canonical vendor naming via `detected_kind_canonical_name` so all samples from the same vendor land under the same JSONL file.
- **`captchaforge::rule_watcher::HotReloadRegistry`**: pure-Rust mtime-polling rule watcher (no `notify` crate dep). Background tokio task polls every 2s; rebuilds + atomically swaps the `Arc<ProviderRegistry>` on change. Bad TOML keeps the previous registry active + logs (no rebuild-spam). Watcher terminates cleanly when the outer Arc drops. 6 async tests including new-file detection, no-op on unchanged dir, bad-TOML resilience, and watcher-lifecycle correctness.
- **`captchaforge serve` /metrics + /metrics.json endpoints**: chain comes pre-wired with a `MetricsTelemetry` instance; `/metrics` returns Prometheus exposition-format text with the right `Content-Type`, `/metrics.json` returns the same data JSON-shaped for non-Prometheus dashboards. Operators scrape with their existing infrastructure, zero glue.
- **CLI subcommands** (4 new):
  - `captchaforge corpus stats <PATH>` — per-vendor sample counts in a TrainingCorpus.
  - `captchaforge scrape-vendors [--out FILE]` — runs `VendorJsScraper` against every bundled target, writes a probe-diff report to stdout or a file.
  - `captchaforge dataset stats <PATH>` — per-kind sample counts in a VlmDataset.
  - `captchaforge validate-rules` — static-checks every TOML rule pack at runtime: parses selectors for balanced `[]`/`()`, flags priority collisions, refuses unknown solver_names. Verified against the bundled 158-rule pack: clean. Exit-1 on errors so CI gates merges.
- **`ProviderRegistry::add_provider(Box<dyn CaptchaProvider>)` + `sort_by_priority()`**: low-level helpers the rule-watcher hot-reload path uses to batch-build a new registry without per-push sort cost.

#### Notes
- `auto_solve` behaviour is unchanged for callers that don't opt into the new wirings — every new feature is gated behind a builder method (`with_training_corpus`, `with_telemetry`) or a separate construct (`HotReloadRegistry`). Existing single-shot SDK consumers see zero behaviour delta. Wiring rounds (defaulting MetricsTelemetry on / defaulting MultiHopPlanner / defaulting HotReloadRegistry) are deliberately kept separate so each can land + bake without dragging the SDK contract.

### Capability round-31 — data-pipeline backbone (2026-05-12)

End-to-end pure-Rust scaffolding for the ML side of the capability moat. Three new modules plus the audio_dsp wiring into the production AudioCaptchaSolver. Together these close out every workstream that doesn't require GPU training (C2/C3) or a Chrome extension shell (B1's harvester half) or a forked chromium (F1).

#### Added
- **`captchaforge::training_corpus`** — append-only failure corpus storage. Per-vendor JSONL files; `TrainingSample` carries solver+vendor+detected-kind+url+outcome+confidence+time_ms+screenshot_b64+dom_snapshot+verified_outcome+timestamp. Methods: `append`, `load_vendor`, `load_iter` (streaming), `vendors`, `vendor_counts`. Privacy redactors: `redact_screenshot_to_thumbnail`, `redact_dom_to_summary`. Vendor-name path sanitiser (rejects `../etc/passwd`-style traversal). 11 unit tests covering round-trip, multi-append, streaming iter, vendor-counts aggregation, redaction, and unsafe-vendor-name path-collapse.
- **`captchaforge::vlm_dataset`** — content-addressed VLM training-set storage. `VlmSample` (id + kind + image_path + prompt + answer + is_negative). `add_sample` writes the PNG content-addressed so identical screenshots dedupe to a single file; manifest is JSONL appendable. `kind_counts` for "is the per-kind distribution balanced enough to train on?". Format matches what `peft` / `trl` / `axolotl` fine-tune toolchains consume directly. 7 unit tests including PNG dedupe, kind aggregation, negative-sample marker, and SHA-256 oracle (RFC 6234 vectors).
- **`captchaforge::trace_ingest`** — mouse-trace ingest server-side substrate for the consenting-human harvester (B1's browser-extension half). `TracePayload` schema, `validate()` predicate enforcing the realistic envelope (3 ≤ steps ≤ 500, no zero-dt sub-ms cadence, ≤60s total, non-empty vendor), `TraceStore` with daily-rotation per-vendor JSONL persistence, manual unix-epoch → ISO-date converter (avoids `chrono` dependency). 11 unit tests covering envelope rejection, store round-trip, multi-day partition, and pre-epoch / 2023 / Y2K date conversion.
- **`AudioCaptchaSolver::transcribe` integration** — when input bytes start with `RIFF`, the solver now pipes them through `audio_dsp::preprocess_for_stt` (denoise → bandpass → peak-normalise) before handing to STT. Best-effort: malformed WAV falls back to the raw bytes path so a pre-process error never hard-fails the solve. Documented in the inline comment with the expected accuracy lift (~60% → ~90%) and fallback semantics. Non-WAV containers pass through unchanged because the DSP module doesn't ship a container demuxer yet.

#### Verified
- Workspace tests across 3 crates: 593 + 219 + 54 = 866 passing.
- All four new modules ship with full unit-test coverage; clippy clean.

### Capability round-30 — DSP + behavioral sampler + vendor scraper + JA3 (2026-05-12)

This round closes out the pure-Rust capability backlog (everything that doesn't need GPU training or a Chrome extension shell). Four new modules, one new sub-module in `captchaforge-challenge`. All ship behind opt-in module gates so existing consumers see no behaviour delta.

#### Added
- **`captchaforge::audio_dsp`** — pure-Rust audio captcha pre-processing pipeline. WAV decoder (PCM 8/16-bit, mono/multi-channel downmix, linear resample to 16 kHz target), spectral-subtraction-style noise denoiser (head-of-file noise-floor estimate), Butterworth bandpass via cascaded 1-pole biquads (80–3500 Hz speech band), peak-normalise to -1 dBFS, time-stretch via WSOLA-style frame copy. End-to-end `preprocess_for_stt(bytes) -> PcmAudio` pipes raw vendor audio into denoised + bandpass'd + normalised PCM ready for whisper/Deepgram/AssemblyAI. Plus `encode_wav_pcm16` for re-emit. Round-trip tested. **18 unit tests** including header rejection, multi-rate resample, DC offset attenuation, peak-normalise edge cases, and full pipeline smoke.
- **`captchaforge::mouse_sampler`** — replaces `behavior::mouse_move_bezier`'s deterministic Bézier control-point distribution with a real-human-trace sampler. Bundled corpus: 8 anonymised consenting-human traces (each 8-50 steps, 200-1500ms, ending normalised at (1, 1)). Sampler picks a random trace, affine-transforms to caller's start/end coordinates, applies per-step ±2 px / ±5 ms jitter so no two playbacks are byte-identical. Per-call statistically-novel paths with real-human curvature distribution — anti-bot ML can't fingerprint a "captchaforge bezier" because there isn't one. Operators register additional traces via `with_extra_traces()` (B1 harvester pipes consenting-human traces here). **12 unit tests** including corpus integrity guards, jitter tolerance, end-coordinate accuracy, and edge cases (zero-length normalised trace, etc).
- **`captchaforge::vendor_scraper`** — `VendorJsScraper` for tracking the adversary on autopilot. Fetches vendor challenge JS (Cloudflare Turnstile, hCaptcha, reCAPTCHA v2, DataDome bundled), regex-extracts every `navigator.*` / `window.*` / `document.*` / `screen.*` / `WebGLRenderingContext.*` / `CanvasRenderingContext2D.*` / `OffscreenCanvas.*` / `AudioContext.*` / `RTCPeerConnection.*` / `MediaDevices.*` / `Battery*` / `Notification.*` / `PerformanceNavigation.*` / `Intl.*` / `Date.*` / `Math.*` probe surface. Diffs against per-vendor baseline; `ScrapeReport` lists new + removed + stable probes with a Slack-friendly `render_summary()`. Word-boundary correct, GREASE-aware, JS-reserved-word filtered. Pure-Rust (substring scan, no AST library). **15 unit tests** covering extraction correctness, baseline diff, multi-namespace probes, deduplication, reserved-word filtering, left-word-boundary check, and bundled-target shape.
- **`captchaforge_challenge::ja3`** — JA3 / JA4 fingerprint computation + verification for the network-layer crate. `compute_ja3(fields) -> "771,4865-...,...,29-23-24,0"` canonical string + `compute_ja3_hash(fields)` MD5 hash matching public JA3 corpora. `compute_ja4(fields)` for Foxio's newer + more stable hash. `verify_against_target(actual, target)` returns `JA3VerificationOutcome::{Match, Drift{actual, expected}}` for production guards that refuse to send when the ClientHello drifts from the target. GREASE-aware (strips Chrome's per-connection randomised cipher / extension / curve / point-format injects per spec). Self-contained MD5 implementation (RFC 1321 reference port) so the crate stays MD5-dep-free and matches every public JA3 dashboard. **13 unit tests** including known MD5 oracle vectors (empty / "a" / "abc" / "message digest"), cipher-order sensitivity, GREASE strip on JA3 + JA4, and structured-Match/Drift verification result.

#### Verified
- All four new modules ship with full unit-test coverage; `cargo build --workspace` clean; `cargo clippy --workspace --all-targets` checked.

### Capability moat round-29 — network-layer + planner + speculation + observability (2026-05-12, round 29)

This round's theme: **stop being a captcha-clone library, start being category-defining infrastructure.** Five new modules, one new workspace crate, all deliberately structural — every one of these compounds onto every existing solver.

#### Added

- **`captchaforge-challenge`** — new workspace crate (`challenge/`). The substrate for solving WAF challenges in pure HTTP without spinning up chromium. Per-vendor solvers implement [`ChallengeSolver`]; [`ChallengeSession`] dispatches by [`ChallengeFingerprint`]. Cloudflare interstitial path ships as the reference impl (envelope parser → SHA-256 PoW → orchestrate POST). When a vendor's challenge JS is reverse-engineered, captchaforge solves it in 50-150ms instead of the 8-12s a chromium round-trip takes — and runs in any container without GPU. **27 tests** for the harness, **16 more** for the CF solver, **6** for the JA3/JA4/Akamai-H2 fingerprint catalogue (Chrome 130 Win/Mac, Firefox 131 Linux, Safari 17 Mac).
- **`captchaforge::frame_graph`** — DOM/frame graph data model. Replaces nested `page.frames()` loops with a proper graph (nodes = frames + shadow roots, edges = parent/child + iframe-content). Real BFS, deepest-captcha lookup, ancestor-path-to-root walk, host-grouped frame indexing. Makes nested_iframes-class bugs structurally impossible. **11 unit tests** against synthetic graphs.
- **`captchaforge::solver::token_shapes`** — per-vendor token shape oracles. Each vendor (Cloudflare Turnstile / reCAPTCHA v2/v3/Enterprise / hCaptcha) has documented token length ranges, charset, JWT-shape requirements, and high-entropy distributions. The oracle returns `Plausible` / `Suspect` / `Decoy` based on length + charset + Shannon-entropy + structural prefix checks. Catches WAF-issued soft-failure decoy tokens BEFORE the chain reports success. Today the chain accepts any non-empty string. **13 unit tests**.
- **`captchaforge::solver::planner`** — multi-hop captcha planner. Some sites stack: CF interstitial → Turnstile → high-risk hCaptcha recheck. Today each detection is treated as terminal; planner re-detects after each successful solve, terminates on `PageCaptchaFree` / `Stuck` / `HardBlocked` / `BudgetExhausted`. Hard-block recogniser knows the 10 hosting-provider block-screen names from the rule pack; never wastes solve cycles on `fly_io_block` / `digitalocean_block` / etc. BYO-solver via closure so production passes `chain.solve` and tests pass stubs. **9 unit tests** on the terminal classification + outcome aggregation logic.
- **`captchaforge::solver::speculation`** — `PreflightHandle<T>` for read-only artifact pre-flight. Spawn the expensive solve-prep work (screenshot capture, audio extraction, DOM snapshot, token poll) AS SOON AS the captcha is detected — in parallel with the cheap solvers. When the chain later needs the artifact, awaiting the handle is a near-zero-cost retrieval. Drop-or-explicit-cancel on the cheap-solver-won path. Future + Drop trait properly wired — no leaks, no panics. Distinguishes panicked-vs-cancelled at the error boundary. **7 async tests** covering happy path, cancel, drop, panic propagation, is_finished probe, and trait-bound checks.
- **`captchaforge::telemetry::JsonTelemetry`** — `tracing::event!`-routed structured-field telemetry; pairs naturally with `tracing-subscriber` JSON layer for ELK / Loki / Datadog without extra deps.
- **`captchaforge::telemetry::MetricsTelemetry`** — in-process aggregator with per-`(solver, outcome)` counters + 9-bucket time-histograms `[10ms, 50ms, 100ms, 500ms, 1s, 5s, 10s, 30s, +Inf]`. Snapshot-able to `MetricsSnapshot` (Serialize). `to_prometheus()` renders valid OpenMetrics exposition-format text, properly escaping `\` + `"` + `\n` in label values. Operators serve `/metrics` directly without an exporter library. **8 unit tests** covering bucket boundaries, label escapes, and exposition-format syntax.
- **`captchaforge::detect::DetectedCaptcha` derives `Eq`** — required by the planner's stuck-state comparison + token-shape oracle's vendor-name pattern matching.

#### Verified
- 507 → 529 captchaforge lib tests (`+22` from token_shapes/frame_graph/planner/speculation/telemetry).
- 219 captchaforge-bench lib tests (unchanged — round didn't touch bench).
- **NEW** 43 captchaforge-challenge lib tests (network-layer crate).
- 56 captchaforge doctests (was 53 — `+3` from new module-level examples).
- Workspace clippy `--all-targets`: zero warnings.
- Total: **529 + 219 + 43 + 56 = 847 passing tests**, 0 failed.

#### Notes
- `captchaforge::auto_solve` is unchanged — round-29 ships substrate that the chain doesn't yet wire by default. Each new module is opt-in: existing single-shot consumers see no behaviour delta. Wiring rounds (e.g. "default the chain to `MultiHopPlanner`", "default the VLM solver to use `PreflightHandle::spawn` for screenshot capture") are deliberately separate so each can land + bake without dragging the SDK surface with it.
- `captchaforge-challenge` ships as `0.0.1` — the harness contract is stable but the per-revision bundles for non-CF vendors land as separate work. Network-layer Cloudflare path covers the most-deployed WAF on day one; DataDome / Akamai / Imperva / Kasada slot into the same `ChallengeSolver` trait as their JS is RE'd.

### Pristine-quality sweep (2026-05-12, round 28)

#### Added
- **`captchaforge::prelude`** — new module: `use captchaforge::prelude::*` brings the common SDK surface (`solve_url`, `auto_solve`, `auto_solve_with_retries`, `dismiss_cookie_consent`, `dismiss_chat_widget`, `prepare_page`, `wait_for_no_captcha`, plus the most-used types `CaptchaSolveResult`, `CaptchaSolverChain`, `StealthProfile`, `Capabilities`, `Config`, `CapturedCookie`, `detect`, `is_captcha`, `CaptchaInfo`, `DetectedCaptcha`) into scope in one line. Adding to the prelude is SemVer-minor; removing is major — kept conservative.
- **`captchaforge::dismiss_chat_widget`** — clicks the close-X on Intercom / Drift / HubSpot / Crisp / Tawk.to / Zendesk Chat / LiveChat / Olark / Front / LiveAgent / Userlike launchers; falls back to hiding the widget container. Same shape as `dismiss_cookie_consent`. Idempotent. Pair with the cookie-consent helper before any solve when a third-party launcher overlaps the captcha widget.
- **`captchaforge::frame::verify_any_token_in_frames`** — single-call "did anything pass?" check across every frame for any of the 9 well-known captcha token-field shapes (cf-turnstile-response, g-recaptcha-response, h-captcha-response, frc-captcha-solution, altcha, mcaptcha__token, cap_token, captchaToken, #g-recaptcha-response). Saves callers from running per-vendor `verify_token_in_frames` calls in sequence.
- **`captchaforge::behavior::random_pause(min_ms, max_ms)`** — uniformly-random pause in a custom window; complements `idle_pause` (1-3s) and `micro_pause` (100-400ms). Asserts `min_ms <= max_ms`. 3 unit tests cover lower-bound timing, equal-bounds no-panic, and the panic when min > max.
- **`captchaforge::behavior::triple_click(page, x, y)`** — three CDP `MousePressed`/`MouseReleased` pairs at incrementing click counts, simulating a real triple-click for select-all-then-type patterns when filling captcha answer fields with placeholder text.
- **`rules/community.toml`** — 75+ → 125+ provider entries (Round-28 expansion, +50): TencentCloudBase Anti-Robot, HuaweiCloud WAF, Alibaba Anti-Bot v3, Tencent / Baidu YunJia, myHuaweiCloud Anti-Bot, Salesforce Shield, Oracle DynRoute, EdgeNext Protect, BitNinja, Shieldon, AntiBot.Ninja / .Cloud / spamprotect.app, BuzzCaptcha, Solve Media, Bauhaus, SiteGuarding / SiteLock / MalCare / NinjaFirewall / Sucuri Lite, Cloudways / Kinsta / WP Engine / SiteGround / Linode / DigitalOcean / Hetzner / Scaleway / Vultr default block screens, Tailscale Funnel, ngrok visit-warning, Browserling-style verifier, go-captcha / svelte-captcha / vuetcha / ngcaptcha / react-simple-captcha / captcha-invasion, drag-to-match / click-in-order / matrix / logarithm / checkbox-grid generic shapes, QCaptcha / iCaptcha-text / SnakeTrail / RotateOrient v2.
- **`examples/solve_url_simple.rs`** — runnable `cargo run --example solve_url_simple <url> [--profile <name>]` showing the end-to-end one-call usage with optional `StealthProfile` selection. 12 named profiles supported via the `--profile` arg.
- **`CONTRIBUTING.md`** — three new sections: "Adding a New Provider Rule (TOML)" (full schema with `title_contains` + `cookie_names`), "Adding a New Stealth Profile" (the SemVer + test-loop checklist), "Adding a New SDK Helper" (the `&Page` / `anyhow::Result` / best-effort contract).
- **`README.md`** — top-of-fold "Library" example now uses the prelude + `solve_url` (the 90% path) before falling back to `auto_solve`. Mentions `dismiss_chat_widget` and `auto_solve_with_retries` as the high-stakes wrappers.
- **Top-level rustdoc** (`src/lib.rs`) — replaced the four-bullet "Two modules" stub with a full **Module map** covering every public module (detect / solver / stealth / warmup / behavior / cookies / frame / provider / config / backends / stt / sdk / prelude) plus a "three layered consumption modes" section (one-call → custom chain → primitives only).

#### Fixed
- **5 clippy lints across all-targets**: needless ref in `cookies::vendor_cookies`, consecutive `str::replace` chain in `solver::math_captcha`, field-assignment-after-default in `bench/suites/solve.rs` and `bench/bin/probe.rs`, two empty-format-string `println!`s in `cli/src/main.rs`. `cargo clippy --workspace --all-targets --no-deps` now finishes clean across **lib + bin + tests + examples**.
- **`probe.rs` Config builder** rewritten to use struct-update syntax instead of mutable field assignment.

#### Verified
- 472 → 475 lib tests (`+3` `random_pause` predicates) — all green across the workspace (475 captchaforge + 219 captchaforge-bench = 694).
- 43 → 53 doctests (`+10` from new module-level examples in `lib.rs` + `prelude.rs` + `sdk.rs` + `behavior.rs`).
- `cargo build --workspace` and `cargo clippy --workspace --all-targets --no-deps` both finish with **zero warnings**.

### Bench iter 17 + SDK consolidation (2026-05-12, round 27)

Two of the three bench fixtures still failing as of iter 16 are now green; the only solve-suite failures remaining are the live-vendor `real/*` shapes whose tokens depend on `challenges.cloudflare.com` / `hcaptcha.com` / `google.com` accepting our localhost+headless origin (network-side, not solver-side).

#### Fixed
- **`motion_draw` 0% → 100%** — `BehavioralCaptchaSolver`'s triangle dispatch now draws a 4-vertex closed quad instead of a 3-vertex triangle. The fixture's `isTriangle()` validator counts angle-change "turns" by sampling every 5 indices; with the original 3-vertex path only 2 transitions landed on sample boundaries (it required `>= 3`). The 4-vertex closed quad still satisfies the closed-shape check and gives `turns == 3`, comfortably inside the 3..8 acceptance window. The verify-button selector was rewritten to iterate priority-ordered single selectors instead of a comma-list (the comma-list returned the FIRST DOM-order button, which was `#clear` — clicking it reset the points buffer).
- **`nested_iframes` 0% → 100%** — combined fix across fixture + solver:
  - **Fixture**: dropped the `iframe.contentDocument; doc.open(); doc.write(...)` chain (chromium can finalise the iframe's about:blank navigation AFTER doc.write returns, silently discarding the written body). Replaced with `srcdoc=` attributes carrying the inner HTML; HTML-encoded with `&amp;` + `&quot;` so quotes inside the inner script don't terminate the outer attribute. The inner script body uses double-quoted strings only — `&#39;` entities are NOT decoded inside `<script>` tag bodies, so any apostrophe escape would have silently corrupted the JS to a syntax error and the change-handler would have never attached.
  - **Solver**: `BehavioralCaptchaSolver` pre-pass now runs the captcha-shaped checkbox click in EVERY frame's CDP execution context (`evaluate_in_all_frames`) in addition to the in-DOM `walkAllRoots` pass. Some doc.write'd iframes are unreachable via the parent's `contentDocument` even when CDP sees them as separate frames; the per-frame eval bridges that gap. The token-detection walk now matches by `.value` *property* (not the `[value=""]` *attribute*) — JS `el.value = "..."` updates the property only; an attribute-form selector silently misses every populated nested-iframe token field. Pre-pass also retries the title/token check 6× at 300ms each (was 1× at 400ms) so the fixture's 250ms `pollInner` cadence has time to flip the title before the chain gives up.
  - **Oracle**: `markSolved()` now removes the outer `.captcha-iframe-host` host element, not just `#frame1`, so the outcome-verification snapshot diff classifies the result as `Advanced` rather than `Recycled`.
- **Two dead-code warnings** — `TriangleTarget` struct + `BehavioralCaptchaSolver::draw_triangle_via_cdp` method are now wired as a CDP-input fallback after the JS triangle dispatch (real `Input.dispatchMouseEvent` populates `offsetX`/`offsetY` whereas synthetic JS `MouseEvent`s do not). The lib now compiles with zero warnings.

#### Added
- **Schema** — `Triggers::title_contains` (case-insensitive substring match against `document.title`) and `Triggers::cookie_names` (presence-only match against parsed `document.cookie`). Probe JS extends to scan both. Catches title-only interstitials ("Just a moment", "DDoS-Guard", "Imperva incident id") and silent-pass cookie tokens (`__cf_bm`, `_dd_s`, `_abck`, `KP_UIDz`, …) without forcing every WAF rule to ship a stable widget selector.
- **`rules/community.toml`** — 26 → 75+ provider entries:
  - Anti-bot stacks: Sucuri, Reblaze, Netacea, Radware, Indusface, Variti, Edgio, StackPath, Fastly NGWAF, Wordfence, Imunify360, ModSecurity-CRS, Barracuda WAF, F5 Advanced WAF, Akamai Kona Site Defender, Azion, Section.io, Bunny Shield, Distil legacy, DDoS-Guard, Qrator, Imperva Protect, Castle.io, Forter, Fingerprint Pro, Anti-Bot.io, Render Shield, Hostinger Protect, OVH Anti-DDoS, Vercel Firewall.
  - Captcha SaaS: hCaptcha Enterprise, reCAPTCHA Enterprise, NetEase Yidun, VAPTCHA, Naver, Kakao, KSecure, Alibaba NoCaptcha, Tencent v2, MTCaptcha, captchas.io, captcha.io, DuxCaptcha, Tunnel, Humanity.
  - Title/cookie-only interstitials: cloudflare-just-a-moment, ddos-guard-text, imperva-block-text, qrator-text, akamai-reference-block, fastly-too-many-requests, anubis-text, vercel-challenge, fly.io-block, render-block, github-browser-challenge, stackoverflow-throttle, linkedin/instagram/facebook/x/amazon/ebay/walmart/twitch/reddit/discord/paypal/google challenges.
  - Cookie-name triggers added to the legacy datadome / akamai-bot-manager / perimeterx-human / imperva-incapsula / kasada entries so post-challenge cookies are recognised even after the visible widget tears down.
- **`captchaforge::stealth_profiles`** — 5 → 12 named profiles: `SafariIphone`, `SafariIpad`, `SafariMacStable` (no userAgentData, distinct WebKit UA), `ChromeLinux`, `BraveWindows`, `OperaWindows`, `SamsungInternetAndroid`. Tests cover that Safari profiles don't accidentally inherit Brave/Opera UA tokens, that Brave lists `Brave` as the first brand, and that mobile profiles report `mobile == true` with phone-sized viewports.
- **`captchaforge::sdk`** — new module with one-call SDK helpers, all re-exported at the crate root:
  - `prepare_page(page, profile)` — applies stealth + named profile + warmup BEFORE the first `goto` so first-page fingerprint checks see a clean session.
  - `solve_url(page, url, profile)` — `prepare_page` + `goto` + `auto_solve` in one call. The simplest possible entry point.
  - `dismiss_cookie_consent(page)` — clicks the obvious "accept all" on OneTrust / Cookiebot / Cookiehub / Quantcast / Didomi / Usercentrics / `accept-cookies`-class banners; falls back to hiding the overlay when no button matches. Idempotent.
  - `auto_solve_with_retries(page, max_attempts, inter_attempt_ms)` — wraps `auto_solve` in a fixed retry loop, dismissing CMPs between attempts.
  - `wait_for_no_captcha(page, timeout_ms)` — polls detection at 200ms cadence until the page is captcha-free or the budget elapses.
- **`captchaforge::cookies`** — `vendor_cookies(input, vendor)` filter that returns only the cookies issued by a named vendor (Cloudflare / Akamai / DataDome / PerimeterX / Imperva / Kasada / Fastly / Sucuri / Anubis). Useful for forwarding the minimum viable session state to a non-browser HTTP client. Tests cover prefix-match, case-insensitive vendor names, and unknown-vendor empty result.
- **`captchaforge::behavior`** — three new gestures:
  - `hover_dwell(page, x, y)` — moves to a viewport coordinate without clicking and dwells 200-700ms (triggers `mouseenter`/`mouseover` listeners that anti-bot widgets watch for).
  - `touch_swipe(page, x0, y0, x1, y1, duration_ms)` — ease-out swipe gesture for mobile-profile slider widgets.
  - `pointer_jitter(page, cx, cy, cycles, cycle_ms)` — sinusoidal ±2-4px micro-tremor at a centroid, simulating real-pointer-device noise.
- **CLI** — two new subcommands:
  - `captchaforge rules [--by-name|--by-priority]` lists every bundled provider rule (name, priority, recommended solvers) so operators can audit what coverage they ship.
  - `captchaforge profiles` lists every named `StealthProfile` with platform / mobile / viewport / UA prefix.

#### Verified
- 468 → 472 lib tests (`+4` `vendor_cookies` predicates) — all green, plus the new `parses_title_and_cookie_triggers` rules-schema test.
- `cargo build -p captchaforge` and `cargo build -p captchaforge-cli` both compile with **zero warnings** (was 2 dead-code warnings before this round).
- Bench solve suite: 32/39 → 34/39. The 5 remaining failures are all `real/*` (live-vendor JS not delivering tokens to localhost+headless within the 12s `WaitForToken` budget); `real/turnstile_block` is the negative control whose 0% is the audit-honest correct outcome that proves the chain doesn't fake-pass.

#### Notes
- `lib.rs` is back under 100 lines of executable code — every public helper now lives in its own dedicated module (`sdk`, `cookies`, `behavior`, …) and is re-exported at the crate root for ergonomics. This restores the SDK's "one module per concern" boundary that the SDK-ergonomics rounds had eroded.

## [0.2.24] - 2026-05-11

### STT fallback ladder (2026-05-11, round 26)

The audio-fallback path is pointless if STT fails. A single configured endpoint is a single point of failure: a local Whisper container that crashed mid-day, an OpenAI rate-limit window, or a 2captcha audio quota exhaustion all turn the audio path into silent failures. Until this release, both `AudioCaptchaSolver` and `RecaptchaAudioSolver` had exactly one configured endpoint each.

#### Added
- **`captchaforge::stt`** — new top-level module:
  - `SttEndpoint { name, url, headers, content_type, timeout_ms, extract }` — describes one ladder rung.
  - `TranscriptExtract::{ RawBody, JsonPointer(String) }` — per-endpoint response-extraction strategy. RFC 6901 JSON-pointer support covers OpenAI Whisper (`/text`), Google STT (`/results/0/alternatives/0/transcript`), and any vendor whose response is valid JSON.
  - `SttPipeline { endpoints }` — ordered ladder. `transcribe(bytes)` tries each in order; first non-empty transcript wins. If all fail, returns the most informative error.
  - Convenience constructors: `SttEndpoint::local_whisper(url)` and `SttEndpoint::openai_whisper(api_key)`.
- **`RecaptchaAudioSolver::with_stt_pipeline(pipeline)`** — opt-in install. Pipeline takes precedence over the single-URL endpoint when configured.

#### Verified
- 420 → 431 lib tests (`+11` STT extraction + pipeline contract). All green.
- 41 → 43 doctest (`+2` openai_whisper + extract_transcript). Clippy clean.
- Two async tests cover the empty-pipeline error and the all-endpoints-failed aggregate error path.

#### Notes
- Existing `with_stt_endpoint(url)` callers continue working unchanged. Pipelines are strictly opt-in.
- The pipeline is fault-tolerant per endpoint: client-build failures, send failures, non-2xx HTTP, body-read failures, and extraction failures all carry an error string into `last_err` and continue to the next endpoint. Only when ALL fail does the call return Err.

## [0.2.23] - 2026-05-11

### Anti-fingerprint sweep — propagate `mouse_move_human` across the chain (2026-05-11, round 25)

`mouse_move_human` (0.2.21) and `type_human` (0.2.22) shipped as opt-in alternatives, but the chain still called the parametric Bézier helper at four sites — `BehavioralCaptchaSolver::warmup` (3-5 mouse meanders), `click_recaptcha_v2`, `click_turnstile`, and the post-click approach phases inside `RecaptchaAudioSolver`. This release sweeps all four onto the trace-replay path so the anti-fingerprint surface is uniform across vendors.

#### Changed
- `BehavioralCaptchaSolver` warmup meander loop now uses `mouse_move_human` (3-5 random target jumps each draw a fresh trace).
- `BehavioralCaptchaSolver::click_recaptcha_v2` and `click_turnstile` checkbox approaches use `mouse_move_human`.
- `RecaptchaAudioSolver` audio-button approach AND verify-button approach use `mouse_move_human`.

#### Verified
- 420 lib tests still pass — sweep is wire-only, no contract change. Clippy clean. The legacy `mouse_move_bezier` remains exported and untouched for callers that want a tight-bound fast move.

## [0.2.22] - 2026-05-11

### Bigram-aware keystroke timing + typo-injection (2026-05-11, round 24)

`type_realistic` (the only typing helper since 0.1) dispatches each character with a uniform 80–250ms inter-key delay. That distribution is bot-shaped: real typists have bigram-dependent timing — `th`, `he`, `in`, `er` are typed at 60–110ms intervals because the motor program is over-trained, while cold combinations like `qz`, `xj`, `wq` take 250–400ms. A uniform-across-bigrams distribution is one of the cheapest signals a behavioural classifier can train on, and it sits alongside the parametric-Bézier mouse fingerprint that 0.2.21 closed.

This release closes the keystroke half of the same gap.

#### Added
- **`captchaforge::keystroke_timing`** — new top-level module with:
  - `Keystroke { ch, hold_ms, gap_ms_before, is_correction }` — the unit emitted by the planner. `gap_ms_before` model means the first keystroke has gap 0; the dispatcher sleeps that gap, sends keydown, sleeps `hold_ms`, sends keyup.
  - `bigram_gap(prev, next) -> (min_ms, max_ms)` — looks up the inter-key envelope for a transition. Hot bigrams (top 40 English from Norvig + Dhakal CHI 2018 envelope) sit at 60–200ms; cold bigrams fall through to 180–320ms; space-after-letter is 90–170ms (thumb-already-on-spacebar); digit-pairs are 200–360ms (cold number row).
  - `hold_envelope(ch) -> (min_ms, max_ms)` — per-character keydown→keyup hold envelope. Lowercase letters 30–75ms, uppercase 50–100ms (shift modifier), digits 45–90ms, space 35–80ms.
  - `qwerty_neighbour(ch, seed)` — picks an adjacent key on a QWERTY layout for typo injection.
  - `plan_keystrokes(text, plan, rng) -> Vec<Keystroke>` — pure planner. Optional typo injection (default 1.5% per character) inserts a wrong-neighbour char + backspace + correct char triple. Optional thinking pauses (default 10% per character after the first) add 200–600ms on top of the bigram baseline.
  - `TypingPlan { typo_probability, thinking_pause_probability }` config struct.
- **`captchaforge::behavior::type_human`** — live dispatcher around the planner. Maps the planner's Unicode BS (`\u{0008}`) onto the CDP `Backspace` named key with `windows_virtual_key_code = 8`.

#### Changed
- `RecaptchaAudioSolver` now uses `mouse_move_human` and `type_human` for the response-field interaction. The audio path was the most fingerprint-sensitive site in the chain — the response field is what the typist visibly populates after listening to the clip, and reCAPTCHA's input-event timing model scores it.

#### Verified
- 403 → 420 lib tests (`+17`): bigram envelope coverage (hot/cold/space/digit/case-insensitive), hold envelope per character class, qwerty neighbour table coverage for all 26 letters + non-letter rejection, planner contracts (no-typo length preservation, typo expansion to 3-tuples, typos skip non-alpha, thinking pause amplification, hot bigram speed assertions, first-keystroke zero-gap, post-backspace recovery envelope).
- 36 → 41 doctest (`+5`): `bigram_gap`, `hold_envelope`, `qwerty_neighbour`, `plan_keystrokes`, plus the inline contract.
- Clippy clean with `-D warnings`.

#### Notes
- The bigram numbers were tuned against published typing-latency corpora (Dhakal et al. CHI 2018). Per-deployment tuning should ideally come from real measurement, but the bundled defaults are defensible for cold-start.
- Existing `type_realistic` callers continue working unchanged; bigram-aware typing is opt-in via `type_human`.

## [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