Files
alksocks/tasks/architecture/adr-014-idle-timeout-posture.md
T
glm-5.3-flash 986bd13ce7 adr-014: the idle-timeout posture — teardown-governed phases, no protocol-layer idle bound
- ADR-014 accepted (review 002 C-2): the pre-command handshake, the
  BIND quiet wait, and the association lifetime are all
  teardown-governed; the protocol layer never times out (option 1)
- the BIND relationship recorded as same regime (ADR-007 decided one
  phase's ends by designing BIND; ADR-014 records the inherited
  resolution — one principle, not two)
- the doors' accept-path obligations recorded pre-batch-2 (accept-stage
  idle bound, per-credential/per-peer caps, accept-queue bounds);
  revisitable additively (an optional producer-side idle seam would
  be config, not wire)
- spec lines carry the decided posture: wire.md (association-lifetime
  bullet, no-in-band-control bullet, preamble, frontmatter);
  producer.md (the open handler, the local-backend section, decision
  table, frontmatter); overview.md ADR index
- one upstream-fidelity correction: upstream's dial timeout maps to
  0x06 (TTL expired), not 0x01 as the review sketch suggested
- open-questions.md unchanged (C-2 was never tracked there)

Verification: cargo test green (357 passed), clippy -D warnings clean,
fmt clean, cargo doc clean, wasm check + clippy (wasm32) clean
2026-09-24 19:16:30 +00:00

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 — 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 — 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/composed door work (the batch-2 tasks may depend on this one)
  • 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).