Files
alksocks/docs/research/phase-0.md
T
glm-5.3-flash 3044abe296 docs: consolidate phase-0 — current-state form, one statement per decision
The Phase 0 doc had accumulated stratified updates (each revision layered
on top of the prior text), leaving duplicated statements, superseded
reasoning presented as live, and a few internal conflicts. This rewrite
is a consolidation only — no decisions changed:

- Each OQ opens with a status line; resolved content stated as settled
  fact; superseded reasoning retained only where it carries Phase 1 ADR
  weight (e.g. OQ-SK-04's decision tree as the reasoning trail).
- The inline-vendor decision now stated once (OQ-SK-04 + the Convergence
  Layering bullet) instead of ~10 restatements; stale 'file the upstream
  ask' residue removed (Convergence protocol-base bullet).
- The component-3 transfer to alknet stated once in §Vision; the noq
  section is background-only; OQ-SK-06 compressed to its resolution.
- OQ-SK-01/03's duplicated direct-op update merged into its OQ;
  OQ-SK-08's superseded sequencing consistently framed as the retired
  POC workaround against alktunnels 0.1.0.
- Checklist: OQ-SK-03/06/07 and the POC box checked to match their own
  text; 'RFC-refused in both POCs' corrected (fast-socks5 refuses BIND;
  POC #5 implemented it).
- Preserved: all external anchors cross-referenced by the findings files
  (§OQ-SK-07 register + numbering, §Prior art's run_udp_proxy_custom
  caveat, OQ-SK-04's decision record, the Convergence Layering bullet),
  every ADR/API/file:line reference, POC numbers, and measurement
  figures. Restored on audit: the 65507 u16-suffices rationale,
  skip_auth_this_is_not_rfc_compliant, lib.rs:91, the wasm-resolver
  interaction note.

Verified: anchor audit against all cross-referencing docs (no dangling
references), content-loss audit vs the prior version (no unsuperseded
fact dropped), fast-socks5 v1.0.0 spot-checks (TCPBind refusal sites,
wait_on_tcp:1195, private new_reply, typestate methods — all true).
2026-09-20 11:45:32 +00:00

66 KiB


status: draft last_updated: 2026-09-20 (consolidation rewrite: current-state form, one canonical statement per decision, reasoning trails trimmed to their Phase 1 weight; no decisions changed)

alksocks — Phase 0 (Exploration)

This document captures Phase 0 (Exploration) for the alksocks crate: vision, guiding principles, prior art, open questions (OQ-SK-01..NN), and the converged recommendation. Phase 0's objective per docs/sdd_process.md: capture vision and guiding principles; research options; validate approaches; converge on a recommended approach. It is the input to Phase 1 (Architecture), where the Architect will produce docs/architecture/ specs, ADRs, and the open-questions tracker.

Drafted 2026-09-12, emerging from the initial setup discussion. The crate is the sibling of alktty (alk/tty) and alktunnels (alk/tunnel) on the alkcall substrate; where alktunnels realizes the -L/-R forwarding flavors, alksocks realizes the -D flavor (dynamic / SOCKS5 proxy) — the one alktunnels explicitly deferred to composition.

Phase 0 converged 2026-09-14 and is POC-complete: every numbered POC on the register either passed or was dropped with the scope decision (§OQ-SK-07). The remaining open questions are Phase 1 ADR decisions, each with its inputs collected (§What Phase 0 could NOT answer).

Vision and guiding principles

One sentence: a SOCKS5 (RFC 1928) producer/consumer protocol crate on alkcall channels — an arbitrary-egress proxy service that never binds a port unless explicitly configured to, carrying the RFC conversation inside a channels data channel like any other produced resource. The RFC 1928 implementation is the vendored fast-socks5 subset (the inline vendor, §Layering) — the crate owns the protocol code in-tree.

Scope (resolved 2026-09-14, post-POC): socks + channels, nothing else. The original three-component split serves different purposes and was at risk of being conflated; two of the three are this crate:

  1. Producer half — a side exposes the SOCKS5 service as a produced, ACL-scoped resource (registers openable channels; the open handler runs the RFC state machine; target dials happen there or further downstream). This is the -D service over channels.

  2. Consumer half — a side opens SOCKS5 channels against a produced service and speaks RFC 1928 inside them. Optionally it may expose the service locally: bind a real socks5://127.0.0.1:port endpoint and relay each accepted connection into a channel (the ssh -D local-exposure shape, and the path tun2proxy/curl/browser point at).

  3. SOCKS client wrapper for QUIC runtimes — AsyncUdpSocket implementations (quinn and noq) so downstream clients can route QUIC through any third-party SOCKS5 server (the iroh privacy posture: a client that doesn't want to leak its real IP to relays needs a SOCKS hop ahead of the QUIC dial). Moved to the alknet rewrite — it is alknet's own ancestor (ADR-090 + the quinn-proxy POC live there), it neither produces nor consumes channels, and alknet is where downstream clients will actually consume it. The transfer took OQ-SK-06 and POC #3 with it. This crate's client-side story is the channels-native consumer session (POC #1) plus the wrapper-aware associate session (POC #2) and the optional front doors — both POC-validated.

Components 1+2 form the producer/consumer protocol pair (channels in, RFC 1928 inside). Keep them structurally separate in the crate layout.

Guiding principles, inherited from the alk* family:

  1. "ALPN as a service," not "a server." The SOCKS5 service is a produced, ACL-scoped resource on a channels connection — the same shape as an alktty terminal or an alktunnels TCP tunnel. Kernel binds exist only as explicit, optional assembly-layer shapes on either side — the producer may serve from a real listener (a feature-gated local backend, component 1's optional shape), and the consumer may expose the resource on a local port (component 2's optional shape, the ssh -D front door). The protocol itself never binds; the binding decision belongs to the caller.
  2. The -D conclusion, realized. alktunnels' Phase 0 settled that -D composes at the assembly layer: "-D is just tunnel a socks5 connection"; target selection lives in the SOCKS5 protocol at the producing side, governed by the same op-level ACL as the resource. This crate is that conclusion's realization as a first-class protocol crate, not an assembly-layer afterthought.
  3. The protocol layer is substrate-agnostic and generic over the stream. The vendored fast-socks5 typestate API (Socks5ServerProtocol<T, states::*>) is generic over T: AsyncRead + AsyncWrite + Unpin, and its interception points (run_tcp_proxy, run_udp_proxy_custom, transfer) accept any such T. The channels adapter feeds the state machine a BiStream; a local backend feeds it a TcpStream. The wrapper must not leak either substrate into the protocol layer (alktty TtyBackend / alktunnels pump-halves inversion-point precedent).
  4. Producer/consumer vocabulary. Both sides of a channels connection can initiate; a producer registers openable SOCKS5 channels (ChannelCore::register_openable), a consumer opens them and speaks RFC 1928 inside. RFC 1928's own client/server roles keep their RFC names inside the protocol layer, but crate-level docs and API use producer/consumer. Avoid "SOCKS5 server" as a crate-level noun.
  5. Extract-and-improve from alknet (producer half only). The server side is the -D capability alktunnels deferred; the client side (alknet ADR-090's Socks5UdpSocket, the quinn-proxy POC) stays in alknet per the scope resolution above. The crates share conclusions, not code paths.
  6. The vpn-like endgame composition. A SOCKS5 server + tun2proxy is the last step (ish) of "vpn-like without actually being a vpn": a user tunnels a produced SOCKS5 resource to a local port (component 2's optional shape), then points tun2proxy (/workspace/tun2proxy — it takes --proxy socks5://user:pass@host:port natively, src/args.rs) at it, and the whole host's traffic rides the alk* egress. This crate is the SOCKS5 leg of that composition; alktunnels carries the tunnel; tun2proxy closes the loop. (The udpgw framing alktunnels' UDP format is based on is the same prior art OQ-SK-03's datagram codec draws on.)

What is already settled

The foundation is POC-validated and ADR-pinned upstream; this crate does not start from zero. It inherits:

  • The channels data path — demux→Connection→handler→mux, validated by the alknet-channels POC and production alktty/alktunnels. The SOCKS5 payload is raw bytes inside a channels data channel's BiStream; channels strips its 8-byte header transparently (alknet ADR-093 / alkcall ADR-035). SOCKS5 CONNECT is one BiStream per session — the RFC conversation is the stream; no sub-demux, no framing, no flow key (one channel per SOCKS5 session).
  • The two-pump data plane — the CONNECT proxy loop is the canonical two-pump shape; alkcall::channels::pump_bidi (ADR-050) is pinned upstream and alktunnels-validated. Use it; do not hand-roll.
  • The establishment story — session opens use register_openable_with_establisher (alkcall ADR-049 + amendments): the establisher runs as an awaited, bounded establishment phase; a refused session is a typed channel:open_failed call error (reason ∈ dial_failed / unknown_resource / resource_shortage / handler_error / timeout), never a phantom channel. The negotiate/ auth half of the RFC conversation still runs in-stream over the BiStream — establishment covers only the producer's accept policy, not the RFC handshake. The establisher can project reply fields (alkcall 0.8.0, Establishment::with_reply_field(s), ADR-049 amendment 3; consumable via ChannelClient::open_channel_with_reply) — the carrier alktunnels' bind-first bound address rides; a SOCKS5 establisher that ever needs to return open-time metadata has the same seam (additive; absent fields leave the reply byte-identical).
  • The producer/consumer model — producer registers openable channels (authorization for free via AccessControl); consumer opens them via ChannelClient (alkcall ADR-037, ADR-043). Connection direction is independent of service direction.
  • The relay/hub story — a SOCKS5 resource traverses alkcall hub relays like any produced resource; the hub's terminate-and-re-produce proxy applies per hop, and a SOCKS5 resource is exactly the "further downstream" shape alktunnels described. No socks5-specific relay work. The relay machinery is in-tree — alkcall 0.8.0 ships ChannelRelay + HubLegTemplate (ADR-051), so a hub leg is an install-template composition, not assembly-layer hand-rolling; relayed replies carry extra fields through per-hop-truthfully (the bind-first bound flows end-to-end), and flavor-form ops (the direct op's channels/tunnel/direct shape) re-produce as channels, not forwarding stubs.
  • The identity seam — alkcall's CF-005/CF-006 (0.7.0): the per-call opener identity arrives on the open-op hooks. Auth mapping (identity → SOCKS5 credentials/ACL, if any) happens there, not via in-band SOCKS auth, unless RFC-facing username/password is needed for vanilla SOCKS5 clients (OQ-SK-02 — settled posture: split by the door). Still current at alkcall 0.8.0 — the 0.8.0 additions (reply projection, relay) are additive and do not touch it.
  • The backend inversion point pattern — substrate-specific types confined to feature-gated backend modules, injected at the assembly layer (alktty TtyBackend / alktunnels no-trait precedent).
  • The wasm-clean default crate — protocol-only code should compile to wasm32-unknown-unknown (alktty/alktunnels precedent). The vendored fast-socks5 subset is wasm-clean: the inline vendor gates kernel-net behind a default-on net-shaped feature (OQ-SK-04 resolved; POC #4 verified the subset is functionally complete).
  • The upstream substrate this crate composes against — alkcall 0.8.0 (reply projection; ChannelRelay/HubLegTemplate per ADR-051; flavor-form open-op ids in discovery per ADR-047 amendment 3) and alktunnels 0.2.0 (the direct op channels/tunnel/direct, ADR-007; the bind-first establisher + forwarded op channels/tunnel/forwarded, ADR-008; TunnelListener). The graduation (alktunnels docs/research/tunnels-graduation.md) took this crate's asks as evidence and landed them in one version bump — see the per-OQ notes (OQ-SK-01, OQ-SK-03, OQ-SK-08) for what changed in shape.

Prior art

alktunnels Phase 0 — the closest sibling

/workspace/@alkdev/alktunnels/docs/research/phase-0-findings.md. The load-bearing conclusions this crate inherits:

  • Hub-owns-the-connection model — role follows the resource; whoever can reach the target is the producer, whoever wants the bytes is the consumer. The "exposed port" is a virtual, ACL-scoped resource, not a bind. For alksocks: the producer is the side that dials targets (the egress side); the consumer is the side that wants proxied egress. "A SOCKS5 service is the same shape [as a produced tunnel resource], further downstream" is alktunnels' own wording — this crate exists to make it literal.
  • -D composes at the assembly layer — the original half-answer, now with the mechanism named: a SOCKS5 server at some assembly layer is just a consumer opening channels with per-connection dynamic targets. The refinement this crate adds: the SOCKS5 service is itself a produced resource (producer half), so a vanilla SOCKS5 client (curl, a browser, ssh -D's own client half) can attach through the optional local backend, and channels-native consumers get the same service in-band. Both halves wrap the same protocol layer.
  • Target policy = resource policy. alktunnels OQ-TN-08 dissolved dynamic-target policy: whatever ACL governs the socks5 resource governs everything reachable through it, plus whatever policy the SOCKS5 implementation itself applies downstream. No target allowlists in the base crate unless Phase 1 wants them (OQ-SK-05).
  • The codec conclusion does NOT transfer. alktunnels needed length-framing only for datagram substrates; a SOCKS5 CONNECT session is a byte stream end to end (RFC 1928 defines its own framing on the wire) — the channel payload is pure pass-through, 0 B overhead over the channels 8-byte header. UDP ASSOCIATE's datagram stage is the exception (OQ-SK-03, resolved shape below).

fast-socks5 — the vendored implementation

/workspace/fast-socks5 (v1.0.0, MIT — third-party upstream, not ours; read-only reference checkout). The posture is the inline vendor (OQ-SK-04, resolved 2026-09-20): vendor the T-generic subset into this crate behind a default-on net-shaped feature, carrying the wrapper's needs in-tree. /workspace/fast-socks5 remains the differential-testing reference for RFC edge cases (reply-code mapping, domain addressing, fragmentation); note the vendor point (v1.0.0) in the vendored code. Key surface points, verified:

  • Explicit typestate server API (src/server.rs): Socks5ServerProtocol<T, states::Opened|Authenticated|CommandRead>, generic over T: AsyncRead + AsyncWrite + Unpin. Flow: start(inner) → negotiate_auth(&methods) → finish_auth() / accept_no_auth / accept_password_auth → read_command() → (reply_success, reply_error). The legacy Socks5Server/Socks5Socket/Incoming API (binds a TcpListener) is deprecated — the wrapper uses the explicit API only.
  • Auth surface — the AuthMethod<T> trait (metadata: method_id, new) + AuthMethodSuccessState<T> (carry the socket back out); StandardAuthentication enum for NoAuth + Password with static dispatch; custom methods implement the trait. Username/password check is a closure (accept_password_auth). The auth decision is where the alkcall identity seam plugs in (OQ-SK-02).
  • Command handling is swappable — the interception points:
    • run_tcp_proxy(proto, addr, timeout, nodelay) — dials, replies, then transfer(inbound, outbound) (a copy_bidirectional wrapper, itself generic). This is where a channels-native dial replaces the tokio::net dial: the wrapper's producer half intercepts here, dials via alktunnels/alkcall primitives (or its own dial policy), and returns the T back.
    • run_udp_proxy_custom(proto, addr, peer_bind_ip, reply_ip, transfer) — the customizable UDP ASSOCIATE handler. Caveat (verified 2026-09-13): the "custom" seam is narrower than it looks — it calls udp_bind_random_port(peer_bind_ip) unconditionally and derives the reply port from that kernel socket; the wrapper can customize the relay loop (transfer) and the reply IP only. The channels path therefore cannot use this handler: it drives the typestate directly (reply_success(sentinel_addr) + own datagram stage). Under the inline vendor this is a change we make in-tree, not an upstream ask: make the bind optional or accept a caller-supplied socket.
    • transfer(inbound, outbound) — plain two-pump copy; a channels BiStream is a legal T on either side.
  • UDP support — new_udp_header(target) / parse_udp_request(buf) are public and pure (no socket I/O) — the SOCKS5 UDP datagram header codec is reusable without the Socket2-based relay machinery. The default run_udp_proxy binds two kernel sockets (Socket2, random ports) — the binds are in the default handler, not in the protocol, which is exactly the seam the no-bind requirement needs.
  • Client side — Socks5Stream<S> (generic over the backing socket; use_stream upgrades any AsyncRead + AsyncWrite + Unpin already connected) and Socks5Datagram<S> (UDP associate; also accepts a caller-supplied socket via use_socket). Socks5Stream impls AsyncRead + AsyncWrite itself, so a consumer session over a channels BiStream is the intended use, not a hack. Note: Socks5Stream::connect convenience constructors dial tokio::net::TcpStream and are non-wasm; use_stream is the substrate-free path.
  • Error surface — ReplyError (the RFC reply codes, as_u8/ from_u8) and SocksError; SocksServerError on the explicit API. Map these faithfully; never collapse a refusal into a generic error.
  • router.rs example — the "conditional interception" template: a server that inspects the command/target, then decides whether to proxy, refuse, or handle in-process. Structurally the shape of the channels open handler (inspect params → dial or refuse) and of the alknet ADR-090 client (hand the established stream to quinn).
  • What the vendor must add (the four carried needs): the kernel-net feature gate (POC #4: ~120 cfg lines, wasm-clean subset functionally complete); BIND (absent upstream — fast-socks5 parses Socks5Command::TCPBind but every handler refuses it, lib.rs:384-386 / server.rs:813-816; the wrapper drives the typestate itself); the run_udp_proxy_custom bind seam (above); and the publication of new_reply (the reply encoder is private; the BIND reply#2 writer otherwise hand-rolls the 10-byte reply — poc-bind-findings.md).

iroh-socks5 — the structural sibling (evaluated, not the base)

crates.io/crates/iroh-socks5 (v0.5.0, MIT OR Apache-2.0, repo: github.com/mattgeddes/iroh-socks5 — crates.io lacks the repository link). A ~1.3k-LoC iroh ProtocolHandler tunneling SOCKS5 over iroh QUIC: framed Request/Reply protocol postcard-encoded over one bidirectional stream, then raw pass-through (CONNECT/BIND) or framed Datagrams (UDP ASSOCIATE); producer-side dial + allow-list (EndpointIds), consumer-side local TCP front door for vanilla clients. Full evaluation: docs/research/iroh-socks5-eval.md. Verified:

  • Independent confirmation of POC #1's shape — one stream per SOCKS5 session, framed control phase then pass-through, RFC codec only at vanilla-client boundaries, resolution producer-side. Convergent evolution from a codebase with no alk* lineage.
  • The load-bearing OQ-SK-03 prior art — its UDP ASSOCIATE design carries framed {addr, data} datagrams in-stream (no RFC UDP header on the tunnel wire; the RFC header codec applies only at the local front-door socket), needs no producer-side flow table (per-datagram addressing in the frame does the demux), ends the association on control-stream EOF, and refuses FRAG ≠ 0. Adopted as the POC #2 design baseline; POC #2 validated it.
  • Why it is not the base: iroh types are load-bearing in the data plane (SendStream/RecvStream in relay signatures; no generic T seam), the RFC surface is a subset (no-auth only, no auth-method machinery, no reply-code enum fidelity), and the client side has no in-band session type (local front door only). Extracting a substrate-agnostic core yields ~550 LoC of codecs fast-socks5 already provides in stricter form — the POC #1/#4 base decision stands.

alknet's SOCKS5 client + the quinn-proxy POC — the client-side ancestor

/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs (ADR-090) implements Socks5UdpSocket: quinn::AsyncUdpSocket — a SOCKS5 UDP ASSOCIATE tunnel wrapped as the socket QUIC polls. The quinn-proxy POC (/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/findings.md) validated the approach end-to-end (5/5 clean runs) against quinn 0.11: one public trait (AsyncUdpSocket), one public constructor (Endpoint::new_with_abstract_socket), no fork. Known limitations there, likely inherited: ECN is lost through the SOCKS5 header; the proxy must support UDP ASSOCIATE; may_fragment() == true disables path MTU discovery.

This work belongs to the alknet rewrite (the 2026-09-14 scope decision, §Vision): the client wrapper is alknet's ancestor-shaped component — it neither produces nor consumes channels, and its open research (the noq trait-shape split, OQ-SK-06, §OQ-SK-06) moves to alknet's Phase 0. The alknet-side cleanup that applies there: the hand-rolled codec in socks5.rs inlines the greeting/auth/CONNECT/ ASSOCIATE byte logic twice — fast-socks5's client types and new_udp_header/parse_udp_request supersede it.

noq — the quinn fork the client story must also fit

Transferred to the alknet rewrite (2026-09-14, the scope decision, §Vision). Retained as background research for alknet's Phase 0; see OQ-SK-06 for the transfer rationale.

/workspace/noq (v1.2.0; iroh's fork of quinn — iroh depends on noq = "1.2.0"). Surface verified:

  • noq::AsyncUdpSocket exists (noq/src/runtime/mod.rs:44) but with a different shape than quinn 0.11: create_io_poller + try_send are replaced by create_sender() -> Pin<Box<dyn UdpSender>> with poll_send(transmit, cx) — a sender-object split (any number of UdpSenders per socket, each holding its own waker).
  • RecvMeta gained fields (interface_index, timestamp) but stays default-constructible; Transmit is unchanged in the fields the SOCKS5 wrapper touches (destination, contents, ecn, src_ip, segment_size).
  • Endpoint::new_with_abstract_socket exists (noq/src/endpoint.rs:162) with the same doc-comment intent, but takes Box<dyn AsyncUdpSocket> rather than Arc<dyn AsyncUdpSocket> — the poller-removal reshuffle also changed the ownership shape.
  • The alknet Socks5UdpSocket does not drop in unchanged: the impl must be rewritten against create_sender/poll_send, and the Arc→Box change ripples into construction. Whether one crate can serve both quinn and noq behind feature flags (shared core, two thin trait-impl shells) or whether the shapes have diverged enough to justify two impls is the research question (OQ-SK-06, transferred).

Anti-prior-art (what NOT to carry over)

  • The alknet client's hand-rolled SOCKS5 codec — socks5.rs inlines the greeting/auth/CONNECT/ASSOCIATE byte logic twice (handshake for UDP, again for CONNECT). fast-socks5's client types and new_udp_header/parse_udp_request supersede it; the vendored crate provides the codecs — no second codec.
  • Socks5Server (the legacy bind-based API) — deprecated upstream; never the default path here.
  • In-band SOCKS auth as the primary auth story — alkcall's identity seam (CF-005/CF-006) authorizes at the open-op layer. In-band RFC username/password remains available only for vanilla-client compatibility at the front door (OQ-SK-02).

Open Questions

These are the design questions Phase 0 resolved (or explicitly deferred) before the architecture spec. Numbered OQ-SK-01.. so they can be referenced, tracked, and promoted into docs/architecture/ open-questions.md in Phase 1. Status is stated per question; what remains open is named explicitly. The point of this document is to hold the reasoning without forcing premature decisions.

OQ-SK-01: Producer dial policy — what dials the target?

Status: shape resolved empirically (POC #1, 2026-09-13); the A/B assembly-layer choice is a Phase 1 policy decision. The dial callback exists and is called in-stream at the command-read interception point: an injected DialFn(target) -> boxed halves closure (function-not-trait, the alktunnels ADR-004 shape), returning TargetHandle { read, write } (boxed halves, + Sync, satisfies the ChannelPlan payload bound). Per-target refusal is an RFC reply code, not an establishment failure; session-level refusal stays channels-level (channel:open_failed). The no-trait hunch is evidence-backed; EgressFactory (OQ-SK-03) and BindAcceptFactory (OQ-SK-08) extend the same shape to the datagram and accept planes.

What remains open is where the callback comes from (the dial policy):

  • Option A: dial via alktunnels (composition) — the SOCKS5 handler opens an alk/tunnel channel naming the target, and the two-pump data plane is tunnel-channel ↔ SOCKS5-BiStream. Since alktunnels 0.2.0 (ADR-007) the dynamic-target channel exists — the direct op (channels/tunnel/direct, {substrate, target} params, the ssh direct-tcpip shape, tunnel:direct as a separate ACL grant) — so Option A no longer needs a pre-registered per-target resource: one direct-op channel per CONNECT target (what the direct op was graduated for). The udp substrate rides the same op (one op, both substrates — OQ-SK-03's composed egress uses it too).
  • Option B: dial directly — the producer dials the target itself (TcpStream behind the local feature, or an injected dial callback). No alktunnels dependency; the dial policy (allowlists, routing through another alksocks hop) is the caller's callback.
  • Option C: injected dialer trait — a Dialer-shaped trait with alktunnels and local-TCP implementations behind features. The POC evidence stands against the trait (two real implementations exist as callbacks, not trait impls; alktunnels OQ-TN-05's no-trait resolution holds).

Considerations: substrate-agnostic-by-construction (AGENTS.md convention 7) favors injection over hardwiring; the "hub terminates and re-produces" story means a hub-hop SOCKS5 resource's dialer is just "open a channel further downstream," which is Option A's shape — so whatever is decided must not prevent A when composing. The A/B choice is an assembly-layer policy question; POC #5's scope-gate finding (socks5:bind ≠ socks5:open) plus ADR-007's own rationale ("dial postgres-primary" vs "dial anything" are different grants) both point the same way: separation lives at the ACL, not in the protocol. The dialer-trait-vs-callback half of this OQ is closed; the Phase 1 ADR picks the default provider set and the params shape for the composed path.

OQ-SK-02: Auth mapping — identity seam vs in-band SOCKS auth

Status: settled (2026-09-13): "both," split by the door. The open-op hooks receive the per-call opener identity (alkcall CF-005/ CF-006); the RFC conversation also carries its own optional username/password auth (RFC 1929). The settled posture: both mechanisms are real, each on its own side of the door — the consumer's optional front door (component 2's ssh -D shape, where the vanilla world meets the ACL world; the vpn-like endgame composition points tun2proxy's user:pass at it).

  • Producer side: identity only. The channels-native open is authorized by the alkcall ACL/identity seam (CF-005/CF-006); a producer-side password is moot — the ACL system is strictly better than a shared secret, and the producer's in-band conversation runs NoAuth/no-op (fast-socks5's skip_auth_this_is_not_rfc_compliant, or a no-op NoAuth method). A password there would add nothing the identity seam doesn't already do.
  • Consumer side (the front door): RFC 1929 where vanilla clients need it. The door owns credential presentation: a vanilla client carrying user:pass (tun2proxy, curl) gets a real RFC 1929 conversation at the door. The door then maps the accepted credential to local configuration — which channel identity/endpoint to open the producer channel with — and that identity reaches the producer via the CF-005/CF-006 seam. The SOCKS password never crosses the channel as a SOCKS password; the channel identity never reaches the vanilla client.

The "replace or second-gate" sub-question dissolves: the mechanisms occupy different planes (in-band creds are door-local; identity is channel-level). No fast-socks5 AuthMethod impl is needed — the door checks credentials itself before handing the stream to the typestate (the door is this crate's code; the trait was designed for embedders who don't own the accept loop). The NoAuth door (POC #1's raw-pipe shape, zero protocol translation) remains valid for loopback-only or wrapper-aware use; the cred-bearing door is the same bridge plus the greeting half (fast-socks5's server typestate for auth + read_command, then the consumer session in-band) — an assembly-layer choice.

Remaining for Phase 1: the door's credential→identity mapping shape (static config vs pluggable lookup); whether the producer-side optional local backend offers the same door surface (same code shape — door = vanilla↔ACL boundary — so presumably yes).

OQ-SK-03: UDP ASSOCIATE without binding — the hard case

Status: baseline settled (2026-09-13, the handle swap) and POC-validated end to end (POC #2, 2026-09-14 — poc-udp-associate-findings.md). The datagram-framing ADR (the one-way door) is Phase 1's, with the first consumer in sight; the framing has empirical ground under it.

The handle swap — "tell the client about a UDP port" → "tell the client about the UDP tunnel." The design's load-bearing reading of RFC 1928: 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. So swapping the handle preserves every RFC property:

  • one relay per association → one channel per association (the CONNECT shape; no second channel, no correlation params);
  • per-datagram destinations → {addr, data} frames in-band (the iroh-socks5 cut: boundary-preserving, no RFC header on the tunnel wire, no producer-side flow table; RFC codec at front doors only). The length prefix is u16 BE — 65507 (the max UDP payload) < 65535, so u16 suffices; boundary preservation is mandatory (empty datagram ≠ EOF — alktunnels F-2's lesson transfers directly; the frame shape meets it structurally);
  • lifetime tied to the control connection → channel EOF;
  • "bind locally if you want" → the optional front door on either side (a real UDP socket bridged to the channel, RFC codec at that boundary only).

Vanilla clients still literally sendto() the replied address, so the in-band path is wrapper-aware-only by RFC necessity — the front-door bridge is where a real socket address exists. The reply's BND.ADDR/ BND.PORT on the in-band path are pure RFC-compatibility surface (the POC used the sentinel 127.0.0.1:0); only the front door reports a real address.

Producer egress — two variants (the "no ports" story has two halves). The handle swap settles the consumer-facing half. The producing-side half is: after {addr, data} frames exit the channel, what gets each datagram to addr? Both are dial-callback policy (OQ-SK-01's shape); the protocol layer sees neither.

  1. Local egress — the handler binds a real UdpSocket and send_tos per datagram (iroh-socks5's relay_udp_server behind this crate's local feature). No flow table, native-only. POC #2 implemented it (LocalEgress); domain-form destinations resolve at the egress (producer-side DNS — the ssh -D semantic, POC-resolved as "the egress owns resolution").
  2. Composed egress — the dial callback opens an alktunnels udp-substrate channel per destination: a lazy destination→tunnel table (open on first datagram, LRU-evict; POC #2 implemented it as ComposedEgress). The flow table is reborn as channel handles — because alktunnels' udp substrate is connected (one tunnel, one fixed target) while ASSOCIATE names a different destination per datagram. The composed form's vehicle since alktunnels 0.2.0 (ADR-007) is the direct op — open_direct + Substrate::Udp dials a real UDP target through the dynamic-target op (channels/tunnel/direct), pinned by alktunnels' own test (a real UDP echo through the establisher path); the table entries are direct-op channels, and the per-destination ACL question shifts to the tunnel:direct grant + the dialer's target policy (the ACL governs the capability, the dial callback governs per-destination checks). Cost: one open per destination, measured ~0.8 ms (POC #2, N=20 loopback) — fine for DNS
    • browsing; the 256-channel default cap bounds fan-out (OQ-SK-09). Benefit: per-target ACL for free — dissolving most of OQ-SK-05 for the composed path — and hub relaying is per-target terminate-and-re-produce.

No upstream ask exists here: alktunnels' connected-udp substrate is correct as-is for per-target composition; an "unconnected arbitrary-egress udp resource" upstream would just be SOCKS5 again with different framing (circular).

Phase model vs alktty-style demux (the extensibility trade — Phase 1 ADR input). An alternative shape surfaced: alktty's logical demux (input/output/error/control sub-streams inside one channel, type byte per chunk) applied to the SOCKS5 channel. Analysis against RFC 1928's actual structure: RFC 1928 is one command per connection, and control and data never interleave in either direction. CONNECT's control conversation ends at the reply, after which the channel is a pure byte stream; ASSOCIATE's control stream goes silent after the reply — fast-socks5's own wait_on_tcp (src/server.rs:1195) treats any post-reply control byte as protocol garbage (UnexpectedUdpControlGarbage); the phase model needs no mode byte (the reply itself synchronizes the switch — POC #2 confirmed post-reply garbage is refused structurally). So the per-chunk type byte demuxes a problem SOCKS5 doesn't have. What the demux genuinely buys: an additive in-band vocabulary — new stream types extend the wire additively (no format break), whereas adding a post-reply phase later is a wire break. alktunnels accepted the opposite ("no in-band control path, ever — the escape hatch is a new ALPN, a wire change is not"); the demux reintroduces that vocabulary at 2 B/chunk (type byte + length prefix) on every datagram. The one-way-door cost is real either way: demux bakes the vocabulary in before it has a user; phase model keeps 0 B overhead on CONNECT and 2 B on ASSOCIATE datagrams. Evidence after both POCs favors the phase model — no in-band control vocabulary was needed. Decide via ADR with the first consumer in sight.

OQ-SK-04: fast-socks5 wasm posture — RESOLVED: inline vendor

RESOLVED 2026-09-20: inline vendor (decided). Two facts settled it: (1) the ownership premise was wrong — we do not own fast-socks5 (third-party upstream), so the "file asks / PR" legs of the old decision tree lose their cheap path; and (2) the wrapper's needs go beyond the wasm gate regardless — BIND is absent upstream (POC #5 proved the wrapper must drive it itself), and the two seam fixes (run_udp_proxy_custom's unconditional bind; private new_reply) were only "asks" under the ownership assumption. The change set (net-gate + BIND + bind seam + new_reply) is exactly the shape POC #4 measured as minimal (~120 cfg lines + additive additions in the T-generic region), so the vendor pays for itself without leaning on the adapter story. Mechanics in the Convergence §Layering bullet; the reasoning trail below is retained for the record.

The decision tree (2026-09-12, both legs written under the wrong ownership premise "we own upstream"):

  • Case 1: wasm matters for this crate → fork-first. Make the minimal fork and use it — offer it upstream as a PR (merge if they want it; we carry the fork regardless; never wait on approval). Concretely "fork" means vendoring the relevant subset into this crate (the codecs, typestate machinery, client use_stream path — the parts that are already T-generic) and gating the native-net pieces behind the crate's own local feature. (The vendoring shape is what was adopted — the PR is optional courtesy, and "shrinks to a plain dependency" is moot since the vendor also carries BIND + the seam fixes upstream lacks.)
  • Case 2: wasm doesn't matter → plain dependency, no fork. Ride the router.rs native path exactly as-is (the alkhttp precedent — a crate in this suite can ship native-only). Strictly better if its premise holds — a wrap-only relationship with no carried changes. (Superseded: the premise was falsified by the BIND + seam needs.)

The root question was whether wasm makes sense for alksocks; the analysis on file: what wasm would serve is the protocol layer (components 1+2) in a sandboxed adapter — the alktty/alktunnels posture that a wasm-compiled protocol crate is the protocol layer for downstream TS/Python adapters; the natural wasm shape is a consumer (speak RFC 1928 client-side over a channels BiStream — pure byte framing; no local sockets needed client-side). A wasm producer is odd standalone (no kernel egress) but composes — its dialer can open alktunnels channels further downstream (the hub story). What wasm would not serve: the quinn/noq client wrapper (since transferred to alknet) — native-only in practice. The alkhttp contrast, honestly weighed: alkhttp's substance is socket-native so nobody misses wasm there; alksocks differs structurally — its protocol substance is byte-framing over a generic T — but "costs nothing under the fork" is only an argument once Case 1 is chosen; the use case is a planned sandboxed SOCKS consumer. (Retrospective, 2026-09-20: the ownership correction resolved the OQ ahead of the adapter story — the vendor is justified by BIND + the seam fixes alone. The sandboxed-wasm-consumer adapter story remains a product input for how much wasm surface to exercise, not a gate.)

OQ-SK-05: Target policy and egress scoping

SOCKS5 is arbitrary-egress by nature — the open gate plus the target policy are the security boundary (AGENTS.md convention 12). alktunnels resolved that whatever ACL governs the socks5 resource governs everything reachable through it; OQ-SK-01's dial policy is the mechanism. Residual questions for Phase 1 (note: for the composed UDP egress path, per-target ACL comes free — each destination rides its own direct-op channel governed by the tunnel grant's ACL, OQ-SK-03 variant 2):

  • Does the base crate ship a per-target allowlist hook (producer-side policy injected at registration), or is target policy entirely the dialer's concern (the dial callback refuses)? The dialer-refuses shape avoids a second policy layer (alktunnels' resolution pattern, evidence-backed); the allowlist-hook shape makes a common policy declarative.
  • Is there a SOCKS5_OPEN_SCOPE (scope-gate on the open op, alktty TTY_OPEN_SCOPE shape) — presumably yes, but the exact scope-string convention should follow alkcall's registry conventions. POC-validated in shape (socks5:open, socks5:bind); the convention ADR is Phase 1's.
  • Domain-form targets (RFC 1928 ATYP 0x03): resolve producer-side (fast-socks5's dns_resolve config), refuse, or pass through to the dialer unresolved? POC #2 resolved the local-egress half ("the egress owns resolution"); the general dialer-pass-through posture is the remaining Phase 1 choice (wasm producers may not have a resolver at all — an interaction with the vendored crate's wasm posture, OQ-SK-04 resolved 2026-09-20).

Virtual address space / identity-scoped subnets (added 2026-09-14, post-POC #2). A proposal surfaced during the findings review: give identities scoped virtual subnets (e.g. 10.x.0.0/16 slices) so proxied resources become IP-addressable — "BND.ADDR ↔ channel id" translation, a hub exposing proxied resources per identity under ACL. The goal is adoptable; the mechanism needs no wire change: DST.ADDR and BND.ADDR are arbitrary RFC fields, so virtual addresses ride ordinary CONNECT/BIND requests — the translation layer is the dial callback (OQ-SK-01's shape: dial(target) matches a virtual subnet against a per-identity resource table), per-identity scoping is resources + ACL

  • target policy (this OQ), and hub exposure is the terminate-and-re-produce story with no new wire vocabulary. BND.ADDR stays per the handle swap (OQ-SK-03): in-band the handle is the channel (compat surface only); a real network-B address exists wherever a local backend binds — the listener's local_addr (OQ-SK-08 variant 2, the bind-first bound reply field). The one place a virtual subnet becomes routable from vanilla apps is the full-tunnel composition: the host routes the subnet into the proxy (tun2proxy), so connect(10.x.y.z:p) is an ordinary CONNECT the producer translates. Worth checking tun2proxy's virtual-network/virtual-DNS support as prior art before Phase 1 decides whether the crate ships a translation-table helper or leaves it entirely to assembly-layer policy.

OQ-SK-06: noq client support — RESOLVED by transfer

Resolved by the scope decision (2026-09-14, §Vision): transferred to the alknet rewrite. The client wrapper is alknet's ancestor-shaped component (ADR-090 + quinn-proxy POC already live there), consumes no channels, and has no consumers planned inside this crate — so its research questions (the noq trait-shape comparison: create_sender() -> Pin<Box<dyn UdpSender>> replacing create_io_poller + try_send, the Box<dyn AsyncUdpSocket> constructor ownership change; the dependency posture — noq is consumed from the n0 workspace/git, not crates.io; the ECN/MTU carry-over) move to alknet's Phase 0 wholesale. This crate's client-side story is complete without it: the channels-native consumer session is POC-validated (POC #1's use_stream over a BiStream, POC #2's associate session). The noq POC (POC #3) is dropped from this crate's register accordingly. The noq surface notes are retained under §Prior art as alknet-facing background.

OQ-SK-07: POC register (closed)

All POCs resolved; the register is closed for this crate's Phase 0. Numbering is historical (do not renumber — findings files and commit messages reference these numbers):

# What Status Where
1 Channels-native SOCKS5 CONNECT Passed 2026-09-13 poc-connect-wasm-findings.md
2 UDP ASSOCIATE (handle-swap shape) Passed 2026-09-14 poc-udp-associate-findings.md
3 noq AsyncUdpSocket impl Dropped 2026-09-14 — transferred to alknet with OQ-SK-06 —
4 fast-socks5 wasm fork-minimality Passed 2026-09-13 — folded into #1's worktree, shares #1's findings file poc-connect-wasm-findings.md §POC #4
5 BIND (accept-side command) Passed 2026-09-15 poc-bind-findings.md

Highlights (details in the findings files):

  • POC #1 validated the BiStream-as-T genericity claim, register_openable_with_establisher fit, pump_bidi as the data plane, the dial-at-command-read layering, the identity seam end to end, typed refusal paths (session-level at the registry ACL; target-level as RFC reply codes), and the curl front door.
  • POC #4 measured the fast-socks5 wasm gating at ~120 cfg lines across 5 files; the wasm-clean subset is the functional protocol subset (a full RFC 1928 CONNECT conversation passes --no-default-features).
  • POC #2 validated the handle-swap baseline end to end (both egress variants; composed per-destination open cost ~0.8 ms; front-door RFC-codec composition) and produced the load-bearing API findings: the datagram stage is select!-driven with the egress readiness contract (next_reply must be Pending when no reply is ready — an instantly-ready Ok(None) starves a biased select into livelock), framing applied exactly once by the wire layer (inner bytes at the egress seam), and the egress/session ownership must ride the table entry (teardown-at-birth).
  • POC #5 validated the BIND phase model end to end (both accept variants; the socks5:bind scope gate; failure mapping RFC-faithful; the vanilla front door for BIND), pinned the composed-variant sequencing against alktunnels 0.1.0 (superseded in shape by the bind-first graduation — see OQ-SK-08), and surfaced the ownership-rides-the-handle lesson as the third witness of the teardown-at-birth pattern.

POC placement conventions (inherited from alktunnels): a POC that needs code from this repo runs in a worktree/branch (.worktrees/research/ <task-id>/ per the SDD process); a self-contained POC runs as a standalone crate in the global workspace with findings written into docs/research/ here. Findings always land in docs/research/ regardless of where the code lives.

OQ-SK-08: BIND — the accept-side command

Status: use case found, POC-validated (POC #5, 2026-09-15); the composed path composes against alktunnels 0.2.0 (the graduation superseded the ask ledger's shape); the ship-in-base-crate and default accept variant are Phase 1 ADRs.

The gap (fast-socks5): Socks5Command::TCPBind is parsed (lib.rs:91) but every handler refuses it (lib.rs:384-386, server.rs:813-816 — CommandNotSupported); no bind machinery exists. The typestate flow (read_command() returning cmd + TargetAddr) means the wrapper drives the command dispatch itself — implementing BIND is wrapper-side code, carried in the vendor (no upstream ask). POC-confirmed: reply_success's sock_addr parameter carries reply#1's BND.ADDR verbatim; the courtesy item is publishing new_reply (rides the vendor — see §Prior art's four carried needs).

The shape (convergent precedent: iroh-socks5's channels-native BIND — server relays raw bytes post-reply for CONNECT/BIND, docs/research/ iroh-socks5-eval.md): one channel per BIND session, phase model — request → reply#1 carrying BND.ADDR → quiet wait (any control byte after reply#1 is protocol garbage, same as ASSOCIATE's post-reply stage) → exactly one inbound accept → reply#2 carrying the peer address → raw pass-through data plane (zero per-packet headers in-band, same as CONNECT; only the ASSOCIATE datagram stage is framed). RFC-refusal of BIND remains valid for old consumers; support is purely additive (same ALPN, same params, no framing change — no one-way-door exposure).

The mechanism — accept-side resources (the -R far-side listener shape). The consumer's -R path in alktunnels proves accept-side resources exist without forced binds; SOCKS5 BIND is their mirror image: the producer-side listener (network B — the app server must reach it) is the accept resource, each popped accept two-pumps against the SOCKS5 BiStream. The phase-0 hand-off note ("a producer-side BIND wants the accept-side shape, not a dial") predicted exactly this; the composition supplies the use case it lacked.

Accept variants (mirror of OQ-SK-03's egress variants):

  1. Local backend binds for real (feature-gated local) — the faithful RFC shape, iroh-socks5-faithful. A real network-B address goes in reply#1, vanilla front-door clients work; FTP active mode is the canonical use and the reason vanilla client libraries speak BIND at all. No upstream ask.
  2. Composed (alktunnels listen resource) — the listener lives on a listen resource (per-listener ACL for free, hub-relayable per the terminate-and-re-produce story). Superseded in shape (2026-09-20): the ask ledger landed via the bind-first/forwarded graduation — see below.
  3. Wrapper-aware only — zeros in reply#1, reply#2 in-band; simplest, but vanilla clients lose BIND and the use case mostly evaporates. Retained for completeness; weak hunch against.

POC #5's resolution (2026-09-15 — poc-bind-findings.md): variants 1 and 2 both implemented and passing. The hunch (variant 1 as the faithful + zero-ask default) was right. Against alktunnels 0.1.0 the composed variant needed a pinned workaround — reply#1 first, the tunnel open rides the accept future (the accept-first establisher could not open before reply#1 without deadlocking into the establishment timeout) — plus a two-item ask ledger (learn the bound address without consuming an accept; peer metadata on accepted handles).

The graduation landed — superseded in shape (2026-09-20, alkcall 0.8.0 + alktunnels 0.2.0). The graduation (alktunnels docs/research/tunnels-graduation.md → ADRs 007/008) took the ledger's two asks as evidence and adopted the ssh forwarded-tcpip-shaped alternative over the per-ask carriers:

  • Ask #1 (learn the bound address without consuming an accept) is dissolved, not implemented. The bind-first establisher (bindfirst_establisher, ADR-008 §1) binds during the establishment phase — fast, no app-server dependency — so the open-op reply natively carries the real OS-chosen bound address (the bound reply field, projected via alkcall 0.8.0's Establishment::with_reply_field, ADR-049 amendment 3; consumed via open_channel_with_reply / TunnelListener::bound()). There is no accept to avoid consuming: establishment = bind. This replaces the pinned "reply#1 first, the tunnel open rides the accept future" workaround (it existed for the accept-first establisher) and dissolves POC #5's establishment-bound finding: the accept wait now lives in the teardown-governed pump phase, so FTP-length BIND waits stop resolving as spurious 10s-establishment refusals (ADR-008's stated second win). The old accept-first surface (listen_establisher + AcceptQueue) stays in 0.2.0 for the accept-as-open shape.
  • Ask #2 (peer metadata on accepted handles) landed at the wire's native moment. The forwarded op (channels/tunnel/forwarded, ADR-008 §3) carries {listen, substrate, peer} params per accept — the peer address rides the open-op params (deny-unknown, schema-gated) rather than a TargetHandle field; ForwardedOpener + make_forwarded_op_handler are the serving side. Correlation is a producer-minted u32 token (ADR-008 Amendment 1, 2026-09-18: the listen reply field + forwarded param), not the originally-planned channel-ID key.

Consequence for variant 2: the composed BIND path is now fully vanilla-fidelity with no interim stand-ins — the factory no longer carries an expected bind_addr, reply#1 advertises the real bound, reply#2 carries the real peer, and no establishment bound threatens long BIND waits. POC #5's findings remain accurate as the record of what was validated against alktunnels 0.1.0; the Phase 1 shape composes against 0.2.0 (register_tunnel_bindfirst_openable + BindFirstInjections, or TunnelListener on the opener side).

Security boundary note: BIND is an inbound listener — the accept-side twin of arbitrary egress. Whatever ACL governs the socks5 resource now also governs who can mint listeners on the producer's network; per-listener policy (address/port ranges allowed to bind) is the accept-callback's target-policy twin (OQ-SK-05's mirror). The open-op gate (SOCKS5_OPEN_SCOPE) must distinguish CONNECT/ASSOCIATE from BIND, or any identity with proxy egress can also open ingress listeners. POC-validated (socks5:bind checked at command dispatch from the per-call opener identity; ConnectionNotAllowed (0x02) refusal while CONNECT still serves the same identity).

OQ-SK-09: Channel capacity for proxy workloads (the 256 default)

Status: analysis complete (2026-09-14); the documented proxy-workload recommendation is Phase 1's. The 256-channel default cap (per-connection ADR-040; per-identity ADR-041) is tight for a proxy workload: every proxied flow is a channel (a browser or tun2proxy fan-out opens one CONNECT channel per flow; composed UDP egress spends a downstream channel per destination; BIND spends an accept resource per listener).

  • No wire change — rejected permanently: the caps are policy knobs (configurable without a wire change; the u32 channel-ID wire space is ~4 billion). The resolution is config, not wire.
  • Assembly-layer config is the mechanism (ADR-040/041): a proxy-shaped deployment raises the per-connection/per-identity caps; the default stays conservative for everyone else.
  • Documented proxy-workload default for Phase 1: the crate docs should recommend a raised cap (and its memory arithmetic: a session is bounded + predictable — CONNECT/ASSOCIATE are bounded-buffer pumps, BIND adds one listener + one accept) so deployments don't discover the cap in production.
  • Defense-in-depth posture: the cap's rationales both still apply under SOCKS5. Per-connection memory bound — arguably weaker here, since the three command shapes are well-known and bounded (a session's memory is predictable); per-identity DoS brake — arguably stronger here, since each SOCKS5 session maps to real producer-side resources (a dial, a downstream channel, an accept listener) and the cap is the brake on one identity opening thousands. Layered defense: ACL → per-connection cap → per-identity cap → dial/accept policy; relaxing the caps leans on the rest.

Considered and rejected: in-channel port multiplexing. A related proposal (2026-09-14 review) would reframe the cap by multiplexing "ports" inside one channel — a [port: u16][len][payload] per-chunk prefix (channel-id-as-IP, port-as-port; modeled on alktty's wire). Rejected on the established grounds: it re-opens the demux-vs-phase trade OQ-SK-03 already resolved (phase model, no in-band control vocabulary needed across both POCs), it breaks the 0-B pass-through that lets fast-socks5's raw transfer drop in unchanged (POC #1's validated fit), it imposes chunk boundaries on a stream RFC 1928 treats as unstructured, and it violates the one-channel-per-SOCKS5-session convention (AGENTS.md #10). Channel-ID economy is not a wire problem the u32 space + policy knobs fail at. Revisit trigger: if a deployment hits a fixed upstream channel budget that config cannot raise, the additive escape is a new multiplexed ALPN — never a change to this ALPN's framing.

The related idea that survives — virtual address space / identity-scoped subnets — is recorded under OQ-SK-05: BND.ADDR/ DST.ADDR translation via the dial callback, no wire change.

Survey / prior-art list

Candidate reading for the research specialist (to be expanded):

  • RFC 1928 (SOCKS5) — the fixed protocol: method negotiation, CONNECT, BIND, UDP ASSOCIATE, reply codes. RFC 1929 (username/password).
  • fast-socks5 /workspace/fast-socks5 — src/server.rs (typestate API, interception points, auth traits), src/client.rs (Socks5Stream/Socks5Datagram, use_stream), src/lib.rs (new_udp_header/parse_udp_request, ReplyError), examples/router.rs (conditional interception), examples/ custom_auth_server.rs. (Third-party upstream; the inline vendor — OQ-SK-04, resolved 2026-09-20 — vendors the T-generic subset into this crate; this checkout is the differential-testing reference.)
  • iroh-socks5 — crates.io/crates/iroh-socks5 v0.5.0, repo github.com/mattgeddes/iroh-socks5 (crates.io has no repository link; the published source matches). Evaluated in docs/research/iroh-socks5-eval.md: not the protocol base (iroh coupling, RFC subset), but the OQ-SK-03 UDP prior art (framed {addr, data} in-band datagrams, no flow table, control-EOF association lifetime) and an independent confirmation of POC #1's one-stream-per-session shape.
  • alktunnels — docs/research/phase-0-findings.md (the -D composition conclusion, hub-owns-the-connection, the UDP codec decision + F-2), docs/research/tunnels-graduation.md (the direct op + bind-first/forwarded graduation — the substrate this crate composes against), docs/architecture/ (params/ALPN/ACL ADR template), POC summaries (POC placement and findings conventions).
  • alkcall — docs/architecture/decisions/ ADR-037/039/047/049/050/051 (channel ops, params-is-ALPN-specific, establishment, pump_bidi, reply projection, hub relay), ledger CF-005/CF-006 (identity seam), src/channels/operations.rs (ChannelCore, OpenHandler, Establishment, ChannelPlan), src/channels/pump.rs.
  • alknet — crates/alknet-client/src/socks5.rs (ADR-090, the client ancestor), docs/research/quinn-quic-proxy/findings.md (the POC findings: trait + constructor surface, ECN/MTU limitations, BotBrowser production precedent).
  • noq — /workspace/noq: noq/src/runtime/mod.rs (AsyncUdpSocket, UdpSender), noq/src/endpoint.rs (new_with_abstract_socket), noq-udp/src/lib.rs (RecvMeta, Transmit), Cargo.toml (workspace versioning / publication posture). (Research transferred to the alknet rewrite with OQ-SK-06 — retained here as alknet-facing background.)
  • alktty — backend inversion point (TtyBackend), TTY_OPEN_SCOPE scope-gating shape, feature-gated local backend, wasm-clean default-crate verification commands.
  • tun2proxy — /workspace/tun2proxy src/udpgw.rs and its SOCKS5 files (socks.rs, proxy_handler.rs): UDP-over-stream framing and flow-table prior art (analyzed in alktunnels phase-0-findings; the SOCKS5-specific parts feed OQ-SK-03). Also the endgame composition partner: it takes --proxy socks5://... upstream natively (src/args.rs) — the vpn-like endgame is tunnel + local bind + tun2proxy (§Vision, principle 6).
  • alkhttp — /workspace/@alkdev/alkhttp: the native-only precedent in the suite (axum/reqwest/hyper, no wasm target) — cited in OQ-SK-04 as the "wasm-clean is preferred, not mandatory" escape hatch.
  • iroh — /workspace/iroh iroh/Cargo.toml (the noq dependency posture: noq = "1.2.0" from the n0 workspace) — context for OQ-SK-06's dependency question (transferred to the alknet rewrite); also the privacy motivation that originally motivated the client wrapper (relays/peers see the real IP).

Convergence checklist (what Phase 0 must produce)

  • Vision + guiding principles captured (this doc, §Vision — including the component split and the scope resolution)
  • Prior-art pass complete: fast-socks5 surface verified (§Prior art), alktunnels/alknet/noq lineage mapped, anti-prior-art list written; iroh-socks5 evaluated (structural sibling, not the base; OQ-SK-03 prior art — docs/research/iroh-socks5-eval.md); ownership premise corrected 2026-09-20 (fast-socks5 is third-party upstream; the posture is the inline vendor, OQ-SK-04 resolved)
  • OQ-SK-01 (dial policy) — shape resolved empirically (POC #1: dial callback at the command-read interception point, boxed halves, no trait); the A/B assembly-layer choice is a Phase 1 policy decision
  • OQ-SK-02 (auth mapping) — settled 2026-09-13 ("both," split by the door; POC #1 validated the seam end to end); Phase 1 formalizes the door's credential→identity mapping
  • OQ-SK-03 (UDP ASSOCIATE) — the handle-swap baseline settled 2026-09-13 and POC-validated end to end (POC #2, 2026-09-14); the datagram framing is wire-stable once published — the Phase 1 ADR (framing + demux-vs-phase) remains, with empirical ground
  • OQ-SK-04 (fast-socks5 wasm) — resolved 2026-09-20: the inline vendor (the ownership premise was corrected — we don't own fast-socks5; BIND + the seam fixes must be carried regardless, so the vendor pays for itself independent of the adapter story; POC #4 de-risked the mechanism)
  • OQ-SK-05 (target policy) — dialer-refuses shape evidence-backed; per-target-ACL-for-free validated for the composed egress path (POC #2); scope-gate convention (socks5:open/socks5:bind) POC-validated in shape; the allowlist variant, exact scope convention, and the 2026-09-14 virtual-subnet proposal are Phase 1 decisions
  • OQ-SK-06 (noq client) — resolved by the scope decision (2026-09-14): transferred to the alknet rewrite; this crate's channels-native client story is POC-complete without it
  • OQ-SK-07 (POC register) — closed: #1, #2, #4, #5 passed; #3 dropped with the scope decision; findings in docs/research/
  • OQ-SK-08 (BIND) — use case + mechanism found; POC #5 validated the shape end to end; the ask ledger landed 2026-09-18/20 (superseded in shape by the bind-first/forwarded graduation); the remaining Phase 1 ADR (ship in base crate? default accept variant?) has empirical ground under it
  • OQ-SK-09 (channel capacity) — proxy-workload analysis written (wire change rejected; config is the mechanism; in-channel port multiplexing considered and rejected); Phase 1 pins the documented proxy-workload recommendation
  • Targeted POCs run + summaries in docs/research/ — complete; no pending POCs remain for this crate's Phase 0
  • Converge: recommended approach written up, ready to hand to the Architect for Phase 1 (§Convergence, 2026-09-14)

This is Phase 0's output: a clear WHAT/WHY with validated approaches, ready for the Architect.

The crate, scoped

alksocks = the SOCKS5 producer/consumer protocol pair on alkcall channels (socks + channels, nothing else). The component split resolved: components 1+2 are this crate; component 3 (the quinn/noq AsyncUdpSocket client wrapper) moves to the alknet rewrite, where its ancestor work (ADR-090, quinn-proxy POC) already lives. No local binds except the two explicit, optional, feature-gated assembly shapes (producer's local backend; consumer's front door).

  • Protocol base: the vendored fast-socks5 explicit typestate API — the T: AsyncRead + AsyncWrite + Unpin genericity carries both substrates (POC #1: a channels BiStream drops in directly; the local backend feeds a TcpStream). Drive the typestate directly for UDP ASSOCIATE (run_udp_proxy_custom is unusable on channels — unconditional kernel bind; the vendor fixes the seam in-tree).
  • Wire shape: the handle swap (POC #2-validated) — one ALPN (alk/socks5), one open op (channels/socks5/sub), trivial params; the RFC conversation is the data plane; CONNECT = pump_bidi pass-through post-reply (POC #1); ASSOCIATE = the select!-driven datagram stage over [len: u16 BE][addr + data] frames (alktunnels' length codec; RSV/FRAG only at front doors; channel EOF ends the association; per-datagram addressing, no flow table); BIND = the phase model extended (POC #5).
  • Egress/accept seams: the dial-callback shape, extended — DialFn for CONNECT (POC #1's command-read interception point), EgressFactory for ASSOCIATE (POC #2), and BindAcceptFactory for BIND (POC #5): all function-not-trait, all returning boxed halves. Local-socket and composed providers are assembly-layer policies; the composed variant's per-destination open cost is measured (~0.8 ms) and its per-target-ACL-for-free benefit is validated. The composed providers compose against alktunnels 0.2.0 — the direct op (channels/tunnel/direct, ADR-007) is the dynamic-target channel for CONNECT, and open_direct + Substrate::Udp is the composed ASSOCIATE vehicle (both substrates, one op, one grant: tunnel:direct); the hub-leg for relaying them is alkcall 0.8.0's in-tree ChannelRelay/HubLegTemplate (ADR-051).
  • Auth: "both," split by the door (OQ-SK-02's settled posture) — producer side: the alkcall identity seam only (the in-band conversation runs NoAuth); the consumer's front door owns RFC 1929 for vanilla clients and maps credentials to the channel identity.
  • Layering: the alktty/alktunnels precedents — substrate-agnostic protocol modules at the crate core; feature-gated backend modules (local binds) never imported by the protocol layer; producer + consumer + params modules mirroring POC #1/#2's structure. The fast-socks5 posture is the inline vendor (OQ-SK-04, resolved 2026-09-20): vendor the T-generic subset into this crate (the codecs, typestate machinery, client use_stream path), gated behind a default-on net-shaped feature (POC #4: ~120 cfg lines, wasm-clean subset functionally complete) — and carry the four wrapper needs in the same vendor: BIND (absent upstream; POC #5's typestate-driven shape), the run_udp_proxy_custom bind seam, the new_reply publication, and the wasm gate. /workspace/fast-socks5 stays the differential-testing reference; note the vendor point (v1.0.0) in the vendored code.
  • Open for Phase 1 ADRs (inputs collected, decisions pending): the demux-vs-phase extensibility trade (evidence now favors the phase model — no in-band control vocabulary was needed across both POCs); the datagram-framing ADR (the one-way door — decide with the first consumer in sight); the params-format/ALPN naming ADRs (wire-stable once published, no consumers exist yet); SOCKS5_OPEN_SCOPE convention (must distinguish BIND from CONNECT/ASSOCIATE — OQ-SK-08's security note); the door's credential→identity mapping shape (OQ-SK-02); the A/B egress provider default (OQ-SK-01); BIND's base-crate inclusion + default accept variant (OQ-SK-08); the proxy-workload channel-cap recommendation (OQ-SK-09); target-policy shape + the virtual-subnet translation-layer shape (OQ-SK-05). The wasm decision itself is settled; only the sandboxed-wasm-consumer adapter story remains a product input, and nothing waits on it.

What Phase 0 could NOT answer (handed to Phase 1)

  • The datagram-framing ADR (the one-way door) — empirical ground in hand: framing validated, LRU/dedup semantics pinned, open cost measured. Decide with the first consumer in sight.
  • Target-policy shape (OQ-SK-05) — the dialer-refuses shape is evidence-backed; the declarative-allowlist variant stays a Phase 1 choice. (The 2026-09-14 virtual-subnet proposal is recorded there — dial-callback translation, no wire change.)
  • Channel capacity for proxy workloads (OQ-SK-09) — wire change rejected; assembly-layer config + a documented proxy-workload cap recommendation stay Phase 1 decisions.
  • BIND — validated end to end (POC #5); the graduation supplied the composed variant's full-fidelity substrate (alktunnels 0.2.0's bind-first establisher + forwarded op). Deciding whether BIND ships in the base crate (and which accept variant is default) is a Phase 1 ADR — support is additive (old consumers still get the RFC refusal), so nothing here is a one-way door.
  • The door's credential→identity mapping shape (OQ-SK-02) and the scope-string convention — Phase 1 ADRs with POC ground under them.