# Plan: alkhttp fuzzing Adopted from alkcall's `docs/research/fuzzing.md` (the pattern is operational in four sibling crates: alkcall, alktty, alktunnels, alksocks — each with the same `fuzz/` layout, the detached runner, and the corpus-replay-as-plain-test gate). This document is the working plan for alkhttp; the sibling docs are the reference for rationale (alkcall's doc is the deep version: tool landscape, comparable-crate survey, CI tiers) — this plan stays focused on what alkhttp fuzzes and in what order. **Status:** targets 1–3 implemented and verified (1–2 on 2026-09-29, wave 1; 3 on 2026-09-29, wave 2); target 4 implemented (2026-09-29, wave 3: `ws_session_opseq` + `retry_throttle`; campaign results in §5); the only crash found so far (FWD-21, the `WriteChunkWalk` failure-carry drop) was a crate contract fix, not a harness misassert. Progress log: - **2026-09-29 — targets 1–2 + infra landed (commit 5971c0a).** The `fuzz/` workspace, 80 committed seeds (52 + 28), the detached runner, the corpus-replay gate (AGENTS.md verification checklist gains the line), nightly pinned subtree, explicit root `[workspace]` exclusion. One crate finding on the way (below). `cargo fuzz build` clean; corpus replay 80/80 green; campaigns pending at commit time. - **2026-09-29 — FWD-20, the SSE dribble-drain quadratic (fixed in the same commit).** Found by driving the new `sse_parser` harness's split-feed invariants: `SseParser::feed` re-walked the whole reassembly buffer per feed — a subscription dribble-fed one giant unterminated line (the attacker can hold `data: ` open forever without sending `\n`) paid O(n²) in the dribbled bytes: 64 KiB dribble ≈ 12.5 s CPU, 1 MiB would run to minutes per connection, with no per-stream total-CPU bound (the alkcall research §6.2-3 shape, in this crate). The fix: line assembly walks only the arriving chunk — the reassembly buffer holds just the current line, so the scan never revisits drained bytes; measured 1 MiB dribble now 21 ms (linear, 4× data = ~4× time). Regression test `sse_parser_dribble_feeds_do_not_rescan_the_buffer`. Also fixed by corpus replay pre-campaign: the harness's own re-frame round-trip was wrong for data ending in `\r` (un-re-encodable under WHATWG CR-stripping — the serde_json key-order class; the harness skips that documented-lossy shape). - **2026-09-29 — target 3 (`spec_import`) implemented (wave 2).** Harness + 42 seeds + the three `fuzzing` hubs; no crate findings in the initial corpus replay (11 shared tests green, main crate 457 default / 590 all-features). Smoke campaign clean: 2.34M execs / 10 min, zero crash/OOM/timeout, coverage still growing at budget end (see §5). One harness-assert fix on the way (the quoted-`""` YAML key noted above). - **2026-09-29 — target 4 (`ws_session_opseq` + `retry_throttle`) implemented (wave 3).** Two stateful harnesses + 16 seeds + the `byte_adapter::fuzzing` drive hub (generic pump bodies over a mock socket flavor) + the `client::fuzzing` throttle hub. One crate finding on the way: FWD-21 (above) — fixed same commit. - **2026-09-29 — FWD-21, the `WriteChunkWalk` failure-carry drop.** Found minutes into the first `ws_session_opseq` campaign: one feed that drains a complete chunk and then hits an oversize header (or the pending cap) in the same call returned `Walk::Oversize`/`PendingOverCap` and **discarded the already drained pieces** — the walk's own consumption-accounting contract ("every parsed byte is either emitted or retained", wave-1 invariant 6) was violated by the crate, not the harness. Production could not reach the shape (the mux's per-write invariant caps `pending` at one chunk + header), so impact was nil, but `WriteChunkWalk` is public API and the wave-1 `split_walk` equality check was silently skipped on exactly this path. Fix (user-approved semver-relevant API change): `Walk::Oversize(Vec>)` / `Walk::PendingOverCap(Vec>)` carry the pre-failure pieces; the write pump emits them before `fail_write_pump`, so a 1011 close never swallows a legal chunk the mux already counted as written. Regression seeds cover the drain-then-fail shape in both chunk harnesses. - **2026-09-29 — FWD-22, the round-trip guard-CR (harness-assert fix; wave 4).** Found ~32 min into the first long `sse_parser` campaign: the re-frame round-trip probe's `frame_event` emitted `data: ` unguarded, so a JSON payload carrying an *interior* CR (multi-line data `"\r\n…"` — the payload line is `"\r"`) had its payload CR consumed by `strip_cr` at re-parse time and the round-trip lost it (wave-1 had skipped only the *terminal*-CR case). The parser was never wrong — the shape is representable on input (`data: x\r\r\n` → value `x\r`) and both drives agreed — the re-encode was weak: one CR in the wire form is ambiguous between "payload CR" and "line-terminator CR". Fix: `frame_event` emits `data: \r` for every data line (the guard CR is the terminator, `strip_cr` removes exactly it, the payload CR survives), and the terminal-CR skip is dropped — the round trip is now total over anything JSON-encodable (JSON never encodes a raw CR, so the residual asymmetry stays the serde_json non-preserved key-order class). No crate API change. Regression seeds + unit cases cover the interior-CR JSON shape (`{"k": "a\\rb"}`, two-line `{…}\r\r\n{…}`). --- ## 1. Why alkhttp fuzzes (the if) Same three converging arguments as every sibling crate, with one stronger premise: 1. **This is the outermost shell.** alkcall's peers are authenticated by construction; alkhttp's HTTP surface faces anonymous callers — gateway routes accept bytes on the wire before any identity is resolved, `/healthz` and the stealth decoy are fully public, and the `from_*` adapters parse responses from remote HTTP servers nobody in this repo controls. The quinn-CVE class (RUSTSEC-2026-0037: a panic on attacker wire bytes is a remote DoS) applies here at its most direct. A remote crash in this crate does not need a peer trust step to reach. 2. **Real hand-rolled byte parsing on untrusted streams.** The HTTP framing itself is hyper/axum/reqwest (upstream-fuzzed, not our job), but the crate owns an incremental SSE frame parser, the WS↔byte-stream chunk parser on both directions, a YAML→JSON conversion pass with its own number/key/pointer semantics, and bounded-schema compilation over arbitrary input values. These are the exact shapes (incremental, splitting-sensitive, cap-check-ordered) where example tests miss off-by-one bugs. No `unsafe` exists (grep-verified), so the expected yield is the alkcall profile: panics, arithmetic, allocation, and state-growth bugs — not memory corruption. 3. **The integration seam is untested from this side.** alkhttp sits downstream of the alkcall 0.8.1 fix (duplicate adopt/open destroying the live channel — found by alkcall's `manager_routing` campaign minutes into its first run). alkhttp's WS path hands raw bytes to alkcall's demux; `fuzz/shared` depending on alkcall means corpus replay exercises the alkcall surface from the integration crate, a direction alkcall's own harness does not cover. Also the standing empirical argument: the 0.8.1 bug was a stateful interleaving invisible to example-based tests, and it cost a patch release across every sibling. The infrastructure is already proven and cheap to replicate; the marginal cost here is only the target logic. ## 2. Infrastructure (identical to the siblings) Layout (copy of alksocks/alktty/alktunnels/alkcall): ``` fuzz/ ├── Cargo.toml alkhttp-fuzz (nightly-only bins; own [workspace]) ├── rust-toolchain.toml pins nightly + llvm-tools for this subtree only ├── fuzz_targets/ thin fuzz_target! wrappers (3 lines each) ├── shared/ alkhttp-fuzz-shared — STABLE-toolchain library: │ invariant logic + corpus-replay tests ├── corpus// committed seeds (generated by gen_fuzz_seeds.py) ├── artifacts/ gitignored crash/oom/timeout artifacts + logs ├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern) ├── json.dict JSON token dictionary (spec_import) ├── run-detached.sh detached campaign runner (copied from the siblings) └── README.md operational cheat-sheet ``` Load-bearing details (all four siblings hit these): - **Invariant logic lives in `fuzz/shared/`**, not the target binaries. The stable-toolchain shared crate replays every committed seed through the identical invariant functions as plain `cargo test` — the standing fuzz gate (alkcall research §7.9 tier-3 deliverable, this repo has no hosted CI by policy) — and the `fuzz_target!` binaries are thin wrappers. - **Root `Cargo.toml` needs an explicit `[workspace]` table** (`members = ["."]`, `exclude = ["fuzz"]`); without it auto-discovery pulls `fuzz/shared/` into the main workspace and the stable toolchain builds nightly-consumed dev-deps. alkcall hit this trap. - **`fuzz/rust-toolchain.toml` pins nightly** so `cargo fuzz build` works from any CWD; nightly stays confined to `fuzz/`, MSRV 1.88 untouched. `fuzz/` joins the publish `exclude` list. - **`.gitignore` additions**: `fuzz/artifacts/`, grown-corpus dirs (committed seeds stay). - **Detached runner (non-negotiable operating rule).** Campaigns never run as a foreground child of an agent session (an OOM in a target can take down the session host's process tree). Runner pins `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25` and detaches via `setsid` + `nohup` + log redirect; the agent polls the log and artifact directory, never waits. Copied from alksocks (§`fuzz/run-detached.sh`). **Verification-gate change:** `cargo test --manifest-path fuzz/shared/Cargo.toml` (corpus replay) joins AGENTS.md's verification checklist, as the siblings did. ## 3. Target inventory (all 4) | # | Target | Drives | Input style | Status | |---|---|---|---|---| | 1 | `sse_parser` | `SseParser::feed` + `sse_event_envelope` mapping (`src/adapters/forward.rs`) | chunk-split sequence × eof flag | implemented 2026-09-29 | | 2 | `ws_chunk_framing` | the WS↔byte-adapter chunk parsers (`WriteChunkWalk`, the write pump's extracted chunk walk) (`src/websocket/byte_adapter.rs`) | byte sequences + message-split boundaries | implemented 2026-09-29 | | 3 | `spec_import` | `OpenAPISpec::from_str`/`from_yaml`/`from_json`, `CompiledInputSchema`/`harden_closed_by_default`/`validate`, `FromMCP::build_spec` (`src/adapters/openapi_spec.rs`, `from_jsonschema.rs`, `from_mcp/mod.rs`) | attacker-shaped OpenAPI/JSON/YAML docs + schemas | implemented 2026-09-29 | | 4 | `ws_session_opseq` / `retry_throttle` | stateful op sequences over the WS byte-adapter pumps (write/read pump interleavings, close timing, oversize frames); `RetryAfterMiddleware` throttle-map growth | `#[derive(Arbitrary)]` op enums | implemented 2026-09-29 | ### Target 1 — `sse_parser` (the chunk_header-class target) The crate's most direct untrusted-byte parser: any subscription over `from_openapi`/`from_jsonschema` pipes remote HTTP bytes through `SseParser::feed`. - **Drives:** incremental `feed` with the fuzzer choosing the chunk split boundaries; also the full-input single feed, and the `sse_event_envelope` mapping (FWD-17: JSON payload surfaces as itself, non-JSON as the `{"data","event"}` wrapper, empty → `Null`). - **Invariants:** - no-panic on any byte sequence, any split, any eof timing; - splitting transparency: whole-feed and every-slice-split feed produce identical event streams (the alksocks `datagram_codec` pattern); - the buffer-cap check runs *before* the buffer grows (FWD-14): the reassembly buffer can never exceed `SSE_EVENT_BUFFER_CAP`; overflow surfaces as `BufferOverflow`, never a panic or unbounded growth; - dispatch discipline: an event is dispatched only on a blank line (or EOF for a pending event with data); `data:` lines join with `\n`; last `event:` before the blank line wins; `id:`/`retry:`/ comment lines (`:` prefix) never dispatch or alter state; - shape partition: `BufferOverflow` only when a fed chunk would push the reassembly buffer over the cap; a cap-sized-and-under input can never trigger it — never allocate beyond the cap; - UTF-8 discipline: a multi-byte character split at a chunk boundary is not corrupted (decode happens once a complete line/EOF bounds the window); invalid raw UTF-8 lines are skipped silently (WHATWG tolerance), never panic; - envelope mapping (FWD-17): every `feed`-produced event maps to a success `ResponseEnvelope`; the JSON-ness branch agrees with `serde_json::from_str` on the joined data; the JSON branch never carries the `event` binding. - round-trip-ish probe: re-serializing a parsed event as `data:`/`event:` lines re-parses to an equal event for JSON-encodable payloads (catches field-name and joining drift; structural equality only). - **Exposure:** `SseParser`/`SseEvent`/`sse_event_envelope` are `pub(crate)`. Shared/invariant access via quinn/rustls-style exposure if the pure functions can't be reached — alkcall's campaign never needed `#[cfg(fuzzing)]` after making parse helpers `pub(crate)` + re-export; the first choice here is the same: a minimal `#[cfg(fuzzing)] pub mod fuzzing` re-export in `src/adapters/forward.rs` (nightly-fuzz-only builds see it; stable builds don't). ### Target 2 — `ws_chunk_framing` The WS↔byte adapter parses 8-byte chunk headers on the write (outbound byte→WS-message) side and validates against `MAX_CHUNK_LEN`; the inbound side reassembles WS messages into the byte stream that alkcall's demux parses. The hand-rolled pieces are the chunk-walk loop and the pending accumulator. - **Drives:** the write-pump parse loop driven directly with the same logic `run_write_pump` executes on each `WriteMsg::Bytes` (header scan → `MAX_CHUNK_LEN` gate → drain → 1 MiB message-piece split → pending cap), and the read-pump inbound path's message→header reassembly, driven as a byte-sequence target with the fuzzer controlling both the byte stream and the message split boundaries. - **Invariants:** - no-panic on any byte sequence at any message-split boundary; - a header claiming `> MAX_CHUNK_LEN` fails the stream loudly (WS-04/HY-09) at exactly the point the pump reaches the header — never a silent wait and never an allocate-before-reject; - the pending accumulator never exceeds `PENDING_BUFFER_CAP` = `MAX_CHUNK_LEN + 8` (WS-14): bound asserted after every op; a single write above the cap is rejected in `poll_write` before queueing, so the cap is invariantly satisfied; - message-splitting transparency: a chunk split across N WS-message feeds parses exactly like the whole-buffer feed (receiver's boundary is the header, not the message — WS-06); pieces split at any offset ≤ `WS_MESSAGE_CAP` reassemble losslessly; - consumption accounting: every parsed byte is either emitted as a chunk piece or retained in the pending buffer — nothing lost or duplicated across the walk; - the over-cap write rejection (`poll_write` > `PENDING_BUFFER_CAP` → `InvalidData` before queueing) is exact: no partial state, no silent truncation. - **Exposure:** the pure parse helpers are extracted/testable; the pump functions are `pub(crate)` async — drives via a `#[cfg(fuzzing)]` re-export and a current-thread runtime with a mock sink (the alkcall `envelope_frame` Cursor pattern). ### Target 3 — `spec_import` (implemented 2026-09-29) - **Drives:** raw bytes (and `json.dict`) → `serde_json`/`yaml_serde` → `OpenAPISpec::from_str` (JSON-first, YAML-fallback, ADR-051) and the typed helpers: `yaml_to_json_value` boundary, number/key conversion; plus `CompiledInputSchema::compile` + `harden_closed_by_default` + `validate` over attacker-shaped schemas and input values; plus `FromMCP::build_spec` over arbitrary `Tool`-shaped JSON. - **Invariants:** no-panic; parse failures are always clean `AdapterError::SchemaParse` naming a pointer (never a panic/silent null); the YAML normalization rejects loudly what `serde_json::Number` cannot represent (non-finite floats, non-string keys); `$ref` resolution errors on cycles instead of unbounded recursion; the compiled validator rejects/accepts consistently w.r.t. the schema; closed-by-default hardening pins `additionalProperties: false`; registry-compile shape parity with alkcall's `spec_parse` invariants (bounded compile, always `Result`). - **Implemented shape:** the harness drives one attacker string through all four lanes — the three spec constructors (str/json, yaml under `openapi`), the direct YAML→JSON normalization boundary (merge keys applied; a value the boundary accepted can't contain non-finite numbers — serde_json-invariant, asserted recursively), the compile/harden/validate lane against the whole doc (hardening partition: injected iff absent and the input is an object; validate determinism probe with an unknown-key input; deterministic two-call agreement), the MCP tool-shape lane (the doc and a `{name, inputSchema}` wrapper through `serde_json`→`Tool`→ `build_spec`; success pins the namespace prefix), and the `$ref` lane (`resolve_refs_recursive` over the raw doc + declared component schemas when the doc parses). Exposure via `fuzzing` hubs alongside the wave-1 one: `openapi_spec::fuzzing`, `input_validation::fuzzing` (an opaque `FuzzCompiledValidator` newtype so the `pub(crate)` validator never leaks), `from_mcp::fuzzing` (`mcp` feature). The shared crate gates `openapi`/`mcp` (both on by default, riding the main crate's default build); corpus replay passes both with and without. - **Note:** yaml_serde (ADR-051: YAML 1.2 + merge keys) is the deepest third-party parse we own — the campaign should also encode the already-documented boundaries (`apply_merge` error path, duplicate-key rejection with line/column) as corpus expectations. 42 committed seeds cover the menu below. ### Target 4 — stateful wave (implemented 2026-09-29) - **`ws_session_opseq`:** `#[derive(Arbitrary)]` op sequences over the WS byte adapter driven end-to-end (like alktty's `session_opseq` / alkcall's `manager_routing`): write-side ops (well-formed chunks, oversize headers, the pending shape), read-side ops (binary deliveries, dribble, text, inbound read error, peer close), a lane-healthy requested close, shutdown vs close ordering, backpressure scaled 1..=8 read slots. Asserts: lossless inbound forwarding (forwarded == delivered exactly after the teardown joins, prefix mid-sequence), the WS-01 walk's completed-chunk count vs the declared frame count, the loud-gate close mapping (1002/1011/verbatim-requested + a per-cause reason, WS-15), lossless write framing (outbound concat == the declared chunk stream incl. the FWD-21 failure-carry), lane-failure isolation (the alkcall 0.8.1 class), and pump termination within the harness bound. Exposure: `byte_adapter::fuzzing` (`FuzzMockSocket` flavor, `FuzzChannels`/`FuzzWriteSide` channel bundle, pump spawn funnels, `FuzzInboundChunkProgress`). - **`retry_throttle`:** bounded-map growth of the inlined `RetryAfterMiddleware`: capacity bound (exact, distinct-URL observable partition), expired-first + Farthest-future eviction policy, ceiling clamp (deadline never grows past the ceiling of the record moment), refresh contraction (a higher `Retry-After` never extends the first-seen deadline; the missing-header refresh is a documented no-op), `Retry-After` parse partition (non-negative seconds ∪ future HTTP dates, post-clamp), and the jitter bounds (≤ 25% of a short wait, capped at 2 s). Op sequences of records/refreshes/queries/rebuilds with seconds, huge-seconds, HTTP-date, past-date, garbage, and no-header shapes. Exposure: `client::fuzzing` (constructor, synthetic throttled response, record/deadline/parse/jitter funnels — the middleware map never leaks). - Sequenced after 1–3 stabilize, per the sibling pattern. ## 4. Corpus policy Committed hand-made seeds per target, generated by `fuzz/gen_fuzz_seeds.py` (deterministic, in-tree, quiche pattern); grown corpora and artifacts gitignored. Seed menus: - `sse_parser`: a minimal valid event; every framing edge (`data:` multi-line joins, `event:` override, comment, `id:`, `retry:`, BOM, CRLF/LF, field with no colon, `data` with no space); truncation at every structural prefix; split at a UTF-8 multibyte boundary; cap-boundary event (`SSE_EVENT_BUFFER_CAP - 1`, exactly at cap, one over); an over-cap trailing line; EOF with pending data / pending no-data; empty stream; pure whitespace. - `ws_chunk_framing`: a minimal valid chunk (`[id:u32 BE][len:u32 BE][payload]`); `len = 0`, `len = MAX_CHUNK_LEN`, `len = MAX_CHUNK_LEN + 1`, `len = u32::MAX`; truncation at every prefix length (1–8 bytes); chunk split across a message boundary at every offset; channel-id 0 (the control channel); a header claiming big length whose payload never arrives (the pump-wait shape); the >cap single write (`poll_write` rejection). - `spec_import` (when built): a minimal valid OpenAPI doc (JSON); the same doc as YAML; missing `info`/`paths`; a deep-nesting probe; duplicate-key YAML; `<<: *anchor` merge; `.inf`/`.nan` scalars; non-string map keys; a `$ref` cycle; an unreachable `$ref`; `additionalProperties` absent (hardening probe) and present both ways. - Stateful seeds (target 4): the semantic fixtures the campaign must always replay — a write/echo flow, the oversize loud-gate after a clean chunk, the text-rejection + surviving write lane, an inbound read error, a peer close mid-flight + shutdown drain, a requested close, the throttle-map fill/evict cycle at capacity, the missing-header refresh no-op, hostile-seconds clamping, refresh contraction, and a mid-sequence rebuild (16 total; the `ws_session_opseq`/`retry_throttle` blocks in `fuzz/gen_fuzz_seeds.py`, hand-encoded against the `arbitrary` 1.4.x derive layout with ceiling-division tags and per-element keep-going bytes, pinned by seed-decode tests). ## 5. Campaign + gate policy Identical to the siblings (alkcall §7.9 posture; no hosted CI in this repo): - **Corpus replay is the standing fuzz gate:** `cargo test --manifest-path fuzz/shared/Cargo.toml` — in AGENTS.md's verification checklist. - **Campaigns run detached** via `fuzz/run-detached.sh` (never a foreground child of an agent session); budget 10 min per target for a smoke campaign, 30+ min before a release or after touching the SSE parser, the WS adapter, or the import adapters. - **Grown corpora stay gitignored**: the hash-named files a campaign drops into `fuzz/corpus//` after run-detached runs are ignored via a hash-pattern (the committed `seed-*` files stay); merge worthy entries into seeds only deliberately. Wave-1 smoke campaign results (2026-09-29, 10 min per target, both exited 0, artifact dirs empty — no crash/hang/OOM/leak): - `sse_parser` — 18.9k execs at ~30/s (each exec replays the whole-feed + 3-byte-split + cap-probe + envelope-mapping + round-trip chain, so per-exec work is heavy), coverage 1100 edges / 9510 features / 807 in-memory corpus entries, still discovering when the budget ended — longer campaigns keep paying. - `ws_chunk_framing` — 4.8M execs at ~9k/s, coverage saturated at 245 edges / 1071 features / 136 corpus entries. - **Seeds regenerate deterministically:** `python3 fuzz/gen_fuzz_seeds.py`. - Toolchain notes live in `fuzz/README.md`; nightly stays confined to `fuzz/`. Wave-2 (`spec_import`) smoke campaign (2026-09-29, 10 min, exited 0, artifact dirs empty): - `spec_import` — 2.34M execs at ~4k/s (each exec replays the three spec constructors + the YAML boundary + compile/harden/validate + the MCP tool lane + `$ref` resolution, so per-exec work is heavy), coverage still growing at budget end: 14.1k edges / 36.9k features / 3046 in-memory corpus entries. One early abort in the *first* launch, a harness assert too strong for the documented boundary (an explicitly-quoted `""` YAML mapping key is legal — JSON has the same key; the nullish-key rejection covers the implicit null key, not a quoted empty string), fixed before the counted run; no crate finding. - **Seeds regenerate deterministically:** `python3 fuzz/gen_fuzz_seeds.py`. - Toolchain notes live in `fuzz/README.md`; nightly stays confined to `fuzz/`. Wave-3 stateful smoke campaigns (2026-09-29, 10 min per target, both exited 0, artifact dirs empty — no crash/hang/OOM/leak; the first `ws_session_opseq` launch aborted at 17 s on FWD-21, fixed before the counted run): - `ws_session_opseq` — 206k execs at ~340/s (each exec interleaves up to 64 two-lane ops with yield quiesce points per exec, so per-exec work is heavy), coverage 2251 edges / 5831 features / 232 in-memory corpus entries — coverage still growing at budget end. - `retry_throttle` — 897k execs at ~1.4k/s, coverage saturated at 1154 edges / 6925 features / 324 corpus entries. - **Seeds regenerate deterministically:** `python3 fuzz/gen_fuzz_seeds.py`. - Toolchain notes live in `fuzz/README.md`; nightly stays confined to `fuzz/`. Wave-4 release-budget campaigns (2026-09-29, 45 min per target, launched concurrently, five of five): - `ws_chunk_framing` — exited 0: 13.7M execs at ~5k/s, coverage saturated (297 edges / 1216 features / 139 corpus). - `spec_import` — exited 0: 6.85M execs at ~3.2k/s, coverage growing to budget end (14.9k edges / 43.4k features / 3946 corpus). - `ws_session_opseq` — exited 0: 715k execs at ~253/s, coverage growing to budget end (2251 edges / 6337 features / 260 corpus). - `retry_throttle` — exited 0: 3.3M execs at ~1.3k/s, coverage saturated (1154 edges / 7071 features / 352 corpus). - `sse_parser` — aborted at 1913 s on FWD-22 (above; the interior-CR round-trip loss, a harness fix); before the abort it had 57k execs at ~35/s (1269 edges / 5584 features / 509 corpus), still growing. 45-min rerun post-fix exited 0: 96k execs at ~33/s, coverage still growing at budget end (1378 edges / 6655 features / 734 corpus) — zero findings. - A stale crash artifact from the wave-2 morning session (quoted-`""` YAML key, predating that fix) was still present in `fuzz/artifacts/spec_import/` at campaign start — not produced by wave 4 (the wave-4 `spec_import` log has zero findings). - **Seeds regenerate deterministically:** `python3 fuzz/gen_fuzz_seeds.py`. - Toolchain notes live in `fuzz/README.md`; nightly stays confined to `fuzz/`. 1. ✅ **Done (2026-09-29, commit 5971c0a)** — infra (workspace exclude, toolchain pin, runner, seed generator, README) + targets 1–2 + corpora + corpus replay + AGENTS.md gate + smoke campaigns (clean, see §5). Includes the FWD-20 SSE dribble quadratic fix. 2. ✅ **Done (2026-09-29, commit e6491e3)** — target 3 (`spec_import`): harness + invariants (`fuzz/shared/src/spec_import.rs`), 42 seeds, `json.dict` extended with the OpenAPI-token set, fuzzing hubs in the three driven modules. Smoke campaign clean (see §5). 3. ✅ **Done (2026-09-29, wave 4)** — long (45 min) release-budget campaigns, all five targets, all exited 0 (see §5; the one finding, FWD-22, was a harness-assert fix, fixed + re-run clean in the same wave). Corpus merges remain deliberately deferred (grown corpora stay gitignored; nothing campaign-dropped has been curated into seeds). 4. ✅ **Done (2026-09-29, wave 3)** — target 4 stateful (`ws_session_opseq` + `retry_throttle`): two harnesses + 16 seeds + the `byte_adapter::fuzzing` / `client::fuzzing` hubs + FWD-21 (the `WriteChunkWalk` failure-carry drop — a crate contract fix; user-approved semver-relevant API change). Smoke campaigns clean (see §5). Status updates land in §3's table and §6's list as work completes.