- docs/architecture/: README index, overview, wire, producer, consumer, vendor, bast specs + open-questions.md (the OQ-SK-01..09 ledger promoted; new questions as OQ-SK2-06..) - ADRs 001-012: ALPN/op/params, protocol base, the handle swap, the dial-callback shape, auth split by the door, access control (open gate + BIND command gate), BIND in base, provider defaults, feature gates, channel capacity, the inline vendor, target policy - deferred OQs (SK2-06, SK2-07) with tracker tasks in tasks/architecture/ per the sdd_process deferred-OQ protocol - review pass applied: frame-length joint bound (65766 corner), accept_no_auth vs skip_auth corrected against fast-socks5 source, composed-feature wasm posture, cross-reference fixes Verified: cross-references (all ADR/OQ refs resolve), doc sizes under split threshold, Phase 0 fidelity spot-checks
4.7 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)