--- id: implementation/core-producer-bind name: "Core: producer BIND, the phase model + the socks5:bind command gate" status: completed depends_on: [implementation/core-producer-connect] scope: moderate risk: medium impact: component level: implementation --- ## Description Implement the TCP BIND producer path in `src/producer.rs` per wire.md §BIND and ADR-007: replace the dispatch arm's refusal with the BIND phase model (POC #5's validated scope), plus the `socks5:bind` command gate (ADR-006). Deliverables (port from `/workspace/alksocks-bind-poc/src/producer.rs` + `accept.rs`, adapted to the crate's seam types): 1. **The command gate**: at BIND dispatch, require `socks5:bind` from the per-call opener identity (the open-op `AuthContext`'s identity of the open-op `AuthContext`, CF-005/CF-006). An identity without it gets the RFC-faithful `ConnectionNotAllowed` (0x02) refusal while CONNECT still serves the same identity (POC #5 test 4). The gate is the open-op identity seam consumed at command-dispatch time: no new ops, no params change. 2. **The BIND phase model**: - **reply#1 carries BND.ADDR**: the `BindAcceptFactory` is called first; its `BindAccept.bind_addr` is reply#1's carrier (the local variant's real listener; the sentinel on the wrapper-aware in-band path when no factory is wired, per ADR-007 variant 3). - **The quiet wait**: `select!`-single-shot with exactly two exits, the one inbound accept (the `BindAccept` future) or the control stream's EOF/garbage/error (the client-gone teardown riding the accept future's drop). **Not a loop** (the POC's `never_loop` finding: exactly two exits). Any byte in the quiet wait is protocol garbage → the session ends with no data plane. - **reply#2 carries the peer address** (the accepted handle's peer; sentinel `0.0.0.0:0` wrapper-aware). - **Phase 2 is raw pass-through** (`pump_bidi` inline, the CONNECT shape) between the channel and the accepted halves. 3. **Failure mapping is RFC-faithful end to end**: factory failure (pre-reply#1) → reply#1 with the mapped code; accept-stage failure (post-reply#1) → reply#2 with the mapped code, no data plane. No phantom listeners, no phantom data planes. 4. **The establishment bound covers only the open-op round trip** (ADR-007): the accept wait lives in the teardown-governed pump phase: the quiet wait must not run under the establisher's 10s bound (FTP-length BIND waits stop resolving as spurious establishment refusals). 5. **Missing-accept refusal**: a registration without a `BindAcceptFactory` serves the wrapper-aware in-band default (sentinel reply#1 + sentinel reply#2, ADR-007 variant 3) or refuses; the ADR names variant 3 as the in-band default when neither provider is wired; implement variant 3 as the no-factory default and document the choice. 6. **Tests** (via the harness + raw RFC client): BIND → reply#1 → (the harness accept resolves) → reply#2 with the peer → pass-through echo both directions; the command gate (a `socks5:open`-only identity gets 0x02 on BIND while CONNECT still works, the POC's test 4); quiet-wait garbage byte ends the session with no data plane; client-gone teardown (EOF mid-wait drops the accept future, so a fresh dial to the advertised address fails); factory failure → reply#1 mapped code; accept-stage failure → reply#2 mapped code, no data plane; CONNECT + ASSOCIATE regression guard (one registration, all three commands). ## Acceptance Criteria - [x] The command gate: `socks5:bind` required at BIND dispatch; `ConnectionNotAllowed` refusal; CONNECT unaffected (tested) - [x] The phase model: reply#1 → quiet wait (single-shot, two exits) → accept → reply#2 → `pump_bidi` pass-through - [x] Two-stage failure mapping (factory → reply#1; accept-stage → reply#2), tested - [x] The accept wait is NOT under the establishment bound - [x] No-factory default = wrapper-aware variant 3 (documented) - [x] Three-command regression guard passes - [x] `cargo test` green; clippy/fmt clean; both wasm checks clean ## References - docs/architecture/wire.md §BIND (the normative phases) - docs/architecture/decisions/007-bind-in-base.md (the phase model + the three variants) - docs/architecture/decisions/006-access-control.md (the command gate) - POC sources: `/workspace/alksocks-bind-poc/src/producer.rs`, `/workspace/alksocks-bind-poc/src/accept.rs` (the quiet-wait + failure-mapping origin) ## Notes - **The gate seam**: `serve_socks5_over` gained a `can_bind: bool` parameter: the pump handler resolves it from the open-op `AuthContext`'s per-call opener identity (CF-005/CF-006: the `socks5:bind` scope, never implied by `socks5:open`); the substrate-free entry takes the resolved boolean explicitly, so the gate is caller-policy at the raw-entry shape and identity-resolved at the channels shape. A scope-less BIND gets `ConnectionNotAllowed` (0x02) with no factory call (the gate precedes the seam, so no phantom listener), while CONNECT still serves the same identity (tested unit + over-channels). - **Variant 3 as the no-factory default** (documented in `serve_tcp_bind`'s docs): when no `BindAcceptFactory` is wired, the BIND arm synthesizes a `BindAccept`: the sentinel reply#1 (via `sentinel_bind_peer_addr()` = `0.0.0.0:0` riding the accept future's instant resolution, so the full phase model is preserved: reply#1 → quiet wait → reply#2 → pass-through) + the sentinel reply#2 peer. The channel is the connection (the handle-swap reading); the in-band halves are `tokio::io::empty`/`sink`, and the data plane exists and EOFs. - **The quiet wait** is a single-shot `select!` with exactly two exits (`bind_quiet_wait_and_serve`): the accept branch (its drop IS the client-gone teardown, verified by an owner-guard test) or the control stream's read (EOF = clean end, any byte = protocol garbage → `UnexpectedUdpControlGarbage`, no reply#2, no data plane). Not a loop (the POC's `never_loop` finding). - **Reply#2** uses the fork's published `new_reply` (carried change #2's payoff: no local re-encode); the peer address carries as `TargetAddr::Ip` → the real socket addr, domain-form peer → v4 unspecified with the port. - **Failure mapping**: factory failure → reply#1's mapped code, then `Bug("bind factory refused in-stream")` (surfaced in-stream, mirroring CONNECT's dial shape); accept-stage failure → reply#2's mapped code, then `Bug("bind accept refused in-stream")`. No phantom listeners, no phantom data planes. - **Establishment bound**: structurally satisfied. The quiet wait runs inside the pump handler's spawned task, which the alkcall open wrapper never bounds (the establisher's 10s bound is pre-handler); the harness round-trip + the substrate-free "parked accept still answering after a sleep" test pin the shape. - **Test determinism**: the quiet-wait tests use a parked accept factory (`std::future::pending`) so the garbage/EOF branch wins deterministically (an instantly-resolving accept races the garbage branch, and the client-gone test's owner flag witnesses the future's drop). - **Harness additions**: `bind_scoped_identity()` (both scopes, since a session needs the open gate AND the command gate) + BIND integration tests: round-trip over channels, the gate over channels, both-scopes identity serves, quiet-wait garbage over channels (the client EOF + R-02 pump-completion shape), the two-stage failure mapping over channels, the no-factory variant 3 over channels, and the three-command regression guard. The stale "ASSOCIATE/BIND refuse 0x07" test became ASSOCIATE-only (BIND now serves under the gate). - **dev-dependency**: `tracing-subscriber` (dev-only) added for the over-channels teardown debugging; `init_trace()` helper is used in two tests (harmless observability, no behavior pin). ## Summary Implemented the TCP BIND producer path (`src/producer.rs`): the `socks5:bind` command gate (ADR-006, resolved from the open-op identity at the pump handler, passed as `can_bind` to the substrate-free entry), the phase model (ADR-007 / POC #5: accept factory → reply#1 carrying BND.ADDR → the single-shot quiet wait (two exits; any byte = garbage, EOF = client-gone teardown riding the accept future's drop) → reply#2 carrying the peer → `pump_bidi` inline), two-stage RFC-faithful failure mapping (factory → reply#1, accept-stage → reply#2, no phantom listeners/data planes), and the no-factory wrapper-aware variant 3 default (documented). 16 unit tests + 7 harness integration tests cover the happy path, the gate (0x02 while CONNECT serves), quiet-wait garbage, client-gone teardown (owner-flag witness), both failure stages, the establishment-bound independence, and the three-command regression guard. `cargo test` (96+26+37), clippy `-D warnings` (native + wasm), fmt, and both wasm checks clean.