- Rewrote em dash prose across the whole tree outside docs/reviews/ (the historical reviews stay verbatim, matching the alktunnels and alktty precedent): src doc comments and inline comments (587), docs/architecture (319), docs/research + sdd_process (484), tests (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77), AGENTS.md (85), and the .opencode/agents prompts (109) - Fenced content follows the precedent: comments, ASCII-diagram label separators, and template placeholders rewritten; wire data, JSON/BAST documents, byte diagrams, and transcripts survive verbatim; assert, expect, panic, error-display, and format message literals rewritten as prose; no code, fixture, or test data change - src/rfc1928/LICENSE: the two dashes in alksocks' own provenance header rewritten; the MIT legal text untouched - AGENTS.md adds Project Convention 18: no em dashes in prose; varied restructure, no spaced-hyphen substitutes, fenced prose rule, en dash numeric ranges exempt, docs/reviews/ exempt from the greps - tasks/*.md frontmatter: the name values rewritten by the sweep are now quoted (a raw colon inside an unquoted YAML scalar parsed as null); taskgraph validate reports 37 tasks, no issues Verification: cargo test; cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; fuzz corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/ and the bast.md BAST JSON data fence
4.6 KiB
ADR-003: The Handle Swap: one Channel per Association, Frames In-Band
Status
Accepted (2026-09-20, Phase 1; baseline settled 2026-09-13, POC #2 validated end to end 2026-09-14)
Context
UDP ASSOCIATE is the hard case for the no-bind requirement: RFC 1928
replies with a UDP relay address (BND.ADDR/BND.PORT) and the
client sends datagrams to it, which seems to demand a producer-side
kernel bind, the one thing the protocol layer must never do.
The design's load-bearing reading of RFC 1928 (OQ-SK-03, settled
2026-09-13): the ASSOCIATE reply's BND.ADDR/BND.PORT is just the
handle for the relay, and one relay serves the whole association:
the per-datagram destination is carried in the datagram header
(ATYP/addr/port), not in the handle. The relay port was never "a port
that goes to one place"; it is a multiplexing endpoint. Swapping the
handle (channel instead of UDP socket) preserves every RFC property.
POC #2 validated the whole shape end to end, both egress variants,
including the front door.
Decision
The in-band (channels-native) UDP ASSOCIATE shape is:
- One channel per association: the CONNECT shape; no second channel, no correlation params (the op's params stay trivial, ADR-001).
- Per-datagram destinations ride in-band frames: every datagram
is
[len: u16 BE][addr + data], the boundary-preserving length codec shaped on alktunnels' (frame_datagram/DatagramReader, re-implemented in first-partysrc/wire.rs; behavior pinned by the same F-2 tests) over the RFC address encoding (TargetAddr::to_be_bytes(), the RFC UDP header minus RSV/FRAG). The RFC header codec (new_udp_header/parse_udp_request) exists only at vanilla front-door boundaries. RSV/FRAG do not ride the tunnel wire; fragmentation is refused at front doors (FRAG ≠ 0 → refuse, the iroh-socks5 v1 posture; it cannot even be expressed in-band). - Association lifetime = channel lifetime: channel EOF is the end-of-life signal; no extra control vocabulary (POC #2 confirmed post-reply garbage is refused structurally: the frame's first byte is ATYP, so any post-reply control byte parses as a garbage ATYP and fails the association loud).
- The reply's
BND.ADDR/BND.PORTon the in-band path are pure RFC-compatibility surface (the sentinel127.0.0.1:0, POC #2's shape). Only a front door with a real socket reports a real address. - Producer egress is the
EgressFactoryseam (ADR-004, the dial-callback shape): the protocol layer sees neither egress variant. The two assembly-layer variants (local socket; composed via the alktunnels direct op) are policy, not protocol. - The datagram stage is
select!-driven with the egress readiness contract (next_reply isPendingwhen nothing is ready; an instantly-readyOk(None)starves a biased select into livelock; POC #2's readiness findings are load-bearing API design for the crate). Framing is applied exactly once, by the wire layer:Egress::next_replyreturns inner bytes; the producer's frame writer adds the single length prefix. - The consumer side mirrors the framing-once invariant, and its
reader state persists. The associate session's
send_datagramframes once; itsrecv_datagram's incremental reader state (mid-frame position + batched-datagram pending queue) persists across calls; a datagram split across channel chunks survives a caller-driven receive loop (POC #2: the first cut dropped the reader state per call; the 2-byte-prefix chunk test exposed it). This is part of the datagram stage's consumer-side surface this ADR owns.
Consequences
- The no-bind requirement holds for all three commands: CONNECT and BIND need no socket at all; ASSOCIATE's "bind" is virtualized as the channel (the handle swap). Local binds remain optional assembly-layer shapes (front doors; the local egress variant).
- The datagram framing is wire-stable once published; the one-way door is now closed with empirical ground under it (open cost ~0.8 ms per composed destination, measured; LRU + dedup pinned by test).
- Boundary preservation is mandatory (empty datagram ≠ EOF; alktunnels F-2's lesson transfers directly; the frame shape meets it structurally). The BAST document for the frame is bast.md.
References
- OQ-SK-03 (the full reasoning trail), OQ-SK-09 (fan-out bound; see ADR-010)
- POC #2
poc-udp-associate-findings.md(the validation record + the readiness/framing/ownership findings) - alktunnels ADR-003 (the length codec + F-2), alktunnels ADR-007 (the direct op, the composed egress vehicle)
- iroh-socks5 eval (
docs/research/iroh-socks5-eval.md, the frame design prior art)