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

210 lines
11 KiB
Markdown

---
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)
```rust
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](decisions/001-alpn-and-open-op.md) | ALPN + op + params | `channels/socks5/sub`, trivial params, session-gate establisher |
| [002](decisions/002-protocol-base.md) | Protocol base | the forked typestate drives the conversation |
| [004](decisions/004-dial-callback-shape.md) | The seams | DialFn / EgressFactory / BindAcceptFactory; ownership rides the handle |
| [005](decisions/005-auth-split-by-door.md) | Auth | producer = identity seam; in-band NoAuth no-op |
| [006](decisions/006-access-control.md) | ACL | `socks5:open` + `socks5:bind` command gate; dialer-refuses |
| [007](decisions/007-bind-in-base.md) | BIND | the phase model; the accept variants |
| [008](decisions/008-provider-defaults.md) | Providers | local + composed, feature-gated |
| [011](decisions/011-inline-vendor.md) | The extraction | the typestate subset in-tree |
| [013](decisions/013-targeted-fork.md) | The targeted fork | the core is owned (`src/rfc1928/`) |
| [014](decisions/014-idle-timeout-posture.md) | 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](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](wire.md) (the normative wire),
[consumer.md](consumer.md) (the other half),
[ADR-014](decisions/014-idle-timeout-posture.md) (the
teardown-governed phase lifetimes)