# Fuzzing alktunnels: Proposal and Plan **Status:** adopted — wave-1 complete (`datagram_codec` + `params_parse` implemented, seeds committed, two 10-min campaigns clean, two harness-model findings fixed — see §7); wave-2 (`session_pumps`) implemented and campaigning — see §3.3/§7. Campaigns run via the detached runner (§6) — **never as a foreground child of an agent session** (§5). **Date:** 2026-09-28 **Upstream precedent:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (0.8.0 — 0.8.1 campaign; one real finding: duplicate adopt/open destroying the live channel, fixed in 0.8.1). Tooling, corpus policy, runner, and the no-hosted-CI posture are inherited from that doc wholesale — this doc does not re-litigate them; it scopes them to this crate. --- ## 1. Should this crate be fuzzed? **Yes — but scoped smaller than alkcall's.** The decision follows from three lines, all pointing the same way: 1. **alkcall's fuzz doc already assigned this to us.** Its §5 "non-surfaces (deliberately)" note ends: *the channels layer carries opaque `Bytes` and the handler owns sub-stream framing (ADR-035) — those downstream parsers are downstream crates' fuzz targets.* Our `wire.rs` datagram codec IS that named downstream target. Fuzzing it completes alkcall's fuzz scope rather than duplicating it. 2. **We own one hand-rolled parse surface of exactly the covered class.** `DatagramReader::feed` (`src/wire.rs:100`) is an incremental length-prefix state machine reassembling `[len: u16 BE][payload]` frames across arbitrary chunk boundaries — length arithmetic and boundary handling, the dominant rust-fuzz trophy-case classes. It is sync, pure, allocation-light, and infallible **by contract** (the decode direction cannot fail; oversize is rejected at frame time) — a fuzz target is how that contract is *demonstrated* rather than believed. 3. **The stateful DoS class applies to us directly.** One pre-fuzz code-review candidate, the analog of alkcall §6.2-1 (the early-arrival key-space): `DatagramHalves.pending` (`src/consumer.rs:78`) queues every datagram decoded from a chunk beyond the one returned to the caller. A single 16 MiB chunk of `[00 00]` pairs = ~8.4 M empty datagrams → ~8.4 M `VecDeque` entries (≈ 400+ MiB) from ONE peer chunk — a ~25–50× memory amplification riding a bounded channel buffer. Example-based tests structurally cannot find this; a byte-sequence target with a pending-bytes assertion will. **The honest caveat** (mirroring alkcall §1): the highest-value alkcall targets — envelope framing, chunk header, demux, manager — are upstream and now fuzzed (0.8.1). Our params parsing is serde + registry-side jsonschema (upstream's `spec_parse` target covers attacker-shaped schemas compiling at registration). Expected yield here: datagram-codec pathologies, the pending-queue bound (§1.3), serde/untagged-enum edge cases in params, and lifecycle invariants in the two-pump shape. That is worthwhile, not five-targets-worth of new machinery on day one — hence the two-wave scope in §4. If the first campaigns find nothing, that is the successful-negative-result posture alkcall's §1 codified: "we think the codec is robust" becomes a demonstrated property, and the targets become regression protection for a wire-stable format (ADR-003). ## 2. Toolchain (inherited, not re-decided) Identical to alkcall §4: **cargo-fuzz 0.13.2 + `arbitrary` 1.4.x**, nightly confined to the `fuzz/` subtree via `fuzz/rust-toolchain.toml`, the crate itself stays stable at MSRV 1.88. AFL optional/deferred; bolero deferred (same async-friction argument); libafl/honggfuzz/OSS-Fuzz not adopted. Sanitizer: `--sanitizer=address` with debug assertions (cargo-fuzz defaults) — no MSAN (no `unsafe` in this crate). **Layout (the alkcall pattern, load-bearing):** ``` fuzz/ ├── Cargo.toml alktunnels-fuzz (nightly-only bins; own [workspace]) ├── rust-toolchain.toml pins nightly for this subtree only ├── fuzz_targets/ thin fuzz_target! wrappers (3-line each) │ ├── datagram_codec.rs │ ├── params_parse.rs │ └── session_pumps.rs ├── shared/ alktunnels-fuzz-shared — STABLE-toolchain library │ └── src/… invariant logic + corpus replay (§3) ├── corpus// committed hand-made seeds (§3) ├── gen_fuzz_seeds.py deterministic seed generator (quiche pattern) ├── run-detached.sh §6 detached runner (CWD-independent) └── README.md ``` - **Invariant logic lives in `fuzz/shared/`**, not inline in the target binaries — the stable-toolchain corpus replay (`cargo test --manifest-path fuzz/shared/Cargo.toml`) runs the SAME invariant functions the fuzzer runs, on stable, in the release verification checklist. This is CI tier-3's deliverable (alkcall §7.2) minus automation; per alkcall §7.9 this repo has no hosted CI and will not get one (same gitea posture) — corpus replay as plain `cargo test` is the fuzz gate. - **Root `Cargo.toml` needs the explicit `[workspace]` table** (`members = ["."]`, `exclude = ["fuzz"]`) and `"fuzz/"` on the publish exclude list — without it cargo auto-discovers `fuzz/shared/` into the main workspace and the MSRV toolchain tries to build the nightly-consumed dev-deps (the exact failure alkcall hit). - **Corpus policy:** commit hand-made seeds, gitignore grown corpora (quinn/h2 model). Seeds per target: valid frames, truncation at every short prefix length, `len = 0` / `len = MAX` / `len = MAX+1`, empty datagrams, batching (two frames per chunk), splitting (one frame per many chunks), and for params: every substrate value, unknown substrate, unknown/missing fields, `listen: 0`, malformed reply shapes, untagged cross-shape targets, deep/invalid JSON. ## 3. Invariants by target ### 3.1 `datagram_codec` (wave 1) Input: raw `&[u8]` (and in the shared logic, chunk-slice parameter arbitrary). Drives `DatagramReader::feed` + `frame_datagram` (`src/wire.rs`). Invariants: - **no-panic on any byte sequence** (the baseline; the one `unreachable!` in `feed` is a structural invariant of the state machine — the target is how it stays true). - **infallibility**: `feed` never errors (contract — oversize is rejected at frame time; the decode direction is infallible by design, `src/wire.rs` docs). - **datagram-size bound**: every yielded datagram is `<= MAX_DATAGRAM_LEN` (u16 frame field makes this structural — the target pins it against future refactors). - **no partial yields**: a datagram completes only when exactly `declared` payload bytes are buffered (`is_mid_datagram` observable consistency); `feed(&[])` never yields anything and never clears mid-datagram state. - **round-trip**: `feed(frame_datagram(x)) == [x]` for any x; the reverse direction `write(feed-parse(x))` re-frames to the same frame for any well-framed input. - **F-2 sentinel layering**: `len == 0` is a legal empty datagram, never EOF; the codec never emits zero-length datagrams for non-empty frames and never treats an empty datagram as stream end. - **consumption accounting**: re-framing all consumed bytes and feeding the byte-level remainder yields the same datagram stream (splitting a feed at any boundary is transparent). - **oversize frame rejection**: `frame_datagram` errors iff payload > `MAX_DATAGRAM_LEN`, echoing the sizes. - **pending-queue bound (the §1.3 finding-class assertion)**: total bytes retained across `buf` + `current` + `pending` after a feed is bounded by the fed chunk size plus the one in-flight frame — an adversarial peer chunk cannot force unbounded queue growth in the decoder state. (NOTE: this is the *decoder-state* bound; the consumer-side `DatagramHalves.pending` backlog between chunk-read and `recv_datagram` pulls is a session-shape question tracked in §4/§8 — the wave-2 stateful target, or a code fix if the bound is judged unacceptable. The fuzzer documents the amplification factor; the remediation decision is ours. **Wave-2 resolution (§3.3):** the session-shape backlog is now an asserted invariant — the harness's per-op drain holds the pending queue to the routed-batch remainder (≤ one chunk's worth of batched datagrams, drained by `recv_datagram` pulls), so the amplification question is bounded by construction + assertion rather than remediated; the `datagram_backlog()` diagnostic exposes the live count.) ### 3.2 `params_parse` (wave 1) Input: raw bytes → `serde_json` (with alkcall's bounded `arbitrary_value` approach — depth/width-capped `Value` generation) → `TunnelParams` / `TunnelDirectParams` / `TunnelForwardedParams` / `SubstrateAddr` deserialization + `parse_listen_reply` / `listen_reply_fields` (`src/params.rs`). Invariants: - **no-panic on any JSON-shaped input** (untagged-enum serde dispatch, `deny_unknown_fields`, the `positive_u32` pin). - **clean failure only**: parse failures are `Err`, never panics; `parse_listen_reply` returns `TunnelError::MalformedReply` on malformed shapes. - **round-trip for valid params**: `from_json(to_json(p)) == p` for constructed params of every substrate. - **deny-unknown posture**: unknown top-level fields reject; untagged-variant leniency (extra fields inside a `SubstrateAddr` variant) is the documented asymmetry — asserted, not "fixed". - **the `listen: 0` pin**: zero rejects at serde level. - **projection agreement**: `listen_reply_fields` yields both-`Some` iff `parse_listen_reply` is `Ok`; it never panics and never refuses construction (the lenient view projects malformed to both-`None`). - **establishment-reason surface**: `establishment_reason` never panics on arbitrary `ChannelOpenError` shapes. ### 3.3 `session_pumps` (wave 2) Input: op sequences (`#[derive(Arbitrary)]` `SessionSequence` — the `manager_routing` pattern) over one live channels stack per exec: MuxRunner → raw chunk stream → the real `run_demux_loop_for_client` demux → `ChannelManager` per side (producer Connect / consumer Accept over one duplex transport — the reverse POC's topology). Ops: `Route` (producer→consumer framed datagrams / raw stream bytes via `route_payload`), `AdoptDatagram` / `AdoptStream` (the `TunnelSession` data-plane mirrors — codec reader + `pending`, and `pump_bidi` against in-process peer halves), `Pump` (peer→channel; `len == 0` drops the p2c source — the EOF path), `Teardown`, `ClearAll`. Invariants: - **no-panic / no-wedge on any op sequence** (the baseline; the harness's 5 s reconcile bound turns a wedged pump into a finding, and §5's `-timeout=25` fork containment is the outer net). - **two-pump shutdown-on-completion (convention 8)**: after the p2c source drops, the pump's channel-side shutdown must propagate and the pump must complete once the teardown EOFs the c2p source — pinned by the `reconcile` join with the 5 s bound. - **lossless p2c copy**: the pump's `p2c` count equals exactly what the harness pushed into the peer half (copy never truncates). - **EOF-sentinel teardown semantics**: after a sentinel (teardown / `clear_all`), the reassembled stream drains its queue before reporting EOF — every routed datagram consumable, nothing lost; the consumer map empties; the codec's `len == 0` sentinel layering holds at the session layer (a routed empty datagram is data; the channels EOF is a separate mechanism). - **FIFO reconciliation**: every complete frame routed through the real demux path arrives as exactly one datagram of the declared length, in order (the model walks the framing as the codec does — the wave-1 F-2 walk, reused at the session layer). - **pending-queue bound (the §1.3 finding-class assertion)**: the session-shape `DatagramHalves.pending` backlog never exceeds the unrouted remainder — i.e. the backlog holds only datagrams decoded from routed chunks beyond the ones consumed; with the harness's per-op drain the queue returns to zero after every op, so an amplification factor > 1× chunk size would trip the assert. (The wave-1 decoder-state bound re-asserted per op alongside.) - **monotonicity**: the manager's early-arrival / dropped counters survive `clear_all` (the alkcall §7.7 pattern), and the consumer map empties on the transport-EOF path. Bounds: 128 ops, ≤ 512-byte payloads (generator-clamped; the codec's `MAX_DATAGRAM_LEN` stays the wire bound). Even-id masking (ADR-047 §5 — the consumer is the Accept side; the producer allocates even). ## 4. Scope: two waves **Wave 1 (complete):** the two targets above. Both drive already- `pub` APIs — no `#[cfg(fuzzing)]` exposure needed (matching alkcall's experience where none of five targets needed it). **Wave 2 (this session):** `session_pumps`, the stateful op-sequence target over the two-pump tunnel shape in the alkcall `manager_routing` style (`#[derive(Arbitrary)]` op enum; live mux + demux runners; the alkcall harness lessons applied: even-id masking, drained-state asserts). Drives: the `TunnelSession` data-plane shapes (the datagram codec plane + the `pump_bidi` stream plane) over a REAL channels stack — the two-pump shutdown-on-completion contract (AGENTS.md convention 8), the EOF-sentinel teardown semantics, and the §1.3 backlog bound as byte-accounting invariants (§3.3). The `forwarded_routing` correlation target stays deferred until the assembly-side correlation registry (ADR-008 §3, assembly bookkeeping) grows into this crate. Sequencing mirrors alkcall §7.4: init + wave-1 targets → 10–30 min campaigns per target via the detached runner → fix anything found → wave 2 (`session_pumps`) → further targets when justified. ## 5. Operational isolation: campaigns never share fate with the agent session **This is an environment constraint, not a nice-to-have** (upstream doc §7.6, adopted verbatim as policy here). Agent sessions (opencode) run fuzzer builds/campaigns through a bash tool that spawns the fuzzer as a **child of the session's own process tree and cgroup**. A fuzz run is precisely the workload shape that kills its own host: - **OOM-killer shared fate**: the target's worst case IS unbounded allocation. If libFuzzer's soft RSS guard loses the race with the kernel, the OOM killer fires — and it picks the largest-RSS process in the cgroup, which can be the opencode server hosting the session. That is *recoverable* (sessions are recorded in a DB) but hugely derailing — the session's live environment access dies mid-task. An OOM in a fuzz target must cost the fuzzer, never the session. - **Fork bombs / CPU saturation**: `-fork=N` workers; a runaway campaign starves the session host. - **Wedged targets**: a deadlock in an async harness would hang a foreground bash call forever. Three layers of defense, cheapest first — **all mandatory**: 1. **libFuzzer soft limits (always on)**: `-rss_limit_mb=2048` `-malloc_limit_mb=2048` make libFuzzer exit cleanly (artifact recorded) before the kernel gets involved. Note the RSS limit is *soft* (polls `/proc/self/statm`); a fast huge allocation can beat it — that is what layers 2–3 are for. Never `ulimit -v` with ASAN (it reserves terabytes of virtual address space); the ASAN-safe hard-limit recipe is `-fork=1`. 2. **Fork mode as blast-radius containment (default)**: `-fork=1` (or a small number) runs each input in a short-lived child — a crash/timeout/OOM/leak kills only that child; the harness survives, records the artifact, keeps fuzzing. Also the deadlock answer: the per-input timeout kills the child, not the session's bash call. 3. **Process detachment from the session tree (MANDATORY for agent-run campaigns)**: never run fuzzing as a foreground child of the session. The runner script wraps `cargo fuzz run` in `setsid` + `nohup` + full redirection to a log file: (a) removes the fuzzer from the session's controlling terminal and signal relation, (b) survives the session ending, (c) gives the agent a pollable log + artifact directory instead of a blocking call. Combined with fork mode, an OOM-class finding costs one child process. **The runner** (`fuzz/run-detached.sh`, CWD-independent — resolves its own repo root): ```bash #!/usr/bin/env bash # Detached fuzzing runner for agent sessions: the campaign never runs # as a foreground child of the session (an OOM in a target must cost # the fuzzer, never the agent host), and survives the session ending. set -euo pipefail target="${1:?usage: run-detached.sh [extra libfuzzer args...]}" shift runtime="${FUZZ_RUNTIME_SECS:-600}" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" log_dir="$root/fuzz/artifacts" mkdir -p "$log_dir" log="$log_dir/${target}-$(date -u +%Y%m%d-%H%M%S).log" cd "$root" setsid nohup cargo fuzz run "$target" -- \ -fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 \ -max_total_time="$runtime" "$@" \ >"$log" 2>&1 < /dev/null & echo "pid=$! log=$log" ``` **Agent polling protocol** (never `wait` on the detached process from a tool call — poll instead): - `tail -n 50 fuzz/artifacts/-*.log` — campaign progress. - `ls fuzz/artifacts/` — `crash-*` / `oom-*` / `timeout-*` / `leak-*` artifacts are the findings. - `pgrep -f "cargo fuzz run "` — still running? - One detached campaign per target at a time; log filenames carry the UTC timestamp. **Flags worth pinning beyond the runner defaults**: `-max_len=65536` on the codec target; `-use_value_profile=1`; a JSON token `-dict` for the params target (alkcall's `json.dict` transfers as-is). ## 6. No hosted CI Per alkcall §7.9 — the standing policy applies to this repo identically: no hosted CI will exist; do not add Actions/Gitea-Actions workflow files. **Corpus replay is the fuzz gate as plain `cargo test`**: `cargo test --manifest-path fuzz/shared/Cargo.toml` replays every committed seed through the invariant functions — add it to the release verification checklist (AGENTS.md). Campaigns run manually via the detached runner (§6) — e.g. before a release, or after touching `src/wire.rs`, the consumer data planes, or `src/params.rs`. ## 7. Implementation log - **2026-09-28 — proposal adopted** (this doc). Wave-1 targets `datagram_codec` + `params_parse`; wave-2 deferred (§4). - **2026-09-28 — `alkcall` bumped 0.8.0 → 0.8.1.** Bug-fix release, no API/wire change; the fix (duplicate adopt/open no longer destroying the live channel) is directly relevant to this crate's `TunnelSession::adopt` path — the changelog's "open-op response replayed / id re-announced" scenario is our reverse path. Verification: full suite (135 passed), clippy, fmt, wasm check (§8 commands). - **2026-09-28 — wave-1 implemented.** `fuzz/` workspace laid out per §2 (nightly-pinned subtree, stable `fuzz/shared` library, runner, dict, generator); `datagram_codec` + `params_parse` targets over already-`pub` APIs — no `#[cfg(fuzzing)]` exposure needed. One new public diagnostic: `DatagramReader::retained_bytes()` (the decoder-state byte count the §3.1 bound asserts against, in the `is_mid_datagram` diagnostic style). Seeds committed: 27 + 69 generated (fuzz/gen_fuzz_seeds.py) + 1 campaign-found crash input promoted to `datagram_codec/seed-900` (see the finding below). Stable side: corpus replay green, shared crate clippy/fmt clean; main suite untouched and green; `cargo fuzz build` clean. - **2026-09-28 — first campaign finding: a HARNESS-model bug, not a crate bug.** The `datagram_codec` campaign crashed in its first minute on input `00 2b 01 00 00 05` (`fuzz/artifacts/datagram_codec/ crash-e9ba284e…`, exit 77, fork-mode containment worked as designed — the harness parent survived and persisted the artifact). Analysis: the codec is CORRECT — `00 2b` declares a 43-byte datagram; `01 00 00 05` is its in-flight payload; nothing is yielded. The invariant that tripped ("an empty datagram must come only from a [00 00] length header") was mis-modeled: it counted `00 00` byte pairs anywhere in the RAW stream, but a `00 00` pair sitting INSIDE a frame's payload region is payload, not a length header — exactly the same class as alkcall §7.7's probe (the harness claimed `4 + len` when the prefix counts only the body). Fix: the count now walks the framing as the decoder does (complete frames from the start; the walk stops where the decoder would be stuck mid-frame). The crash input is committed as seed-900 and replays clean; the campaign relaunched clean. - **2026-09-28 — second harness finding (caught by corpus replay, pre-campaign): `establishment_reason` is a pass-through extractor, not a validated table.** The params.rs doc names the pinned reason strings, but the upstream implementation (alkcall `channels/client.rs` `establishment_reason`) extracts `details.reason` AS-IS whenever the code is `channel:open_failed` — a made-up reason is surfaced as-is by design, and validation is the consumer's job. The harness first asserted a closed set of reason strings and corpus replay tripped it on `made_up_reason` before any campaign ran. Invariant corrected to assert the extraction shape (Some iff code == `channel:open_failed` and reason is a string). - **2026-09-28 — second harness finding (same replay pass): `is_mid_datagram` means a partial DATAGRAM, not buffered header bytes.** A dangling 1-byte length prefix at input end is buffered header state (no frame in flight, `is_mid_datagram == false`); the harness first asserted `true` for that case. Corrected: mid-datagram iff the framing walk leaves a remainder ≥ 2 bytes (a parsed-but-unfinished payload), not `!= 0`. - **2026-09-28 — wave-1 campaigns run (10-min detached runs, §6 runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25`; codec with `-max_len=65536`, params with `-dict=json.dict -use_value_profile=1`). Both exited 0.** - `params_parse` — 3.14M execs (~5.3k exec/s), 33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage 2049 edges / 15.6k features; in-memory corpus 69 committed seeds → 3018 entries — the serde/untagged-enum shape space was still expanding at budget end, so longer campaigns keep paying here. - `datagram_codec` (post-harness-fix) — 827k execs, 33 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage saturated at 221 edges (ft 1198) — the 2-byte-header state machine is fully enumerated, matching alkcall §7.7's `chunk_header` saturation profile. Exec/s reads low (~400) because each exec replays the input through a ~35-feed 3-byte-sliced sequence alongside the whole-input feed; the state space is small and exhausted early. - The pre-fix `datagram_codec` run (the §7 finding above) crashed at job 4 and exited 77; fork-mode containment behaved as designed — the harness survived, persisted the artifact, and the run wrapped up. That artifact is the promoted seed-900. - Grown corpora (3018 params entries) were **not** merged into the committed seeds — per the §2 policy, grown corpora stay gitignored. - Operational note for future sessions: `cargo fuzz run` returns before its last fork-mode children fully exit; a `pkill -f "cargo fuzz run "` followed immediately by another `pgrep` of the same pattern can wedge a bash tool call on its own process match — prefer `pgrep -af` to LIST, kill by explicit pid from the runner's echoed `pid=`, and never pattern-kill-and-recheck in one call. - **NO crate bugs found by wave 1.** The two findings this session were both harness-model defects (§7 above), each invisible to example-based review and caught by fuzzing/replay — the alkcall §7.7 "fuzzer as a harness-checker" pattern, confirmed. The §1.3 decoder-state bound held everywhere: `retained_bytes()` never exceeded fed + one frame on any adversarial input. The session-shape `DatagramHalves.pending` amplification remains a documented §1.3/§4 note (wave-2's motivation), not a campaign finding — the wave-1 targets assert the decoder, not the session pump. - **2026-09-28 — wave-2 implemented: `session_pumps` (the stateful two-pump/EOF-sentinel target, §3.3).** The harness mirrors `TunnelSession`'s data-plane shapes over one REAL channels stack per exec (MuxRunner → raw chunk stream → the real `run_demux_loop_for_client` demux → `ChannelManager` per side over one duplex transport; producer Connect / consumer Accept — the reverse POC's topology). Ops: Route/AdoptDatagram/AdoptStream/ Pump/Teardown/ClearAll; invariants per §3.3. One diagnostic addition to the crate: `TunnelSession::datagram_backlog()` (the `DatagramHalves.pending` count the §3.3 bound asserts against, in the `retained_bytes()` diagnostic style) + a suite test pinning its rest states. Seeds: 8 committed (deterministic decode-scan hex patterns, the `manager_routing` precedent — arbitrary ships no encoder) + 2 in-source semantic fixtures (the §1.3 batched- backlog probe; the W4 half-close shape). Two harness-model findings caught by corpus replay pre-campaign: - **the p2c-EOF pump-join wedge is the DESIGNED half-close shape, not a wedge.** The first `reconcile` draft awaited the pump after dropping only the p2c source — but the c2p pump's source (the channel read half) EOFs only via teardown, so the join legitimately blocked (the 5 s bound tripped). `pump_bidi` completes when BOTH pumps finish; the half-close contract is that the p2c completion shuts the channel side down (the observable), and the pump completes once the c2p source EOFs too. The harness now models exactly that: drop p2c source → teardown the entry → join with the 5 s bound. (The alkcall §7.7 harness-checker pattern again — the fuzzer harness's first campaign-found crash in wave 1 and this replay catch are both harness-model defects, not crate bugs.) - **the stream-plane c2p accounting is asynchronous** — the pump writes channel→peer into the peer duplex while a far-end reader task counts; the reconcile join (after the pump completes) is when the count is final. First draft read it early. Fixed by reading after the reader task's join. - **2026-09-28 — third harness finding (campaign-found, fork-mode containment as designed): duplicate adopt is a LEGAL manager outcome, not a finding.** The first campaign crashed at ~49 s on `AdoptDatagram { channel_id: 0 }` after the id had already been adopted — `ChannelExists` is the manager's documented id-collision error, and my adopt arms treated every error as a finding. Fix: both adopt arms model the documented error table (`ChannelExists` / `TooManyChannels` are no-ops, exactly the alkcall `manager_routing` harness's treatment). The crash input is committed as seed-900 and replays clean; the campaign relaunched clean. - **2026-09-28 — fourth harness finding (second campaign): the harness modeled the producer→consumer path through the WRONG primitive.** The second campaign crashed at ~5 s on `Route`: raw-id addressing (`even_id` masked arbitrary ids; collisions + an `UnknownChannel`-class state the model didn't track), `len == 0` raw routes doubling as EOF sentinels mid-model, and — the structural one — `route_payload` was used as the producer's forward write. `route_payload` routes into the LOCAL manager's channel entry (the demux direction); the producer pump handler writes the channel's SEND half (the mux pump frames onto the transport). Bytes never reached the consumer. Rewrite (the real protocol shape, matching `manager_routing`'s op-vocabulary lesson): producer `Open` (`open_channel` allocates the id, the harness holds the send half — the pump handler's write side), session-INDEX addressing (no masking, no collisions), `Adopt` consumes the producer-allocated id (the early-arrival drain is the real adopt-race path), `Route` writes the producer send half (the real wire path: mux pump → transport → consumer demux → reassembly), `Pull` (the `recv_datagram` pull pops the backlog + the model), and a STATEFUL framing model (partial frames persist across ops — the wave-1 walk carried across ops). EOF sentinels now arise only from teardown/sender-drop (the real mechanisms), never from `len == 0` routes (F-2: a routed empty datagram is DATA). All committed session_pumps seeds regenerated by the rewrite (the old hex patterns decode against the new op enum); the semantic fixtures re-pinned. Verification of the fix: the campaign's 0-exec crash went away; batched frames now reconcile FIFO through the real two-manager stack. - **2026-09-28 — fifth + sixth harness findings (third campaign's first minutes): the two ClearAll modeling defects.** (5) A double-adopt of a live session panicked the harness's own guard — but a re-adopt of a LIVE id is the manager's documented `ChannelExists` outcome (a legal no-op; alkcall 0.8.1's insert-replace fix leaves the live entry intact). Modeled as a no-op. (6) A `ClearAll` followed by `Route` on the same session tripped the backlog reconciliation — the harness retired only the sessions whose ids appeared in `clear_all`'s RETURN value, but that value is the OPENER-LEDGER records (ADR-047 §7): adopted channels carry no ledger entry, so the set is empty while the map itself empties. The alkcall §7.7 lesson ("`drained.len() == open_count()` is not an invariant"), learned again at this crate's layer — the map-empties assert is the ground truth; the harness now retires EVERY session on `ClearAll`. - **2026-09-28 — wave-2 campaign run (10-min detached run, §6 runner; `-fork=1 -rss_limit_mb=2048 -malloc_limit_mb=2048 -timeout=25 -max_len=4096 -use_value_profile=1`): exited 0.** 228,676 execs (~380 exec/s; each exec replays up to 128 ops on a live two-manager stack with real mux/demux tasks), 32 fork jobs, `oom/timeout/crash: 0/0/0` throughout. Coverage 3427 edges / 17,529 features / 1184 in-memory corpus entries and STILL EXPANDING at budget end (the op-interleaving space is much larger than the parser targets') — longer campaigns keep paying. The two-pump contract held across every adversarial sequence: no wedged pump (the 5 s reconcile join), no lost datagram (the FIFO/backlog reconciliation), no premature EOF (the sentinel layering). The §1.3 backlog bound held everywhere: the `DatagramHalves.pending` queue never exceeded the routed-but-unpulled remainder. - **NO crate bugs found by wave 2.** All six findings across waves 1–2 were harness-model defects (four this session), each invisible to example-based review and caught by fuzzing/replay — the alkcall §7.7 "fuzzer as a harness-checker" pattern, re-confirmed. The stateful thesis is validated from the harness side: the harness defects themselves (double-adopt semantics, the clear_all ledger/map distinction, the route_payload direction) are exactly the interleaving-shape mistakes the alkcall §7.8 campaigns caught in their own drivers. - (progress appended as implementation proceeds — see git log) ## 8. Verification commands ```bash cargo test # main suite (stable) cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay (fuzz gate) cargo fuzz build # in fuzz/ (nightly, ASAN) fuzz/run-detached.sh # campaigns (never foreground) cargo clippy --all-targets -- -D warnings cargo fmt --check cargo clippy --manifest-path fuzz/shared/Cargo.toml --all-targets -- -D warnings cargo fmt --manifest-path fuzz/shared/Cargo.toml --check cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean ``` Campaign flags per target: `datagram_codec -max_len=65536`; `params_parse -dict=json.dict -use_value_profile=1`; `session_pumps -max_len=4096 -use_value_profile=1`. ## 9. References - Upstream campaign + operational model (authoritative for tooling, runner, corpus policy, no-CI posture): `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` - alkcall 0.8.1 changelog — the finding our adopt path is exposed to - This crate: ADR-003 (codec framing), ADR-002 (ALPN), ADR-007/008 (direct/forwarded ops), `docs/architecture/wire.md` - External: RUSTSEC-2026-0037 (quinn-proto — the maintainers' fuzzing-coverage statement), rust-fuzz trophy case, cargo-fuzz Rust Fuzz Book (all linked from the upstream doc's §9)