--- id: implementation/core-wire-codec name: Core — the ASSOCIATE datagram frame codec (src/wire.rs) status: completed depends_on: [implementation/core-crate-deps] scope: narrow risk: low impact: component level: implementation --- ## Description Implement `src/wire.rs` per wire.md §The Data Plane and bast.md: the first-party `[len: u16 BE][addr + data]` frame codec for the UDP ASSOCIATE datagram stage (ADR-003), re-implemented in first-party code shaped on alktunnels' length codec (`/workspace/@alkdev/ alktunnels/src/wire.rs` — `frame_datagram`/`DatagramReader` — same F-2 behavior, but the payload here is the RFC 1928 address encoding plus data, not a bare payload). The codec is the crate's one framed stage (CONNECT/BIND are raw pass-through — no framing). Framing is applied **exactly once, by this layer**: `Egress::next_reply` returns *inner* bytes (`addr + data`); the producer's frame writer adds the single length prefix (the double-framing bug class POC #2 caught is an API-design invariant this module's docs must state). Deliverables: 1. **`frame(addr: &TargetAddr, payload: &[u8]) -> Result`** — one frame per datagram: `[len: u16 BE][addr + data]` where `addr + data` is `TargetAddr::to_be_bytes()` (ATYP + addr + port) followed by the payload. 2. **The joint bound (the load-bearing constraint)**: reject `Oversize` at frame time whenever `len(addr) + len(payload) > 65535`. The UDP payload bound alone (65507) does NOT fit u16 unconditionally — a 65507-byte datagram to a 255-character domain is 65766 frame bytes. Max data: 65276 for domain-form destinations, 65516 for IPv6, 65528 for IPv4. No unchecked u16 arithmetic on the payload bound (wire.md's explicit requirement). Expose `addr_encoding_len(addr)` (7 v4 / 19 v6 / 259 max domain) so callers can compute the joint bound. 3. **`DatagramReader`** — the incremental decoder: feed it whatever `read()` yields; it reassembles frames across chunk boundaries (split frames, batched frames, partial headers). Returns `(TargetAddr, Bytes)` per completed frame — it decodes the addr sub-layout (ATYP determines the address length) and splits `addr + data`. Infallible decode direction (the oversize bound is enforced at frame time); `is_mid_frame()` diagnostic for teardown. 4. **The F-2 invariant, correctly stated**: a **zero-length payload** is a legal empty datagram — never EOF. (`len == 0` is unreachable on this wire since `addr` is always ≥ 7 bytes; the invariant that matters is payload-level. See the doc-sync deliverable.) 5. **Doc-sync (wire.md + bast.md residue)**: both docs say "`len = 0` is a legal empty datagram" while also pinning that `len` covers `addr + data` (joint bound) — `len = 0` is unreachable since `addr` ≥ 7 bytes. Correct both docs to state the operative invariant at payload level ("a zero-length payload is a legal empty datagram, never EOF — the F-2 invariant") in the same change; do not silently diverge. 6. **EOF layering note in the module docs**: EOF is the channels-level sentinel on the `BiStream`, outside this codec; the codec never emits zero-length reads, so the layers never collide (the alktunnels wire.rs module-doc posture). Tests: round-trip (v4/v6/domain), empty payload legal + not EOF, split across awkward chunks, batched frames in one chunk, partial header waits, joint-bound rejection (the 65507 + 255-char-domain case), oversize at frame time, mid-frame state observable, garbage ATYP decode error (the `udp_undecodable_frame_ends_association` shape — the decode error type must distinguish a bad ATYP so the producer stage can fail the association loud). ## Acceptance Criteria - [ ] `frame`/`DatagramReader`/`addr_encoding_len` implemented per the joint bound; no unchecked u16 arithmetic - [ ] Zero-length payload round-trips as a legal empty datagram - [ ] Split/batch/partial-header reassembly tests pass - [ ] Joint-bound rejection test (65507-byte payload + 255-char domain → `Oversize`) passes - [ ] Garbage-ATYP decode surfaces a typed error (not a panic) - [ ] wire.md + bast.md residue corrected (payload-level F-2 statement) in the same change - [ ] `cargo test` green; clippy `--all-targets -- -D warnings` clean; `cargo fmt --check` clean - [ ] `cargo check --target wasm32-unknown-unknown` + `cargo clippy --target wasm32-unknown-unknown -- -D warnings` clean ## References - docs/architecture/wire.md §The Data Plane (the frame + the joint bound arithmetic) - docs/architecture/bast.md (the BAST contract for the frame) - docs/architecture/decisions/003-udp-handle-swap.md (framing-once, F-2) - Template: `/workspace/@alkdev/alktunnels/src/wire.rs` (the length codec + its test matrix — shape on it, do not depend on it) - POC sketch: `/workspace/alksocks-udp-poc/src/datagram.rs` (`frame`/`inner`/`unframe`/`addr_encoding_len`) ## Notes - **API shape** (`src/wire.rs`, re-exported from `lib.rs` per convention 15): `frame(addr, payload) -> Result`, `DatagramReader::feed(&[u8]) -> Vec>` + `is_mid_frame()`, `addr_encoding_len(&TargetAddr) -> usize`, `MAX_INNER_LEN` (65535). The reader returns per-frame `Result`s rather than panicking or skipping: reassembly is infallible (oversize enforced at frame time bounds the buffer by the u16 declared length), so the only failure per completed frame is the addr decode, typed distinctly — `BadAtyp(u8)` (the `udp_undecodable_frame_ends_association` shape, POC #2), `ShortAddress`, `DomainNotUtf8`, `EmptyFrame` (wire `len == 0` — unreachable from a compliant peer; protocol garbage). The reader consumes the frame's bytes either way and keeps going; the caller fails the association loud on the first error. - **The joint bound**: `frame` computes `len(addr) + len(payload)` with `checked_add` (saturating past the bound — no unchecked u16 arithmetic) and rejects `Oversize(total, max)` when it exceeds `MAX_INNER_LEN`. `addr_encoding_len` exposes 7 (v4) / 19 (v6) / `4 + domain.len()` (domain, 259 max) so callers compute the joint bound themselves. Tests pin the load-bearing case (65507-byte payload + 255-char domain → 65766 > 65535 → `Oversize`), the per-form maxima at the exact limit (65276 domain / 65516 v6 / 65528 v4) and one-over rejection. - **Decode direction**: hand-parsed addr sub-layout over the borrowed frame (the sync mirror of the forked `read_address` — the stage's decode must not be async), UTF-8-validated like the fork's codec, payload = the remainder. `TargetAddr::to_be_bytes()`'s output is the encode direction verbatim. - **Doc-sync**: wire.md §UDP ASSOCIATE + the byte diagram and bast.md's BAST field description + the Annotations paragraph now state the F-2 invariant at payload level ("a zero-length payload is a legal empty datagram, never EOF"), with `len == 0` (zero inner bytes) noted unreachable from a compliant peer since `addr` ≥ 7 bytes — the residue corrected in this same change. - A first cut returned bare `(TargetAddr, Bytes)` per frame; the garbage-ATYP deliverable (typed decode error, not a panic) forced the `Result` per frame — the shape the producer stage's loud-fail needs. ## Summary Implemented the ASSOCIATE datagram frame codec in `src/wire.rs` (ADR-003): `frame` builds `[len: u16 BE][addr + data]` over `TargetAddr::to_be_bytes()` + payload, enforcing the joint bound (`len(addr) + len(payload) > 65535` → `Oversize` at frame time; `checked_add`, no unchecked u16 arithmetic) with `addr_encoding_len` exposed for callers; `DatagramReader` reassembles frames across chunk boundaries (split frames, batched frames, partial headers) and decodes each completed frame's addr sub-layout (ATYP-keyed, UTF-8-validated) returning `(TargetAddr, Bytes)`, infallibly reassembling with typed per-frame decode errors (`BadAtyp` / `ShortAddress` / `DomainNotUtf8` / `EmptyFrame`) so the producer stage fails loud on protocol garbage; the zero-length payload is a legal empty datagram (the F-2 invariant at payload level), EOF is documented as the channels-level sentinel outside the codec, and framing-once (the egress speaks inner bytes; this layer owns the single length prefix) is stated as the module's API-design invariant. wire.md and bast.md's `len == 0` statements were corrected to the payload-level F-2 phrasing in the same change. 19 unit tests cover the matrix (round-trips v4/v6/domain, empty payload legal + not EOF, byte-by-byte split reassembly, batching, partial-header waits, joint-bound rejection incl. the 65507 + 255-char-domain case, exact-limit acceptance per address form, oversize at frame time, mid-frame observability, truncated-stream buffering, all four decode-error shapes); lib.rs re-exports `frame`, `DatagramReader`, `addr_encoding_len`, `MAX_INNER_LEN`, `WireError`. Verified: `cargo test` (58 lib + 37 differential), `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo doc --no-deps`, `cargo check --target wasm32-unknown-unknown`, `cargo clippy --target wasm32-unknown-unknown -- -D warnings` — all clean.