--- status: draft last_updated: 2026-09-24 (the open handler's phase lifetimes carry the decided timeout posture — ADR-014; Phase 1 initial draft 2026-09-20) --- # alksocks — The Producer Half The producer exposes the SOCKS5 service as a produced, ACL-scoped resource: it registers the `channels/socks5/sub` open op (ADR-001) and serves RFC 1928 inside each opened channel. The layering is the POC #1/#2/#5-validated shape: **establisher = session gate** (the target is unknown at open time), **open handler = the RFC state machine + command dispatch** (the forked typestate driving, ADR-002), **I/O planes = the three seams** (dial/egress/accept, ADR-004). ## The Open Op and the Session-Gate Establisher Registration uses `ChannelCore::register_openable_with_establisher` (alkcall ADR-049) with: - the op spec `channels/socks5/sub` on the `alk/socks5` ALPN, `required_scopes = ["socks5:open"]` (`SOCKS5_OPEN_SCOPE`, ADR-006); - params schema `{type: object}`, `additionalProperties: false` (trivial — ADR-001); - the establisher: validates/records the opener identity (the CF-005/ CF-006 seam — ADR-005) and applies session-level policy. It runs `Establishment::default()`-shaped (no plan payload) — the dial happens post-establishment, so there is nothing to thread establisher→handler (POC #1's wire note). Failure semantics: a scope-less identity is `FORBIDDEN` at the registry ACL gate; establisher refusal is a typed `channel:open_failed` (`handler_error`-class or the mapped reason) — never a phantom channel. Session-level refusal is channels-level; target-level refusal is in-stream RFC (below). **Harness wiring requirement** (POC #1, POC #2 re-witnessed): the accept-side install hook must call `channel0_conn.set_identity (resolved)` before building the `CallConnection` — the transport identity does not flow to channel-0 dispatch automatically. A real mTLS transport resolves the peer here. Without it, scope-gated opens fail closed with `FORBIDDEN "authentication required"` — correct behavior, but the wiring requirement is implicit. ## The Open Handler: RFC Conversation + Command Dispatch The open handler runs the forked typestate over the channel's `BiStream` (`T`-generic — the POC #1 fit). Every phase is teardown-governed, never idle-expired (ADR-014): the handler parks on the handshake / stage reads like any other phase — no protocol-layer inactivity timer exists (the alkcall 10s establishment bound covers only the open-op round trip, ADR-049 §2); a silent peer holds the task + channel entry until the channels-level sentinels arrive, and the cap regime (ADR-040/041) bounds the aggregate. A deployment that wants an idle bound enforces it outside the protocol layer (an accept-path bound at a door; a caller-side timeout around a substrate-free `serve_socks5_over`): 1. **Negotiation**: `start(inner)` → `negotiate_auth(&methods)` — NoAuth as a no-op (ADR-005: the producer side is identity-only; authorization already happened at the open gate). 2. **Command read**: `read_command()` returns `(cmd, target)` — the dispatch point: - **CONNECT** → the command gate (`socks5:open` already checked at the open gate — no extra check) → invoke `DialFn(target)` (ADR-004). Success: `reply_success` then the pass-through data plane. Refusal: `reply_error` with the mapped RFC code (dial-failure → `ConnectionRefused` 0x05; scope-class refusal → `ConnectionNotAllowed` 0x02) — no phantom data plane. - **TCP BIND** → **the command gate**: require `socks5:bind` from the per-call opener identity (ADR-006) — an identity without it gets `ConnectionNotAllowed` (0x02) while CONNECT still serves the same identity (POC #5 test 4). Then the BIND shape (ADR-007, below). - **UDP ASSOCIATE** → `reply_success(sentinel)` (the RFC-compatibility handle, ADR-003) then the datagram stage. 3. **Data plane** per command (wire.md §The Data Plane): - CONNECT: `pump_bidi` inline inside the handler task (the returned `JoinHandle` tracks the data-plane lifetime — R-02, alkcall review 007, `/workspace/@alkdev/alkcall/docs/reviews/ 007-establishment-follow-ups-review.md`; early return = teardown-at-birth). - ASSOCIATE: the `select!`-driven datagram stage; the `EgressFactory` is called once per association; the stage owns the egress; stage exit drops it. Framing applied exactly once by the wire layer; the readiness contract (`Pending` when nothing is ready) is the seam's API invariant. - BIND: reply#1 → the quiet wait (`select!`-single-shot; the `BindAcceptFactory` future owns the listener) → reply#2 → `pump_bidi` against the accepted halves. ## The Three Seams (ADR-004) | Seam | Called | Returns | Refusal shape | |------|--------|---------|---------------| | `DialFn(target)` | per CONNECT, at command read | `TargetHandle {read, write}` (boxed halves, `+ Sync`) | RFC reply code | | `EgressFactory()` → egress | once per ASSOCIATE; stage owns it | per-association egress (`next_reply` readiness contract) | reply#1 mapped code (pre-reply failure) | | `BindAcceptFactory` | once per BIND session; quiet wait selects its future | accepted halves + peer | reply#1 (factory) / reply#2 (accept-stage) mapped codes | Contracts (POC-pinned, load-bearing): - **Ownership rides the handle structurally** — handle payloads own their channel sessions/sockets; the compiler should enforce it (no `_` bindings of owning values; the third teardown-at-birth witness). - **The egress speaks inner bytes** (`addr + data`); the wire layer frames once. - **The accept future owns its resources end-to-end**; the quiet wait's `select!` drop IS the client-gone teardown. - **No trait** — the re-evaluation threshold is three real implementations as trait impls (the alktunnels ADR-004 posture). ## Scope Gating (ADR-006) - `SOCKS5_OPEN_SCOPE = "socks5:open"` — the open op's `required_scopes`; egress capability (CONNECT + ASSOCIATE). - `SOCKS5_BIND_SCOPE = "socks5:bind"` — the BIND command gate, checked at command dispatch from the per-call opener identity; ingress capability, never implied by `socks5:open`. - Target policy: the dialer/egress/accept callbacks refuse (RFC reply codes); no allowlist hook in v1 (ADR-006/ADR-012). Whatever ACL governs the socks5 resource governs everything reachable through it, plus the callbacks' policy. - Optional: consult `OwnershipProvider` for resource ownership — a registration-time choice. ## Producer-Side Local Backend (feature `local`, optional) The optional shape where the producer serves from a real listener: a feature-gated backend binds `socks5://127.0.0.1:port` (or any address) and hands each accepted TCP connection to the landed substrate-free entry `Socks5Producer::serve_socks5_over` directly — the RFC conversation runs on the accepted socket (the NoAuth in-stream shape: greeting, command, reply, data plane, all on the accepted socket), and the seams dial/egress/accept locally. The same door shape in reverse: this is a *front door for the producer side* — vanilla clients reach a locally-served SOCKS5 resource through it (`src/local/producer_door.rs`, the `ProducerDoor`/builder/guard surface). A cred-bearing variant (RFC 1929) would wrap `accept_password_auth` before `serve_socks5_over` — a documented follow-up, not landed. The door's accept path owns the idle defense the protocol layer does not provide (ADR-014): an accepted connection that sends no first byte within a configured window is closed door-side (an accepted socket is not a channel — the cap regime does not bound it), plus the max-concurrent-sessions cap (an accept beyond the bound is refused door-side, no session spawned) — the two constructor knobs. The producer-side local *backend* for the I/O planes (the local-socket providers — TCP dial, UDP egress, BIND listener) is ADR-008's variant set, also feature `local`. The BIND command gate on this path is the builder's `bind_policy` (there is no open-op identity on an accepted socket — the accepted socket is the whole caller; the pump handler's identity-derived gate does not apply). ## Wiring (Assembly Layer) ```rust let producer = Socks5Producer::builder() .dial(dial_fn) // DialFn — required for CONNECT .egress(egress_factory) // EgressFactory — required for ASSOCIATE .accept(bind_accept_factory) // BindAcceptFactory — required for BIND .build(); // register(&core, ®istry, auth, witness) — the establisher rides // the registration (the optional witness is the CF-006 test probe). producer.register(&core, ®istry, auth, None)?; ``` Missing callbacks = those commands refuse RFC-faithfully (`CommandNotSupported`-class), never a panic. Substrate providers are feature-gated constructors over the same seams (ADR-008/ADR-009): `local::tcp_dial()`, `local::udp_egress()`, `local::bind_accept()`, `composed::direct_egress(...)`, `composed::bindfirst_accept(...)`. ## Design Decisions | ADR | Decision | Summary | |-----|----------|---------| | [001](decisions/001-alpn-and-open-op.md) | ALPN + op + params | `channels/socks5/sub`, trivial params, session-gate establisher | | [002](decisions/002-protocol-base.md) | Protocol base | the forked typestate drives the conversation | | [004](decisions/004-dial-callback-shape.md) | The seams | DialFn / EgressFactory / BindAcceptFactory; ownership rides the handle | | [005](decisions/005-auth-split-by-door.md) | Auth | producer = identity seam; in-band NoAuth no-op | | [006](decisions/006-access-control.md) | ACL | `socks5:open` + `socks5:bind` command gate; dialer-refuses | | [007](decisions/007-bind-in-base.md) | BIND | the phase model; the accept variants | | [008](decisions/008-provider-defaults.md) | Providers | local + composed, feature-gated | | [011](decisions/011-inline-vendor.md) | The extraction | the typestate subset in-tree | | [013](decisions/013-targeted-fork.md) | The targeted fork | the core is owned (`src/rfc1928/`) | | [014](decisions/014-idle-timeout-posture.md) | Idle-timeout posture | every phase teardown-governed; no protocol-layer idle bound; doors own the accept-path defense | ## Open Questions Open questions are tracked in [open-questions.md](open-questions.md). None currently blocking this document: the seams, gates, and BIND shape are decided (ADR-004/006/007); OQ-SK2-07 (virtual-subnet translation) rides the dial callback without touching this surface. ## References - POC #1/#2/#5 findings (the three seams' empirical records) - Upstream: alkcall ADR-047/049 (ops/establishment), ADR-050 (`pump_bidi`), ADR-042/051 (hub relay), CF-005/CF-006 (identity seam); alktunnels ADR-004 (no-trait), ADR-007/008 (direct op; bind-first + forwarded) - [wire.md](wire.md) (the normative wire), [consumer.md](consumer.md) (the other half), [ADR-014](decisions/014-idle-timeout-posture.md) (the teardown-governed phase lifetimes)