Files
alksocks/tasks/architecture/adr-014-idle-timeout-posture.md
T
glm-5.3-flash e196ed43e3 docs: remove em dashes from repo prose and forbid them going forward
- 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
2026-11-03 19:31:53 +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, 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

  • 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).