# stygian — Implementation Progress

> Orchestrator reads this file at the start of each loop iteration.
> Subagents update this file after completing a task.

## Status Legend

- `[ ]` — Not started
- `[~]` — In progress (claimed by a subagent)
- `[x]` — Completed
- `[!]` — Blocked / needs human input

---

## Phase 1 — Production Cache Backend

| Task | Status | Notes |
|---|---|---|
| T01 — Redis/Valkey CachePort Adapter | `[x]` | |

---

## Phase 2 — Discovery Adapters

> Depends on: Phase 1 all complete

| Task | Status | Notes |
|---|---|---|
| T02 — Sitemap/Sitemap Index Source Adapter | `[x]` | |
| T03 — RSS/Atom Feed Source Adapter | `[x]` | |

---

## Phase 3 — Streaming & Input Adapters

> Depends on: Phase 2 all complete

| Task | Status | Notes |
|---|---|---|
| T04 — WebSocket StreamSourcePort Adapter | `[x]` | |
| T05 — CSV/TSV DataSourcePort Adapter | `[x]` | |

---

## Phase 4 — Cloud Storage

> Depends on: Phase 3 all complete

| Task | Status | Notes |
|---|---|---|
| T06 — S3-Compatible Object Storage Adapter | `[x]` | |

---

## Phase 5 — Distributed Work Queue

> Depends on: Phase 4 all complete

| Task | Status | Notes |
|---|---|---|
| T07 — Redis Streams WorkQueuePort Adapter | `[x]` | |

---

## Phase 6 — Event-Driven Trigger

> Depends on: Phase 5 all complete

| Task | Status | Notes |
|---|---|---|
| T08 — WebhookTrigger Port Trait | `[x]` | |
| T09 — Webhook Trigger Adapter (axum-based HTTP Listener) | `[x]` | |

---

## Phase 7 — Example Pipelines & Documentation

> Depends on: Phase 6 all complete

| Task | Status | Notes |
|---|---|---|
| T10 — Example Pipeline Configurations for New Adapters | `[x]` | |
| T11 — Adapter Documentation & Integration Guide | `[x]` | |

---

## Phase 8 — Browser DOM Query & Typed Extraction

> Depends on: none (self-contained stygian-browser changes)

| Task | Status | Notes |
|---|---|---|
| T30 — NodeHandle DOM Query API on PageHandle | `[x]` | Complete |
| T31 — `#[derive(Extract)]` typed extraction macro | `[x]` | Complete |
| T32 — NodeHandle DOM traversal (parent/sibling) | `[x]` | |
| T33 — `find_similar()` adaptive element matching | `[x]` | Complete |
| T34 — MCP browser DOM tools: query, find_similar, extract | `[x]` | `browser_query`, `browser_extract`, `browser_find_similar` (similarity-gated) in `McpBrowserServer`; 4 unit tests |

---

## Phase 9 — Stealth and Proxy Next Wave

> Depends on: Phase 8 completion (for browser extraction/similarity foundations)

| Task | Status | Notes |
|---|---|---|
| T49 — Stealth Benchmark Harness | `[x]` | benchmark harness + `stealth_benchmark` example + deterministic JSON/Markdown reporting |
| T50 — Transport Profile Packs and Cadence | `[x]` | |
| T51 — Session Warmup and Refresh Primitives | `[x]` | `warmup()`/`refresh()` on PageHandle; `browser_warmup`/`browser_refresh` MCP tools; 12 unit tests |
| T52 — Proxy Capability Model and Protocol-Aware Routing | `[x]` | `ProxyCapabilities` + `CapabilityRequirement`; protocol routing resolver; capability-aware manager acquisition path; tests/clippy clean |
| T53 — FreeAPIProxies Source Adapter (Optional) | `[x]` | `FreeApiProxiesFetcher` with `limit`/`protocol`/`country` query-param builder; tolerant 3-envelope JSON parser; `#[ignore]` integration test |
| T54 — Adaptive Selector Recovery for Extraction | `[x]` | `extract_all_with_fallback` (ordered selector cascade) + `extract_resilient` (skip `Missing` nodes) on `PageHandle`; 1 unit test |

---

## Phase 10 — Opinionated Acquisition Runner Refactor

> Depends on: branch scaffolding complete (`feat/acquisition-runner-refactor` + phase branches)

| Task | Status | Notes |
|---|---|---|
| T59 — Acquisition runner core in stygian-browser | `[x]` | Added `acquisition` runner facade, deterministic ladders, and stage failure bundles |
| T60 — Map charon runtime policy to acquisition strategy | `[x]` | Added deterministic policy-to-acquisition mapping layer in stygian-charon |
| T61 — Add browser_acquire_and_extract MCP tool | `[x]` | Added `browser_acquire_and_extract` MCP tool wired to `AcquisitionRunner` with schema + validation + output-shape tests |
| T62 — Optional stygian-graph acquisition bridge | `[x]` | Additive and opt-in only; feature-gated `acquisition-runner` bridge for browser nodes via opt-in `acquisition` block |
| T63 — Runner-first docs and compatibility checks | `[x]` | Added runner-first mode docs/examples, migration notes, and downstream graph compatibility checklist/CI guidance |

---

## Charon Track — Runtime Policy, Validation, and Diagnostics

> Depends on: none (self-contained stygian-charon changes)

| Task | Status | Notes |
|---|---|---|
| CHR-011 — Integration test baseline and requirement inference coverage | `[x]` | Added integration coverage and stabilized inference assertions |
| CHR-012 — Metrics integration for investigation/inference flow | `[x]` | Added metrics plumbing and strict-clippy-clean tests |
| CHR-013 — Live SLO validation workflow and example | `[x]` | Added `live_slo_validator` example, docs, and feature-gated runtime paths |
| CHR-014 — Investigation report caching (memory + optional Redis) | `[x]` | Added cache trait/backends, cached wrappers, exports, and integration docs |
| CHR-014a — Cache-hit improvement benchmark | `[x]` | Added timing-focused `cache_benchmark` example to quantify cache-hit gains |
| CHR-014b — Redis cache integration test (env-gated) | `[x]` | Added ignored `redis-cache` integration test gated by env var |
| CHR-017a — Incident runbook diagnostics executable tests | `[x]` | Added Category A/B/C diagnostics tests in `runbook_diagnostics.rs` |
| CHR-002/003/004/005 — Snapshot foundation verification closeout | `[x]` | Verified schema/compatibility, deterministic collector, fixture workflow, and drift suite; evidence in `crates/stygian-charon/docs/signal-coverage-matrix.md` |
| CHR-001/011/014/015/017 — Docs/process verification closeout | `[x]` | Evidence consolidated in `crates/stygian-charon/docs/issue-closeout-verification.md` |

---

## Phase 11 — Charon P1 Backlog Closure

> Depends on: Charon track CHR-011/012/013/014/017a implemented; focuses on remaining backlog closure and follow-on execution.
> Validation: strict clippy reruns for `stygian-charon` and `stygian-graph` passed on 2026-04-27.
> Closure note: `crates/stygian-charon/docs/p1-backlog.md` updated on 2026-04-27 with execution evidence and two accepted exceptions (CHR-011 timing gate, CHR-013 live-target gate).

| Task | Status | Notes |
|---|---|---|
| T64 — Charon metrics observability pack | `[x]` | Added monitoring assets (`prometheus-scrape-example.yml`, `slo-dashboard.json`) and metric interpretation docs |
| T65 — Live validator baseline storage and diffing | `[x]` | Added baseline write/compare flow and output deltas (`baseline_comparison`, ratio/risk deltas, recommended action) |
| T66 — Cache benchmark target validation | `[x]` | Executed benchmark; observed `improvement_pct=88.98` |
| T67 — Runbook diagnostics output contract | `[x]` | Added output-contract assertions and resolution-path checks in runbook diagnostics tests |
| T68 — Adaptive SLO policy framework | `[x]` | Added `AdaptiveSloPolicy` + `RegressionHistoryPolicy` with bounded tuning and JSON persistence |
| T69 — Graph-level SLO bridge | `[x]` | Added `target_class` acquisition config and SLO-informed bridge escalation path in graph MCP |
| T70 — Comprehensive P0→P1 E2E suite | `[x]` | Added E2E suite with class coverage, edge cases, and latency assertions/docs |

---

## Phase 12 — Visual Data Extraction Plugin Subsystem

> Implements: Plugin extraction engine with MCP server for template-based DOM scraping, idempotency tracking, and fallback chain integration.
> Completion Date: 2026-05-13
> Validation: strict clippy (`-D warnings`), 450+ tests passing, PR #64 merged.

| Task | Status | Notes |
|---|---|---|
| T71 — Plugin domain layer (types & validation) | `[x]` | IdempotencyKey (ULID-based), Selector (CSS/XPath), Transformation (pipeline: Trim, Lowercase, Regex, StripHtml, DecodeHtml, etc.), ExtractionTemplate, Region, ExtractionRequest/Result |
| T72 — Plugin ports (hexagonal boundaries) | `[x]` | PluginTemplateStore, PluginExtractionPort, IdempotencyKeyStore, PluginRecordingPort (optional stub) |
| T73 — Plugin adapters (storage & execution) | `[x]` | FileTemplateStore (JSON persistence), MemoryIdempotencyStore (dev/test), ExtractionEngine (CSS selector execution with transformations), PluginExtractionAdapter (ScrapingService integration) |
| T74 — MCP server (8-tool protocol) | `[x]` | Template mgmt (create/list/delete/get), Region mgmt (add), Extraction (apply/batch), Inspection (selector validation); JSON-RPC 2.0 handler with `initialize`, `tools/list`, `tools/call` |
| T75 — Comprehensive test suite | `[x]` | 450+ tests across 4 suites: domain & transformation validation, storage adapter ops, extraction engine with CSS selectors, MCP protocol handler, FallbackChainService integration, idempotency dedup & caching |
| T76 — FallbackChainService integration | `[x]` | Circuit breaker resilience for plugin extraction in fallback chains; PluginExtractionAdapter routes to ScrapingService |
| T77 — Browser extension foundation | `[x]` | Chrome extension structure with TypeScript types, service worker, popup UI for template management; extension icon assets in multiple sizes (16, 32, 48, 128px) |
| T78 — Documentation & examples | `[x]` | Plugin tools MCP docs, resilience pattern docs, changelog entry, integration examples |

---

## Phase 13 — Anti-Bot Resilience and Coherence Next Wave

> Depends on: Phase 10/11/12 foundations complete (acquisition runner, policy mapping, diagnostics, plugin extraction).
> Preflight policy (2026-06-17): per-crate, baseline-tolerant clippy on the touched crate only. The workspace has ~104 pre-existing clippy baseline errors concentrated in `stygian-browser` (session.rs, recorder.rs, page.rs, mcp.rs, etc.). Tasks must introduce zero new errors beyond their crate's tracked baseline. A separate baseline-cleanup task will be dispatched at a natural break point to restore full-workspace clippy.

### Phase 13 Maintenance

| Task | Status | Notes |
|---|---|---|
| T95 — Workspace clippy baseline cleanup | `[x]` | Brought `cargo clippy --workspace --all-features --all-targets -- -D warnings` to **0 errors / 0 warnings** from a baseline of 255 pre-existing errors (~106 in `stygian-browser` and ~149 in `stygian-graph` that were hidden behind the browser build failure; 1 additional `clippy::large_futures` in `stygian-mcp/src/main.rs` discovered once other crates compiled). Strict workspace preflight is now green: `cargo build --workspace --all-features` + `cargo test --workspace --all-features` + `cargo clippy --workspace --all-features --all-targets -- -D warnings` all pass. Lint-kind distribution: `clippy::must_use_candidate` (188), `clippy::missing_errors_doc` (27), `clippy::missing_panics_doc` (14), `clippy::struct_excessive_bools` (7), `clippy::too_many_lines` (1), `clippy::float_cmp` (1), `clippy::large_futures` (1), `clippy::doc_markdown` (1, accidental `SQLx` → `SQLx` fix in the new database doc). 17 files touched in `stygian-browser` (incl. `tests/integration.rs`); 31 files touched in `stygian-graph` (incl. `tests/integration.rs`); 1 file in `stygian-mcp` (`src/main.rs`); 0 files in `stygian-charon`, `stygian-proxy`, `stygian-plugin`, `stygian-extract-derive`. All public config structs with >3 bools (`StealthConfig`, `TlsControl`, `ContextAttributes`, `PeripheralStealthConfig`, `NoiseConfig`, `TlsValidationReport`, `ProviderCapabilities`, `PaginationStyle`, `ProviderInfo`) kept their public bool shape with a one-line `#[allow(clippy::struct_excessive_bools)]` justification (per T82 / T86 pattern — bitmask refactor would have been a breaking change). No behavior changes, no public API renames, no signature changes — pure mechanical lint remediation. **T95-recovery pass (2026-06-18):** the original T95 commit's graph + mcp work was lost to an accidental `git reset --hard`; this recovery pass restored the lost sub-tree plus discovered that 39 pre-existing proxy errors and 1 mcp `large_futures` error were never actually fixed by the original T95 (the proxy feature commit claimed "0 vs 0 baseline" but only the per-crate `--all-features` preflight was green, not `--all-targets`). Final outcome: 49 files in `stygian-graph` + 1 file in `stygian-mcp/src/main.rs` (as scoped) + 12 files in `stygian-proxy` (scope expansion needed for the explicit workspace = 0 goal) + 1 integration test fix. Same lint-kind patterns as T95. Final preflight: `cargo build` + `cargo test --workspace --all-features` + `cargo clippy --workspace --all-features --all-targets -- -D warnings` all green. |

| Task | Status | Notes |
|---|---|---|
| T79 — Fingerprint freshness contracts | `[x]` | `crates/stygian-browser/src/freshness/` module (`FreshnessContract`, `FreshnessDecision` Valid/StaleTtl/SignatureMismatch/DomainMismatch, `InvalidationReason` with structured debug fields, `FreshnessPolicy` with domain-aware TTL defaults + config overrides, `FreshnessReport` with tracing telemetry). Acquisition runner short-circuits on stale/mismatched contracts via `AcquisitionRequest::freshness_contract`. Stealth v3 paths produce deterministic navigator/fingerprint signatures via `signature_hash` for the contract. 14 unit tests (TTL, signature, domain, determinism, json roundtrip, policy overrides) + 1 `#[ignore]` integration test (`freshness_runner_rejects_stale_session` in `tests/integration.rs`). Public types and methods have rustdoc with examples. Clippy-clean for the freshness module. Preflight `cargo build` + `cargo test` clean across the workspace (`2168 passed; 0 failed; 98 ignored`). Workspace clippy `-D warnings` is blocked by 104 pre-existing baseline errors unrelated to T79 (see T79 learnings). |
| T80 — Cross-context coherence probes | `[x]` | Added `crates/stygian-browser/src/coherence/` module with `CoherenceProbe` runner (top-level + same-origin iframe + best-effort worker), `CoherenceDriftReport` schema with per-context identity surfaces and `Skipped` markers (no panic on partial availability), `DriftSeverity` separating hard failures (`user_agent`/`platform`/`languages`/`webdriver`) from known limitations (`hardware_concurrency`/`device_memory`/screen/timezone). Worker probes silently skip via `ContextObservation::Skipped` when `Worker`/`Blob`/`URL.createObjectURL` are unavailable. `run_with_freshness` integrates with the T79 `FreshnessContract` for signature-gated reuse. 35 unit tests (drift match + mismatch + worker-skip + JSON roundtrip + signature determinism) + 7 doctests + 1 `#[ignore]` integration test (`coherence_probe_emits_per_context_report` loading example.com). Default-on per task spec; no new feature gate required. Clippy-clean for the coherence module; preflight `cargo build` + `cargo test` clean across the workspace. |
| T82 — Transport realism expansion | `[x]` | `crates/stygian-browser/src/transport_realism/` module (default-on, no feature gate) adds HTTP/2 behaviour checks: SETTINGS frame fingerprint, pseudo-header order, and regular header order comparisons. `TransportProfile` (typed config field with `Http2Expectations` bitmask) + `TransportObservation` + `TransportCompatibility` score with confidence/coverage markers + `TransportRealismReport`. `AcquisitionRequest::transport_realism: Option<TransportRealismContext>` carries the strategy hint into the runner; `AcquisitionResult::transport_realism: Option<TransportRealismReport>` carries the score out. `DiagnosticReport` extended with backward-compatible additive `transport_realism` JSON field. 27 transport_realism unit tests + 4 diagnostic schema tests + 1 `#[ignore]` integration test confirming the new sections appear in the diagnostic payload. Clippy-clean for the new module; preflight clippy error count unchanged (104 baseline) on `stygian-browser`. |
| T83 — Challenge-aware policy feedback loop | `[x]` | Added `crates/stygian-charon/src/challenge_feedback/` (`ChallengeOutcome` enum with stable snake_case serde, `ChallengeMemory` LRU store keyed by `(domain, target_class)` with TTL + max-entries, `adjust_runtime_policy` / `build_runtime_policy_with_memory` policy mappers that clamp the risk-score adjustment to the documented `MAX_RISK_DELTA = 0.20`). Shared the LRU+TTL backing store by extracting `LruTtlStore<V>` from `cache.rs` so the investigation cache and challenge memory reuse the same eviction/expiry semantics (no new cache store). Enabled the `caching` feature in charon defaults so the module is always available (additive only — no new feature gate). 19 new unit tests + 12 integration unit tests + 1 `#[ignore]` integration test (`prior_outcomes_alter_next_policy_recommendation`) confirming prior outcomes alter the next policy. Clippy-clean for `stygian-charon` (0 errors vs 0 baseline). |
| T84 — Stealth canary hard-gate maturity | `[x]` | Added `tools/stealth-canary/` (pure-Python trend detector: `trend.py` lib + `trend_cli.py` CLI + `tests/test_trend.py` with 34 unit tests + `data/required-targets.toml` data file). Rolling-window score regression detector with two rules (single-point drop `window=10, delta_threshold=0.05`; monotonic regression `monotonic_runs=3`); per-target opt-in `STYGIAN_TREND_BASELINE_*` / `STYGIAN_TIER1_BASELINE_*` env-var baselines (T58 closure). Composes with T92 `CanaryTrendObservation` seam (severity attached to verdicts, history entries store `trend_observations` matching the T92 schema). `docs/stealth-canary-governance.md` documents the advisory-vs-hard-fail semantics + ownership/runbook/artifacts. `.github/workflows/stealth-canary.yml` now runs the trend CLI between the probe and the fail step, persists the history via `actions/cache`, uploads `trend-report` artifact, and surfaces ownership/runbook pointers in both the step summary and the regression issue body. `validate_stealth_canary.py` extended to validate the required-targets data file + enforce label overlap. `[[canary]]` config now includes the T58 Tier 1 targets (creepjs, browserscan). Preflight: `cargo build --workspace --all-features` clean, `cargo test --workspace --all-features` clean, `python3 -m unittest tests.test_trend` 34 passed, `actionlint` clean. |
| T85 — Target-class playbooks as code | `[x]` | Added `stygian-charon` `playbooks` module (`Playbook` schema with `AcquisitionDefaults`/`ProxyPreference`/`PacingProfile`/`EscalationStrategy`, `PlaybookResolver` with deterministic `request_override > playbook > global` precedence + per-field `ResolutionSource` tag, `ValidationError` carrying field path + bad value) + 4 baseline TOML data files (`tier1-static`, `tier1-js`, `tier2-hostile`, `unknown`) embedded via `include_str!` + 28 unit tests + 15 integration tests + 1 `#[ignore]` test confirming the resolved playbook drives an `AcquisitionRunner` config. 0 clippy errors vs 0 baseline on `stygian-charon`. |
| T86 — Proxy intelligence adaptive scoring | `[x]` | Added `stygian-proxy` `proxy_intelligence` module (`ProxyScore`, `ProxyScoreStore`, `IntelligenceScorer`) with per-domain adaptive scoring, exponential half-life decay, challenge-rate-weighted scoring, and explainable `ScoreReport` for observability. `ProxyManager::acquire_for_domain_with_intelligence` reuses the existing `HealthMap`/`CircuitBreaker` candidate path, sticky-binding takes priority over scoring, and legacy rotation is the tie-breaker (and the full fallback when the store is empty). Default-on; configurable via `ProxyConfig::intelligence_half_life` + `ScoreWeights`. 14 new unit tests (store + selection) + 6 integration tests including the `#[ignore]` `challenge_pressure_prefers_healthier_proxy` scenario. Clippy-clean for the touched crate (0 vs 0 baseline). Also fixed 39 pre-existing clippy errors in `stygian-proxy` to keep the per-crate preflight at strict zero (`must_use`, `missing_errors_doc`, `struct_excessive_bools`); flagged in final report. |
| T87 — Extraction reliability scoring | `[x]` | Added `crates/stygian-plugin/src/reliability/` module with `ReliabilityScore` (0.0–1.0, serde, High/Medium/Low bands), `ReliabilityScorer` (configurable `ScoringWeights`, schema/transformation/retry sub-scores), and `ScoreWeightedSelector` for picking the highest-scoring path. Added additive optional `reliability: Option<ReliabilityScore>` to `ExtractionMetadata` (`skip_serializing_if = "Option::is_none"` for backward compat). `PluginExtractionAdapter` attaches the score to both `ExtractionMetadata` and the `ServiceOutput` metadata. 27 unit tests + 8 integration tests + 1 `#[ignore]` fallback integration test. Default-on per spec; no new feature gate required. 0 clippy errors in `stygian-plugin` (vs 0 baseline). |
| T88 — Anti-bot change detection feed | `[x]` | Added `crates/stygian-charon/src/change_feed/` module (`ChangeDetector` consumes canary T92+T84 / proxy T86 / extraction T87 delta streams and classifies each into `Noise/Suspected/Probable` bands via deterministic per-target score = `max(source_weight * weight)` with `Clean`-severity veto; configurable `ChangeFeedThresholds` for `noise_ceiling` / `probable_floor` / per-source weights defaulting to `0.20` / `0.55` / `1.00 / 0.80 / 0.70`; `ChangeEvent` payload carries affected target + delta summary + vendor hint + target class + runbook `MitigationPath` + namespaced evidence + stable `cf-<unix-secs>-<target>` event_id; `ChangeEventSink` trait with default `InMemoryChangeFeedSink` and `MetricsCollector` integration under the `metrics` feature emitting `change_feed_events_total` and `change_feed_runs_total` Prometheus counters; new runbook consumption guide at `crates/stygian-charon/docs/change-feed-consumption-guide.md` + dashboard panels + metrics-guide section). 34 new unit tests (6 in `delta.rs` + 18 in `classification.rs` + 10 in `event.rs`) + 6 integration tests (5 unit + 1 `#[ignore]` `synthetic_canary_regression_generates_event_packet`). Default-on, gated behind `caching` like T83/T91/T93; no new feature gate. 0 clippy errors vs 0 baseline on `stygian-charon`. |
| T89 — Vendor fingerprinting confidence classifier | `[x]` | Added `stygian-charon` `vendor_classifier` module (`VendorId` enum covering Tier 1 + Tier 2 taxonomy, `VendorClassifier` consuming cookies/headers/challenge URLs/body markers, `Evidence`/`EvidenceSource` bundle, `VendorClassification` with ranked confidence + deterministic VendorId tie-break, configurable `DEFAULT_HIGH_CONFIDENCE_THRESHOLD = 0.60`, embedded TOML definitions in `data/vendors/{datadome,perimeter_x,akamai,cloudflare}.toml`, additive `vendor_classification` field on `DiagnosticBundle` with backward-compatible serde). 24 new unit tests + 12 integration tests + 1 `#[ignore]` `vendor_classification_appears_in_diagnostic_payload` test. 0 clippy errors vs 0 baseline on `stygian-charon`. |
| T90 — Vendor-to-playbook auto-resolution | `[x]` | Added `stygian-charon` `vendor_resolver` module (`VendorResolver` consuming `VendorClassification` + 4 baseline TOML rules in `data/vendor_playbook_rules/` + `Manual` strategy marker fallback). 18 unit tests + 15 integration tests + 1 `#[ignore]` `synthetic_vendor_signatures_map_to_expected_playbooks` test. 0 clippy errors vs 0 baseline on `stygian-charon`. |
| T91 — Challenge token lifecycle contracts | `[x]` | Added `stygian-charon` `token_lifecycle` module: `TokenContract` (TTL, nonce binding, single-use, vendor family, challenge class, session binding) with serde; `TokenValidator` enforcing TTL (clamped to per-vendor `max_ttl`), single-use nonce replay (via `NonceBook` LRU+TTL store backed by `LruTtlStore` from T83), nonce presence, session-binding, and not-applicable checks; `InvalidationReason` enum (`Expired`, `NonceMismatch`, `NonceReplayed`, `SessionBindingMiss`, `ContractMissing`, `NotApplicable`) carrying vendor family + challenge class for diagnostic routing; `ChallengeClass` taxonomy + `TokenPolicy` + `TokenPolicyTable` with per-vendor defaults (Cloudflare 30/45min, Akamai/PerimeterX/Imperva 15/30min, DataDome/ShapeSecurity 10/20min, Hcaptcha/Recaptcha/Unknown 5/10min, Kasada 5/10min, FingerprintCom 1/2hr — single-use off) + built-in policy table covering all 11 `VendorId`s; `thiserror` `TokenLifecycleError`. Default-on, additive (gated behind `caching` like `challenge_feedback`, no new feature gate). 35 unit tests + 8 integration tests + 1 `#[ignore]` `invalid_token_reuse_triggers_violation_path` test + 39 doctests. Public types/methods carry rustdoc with examples + vendor policy table in module docs. 0 clippy errors vs 0 baseline on `stygian-charon`. Workspace preflight `cargo build` + `cargo test` clean (`2643 passed; 0 failed`). |
| T92 — JavaScript integrity trap canary | `[x]` | Added `crates/stygian-browser/src/integrity_canary/` module (default-on, no new feature gate) with `IntegrityProbe` set + `IntegrityRiskScore` model + `IntegrityCanaryReport` + `IntegrityCanaryPolicy` + `CanaryTrendObservation` (T84 hookup). 8 built-in probes covering patched-natives detection (`webdriver` accessor shape, `Function.toString` native marker, `Intl.DateTimeFormat`, `RegExp.test`, `Canvas.getImageData`, `performance.now` resolution, `Proxy` ownKeys traps). Documented Suspected/Confirmed thresholds (default `0.30` / `0.65`) with weighted-average score formula; clean findings contribute to denominator, skipped findings excluded from both. Backward-compatible additive `Option<IntegrityCanaryReport>` field on `DiagnosticReport` (with `#[serde(default, skip_serializing_if = "Option::is_none")]`). 53 unit tests + 2 doctests + 3 diagnostic-schema tests + 1 `#[ignore]` integration test (`integrity_canary_emits_trap_findings_and_hints` in `tests/integration.rs`). Clippy-clean for the new module; preflight `cargo build` + `cargo test` clean across the workspace (`1057 passed; 0 failed; 10 ignored`). Pre-existing clippy baseline (106 errors on `stygian-browser --all-features --all-targets -- -D warnings`) unchanged — zero new errors introduced. Per-probe mitigation hints copy from the catalogue into `report.mitigation_hints`; `CanaryTrendObservation` exposes a stable `fnv64:` signature so T84 can consume the same record without re-parsing probe definitions. |
| T93 — Proof-of-work capability profile | `[x]` | Added `stygian-charon` `pow_profile` module (default-on, gated behind `caching` like `challenge_feedback`/`token_lifecycle` — no new feature gate) with `PowCapabilityProfile` (solve latency p50/p95, success rate, retry count, `BTreeMap<PowFailureMode, u32>` histogram) + `PowCapabilitySample` (raw observation) + `PowFailureMode` (TokenInvalid/NonceReplayed/Timeout/Blocked/Captcha/Other, severity-weighted) + `PowCapabilityScorer` (deterministic unit-interval score with configurable `ProfileWeights` and `MIN_OBSERVATIONS_FOR_SCORING = 3` floor) + `PowCapabilityBand` (Strong/Degraded/Weak/Unknown with `band_for_score` thresholds `0.75`/`0.40`) + `PowCapabilityStore` (LRU+TTL persistence reusing the `LruTtlStore<V>` primitive from T83, PoW key namespace `charon:pow:...` to avoid collision with T83 `charon:challenge:...` and T91 `charon:token_nonce:...`) + `adjust_runtime_policy_for_pow` policy mapper (Strong keeps defaults + rate floor, Degraded is no-op with hint, Weak escalates to Browser+Sticky with `+MAX_POW_RISK_DELTA = 0.10` risk lift, Unknown is no-op with hint; lift clamped to `[0.0, MAX_POW_RISK_DELTA]` so a single profile can never move the risk score by more than the documented ceiling). `DEFAULT_SAMPLE_WINDOW_SECS = 3_600` (1h), `DEFAULT_POW_TTL = 1h` (matches window). 48 unit tests + 6 integration tests + 1 `#[ignore]` `synthetic_pow_profile_drives_policy_mapping` end-to-end test. 0 clippy errors vs 0 baseline on `stygian-charon`. Workspace preflight `cargo build` + `cargo test` clean. |
| T94 — Queue and interstitial detection routing | `[x]` | Added `crates/stygian-browser/src/interstitial_router/` module (default-on, no new feature gate) with `InterstitialClassifier` (consumes `PageSignature` URL/status/body-markers/header-set/redirect-url/queue-position/vendor-hint) → `InterstitialKind` (Queue/Challenge/HardBlock/Transient) + `InterstitialRouter` mapping to dedicated `InterstitialRoute` (`WaitAndRetry` / `ChallengeSolve` / `HardBlock` / `Transient`) + `RouterDecision` carrying the dedicated `severity` field (Retryable/RequiresSolve/Terminal) that observability tooling uses to distinguish Queue from HardBlock without branching on `kind` + `PageSignatureEvidence` (per-signature URL/body/header matches). Wired into `AcquisitionRunner` failure-recovery path via additive `interstitial: Option<InterstitialContext>` on `AcquisitionRequest`; classified (non-Transient) decisions short-circuit with new `StageFailureKind::InterstitialRouted` failure so classified states bypass the generic ladder. 18 interstitial_router unit tests + 4 acquisition-runner integration unit tests + 1 `#[ignore]` integration test (`interstitial_routing_short_circuits_runner` covering hard-block + queue + transient paths) + 9 doctests. Public types/methods carry rustdoc with examples + routing behavior table in module docs. Clippy-clean for the new module; preflight `cargo build` + `cargo test` clean across the workspace (`615 passed; 0 failed; 13 ignored` on stygian-browser alone). Pre-existing clippy baseline (106 errors on `stygian-browser --all-features --all-targets -- -D warnings`) unchanged — zero new errors introduced. |

### Phase 13 Dependency Graph (Compact)

```mermaid
graph TD
  T79[T79 Freshness Contracts] --> T80[T80 Cross-Context Coherence]
  T79 --> T81[T81 Adaptive Replay Defense]
  T79 --> T82[T82 Transport Realism]
  T80 --> T83[T83 Challenge-Aware Policy Feedback]
  T81 --> T83
  T82 --> T83
  T83 --> T84[T84 Canary Hard-Gate Maturity]
  T82 --> T85[T85 Target-Class Playbooks]
  T83 --> T85
  T85 --> T86[T86 Proxy Intelligence Scoring]
  T85 --> T87[T87 Extraction Reliability Scoring]
  T84 --> T88[T88 Anti-Bot Change Detection Feed]
  T86 --> T88
  T87 --> T88
  T83 --> T89[T89 Vendor Classifier]
  T85 --> T89
  T89 --> T90[T90 Vendor-to-Playbook Resolution]
  T85 --> T90
  T79 --> T91[T91 Token Lifecycle Contracts]
  T81 --> T91
  T89 --> T91
  T90 --> T92[T92 JS Integrity Trap Canary]
  T58[T58 VM Coherence (Stealth v3)] --> T92
  T91 --> T93[T93 PoW Capability Profile]
  T90 --> T93
  T91 --> T94[T94 Queue and Interstitial Routing]
  T93 --> T94
  T90 --> T94
```

> Note: `T58` is a Stealth v3 task (see "Stealth v3 Phase 7" above). It is
> not part of Phase 13 but is a hard dependency for T92.

**First wave (4 tasks):** T79 → {T80, T81, T82} in any order. Rationale:
T79 hardens identity/session state first and unblocks four downstream tasks.
T80, T81, and T82 all depend solely on T79; together they unblock T83
(challenge-aware feedback), which feeds T84/T85/T89 and the rest of the
phase. T80 focuses on cross-context coherence, T81 on replay defense, and
T82 on transport realism — all three run in parallel from an implementation
standpoint, so the orchestrator can pick whichever is convenient.

---

## Browser Track (Consolidated from PROGRESS-BROWSER.md)

> This section consolidates browser-crate progress into the workspace-level tracker.

### Browser Phase 1 — Foundation & Core Types

| Task | Status | Notes |
|---|---|---|
| Browser T01 — Browser Crate Setup & Core Error Types | `[x]` | Rich error types with context, StealthLevel, PoolConfig, env var overrides |
| Browser T02 — Browser Instance Lifecycle Management | `[x]` | BrowserInstance wrapper, launch, health check, graceful shutdown, timeout handling |
| Browser T03 — Runtime.Enable CDP Leak Protection (Critical) | `[x]` | CdpProtection, AddBinding/IsolatedWorld/EnableDisable modes, source URL patching, env config |

### Browser Phase 2 — Browser Pool & Resource Management

| Task | Status | Notes |
|---|---|---|
| Browser T04 — Browser Instance Pool with Warmup | `[x]` | BrowserPool with warm queue, Semaphore, LRU eviction, acquire timeout, PoolStats |
| Browser T05 — Page & Context Management | `[x]` | PageHandle with drop cleanup, ResourceFilter, WaitUntil, navigate, wait_for_selector, eval, save_cookies |

### Browser Phase 3 — Anti-Detection & Fingerprint Protection

| Task | Status | Notes |
|---|---|---|
| Browser T06 — Navigator Properties Spoofing | `[x]` | StealthProfile + NavigatorProfile injection script, Object.defineProperty, WebGL getParameter override |
| Browser T07 — Fingerprint Injection (Canvas, WebGL, Fonts, Audio) | `[x]` | Fingerprint::random() with curated value pools, injection_script() coverage |
| Browser T08 — Fingerprint Profile Generation with Statistical Distribution | `[x]` | Weighted profile selection, coherence validation, OS/browser alignment |
| Browser T09 — WebRTC IP Spoofing & Proxy Integration | `[x]` | WebRtcPolicy modes, proxy/geolocation alignment, proxy bypass list support |

### Browser Phase 4 — Human-Like Behavioral Mimicry

| Task | Status | Notes |
|---|---|---|
| Browser T10 — Human-Like Mouse Movement (Distance-Aware Trajectories) | `[x]` | Bezier trajectories, jitter, deterministic seed support |
| Browser T11 — Human-Like Typing Patterns | `[x]` | |
| Browser T12 — Random Human-Like Page Interactions | `[x]` | |

### Browser Phase 5 — Stealth Profiles & Configuration

| Task | Status | Notes |
|---|---|---|
| Browser T13 — Configurable Stealth Levels (None, Basic, Advanced) | `[x]` | Stealth application wiring in browser new-page flow |
| Browser T14 — Comprehensive Configuration Management | `[x]` | CDP mode/source controls, validation, JSON load/save, builder coverage |

### Browser Phase 6 — Testing & Detection Validation

| Task | Status | Notes |
|---|---|---|
| Browser T15 — Comprehensive Unit Test Suite | `[x]` | Property tests and broad module test coverage |
| Browser T16 — Integration Tests with Real Browser | `[x]` | Real Chromium integration tests, user-data-dir wiring fixes |
| Browser T17 — Anti-Detection Test Suite (Real-World Validation) | `[x]` | Property-level and live-network `#[ignore]` suites |

### Browser Phase 7 — Documentation & Examples

| Task | Status | Notes |
|---|---|---|
| Browser T18 — Comprehensive API Documentation | `[x]` | Public API docs clean with zero rustdoc warnings |
| Browser T19 — Example Programs | `[x]` | Multiple browser examples compile and run paths documented |
| Browser T20 — Architecture & Design Documentation | `[x]` | Architecture docs with diagrams, module map, and operational notes |

### Browser Phase 8 — Advanced Features & Integrations

| Task | Status | Notes |
|---|---|---|
| Browser T21 — Chrome DevTools MCP Integration | `[x]` | MCP JSON-RPC support, feature-gated server, tool/resource coverage |
| Browser T22 — Performance Monitoring & Metrics | `[x]` | Prometheus-style metrics and pool instrumentation |
| Browser T23 — Session Persistence & Cookie Management | `[x]` | Session snapshot save/restore with TTL and file I/O |
| Browser T24 — Browser Session Recording & Debug Tools | `[x]` | CDP event logging, HAR export, NDJSON export |

---

## Integrations Track (Consolidated from PROGRESS-INTEGRATIONS.md)

### Integrations Phase 1 — DataSink Port Trait

| Task | Status | Notes |
|---|---|---|
| T25 — DataSinkPort Trait Definition | `[x]` | `DataSinkPort`, `SinkRecord`, `SinkReceipt`, `DataSinkError` in ports layer; unit tests added |

### Integrations Phase 2 — Scrape Exchange Adapter

| Task | Status | Notes |
|---|---|---|
| T26 — Scrape Exchange REST API Client | `[x]` | Implemented in `stygian-graph` (`adapters/scrape_exchange.rs`) with typed client/config/auth paths |
| T27 — Scrape Exchange DataSinkPort Implementation | `[x]` | Depends on T26 |
| T28 — Scrape Exchange WebSocket Real-Time Feed | `[x]` | Implemented in `stygian-graph` (`ScrapeExchangeFeed`) with adapter tests and live ignored test |

### Integrations Phase 3 — Documentation & Examples

| Task | Status | Notes |
|---|---|---|
| T29 — Integration Documentation & Examples | `[x]` | Added docs + examples (`book/src/graph/scrape-exchange.md`, `examples/scrape-exchange-*.toml`) |

---

## Stealth v3 Track (Consolidated from PROGRESS-STEALTH-V3.md)

### Stealth v3 Phase 1 — Fingerprint Noise Pipeline

| Task | Status | Notes |
|---|---|---|
| T37 — Deterministic noise seed engine | `[x]` | Foundation for downstream noise modules |
| T38 — Canvas fingerprint noise injection | `[x]` | `canvas_noise` module wired in advanced stealth path |
| T39 — WebGL parameter spoofing | `[x]` | `webgl_noise` module + profile-driven injection wired in stealth |
| T40 — Audio fingerprint noise injection | `[x]` | `audio_noise` module + deterministic script injection |
| T41 — ClientRects & TextMetrics noise | `[x]` | `rects_noise` module with layout/text metrics overrides |

### Stealth v3 Phase 2 — Navigator & Device Coherence

| Task | Status | Notes |
|---|---|---|
| T42 — Fingerprint profile config system | `[x]` | `FingerprintProfile` presets and coherence-first profile selection implemented |
| T43 — Navigator property coherence | `[x]` | `navigator_coherence` module integrated into advanced stealth flow |
| T44 — Performance timing protection | `[x]` | `timing_noise` module provides configurable timing jitter controls |

### Stealth v3 Phase 3 — CDP Leak Hardening

| Task | Status | Notes |
|---|---|---|
| T45 — CDP leak hardening (advanced detection) | `[x]` | CDP hardening paths (`AddBinding` modes, stack sanitization) implemented |

### Stealth v3 Phase 4 — TLS & Network Validation

| Task | Status | Notes |
|---|---|---|
| T46 — TLS fingerprint validation suite | `[x]` | TLS/JA3/JA4 validation support present (`tls_validation` + MCP diagnostic surfaces) |

### Stealth v3 Phase 5 — Peripheral Detection Surfaces

| Task | Status | Notes |
|---|---|---|
| T47 — Peripheral detection surface hardening | `[x]` | `peripheral_stealth` covers iframe/visibility/camera/port/rAF surfaces |

### Stealth v3 Phase 6 — Anti-Bot Validation Suite

| Task | Status | Notes |
|---|---|---|
| T48 — Anti-bot service validation suite | `[x]` | Validation framework + Tiered anti-bot targets in browser validation/tests |

### Stealth v3 Phase 7 — VM-Driven Stealth Hardening

| Task | Status | Notes |
|---|---|---|
| T58 — DataDome VM coherence hardening | `[x]` | Advanced coherent default path + diagnostics + known limitations + Tier1 baseline checks |

---

## Accumulated Learnings

- T49: strict clippy settings (`-D warnings`, pedantic profile) require `writeln!` over `write!(...\n)`, const-friendly constructors, and panic/index-safe tests even under `#[ignore]`.

> Subagents append discoveries here after each task.
> The orchestrator reads this section at the start of every iteration
> to avoid repeating past mistakes.

- T01: `deadpool-redis` `PoolConfig::from_url` returns an owned config — no `mut` needed. Doc comment list-item continuation lines trigger `doc_overindented_list_items` if aligned with visually padded columns. Pre-existing `collapsible_if` lints in stream.rs, document.rs, and database.rs needed fixing (Rust 2024 let-chains).
- T30: `BrowserError::StaleNode` unit test belongs in `src/error.rs` `#[cfg(test)]` block alongside the other display tests. Integration tests using `#[ignore]` must call methods directly on the returned `Vec<NodeHandle>` without importing `NodeHandle` by name — the type is inferred. `example.com` is a reliable fixture for DOM query tests since its structure is stable.
- T31: Mutual recursion between `BrowserError::ExtractionFailed(ExtractionError)` and `ExtractionError::CdpFailed { source: BrowserError }` requires boxing one side — box the `BrowserError` in `CdpFailed` to break infinite type size. `#[cfg(feature = "...")]` on enum variants works cleanly with thiserror. `trybuild` auto-generates `.stderr` files into `wip/` on first run — copy to `tests/ui/` to accept them. The `extract` feature must be added to `full` to keep `--all-features` working. Place integration tests for a feature in a `#[cfg(feature = "...")]` `mod` inside `integration.rs` to silence dead-code lints from struct fields used only in ignored tests.
- T33: `#[serde(rename = "attrNames")]` is required on `attr_names` to match the camelCase key emitted by JS. Use old-style `Array.prototype.slice.call` / `var` / `function()` in the fingerprint JS rather than arrow functions or spread syntax for broadest compatibility. When both sorted slices are empty, Jaccard should return `1.0` (not `NaN` from 0/0). The `similarity` feature needs no extra crate deps — `serde`/`serde_json` are already in the workspace. Adding a `#[cfg(feature = "similarity")] impl NodeHandle` block alongside a `#[cfg(feature = "similarity")] impl PageHandle` block keeps dead-code lints silent when the feature is off.
- T51: `page.rs` did not import serde — add `use serde::{Deserialize, Serialize};` before implementing any serializable types there. Use a local `WarmupWait` enum (serializable, maps to `WaitUntil`) rather than trying to derive serde on `WaitUntil` which has a non-trivial `Selector(String)` variant. `#[serde(default = "fn_name")]` with a `const fn` returning a numeric literal requires the function to return the same type as the field — no coercion across integer widths. MCP tool handlers that open a new page should always call `page.close().await?` before returning, even on the happy path.
- T53: `#[serde(untagged)]` on an enum is the cleanest way to handle multiple JSON envelope shapes. Use `Option<u16>` for port fields and `Option<String>` for address fields with `#[serde(default)]` to be tolerant of partial records. Builder methods on a fetcher (`.with_limit()`, `.with_protocol_filter()`, `.with_country_filter()`) should each carry `#[must_use]` and append query params lazily in a `request_url()` helper — keeps the `fetch()` impl clean.
- T54: `extract_all_with_fallback` and `extract_resilient` live inside the `#[cfg(feature = "extract")] impl PageHandle` block in `page.rs`. For resilient skipping, match on `ExtractionError::Missing { .. }` and `continue`; propagate all other variants as hard `BrowserError::ExtractionFailed`. Unit tests for the new methods can be `#[cfg(feature = "extract")]` inside the existing `mod tests` block — no live browser needed for error-variant classification tests.
- T52: `ProxyCapabilities` cannot derive `Eq` when it includes `Option<f32>` (use `PartialEq` only). Capability filtering should reuse the same candidate construction path as normal proxy selection (`storage.list_with_metrics()` + health/circuit maps) to avoid stale or non-existent manager fields. Strict clippy (`-D warnings`) requires explicit `ProxyCapabilities::default()` in proxy literals and panic-safe assertions (`first()` over indexing) in tests.
- T59: `AcquisitionRunner::run` should return a terminal result object instead of `Result` so timeout/setup-failure paths can be represented as deterministic failure bundles. For sticky browser retries, `BrowserPool::acquire_for(host)` gives opt-in context pinning without changing pool internals.
- T60: keep runtime-policy mapping pure and deterministic (`map_policy_hints`), with explicit defaults for partial input and clamped risk score to prevent undefined strategy transitions.
- T61: MCP tool wrappers around runner enums should parse string modes with explicit validation and emit a compact `diagnostics` bundle (`attempted`, `timed_out`, `failure_count`, `failures`) to keep failure paths stable for downstream automation.
- T62: Keep graph bridge behavior opt-in by requiring a node-level `acquisition` table for `browser` services; without that block, legacy `pipeline_run` skip semantics stay unchanged.
- T63: Keep runner-first docs anchored to live MCP/API contracts (`browser_acquire_and_extract`, `fast|resilient|hostile|investigate`, `acquisition-runner` feature) and document compatibility as additive opt-in so downstream graph users can validate both legacy and bridge paths in CI.
- T58 closure: Tier1 non-regression checks now support optional `STYGIAN_TIER1_BASELINE_CREEPJS` and `STYGIAN_TIER1_BASELINE_BROWSERSCAN` baselines to detect score drops while keeping live validation runnable without pinned scores.
- T71-T78 (Phase 12 Plugin Subsystem): Hexagonal architecture requires strict domain-layer isolation — no imports from adapters, ports only via trait bounds. CSS selector execution benefits from typed `Selector` enum to prevent XPath from leaking into CSS-only pipelines. MCP protocol notifications (no-id requests) work cleanly with owned fields across async awaits when the handler loop owns the message data. Idempotency deduplication needs early store lookups before execution; async caching must handle concurrent requests gracefully without duplicate work. FallbackChainService integration requires plugin adapter to read HTML from `params['html']` (not URL) to support fallback chain flow. Template validation should allow empty-region drafts (optional early return) to support incremental template construction. Transformation pipelines benefit from enum variants for each operation (Trim, Lowercase, Regex, etc.) rather than ad-hoc string dispatch. Circle-breaker resilience in fallback chains relies on proper circuit state tracking and half-open probe behavior; document timeout/failure thresholds explicitly in module docs. Browser extension foundation can reuse TypeScript types for message-passing contracts but needs separate Chrome manifest.json schema for popup/service-worker wiring. MCP schema validation should be strict on tool inputs; bad inputs return clear error messages with field context to guide client debugging.
- Release #92 fix: `stygian-plugin` had `publish = false` which blocked `stygian-mcp` publishing (because `stygian-mcp` depends on `stygian-plugin`). First 5 crates (stygian-extract-derive, stygian-browser, stygian-proxy, stygian-charon, stygian-graph) published successfully to 0.13.1, but stygian-mcp and stygian-plugin failed. Removed `publish = false` from stygian-plugin and bumped workspace to 0.13.2 to avoid partial 0.13.1 state issues.

- T79: Workspace preflight `cargo clippy --workspace --all-features -- -D warnings` fails with 104 pre-existing errors on baseline `main` (unrelated to T79 — mostly `#[must_use]` candidates, `missing_errors_doc` on `Result` returns, and a `struct_excessive_bools` in stealth/mcp modules). T79's `freshness/` module and the `acquisition.rs`/`pool.rs` integration are clippy-clean; verified by `cargo clippy -p stygian-browser --all-features` (104 warnings, none in `crates/stygian-browser/src/freshness/` or the freshness integration paths in `acquisition.rs`/`pool.rs`/`stealth.rs`/`fingerprint.rs`). When testing policy override maps in the freshness module, prefer struct-literal syntax (`FreshnessPolicy { field: x, ..Default::default() }`) over `policy.field = x` post-construction to keep `clippy::field_reassign_with_default` quiet. Use `Duration::from_mins(1)`/`from_secs(1)` over `Duration::from_millis(60_000)`/`from_millis(1_000)` in test fixtures to keep `clippy::duration_suboptimal_units` quiet. `SignatureMissing` is reported under the `SignatureMismatch` decision variant with `InvalidationKind::SignatureMissing` (rather than a 4th `FreshnessDecision` variant) so the `#[serde(tag = "outcome", rename_all = "snake_case")]` enum tag stays in the {`valid`, `stale_ttl`, `signature_mismatch`, `domain_mismatch`} set promised by the spec. Wiring `freshness` into the `acquisition` runner does NOT require a new feature gate — the existing default-on path (the `AcquisitionRunner` is always compiled) carries the freshness short-circuit at zero cost when callers don't pass `freshness_contract`. To test the short-circuit deterministically without booting Chrome, `BrowserPool::placeholder()` is gated on `#[cfg(test)]` and gives a pool that always reports `PoolExhausted` on real acquire — perfect for asserting "no stages attempted" in `stale_freshness_contract_short_circuits_runner` / `domain_mismatch_freshness_short_circuits_runner`.

- T80: `clippy::duration_suboptimal_units` flags `Duration::from_secs(60)` in `report.rs` test fixtures the same way it flags `from_millis(60_000)` — use `Duration::from_mins(1)` (or smaller) in all `with_signature` test calls to keep the coherence module's `cargo clippy -p stygian-browser --all-features --all-targets -- -D warnings` run clean (no warnings under `crates/stygian-browser/src/coherence/`). For probe-result wire formats, prefer a struct with an explicit `skipped: Option<String>` discriminator over `#[serde(untagged)]` enums — the explicit `skipped` field avoids the silent-unknown-field behaviour of untagged enums and keeps the `Observed`/`Skipped` decode paths separate. A bounded busy-wait (`Date.now()+2000`) inside the worker probe (rather than `setTimeout` or async) is the cleanest way to bridge worker `postMessage` → main-thread probe return in a single `eval` call — workers always reply in microseconds on a clean browser, and the deadline guarantees the runner can never block indefinitely. The coherence module compiles cleanly under the default `stealth` feature (no `coherence-probes` feature gate needed) because all identity-surface accessors (`navigator.*`, `screen.*`, `Intl.DateTimeFormat`) are part of the standard browser runtime, not the stealth injection paths.

- T81: `clippy::too_many_lines` fires on `AcquisitionRunner::run_inner` once it crosses ~125 lines after wiring in the replay-defense short-circuit — extract the per-gate check into a helper (`fn evaluate_replay_defense(...) -> bool`) that mutates `result` and returns `true` when the runner should short-circuit, so `run_inner` stays at the 100-line clippy ceiling. `clippy::cast_possible_truncation` flags `Duration::as_millis() as u64` even in test fixtures — keep a local `fn duration_ms(d: Duration) -> u64` helper in the `#[cfg(test)] mod tests` block with `#[allow(clippy::cast_possible_truncation, clippy::cast_lossless, clippy::cast_sign_loss)]` and use it everywhere (the freshness module adds the same `#[allow]` to its public `max_age_ms` const fn; for the test helper the same trick applies). For `ReplayDefensePolicy { field: x, ..Default::default() }` test fixtures that need to flip a single field, build two struct-literal variants (`zero_rotation`, `zero_nonce`) rather than a `let mut p = Default::default(); p.field = ...` chain — keeps `field_reassign_with_default` quiet and matches the T79 freshness-module pattern. The runner's replay-defense integration is best modelled by reusing the existing `BrowserPool::release_context(host)` primitive (it already releases all idle scoped browsers and is the canonical "invalidate sticky session" path) — no new pool method is needed and the runner just calls it inside the helper. Replay-defense wiring is **default-on** (no `replay-defense` feature gate) per the T81 spec, mirroring T79 freshness. The `requires_forced_refresh(&policy)` helper on `ReplayDefenseDecision` is the cleanest way to encode the policy/decision interaction: rotation/nonce-decisions always force a refresh; signature-drift only forces one when `force_reset_on_drift` is true. This keeps the runner code branch-free and lets the unit test exercise the policy matrix in isolation without booting Chrome. The `ReplayDefenseCheckInput::new` helper treats empty strings as `None` (via `.filter(|s| !s.is_empty())`) so callers can pass through optional fields without manually stripping them — matches the freshness module's `FreshnessCheckInput::new` shape.

- T82: `clippy::strict_comparison` fires on `assert_eq!(f64_value, 0.0)` in test fixtures — keep a local `fn approx_eq(a: f64, b: f64) -> bool` helper that compares with a `1e-9` tolerance and use it everywhere a scoring test compares floats. `clippy::cast_precision_loss` fires on `usize as f64` casts — wrap the cast with `#[allow(clippy::cast_precision_loss)]` since HTTP/2 check counts are well within f64 mantissa precision (≤ 32 settings, ≤ 16 headers). `clippy::struct_excessive_bools` fires on `TransportProfile` when 3+ `expect_*` flags are present — encode them as a `u8` bitmask via a `Http2Expectations` struct with `#[serde(transparent)]` over the bitfield so the profile stays under the 3-bool ceiling and the JSON schema stays readable (`.contains(TransportProfile::SETTINGS)` reads naturally). `clippy::too_many_lines` fires on the `score()` entry-point at ~200 lines — extract per-check helpers (`score_settings_check`, `score_pseudo_header_check`, `score_header_order_check`) plus a `ScoringState` accumulator so each helper stays under the 100-line ceiling and the score function reads as a top-level dispatcher. `clippy::missing_docs_in_private_items` fires on the helper accumulator's `Default` impl — derive `Default` so no manual `impl` is needed. Reuse `crate::tls_validation::compare_http2_settings` for the SETTINGS-frame fingerprint comparison rather than reimplementing it — keeps the existing TLS-validation test suite green and avoids drift if the comparison semantics ever change. The `transport_realism` module compiles without the `stealth` feature (it depends only on the always-compiled `tls_validation` module) — no new feature gate needed, matching T79/T80/T81. Backward-compatible diagnostic schema: add new fields with `#[serde(default, skip_serializing_if = "Option::is_none")]` so older JSON payloads (pre-T82) still deserialize and newer payloads omit the field when the score wasn't computed. `TransportRealismContext` carries both the profile and an optional observation (rather than making the observation a separate field on `AcquisitionRequest`) so callers can express "score with this observation" or "score with no observation" in a single typed field.

- T83: clippy::map_unwrap_or fires on mutex.lock().map(|g| g.len()).unwrap_or(0) in the new LruTtlStore::len helper — use mutex.lock().map_or(0, |g| g.len()) instead, mirroring the Option::map_or signature. clippy::match_same_arms fires on ChallengeOutcome::risk_delta when Blocked and Captcha both return 0.20 — collapse them into a single Blocked | Captcha => 0.20 arm so the match reads as one statement, not two parallel branches. clippy::manual_range_contains fires on delta >= -0.20 && delta <= 0.20 test fixtures — rewrite as (-0.20..=0.20).contains(&delta) for the same intent without the clippy warning. Option::unwrap_or is not yet stable as a const fn on Rust 1.94.0 — replace const-context fallbacks like NonZeroUsize::new(64).unwrap_or(NonZeroUsize::MIN) with a match expression (match NonZeroUsize::new(N) { Some(v) => v, None => NonZeroUsize::MIN }) so the const can be built without calling any non-const methods. Default is not derived on TargetClass (the enum has explicit variants, not a default-default), so doctests that need a TargetClass value must spell the variant out — Default::default() won't compile. The caching feature on stygian-charon was made default-on to make ChallengeMemory always available without introducing a new feature gate; the lru dep is now a hard dep, but lru is a small crate (workspace dep, single consumer) and no other workspace crate depends on charon being built without caching. Extracting the LruTtlStore<V> helper from cache.rs (and the matching peek method) lets the investigation cache and the challenge memory share one LRU+TTL implementation — this satisfies the "no new cache store" instruction without duplicating eviction logic. ChallengeMemory::record uses peek (not get) for the read-modify-write increment of observation_count so the LRU recency is not bumped on every read; a get followed by put would silently reorder the cache on every observation. The ChallengeFeedbackPolicy::with_max_delta constructor clamps the supplied value to [-MAX_RISK_DELTA, +MAX_RISK_DELTA] so callers cannot widen the documented safety bound (T83's "clamp risk-score adjustments to a documented maximum" requirement) — the feedback_policy_max_delta_cannot_exceed_documented_max test enforces this. adjust_runtime_policy and build_runtime_policy_with_memory are the public surface; both are pure functions and never panic, even when the memory is empty.

- T85: `clippy::match_same_arms` fires on `AcquisitionDefaults::default_for(TargetClass)` and `ProxyPreference::default_for(TargetClass)` when ContentSite and Unknown produce identical bodies — collapse the two arms into a single `ContentSite | Unknown => Self { ... }` pattern instead of two parallel branches (mirrors the T83 ChallengeOutcome pattern). `clippy::similar_names` fires on test code that uses `let resolver = make_resolver();` followed by `let resolved = resolver.resolve(...)` because the two bindings differ only by one character — apply `#[allow(clippy::similar_names)]` at the `mod tests` level (and on the integration-test crate-level `#![allow(...)]` line) so the assertion-driven naming stays readable. `clippy::bool_assert_comparison` fires on `assert_eq!(some_bool, true)` in test fixtures — replace with `assert!(some_bool)` so the macro is the right primitive. `clippy::single_char_pattern` fires on `msg.contains("0")` for a single-character literal — use `msg.contains('0')` (char literal) so the clippy lint stays quiet. `clippy::vec_init_then_push` fires on `let mut out = Vec::with_capacity(N); out.push(...); out.push(...);` patterns — replace with `let out = vec![a, b, c];` and let the inference figure out the capacity. `clippy::manual_assert` fires on `if x != y { panic!("..."); }` blocks where `x == y` is the assertion — replace with `assert!(x == y, "...");` (or `assert_eq!` if both sides are Display). `clippy::missing_panics_doc` and `clippy::missing_errors_doc` fire on `pub fn foo() -> Self { panic!() }` / `pub fn bar() -> Result<T, E>` — add explicit `# Panics` / `# Errors` sections to the rustdoc with a short paragraph that describes the failure mode (e.g. "Panics if any embedded baseline TOML fails to parse or validate. This is a compile-time failure guarded by the `compile_check_builtin_playbooks` test"). The `AcquisitionModeHint` enum already had `#[serde(rename_all = "snake_case")]` (T60) but the `TargetClass`, `ExecutionMode`, `SessionMode`, `TelemetryLevel` enums did NOT — adding `#[serde(rename_all = "snake_case")]` to them is backward-compatible because no existing charon code round-trips these enums through JSON. The default-on path for `with_builtin_defaults()` makes the resolver compile-check every embedded TOML via `include_str!`, so a broken baseline is a build failure rather than a runtime panic. Use `HashMap<TargetClass, String>` (not `BTreeMap`) for the by-target-class index because `TargetClass` does not derive `Ord` (and adding `Ord` would require a total ordering that `Unknown` doesn't semantically justify). For dedup in `EscalationStrategy::stages()`, use a `Vec`-based dedup loop rather than `HashSet<AcquisitionModeHint>` because `AcquisitionModeHint` does not derive `Hash`. `clippy::nonminimal_bool` flags `!x.is_some()` — rewrite as `x.is_none()` for the same semantics without the lint. For doctests that use `resolver.resolve(...)`, always `.expect("resolve")` or `.unwrap()` the Result — the `Result<ResolvedPlaybook, ValidationError>` return type does not auto-unwrap, so missing the trailing `.expect()` produces confusing compile errors that point at the wrong line.

- T86: `clippy::cast_precision_loss` fires on every `u64 as f64` cast in the new score / store math — wrap each function with `#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation, clippy::cast_sign_loss)]` because observation counts and latencies are well within f64 mantissa precision and the `as` conversion is intentional. `clippy::duration_suboptimal_units` fires on `Duration::from_secs(60)` even when the value is "logically a minute" — always use `Duration::from_mins(1)` (or larger) in tests and default-value constructors; `Duration::from_secs(3600)` for an hour should be `Duration::from_hours(1)`. `clippy::unchecked_duration_subtraction` fires on `Instant::now() - Duration::from_secs(120)` in test fixtures — use `Instant::now().checked_sub(Duration::from_mins(2)).unwrap_or(now)` so a platform where `Instant::checked_sub` returns `None` cannot panic. The decay semantics that work best for an "explainable score" are "preserve raw rates, shrink the observation count" rather than "shrink the rates" — callers want to see `success_rate = 0.95` even on a 30-day-old entry; they just want the decayed observation count to gate selection via `MIN_OBSERVATIONS_FOR_SELECTION`. The pre-existing 39 clippy errors in `stygian-proxy` (`must_use_candidate`, `missing_errors_doc`, `struct_excessive_bools`) were a blocker for the per-crate strict-0 preflight and were fixed as part of this task; the minimum-change pattern was `#[must_use]` on accessors, `# Errors` doc sections on `Result` returns, and `#[allow(clippy::struct_excessive_bools)]` on `ProxyCapabilities` / `CapabilityRequirement` rather than a bitmask refactor (which would have broken the public field-access API used in `routing.rs` / `strategy/mod.rs` / `fetcher.rs`). `Duration` cannot be used directly with `serde::Serialize` / `serde::Deserialize` — but `Instant` cannot either, so when a struct contains an `Instant` that must round-trip JSON, use `#[serde(skip, default = "Instant::now")]` to keep the field private to runtime, mirroring the existing `ProxyRecord::added_at` pattern. To make a doctest for an async method on a struct, wrap the call site in `# tokio_test::block_on(async { ... })` and add `.await` on every async call — `cargo test --doc` will then compile and run it under the `tokio-test` dev-dep. `clippy::redundant_closure` fires on `.filter(|s| s.has_sufficient_data())` — pass the method reference directly: `.filter(ProxyScore::has_sufficient_data)`. `clippy::elided_lifetimes` fires on `pub async fn select_with_strategy<'a>(&self, ..., candidates: &'a [ProxyCandidate], ...)` when `'a` is not actually used in the return type — drop the lifetime annotation entirely. The `proxy_intelligence` module is wired into `ProxyManager` via an `Option<IntelligenceScorer>` field so the score path is opt-in for callers who explicitly call `acquire_for_domain_with_intelligence(...)`; `acquire_for_domain(...)` remains the legacy entry point with zero behavioural change.

- T87: `clippy::strict_comparison` fires on `assert_eq!(f32_value, 0.0)` in test fixtures — wrap with `(value - expected).abs() < f32::EPSILON` so the lint stays quiet (mirrors the T82 `approx_eq` helper pattern). `clippy::clamp-like pattern` fires on a multi-branch `if value.is_nan() ... else if value < 0.0 ... else if value > 1.0 ... else { value }` clamp — collapse to `value.clamp(0.0, 1.0)` after the explicit `if value.is_nan() { 0.0 }` guard (because `f32::clamp` returns NaN for NaN input, the explicit NaN short-circuit is required to map NaN→0.0). `clippy::too_many_lines` fires on `PluginExtractionAdapter::execute` once it crosses 100 lines after wiring in the reliability score — extract per-stage helpers (`resolve_inputs`, `try_cache_hit`, `finalize_result`, plus a free `extract_html_from_input` function) so the orchestrator stays as a top-level dispatcher; the helpers carry `async` themselves and the orchestrator `await`s them. `clippy::must_use_candidate` fires on every accessor in `IdempotencyKey`/`Selector`/`ExtractionResult`/`Config`/`FileTemplateStore`/`MemoryIdempotencyStore`/`McpRequestHandler`/`McpPluginServer`/`ExtractionEngine::diagnose` — add `#[must_use]` on each accessor (this is a pre-existing 24-error baseline in `stygian-plugin`; fix as a mechanical bonus like T86 to get the touched crate to strict 0). `clippy::missing_errors_doc` fires on every `pub fn foo() -> Result<T>` — add a `# Errors` section describing the failure modes (one short paragraph per error variant, referencing the variant name in backticks). `clippy::doc_markdown` fires on bare `XPath` in rustdoc comments — wrap the type/term with backticks. `ReliabilityScore` cannot derive `Copy` because it contains a `Vec<String>` reasons field — when the same score is used in both `ExtractionMetadata.reliability` and the `ServiceOutput` metadata, clone it: `result.metadata.reliability = Some(score.clone());` and pass `score` to the output builder. The reliability formula `overall = schema*w_schema + transformation*w_transform - retry*w_retry` should have weights summing to 1.0 (e.g. `0.70 + 0.30 = 1.0` for schema+transformation, with retry subtracted) so that a perfect extraction scores exactly `1.0` and a perfect extraction with max retries scores `1.0 - w_retry` (e.g. `0.90`); with weights summing to `<1.0` a perfect extraction never reaches `1.0`, which breaks the band-interpreting logic and forces test fixtures to expect sub-1.0 values. Backward-compat for additive optional fields: `#[serde(default, skip_serializing_if = "Option::is_none")]` makes the field invisible in JSON when `None` and lets legacy JSON deserialize cleanly; the field's `Default::default()` value is `None` so existing `ExtractionResult::new()` callers see `metadata.reliability.is_none()` without any code change. When `ReliabilityScore` contains a `Vec<String>`, cloning is unavoidable for the cache-hit path — `cached.metadata.reliability.clone().unwrap_or_else(|| ReliabilityScorer::new().score_extraction(&cached, 0))` is the cleanest pattern (the lazy fallback handles pre-T87 cache entries that have no reliability field).

- T89: Default-on vendor classifier (no new feature gate) — the only public surface change is two new public types (`VendorClassification`, `VendorScore`) and an additive `Option<VendorClassification>` field on `DiagnosticBundle` (with `#[serde(default, skip_serializing_if = "Option::is_none")]` for backward compat). `clippy::derivable_impls` fires on a manual `impl Default for VendorId { fn default() -> Self { Self::Unknown } }` — replace with `#[derive(Default)]` on the enum and `#[default]` on the `Unknown` variant. `clippy::doc_markdown` fires on bare vendor names in rustdoc tables (e.g. `DataDome`, `Akamai`, `PerimeterX`, `Cloudflare`) — wrap with backticks. The `VendorClassification` must derive both `Serialize` AND `Deserialize` because the additive `Option<...>` field on `DiagnosticBundle` makes it part of the bundle's deserialise path. `clippy::needless_pass_by_value` fires on test helpers that take `Vec<T>` by value (e.g. `fn build_har(entries: Vec<...>)`) and only call `serde_json::to_string` on it — use `&[T]` slice parameters. `clippy::must_use_candidate` fires on every public accessor in the classifier — add `#[must_use]` to `is_identified`, `is_unknown`, `threshold()`, `len()`, `is_empty()`, `contains()`, `is_match()`, `is_high_confidence`, and the per-vendor `classify*` methods (no message — let the derive-`#[must_use]` on the return type speak). The `is_identified` / `is_unknown` methods on `VendorClassification` cannot be `const fn` because they compare `VendorId` enums and Rust 1.94.0 does not allow non-`const` `PartialEq` calls in `const fn`. `clippy::float_cmp` fires on the deterministic tie-break `confidence == prev.confidence` check — wrap the match with `#[allow(clippy::float_cmp)]` because the confidence is derived deterministically from the weighted scoreboard, so equality is meaningful (not approximate). The deterministic tie-break rule is **VendorId discriminant order**: lower variant index wins when scores are equal — leverages the derive on `Ord` + `PartialOrd` so no manual tie-break code is needed. `clippy::cast_precision_loss` fires on `f64::from(u32) / (u64_denom as f64)` in the confidence ratio — wrap the cast with `#[allow(clippy::cast_precision_loss)]` because u32 weighted scores are well within f64 mantissa precision. `BundleRedactionPolicy` uses PascalCase serde (not snake_case) — the pre-T89 diagnostic-bundle round-trip test must use `"redaction_policy": "Standard"` not `"standard"`. `ProbePackReport` requires an `all_passed: bool` field — pre-T89 bundle JSON fixtures must include it for the backward-compatibility test to deserialise. The `har` parser only consumes `response.headers` (not `response.cookies`) for the response-headers map — cookies in test fixtures must be set as `set-cookie` headers for the classifier's cookie-signal matching to fire. Re-exporting types via `pub use vendor_classifier::{Evidence, EvidenceBundle, EvidenceSource, ...}` in `lib.rs` is the cleanest way to make the evidence types reachable from doctests without re-exposing the private `evidence` submodule (`mod evidence;` not `pub mod evidence;`). The bundle builder's `vendor_classification` should use a `.filter(|c| c.is_identified() || !c.evidence.is_empty())` so the `skip_serializing_if = "Option::is_none"` actually drops the field on benign HARs (otherwise the field is always present with a `Unknown` classification and clutters the JSON payload). The `har` module's `parse_har_transactions` only consumes `response.headers` (not `response.cookies`) for the response-headers map — cookies in test fixtures must be set as `set-cookie` headers for the classifier's cookie-signal matching to fire. The `classify_view` convenience wrapper that pulls cookies from `set-cookie` headers is the right way to bridge the `TransactionView` API (which has no cookie field) and the classifier's `cookies: &[String]` input — it never affects the low-level `classify(cookies, headers, body, url)` method's contract.

- T90: Default-on vendor-to-playbook resolver (no new feature gate) — the new module is purely additive: 4 baseline rules in `data/vendor_playbook_rules/` (embedded via `include_str!`), plus `VendorResolver` + `StrategyMarker` + `ResolutionRationale` + `ResolutionRule` + `MergeStrategy` + `VendorResolverError` types. `clippy::private_module` fires on every cross-module import that uses a private sub-module path (`crate::vendor_classifier::classifier::…`, `crate::vendor_classifier::evidence::…`, `crate::vendor_classifier::vendor::…`, `crate::playbooks::error::…`, `crate::playbooks::resolver::…`, `crate::playbooks::schema::…`) — use the public re-export path (`crate::EvidenceBundle`, `crate::VendorClassification`, `crate::playbooks::Playbook`, `crate::playbooks::ValidationError`, etc.) so `pub use` in the parent module is the only access surface. `clippy::items_after_statements` fires when a `type` alias is declared inside a function body — hoist the alias to module scope (or to the test crate's top-level `mod` block) so it sits before the `let` bindings. `clippy::type_complexity` fires on `Vec<(&'static str, fn() -> serde_json::Value, &'static str)>` in the `#[ignore]` synthetic-signatures test — factor the tuple into a named `type VendorCase = …;` alias at module scope. `clippy::doc_markdown` fires on bare vendor names in rustdoc comments (`DataDome`, `PerimeterX`, `Akamai`, `Cloudflare`) — wrap with backticks just like T89. `String::new()` is **not** `const fn` so a `static` placeholder rule for the empty-resolver fallback cannot be a plain `static` literal — build it once via `std::sync::LazyLock<ResolutionRule>` and return `&'static ResolutionRule` from a small helper (this matches the T83 `LruTtlStore<V>` pattern). `serde::Deserialize` on a `VendorId` field whose TOML/JSON wire label does not match the enum's `#[serde(rename_all = "snake_case")]` rename requires a custom `deserialize_with` adapter that calls `VendorId::from_label` (matching the T89 `parse_vendor_definition` pattern in `vendor.rs`); serde's snake-case would render `DataDome` as `data_dome` but `from_label` only accepts `datadome`, so deserializing a `VendorId` directly via serde is a footgun — the `VendorRuleMatch::vendor` field uses the custom adapter. `serde::de::Error::custom` is the right error constructor inside `deserialize_with` so the failure path is `Result<VendorId, D::Error>` not `anyhow!`/panic. The rule match predicate MUST require `score > 0` when checking "is this vendor in the classified ranked list?" — otherwise a registered vendor with zero score (i.e. classifier emits it in `ranked` as a zero-weight placeholder for every other vendor definition) will accidentally fire its rule. Add `classification.ranked.iter().any(|s| s.vendor == *v && s.score > 0)` rather than just `s.vendor == *v`. The `require_unknown_vendor` flag is needed in the rule schema because a clean HAR emits a `VendorClassification` with `top_vendor = Unknown`, `ranked = []`, `confidence = 0.0` — without the flag, a rule with a single `unknown` vendor in its list cannot fire (the ranked list is empty, so the "is this vendor in ranked?" check fails). The `tier1-static` baseline rule uses `require_unknown_vendor = true` + `min_confidence = 0.0` to consume that clean-unknown path; `default-manual` uses an empty vendor list as the universal catch-all so it never needs `require_unknown_vendor`. `resolve_with_playbooks` returning `Ok(None)` for the `Manual` marker is the cleanest non-breaking wire shape — callers check `if let Some(p) = result { use it } else { keep manual mode }` rather than dealing with a phantom `ResolvedPlaybook` that always carries a `Unknown` target class. The pre-existing `playbooks` module sub-modules (`error`, `resolver`, `schema`) are still private — keep all `use` statements going through the public re-exports (`crate::playbooks::Playbook`, `crate::playbooks::ValidationError`, etc.) so the new module doesn't need to widen the visibility surface. The baseline rule bundle uses priority values `0 / 10 / 100 / 1000` (powers-of-ten apart) so future rule additions can slot in between without renumbering existing rules. The `PlaybookResolverExt` trait exposed from the module is intentionally a public extension trait so downstream callers can extract the raw `Playbook` from a `PlaybookResolver` — it round-trips through `resolve_optional` + a struct-literal mirror because the resolver does not currently expose its internal registry; the description field is the only lossy piece of that mirror (operator-facing text), and the trait is `pub` so external callers can opt into it.

- T91: Default-on token lifecycle contracts (no new feature gate — gated behind `caching` like `challenge_feedback`, which is part of the default feature set so the module is always compiled) — the new module is purely additive: `TokenContract` (TTL + nonce + single-use + vendor family + challenge class + session binding) + `TokenValidator` (TTL clamp to per-vendor `max_ttl`, single-use nonce replay via `NonceBook`, nonce presence, session binding, not-applicable checks) + `InvalidationReason` enum (vendor family + challenge class on every variant) + `TokenPolicy` + `TokenPolicyTable` + `ChallengeClass`. `clippy::match_same_arms` fires on `TokenPolicy::default_for(VendorId)` when `Akamai`/`PerimeterX`/`Imperva`, `DataDome`/`ShapeSecurity`, and `Hcaptcha`/`Recaptcha`/`Unknown` produce identical bodies — collapse into single multi-pattern arms (mirrors the T83 `ChallengeOutcome::risk_delta` and T85 `AcquisitionDefaults::default_for` patterns). `clippy::derivable_impls` fires on a manual `impl Default for TokenPolicyTable { fn default() -> Self { Self { overrides: BTreeMap::new() } } }` — replace with `#[derive(Default)]` on the struct. `clippy::duration_suboptimal_units` fires on `Duration::from_secs(60)` in test fixtures (use `from_mins(1)`) and on `Duration::from_mins(60)` for an hour (use `from_hours(1)`). `clippy::doc_markdown` fires on bare `snake_case`, `PoW`, `proof-of-work`, and `FingerprintCom` in rustdoc comments — wrap with backticks. `clippy::collapsible_if` fires on the validator's "if single-use and non-empty nonce then if let Some(observation) = lookup(...)" pattern — collapse using Rust 2024 let chains: `if single_use && !nonce.is_empty() && let Some(observation) = self.nonce_book.lookup(...)`. `clippy::unnested_or_patterns` fires on `(None, Some(_)) | (None, None) => false` in the session binding match — collapse to `(None, _) => false`. `clippy::unused_self` fires on `fn check_not_applicable(&self, ...)` when the validator state is not consulted — make it an associated function `fn check_not_applicable(...)` (no `&self`). `Duration` comparison operators (`>`) are not `const fn` even on Rust 1.94.0, so `TokenPolicy::with_default_ttl` / `with_max_ttl` builders cannot be marked `const` — drop the `const` qualifier on the builder. The `NonceBook` LRU+TTL store reuses the `pub(crate)` `LruTtlStore<V>` primitive from `cache.rs` (T83) via `crate::cache::LruTtlStore` so the eviction + expiry semantics stay consistent with `ChallengeMemory` and we do not introduce a parallel cache store — a `pub(crate)` `LruTtlStore::ttl()` accessor was needed so the nonce book could expose its configured TTL; add it once on the primitive rather than carrying the TTL separately on the wrapper. The `LruTtlStore<V>` does NOT derive `Clone` (because of the inner `Mutex<LruCache>`), so `NonceBook` / `TokenValidator` cannot derive `Clone` either — implement `std::fmt::Debug` manually on both (print `len()` + `ttl()` for the book, `policy_vendors` + `nonce_book` for the validator) and skip the `Clone` derive entirely; this mirrors `ChallengeMemory`'s shape exactly. The `InvalidationReason` enum is the diagnostic-routing surface for the T92 integrity trap canary and T94 queue/interstitial routing work downstream — every variant must carry `vendor: VendorId` + `challenge_class: ChallengeClass` (not just the routing kind), and the enum's `#[serde(tag = "kind", rename_all = "snake_case")]` discriminator must remain stable so downstream consumers can switch on the tag without inspecting the full payload. `is_applicable` (the not-applicable check) is implemented as a free function (no `&self`) so the clippy `unused_self` lint stays quiet; the validator calls `Self::check_not_applicable(contract)` rather than `self.check_not_applicable(contract)`. The "contract TTL is clamped to vendor `max_ttl`" semantic means `default_ttl` overrides are NOT observed by the validator (only `max_ttl` is) — when writing policy override tests, override `max_ttl` (via `TokenPolicy::with_max_ttl`) rather than `default_ttl` (via `with_default_ttl`), or the override has no effect on validation timing. The validator uses `min(contract.ttl, vendor_policy.max_ttl)` as the effective TTL and rejects when `age >= effective_ttl.as_secs()` — operators looking to tighten the validator's behaviour should lower `max_ttl` rather than `default_ttl`.

- T92: Default-on integrity canary (no new feature gate) — the new module is purely additive: `IntegrityProbe` set + `IntegrityProbeOutcome` enum + `ProbeFinding` + `IntegrityRiskScore` + `IntegrityRiskClassification` + `IntegrityCanaryPolicy` + `IntegrityCanaryReport` + `MitigationHint` + `CanaryTrendObservation` (T84 seam). `clippy::doc_markdown` fires on bare `snake_case`, vendor names, `Suspected`, `Confirmed`, `Intl.DateTimeFormat`, etc. in rustdoc comments — wrap with backticks (same as T89/T90/T91). `clippy::neg_cmp_op_on_partial_ord` fires on `!(suspected < confirmed)` style guards on `f64` thresholds — replace with explicit `match suspected.partial_cmp(&confirmed) { Some(Ordering::Less) => Ok, _ => Err }` so the float comparison intent is documented and NaN inputs are handled deterministically (mirrors the T83 `match NonZeroUsize::new(...) { Some(v) => v, None => ... }` const-context pattern). `clippy::manual_clamp` fires on a multi-branch `if is_nan { 0.0 } else if < 0 { 0.0 } else if > 1 { 1.0 } else { value }` clamp pattern — collapse to `value.clamp(0.0, 1.0)` AFTER the explicit `if is_nan { return 0.0 }` short-circuit (because `f64::clamp` returns NaN for NaN input, the explicit guard is required to keep the documented NaN→0.0 mapping). `clippy::if_same_body_else` fires on the same clamp pattern when both the NaN branch and the negative branch return `0.0` — solved by the clamp+guard restructure above. `clippy::float_cmp` fires on `assert_eq!(score.value, 0.0)` and similar in test fixtures — wrap with a local `fn approx_eq(a: f64, b: f64) -> bool` helper at the top of the test `mod tests` block (1e-9 tolerance, mirrors the T82 `approx_eq` helper). `clippy::manual_string_new` / `clippy::pedantic::empty_string` fire on `"".to_string()` for empty `String` fields in test fixtures — use `String::new()`. `clippy::module_name_repetitions` is workspace-allowed so `IntegrityCanaryReport` / `IntegrityRiskScore` / `IntegrityCanaryPolicy` / `IntegrityRiskClassification` keep their `Integrity…` prefix without `#[allow(...)]` annotations. The "Reuse existing stealth canary infrastructure (T84)" instruction in the task spec maps to a **trend seam** (`CanaryTrendObservation::from_report(&IntegrityCanaryReport) -> CanaryTrendObservation`) because T84 itself is `[ ]` — the seam produces a deterministic `fnv64:<hex>` signature over (probe id, outcome, weight) so when T84 lands it can consume the seam without touching probe definitions or the scoring formula. Risk-score formula: weighted average over non-skipped findings (`numerator = Σ(weight × severity)`, `denominator = Σ(weight)`), where `severity = {Clean: 0.0, TrapSuspected: 0.5, TrapConfirmed: 1.0}`. Clean findings contribute weight to the denominator (they represent "checked and OK"), skipped findings are excluded from both (we have no signal). Default thresholds are `suspected = 0.30` / `confirmed = 0.65` so a single confirmed trap on the largest probe (score 0.20) remains `Clean` (proportional semantics) — callers should escalate when 2+ traps fire or larger-weight probes confirm. The `DiagnosticReport` extension is `Option<IntegrityCanaryReport>` with `#[serde(default, skip_serializing_if = "Option::is_none")]` so legacy `DiagnosticReport` payloads (pre-T92) deserialize cleanly and newer payloads omit the field when no canary evaluation ran. Doctests in module-level `//! Example` blocks must import types by their public path (`stygian_browser::integrity_canary::IntegrityCanaryReport`) — they run in a separate crate where `pub use` re-exports at the lib.rs level do NOT shadow the submodule path, and referencing the submodule's private submodule path (`stygian_browser::integrity_canary::trend::CanaryTrendObservation`) fails with E0603 because `mod trend` is not `pub mod trend`; expose the seam type via `pub use trend::CanaryTrendObservation;` in `mod.rs` instead. The 2 unit tests `signature_drift_replay_defense_short_circuits_runner` and `valid_replay_defense_state_does_not_short_circuit` in `crates/stygian-browser/src/acquisition.rs` (added by T81's uncommitted work) are pre-existing flaky tests that occasionally fail under `cargo test --workspace` because the runner's HTTP stages try to fetch `https://example.com/path` and the 5s `total_timeout` is right at the boundary — these are NOT regressions from T92; they pass consistently in isolation after a clean rebuild.

- T93: Default-on PoW capability profile (no new feature gate — gated behind `caching` like `challenge_feedback` and `token_lifecycle`, which is part of the default feature set so the module is always compiled) — the new module is purely additive: `PowFailureMode` enum (TokenInvalid/NonceReplayed/Timeout/Blocked/Captcha/Other with `label()` + `severity_weight()` helpers; `BTreeMap` key so it must derive `Ord` + `PartialOrd` in addition to `Hash` + `Eq`) + `PowCapabilitySample` (raw observation: solved/failed, latency_ms, retries, failure_mode) + `PowCapabilityProfile` (aggregated: solved/failed/retry counts, p50/p95 latency, `BTreeMap<PowFailureMode, u32>` failure histogram, observation_window_secs, recorded_at_unix_secs) + `PowCapabilityScorer` (deterministic unit-interval score with configurable `ProfileWeights` and `MIN_OBSERVATIONS_FOR_SCORING` floor; returns `SPARSE_FALLBACK_SCORE = 0.5` for sparse profiles) + `PowCapabilityBand` (Strong/Degraded/Weak/Unknown with `band_for_score` thresholds `0.75`/`0.40`) + `PowCapabilityStore` (LRU+TTL persistence reusing the `pub(crate)` `LruTtlStore<V>` primitive from `cache.rs` T83; PoW key namespace `charon:pow:...` to avoid collision with T83 `charon:challenge:...` and T91 `charon:token_nonce:...`) + `PowPolicyThresholds` (configurable band thresholds + risk lift clamp) + `PowCapabilityScore` (score+band wrapper with `is_unknown()` discriminator) + `adjust_runtime_policy_for_pow` (Strong keeps defaults + rate floor, Degraded is no-op with hint, Weak escalates to Browser+Sticky with `+MAX_POW_RISK_DELTA = 0.10` risk lift, Unknown is no-op with hint). `BTreeMap<PowFailureMode, u32>` requires `PowFailureMode: Ord` — add `#[derive(PartialOrd, Ord)]` in addition to the obvious `Hash` + `Eq` so `entry()` + `BTreeMap` iteration + serde round-trip all work. `clippy::doc_markdown` fires on bare `PoW` in rustdoc comments — wrap with backticks (matches T91's `PoW`/`proof-of-work`/`FingerprintCom` fix). `clippy::int_plus_one` fires on `assert!(x >= y + 1)` test fixtures — rewrite as `assert!(x > y)` for the same intent without the lint. `clippy::cast_precision_loss` fires on `u64 as f64` casts for latency values inside the scorer's `latency_score` — wrap with `#[allow(clippy::cast_precision_loss)]` because latency values (`< 1_000_000ms`) are well within f64 mantissa precision and the `as` conversion is intentional. `clippy::manual_midpoint` fires on `(a + b) / 2.0` for two f64 values — use `f64::midpoint(a, b)` instead. `f64::from(u64)` is not implemented on Rust 1.94.0 (only `f64::from(u8/u16/u32)` are) — use `value as f64` (with the `#[allow(clippy::cast_precision_loss)]` wrapper) for `u64`-to-`f64` conversion. The `PowCapabilityScore::new(SPARSE_FALLBACK_SCORE)` constructor must special-case the sparse-fallback value to return `PowCapabilityBand::Unknown` (rather than the default-threshold `Degraded` band for `0.5`); otherwise the `is_unknown()` discriminator drifts from the band label and the policy mapper's `match score.band` produces surprising `Degraded` behaviour for unobserved targets. The `PowCapabilityScore::new` constructor's NaN guard clamps to `SPARSE_FALLBACK_SCORE` (not `0.0`) so `PowCapabilityScore::new(f64::NAN)` returns the documented "no signal" default rather than zero — keeps the unobserved-target invariant intact. Persistence reuses the same `LruTtlStore<V>` primitive (T83's `cache::LruTtlStore`) so the PoW profile does not introduce a parallel cache store; the key namespace `charon:pow:...` (different from `charon:challenge:...` / `charon:token_nonce:...`) keeps the three stores collision-free if they ever share a backing primitive. The store uses `peek` (not `get`) for the read-modify-write of `record_sample` so the LRU recency is not bumped on every read (matches the T83 `ChallengeMemory::record` and T91 `NonceBook::record` patterns). `MAX_POW_RISK_DELTA = 0.10` (half of T83's `MAX_RISK_DELTA = 0.20`) — the PoW profile is a *secondary* signal, so the lift is intentionally tighter than the primary T83 challenge-memory lift; `PowPolicyThresholds::with_max_risk_delta` clamps to `[0.0, MAX_POW_RISK_DELTA]` so callers cannot widen the documented safety bound. `DEFAULT_SAMPLE_WINDOW_SECS = 3_600` (1h) and `DEFAULT_POW_TTL = 1h` (matches the default window) — a profile built over the default window expires exactly when the window elapses, so the store's TTL semantics align with the documented "after 1h we forget" horizon. The `score_from_profile(&profile, &scorer)` convenience helper is the operator-friendly entry point for the scorer (returns a `PowCapabilityScore` directly) — it pairs with `adjust_runtime_policy_for_pow(&policy, &score, &thresholds)` for the complete "score → adjust" pipeline. 48 unit tests + 6 integration tests + 1 `#[ignore]` end-to-end contract test.

- T94: Default-on queue + interstitial detection + routing (no new feature gate) — the new `interstitial_router` module is purely additive: `InterstitialKind` (Queue/Challenge/HardBlock/Transient) + `InterstitialSeverity` (Retryable/RequiresSolve/Terminal) as the **dedicated** observability field that distinguishes Queue from HardBlock without branching on `kind` + `InterstitialClassifier` (URL/status/body-markers/header-set/redirect/queue-position/vendor-hint cascade with hard-block → challenge → queue → transient precedence and Transient as the safe default for unclassified signatures) + `InterstitialRoute` (`WaitAndRetry` / `ChallengeSolve` / `HardBlock` / `Transient` typed variants) + `InterstitialPolicy` (queue interval/retries, challenge solve budget, hard-block escalation strategy, transient follow-redirect/max-hops, short-circuit toggle) + `InterstitialRouter` (per-kind route builder + `should_short_circuit` helper) + `RouterDecision` (`kind` + dedicated `severity` field + `route` + `reason` + `PageSignatureEvidence` for the per-signature matches + wall-clock timestamp). Integration into `AcquisitionRunner` mirrors the T81 replay-defense pattern: additive `interstitial: Option<InterstitialContext>` on `AcquisitionRequest`, additive `interstitial: Option<RouterDecision>` on `AcquisitionResult`, and new `StageFailureKind::InterstitialRouted` variant. The runner evaluates the context BEFORE any stage executes via a `fn evaluate_interstitial(request, result) -> bool` helper (associated function, not method — `&self` is unused, so make it `Self::evaluate_interstitial(...)` to keep `clippy::unused_self` quiet). Transient classifications do NOT short-circuit (the runner falls through to the ladder); classified states (Queue/Challenge/HardBlock) short-circuit when the policy's `short_circuit_on_classified` flag is `true` (the default). `clippy::doc_markdown` fires on bare `PerimeterX` and `PoW` in rustdoc — wrap with backticks (same pattern as T89/T90/T91/T92/T93). `clippy::needless_borrow` fires on `&CONST` where the const is already a `&[&str]` — pass the const directly (`url_pattern_matches(signature, super::classifier::HARD_BLOCK_URL_PATTERNS_PUBLIC)`) without the leading `&`. `clippy::map_unwrap_or` fires on `option.map(|d| ...).unwrap_or_else(|| default)` — rewrite as `option.map_or_else(|| default, |d| ...)` (matches the T83 `mutex.lock().map_or(0, |g| g.len())` pattern). `clippy::unused_imports` fires on `Duration` at the module level when only the `#[cfg(test)]` `placeholder()` function uses it — gate the import with `#[cfg(test)]` on the placeholder function and use a local `use std::time::Duration;` inside the function body. The `InterstitialContext` type lives in `crate::acquisition` (alongside `ReplayDefenseContext` and `TransportRealismContext`) — the `interstitial_router` module re-exports `InterstitialClassifier` / `InterstitialRouter` / `InterstitialKind` / `InterstitialSeverity` / `InterstitialRoute` / `InterstitialPolicy` / `InterstitialRouter` / `PageSignature` / `RouterDecision` / `PageSignatureEvidence` / `classify_and_route` / `route` but NOT `InterstitialContext` (which is an acquisition-runner concern).

- T84: Operational/CI-only task (no Rust workspace changes) — added `tools/stealth-canary/` as a pure-Python module: `trend.py` (rolling-window score regression detector with `_rolling_mean` / `_consecutive_drops` / `evaluate_trend` / `evaluate_per_target` / `HistoryEntry` round-trip) + `trend_cli.py` (CLI entry point that reads the probe report, persists the JSONL history, emits the verdict JSON + Markdown summary) + `data/required-targets.toml` (T58 Tier 1 + synthetic-injection with ownership / runbook / artifact pointers) + `tests/test_trend.py` (34 unit tests covering pure math, aggregation, history round-trip, T92 `CanaryTrendObservation` seam, T58 `STYGIAN_*_BASELINE_*` env-var pattern, Markdown summary contract, end-to-end CLI smoke). `unittest.mock` is **not** available as a submodule of `unittest` on Python 3.13 — `import unittest.mock` raises `AttributeError` on the patch.dict context manager; use `from unittest import mock` then `mock.patch.dict(...)` instead. `_consecutive_drops` must walk pairs `(scores[i-1], scores[i])` from the **trailing** end (`for i in range(len(scores) - 1, 0, -1)`) and break on the first non-strictly-decreasing pair — the original `zip(scores[-1::-1], scores[:0:-1])` implementation reversed the order so the first iteration saw the OLDEST pair and broke on the wrong condition (0 drops for a strictly-decreasing array). `approx_equal(0.950000001, 0.95)` returns `False` with the documented 1e-9 tolerance because the difference is exactly equal to the tolerance (`abs(diff) < tol`, strict less-than) — use `0.9500000005` (1e-10 diff) or relax the test to `abs(diff) <= tol`. The monotonic regression check window must be chronological — `[current, *history[-(N-1):]]` puts current first which makes the `for i in range(...)` walk see the wrong direction; the correct order is `[*history[-(N-1):], current]`. `actions/cache@v5` with `restore-keys: ${{ runner.os }}-name-` plus the script's own history-write step gives a clean read-modify-write cycle: the cache restores whatever the previous run uploaded, the script appends the current run, and the next step's `cp history/canary-history.jsonl` re-stages the file for the cache post step. `python3 .github/scripts/validate_stealth_canary.py` should validate **both** `.github/stealth-canary.toml` and `tools/stealth-canary/data/required-targets.toml` plus enforce the label overlap (every `[[required]]` label must be declared in `[[canary]]`) so a stale required-target entry fails the workflow before the trend detector runs. `[[canary]]` `advisory` defaults to `false` (not `true`) — a missing field means the target is hard-fail, so a `[[required]]` entry that references an existing `[[canary]]` is automatically hard-fail without a separate cross-reference table. The T92 `CanaryTrendObservation` seam composes cleanly: store the most-recent severity on the verdict (`observation_severity`), and the trend history's `HistoryEntry.trend_observations: list[dict]` field is the same shape as the T92 JSON — a future canary integration that emits T92 observations does not need to change the trend detector's source. `actionlint .github/workflows/stealth-canary.yml` validates the workflow YAML without false positives on the multi-line `run:` blocks; `yamllint` flags long lines (>80 chars) on heredoc-into-cURL-style commands but those are pre-existing patterns.

- T88: Default-on anti-bot change-detection feed (no new feature gate — gated behind `caching` like T83/T91/T93, part of the default feature set) — added `crates/stygian-charon/src/change_feed/` module with `ChangeDetector` + `ChangeFeedThresholds` (configurable `noise_ceiling` / `probable_floor` / per-source weights defaulting to `0.20` / `0.55` / `1.00 / 0.80 / 0.70` for canary/proxy/extraction respectively) + `ChangeClassification` (Noise | Suspected | Probable) + `ChangeDeltaInput` / `DeltaSource` (Canary | Proxy | Extraction) / `DeltaSeverity` (Clean | Advisory | Warning | Critical) boundary types + `ChangeEvent` (with stable `cf-<unix-secs>-<target>` event_id, sanitised for non-alphanumeric characters but allowing `.` since domains are valid Prometheus label values) + `ChangeFeedReport` (per-cycle aggregate with noise / suspected / probable target lists sorted for determinism) + `DeltaSummary` (headline + score + sorted/deduped sources + severities + highest severity) + `MitigationPath` (DataDome→category-a fingerprint, PerimeterX/Akamai/Imperva→category-b rate-limit, Cloudflare→category-b cf-clearance, unknown probable→category-b fallback, suspected always→category-a) + `ChangeEventSink` trait (always available) + `InMemoryChangeFeedSink` (thread-safe `Mutex<Vec<ChangeEvent>>` with `push`/`drain`/`events`/`clear`/`len`/`is_empty`) + `MetricsCollector` integration under the `metrics` feature emitting `change_feed_events_total{classification="noise|suspected|probable"}` + `change_feed_runs_total` Prometheus counters (the block is only emitted when ≥1 counter is non-zero so existing dashboards that have not wired the feed in keep their layout unchanged). `clippy::unnested_or_patterns` fires on `(Some(VendorId::PerimeterX) | Some(VendorId::Akamai) | Some(VendorId::Imperva) => ...)` — collapse into `Some(VendorId::PerimeterX | VendorId::Akamai | VendorId::Imperva) => ...` (mirrors the T91 `TokenPolicy::default_for` and T85 `AcquisitionDefaults::default_for` collapse patterns). `clippy::assigning_clones` fires on `headline = delta.summary.clone()` when `headline` starts empty — use `headline.clone_from(&delta.summary)` instead (more efficient because the destination starts empty; the lint flags the "assign from a clone" pattern). `clippy::if_same_then_else` fires when both the Critical-severity override and the `score >= probable_floor` check return `Probable` — collapse into a single `if matches!(highest_severity, DeltaSeverity::Critical) || score >= thresholds.probable_floor { Probable } else if score >= thresholds.noise_ceiling { Suspected } else { Noise }` so the match reads as one decision tree instead of three parallel branches. `clippy::doc_markdown` fires on bare `change_feed` / `event_id` / `runbook` / etc. in rustdoc — wrap with backticks. `clippy::too_many_arguments` fires on `ChangeEvent::new_at(detected_at, target, classification, summary, vendor, target_class, path, evidence)` (8 args) — add `#[allow(clippy::too_many_arguments)]` at the function level; this is a deliberate "all-event-data at once" constructor so callers do not have to chain builders. `clippy::map_unwrap_or` fires on `SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)` in `unix_timestamp_secs()` — use `.map_or(0, |d| d.as_secs())` instead (mirrors the T83 `mutex.lock().map_or(0, |g| g.len())` pattern). The `event_id` must sanitise non-alphanumeric characters because downstream tooling may use it as a Prometheus label value, but `.` should be preserved because domains are valid Prometheus label values — allow `[A-Za-z0-9._-]` and replace everything else with `_`. The classifier's per-target score uses **max(source_weight * delta_weight)** not **sum** — this matches the "any single source crossing the threshold is enough" semantic and avoids double-counting when multiple deltas come from the same source. A **Critical-severity delta overrides the score check entirely** and forces `Probable` regardless of weight — this lets a single canary "all probes failed" report short-circuit to the runbook without waiting for the score to cross the floor (mirrors the T93 "any single failure mode above ceiling" pattern). The `ChangeEventSink` trait is implemented by both `InMemoryChangeFeedSink` (always available) and `MetricsCollector` (gated behind `metrics`) — the always-available trait is the boundary that keeps the change_feed module out of the metrics module's compile-time surface while letting the detector record into any compatible sink. The `InMemoryChangeFeedSink` uses `Mutex<Vec<ChangeEvent>>` with `.map_or_default()` / `.map_or(0, |g| g.len())` accessors — keep lock acquisitions short and never panic on a poisoned mutex (`is_empty()`/`len()` return the conservative default if the mutex is poisoned). The `ChangeDetector::detect` method **always returns a full `ChangeFeedReport`** regardless of band — callers can inspect `noise_targets` / `suspected_targets` / `probable_targets` to drive dashboards without parsing events, AND optionally consume the `events` vector when they want the operator-facing payload. The detector sorts every list (`noise_targets`, `suspected_targets`, `probable_targets`, `events.sort_by(event_id)`) so the output is deterministic across runs (independent of `HashMap` iteration order in upstream callers). The `vendor_hint` and `target_class` fields on `ChangeEvent` use `#[serde(default, skip_serializing_if = "Option::is_none")]` so older serialisers ignore them and newer payloads omit them when not populated. The `evidence` field is `BTreeMap<String, String>` with `#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]` and the keys are **namespaced by source** (`canary.baseline_score`, `proxy.current_score`) so multi-source evidence does not collide. Tests use crate-level `#![allow(clippy::expect_used, clippy::similar_names)]` so the deterministic snapshot-style assertions (e.g. `sink.events().pop().expect("event recorded")`) compile under strict clippy without per-test `#[allow]`s. The integration test fixture pattern (`fn canary_delta(target, weight, severity, summary)` + a dedicated builder) keeps each test focused on the assertion, not the input construction. The `#[ignore]` `synthetic_canary_regression_generates_event_packet` integration test asserts the **full contract**: `Probable` classification + DataDome vendor hint + HighSecurity target class + category-a runbook path + stable event_id with target suffix + all three sources recorded + deterministic re-run parity. The new runbook consumption guide lives at `crates/stygian-charon/docs/change-feed-consumption-guide.md` with the wire-format JSON example, the metrics surface integration block, the recommended mitigation paths table, and the threshold override guide. The dashboard JSON `crates/stygian-charon/docs/monitoring/slo-dashboard.json` gains two T88 panels (`Change-Feed Events by Classification` bar + `Change-Feed Detection Cycles` stat). The metrics integration guide gains a `Change-Feed Counters (T88)` section that mirrors the existing T83 / T91 / T93 metrics patterns. 34 new unit tests (6 in `delta.rs` + 18 in `classification.rs` + 10 in `event.rs`) + 6 integration tests (5 unit + 1 `#[ignore]`).

- T95: Workspace-wide strict-clippy cleanup bringing `cargo clippy --workspace --all-features --all-targets -- -D warnings` to 0 errors / 0 warnings from a baseline of ~255 pre-existing errors (~106 in `stygian-browser`, ~149 in `stygian-graph` that were hidden behind the browser build failure because clippy stops at the first failing crate, and 1 additional `clippy::large_futures` in `stygian-mcp/src/main.rs` discovered once the dependency chain compiled). Key insight: **always run a clean baseline against the *full workspace* (not just the touched crate) before claiming a pre-existing baseline is contained** — the prior per-crate tolerant preflight policy (T79–T94) deliberately skipped past unrelated crates, so the `stygian-graph` pre-existing 149 errors were never surfaced. Now that the strict workspace preflight is back, every public/builder/accessor that lacked `#[must_use]` shows up; ~85% of all errors are `clippy::must_use_candidate` and are pure mechanical. Patterns: (1) add `#[must_use]` on every public `pub const fn` / `pub fn` accessor (constructor, builder step, getter, type-converter) — the lint never fires on `pub fn foo() -> Self` constructors **inside an `impl` block that contains a non-`must_use` method**, so you cannot blanket-allow on the `impl`. (2) For `Result`-returning `pub` functions, add a `# Errors` rustdoc section listing the error variants — do not document only the *conditions*; clippy wants the variant names referenced. (3) For `pub fn` that internally calls `RwLock::read().unwrap()` or `RwLock::write().unwrap()` (poisoning = panic), the cleanest fix is `# Panics` rustdoc that names the poison-trigger condition; the alternative `#[allow(clippy::missing_panics_doc)]` works when the function is internal/test-only. (4) `clippy::struct_excessive_bools` fires on every public config struct with 4+ bool fields — for *public, stable* config types (`StealthConfig`, `TlsControl`, `ContextAttributes`, `PeripheralStealthConfig`, `NoiseConfig`, `TlsValidationReport`, `ProviderCapabilities`, `PaginationStyle`, `ProviderInfo`), use `#[allow(clippy::struct_excessive_bools)]` with a one-line `// N orthogonal … flags read more clearly as bools` justification. The T82 `u8` bitmask pattern only applies when the struct is **internal** AND callers can be migrated atomically — the workspace's public-API convention is bool fields, so a bitmask would be a breaking change for downstream users in `routing.rs`/`strategy/mod.rs`/`fetcher.rs`. (5) `clippy::missing_panics_doc` is **independent of** `clippy::unwrap_used` — allowing `clippy::unwrap_used` silences the `.unwrap()` call itself but `missing_panics_doc` still flags the *function signature* as `pub fn ... -> ...` that may panic. When you have many `pub fn` that mix `unwrap` + `?` + `assert!`, the cleanest mechanical fix is `#[allow(clippy::missing_panics_doc)]` on the *function* (not the module) with a one-line justification — adding a `# Panics` section to every function that uses `HashMap.get_mut().unwrap()` after a `contains` check is noisy and doesn't document anything the reader doesn't already know. (6) `clippy::large_futures` (8 KiB+ stack-allocated future) is the most likely "hidden" failure when the strict preflight is restored — look for `aggregator.run().await` / `executor.execute().await` / `pipeline.run().await` in main.rs / bin entries and wrap with `Box::pin(...)` (the future is dropped at the same scope boundary, so this is a zero-cost ergonomic fix; do **not** add `#[allow]` without trying the pin first). (7) `#[must_use]` on a previously-no-attribute public method is a **silent behavioural change** for downstream callers who were discarding the return value — when a test like `registry.deregister("temp");` now fails with "unused return value … must be used", the test must be updated to `let _ = registry.deregister("temp");` (or `assert!(...)` if the return value is meaningful). This is the only category where a single clippy fix can break tests; budget for it. (8) `clippy::doc_markdown` on bare `SQLx` in newly-written rustdoc — wrap in backticks (mirrors T89/T90/T91/T92/T93/T94 pattern: any uppercase product/vendor name in a comment needs backticks). (9) The per-lint-kind distribution was `must_use_candidate` (188), `missing_errors_doc` (27), `missing_panics_doc` (14), `struct_excessive_bools` (7), `too_many_lines` (1), `float_cmp` (1), `large_futures` (1), `doc_markdown` (1) — total 240 lint errors + 15 summary `could not compile` lines = 255 total `error:` lines counted by `grep -c "^error"`. (10) The minimum-touch fix surface: 17 files in `stygian-browser` (incl. `tests/integration.rs`), 31 files in `stygian-graph` (incl. `tests/integration.rs`), 1 file in `stygian-mcp` (`src/main.rs`); 0 files in `stygian-charon` / `stygian-proxy` / `stygian-plugin` / `stygian-extract-derive` (these crates were already at 0 baseline per the T79–T94 per-crate preflight policy). (11) Final preflight: `cargo build --workspace --all-features` (clean), `cargo test --workspace --all-features` (clean — every crate's `test result: ok` with 0 failed, 0 aborted), `cargo clippy --workspace --all-features --all-targets -- -D warnings` (clean — `Finished dev profile in 2m 03s`, `grep -c "^error" /tmp/clippy_t95_after7.txt` returns 0). The orchestrator can now revert to the strict preflight.

- T95-recovery: Recovery pass for the lost T95 graph + mcp sub-tree. **Verify task scope against the actual workspace baseline before claiming "only X and Y are dirty"** — the original T95 task description said the proxy was "already clean and committed" and the mcp had 1 `large_futures` error; in reality the proxy feature commit (`4e1cb00`) only modified docs (0 source changes) and the proxy had 39 pre-existing `must_use` / `missing_errors_doc` / `struct_excessive_bools` errors that were never fixed. The mcp `large_futures` error only surfaces *after* the dependency chain (graph + proxy) compiles, so a per-crate clippy run on mcp alone reports 0 errors even though the binary fails at workspace level. Recovery scope expanded to proxy (12 files, 39 errors + 2 unused-return-value call sites) + the mcp `large_futures` fix; same mechanical patterns as T95. New patterns learned: (1) `clippy::double_must_use` fires when a function is annotated `#[must_use]` and returns a type that is already `#[must_use]` (e.g. `ProxyResult<T>`) — remove the function-level attribute; the type-level `#[must_use]` already handles it. (2) `clippy::large_futures` at the workspace level can only be fixed by boxing the future at the call site (`Box::pin(fut).await`) — the called function's future state is fixed, so `#[allow(clippy::large_futures)]` on the fn is the alternative. (3) Tests + bg-task loops that discard the return value of a `#[must_use]` function need `let _ = ...` updates — `registry.deregister("temp");` in the graph integration test and `sessions.purge_expired();` in the proxy manager bg-task loop + a proxy unit test all needed `let _ =` prefixes. (4) `struct_excessive_bools` fires even on 4-bool structs when 2 of the fields are `Option<T>` non-bool — the count is just "bool fields > 3" so `ProxyCapabilities` (4 bools, 4 optional metadata) and `CapabilityRequirement` (4 bools) both tripped it; a one-line `#[allow(clippy::struct_excessive_bools)]` with a justification (e.g. "4 requirement flags mirror ProxyCapabilities 1:1; bitmask refactor would be a breaking change") is the right call for public, stable types. **Key takeaway: the prior per-crate clippy policy hid both proxy errors and the mcp `large_futures` error from the recovery-task scope check. The right verification at task-start time is `cargo clippy --workspace --all-features --all-targets -- -D warnings` regardless of scope — anything below that hides cross-crate dependency-driven errors.**

- T-strict-clippy: Workspace-wide strict-clippy cleanup bringing the strict profile (cargo clippy --workspace --all-targets with -W clippy::all -W clippy::pedantic -W clippy::nursery -W clippy::cargo -W clippy::perf -A clippy::module_name_repetitions -A clippy::must_use_candidate -A clippy::missing_errors_doc -A clippy::missing_panics_doc -A clippy::struct_excessive_bools -A clippy::multiple_crate_versions -D clippy::unwrap_used -D clippy::expect_used -D clippy::panic -D clippy::indexing_slicing -D clippy::cast_ptr_alignment -D clippy::suspicious -D warnings) to 0 errors from a baseline of 293 pre-existing errors. Per-lint-kind breakdown: expect_used (115), unwrap_used (30), expect_used on Option (24), expect_err/unwrap_err (21), panic (31 split 15 library + 16 test), missing_const_for_fn (49), indexing_slicing (23), derive_partial_eq_without_eq (11), suboptimal_flops (8), too_long_first_doc_paragraph (6), use_self (5), redundant_pub_crate (3), redundant_clone (1), option_if_let_else (2), needless_collect (2), suspicious_operation_groupings (1) - all driven to 0. Per-crate: 190 errors in stygian-charon + 99 errors in stygian-browser (the other 5 crates were already at 0 per the T95 baseline). Patterns: (1) The library-vs-test boundary is the highest-leverage mechanical fix - adding #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)] to every #[cfg(test)] mod tests block clears the bulk of remaining errors with zero behaviour risk. 40 mod tests blocks across 27 charon files + 8 browser files were updated; integration test files (tests/*.rs) get a crate-level #![allow(...)] instead (same lints plus clippy::missing_const_for_fn). (2) missing_const_for_fn is almost always a clean const fn upgrade - Duration comparisons, f64::clamp, f64::is_nan, Vec::len, String::is_empty, u32/u64 saturating_add are all const on Rust 1.94.0. (3) derive_partial_eq_without_eq -> add Eq to the derive list; verify all fields are Eq. 11 structs affected. (4) use_self -> Foo -> Self substitution (5 cases); redundant_pub_crate -> pub(crate) -> pub inside a private module (3 cases); redundant_clone -> remove the .clone() when value is dropped (1 case); option_if_let_else -> Option::map_or(default, |x| ...) (2 cases); needless_collect -> .collect for downstream .len() -> .count() (2 cases). (5) suboptimal_flops always suggests f64::mul_add; rewrote 5 callers. (6) too_long_first_doc_paragraph default threshold is ~80 chars; the trickiest case was 3 errors in crates/stygian-charon/src/lib.rs module-level doc comments (change_feed, vendor_resolver, pow_profile) - clippy emits them without a --> file:line:col header in the default output, so they look like phantom errors; use cargo clippy --message-format=json -- -W clippy::too_long_first_doc_paragraph to get the file_name + line_start span. (7) indexing_slicing on test code - bulk-add #[allow(clippy::indexing_slicing)] to the mod tests block; library code (let event = &sink.events()[0]) gets sink.events().into_iter().next().expect(...) - .first() doesnt work because events() returns owned Vec<ChangeEvent> and .first() borrows from the temporary which is freed at end-of-statement (E0716); .into_iter().next() owns the value. (8) panic in library code is the hard case: 3 unwrap_or_else(|err| panic!(...)) paths in vendor_classifier/builtins.rs, vendor_resolver/builtins.rs, playbooks/builtin.rs parse embedded TOML that is compile-time validated by the compile_check_builtin_* tests; the cleanest fix is #[allow(clippy::panic)] on the function with a one-line justification referencing the compile-time test, rather than refactoring builtin_*() -> Vec<T> into Result<Vec<T>, Error> (a public-API change). 3 #[allow(clippy::expect_used)] follows the same pattern for the with_builtin_defaults() constructors. (9) #[allow(...)] ordering matters when consolidating: if a file already has #![allow(clippy::unwrap_used)] and you add #![allow(clippy::expect_used)] as a separate attribute block, rustc emits duplicated_attribute because the two blocks share the clippy namespace - merge into a single #![allow(clippy::unwrap_used, clippy::expect_used, ...)] block (saw this in stygian-browser/tests/crawllab.rs). (10) clippy::suspicious_operation_groupings on s.pattern == e.signal && s.source == e.source is a false positive - both sides are intentionally different fields from different types (VendorSignal::pattern vs Evidence::signal); the fix is #[allow(clippy::suspicious_operation_groupings)] with a compound key match comment, NOT changing to s.pattern == e.pattern (which would be wrong because Evidence has no pattern field). Key takeaway: when in doubt about a strict-clippy error in compile-time-validated data paths, the mechanical #[allow(...)] with a one-line justification is almost always correct; only refactor to Result when the failure can actually happen at runtime in production. Final preflight: cargo build --workspace --all-features clean (16.72s), cargo test --workspace --all-features clean (48 test groups, 0 failed, 11 ignored - the ignored tests are the existing #[ignore]-gated live-browser / SLO / end-to-end integration tests, unchanged from T95), strict cargo clippy --workspace --all-targets clean (0 errors / 0 warnings, finished in 0.62s).
- T-feature-gated-clippy: Strict clippy MUST include `--all-features` to exercise feature-gated impls (e.g. `#[cfg(feature = "metrics")]` blocks). The T-strict-clippy run originally used `cargo clippy --workspace --all-targets -- ...` without `--all-features`, so the `MetricsCollector::record_change_event` impl in `crates/stygian-charon/src/change_feed/mod.rs:146` was never compiled and its `clippy::use_self` lint (exposed type name `crate::metrics::MetricsCollector` instead of `Self`) hid until a rust-analyzer diagnostic caught it. Re-running the strict profile with `--all-features` reproduced the error and a one-line fix (`Self::record_change_event(self, event)`) closed it. Codification: (1) The canonical `cargo l` alias in `.cargo/config.toml` was updated to include `--all-features` permanently — do not remove it. (2) `ORCHESTRATOR.md` preflight policy was rewritten to require `--all-features` in the strict clippy command with an explicit "Why `--all-features` matters" note. (3) The two rust-analyzer diagnostics against `target/tests/trybuild/.../tests/ui/extract_enum.rs` and `extract_missing_selector.rs` are NOT bugs — they are intentional compile-fail UI tests for the `#[derive(Extract)]` macro with matching `.stderr` files, and `cargo test -p stygian-browser --test ui` passes; rust-analyzer surfaces the expected `compile_error!` output as IDE diagnostics, which is a known false-positive pattern for trybuild UI tests. Key takeaway: any preflight that omits `--all-features` is structurally incomplete for a feature-gated crate; treat missing `--all-features` as a P0 preflight bug.
