Review checkpoint closing batch 2 (the assembly layer). The gate opens: the never-binds posture, seam contracts, door fidelity, the bind-first graduation, feature-gate hygiene, and POC fidelity all verify clean; two findings were corrected in the review change. Findings corrected: - R-1 (major): the UDP front door's in-band decode created a fresh DatagramReader per call and kept only the first frame of a batch — POC #2's first-cut bug (reader state must persist across calls; batched datagrams were silently dropped, mid-frame EOF was silent). Fix: an owning InbandBridge (persistent reader + pending queue + fail-loud mid-frame EOF, the AssociateSession shape) threaded through the channel->socket pump; pinned by three bridge unit tests (which fail against the per-call reader) and an end-to-end doubled-echo door-udp test. - R-2 (convention 2): seven non-test expects -> typed errors — DoorBuildError (both door builders' .build() is now Result), ProducerDoorBuildError/ProducerDoorError (the bind decision is serve's typed error), the composed table invariant as an egress error, register_forwarded_serving -> Result (the alkcall diagnostic surfaced), TokenClientError (wrap on a spent client). New error types re-exported; test call sites updated. - R-3/R-4 (doc residues): producer.md's local-backend section described a channel pipe — the landed ProducerDoor serves serve_socks5_over on the accepted socket directly; associate_with_token's doc (the TCP door refuses ASSOCIATE, never calls it); SessionOwner's doc (the accept uses SyncHalves/ OwnedWrite). Verification: cargo test (default) green; cargo test --all-features green; cargo clippy --all-targets [--all-features] -- -D warnings clean; cargo fmt --check clean; both wasm checks clean (default + composed); local-on-wasm fails at mio as designed; cargo doc --no-deps clean. Task: tasks/implementation/review-assembly-layer.md (status: completed; the batch-2 gate opens release polish).
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/subon thealk/socks5ALPN,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):
- 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). - Command read:
read_command()returns(cmd, target)— the dispatch point:- CONNECT → the command gate (
socks5:openalready checked at the open gate — no extra check) → invokeDialFn(target)(ADR-004). Success:reply_successthen the pass-through data plane. Refusal:reply_errorwith the mapped RFC code (dial-failure →ConnectionRefused0x05; scope-class refusal →ConnectionNotAllowed0x02) — no phantom data plane. - TCP BIND → the command gate: require
socks5:bindfrom the per-call opener identity (ADR-006) — an identity without it getsConnectionNotAllowed(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.
- CONNECT → the command gate (
- Data plane per command (wire.md §The Data Plane):
- CONNECT:
pump_bidiinline inside the handler task (the returnedJoinHandletracks 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; theEgressFactoryis called once per association; the stage owns the egress; stage exit drops it. Framing applied exactly once by the wire layer; the readiness contract (Pendingwhen nothing is ready) is the seam's API invariant. - BIND: reply#1 → the quiet wait (
select!-single-shot; theBindAcceptFactoryfuture owns the listener) → reply#2 →pump_bidiagainst the accepted halves.
- CONNECT:
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'srequired_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 bysocks5: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
OwnershipProviderfor 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) — 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; 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, ®istry, auth, witness) — the establisher rides
// the registration (the optional witness is the CF-006 test probe).
producer.register(&core, ®istry, 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)