Review 001 remediation (tasks/tunnels/fix-udp-framed-adapter): - U-1 [major]: UdpHalf now composes the ADR-003 codec at the substrate boundary — poll_write de-frames channel bytes (incremental DatagramReader) and sends each payload as one raw datagram; poll_read frames raw target datagrams and hands frame bytes to the pump. Targets see payload-only bytes; empty datagrams round-trip (the F-2 fix end-to-end); >8 KiB datagrams arrive as ONE datagram. Mechanics the verification gates forced beyond the sketch: poll_write accepts all offered bytes (tokio copy re-offers a full window unsatisfied — partial-frame holds deadlock >8 KiB frames), backpressure is an accepted-byte high-water mark, socket drain pumps on write/flush/shutdown; >caller-buffer frames stage across reads (the truncation probe's assertion surface updated per the re-validation correction). - U-2 [minor]: connect_udp resolves the target and binds the unspecified address of the matching family (v6 targets dial from [::]:0); bind_tcp gains the wildcard-exposure doc warning. - U-3 [minor]: the 65535 recv scratch allocates lazily on first recv (full-size read kept — try_recv truncates silently; the MTU-shrink idea confirmed unsafe in re-validation). - N-1: the module/connect_udp/UdpHalf docs now describe the composed codec truthfully. Gates (permanent tests in tests/local_halves.rs): recording-target suite (payload-only at the target, empty-datagram round-trip, 9000- byte single datagram, unframed-target resolution), the 65507-byte max-datagram staging probe, and the v6 dial gate. Docs: ADR-003 §Amendment (the boundary mechanics), wire.md truncation bullet, consumer.md adopt sketch (substrate param). Review 001 resolution notes on U-1/U-2/U-3/N-1; task marked completed. Verified: 63 default / 76 local tests ×3 repeat runs; clippy -D warnings (all-targets + wasm32), fmt --check, doc --no-deps (warning-free), wasm check, publish dry-run.
8.6 KiB
ADR-003: Data-Plane Codec — Raw Pass-Through (Stream) / Mandatory Length Framing (UDP)
Status
Accepted (2026-09-07; supersedes the Phase 0 "codec direction" hunch
by making the UDP framing mandatory — reverse POC finding F-2).
Amended (2026-09-08, review 001 U-1 remediation): the substrate-
boundary placement is pinned mechanically — the local feature's
UdpHalf composes the codec at the boundary (channel side framed,
target side raw), with the pump-window and recv-scratch disciplines
the composition requires. See §Amendment (2026-09-08).
Context
The tunnel data plane rides a channels data channel: raw bytes inside
the channel BiStream (the channels layer strips its 8-byte header
transparently — alknet ADR-093/ADR-071; AGENTS.md convention 5). The
tunnel protocol owns whatever framing it puts inside the BiStream.
A tunnel has one data stream per direction, so there is no
sub-demux key — a 5-byte header like alktty's (with stream_type)
would be pure overhead here.
The Phase 0 direction (phase-0-findings.md checklist, 2026-09-06):
raw pass-through for stream substrates (0 B tunnel overhead),
[len: u16 BE] per datagram for UDP (2 B/datagram), with the
empty-datagram (len=0) semantic left as a Phase 1 residue.
Two validation passes sharpened this:
- Forward POC (2026-09-06, 17 tests): the codec round-trips
datagrams split across chunks, batched in one chunk, partial
headers, and empty datagrams (
len=0is a legal payload, NOT EOF — EOF is the channels-levellength=0chunk sentinel; the two coexist at different layers). The 1400-byte (max ethernet MTU payload) datagram rides the bounded-buffer path; >65535 is rejected at frame time. - Reverse POC finding F-2 (2026-09-07, executable-pinned): in
the RAW pass-through pump shape, an empty UDP datagram is a
zero-byte read from the substrate adapter —
tokio::io::copytreatsOk(0)as end-of-stream and shuts the pump down. An empty datagram and the zero-length EOF sentinel are the SAME wire shape at the pump level. The test pins the non-round-trip so the conclusion is executable, not anecdotal.
Decision
The data plane inside a alk/tunnel channel is:
- Stream substrates (
tcp,unix): raw pass-through. The halves are the tunnel; zero tunnel-level framing. The only wire overhead is the channels 8-byte chunk header. A zero-length read on a stream substrate is genuinely EOF (stream semantics) — no collision. Both pumps runalkcall::channels::pump_bidi(ADR-050) over the raw halves. - Datagram substrate (
udp): mandatory[len: u16 BE][datagram]framing, applied at the substrate/pump boundary. One datagram per length-prefixed frame, on BOTH directions:- The establisher wraps the dialed UDP socket in the framed adapter BEFORE returning the plan (the plan payload is the adapter, not a raw socket) — the pump handler stays substrate-agnostic (ADR-004's invariant; producer.md's placement).
- Consumer side: the
TunnelSession's datagram variant does the same over the adopted halves. len=0is a legal empty datagram (DNS-over-TCP-style zero-payload probes); it round-trips under the codec (forward POC). EOF is exclusively the channels-level sentinel; the codec never emits zero-length reads, so the layers never collide. This is the load-bearing property F-2 validated from a second angle: the length prefix makes an empty datagram two bytes — unambiguous with EOF.
- MTU discipline: the codec rejects framing a >65535-byte
datagram (
Oversizeerror at frame time, never a wire overflow — the u16 length field would wrap). A truncated receive (caller buffer smaller than the datagram) fails loudly at the adapter level — resolved 2026-09-07 (OQ-TN-13, fail-loud): silent truncation would corrupt the framing invariants (the same invariant class F-2 protects). - Chunking is transparent. Datagrams split across channel chunks, batch into single chunks, and survive partial headers — the incremental decoder is the only consumer-visible decode path (forward POC: maximally awkward 7-byte chunk splits verified).
Consequences
Amendment (2026-09-08 — the boundary mechanics, from review 001 U-1's remediation)
The placement sentence ("the establisher wraps the dialed UDP socket
in the framed adapter before returning the plan") leaves the adapter's
mechanics open; review 001 found UdpHalf shipping as a raw
boundary-preserving adapter (the codec on one end only — real targets
saw the tunnel's length prefixes; empty datagrams collapsed to EOF at
the pump's zero-read check; >8 KiB datagrams split at the copy
buffer). The remediation pins the mechanics:
- Orientation is channel-relative. The adapter's caller is the
pump; the CHANNEL side is the framed side.
poll_write(fed channel bytes) de-frames (an incrementalDatagramReader) and sends each decoded payload as one raw datagram;poll_readframes (frame_datagram) each raw target datagram and hands frame bytes to the pump. "Frame on write, de-frame on read" describes the TARGET's face and is the inverted (wrong) reading — the review's original fix sketch had it inverted and was corrected in re-validation. - The write half accepts all offered bytes and reports them
consumed. The pump's copy engine re-offers a full window
unsatisfied until it drains (tokio
copy'sread_donepath never tops the window up), so a partial-frame tail could sit forever in a full window if the adapter accepted only complete frames — the8 KiB frame would deadlock the pump. Completed datagrams queue for the socket; a trailing partial frame waits in the
DatagramReader(its state is bounded by construction — the u16 cap, review 001 N-2). Backpressure is an accepted-byte high-water mark onpoll_write, and the socket drain pumps opportunistically on write/flush/shutdown (tokio's copy polls flush after every window drain). - The recv scratch is full-size (65535) and lazy.
try_recvtruncates SILENTLY at the buffer size — a smaller scratch corrupts the framing before any size check could reject it (the review's "size to the MTU" suggestion is unsafe as written). The 64 KiB eager allocation is instead lazy (first recv), the U-3 mitigation. - A >caller-buffer frame is staged, not an error. The frame's
length prefix reaches the pump on the first read; the remainder
hands out across reads. The pump's far-side de-framer reassembles
the true length — the raw adapter's read-level
InvalidDatais replaced by staging (the fail-loud posture now protects the frame stream, and a wire-level over-bound datagram remains a loudOversize-class error at the frame path).
The v1 verification gates (review 001 §Verification gates) pin these
end-to-end over real sockets in tests/local_halves.rs (the
recording-target suite).
Consequences (original)
- TCP/unix tunnels have 0 B tunnel overhead — the SSH
direct-tcpipexperience (the payload is the bytes). - UDP correctness requires the codec — raw pass-through over UDP is structurally broken for empty datagrams (F-2). The substrate discriminator (ADR-001) is what tells both sides to engage the codec; a consumer bypassing it for UDP is a spec violation, not a degraded mode.
tokio::io::copy-shaped pumps fit UDP only through the framed adapter (theUdpHalf+ codec wrapper presentsAsyncRead/AsyncWritewhose zero-length read never occurs for a well-formed stream —len=0yields a 2-byte wire frame).- The BAST document exists (AGENTS.md convention 12 — the
mandatory framing IS binary framing beyond pass-through, so the
trigger fired):
docs/architecture/bast.mddescribes the[len: u16 BE]datagram frame per the BAST meta-schema; the hand-rolled codec remains the runtime implementation. - No mid-stream control frames. Pump-phase failures are EOF-shaped by design (alkcall ADR-049 §6); there is no tunnel-level error frame, no KEEPALIVE (channels transport liveness is upstream's), no flow-expiry frame (producer-side bookkeeping).
References
- OQ-TN-02 (promoted), OQ-TN-13 (resolved by this ADR's fail-loud posture)
- Forward POC:
docs/research/poc-summary.md§3 (codec decision + sentinel layering + MTU); reverse POC:docs/research/reverse-poc-summary.md§F-2 (the mandate) docs/research/ssh-socks5-survey.md§UDP (tun2proxy udpgw framing precedent — no CONN_ID, no FLAGS; boundary framing only)- alknet ADR-071/093 (channels wire format; the zero-length sentinel)
- alkcall
channels::pump_bidi(ADR-050)