Files
alksocks/docs/architecture/wire.md
T
glm-5.3-flash 986bd13ce7 adr-014: the idle-timeout posture — teardown-governed phases, no protocol-layer idle bound
- ADR-014 accepted (review 002 C-2): the pre-command handshake, the
  BIND quiet wait, and the association lifetime are all
  teardown-governed; the protocol layer never times out (option 1)
- the BIND relationship recorded as same regime (ADR-007 decided one
  phase's ends by designing BIND; ADR-014 records the inherited
  resolution — one principle, not two)
- the doors' accept-path obligations recorded pre-batch-2 (accept-stage
  idle bound, per-credential/per-peer caps, accept-queue bounds);
  revisitable additively (an optional producer-side idle seam would
  be config, not wire)
- spec lines carry the decided posture: wire.md (association-lifetime
  bullet, no-in-band-control bullet, preamble, frontmatter);
  producer.md (the open handler, the local-backend section, decision
  table, frontmatter); overview.md ADR index
- one upstream-fidelity correction: upstream's dial timeout maps to
  0x06 (TTL expired), not 0x01 as the review sketch suggested
- open-questions.md unchanged (C-2 was never tracked there)

Verification: cargo test green (357 passed), clippy -D warnings clean,
fmt clean, cargo doc clean, wasm check + clippy (wasm32) clean
2026-09-24 19:16:30 +00:00

13 KiB


status: draft last_updated: 2026-09-24 (the phase lifetimes' timeout posture decided — teardown-governed, no protocol-layer idle bound, ADR-014; Phase 1 initial draft 2026-09-20 — the POC-validated wire shapes promoted into the normative spec)

alksocks — Wire Format

The alk/socks5 wire surface. Two layers: the open op (JSON params on the channels call plane — trivial here, ADR-001) and the data plane (the RFC 1928 conversation + the data streams inside the channel BiStream). The channels layer strips its own 8-byte header transparently (alknet ADR-093 / alkcall ADR-035); alksocks owns everything inside the BiStream — which is, per the phase model (ADR-003/ADR-007), the RFC conversation itself plus raw pass-through, with exactly one framed stage (ASSOCIATE datagrams).

Design decisions live in the ADRs (001, 003, 007, 011, 013, 014); this document is the normative WHAT. RFC 1928's own wire rules (method negotiation, command encoding, reply encoding, the RFC UDP datagram header) are specified by the RFC itself — this document covers the channels-specific surface and the phase model's stage boundaries.

The Open Op (channels/socks5/sub)

Sub-typed operation (alkcall ADR-047): the reply carries { "channel_id": <u32> } once; everything else — the entire RFC 1928 conversation and the data plane — flows on the channel's BiStream, never in the call response stream. ALPN marker alk/socks5 (ADR-001).

Input (params)

{}
  • Trivial: the SOCKS5 target is unknown at open time (it arrives in-stream post-negotiation); the establisher is a session gate, not a dial (POC #1's structural finding — ADR-004).
  • Schema: {type: object} with additionalProperties: false. Empty params stay valid forever; a future per-session option would be an additive schema extension (ADR-001).

Reply / errors

  • Success: { "channel_id": <u32> } — the establishment phase (the session gate) succeeded, the open handler is running.
  • Failure: a typed channel:open_failed call error with details: { reason, message } — reason ∈ dial_failed / unknown_resource / resource_shortage / handler_error / timeout (alkcall ADR-049 §3). The registry's ACL gate runs before the wrapper (FORBIDDEN for a scope-less identity — POC #1). A failed open never returns a channel_id — no phantom channel. Session-level refusal is always channels-level; target-level refusal happens later, in-stream, as an RFC reply code (ADR-004).
  • The scope gate: required_scopes = ["socks5:open"] (SOCKS5_OPEN_SCOPE, ADR-006). Caller identity resolves in the alkcall CF-005 precedence order (payload auth_token > ServingConfig.identity > transport identity); the establisher and open handler receive the per-call opener identity (CF-006). No extra reply fields in v1 (the session gate has nothing to project; with_reply_field stays available additively).

The Phase Model

RFC 1928 is one command per connection, and control and data never interleave in either direction. The channel's BiStream therefore has a phase structure with no mode byte — the reply itself synchronizes the switch (POC #2 confirmed post-reply garbage is refused structurally; the demux-vs-phase trade is resolved by ADR-003/ADR-007):

Command Phase 1 (control) Phase 2 (data) Framing in phase 2
CONNECT negotiate → command → reply raw pass-through none (0 B)
BIND negotiate → command → reply#1 quiet wait (no bytes allowed) → accept → reply#2, then pass-through none after reply#2
UDP ASSOCIATE negotiate → command → reply (sentinel BND.ADDR) datagram stage [len: u16 BE][addr + data] per datagram
  • Post-reply control bytes are protocol garbage. CONNECT: the stream is a pure byte pipe; any non-data interpretation is a consumer violation. ASSOCIATE: the frame's first byte is ATYP, so any post-reply control byte parses as a garbage ATYP and fails the association loud (udp_undecodable_frame_ends_association, POC #2). BIND: any byte in the quiet wait ends the session with no data plane (POC #5 — the RFC 1928 §3 reading).
  • No in-band control vocabulary. The alktunnels resolution ("no in-band control path, ever — the escape hatch is a new ALPN") holds: no KEEPALIVE, no error frames, no flow-expiry frames. No idle/inactivity vocabulary either — every phase's lifetime is teardown-governed (EOF / garbage / error sentinels), never protocol-layer timed (ADR-014; ADR-007 decided the BIND quiet wait's ends the same way). A deployment enforces idle policy at its edges, not in-band. Errors are EOF-shaped (channels-level) or reply-code-shaped (in-stream, pre-data-plane).
  • One channel per SOCKS5 session — no sub-demux, no flow key (AGENTS.md convention 10; the ASSOCIATE handle swap makes the channel the relay handle, ADR-003).

The Data Plane

CONNECT: raw pass-through

Post-reply, the handler runs alkcall::channels::pump_bidi(channel_ bistream, target_read, target_write) (ADR-050) — the canonical two-pump shape (alknet ADR-078); each pump shuts down the opposite sink on completion (half-close semantics fall out); EOF sentinels are the channels layer's. Zero protocol framing in-band: the RFC conversation is the stream (POC #1: echo round-trips clean; the front door adds no framing).

The dial seam (DialFn, ADR-004) is invoked at the command-read interception point with the parsed target. Per-target refusal: reply_error with the mapped RFC code (POC #1: dial refusal → ConnectionRefused 0x05); no phantom data plane.

UDP ASSOCIATE: the handle swap + framed datagram stage

The ASSOCIATE reply's BND.ADDR/BND.PORT is the sentinel 127.0.0.1:0 on the in-band path — pure RFC-compatibility surface; the channel is the relay handle (ADR-003). The datagram stage rides the same channel:

  • Frame: [len: u16 BE][addr + data] where addr + data is the RFC address encoding (TargetAddr::to_be_bytes() — ATYP + addr + port; the RFC UDP header minus RSV/FRAG). One frame per datagram, both directions. A zero-length payload is a legal empty datagram (never EOF — the F-2 invariant, shaped on alktunnels ADR-003's codec, re-implemented in first-party src/wire.rs); len == 0 — zero inner bytes — is unreachable from a compliant peer, since addr is always ≥ 7 bytes, so the operative invariant lives at payload level. The length covers addr + data, not data alone — the joint bound is the constraint:** a frame is rejected (Oversize, at frame time) whenever len(addr) + len(data) > 65535. The UDP payload bound alone (65507) does not fit u16 unconditionally: a 65507-byte datagram to a 255-character domain is 65507 + 259 (max domain-form address: ATYP + length byte + 255 + port) = 65766 bytes of frame payload — a legal RFC 1928 datagram that would overflow u16. The joint rejection keeps the length field honest: max data is 65276 for domain-form destinations, 65516 for IPv6, 65528 for IPv4. Implementations must not use unchecked u16 arithmetic on the payload bound.
  • RSV/FRAG do not ride the tunnel wire — fragmentation cannot be expressed in-band; FRAG ≠ 0 is refused at front doors (the iroh-socks5 v1 posture).
  • The RFC header codec (new_udp_header/parse_udp_request) exists only at vanilla front-door boundaries (ADR-003; POC #2 test 8: RFC datagram in, {addr, data} frames in-band, RFC reply-header'd datagrams out, zero payload translation).
  • Association lifetime = channel lifetime. Channel EOF (the channels-level sentinel) ends the association; no extra control vocabulary (POC #2 test 4: a fresh associate succeeds immediately after a half-close teardown). No inactivity timer exists at the protocol layer — every phase (the pre-command handshake, the BIND quiet wait, the association) is teardown-governed, never idle-expired (ADR-014); a deployment wanting an idle bound enforces it outside the protocol layer (door accept paths, caller-side wrappers).
  • The stage is select!-driven (channel reads ∥ egress replies), with the egress readiness contract: next_reply is Pending when nothing is ready (an instantly-ready Ok(None) starves a biased select into livelock — POC #2's load-bearing API finding). Framing is applied exactly once, by the wire layer: Egress::next_reply returns inner bytes (addr + data); the frame writer adds the single length prefix (the double-framing bug class POC #2 caught is an API-design invariant).
  • Producer egress is the EgressFactory seam (ADR-004/ADR-008): local-socket (one UdpSocket per association, per-datagram send_to/recv_from, no flow table; replies attributed by the sender's address per the RFC's per-datagram source semantics) or composed (a lazy destination→direct-op-tunnel table, LRU-evicted; open cost ~0.8 ms measured, per-target ACL for free). Per-datagram send is awaited inline (the stage interleaves sends with reply drains); a slow egress applies backpressure to the association, not to other channels — the batch-2 composed-egress author inherits this bound knowingly.

BIND: the phase model extended (ADR-007)

  • Phase 1 ends at reply#1 carrying BND.ADDR (the accept variant's real listener address; the sentinel on the wrapper-aware in-band path).
  • The quiet wait: no bytes are legal; the select! is single-shot with exactly two exits — the one inbound accept (from the BindAcceptFactory seam, whose future owns the listener), or the control stream's EOF/garbage/error (the client-gone teardown riding the accept future's drop).
  • reply#2 carries the peer address (the real peer on both non-sentinel variants — local: accept()'s peer, byte-exact; composed: the forwarded op's peer param since the bind-first graduation, alktunnels docs/research/tunnels-graduation.md; sentinel 0.0.0.0:0 wrapper-aware).
  • Phase 2 is raw pass-through (pump_bidi, the CONNECT shape) between the channel and the accepted halves.
  • Failure mapping is RFC-faithful: factory failure (pre-reply#1) → reply#1 with the mapped code; accept-stage failure (post-reply#1) → reply#2 with the mapped code, no data plane (POC #5 test 6).

Sentinels and lifecycle (all commands)

  • EOF is the channels-level zero-length chunk — per-direction (half-close); written by pump_bidi's shutdown-on-completion or teardown. CONNECT/BIND data planes end on EOF; the ASSOCIATE association ends on EOF.
  • No mid-stream control frames in any phase (the phase model's whole point); pump-phase failures are EOF-shaped (alkcall ADR-049 §6).
  • BAST: the ASSOCIATE datagram frame is the one binary framing beyond pass-through — its BAST document is bast.md.

Normative byte diagrams

CONNECT (post-reply, one direction's view inside the channel BiStream, after the channels layer's 8-byte header per chunk):

<payload bytes> ... <EOF sentinel: channels-level length=0 chunk>

UDP ASSOCIATE (the datagram stage, one direction's view):

[len:u16 BE][ATYP + addr + port + data] [len:u16 BE][...] ...
  zero-length data: an empty datagram (legal payload, NOT EOF —
    the F-2 invariant at payload level)
  len == 0 (no addr bytes): unreachable from a compliant peer
  EOF: the channels-level sentinel, outside the codec

BIND (the phase sequence, one channel's view):

<negotiation> <BIND cmd> |
  <- reply#1 (BND.ADDR) | <quiet: any byte = garbage> | (accept)
  <- reply#2 (peer addr) | <pass-through bytes> ... <EOF sentinel>

Open Questions

Open questions are tracked in open-questions.md. None currently open against this document — the datagram framing (the one-way door) is decided (ADR-003); the phase lifetimes' timeout posture is decided (ADR-014 — teardown-governed, no protocol-layer idle bound); OQ-SK2-07 (the virtual-subnet translation layer) rides the dial callback and does not touch this surface.

References

  • ADR-001 (op/params), ADR-003 (the handle swap + frame), ADR-004 (the seams + phase-model refusal paths), ADR-007 (BIND), ADR-011 (the forked codecs), ADR-014 (the teardown-governed phase lifetimes — no protocol-layer idle timeout)
  • POC findings: poc-connect-wasm-findings.md (CONNECT pass-through, refusal mapping), poc-udp-associate-findings.md (the stage, readiness contract, framing-once invariant, association lifetime), poc-bind-findings.md (the BIND phases, quiet-wait semantics, failure mapping)
  • Upstream: alkcall ADR-035 (channels pure multiplexing), ADR-037 (channel open), ADR-049 (establishment/typed errors), ADR-050 (pump_bidi); alktunnels ADR-003 (the length codec + F-2)
  • RFC 1928 (the protocol's own wire rules), iroh-socks5 eval (docs/research/iroh-socks5-eval.md — the frame prior art)