Files
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

11 KiB


status: draft last_updated: 2026-09-24 (the open handler's phase lifetimes carry the decided timeout posture, ADR-014; Phase 1 initial draft 2026-09-20)

alksocks: The Producer Half

The producer exposes the SOCKS5 service as a produced, ACL-scoped resource: it registers the channels/socks5/sub open op (ADR-001) and serves RFC 1928 inside each opened channel. The layering is the POC #1/#2/#5-validated shape: establisher = session gate (the target is unknown at open time), open handler = the RFC state machine + command dispatch (the forked typestate driving, ADR-002), I/O planes = the three seams (dial/egress/accept, ADR-004).

The Open Op and the Session-Gate Establisher

Registration uses ChannelCore::register_openable_with_establisher (alkcall ADR-049) with:

  • the op spec channels/socks5/sub on the alk/socks5 ALPN, required_scopes = ["socks5:open"] (SOCKS5_OPEN_SCOPE, ADR-006);
  • params schema {type: object}, additionalProperties: false (trivial, ADR-001);
  • the establisher: validates/records the opener identity (the CF-005/ CF-006 seam, ADR-005) and applies session-level policy. It runs Establishment::default()-shaped (no plan payload): the dial happens post-establishment, so there is nothing to thread establisher→handler (POC #1's wire note).

Failure semantics: a scope-less identity is FORBIDDEN at the registry ACL gate; establisher refusal is a typed channel:open_failed (handler_error-class or the mapped reason), never a phantom channel. Session-level refusal is channels-level; target-level refusal is in-stream RFC (below).

Harness wiring requirement (POC #1, POC #2 re-witnessed): the accept-side install hook must call channel0_conn.set_identity (resolved) before building the CallConnection; the transport identity does not flow to channel-0 dispatch automatically. A real mTLS transport resolves the peer here. Without it, scope-gated opens fail closed with FORBIDDEN "authentication required": correct behavior, but the wiring requirement is implicit.

The Open Handler: RFC Conversation + Command Dispatch

The open handler runs the forked typestate over the channel's BiStream (T-generic, the POC #1 fit). Every phase is teardown-governed, never idle-expired (ADR-014): the handler parks on the handshake / stage reads like any other phase; no protocol-layer inactivity timer exists (the alkcall 10s establishment bound covers only the open-op round trip, ADR-049 §2); a silent peer holds the task + channel entry until the channels-level sentinels arrive, and the cap regime (ADR-040/041) bounds the aggregate. A deployment that wants an idle bound enforces it outside the protocol layer (an accept-path bound at a door; a caller-side timeout around a substrate-free serve_socks5_over):

  1. Negotiation: start(inner) → negotiate_auth(&methods); NoAuth as a no-op (ADR-005: the producer side is identity-only; authorization already happened at the open gate).
  2. Command read: read_command() returns (cmd, target), the dispatch point:
    • CONNECT → the command gate (socks5:open already checked at the open gate, no extra check) → invoke DialFn(target) (ADR-004). Success: reply_success then the pass-through data plane. Refusal: reply_error with the mapped RFC code (dial-failure → ConnectionRefused 0x05; scope-class refusal → ConnectionNotAllowed 0x02); no phantom data plane.
    • TCP BIND → the command gate: require socks5:bind from the per-call opener identity (ADR-006); an identity without it gets ConnectionNotAllowed (0x02) while CONNECT still serves the same identity (POC #5 test 4). Then the BIND shape (ADR-007, below).
    • UDP ASSOCIATE → reply_success(sentinel) (the RFC-compatibility handle, ADR-003) then the datagram stage.
  3. Data plane per command (wire.md §The Data Plane):
    • CONNECT: pump_bidi inline inside the handler task (the returned JoinHandle tracks the data-plane lifetime: R-02, alkcall review 007, /workspace/@alkdev/alkcall/docs/reviews/ 007-establishment-follow-ups-review.md; early return = teardown-at-birth).
    • ASSOCIATE: the select!-driven datagram stage; the EgressFactory is called once per association; the stage owns the egress; stage exit drops it. Framing applied exactly once by the wire layer; the readiness contract (Pending when nothing is ready) is the seam's API invariant.
    • BIND: reply#1 → the quiet wait (select!-single-shot; the BindAcceptFactory future owns the listener) → reply#2 → pump_bidi against the accepted halves.

The Three Seams (ADR-004)

Seam Called Returns Refusal shape
DialFn(target) per CONNECT, at command read TargetHandle {read, write} (boxed halves, + Sync) RFC reply code
EgressFactory() → egress once per ASSOCIATE; stage owns it per-association egress (next_reply readiness contract) reply#1 mapped code (pre-reply failure)
BindAcceptFactory once per BIND session; quiet wait selects its future accepted halves + peer reply#1 (factory) / reply#2 (accept-stage) mapped codes

Contracts (POC-pinned, load-bearing):

  • Ownership rides the handle structurally: handle payloads own their channel sessions/sockets; the compiler should enforce it (no _ bindings of owning values; the third teardown-at-birth witness).
  • The egress speaks inner bytes (addr + data); the wire layer frames once.
  • The accept future owns its resources end-to-end; the quiet wait's select! drop IS the client-gone teardown.
  • No trait: the re-evaluation threshold is three real implementations as trait impls (the alktunnels ADR-004 posture).

Scope Gating (ADR-006)

  • SOCKS5_OPEN_SCOPE = "socks5:open": the open op's required_scopes; egress capability (CONNECT + ASSOCIATE).
  • SOCKS5_BIND_SCOPE = "socks5:bind": the BIND command gate, checked at command dispatch from the per-call opener identity; ingress capability, never implied by socks5:open.
  • Target policy: the dialer/egress/accept callbacks refuse (RFC reply codes); no allowlist hook in v1 (ADR-006/ADR-012). Whatever ACL governs the socks5 resource governs everything reachable through it, plus the callbacks' policy.
  • Optional: consult OwnershipProvider for resource ownership, a registration-time choice.

Producer-Side Local Backend (feature local, optional)

The optional shape where the producer serves from a real listener: a feature-gated backend binds socks5://127.0.0.1:port (or any address) and hands each accepted TCP connection to the landed substrate-free entry Socks5Producer::serve_socks5_over directly: the RFC conversation runs on the accepted socket (the NoAuth in-stream shape: greeting, command, reply, data plane, all on the accepted socket), and the seams dial/egress/accept locally. The same door shape in reverse: this is a front door for the producer side; vanilla clients reach a locally-served SOCKS5 resource through it (src/local/producer_door.rs, the ProducerDoor/builder/guard surface). A cred-bearing variant (RFC 1929) would wrap accept_password_auth before serve_socks5_over, a documented follow-up, not landed.

The door's accept path owns the idle defense the protocol layer does not provide (ADR-014): an accepted connection that sends no first byte within a configured window is closed door-side (an accepted socket is not a channel; the cap regime does not bound it), plus the max-concurrent-sessions cap (an accept beyond the bound is refused door-side, no session spawned); these are the two constructor knobs.

The producer-side local backend for the I/O planes (the local-socket providers: TCP dial, UDP egress, BIND listener) is ADR-008's variant set, also feature local. The BIND command gate on this path is the builder's bind_policy (there is no open-op identity on an accepted socket; the accepted socket is the whole caller, and the pump handler's identity-derived gate does not apply).

Wiring (Assembly Layer)

let producer = Socks5Producer::builder()
    .dial(dial_fn)                 // DialFn: required for CONNECT
    .egress(egress_factory)        // EgressFactory: required for ASSOCIATE
    .accept(bind_accept_factory)   // BindAcceptFactory: required for BIND
    .build();
// register(&core, &registry, auth, witness): the establisher rides
// the registration (the optional witness is the CF-006 test probe).
producer.register(&core, &registry, auth, None)?;

Missing callbacks = those commands refuse RFC-faithfully (CommandNotSupported-class), never a panic. Substrate providers are feature-gated constructors over the same seams (ADR-008/ADR-009): local::tcp_dial(), local::udp_egress(), local::bind_accept(), composed::direct_egress(...), composed::bindfirst_accept(...).

Design Decisions

ADR Decision Summary
001 ALPN + op + params channels/socks5/sub, trivial params, session-gate establisher
002 Protocol base the forked typestate drives the conversation
004 The seams DialFn / EgressFactory / BindAcceptFactory; ownership rides the handle
005 Auth producer = identity seam; in-band NoAuth no-op
006 ACL socks5:open + socks5:bind command gate; dialer-refuses
007 BIND the phase model; the accept variants
008 Providers local + composed, feature-gated
011 The extraction the typestate subset in-tree
013 The targeted fork the core is owned (src/rfc1928/)
014 Idle-timeout posture every phase teardown-governed; no protocol-layer idle bound; doors own the accept-path defense

Open Questions

Open questions are tracked in open-questions.md. None currently blocking this document: the seams, gates, and BIND shape are decided (ADR-004/006/007); OQ-SK2-07 (virtual-subnet translation) rides the dial callback without touching this surface.

References

  • POC #1/#2/#5 findings (the three seams' empirical records)
  • Upstream: alkcall ADR-047/049 (ops/establishment), ADR-050 (pump_bidi), ADR-042/051 (hub relay), CF-005/CF-006 (identity seam); alktunnels ADR-004 (no-trait), ADR-007/008 (direct op; bind-first + forwarded)
  • wire.md (the normative wire), consumer.md (the other half), ADR-014 (the teardown-governed phase lifetimes)