- 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
17 KiB
status: complete last_updated: 2026-09-20 (ledger statuses updated with the graduation landed; both alktunnels asks superseded in shape)
alksocks POC findings: channels-native SOCKS5 BIND
POC #5 from phase-0 §OQ-SK-07, run 2026-09-15. Crate:
alksocks-bind-poc (standalone, /workspace/alksocks-bind-poc/), against
alkcall 0.7.1 (crates.io), alktunnels 0.1.0 (crates.io, local feature;
the listen surface is complete in the published version),
fast-socks5 1.0.0 (crates.io). 9 tests, all pass; cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.
Validates OQ-SK-08's design shape: one channel per BIND session; the
phase model extends (request → reply#1 with BND.ADDR → quiet wait →
exactly one inbound accept → reply#2 with the peer address → raw
pass-through data plane); both accept variants behind the accept
callback; the socks5:bind scope gate. fast-socks5 parses the
command but refuses it (lib.rs:384-386, server.rs:813-816); the
wrapper drives the typestate directly and owns the whole BIND shape:
zero fast-socks5 upstream ask for BIND itself (the typestate's
read_command() returns (cmd, target) and the wrapper branches;
reply_success's sock_addr parameter is the reply#1 carrier;
pump_bidi is the data plane).
What was validated
- The BIND phase model was confirmed end to end. The RFC
conversation and the data plane ride the same channel
BiStream: the typestate runsaccept_no_auth → read_command(BIND arrives asSocks5Command::TCPBind), reply#1 carriesBND.ADDR, the quiet wait selects the one inbound accept against the control stream, reply#2 carries the peer address, andpump_bidi(inline, R-02) two-pumps the accepted connection against the channel. Zero per-packet headers in-band; the CONNECT pass-through conclusion extends literally (BIND's post-reply#2 stage is a plain byte stream). - The local accept variant (variant 1, the faithful RFC shape),
confirmed. A real
TcpListenerper BIND session: reply#1 carries the listener's actuallocal_addr(a real network-B address); the "app server" dials the advertised address; reply#2 carries the real peer address (accept()'s peer, byte-exact; the test asserts the app's ephemeral port); the data plane echoes both directions. Vanilla front-door clients work (test 6). - The composed accept variant (variant 2) confirmed, with the
sequencing finding below. The listener lives on an alktunnels
listen-tunnel resource (
register_tunnel_listen_openable+AcceptQueue+ a real far-sideTcpListener); the SOCKS5 producer opens the tunnel during the quiet wait and the popped accept's halves become the data-plane peer. The full stack round-trips: SOCKS5 consumer → SOCKS5 producer → composed accept →alk/tunnellisten channel → tunnel producer → real listener → the app server's connection → back through the pump. Per-listener ACL for free (the tunnel resource's ACL governs who can mint the listener), hub-relayable, the variant's whole point. - The scope gate is confirmed. The pump handler reads the
open-op
AuthContext's identity and requiressocks5:bindbefore serving a BIND; an identity without it gets the RFC-faithfulConnectionNotAllowed(0x02) refusal, while CONNECT still works for the same identity (the gate distinguishes ingress listeners from proxy egress; one identity having egress does not imply ingress). The gate is the open-op identity seam consumed at command-dispatch time; no new ops, no params change, no one-way-door exposure. - The quiet wait's semantics are confirmed. A control byte after
reply#1 is protocol garbage (the ASSOCIATE post-reply semantic
BIND shares; RFC 1928 §3's "BIND: no further control data"
reading); the session is refused with no data plane. A client
that BINDs and vanishes (EOF mid-wait) tears the listener down
with its session; the accept future (which owns the listener)
is dropped by the
select!branch; a fresh dial to the advertised address fails with connection refused. The session's natural end (half-close post-data-plane) releases the listener the same way. - Failure mapping is confirmed RFC-faithful. A factory failure (pre-reply#1: an unbindable port, a held port) maps to reply#1 with the factory error's reply code (shortage → GeneralFailure); an accept-stage failure (post-reply#1) maps to reply#2 with the mapped refusal code and no data plane. No phantom listeners, no phantom data planes; refusals are typed end to end.
- One registration, all three commands, regression-guarded.
CONNECT (POC #1's data plane, unchanged) and UDP ASSOCIATE (the
POC #2 skeleton) still serve on the same
channels/socks5/subop after the BIND branch lands. The RFC 1928 command surface is now complete in the crate's shape: CONNECT + BIND + ASSOCIATE. - The vanilla front door is confirmed for BIND. The raw-pipe
bridge (POC #1's CONNECT front-door shape, reused verbatim) carries
a full vanilla BIND conversation: an in-process RFC 1928 client
(greeting → BIND command → reply#1 → reply#2 → data plane) through
a
copy_bidirectionalpipe onto the channel. Zero protocol translation at the door; the RFC conversation passes through transparently (0 B pass-through, exercised for the BIND conversation this time).
Structural findings (the load-bearing discoveries)
The address-first-vs-accept-first deadlock: the POC's one real
discovery, and the composed variant's sequencing is now pinned.
SOCKS5 BIND is address-first (reply#1 must carry BND.ADDR before
the app server can dial), while alktunnels' listen establisher is
accept-first (its establishment phase pops an already-accepted
connection, so the open op blocks until one arrives). The first cut
opened the listen-tunnel in the factory call (before reply#1) and
deadlocked into the establishment timeout: the app server cannot dial
until reply#1 tells it the address, and the open cannot complete until
it dials. The resolution: reply#1 first, the tunnel open rides the
accept future. The quiet wait is exactly where the establisher's
accept-pop block belongs. What remains upstream-blocked is learning
the bound address without consuming an accept (ledger ask #1): the
stand-in is the factory's carried bind_addr (the expected
local_addr the far-side real listener reports).
Consequence for the ADR: variant 2 needs TWO upstream asks, not one: (1) listen-addr inspection without consuming an accept (the address half), and (2) accepted handles carrying peer metadata (the reply#2 half). Both are additive surface additions, not changes; the ask ledger below records both with the evidence.
Establishment-bound interaction (the composed variant's wait
bound). The accept future's tunnel open runs under the listen
establisher's default 10s establishment bound; a BIND wait longer
than that resolves as a refusal (reply#2, GeneralFailure via the
mapped open error). Correct-by-backstop, but a real BIND deployment
(FTP control sessions idle far longer than 10s) needs either a raised
bound (the open-op's timeout parameter; a registration choice, no
wire change) or the accept future structured so the bound covers only
the open, not the wait (POC note: the bound covered the whole accept
here; the crate's shape should bound only the open-op round trip).
Phase 1 pins this with the registration posture.
The teardown-at-birth lesson repeats, and the fix is structural.
The composed variant's first working cut dropped the
TakenHalves::session (the channel-entry owner) while destructuring
it; the tunnel EOF'd at birth and the data plane read early eof
(POC #2's findings predicted exactly this: "the session must live
inside the table entry; the compiler should enforce it"). The fix:
BindTargetHandle carries _session: Option<TunnelSession>; the
owner rides the handle and drops with it. This is now the third
witness of the pattern (POC #2's egress table, alktunnels ADR-005's
teardown contract, here); the crate's Phase 1 shape should make the
plan payload own its channel handles structurally, never by let-binding.
Peer-address fidelity is lost through the composed path (ledger
ask #2). The popped accept handle (alktunnels::TargetHandle) carries
boxed halves only; the real peer address exists at the tunnel
producer's listener; in-band the composed variant reports the sentinel
(0.0.0.0:0) for reply#2 (the handle-swap reading applied to BIND:
the reply is RFC-compatibility surface; the data plane is the
channel). The local variant reports the real peer (no hop). Ledger
item #2 is the fidelity ask.
The quiet wait is select!-single-shot, not a loop (unlike
ASSOCIATE's stage): the accept resolves at most once, and the control
stream's first byte ends the session (garbage). A loop was
unnecessary; clippy's never_loop caught the first cut's vestigial
loop; the phase model's clean statement is that BIND's quiet wait
has exactly two exits (the accept, or the control stream's
EOF/garbage/error). The data plane starts only from the accept
branch.
BindAccept's future-ownership shape is the accept-side twin of
the egress's readiness contract. The accept future owns the
listener (local variant) or the tunnel-open + popped halves (composed
variant); the quiet wait's select! drops it on the control-stream
branch, and that drop IS the client-gone teardown. The crate's Phase 1
equivalent: the accept callback's future must own its resources
end-to-end (the same ownership lesson as POC #2's egress, mirrored).
Wire/API notes for the wrapper
- fast-socks5's client
request()reads exactly one reply: BIND's reply#2 is hand-read with fast-socks5's own publicread_addresscodec (reply#1's codec applied again; no second codec hand-rolled). The consumer's reply#2 reader (read_reply2) is the Phase 1 client-session sketch. Socks5ServerProtocol::reply_success(sock_addr)is reply#1's carrier: thesock_addrparameter carriesBND.ADDRverbatim (the local variant's real listener; the composed variant's expected far-side address). No upstream change needed.- fast-socks5's
new_replyhelper is private; the BIND reply#2 writer hand-rolls the 10-byte reply (VER, REP, RSV, ATYP, addr, port; 10 bytes v4 / 22 v6). Ledger ask #3 (fast-socks5, courtesy): publishnew_reply(or areply(code, addr)constructor) so embedders don't re-encode replies. (2026-09-20: rides the inline vendor; see §ledger statuses.) ToTargetAddr::to_target_addr()(notFrom) is theSocketAddr → TargetAddrconversion;TargetAddr::fromdoes not exist (the POC hit this; the wrapper's accept layer will too).- The listen establisher's default 10s bound covers the composed variant's whole accept future in this POC's shape; see the establishment-bound finding above for the Phase 1 posture.
- The scope gate is read from the
OpenHandler'sAuthContext(CF-005/CF-006's per-call opener identity), the same seam POC #1 validated for session-gating, now consumed for a command-class distinction.socks5:bindas a scope string follows thesocks5:openconvention (Phase 1 pins the exact convention).
Upstream-ask ledger (the one-version alktunnels bump)
Ledger statuses (2026-09-20, the graduation landed). The user's
standing note held: alktunnels 0.2.0 (ADRs 007/008, over alkcall
0.8.0) shipped in one version bump, but adopted the ssh
forwarded-tcpip-shaped alternative over the per-ask carriers, so
both asks are superseded in shape, not implemented as posed:
- Listen-addr inspection without consuming an accept: dissolved.
The bind-first establisher (
bindfirst_establisher, ADR-008 §1) binds during establishment, so the open-op reply natively carries the real OS-chosen bound address (theboundreply field via alkcall 0.8.0'sEstablishment::with_reply_field, ADR-049 amendment 3; consumed viaopen_channel_with_reply/TunnelListener::bound()). There is no accept to avoid consuming; this ask's stand-in (the factory's carriedbind_addr) is retired, as is the POC's "open rides the accept future" sequencing workaround, and the 10s establishment-bound finding dissolves with it (the accept wait lives in the pump phase now). The accept-first surface (listen_establisher+AcceptQueue) remains in 0.2.0 unchanged for the accept-as-open shape. - Accepted handles carrying peer metadata: landed at the wire's
native moment. The forwarded op (
channels/tunnel/forwarded, ADR-008 §3) carries{listen, substrate, peer}params per accept;TargetHandlegained no peer field; the graduation chose the forwarded-op shape over this item's option-i carrier. Correlation is a producer-minted u32 token (Amendment 1, 2026-09-18), not the originally-planned channel-ID key. - (fast-socks5, courtesy)
new_replyis private; publish it (or a constructor) so embedders don't re-encode the reply wire form. Also still open from POC #4: thenetfeature gate and therun_udp_proxy_customunconditional-bind seam. (Posture note, 2026-09-20: fast-socks5 is third-party upstream and these are no longer asks; all three move into the inline vendor, OQ-SK-04 resolved. 2026-09-21: under the extraction (ADR-011 §Amendment),new_replyis the one that rides in-tree; thenetgate and the bind seam dissolve with the kernel-net region, which is not copied.)
The POC's evidence stands as the record of what was validated against
alktunnels 0.1.0; the Phase 1 shape composes variant 2 against 0.2.0
(register_tunnel_bindfirst_openable + BindFirstInjections).
The user's standing note: collect alktunnels additions so one version bump ships them all. The POC ran against published alktunnels 0.1.0 (no fork needed; the listen surface is complete there), and the asks are additive surface, discovered in priority order:
- Listen-addr inspection without consuming an accept (the
address half of address-first-vs-accept-first). The plan carries
local_addr+ an inspect-only open, or alisten/addrquery op (OQ-SK-08 variant 2's original blocker, now POC-evidenced: the factory's carriedbind_addrstand-in is the exact shape the ask retires). Evidence: the composed accept's reply#1 advertises an expected address; without the ask, a producer whose listener binds an OS-chosen port cannot advertise it before the accept. - Accepted handles carrying peer metadata (the reply#2 half).
AcceptFn'sTargetHandlecarries boxed halves only; the real peer address exists at the tunnel producer's listener. Evidence: the composed accept's reply#2 reports the sentinel; the RFC's "reply#2 carries the address of the incoming connection" is unfilled on the composed path. Shape:TargetHandlegaining an optionalpeer: Option<SocketAddr>(or a siblingAcceptedHandle); additive, no wire change. - (fast-socks5, courtesy)
new_replyis private; publish it (or a constructor) so embedders don't re-encode the reply wire form. Also still open from POC #4: thenetfeature gate and therun_udp_proxy_customunconditional-bind seam. (2026-09-21: under the extraction, ADR-011 §Amendment,new_replyis the one that rides in-tree; thenetgate and the bind seam dissolve with the kernel-net region, which is not copied.)
No asks block the POC's conclusions; 1–2 shape variant 2's full
fidelity, and the POC's stand-ins (carried bind_addr; sentinel
reply#2) are honest interim shapes.
OQ status after this POC
- OQ-SK-08 (BIND): POC-validated end to end: the phase model
extends (reply#1 → quiet wait → accept → reply#2 → pass-through);
both accept variants implemented (local faithful + composed
listen-tunnel); the scope gate (
socks5:bind) distinguishes ingress from egress; the address-first-vs-accept-first question is resolved in shape (reply#1 first, the open rides the accept future) with the two upstream asks pinned as the remaining blockers for variant 2's full fidelity. Remaining for Phase 1: ship-in-base- crate + default-accept-variant ADR (the support is additive, old consumers still get the RFC refusal, so nothing here is a one-way door); the establishment-bound posture; the exact scope-string convention. - OQ-SK-07: #1, #2, #4, #5 complete; #3 dropped (transferred with OQ-SK-06). The register is closed for this crate's Phase 0.
- OQ-SK-01 (dial policy): the accept seam is the dial-callback
shape's mirror (
BindAcceptFactory: function-not-trait, boxed halves, one instance per session). The no-trait precedent now holds for the accept plane too.
POC placement
Standalone crate (global workspace, /workspace/alksocks-bind-poc/)
per the placement conventions; it composes three published crates
(alkcall 0.7.1, alktunnels 0.1.0, fast-socks5 1.0.0) and needs no code
from this repo. Findings land here; the crate is the artifact.