# 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 to 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), and 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, the 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, so 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, with every routed datagram consumable and 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, with 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, since 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, and 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, so 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, which (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, since it 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, with 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, since `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, which is 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`, so 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, with the harness surviving, persisting the artifact, and the run wrapping up. That artifact is the promoted seed-900. - Grown corpora (3018 params entries) were **not** merged into the committed seeds, since 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, since 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, since 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, with 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'), so 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)