--- id: architecture/adr-014-idle-timeout-posture name: "ADR-014: the idle-timeout posture for the pre-command handshake and the association (review-002 C-2)" status: completed depends_on: [] scope: narrow risk: medium impact: project level: implementation --- ## Description Review 002's C-2 (major): the pre-command RFC handshake and the association have **no timeout surface**: a silent channel holds a spawned task and reader state indefinitely (bounded memory, unbounded time). The alkcall 10s establishment bound governs the *open-op call*, not the in-stream conversation (`src/producer.rs:183-191` parks on `accept_bi` → `accept_no_auth` → `read_command` with no timeout; the datagram stage's loop exits only on EOF/error/data). BIND's quiet wait got an explicit decision (ADR-007: teardown-governed, "FTP-length waits stay legal"); the handshake and the association lifetime got "inherited from the channel": implicit, never decided. The mitigations are real (the 256-channel cap bounds the count, memory per idle channel is bounded, teardown fires on channel close; nothing leaks, nothing grows), but the aggregate is: any identity with `socks5:open` can hold 256 parked tasks + 256 channel entries indefinitely *without sending a byte*. And batch 2's providers (`local`, `composed`) are about to route *vanilla clients* through these same phases via the front doors, where the peer is a kernel socket often outside alkcall's channel-cap regime entirely, so slowloris-shaped holds become the assembly layer's problem, and the protocol layer is where a timeout seam would live. **The deliverable is a decision, not code**: ADR-014 (`docs/architecture/decisions/014-*.md`), landing *before* the `local` doors exist. Decide between the review's two options: 1. **Idle timeout is the substrate's/assembly's job**: the protocol layer never times out (consistent with "no in-band control vocabulary" and association-lifetime-equals-channel); record the DoS posture explicitly (the cap bounds it; doors apply their own accept timeouts). Cheapest, defensible. 2. **A producer-side idle bound**: an optional per-session inactivity timeout at the pump level (config on `Socks5ProducerBuilder`), enforced by racing the handshake/stage reads with a sleep timer; a timeout = clean EOF-shaped teardown, no reply (the RFC has no timeout reply code; 0x01 `GeneralFailure` on a pre-reply timeout is the closest and is what upstream's connect-timeout path surfaces). More work; touches no wire format. The ADR must state, whichever option wins: the BIND quiet wait's existing decision (ADR-007) and how the new decision relates to it (same regime or different, and why the difference is principled); the DoS posture in the doors' terms (what a `local` door's accept path must do if the protocol layer does not); and whether the decision is revisitable post-batch-2 or is itself a one-way door (it should not be, since a timeout seam is additive, but say so). Also update `docs/architecture/open-questions.md` if C-2 was tracked there, and the affected spec lines (wire.md §UDP ASSOCIATE's association-lifetime bullet; producer.md's pump description) to carry the decided posture rather than the implicit one. ## Acceptance Criteria - [x] ADR-014 exists at `docs/architecture/decisions/014-.md`, status accepted, deciding explicitly between the two options (or a reasoned third) - [x] The BIND quiet wait's ADR-007 relationship is stated (same regime or a principled difference) - [x] The doors' DoS posture is recorded (what the assembly layer must do if the protocol layer does not) - [x] The affected spec lines (wire.md, producer.md) carry the decided posture; no implicit residue - [x] The decision lands before any `local`/`composed` door work (the batch-2 tasks may depend on this one) - [x] `cargo doc --no-deps` clean if any doc-comment carries the posture; the full verification sweep green ## References - docs/reviews/002-protocol-core-implementation-review.md §C-2 (the finding, the task's source of truth) - docs/architecture/decisions/007-bind-in-base.md (the quiet wait's existing decision, the contrast case) - docs/architecture/decisions/008-provider-defaults.md, 009-feature-gates.md (the batch-2 provider posture the decision precedes) - docs/architecture/wire.md §UDP ASSOCIATE (association lifetime = channel lifetime; the contract the decision qualifies) - The POC findings (the stage's measured shapes, the "sufficient for relay postures" lineage) ## Notes > Filled by the implementation agent (2026-09-24): - **Option 1 won**: the protocol layer never times out; every phase (pre-command handshake, BIND quiet wait, association lifetime) is teardown-governed (EOF / garbage / error sentinels), never idle-expired. No code changed; the posture is recorded. - **The sharpening argument** (why a handshake-only bound is incoherent): every post-reply phase *legally* parks indefinitely on a silent peer: an idle CONNECT tunnel, a quiet association, BIND's quiet wait (ADR-007). The pre-command hold costs exactly what those holds cost (one parked task + one channel entry); a handshake-only bound protects a sliver while the dominant hold stays contractually open, and a bound on both is a full inactivity timeout, which contradicts the landed contracts (association lifetime = channel lifetime; "FTP-length waits stay legal," ADR-007). The unbounded-time aggregate is a property of every idle session, bounded by the cap regime (ADR-040/041), not by a timer. - **One upstream-fidelity correction to the review's sketch**: upstream's connect-timeout path maps its *dial* timeout to `ReplyError::ConnectionTimeout` → 0x06 (TTL expired), not 0x01; verified in the reference checkout (`util/stream.rs`) and the forked table (`src/rfc1928/mod.rs`). Recorded in the ADR; relevant only if option 2 is ever built. - **open-questions.md needed no update**: C-2 was tracked as a review finding (tasks/), not as an OQ; no OQ entry exists for it. - **The doors' obligations are recorded pre-batch-2** (the "inherits this bound knowingly" pattern, wire.md §UDP ASSOCIATE's): an accept-stage idle bound (the slowloris sliver: an accepted socket is not a channel, the cap regime does not bound it), per-credential/per-peer session caps (the cred-bearing door's config table, ADR-005, is the knob), and accept-queue/ relay-task bounds. Same obligations for the producer-side local backend's accept loop and a composed bind-first listener's front. - **Revisitability**: not a one-way door: a producer-side idle seam is additive (builder config defaulting to `None` = this posture; `tokio::time` already in the wasm-clean subset; timeout = EOF- shaped teardown). Revisit trigger recorded: a deployment with a peer class outside both the cap regime and any door policy, with measured evidence. ## Summary ADR-014 landed (`docs/architecture/decisions/014-idle-timeout-posture.md`, status accepted, 2026-09-24), deciding review 002 C-2: **option 1, the protocol layer never times out**. The pre-command handshake, the BIND quiet wait, and the association lifetime are all teardown-governed; the BIND relationship is recorded as *same regime* (ADR-007 decided one phase's ends by designing BIND; ADR-014 decides the rest by recording the inherited resolution. One principle: the protocol layer cannot distinguish malicious silence from a slow legitimate peer, so timeout policy belongs to the deployment-facing layer). The DoS posture is explicit and layered (ACL → per-connection cap → per-identity cap → door/dial policy; the ADR-010 pattern), with the doors' accept-path obligations recorded before any batch-2 door exists. The decision is revisitable additively (not a one-way door). Spec lines updated: wire.md §UDP ASSOCIATE's association-lifetime bullet + the phase-model "no in-band control vocabulary" bullet + the preamble's ADR list + frontmatter; producer.md §The Open Handler's pump description + the local-backend section + the decision table + frontmatter; overview.md's ADR index. No code changes; no OQ update needed (C-2 was never tracked there). `cargo doc --no-deps` clean; full verification sweep green (`cargo test` 348+ green, clippy, fmt, wasm check).