- 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
8.2 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level |
|---|---|---|---|---|---|---|---|
| architecture/adr-014-idle-timeout-posture | ADR-014: the idle-timeout posture for the pre-command handshake and the association (review-002 C-2) | completed | narrow | medium | project | 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:
- 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.
- 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; 0x01GeneralFailureon 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
- ADR-014 exists at
docs/architecture/decisions/014-<slug>.md, status accepted, deciding explicitly between the two options (or a reasoned third) - The BIND quiet wait's ADR-007 relationship is stated (same regime or a principled difference)
- The doors' DoS posture is recorded (what the assembly layer must do if the protocol layer does not)
- The affected spec lines (wire.md, producer.md) carry the decided posture; no implicit residue
- The decision lands before any
local/composeddoor work (the batch-2 tasks may depend on this one) cargo doc --no-depsclean 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::timealready 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).