Rewrote every prose em dash under docs/architecture/ with natural grammar (sentence splits, colons, semicolons, parentheses, commas); wire-format fences, JSON/BAST examples, and byte diagrams untouched. Verified: grep -rln '—' docs/architecture/ returns only overview.md's fenced dependency/module-map blocks (12 dashes), left verbatim per the fenced-code rule; all other files at zero.
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, per reverse POC finding F-2).
Amended (2026-09-08, review 001 U-1 remediation): the substrate-
boundary placement is pinned mechanically, so 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, per alknet ADR-093/ADR-071 and 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, and
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), so there is 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), so 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, which is 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, so 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, by 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, and 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, matching the SSH
direct-tcpipexperience (the payload is the bytes). - UDP correctness requires the codec, since 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)