20 Commits
Author SHA1 Message Date
glm-5.3-flash d789031863 release: v0.2.0 — the graduation (direct op + bind-first/forwarded metadata flow)
- README sync: the direct op (ADR-007), the bind-first establisher +
  the forwarded op (ADR-008), TunnelListener + the reply-field
  projection, the 0.2 summary section, the local bind-first helpers
- CHANGELOG: Unreleased cut to 0.2.0 (the graduation + the alkcall
  0.8.0 bump; compare links updated)
- Cargo.toml: 0.1.0 -> 0.2.0

Verification: cargo test (135 pass default / 162 with local), clippy
(all targets, -D warnings, both feature sets), fmt, doc, the
wasm32-unknown-unknown check + clippy, and cargo publish --dry-run
--allow-dirty all green
2026-09-20 09:30:21 +00:00
glm-5.3-flash 20834d297e docs: review 002 recorder — N-3/N-4 durable trail + N-2 ADR-008 widening + the no-action dispositions
- N-3: parse-before-accept telemetry note on make_forwarded_op_handler
  (the accepted-flag reads false for a malformed-params open by design)
- N-4: ADR-007 Consequences lines — the untagged-leniency-is-
  schema-gated paragraph + the overlapping-variants caution
- N-2: ADR-008 §1 widened to name the any-byte idle-read posture
  (the Ok(0)-only reading hangs on a write-happy peer)
- N-5: mint_token's doc comment records the 256-channel cap analysis
- The N-1/N-6/N-7 + G-1/G-3 no-action dispositions recorded in the
  task file (the tracker the task asked for)

Verified: fmt, clippy (native + wasm32), test, cargo doc — all green
2026-09-20 08:32:45 +00:00
glm-5.3-flash 7b06dc7aac docs: review 002 doc-sync — D-1 schema bounds in wire.md + D-2 ADR-007 oneOf erratum
- D-1: wire.md's Direct Op + Forwarded Op sections record the shipped
  schema constraints (port bounds, additionalProperties: false per
  branch, listen minimum: 1) and the registry-side gate ordering
- D-2: ADR-007 §2 corrected in place + a dated erratum (oneOf was
  wrong-shaped; validation is registry-side, not establisher-time);
  wire.md's mirrored sentence corrected

Verified: cargo doc --no-deps clean; cargo fmt --check
2026-09-20 08:31:47 +00:00
glm-5.3-flash f7dff25f9d docs: review 002 trivial batch — D-3 honest serde test name + D-4 CHANGELOG preamble trim
- D-3: inline the serde-layer check into the renamed
  serde_rejects_unknown_substrate_value; delete the overpromising
  jsonschema_valid helper (the registry's real schema gate stays
  pinned end-to-end by the integration probes)
- D-4: trim the stale Unreleased preamble to a one-liner (written
  before the six graduation implementation commits landed)

Verified: cargo fmt --check; cargo clippy --all-targets -D warnings;
cargo test (86 + 2 doc-tests) green
2026-09-20 08:30:48 +00:00
glm-5.3-flash 98afb092ce test: G-4 unix accept loop + G-5 unnamed fail-loud guards
- G-4: drive UnixListenerHalves::accept_loop — bind_unix + temp path,
  the loop exits on the queue-closed handler error after one unix
  client accept (the unix parity twin of the TCP accept-loop test;
  closes the largest single uncovered block, local/mod.rs 591-609).
- G-5: pin ADR-008 §3's two fail-loud sentences — bind_unix("") is
  the kernel autobind (no filesystem path) so local_addr fails loud
  (578-583), and a socket2 autobind client against a NAMED listener
  is refused by accept_pair's unnamed-peer guard (626-633; Linux —
  abstract addresses/autobind are Linux-shaped, mio maps any
  abstract-namespace peer to unnamed).

Verification: cargo test --features local green (162 tests);
clippy --all-targets -D warnings clean (native + wasm32-unknown-
unknown); cargo fmt --check clean.
2026-09-20 07:39:52 +00:00
glm-5.3-flash 7991bf6ad0 test: G-2 graceful-close far teardown + N-2 write-happy-peer posture
- G-2: observe TunnelListener::close's explicit shutdown sentinel at
  the far accept dispatcher — the accept loop cancels, the pushed
  accept is never consumed, the serving-side listen channel reaps
  (ADR-008 §1's teardown contract, graceful half).
- N-2 residue: pin the write-happy-peer teardown posture — a
  non-empty read on the idle listen stream tears the listener down
  (the fail-closed Ok(_) arm; driven via the raw client surface
  since the typed listener holds its write half idle by contract).

Verification: cargo test --features local green (163 tests + 2
doc-tests); clippy --all-targets -D warnings clean (native +
wasm32-unknown-unknown); cargo fmt --check clean.
2026-09-20 07:18:23 +00:00
glm-5.3-flash 57a728dc4b fix: bind-first dispatcher reads substrate from the plan (review 002 M-1)
- BindFirstPlan gains substrate: Substrate, stamped at establisher
  time from the one successful TunnelParams parse (one parse, one
  source of truth; the plan-payload change is pre-consumer)
- make_bindfirst_accept_handler drops the second params re-parse and
  the silent unwrap_or(Substrate::Tcp) fallback entirely — the
  dispatcher reads plan.substrate; no fallback branch remains, so a
  future drift cannot silently stamp Tcp into a UDP listen tunnel's
  forwarded opens (the raw-vs-framed data-plane mismatch class)
- tests: forwarded_opens_carry_plan_stamped_substrate pins the plan
  as the substrate's source over a real UDP bind-first wire flow
  (a substrate-recording forwarded stub observes substrate: udp);
  the unit downcast + establisher-flow tests pin the stamp
- docs: producer.md (the plan carries {listener, token, substrate};
  the forwarded open's substrate also comes from the plan),
  ADR-008 §1 (the stamp + the removed fallback)
- task review-002-m1-substrate-fallback: completed (Notes filled)

Verification: cargo test (145 pass), cargo test --all-features (153),
clippy --all-targets -D warnings (default + all-features), fmt
--check, doc --no-deps, wasm32 check + clippy -D warnings.
2026-09-20 07:00:18 +00:00
glm-5.3-flash 3df5c484c6 tasks: decompose review 002 findings into six remediation tasks
- M-1: the accept dispatcher's silent substrate fallback (plan-stamp
  shape recommended; failure-path test required)
- D-3 + D-4: trivial batch (rename the overpromising schema test
  helper; trim the stale CHANGELOG Unreleased preamble)
- D-1 + D-2: wire.md schema bounds + ADR-007 oneOf->if/then +
  establisher-time -> registry-gate sentence (doc-only, pre-consumer)
- G-2 (+ N-2's write-happy-peer test): observe close()'s EOF sentinel
  at the far accept dispatcher
- G-4 + G-5: unix accept-loop coverage + the unnamed unix fail-loud
  guards (G-1/G-3/G-6/G-7 explicitly left uncovered per the review)
- N-1..N-7 recorder: the two durable-trail notes (N-3 telemetry
  ordering sentence, N-4 ADR lines) + the no-action dispositions

All six independent (no depends_on) so sessions can parallelize;
validated with taskgraph (28 tasks, no cycles).
2026-09-20 06:46:06 +00:00
glm-5.3-flash 46d3a87b24 review 002: the graduation implementation — spec conformance + coverage appendix 2026-09-20 06:26:27 +00:00
glm-5.3-flash 474844e563 feat: TunnelListener — the listen-flow consumer session (ADR-008 §4)
- src/consumer.rs: `TunnelListener` — the listen-flow sibling of
  `TunnelSession`: `open` rides `open_channel_with_reply` (the U-2
  surface, alkcall 0.8.0) and projects the bind-first `bound`/`listen`
  reply fields via the lenient `listen_reply_fields`; `bound()` /
  `listen()` present them (the Option posture — None for dial/
  accept-first producers and old producers). Teardown ownership
  (ADR-005's posture, listen-flavored): `close` (graceful — `shutdown`
  on the held write half, the explicit EOF sentinel, then the sync
  reap) and `Drop` (the held write half drops → the mux pump's
  implicit-EOF chunk — the far accept dispatcher's teardown signal —
  then the sync reap; never leaks). The held stream is the write half
  (`MpscSendStream`) only, IDLE and private — a listen channel has no
  data-plane role. No `Clone` (compile_fail doc-test).
  `forwarded()` decided: NOT provided — the receipt queue is
  assembly-side bookkeeping; the type hands the assembly the token.
- src/lib.rs: re-exports `TunnelListener`; crate docs mention the
  listen-flow session.
- src/producer.rs: doc-fix drive-by — two pre-existing broken
  intra-doc links (cargo doc is now 0-warning clean).
- tests/harness.rs: `wire_listener_bindfirst` + `ListenerTopology` —
  the opener-dials shape (client + serving on the opener; bind-first
  serving + the call-plane forwarded-open caller on the producer).
- tests/consumer_listener.rs: 5 integration tests — the bind-first
  gate (reply fields + forwarded round-trip through the REAL
  forwarded-openable registration + the teardown cascade), the Option
  posture on a dial producer, close/Drop reaping (the leak-free
  guarantee), the adopt-path pin (`adopt` gains nothing — a forwarded
  channel presents the ordinary session semantics), and the scope
  interaction (`tunnel:open`-only opener opens the listen channel;
  forwarded opens to it fail FORBIDDEN).
- CHANGELOG: Unreleased → Added.

Verification: cargo test 132 passed (157 --all-features; doc tests
2/2 incl. both compile_fail asserts), clippy --all-targets -D warnings
clean (native + wasm32), fmt clean, wasm32-unknown-unknown check
passes (the default crate stays wasm-clean), cargo doc --no-deps clean.
2026-09-19 18:29:18 +00:00
glm-5.3-flash 514e0c1f25 feat: the local bind-first halves (shape 3 substrate half, ADR-008 §1)
- `bind_tcp_first` + `TcpListenerHalves::accept_pair`: the real-socket
  BindFn source (port 0 resolved via local_addr — the `bound` reply
  field's data) and the per-accept (peer, halves) pair (the peer rides
  accept()'s return — the forwarded op's `peer` carrier)
- unix bind-first parity shipped (OQ-TN-14): `bind_unix` +
  `UnixListenerHalves` (local_addr / queue-feeding accept_loop /
  bind-first accept_pair; an unnamed peer fails loud — the forwarded
  `peer` param requires an address) + `bind_unix_first`
- assembly conveniences: `local_bind_first()` (tcp/unix dispatch; udp
  fails loud — no accept on a UDP socket, the shape is not defined
  there, never a fake listener) + `local_bindfirst_accept_loop()`
  (downcasts the opaque listener; a foreign type fails loud)
- direct-op DialFn reuse verified + pinned: `local_dial` shared
  unchanged (the UDP framed wrap composes inside the closure);
  `direct_udp_through_local_dial_round_trips_framed` drives a REAL
  udp echo through the direct op's establisher path
- tests/local_bindfirst.rs: tcp e2e over real sockets (bound fidelity
  — the test dials the reported address; two clients → two forwarded
  channels pumping independently, real distinct peers), unix parity
  (real path in `bound`, named-peer client via socket2, real peer path
  on the forwarded open), udp refusal, downcast-miss probe
- accept-first shapes (accept_loop + AcceptQueue) untouched

Verified: cargo test (default) + --all-features green (45 lib; 151
integration), clippy -D warnings clean (native all-features + wasm32),
fmt clean, wasm32 check passes (default crate stays wasm-clean),
cargo doc clean (no new warnings).

Tasks: tunnels/local-bind-halves (status completed; Notes + Summary
filled). CHANGELOG: Unreleased → Added.
2026-09-19 16:30:46 +00:00
glm-5.3-flash 3fb26d9de6 feat: the forwarded op's serving side + the lenient reply projection (ADR-008 as amended)
- `ForwardedHandler` — the injected assembly handler (parsed params +
  channel `BiStream`); the assembly owns the pump, correlating by the
  `listen` token against its waiting-listener bookkeeping
- `make_forwarded_op_handler` — the built-in no-establisher wrapper:
  deny-unknown params parse (a malformed open fails loud → early
  return → teardown-at-birth reaps the channel, no phantom),
  `accept_bi` FIRST (R-02 accepted-flag telemetry), the injected
  handler's `JoinHandle` awaited inline (R-02)
- `register_tunnel_forwarded_openable` — `register_openable` shape
  (no establisher; the data plane starts immediately), scope gate
  `tunnel:forwarded` (ingress, never implied by `tunnel:open`/
  `tunnel:direct`)
- `listen_reply_fields` — the lenient, infallible reply projection
  (unknown extra reply fields tolerated; a malformed shape logs loud
  and surfaces both-`None`), shared with `TunnelListener`, delegating
  to `parse_listen_reply`
- Tests: 4 unit (reply projection) + 4 integration
  (serving-half e2e round-trip, FORBIDDEN gate, malformed-params
  teardown at both gates, correlation-miss teardown)

Verified: cargo test green (126 default; 146 --all-features),
clippy --all-targets -D warnings clean (native + wasm32), fmt clean,
wasm32-unknown-unknown check passes, cargo doc --no-deps clean
2026-09-19 14:48:08 +00:00
glm-5.3-flash 7d23a89791 feat: the bind-first establisher + accept dispatcher (shape 3, ADR-008 as amended)
- BindFn + BoundListener: the platform-boundary bind seam (the
  protocol never binds, OQ-TN-04); the opaque listener rides the plan
- bindfirst_establisher: registry namespace gate -> token mint
  (non-zero-seeded AtomicU32, per-open fresh, per-producer unique,
  token 0 never minted — the wrap shape unit-pinned) -> bind ->
  `bound` + `listen` reply fields -> the BindFirstPlan plan; the
  typed errors pass through (bind failure dial_failed, exhaustion
  resource_shortage, registry miss unknown_resource)
- BindFirstAcceptLoop: the type-erased accept closure (receives the
  opaque listener; yields (peer, halves) per accept; Err is
  loop-critical)
- ForwardedOpener: the per-accept forwarded-open caller — the call
  plane (call_with_payload on channel 0 + manager.adopt_channel; the
  reverse POC's open_reverse_channel shape)
- make_bindfirst_accept_handler: the accept dispatcher — accept_bi
  first (R-02 telemetry), the idle listen-stream EOF sentinel cancels
  the loop + tears the listener down (select!), per-accept pump_bidi
  tasks tracked by the handler's JoinHandle (awaited on loop exit —
  a forwarded pump never outlives its listener), per-accept failure
  isolation (log + drop; v1 no retry), loop-critical accept errors
  end the loop
- register_tunnel_bindfirst_openable: same spec + tunnel:open scope
  gate (the per-registration establisher posture); the injected
  seams ride BindFirstInjections {bind, accept_loop, open_forwarded}
  (8-arg sketch -> the bundle; clippy too_many_arguments) + the
  optional CF-006 identity witness (witness_establisher wrapper)
- harness: RegistrationMode::BindFirst + wire_bindfirst; the consumer
  hook gained the accept-side identity propagation + a shared serving
  registry (Topology.consumer_registry exposed); the forwarded-op
  serving stub (make_forwarded_stub_handler — correlate by token,
  accept the BiStream, pump_bidi against the correlated halves; the
  opener-side registration lands with tunnels/forwarded-receipt)
- tests: 6 new integration tests (the full flow e2e — the acceptance
  gate incl. the byte round-trip through the whole path, the
  dial-open neither-field projection, EOF teardown, per-accept
  isolation, typed establishment errors, token discipline) + 6 unit
  tests (the mint discipline, the establisher flow at the
  Establishment surface, the witness wrapper)

Verified: cargo test green (118 default; 138 --all-features), clippy
--all-targets -D warnings clean (native + wasm32), fmt clean,
wasm32-unknown-unknown check passes, cargo doc --no-deps clean.
2026-09-19 09:07:28 +00:00
glm-5.3-flash d586a5d2b8 feat: the forwarded op's params + the bind-first plan type (ADR-008 as amended)
- params.rs: OP_TUNNEL_FORWARDED / TUNNEL_FORWARDED_SCOPE; TunnelForwardedParams
  {listen, substrate, peer} (deny-unknown; listen positive via schema minimum 1 +
  a serde pin); tunnel_forwarded_spec() (Sub, External, alk/tunnel ALPN, scope
  tunnel:forwarded, per-substrate if/then peer sub-schema, no establisher);
  TunnelListenReply + parse_listen_reply (the additive bound/listen reply
  projection — unknown reply fields tolerated, malformed ones fail loud)
- error.rs: TunnelError::MalformedReply (the viewer's fail-loud path)
- producer.rs: BindFirstPlan {listener: Arc<dyn Any + Send + Sync>, token: u32}
  — the opaque assembly-typed listener + the correlation token; the mint
  discipline pinned in docs (non-zero-seeded AtomicU32, per-open fresh,
  per-producer unique, wraps acceptable)
- Wire-stable types pin first (the tunnels/params ordering); the establisher +
  accept dispatcher + registration land with tunnels/bindfirst-producer

Verified: cargo test (105 default; 125 --all-features), clippy --all-targets
-D warnings (native + wasm32), fmt --check, wasm32-unknown-unknown check,
cargo doc --no-deps — all clean.
2026-09-19 07:44:28 +00:00
glm-5.3-flash 0789ab62cb feat: the direct op (channels/tunnel/direct, ADR-007) end to end
- params.rs: OP_TUNNEL_DIRECT / TUNNEL_DIRECT_SCOPE constants;
  SubstrateAddr (untagged {host,port} | {path}; also the ADR-008 §3
  peer shape); TunnelDirectParams with render_target();
  tunnel_direct_spec() — Sub-typed, External, alk/tunnel ALPN
  marker, scope ["tunnel:direct"] (never implied by tunnel:open),
  per-substrate if/then target sub-schema (malformed targets fail
  the registry's schema gate as INVALID_INPUT before the
  establisher). 7 unit tests.
- producer.rs: direct_establisher (+ witness variant) — parse,
  render the target to the registry backing-string form, dial via
  the SAME injected DialFn; no registry lookup (unknown_resource
  can never fire); typed errors per ADR-007 §4 minus the registry
  row; CF-006 witness seam. register_tunnel_direct_openable reuses
  the base pump handler (plan-flow unchanged).
- consumer.rs: TunnelSession::open_direct — separate constructor
  (two scopes are two capabilities), plain open_channel (dial
  establishers never bind), identical session/data-planes/teardown.
- tests/producer_direct_op.rs: 15 integration tests — tcp + udp e2e
  round-trips, malformed-target rejection (no phantom session),
  dial-failure pass-through, NOT_FOUND posture, scope separation
  both ways (FORBIDDEN, raw-call + session-level), CF-006 witness
  (raw + session paths), parse backstop, target-rendering pin.
  Harness: Direct registration mode, wire_direct* topologies,
  direct_identity/both_scopes_identity.
- CHANGELOG: Unreleased → Added.

Verified: cargo test green (93 native, 94 --all-features); clippy
-D warnings clean (native + wasm32); fmt clean; wasm32 check passes
(default crate stays wasm-clean); cargo doc clean.

Task: tasks/tunnels/direct-op.md (status: completed)
2026-09-19 04:58:37 +00:00
glm-5.3-flash 52b6252531 fix: taskgraph frontmatter variants — scope medium -> moderate, status done -> completed
tunnels/tunnel-listener + tunnels/forwarded-receipt used `scope: medium`
(not a valid variant — single/narrow/moderate/broad/system), which made
taskgraph silently drop both from the graph (validate failed; topo/
list missed them). tunnels/coverage-weak-spots used `status: done`
(pre-existing; valid variants are pending/in-progress/completed/failed/
blocked).

Verified: taskgraph validate (22 tasks), topo, cycles (none),
parallel, critical all resolve with the full graph.
2026-09-18 14:46:25 +00:00
glm-5.3-flash f315b0e0ca docs+tasks: ADR-008 Amendment 1 (correlation token); decompose the graduation into tasks/tunnels
Verification pass against the landed alkcall 0.8.0 surface surfaced one
design gap: ADR-008 §3 pinned the forwarded op's correlation key as the
listen channel's ID, but (1) the serving-side OpenHandler/establisher
surface never exposes the allocated channel ID, and (2) the in-tree
generic ChannelRelay forwards open-op params untouched (it rewrites
only the reply's channel_id), so a spoke's forwarded open would carry
an id the end opener never allocated. Amended (wire still unconsumed —
alksocks is docs-only — so pre-consumer and legal):

- ADR-008 Amendment 1: the bind-first establisher mints a fresh u32
  correlation token per open — carried in the plan (for the accept
  dispatcher) and contributed as the additive `listen` reply field
  (the same projection carrier `bound` rides); the forwarded op's
  params rename `channel` -> `listen` with token semantics. Tokens
  ride reply fields + params through hubs untouched (relay-transparent
  by construction, alkcall ADR-051). Consumer-minted tokens and a new
  upstream handler surface were considered and rejected (rationale in
  the amendment).
- Spec docs synced: wire.md (the `bound`+`listen` reply section, the
  forwarded params), producer.md (bind-first shape + forwarded op),
  consumer.md (TunnelListener gains `listen()`; correlation registry
  posture), overview.md, OQ ledger (OQ-TN-16 updated),
  tunnels-graduation.md.

Task decomposition (tasks/tunnels/, Phase 2 of the graduation — the
alkcall prerequisites are gone):
- direct-op — params/spec/establisher/open_direct (ADR-007)
- bindfirst-params — forwarded-op spec, TunnelListenReply, the
  BindFirstPlan + token mint (wire types first, the params.md
  ordering precedent)
- bindfirst-producer — bind-first establisher, accept dispatcher,
  register_tunnel_bindfirst_openable
- forwarded-receipt — the opener-side serving op + reply-field reader
- tunnel-listener — the listen-flow consumer session (ADR-008 §4)
- local-bind-halves — the `local` TCP bind + accept-pairs loop,
  unix parity decision, direct-op DialFn reuse pin

Dependency order: direct-op and bindfirst-params are independent
roots; bindfirst-producer -> {bindfirst-params}; forwarded-receipt ->
bindfirst-params; tunnel-listener -> {bindfirst-params,
forwarded-receipt}; local-bind-halves -> bindfirst-producer.

Verification: cargo test 71 passed; clippy --all-targets -D warnings;
fmt --check; wasm32-unknown-unknown check + clippy clean (docs + task
files only — no code change).
2026-09-18 14:26:21 +00:00
glm-5.3-flash c31dd5e100 chore: bump alkcall 0.7.1 -> 0.8.0; mark the review-008 asks landed
alkcall 0.8.0 lands both graduation upstream asks (review 008):
- U-2: the establisher → reply projection (ADR-049 amendment 3,
  Establishment::with_reply_field + open_channel_with_reply) — the
  bind-first 'bound' carrier
- U-1: flavor-form open-op ids in discovery (ADR-047 amendment 3) —
  the direct/forwarded ops reconstruct their marker through discovery
plus the bonus: in-tree ChannelRelay + HubLegTemplate (ADR-051) — the
hub re-produce machinery is upstream now; 'bound' flows end-to-end
per-hop truthful, realizing ADR-008 §6's sentence.

OQ-TN-12 resolves upstream (the composition helper a hub proxy needed
is alkcall's ChannelRelay); spec docs note the asks landed. No
alktunnels API or wire change in this bump — the graduation
implementation (tasks/tunnels) follows.

Verification: cargo test 71 passed (91 all-features), clippy
--all-targets -D warnings, fmt --check, wasm32-unknown-unknown check
+ clippy clean
2026-09-18 13:33:06 +00:00
glm-5.3-flash 0d6b23b7cf docs: spec the graduation — ADR-007 (direct op), ADR-008 (listen metadata + forwarded op)
The alksocks graduation research (tunnels-graduation.md) is decided;
the ADRs are normative now:

- ADR-007: the direct open op — channels/tunnel/direct on the one
  alk/tunnel ALPN, params {substrate, target} (self-describing JSON
  target; ATYP bytes rejected as not schema-describable), scope
  tunnel:direct (never implied by tunnel:open), dial-only.
- ADR-008: listen metadata — bind-first listen establisher (reply
  gains additive 'bound' = BND.ADDR fidelity; the BIND deadlock +
  10s-bound findings dissolve) + the forwarded op
  channels/tunnel/forwarded (accept-as-open, params carry
  {channel, substrate, peer} = reply#2 fidelity). Accept-first stays.
- ADR-001/002/006 amendments: params layout is per-op; flavor-form
  op ids on the one ALPN; per-flavor scopes + the hub re-produce
  policy sentence (the hub's ACL governs the final dial).
- wire/producer/consumer/overview/README updated for the new ops,
  the three establisher shapes, TunnelListener, open_direct.
- OQ-TN-15/16 promoted + resolved; upstream alkcall asks filed at
  alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md
  (flavor-form discovery derivation; the establisher → reply
  projection).

Verification: cargo test (22 passed), clippy -D warnings, fmt --check
(docs-only change; wasm check unaffected)
2026-09-16 08:31:56 +00:00
glm-5.3-flash c86021e6a9 docs: research — graduating proxy surface into alktunnels (direct op + BIND metadata carriers)
Downstream alksocks motivation: CONNECT/BIND/ASSOCIATE need three
additions, all generally useful:

- One wire addition: a direct/dynamic open op on the alk/tunnel ALPN
  (channels/tunnel/direct, scope tunnel:direct) — the ssh
  direct-tcpip capability, additive to ADR-001's resource-not-address
  params. Options weighed; second-op lean (ACL separation at the op,
  per the socks5:bind scope-gate precedent).
- The two BIND metadata asks (bound-addr discovery, peer metadata)
  with carrier options, including the forwarded-tcpip alternative
  (reverse open with peer/target in params) that retires both with
  existing reverse-path machinery.
- Callback→provider mapping: the composed providers the graduation
  gives each alksocks seam (DialFn/EgressFactory/BindAcceptFactory)
  next to the local-socket ones.

Also records the vpn-like endgame positioning (virtual-subnet
translation via dial callback, signing as directory/identity-layer)
and alknodes as the OQ-TN-12 re-produce consumer.

Verification: docs-only change (cargo test/clippy/fmt unaffected).
2026-09-15 14:17:39 +00:00
44 changed files with 10871 additions and 172 deletions

No files matched your search

+175 -1
View File
@@ -4,6 +4,179 @@ All notable changes to this crate are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.2.0] - 2026-09-20
The graduation release: the direct op + the bind-first/forwarded
metadata flow (ADRs 007/008), over alkcall 0.8.0. Composed against by
alksocks (the SOCKS5 layer) and the planned `alknodes` assembly.
### Added
- **`TunnelListener` — the listen-flow consumer session (ADR-008 §4)** —
the listen-flow sibling of `TunnelSession`: `TunnelListener::open`
(rides `ChannelClient::open_channel_with_reply`, alkcall 0.8.0's
U-2 surface — the full reply arrives and the bind-first `bound`/
`listen` fields project through the lenient
`listen_reply_fields`; adopt-free construction — the client already
adopted the channel), `bound()`/`listen()` (the Option posture —
`None` for dial/accept-first producers and old producers), and the
listen channel's teardown ownership (ADR-005's posture extended to
the listen flow): `close` (the graceful EOF sentinel via
`shutdown`, then the reap) and `Drop` (the held write half drops →
the mux pump's implicit-EOF chunk → the far accept dispatcher's
teardown signal; sync reap — never leaks). The held `BiStream`'s
write half stays IDLE and private (a listen channel has no
data-plane role; pumping one is meaningless). No `Clone`
(compile-asserted). Forwarded sessions are NOT owned by the
listener — they arrive as ordinary adopted-side `TunnelSession`s on
the opener's manager (`adopt` gains nothing, pinned by test); the
assembly correlates them via the `listen()` token (the receipt
queue is assembly-side bookkeeping).
- **The `local` feature's bind-first substrate half (ADR-008 §1,
shape 3)** — the real-socket sources behind the committed
`BindFn`/`BindFirstAcceptLoop` contracts (behind `local`; the
default crate stays wasm-clean): `bind_tcp_first` (port 0 resolved
via `local_addr` — the `bound` reply field's data) +
`TcpListenerHalves::accept_pair` (one accept → `(peer, halves)` —
the peer rides `accept()`'s return, the forwarded op's `peer`
carrier); unix bind-first parity shipped (OQ-TN-14): `bind_unix` +
`UnixListenerHalves` (`local_addr`, the queue-feeding `accept_loop`,
the bind-first `accept_pair` — an unnamed (autobind) peer fails
loud: the forwarded `peer` param requires an address) +
`bind_unix_first`; the assembly conveniences `local_bind_first()`
(tcp/unix dispatch; **udp fails loud — a UDP socket has no accept,
the bind-first shape is not defined there, never a fake listener**)
and `local_bindfirst_accept_loop()` (downcasts the plan's opaque
listener to the module's wrappers; a foreign listener type fails
loud as `handler_error`). The accept-FIRST shapes (`accept_loop` +
`AcceptQueue`) are untouched. The direct-op `DialFn` reuse is
verified + pinned: `local_dial` is shared unchanged (the UDP framed
wrap happens inside the closure — review 001 U-1); a
`TunnelSession::open_direct` + `Substrate::Udp` test drives a REAL
UDP echo socket through the direct op's establisher path.
- **The forwarded op's SERVING side + the lenient reply projection
(ADR-008 §3 as amended)** — the listen-opener half, the receipt
counterpart to the bind-first producer (the opener SERVES the op;
the producer CALLS it per accept): `ForwardedHandler` (the injected
assembly handler — the parsed params + the channel `BiStream`; the
assembly owns the pump, since the local halves are assembly state)
+ `make_forwarded_op_handler` (the built-in wrapper: deny-unknown
params parse (a malformed open fails LOUD → the wrapper returns
early → teardown-at-birth reaps the channel — no phantom, the
correct shape for a malformed ingress), `accept_bi` FIRST (R-02
accepted-flag telemetry), then wrapper→assembly bridge, the
injected handler's `JoinHandle` awaited inline); +
`register_tunnel_forwarded_openable` (`register_openable` — no
establisher, the data plane starts immediately; scope gate
`tunnel:forwarded`, ingress — a separate grant from
`tunnel:open`/`tunnel:direct`, never implied by either). Plus
`listen_reply_fields` (the lenient, infallible session-surface
projection of the bind-first reply — unknown extra reply fields
tolerated, a malformed shape logs loud and surfaces both-`None`;
shared with `TunnelListener`). Correlation-miss teardown semantics
documented + test-pinned: the assembly holds the map, its error
path drops the stream, the channel reaps (the producer's accept
loop drops the accepted connection on the open's failure — the
shapes mirror).
- **The bind-first establisher + accept dispatcher (shape 3, ADR-008
as amended)** — the `-R`/ingress establisher whose establishment
phase BINDS (the address-first-vs-accept-first deadlock dissolves;
the accept wait lives in the teardown-governed pump phase):
`BindFn` (the substrate bind function — the platform boundary, the
protocol never binds) + `BoundListener` (the opaque listener + the
real OS-chosen bound address); `bindfirst_establisher` (registry
namespace gate → token mint → bind → `bound` + `listen` reply
fields via `Establishment::with_reply_field` → the `BindFirstPlan`
plan payload; the token mint is the pinned discipline — non-zero-
seeded `AtomicU32`, per-open fresh, per-producer unique, token 0
never minted); `BindFirstAcceptLoop` (the type-erased accept
closure — receives the opaque listener, yields `(peer, halves)` per
accept); `ForwardedOpener` (the per-accept forwarded-open caller —
the call plane: `call_with_payload` on channel 0 +
`manager.adopt_channel`, the reverse POC's shape);
`make_bindfirst_accept_handler` (the dispatcher: `accept_bi` first
per R-02, the idle listen-stream EOF cancels the loop and tears the
listener down, per-accept `pump_bidi` tasks tracked by the handler's
`JoinHandle` — a forwarded pump never outlives its listener;
forwarded-open failures are per-accept isolated — log + drop, the
loop continues, v1 no retry); `register_tunnel_bindfirst_openable`
(same spec + `tunnel:open` scope gate as the dial/listen variants —
the per-registration establisher posture; the injected seams ride
`BindFirstInjections {bind, accept_loop, open_forwarded}` + the
optional CF-006 identity witness). The opener-side forwarded-op
serving shape (`register_openable`, no establisher) landed with
`tunnels/forwarded-receipt`.
- **The forwarded op + the bind-first params surface (ADR-008 as
amended)** — the wire-stable types pin first (the ordering
`tunnels/params` used for the base op): `OP_TUNNEL_FORWARDED` /
`TUNNEL_FORWARDED_SCOPE` constants; `TunnelForwardedParams
{listen, substrate, peer}` (deny-unknown; `listen` is the
producer-minted correlation token, positive by wire contract —
schema `minimum: 1` + a serde-level pin) with a per-substrate
`peer` sub-schema (malformed peers fail the registry's schema gate
before the opener-side handler); `tunnel_forwarded_spec()`
(`Sub`-typed, External, `alk/tunnel` ALPN marker, no establisher —
the forwarded data plane starts immediately); `TunnelListenReply` +
`parse_listen_reply` (the typed projection of the bind-first
listen open's additive `bound`/`listen` reply fields — unknown
reply fields tolerated, malformed ones fail loud via the new
`TunnelError::MalformedReply`); `BindFirstPlan` (the opaque
assembly-typed listener + the token — the plan payload the
bind-first establisher/accept-dispatcher task consumes).
- **The direct op (`channels/tunnel/direct`, ADR-007)** — dynamic-
target egress (the ssh `direct-tcpip` / SOCKS5 CONNECT shape),
riding the same `alk/tunnel` ALPN: `SubstrateAddr` (the untagged
substrate-shaped address object, also the forwarded op's `peer`
shape) + `TunnelDirectParams {substrate, target}` with a
per-substrate `input_schema` (malformed targets fail the registry's
schema gate — `INVALID_INPUT` — before the establisher),
`tunnel_direct_spec()`, `direct_establisher` (+ witness variant;
no registry lookup — `unknown_resource` can never fire; targets
render to the same backing-string form the registry backings use
and ride the SAME injected `DialFn`), `register_tunnel_direct_
openable` (same pump handler + plan-flow as the base op), and
`TunnelSession::open_direct` (separate constructor — `tunnel:direct`
is a separate grant from `tunnel:open`, never implied by it; the
session type, data planes, and teardown API are identical). An old
producer rejects the unknown op loudly (`NOT_FOUND`).
### Changed
- **alkcall 0.7.1 → 0.8.0** (crates.io). The two upstream asks the
graduation ADRs depend on landed (alkcall review 008): the
establisher → reply projection (`Establishment::with_reply_field`,
ADR-049 amendment 3 — the bind-first `bound` carrier) and
flavor-form open-op ids in discovery (ADR-047 amendment 3 —
`channels/tunnel/direct`-shaped ops reconstruct their
`channel_open` marker through discovery, so hub relay wraps them as
channels, not plain forwarding stubs). Plus the bonus:
`ChannelRelay` + `HubLegTemplate` (ADR-051) — the hub re-produce
machinery the graduation's re-produce story assumed would be
assembly-layer is now in-tree, with `bound` flowing end-to-end
per-hop-truthful (ADR-051 §6, ADR-008 §6's sentence realized).
No alktunnels API or wire change in this bump; 71/91 tests pass,
wasm-clean verified.
- **`BindFirstPlan` gains `substrate: Substrate` (review 002 M-1
remediation)** — the accept dispatcher's silent
`unwrap_or(Substrate::Tcp)` fallback (the second
`TunnelParams` re-parse of wire input) is removed: the establisher
stamps the parsed substrate into the plan (one parse, one source
of truth) and the dispatcher reads it from the plan it already
downcast. A drift can no longer silently stamp `Tcp` into a UDP
listen tunnel's forwarded opens (a raw-vs-framed data-plane
mismatch on the opener side). Plan-payload change is pre-consumer
(no external `BindFirstPlan` consumer yet). Pinned by
`forwarded_opens_carry_plan_stamped_substrate` (a UDP bind-first
flow whose forwarded opens carry `substrate: udp` from the plan).
## [0.1.0] - 2026-09-10
The initial release: the v1 tunnel protocol implementation over
@@ -48,5 +221,6 @@ complete (Phase 2, review 001 findings remediated).
matrix). Wasm-clean default crate verified (`wasm32-unknown-unknown`
check + clippy).
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.1.0...HEAD
[Unreleased]: https://git.alk.dev/alkdev/alktunnels/compare/v0.2.0...HEAD
[0.2.0]: https://git.alk.dev/alkdev/alktunnels/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.alk.dev/alkdev/alktunnels/releases/tag/v0.1.0
Generated
+3 -3
View File
@@ -27,9 +27,9 @@ dependencies = [
[[package]]
name = "alkcall"
version = "0.7.1"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac26db919a1db9d72910ead8741af683bedefc625631028194b5f5e1dbd63cb6"
checksum = "8695d47cee5b5a5fe68471f04a505bc257a7e7ed9e64d827de33ff83adf4db12"
dependencies = [
"async-trait",
"bytes",
@@ -48,7 +48,7 @@ dependencies = [
[[package]]
name = "alktunnels"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"alkcall",
"bytes",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "alktunnels"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
rust-version = "1.88"
license = "MIT OR Apache-2.0"
@@ -19,7 +19,7 @@ default = []
local = ["tokio/net"]
[dependencies]
alkcall = "0.7.1"
alkcall = "0.8.0"
tokio = { version = "1", default-features = false, features = ["rt", "sync", "io-util", "macros", "time"] }
bytes = "1"
futures = "0.3"
+117 -8
View File
@@ -9,8 +9,14 @@ transport dependencies; the transport (QUIC, websocket, stdio, ...)
is whatever alkcall runs over.
Downstream crates compose on top of it: wire your substrate's sockets
into the producer's dial/accept closures (the `local` feature ships
reference helpers), and open typed sessions from the consumer side.
into the producer's dial/accept/bind closures (the `local` feature
ships reference helpers), and open typed sessions from the consumer
side. Dynamic-target egress (`ssh -W`/SOCKS5 CONNECT shape) rides the
[`direct op`](https://docs.rs/alktunnels/latest/alktunnels/params/constant.OP_TUNNEL_DIRECT.html)
(ADR-007); the composed `-R` flow (bind + peer metadata) rides the
bind-first establisher + the
[`forwarded op`](https://docs.rs/alktunnels/latest/alktunnels/params/constant.OP_TUNNEL_FORWARDED.html)
(ADR-008).
## Quick start
@@ -76,6 +82,38 @@ let accept: AcceptFn = /* pops from the queue (or your own source) */;
register_tunnel_listen_openable(&core, &registry, &op_registry, auth, accept)?;
```
### Producer — bind-first listen shape (`-R` with address + peer fidelity)
The same open op with a bind-first establisher: establishment BINDS
(the reply carries the real OS-chosen `bound` address + a correlation
token), then every local accept opens the `channels/tunnel/forwarded`
op toward the listen opener with the real peer address.
```rust
use alktunnels::producer::{
register_tunnel_bindfirst_openable, BindFirstInjections,
};
let injections = BindFirstInjections {
// Binds the listener, returns it + the OS-chosen bound address.
// Behind `local`: alktunnels::local::local_bind_first() (tcp/unix).
bind,
// Per accept: yields (peer, halves) from the opaque listener.
// Behind `local`: alktunnels::local::local_bindfirst_accept_loop().
accept_loop,
// Opens the forwarded op on the channel-0 call plane + adopts.
open_forwarded,
};
register_tunnel_bindfirst_openable(&core, &registry, &op_registry, auth, injections, None)?;
// The forwarded op's opener-side serving half (on the consumer's
// connection): correlate the token, pump against the local socket.
register_tunnel_forwarded_openable(
&core, &op_registry, auth,
/* ForwardedHandler: (params, BiStream) -> pump task */,
)?;
```
### Consumer — open a tunnel channel (the `-L` path)
```rust
@@ -135,6 +173,45 @@ let session = TunnelSession::adopt(
session = session.pump_against(accepted_local_stream).await;
```
### Consumer — dynamic target (the direct op, `ssh -W`/CONNECT shape)
```rust
use alktunnels::params::SubstrateAddr;
use alktunnels::TunnelSession;
// `tunnel:direct` is a SEPARATE grant from `tunnel:open` (never
// implied by it) — the caller states which capability it holds. A
// malformed target fails the schema gate; an old producer rejects
// the unknown op loudly (NOT_FOUND).
let mut session = TunnelSession::open_direct(
&client,
SubstrateAddr::HostPort { host: "example.internal".into(), port: 5432 },
Substrate::Tcp,
).await?;
```
### Consumer — the listen flow (bind-first reply fields)
```rust
use alktunnels::TunnelListener;
// The same `channels/tunnel/sub` open op — the establisher shape is
// the producer's per-registration choice, invisible here. A bind-first
// producer's reply carries `bound` (the real OS-chosen address) and a
// correlation token; both are `None` for dial/accept-first producers
// or old producers (the lenient, additive posture).
let listener = TunnelListener::open(&client, params).await?;
let bound = listener.bound().cloned(); // Option<SubstrateAddr> — informational, NOT addressing
let token = listener.listen(); // Option<u32> — the forwarded op's correlation key
// Teardown: `close` sends the EOF sentinel (the far accept dispatcher
// tears its listener down), then reaps; `Drop` is the sync best-effort
// form. Forwarded sessions arrive as separate adopted `TunnelSession`s
// on the manager — the assembly correlates them via the token; the
// listener does not own them.
listener.close().await;
```
## Architecture
Two roles, symmetric per the alk convention (avoid "server"/"client":
@@ -142,8 +219,9 @@ both sides of a connection can be either; role follows the resource):
| Role | Via `alk/channels` |
|------|--------------------|
| **Producer** (owns the backing: local ports, sockets, in-process services) | [`register_tunnel_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_openable.html) / [`register_tunnel_listen_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_listen_openable.html) — the open op (`channels/tunnel/sub`) with a **dial establisher** (target dialing) or a **listen establisher** (pops an accepted connection); the pump handler bridges the channel via [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
| **Consumer** (wants the bytes) | [`TunnelSession`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html) — [`open`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open) (forward) / [`adopt`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.adopt) (reverse), substrate-shaped data planes, teardown ownership |
| **Producer** (owns the backing: local ports, sockets, in-process services) | [`register_tunnel_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_openable.html) / [`register_tunnel_listen_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_listen_openable.html) / [`register_tunnel_bindfirst_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_bindfirst_openable.html) — the open op (`channels/tunnel/sub`) with a **dial establisher** (target dialing), an **accept-first listen establisher** (pops an already-accepted connection), or a **bind-first establisher** (binds; the reply carries `bound` + token, per-accept forwarded opens carry the peer); the pump handler bridges the channel via [`alkcall::channels::pump_bidi`](https://docs.rs/alkcall/latest/alkcall/channels/pump/fn.pump_bidi.html) |
| **Producer** (dynamic-target egress) | [`register_tunnel_direct_openable`](https://docs.rs/alktunnels/latest/alktunnels/producer/fn.register_tunnel_direct_openable.html) — the direct op (`channels/tunnel/direct`, ADR-007) with its own scope (`tunnel:direct`); the target rides the params, no registry lookup |
| **Consumer** (wants the bytes) | [`TunnelSession`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html) — [`open`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open) (forward) / [`open_direct`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.open_direct) (dynamic target) / [`adopt`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelSession.html#method.adopt) (reverse), substrate-shaped data planes, teardown ownership; [`TunnelListener`](https://docs.rs/alktunnels/latest/alktunnels/consumer/struct.TunnelListener.html) — the listen flow with the `bound`/`listen` reply fields |
One connection hosts many tunnels (one per channel); sessions are
independent. Both roles are callable from either connection side —
@@ -151,9 +229,9 @@ independent. Both roles are callable from either connection side —
machines (OQ-TN-03; the reverse POC validated the worker-serves /
hub-initiates shape).
### A tunnel is a resource, not an address
### A tunnel is a resource, not an address (with one additive exception)
The open-op params are wire-stable (ADR-001) and deliberately
The base open-op params are wire-stable (ADR-001) and deliberately
address-free:
```json
@@ -165,6 +243,20 @@ never learns an address. Rich addressing (SOCKS5 ATYP, per-datagram
remotes) enters only through the `-D`/dynamic composition path,
speaking its protocol inside the tunnel payload — never in the wire.
The exception is the direct op (ADR-007) — a *different capability*
on its own op + scope, so the ACL separation is structural:
```json
{ "substrate": "tcp", "target": { "host": "10.0.0.7", "port": 5432 } }
```
Scope-gated separately (`tunnel:direct` ≠ `tunnel:open` ≠
`tunnel:forwarded` — never implied by each other); dial-only (no
listen/direct-BIND form); the same `DialFn` the base op uses. The
composed `-R` metadata flow rides the forwarded op
(`channels/tunnel/forwarded`, `{listen, substrate, peer}` — the peer
is producer-asserted metadata, not transport truth).
### Data plane
- **Stream substrates (`tcp`, `unix`)**: raw pass-through — the
@@ -191,12 +283,29 @@ The default crate (no features) compiles to `wasm32-unknown-unknown`
feature (real sockets) needs a real OS and is non-wasm by design;
enabling it on wasm is a compile error.
## Version 0.2 — the graduation
0.2 rides alkcall 0.8.0 and graduates the proxy surface
(`docs/research/tunnels-graduation.md`, ADRs 007/008):
- **The direct op** (`channels/tunnel/direct`, scope `tunnel:direct`)
— dynamic-target egress; `TunnelSession::open_direct`.
- **The bind-first establisher + the forwarded op**
(`channels/tunnel/forwarded`, scope `tunnel:forwarded`) — the
composed `-R` metadata flow: `bound` + correlation token in the
listen reply, `{listen, substrate, peer}` per accept;
`TunnelListener` + `parse_listen_reply`/`listen_reply_fields`.
- **alkcall 0.7.1 → 0.8.0** — `Establishment::with_reply_field` +
`open_channel_with_reply` (the bind-first carriers) and
flavor-form op ids in discovery (hub relay wraps them as channels);
bonus: in-tree `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051).
## Features
| Feature | Contents | wasm |
|---------|----------|------|
| *(default)* | params, wire codec, open-op spec, establishers (dial + listen), `TunnelSession`, registration helpers — protocol only | yes |
| `local` | [`dial_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_tcp.html) / [`bind_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp.html) / [`connect_udp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.connect_udp.html) (the framed `UdpHalf`) / [`dial_unix`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_unix.html) / [`local_dial`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_dial.html), the TCP accept loop | no |
| *(default)* | params, wire codec, open-op specs (`sub`/`direct`/`forwarded`), establishers (dial + accept-first listen + bind-first), `TunnelSession`, `TunnelListener`, registration helpers — protocol only | yes |
| `local` | [`dial_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_tcp.html) / [`bind_tcp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp.html) / [`connect_udp`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.connect_udp.html) (the framed `UdpHalf`) / [`dial_unix`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.dial_unix.html) / [`local_dial`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_dial.html), the TCP accept loop; the bind-first halves: [`bind_tcp_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_tcp_first.html) / [`bind_unix_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.bind_unix_first.html) + [`local_bind_first`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bind_first.html) + [`local_bindfirst_accept_loop`](https://docs.rs/alktunnels/latest/alktunnels/local/fn.local_bindfirst_accept_loop.html) | no |
Stdio is intentionally out of scope — a spawned process's stdio is
alktty's pipe mode (exit codes + signals are tty semantics); remote
+46 -28
View File
@@ -1,6 +1,6 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-16 (the graduation ADRs 007/008 + the amendments)
---
# alktunnels — Architecture
@@ -18,15 +18,15 @@ raw byte/datagram transport, not terminal sessions).
| Document | Status | Description |
|----------|--------|-------------|
| [overview.md](overview.md) | draft | Crate purpose, the resource model in brief, dependencies, ALPN, feature gates, module map |
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` open op, the establisher (dial + listen variants), the pump handler, registration, scope gating |
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants), the reverse-flow initiator, teardown ownership (ADR-005) |
| [wire.md](wire.md) | draft | The wire format: open-op params JSON (ADR-001; the direct op's `{substrate, target}` — ADR-007), the `bound` reply field + the forwarded op (ADR-008), the data-plane codec (raw pass-through for stream substrates; mandatory `[len: u16 BE]` framing for UDP — ADR-003), sentinels, half-close semantics |
| [producer.md](producer.md) | draft | The producer half: the `channels/tunnel/sub` + `channels/tunnel/direct` open ops, three establisher shapes (dial, accept-first listen, bind-first listen), the pump handler, the forwarded-op opener side, registration, scope gating |
| [consumer.md](consumer.md) | draft | The consumer half: `TunnelSession` (stream + datagram variants, `open`/`open_direct`), `TunnelListener` (the listen flow), the reverse-flow initiator, teardown ownership (ADR-005/008) |
| [bast.md](bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the data-plane's binary framing (the UDP `[len: u16 BE]` codec — ADR-003); the stream pass-through has no binary framing to describe |
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..14; the Phase 0 ledger promoted) |
| [open-questions.md](open-questions.md) | draft | Centralized OQ tracker (OQ-TN-01..16; the Phase 0 ledger promoted) |
## Applicable ADRs
alktunnels-native ADRs (001..005). Upstream ADRs (alkcall/alknet) are
alktunnels-native ADRs (001..008). Upstream ADRs (alkcall/alknet) are
referenced by their own numbers and are NOT ported — the alkcall
originals at `/workspace/@alkdev/alkcall/docs/architecture/decisions/`
remain authoritative for the shared surfaces (establishment, channels
@@ -34,22 +34,28 @@ wire format, `pump_bidi`, identity seam).
| ADR | Title | Status |
|-----|-------|--------|
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted |
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted |
| [001](decisions/001-open-params-layout.md) | Open-Op Params Layout: Resource + Substrate | Accepted; amended (params layout is per-op — the direct op's params ride ADR-007) |
| [002](decisions/002-alpn-strategy.md) | Single `alk/tunnel` ALPN (Option A) | Accepted; amended (flavor-form op ids: `…/sub`, `…/direct`, `…/forwarded` on the one ALPN) |
| [003](decisions/003-codec-and-udp-framing.md) | Data-Plane Codec: Raw Pass-Through (Stream) / Mandatory Length Framing (UDP) | Accepted |
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend Trait — Halves Functions at the Assembly Layer | Accepted |
| [005](decisions/005-consumer-session-owns-teardown.md) | The Consumer Session Type Owns Teardown | Accepted |
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted |
| [006](decisions/006-access-control-posture.md) | Access-Control Posture — the Open Gate Is the Security Boundary | Accepted; amended (`tunnel:direct`/`tunnel:forwarded` scopes; the hub re-produce policy sentence) |
| [007](decisions/007-direct-open-op.md) | The Direct Open Op — Dynamic-Target Egress | Accepted |
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen Metadata — Bind-First Establisher and the Forwarded Op | Accepted |
## Key Design Principles
1. **A tunnel is a resource, not an address.** `params` identify a
produced resource + substrate discriminator; the producer owns the
backing (local port, docker container, in-process service, unix
socket). No URL-style general addressing in the wire — rich
addressing enters only through `-D`/dynamic composition, which
speaks its protocol inside the tunnel payload. See
[ADR-001](decisions/001-open-params-layout.md) and
1. **A tunnel is a resource, not an address — with one additive
exception.** Base params identify a produced resource + substrate
discriminator; the producer owns the backing (local port, docker
container, in-process service, unix socket). Rich addressing
enters through `-D`/dynamic composition, which speaks its protocol
inside the tunnel payload. The exception (ADR-007): the direct op
lets a consumer ask the producer to dial an arbitrary target — a
different capability on its own op and scope (`tunnel:direct`),
never implied by `tunnel:open`. See
[ADR-001](decisions/001-open-params-layout.md),
[ADR-007](decisions/007-direct-open-op.md), and
[wire.md](wire.md).
2. **Role follows the resource; connection direction is independent.**
@@ -57,9 +63,11 @@ wire format, `pump_bidi`, identity seam).
channels); whoever wants the bytes is the consumer. `-L` and `-R`
are the same open op with the entry point on different machines —
no protocol-level direction (the hub-owns-the-connection model,
OQ-TN-03). Validated by the reverse-flow POC: the worker serves on
the connect side; the hub initiates per accept. See
[producer.md](producer.md) and [consumer.md](consumer.md).
OQ-TN-03). The composed `-R` flow rides the bind-first establisher
+ the forwarded op (ADR-008). Validated by the reverse-flow POC:
the worker serves on the connect side; the hub initiates per
accept. See [producer.md](producer.md) and
[consumer.md](consumer.md).
3. **Raw pass-through for stream substrates; mandatory length framing
for UDP.** A tunnel has one data stream per direction — no
@@ -100,14 +108,19 @@ wire format, `pump_bidi`, identity seam).
second demux/mux (AGENTS.md convention 10).
8. **Security posture: the open gate is the boundary.** Scope-gated
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`, stable once published
— ADR-006), alkcall's op-level ACL via `register_openable`; caller
identity resolves in the 0.7.0 precedence order (token >
`ServingConfig.identity` > transport); the establisher/pump
handler see the per-call opener identity. No target allowlists, no
tunnel-specific policy machinery in v1. See
[ADR-006](decisions/006-access-control-posture.md) and
[producer.md](producer.md).
opens (`TUNNEL_OPEN_SCOPE = "tunnel:open"`,
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`,
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` — each stable once
published; ADR-006 + Amendment 1), alkcall's op-level ACL via
`register_openable`; caller identity resolves in the 0.7.0
precedence order (token > `ServingConfig.identity` > transport);
the establisher/pump handler see the per-call opener identity. No
target allowlists in the protocol (an assembly establisher MAY
enforce them — policy, not protocol). Hub re-produce: the hub's
ACL governs the final dial (the hub is the network boundary). See
[ADR-006](decisions/006-access-control-posture.md),
[ADR-007](decisions/007-direct-open-op.md), and
[producer.md](producer.md).
9. **Wasm-clean default crate.** The default crate (protocol only)
compiles to `wasm32-unknown-unknown`; socket/platform I/O lives in
@@ -140,6 +153,11 @@ Questions section.
lifetime contract, R-03 = `pump_bidi`) and
`/workspace/@alkdev/alkcall/docs/reviews/consumer-findings-ledger.md`
(CF-005/CF-006 = the identity seam, resolved in 0.7.0)
- The graduation research (the two new ADRs implement it):
`docs/research/tunnels-graduation.md`; the upstream alkcall asks
(review 008) **landed in alkcall 0.8.0** — flavor-form discovery
derivation + the establisher → reply `bound` projection, plus the
in-tree `ChannelRelay`/`HubLegTemplate` (alkcall ADR-051)
- Sibling precedent: alktty (`/workspace/@alkdev/alktty`) — the
producer/consumer-on-channels shape, wasm-clean default,
feature-gated local backend, BAST convention
+89 -10
View File
@@ -1,18 +1,22 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-18 (`open_direct`, `TunnelListener`, the forwarded-op serving side — ADR-007/008 as amended: the correlation token)
---
# alktunnels — Consumer Half
The consumer half: `TunnelSession` — the typed client for tunnel
channels. The consumer is whoever wants the bytes; it opens tunnel
channels toward the producer (forward path), or per local accept
toward a connect-side serving producer (the reverse flow). Both are
the same session type — role follows the resource, not the connection
(OQ-TN-03). Design decisions: [ADR-005](decisions/005-consumer-session-owns-teardown.md)
(session owns teardown), [ADR-003](decisions/003-codec-and-udp-framing.md)
(codec); this document is the normative WHAT.
channels — plus `TunnelListener` (the listen-flow session, ADR-008)
and the forwarded-op serving side. The consumer is whoever wants the
bytes; it opens tunnel channels toward the producer (forward path), or
per local accept toward a connect-side serving producer (the reverse
flow). Both are the same session type — role follows the resource, not
the connection (OQ-TN-03). Design decisions:
[ADR-005](decisions/005-consumer-session-owns-teardown.md) (session
owns teardown), [ADR-003](decisions/003-codec-and-udp-framing.md)
(codec), [ADR-007](decisions/007-direct-open-op.md) (the direct op),
[ADR-008](decisions/008-listen-metadata-forwarded-op.md) (listen
metadata + the forwarded op); this document is the normative WHAT.
## Opening a Tunnel
@@ -36,6 +40,26 @@ let session = TunnelSession::open(&channel_client, params).await?;
yields a session** — no phantom session, mirroring the
no-phantom-channel property (POC-verified).
### Direct path (dynamic-target egress, ADR-007)
```rust
let session = TunnelSession::open_direct(&client, target, substrate).await?;
```
- A separate constructor from `open`, not a params enum: two scopes
are two capabilities (`tunnel:direct` vs `tunnel:open`), and the
caller states which one it holds. The session type, data planes,
and teardown API are identical.
- `target`: the substrate-shaped JSON object (`{host, port}` /
`{path}` — ADR-007 §2); domain-form hosts stay unresolved (the
producer resolves).
- Flow: `open_channel("channels/tunnel/direct", {substrate, target},
"alk/tunnel")` → same adopt/split/data-plane flow as `open`.
- Errors: the same typed surface minus `unknown_resource` (no
registry lookup); malformed targets are the schema/
`invalid_input`-class failure. An old producer rejects the unknown
op loudly (`NOT_FOUND` — the SSH posture).
### Reverse path (`-R` — the consumer initiates toward a connect-side serving producer)
The hub/accept-side shape (the reverse POC's `ReverseTunnel`, the
@@ -72,6 +96,53 @@ let pumped = session.pump_against(accepted_halves).await; // or drive the halves
The session type and its teardown API are identical on both paths —
one API, `-L` and `-R` alike.
## The Listen Flow — `TunnelListener` (ADR-008)
The listen-flow sibling of `TunnelSession`: the session that OPENS a
bind-first listen tunnel and owns its teardown (ADR-005's ownership
posture extended to the listen flow). The forwarded connections it
receives arrive as ordinary adopted-side `TunnelSession`s on the
opener's manager (`adopt` gains nothing).
```rust
let listener = TunnelListener::open(&client, params).await?; // bind-first: fast establishment
let bound = listener.bound(); // the resolved OS-chosen address (Option — absent when unknown)
let token = listener.listen(); // the correlation token (the producer's; from the `listen` reply field)
let sessions = listener.forwarded(); // the forwarded sessions, as they arrive
listener.close(self); // or Drop — tears the listen channel + far listener down
```
- **`TunnelListener::open`** — the same `channels/tunnel/sub` open
with `{resource, substrate}`; the establisher shape (bind-first vs
accept-first) is the producer's per-registration choice, invisible
to the constructor. What differs: bind-first replies carry
`"bound"` (ADR-008 §2 — the resolved address; informational, never
a dial target) and `"listen"` (ADR-008 Amendment 1 — the
correlation token the producer minted).
- **`bound()`** — `Option`-shaped: `Some` when the producer supplied
the field (bind-first), `None` otherwise (accept-first, dial
establishers, or old producers). reply#1-class fidelity (BND.ADDR).
- **`listen()`** — `Option<u32>`: the correlation token (bind-first
producers). The opener-side correlation registry — token → waiting
session — is assembly-side bookkeeping (consumer.md's posture:
the protocol provides the op and params, not the wait state
machine); `TunnelListener` hands the assembly the key.
- **The forwarded side:** each accepted connection at the far
listener arrives as a producer-initiated
`channels/tunnel/forwarded` open that the opener SERVES
(`tunnel:forwarded` scope), its `listen` param matching the token.
The opener-side assembly correlates the open to the waiting
session, adopts the allocated channel (`TunnelSession::adopt`),
and pumps against the local BIND client's halves.
- **`peer` fidelity:** the forwarded op's params carry the accepted
connection's real remote address (reply#2-class fidelity — the
alksocks POC's sentinel retires). Producer-asserted metadata, not
transport truth.
- **Teardown:** dropping/`close`ing the listener EOFs the listen
channel; the producer-side accept loop cancels and tears the far
listener down (ADR-008 §1's handler contract). Forwarded sessions
already adopted own their own teardown (per-session, ADR-005).
## The Data Plane (substrate-shaped)
- **Stream variant (`tcp`, `unix`):** `stream_halves()` — raw
@@ -146,8 +217,12 @@ serving registry of its own.
- **OQ-TN-12**: hub re-produce composition (deferred — a hub proxy
re-exporting a consumed resource would add a
`register_openable_with_establisher` whose establisher opens a
consumer channel; no concrete consumer yet)
consumer channel; no concrete consumer yet. The re-produce policy
sentence is pinned: the hub's ACL governs the final dial — ADR-006
Amendment 1)
- **OQ-TN-13**: UDP truncation (resolved — fail-loud, ADR-003)
- **OQ-TN-15/16**: resolved (ADR-007/008 — `open_direct`,
`TunnelListener`, the forwarded op)
## References
@@ -155,6 +230,10 @@ serving registry of its own.
[wire.md](wire.md) (the data plane)
- Reverse POC `docs/research/reverse-poc-summary.md` (the seed shape:
`ReverseTunnel` open_and_pump / close / join_and_reap; W3, W4)
- `docs/research/tunnels-graduation.md` (the graduation research)
- Upstream asks (review 008): `alkcall/docs/reviews/
008-tunnels-graduation-upstream-review.md` — landed in alkcall
0.8.0
- alkcall ADR-047 §5 (allocation — the initiator adopts), ADR-050
(`pump_bidi`), ledger
CF-005/CF-006 (identity on the reverse path)
@@ -2,7 +2,8 @@
## Status
Accepted (2026-09-07)
Accepted (2026-09-07); amended 2026-09-16 (params layout is per-op —
the direct op pins its own params, see Amendment 1)
## Context
@@ -117,4 +118,18 @@ carries (socks5, udpgw-style framing), never in base params.
ADR-049 (typed establishment errors)
- Forward POC: `docs/research/poc-summary.md` (the shape riding 17
tests); reverse POC: `docs/research/reverse-poc-summary.md`
- OQ-TN-01 (promoted), OQ-TN-11 (naming residue)
- OQ-TN-01 (promoted), OQ-TN-11 (naming residue)
## Amendment 1 (params layout is per-op, 2026-09-16)
The `{resource, substrate}` layout pins the `channels/tunnel/sub` open
op. It is **per-op, not per-ALPN**: ADR-007 adds the
`channels/tunnel/direct` op on the same `alk/tunnel` ALPN with its own
params (`{substrate, target}` — a dynamic target, a different
capability with its own scope; the resource-not-address invariant is
unchanged because a dynamic target is not a produced resource). The
per-op reading was always latent in the layout's basis — params are
"the `input` of the operation" (alkcall ADR-047), and ops are
per-flavor once flavor-form op ids exist (ADR-002 Amendment 1).
Additive-only evolution per op stands: this amendment adds nothing to
the `…/sub` op's wire surface.
@@ -2,7 +2,8 @@
## Status
Accepted (2026-09-07)
Accepted (2026-09-07); amended 2026-09-16 (flavor-form op ids on the
one ALPN, see Amendment 1)
## Context
@@ -83,4 +84,34 @@ session types).
- OQ-TN-07 (promoted, resolved by this ADR)
- `docs/research/ssh-socks5-survey.md` §ALPN-relevant prior art
- alkcall ADR-004 (ALPN convention), ADR-047 (open ops per ALPN)
- AGENTS.md convention 17 (ALPN naming)
- AGENTS.md convention 17 (ALPN naming)
## Amendment 1 (flavor-form op ids, 2026-09-16)
The decision stands — one ALPN, no ALPN split per flavor. What
changes: the one-ALPN rule is realized as **one ALPN, several
`Sub`-typed open ops**, because ADR-007 (the direct op) and ADR-008
(the forwarded op) are different capabilities sharing the tunnel
transport — ssh's one-transport-many-channel-types shape.
- The op-id convention extends to flavor form: `channels/<alpn>/sub`
(the original resource op, reserved and unchanged),
`channels/<alpn>/direct` (dial an arbitrary target), and
`channels/<alpn>/forwarded` (accept-as-open toward the listen
opener). New flavors are new op ids — additive; the `…/sub` op is
never reused for a different meaning.
- The alternative — sibling ALPNs (`alk/tunnel-direct`,
`alk/tunnel-forwarded`) with standard `…/sub` op ids — was rejected:
it fragments the tunnel namespace per flavor, forces hub relay to
track three ALPNs, and amends this ADR's own new-ALPN justification
clause (the flavors share framing; none needs structurally different
framing from byte zero).
- Upstream coordination: alkcall ADR-047 pins discovery's ALPN
derivation to exactly the `…/sub`/`…/pub` suffix shapes
(`derive_alpn_from_op_name`); flavor-form op ids need that
derivation (and the hub-relay reconstruction riding it) extended —
filed upstream
(`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`,
ask #1) before the first consumer. Runtime was never blocked:
registration sets the ALPN marker explicitly and
`open_channel(op_id, params, alpn)` takes the ALPN as an argument.
@@ -4,7 +4,9 @@
Accepted (2026-09-07; records the OQ-TN-08 resolution as a decision —
the posture was resolved in Phase 0 but the consumer-visible constants
had no ADR backing)
had no ADR backing); amended 2026-09-16 (scope separation for the
direct/forwarded ops + the hub re-produce policy sentence, see
Amendment 1)
## Context
@@ -99,4 +101,31 @@ consumer-visible and stable-once-published:
per-call opener); alknet ADR-024/ADR-050 (registry layering,
ownership — the alkcall ports ADR-019/ADR-011)
- alktty's `TTY_OPEN_SCOPE` posture (the sibling precedent)
- AGENTS.md convention 13 (access control)
- AGENTS.md convention 13 (access control)
## Amendment 1 (per-flavor scopes + hub re-produce policy, 2026-09-16)
The posture stands; the scope space grows with the ops (ADR-007,
ADR-008):
- **`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`** gates
`channels/tunnel/direct` — dynamic-target egress. **Never implied by
`tunnel:open`**: "dial `postgres-primary`" and "dial anything from
my network" are different capabilities, and the separation is
structural (scopes attach to op specs, not params). A deployment
granting both grants both explicitly.
- **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** gates
`channels/tunnel/forwarded` on the opener side — the grant for
RECEIVING producer-initiated forwarded connections (ingress).
Ingress and egress are different grants (the alksocks POC #5
`socks5:bind` gate proved the distinction matters in practice).
- **Hub re-produce policy sentence (the security model):** a hub that
re-produces the direct op applies ITS OWN target policy at the
re-produce hop — the hub's ACL, not the original opener's, governs
the final dial. The hub is the network boundary; the original
opener's grant gets it to the hub, the hub's own grant decides what
it will dial on the far side. Correct-by-default; pinned here
because it is the boundary-allocation rule for every composed
consumer (alknodes included).
- Scope strings are ACL-stable once published (§2 unchanged): renames
are breaking; new gates are new scopes on new ops.
@@ -0,0 +1,238 @@
# ADR-007: The Direct Open Op — Dynamic-Target Egress
## Status
Accepted (2026-09-16)
## Context
ADR-001 pinned params as "resource, not an address": the producer owns
the backing; rich addressing rides the `-D`/dynamic composition path,
never the wire. That decision stands. The use case that justifies an
*additive* revisit is the one the alksocks graduation research
(`docs/research/tunnels-graduation.md`) named: a consumer asking the
producer to dial an arbitrary target chosen at session time — ssh's
`direct-tcpip` shape, SOCKS5 CONNECT's egress, and the vpn-like
dial-callback translation all need it. A dynamic target is a different
capability from opening a named resource, and ssh carries it as its own
channel type precisely because the two capabilities must not share one
grant.
The capability is dangerous in exactly the way ADR-001's "resource, not
address" framing protects against — arbitrary egress from the
producer's network. The mitigation is not refusing the capability (a
SOCKS5 service IS the capability) but separating it at the ACL: "dial
`postgres-primary`" and "dial anything" must not be the same grant.
The alksocks POC #5 scope gate (`socks5:bind`) proved the pattern
matters in practice.
Timing: alksocks is the first real consumer and nothing is published
against the direct shape yet — this is the correct moment to pin the
wire-stable surface.
## Decision
### 1. One ALPN, a second open op
The direct op rides the same `alk/tunnel` ALPN (ADR-002 stands: one
ALPN, one protocol identity — SSH's one-transport-many-channel-types
shape). The op id is **`channels/tunnel/direct`** — the flavor-form
op-id convention (ADR-002 Amendment 1): `channels/<alpn>/<flavor>`,
`Sub`-typed, same establishment phase and wrapper as `…/sub`. An older
producer rejects the unknown op loudly (registry lookup fails — the
SSH "unknown channel type" posture, `NOT_FOUND`).
The op-id convention itself needed an upstream alkcall amendment
(ADR-047 amendment: discovery's ALPN derivation and the
`rebuild_spec_for` marker path recognize flavor-form op ids; hub relay
reconstructs them). Runtime was never blocked — registration sets the
ALPN explicitly and `open_channel(op_id, params, alpn)` takes the ALPN
as an argument — but discovery + hub relay are the re-produce path
(alknodes, OQ-TN-12), so the convention is pinned upstream before the
first consumer. **Landed in alkcall 0.8.0** (review 008 U-1, ADR-047
amendment 3).
### 2. Params: `{ substrate, target }`
```json
{
"substrate": "tcp",
"target": { "host": "db.internal", "port": 5432 }
}
```
- **`substrate`** (string, required) — same discriminator semantics as
ADR-001: `"tcp"` | `"udp"` | `"unix"` in v1; selects the data-plane
framing (ADR-003). Enum-validated; unknown values fail schema
validation loudly.
- **`target`** (object, required) — a self-describing JSON object keyed
by the sibling `substrate` field:
- `tcp` → `{ "host": <string>, "port": <u16> }` — `host` is a
hostname, IPv4/IPv6 literal, or the JSON form of
`SocketAddr`-shaped values; **domain-form targets stay unresolved
on the wire** (producer-side resolution — the ssh `-D` semantic;
both POCs validated egress-side resolution). No DNS dependency on
the consumer.
- `udp` → `{ "host": <string>, "port": <u16> }` — the connected-UDP
destination (endpoint-at-open; ADR-003 framing applies as for base
udp).
- `unix` → `{ "path": <string> }` — the socket path on the
producer's filesystem.
- The schema pins both fields required; the target sub-schema is
per-substrate (an `allOf` of two `if`/`then` branches keyed on the
sibling `substrate` value, validated **registry-side** — the schema
gate runs before the establisher; the establisher's serde parse
re-checks as the backstop; see the Erratum below and Consequences).
Malformed targets are the typed `invalid_input`-class failure;
**`unknown_resource` can never fire on the direct op** (there is no
registry lookup).
- Encoding choice over the RFC-1928 ATYP byte string: the ATYP form is
SOCKS5's wire carrier and alksocks maps `TargetAddr` → this JSON
trivially (`to_target_addr` decomposes to host/port at the adapter),
but a self-describing JSON object is schema-describable (alkcall
ADR-047's registry `input_schema` runs before the establisher),
carries the unix path form without an extension, and is
human-readable in discovery listings and error messages. SOCKS5
remains the reason the *capability* exists, not the carrier the
*wire* encodes.
- **Dial-only: no listen/direct-BIND form.** The reverse shape
(`-R`-style ingress) is the listen-tunnel + the forwarded op
(ADR-008) — a direct-BIND form would duplicate its capability with
none of its metadata fidelity.
### 3. Scope: `TUNNEL_DIRECT_SCOPE = "tunnel:direct"`
The op's `AccessControl.required_scopes` is `["tunnel:direct"]` — a
separate grant from `tunnel:open`, never implied by it. "Dial
`postgres-primary`" and "dial anything from my network" are different
capabilities; the ACL separation is structural (scopes attach to op
specs, not params — ADR-006 §2's additivity rule). Scope strings are
ACL-stable once published (ADR-006 §2): renames are breaking; new
gates are new scopes on new ops.
### 4. Establisher: the dial shape without the registry
Same establisher contract as the dial shape (producer.md), minus the
lookup:
1. Parse and validate `target` against `substrate` (a mismatch —
`unix` without `path`, `tcp` with a bad port — is `invalid_input`
class).
2. Inject the parsed `TunnelTarget` into the assembly-provided
`DialFn` (the same halves function the base op's registry backings
resolve to; ADR-004 — the protocol never sees a socket type).
3. Return the dialed handle via `Establishment::new(plan)` — unchanged
plan-flow.
Error mapping (the dial table, minus the registry row):
| Producer condition | Typed reason |
|---|---|
| target malformed for the substrate | schema / `invalid_input`-class |
| dial refused / timed out / unreachable | `dial_failed` |
| bind/connect resource exhaustion | `resource_shortage` |
| establisher-internal failure | `handler_error` |
| the bound exceeded | `timeout` (wrapper-level) |
### 5. Consumer API
```rust
let session = TunnelSession::open_direct(&client, target, substrate).await?;
```
A separate constructor, not a params enum: two scopes are two
capabilities, and the caller states which one it holds. The session
type, data planes, and teardown API are identical to `open` (ADR-005).
### 6. Hub re-produce: the hub's policy governs the final dial
A hub that re-produces the direct op (terminate-and-re-produce, alkcall
ADR-042) applies **its own** target policy at the re-produce hop: the
hub's ACL, not the original opener's, governs the final dial. The hub
is the network boundary; the original opener's `tunnel:direct` grant
gets it to the hub, and the hub's own grant decides what it will dial
on the far side. Correct-by-default; pinned here as the security model
sentence (also ADR-006 amendment).
## Consequences
- **Wire-stable from the first consumer.** Op id, scope string, params
field names, and the target sub-schema are one-way doors; renames
are breaking. Additive evolution: a new substrate is a new `substrate`
value + a new target key (never a format change); new fields inside
a target object must be optional-with-default.
- **`SubstrateAddr`'s untagged leniency is schema-gated** (review 002
N-4): untagged serde variants cannot `deny_unknown_fields`, so a
cross-shape target (e.g. `{path, host}`) parses at the serde layer —
the SCHEMA's `additionalProperties: false` per branch is the real
shape-vs-substrate gate. One caution for future registry variants: a
schema-less registry would leave the establisher's serde backstop as
the only gate, and it keys `render_target` off the sibling
`substrate` — a cross-shape target that sneaked past would render
*something* (`/p` for the unix case) rather than fail; the serde
backstop renders the sibling-keyed target, so cross-shape garbage is
the schema's absence, not a serde failure. If this crate is ever
consumed against a schema-less registry variant, pin an ADR line
there before the first such consumer.
- **`SubstrateAddr` must not grow overlapping untagged variants.** If
a future variant's shapes overlap (e.g. a `{host, port, flowlabel}`
form), untagged disambiguation starts ordering (first-match wins
becomes load-bearing and silent) — switch to an internally-tagged
or untagged-with-content form at that point, deliberately (an ADR
line), never by accident. Pre-consumer and additive until then.
- **The BAST question is moot** — params is JSON inside the channels
open op; the data plane is unchanged (raw pass-through for tcp/unix,
the `[len: u16 BE]` codec for udp — ADR-003).
- **Discovery exposure**: the direct op's `input_schema` carries
`{substrate, target}`, so a discovery listing reveals that dynamic
egress exists and of which substrates — acceptable; the target
values (what was dialed) never enter discovery (they ride per-call
params). A producer wanting to hide the capability entirely simply
does not register the op (and old producers reject it loudly anyway).
- **`local` feature interaction:** the direct establisher composes the
same `DialFn` the base op uses; a producer that registered
`dial_tcp` for the base op can register the direct op with no new
substrate code. The `local` feature ships the dial functions; the
direct op itself is protocol-layer (default crate, wasm-clean).
- **SOCKS5 CONNECT composes directly:** the alksocks adapter's
`DialFn` seam maps the SOCKS5 CONNECT target (ATYP-decoded) onto
`open_direct` — the graduation's §4 table, now with the stand-in
dissolved.
## Erratum (2026-09-20, review 002 D-2)
§2's original wording said the target sub-schema is "`oneOf` on the
sibling `substrate` value, validated at establisher time". Both
halves of that sentence were stale against the shipped schema
(the decision itself was not — the code is the wire-stable side and
is correct):
1. **The shipped structure is an `allOf` of two `if`/`then`
branches**, not `oneOf`. `oneOf` of full-target-schemas would
actually be wrong here: a `unix`-substrate open would need to FAIL
the `tcp` branch, and plain `oneOf` on sibling-conditioned
subschemas is awkward (exactly-one-branch-matches semantics does
not compose with the base `properties`/`required`);
`if`/`then` composes correctly with them.
2. **Validation is registry-side, not establisher-time.** The
registry's `input_schema` gate runs BEFORE the establisher
(`malformed_target_is_schema_class_and_leaves_no_channel` asserts
the establisher's dial never runs on a malformed target); the
establisher's serde parse re-checks as the backstop. §2's text
above is corrected in place; wire.md §The Direct Op carries the
same corrected sentence.
## References
- `docs/research/tunnels-graduation.md` §2 (the ask, options, and the
lean) — the design discussion this ADR records
- alksocks `poc-bind-findings.md` (the scope-gate precedent) and
`poc-connect-wasm-findings.md` (the dial-at-command-read layering)
- alkcall ADR-047 (open ops + `input_schema` + discovery marker),
ADR-049 (establishment/typed errors), Gap F (op-name → ALPN
derivation — the flavor-form amendment target)
- alktunnels ADR-001 (the resource-params invariant the direct op
extends), ADR-002 Amendment 1 (flavor-form op ids), ADR-006
(scope posture + the hub-policy amendment)
- AGENTS.md conventions 9 (no forced binding — dial-only, no listen
form), 13 (access control), 17 (ALPN naming)
@@ -0,0 +1,341 @@
# ADR-008: Listen Metadata — Bind-First Establisher and the Forwarded Op
## Status
Accepted (2026-09-16); amended 2026-09-18 (the correlation token —
`listen` reply field + forwarded-params key, see Amendment 1)
## Context
alksocks' BIND POC (`poc-bind-findings.md` in the alksocks repo) pinned
two upstream asks for composed (`-R`-style) BIND fidelity:
1. **Bound-addr discovery without consuming an accept** — reply#1's
`BND.ADDR` must precede the app server's dial, but the listen
establisher is accept-first: its establishment phase pops an
already-accepted connection, so the open cannot complete (and
cannot report a bound address) until the app server dials.
2. **Peer metadata on accepted handles** — reply#2's peer address is
lost through the composed path; `TargetHandle` carries boxed halves
only, so the composed variant reports the sentinel.
The graduation research (`docs/research/tunnels-graduation.md` §3)
framed the option space: per-ask carriers (a reply-metadata projection
plus one of three addr-discovery shapes) versus ssh's
`forwarded-tcpip` shape as the combined alternative. Decision: the
`forwarded-tcpip` shape — **bind-first establisher + accept-as-open**
— because it resolves both asks *and* two structural problems the POC
recorded, with zero special carriers:
- The address-first-vs-accept-first **deadlock dissolves** instead of
being sequenced around: establishment = bind the listener (fast,
no app-server dependency), so the open-op reply carries the **real**
OS-chosen bound address (ask #1 solved natively).
- The **10s establishment-bound problem dissolves**: the accept wait
moves out of the establishment phase into the pump-handler phase
(whose lifetime is teardown-governed, not establishment-bounded) —
FTP-style long BIND waits stop resolving as spurious refusals.
- Ask #2 (peer metadata) rides the reverse channel's open-op params —
the wire's native "this connection is for X" moment.
All the machinery is POC-validated: `open_reverse_channel` +
`TunnelSession::adopt` + producer-allocated IDs (the reverse POC), the
listen establisher + `AcceptQueue` (POC #5), per-accept channel opens
over a serving connection (the reverse POC's hub shape).
## Decision
### 1. Third establisher shape: bind-first
The listen establisher gains a bind-first variant alongside the
accept-first shape (which stays — see §5):
- The establisher **binds the listener** (via the assembly-provided
listen function) and returns `Establishment::new(plan)` carrying the
listener handle. Establishment is fast — bounded by the bind, not by
any app-server dial.
- **The establisher mints a correlation token** (Amendment 1): a fresh
u32 unique per open on this producer, carried twice — inside the
plan (the pump handler needs it to correlate per-accept forwarded
opens) and as the additive `listen` reply field (§2). The plan also
carries the listen tunnel's substrate, stamped at establisher time
from the one successful params parse — the accept dispatcher reads
it from the plan and stamps it into each forwarded open's
`substrate` param; it never re-parses wire input (review 002 M-1:
the removed silent `Tcp` fallback). The original
§3 key (the listen channel's ID) is superseded: the serving-side
open-op wrapper never exposes the allocated channel ID to the
establisher or handler (alkcall 0.8.0's `OpenHandler` surface), and
a generic hub relay forwards params untouched (it strips/rewrites
only the reply's `channel_id`), so a spoke's forwarded open would
carry an id the end opener never allocated. A producer-minted token
rides both hops untouched — reply fields pass through relays
per-hop-truthful (alkcall ADR-051), and the same minted token
appears in every hop's reply.
- The establisher MAY contribute the observed bound address to the
open-op reply (§2): `"bound": { ... }` — the real OS-chosen address
(port `0` resolved), substrate-shaped like the direct op's target
(`{host, port}` for tcp/udp, `{path}` for unix).
- The pump handler for this shape is an **accept dispatcher**, not
`pump_bidi`: it accepts the channel's `BiStream` (the R-02 contract)
and runs the assembly-provided accept loop concurrently — each
accepted connection produces a forwarded channel (§3); the channel's
own `BiStream` stays idle and its EOF (the opener dropping the
listener session) cancels the loop and tears the listener down. The
idle-read check is deliberately WIDER than a bare EOF comparison
(review 002 N-2): the watch treats ANY read as teardown — `Ok(0)`
(the EOF sentinel), an error, **and** any non-empty read (a peer
that writes to the idle listen channel is misbehaving; the channel
is not a data plane — fail closed). An implementer reading only
"its EOF cancels the loop" and checking `Ok(0)` alone would hang on
a write-happy peer; the shipped posture is the any-byte one,
pinned by the write-happy-peer test.
### 2. The `bound` reply field (upstream ask)
The open-op reply gains an **optional, additive** field:
```json
{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 } }
```
- Present only when the establisher supplies it (bind-first listeners);
absent for dial establishers — old consumers ignore unknown reply
fields (plain JSON, alkcall `ResponseEnvelope`), so the change is
wire-additive.
- The carrier is an upstream alkcall ask (ADR-049 amendment 3:
establisher → reply projection — `Establishment` gains optional
reply fields the wrapper merges into the success output; the
`channel_id` key is wrapper-reserved). Filed in
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`.
**Landed in alkcall 0.8.0** (review 008 U-2, ADR-049 amendment 3:
`Establishment::with_reply_field` + the wrapper merge with the
reserved-key guard).
- **`listen` — the correlation token (Amendment 1).** A bind-first
establisher also contributes `"listen": <u32>`: the token the
forwarded op's params key on (§3, as amended). Same projection
carrier, same additive posture — a second reply field on the same
opens that carry `bound`. `bound` and `listen` are independent
fields (an accept-first establisher could supply `listen`-shaped
correlation too — the accept queue's establisher mints its own —
but v1 pins the pair on bind-first only).
- `bound` is informational surface, not addressing: a consumer MUST
NOT treat it as a dial target (the producer owns the backing —
ADR-001's invariant). It exists for RFC-1928-reply-class fidelity
(BND.ADDR) and observability.
### 3. The forwarded op: `channels/tunnel/forwarded`
When the listener accepts, the **producer opens a channel back toward
the listen-opener** — accept-as-open, the `forwarded-tcpip` shape:
- Op id **`channels/tunnel/forwarded`** (flavor-form, ADR-002
Amendment 1), `Sub`-typed, ALPN marker `alk/tunnel`. It is served by
the **listen-opener side** (the initiator of the listen-tunnel
open): the producer *calls* it per accept
(`open_reverse_channel`-shaped — the call plane on channel 0), the
opener's registry allocates the `channel_id`, the producer adopts it
(the ADR-047 §5 connection-owner rule — the serving side of the
forwarded call allocates; the producer-side initiator adopts) and
pumps the accepted connection's halves against the adopted channel.
- Params:
```json
{
"listen": 42113,
"substrate": "tcp",
"peer": { "host": "198.51.100.4", "port": 55231 }
}
```
- **`listen`** (u32, required) — the correlation token the
bind-first establisher minted and returned in the listen open's
reply (§2, as amended). The opener matches it against the
`listen` field of its open listener sessions — a `HashMap<u32, ..>`
on the opener side; no registry op, no extra wire field. The
token space is the producer's: fresh per open, unique on that
producer (the opener-side map is keyed by the producer's token,
and one opener's map may hold tokens from several producers — a
hash collision across producers is the opener's bookkeeping
choice, e.g. keying `(token)` uniquely or `(producer, token)`;
v1 leaves it assembly-side). **Amends the original `channel`
key** (the listen channel's ID): that semantic is unimplementable
as pinned — the serving-side wrapper never exposes the allocated
channel ID to the establisher or handler (alkcall 0.8.0's
`OpenHandler` surface), and generic hub relays forward params
untouched, so a spoke's forwarded open would carry an id the end
opener never allocated (§1's Amendment 1 note has the full
rationale). Tokens minted by the producer ride relays untouched:
the reply field passes through per-hop truthful (alkcall ADR-051),
and the forwarded open carries the same minted value at every
hop.
- **`substrate`** (string, required) — the listen tunnel's
substrate; shapes the forwarded channel's data-plane framing
(ADR-003), same discriminator semantics as everywhere else.
- **`peer`** (object, required) — the accepted connection's remote
address, substrate-shaped (`{host, port}` / `{path}`). This is ask
#2's carrier: reply#2-class fidelity rides the open op, the wire's
native metadata moment. Optional in kind, required in v1 — a
producer that cannot know the peer (rare; all POC'd listeners
can) omits... no: **required**; a producer that cannot determine
the peer does not serve a bind-first listener over that substrate.
- Scope: **`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"`** — the
opener-side grant for RECEIVING forwarded connections (ingress).
Separate from `tunnel:open` and `tunnel:direct` (ADR-006 amendment):
exposing a listener and dialing egress are different capabilities.
- The opener-side serving shape: `register_openable_with_establisher`
as-is (no establisher — the forwarded channel needs no
establishment; its data plane starts immediately), ACL-gated by the
scope, handler = correlate the open (via `listen`, the correlation
token) to the waiting
session, accept the `BiStream`, pump against the local BIND client's
halves. The correlation registry is assembly-side bookkeeping —
the protocol provides the op and the params, not the
wait/quiet-state machine.
- An older opener that does not serve the forwarded op rejects the
producer's per-accept call loudly (`NOT_FOUND`) — the SSH posture.
A producer serving bind-first SHOULD prefer openers that advertise
the forwarded op (discovery, alkcall ADR-047).
### 4. Consumer API
```rust
let listener = TunnelListener::open(&client, params).await?; // reply carries `bound` + `listen`
let bound = listener.bound(); // the resolved address
// ... per forwarded accept (opener side, served op):
// correlate by `listen`, accept the BiStream, pump against the
// local session's halves
listener.close(self); // or Drop — tears the listen channel + far listener down
```
`TunnelListener` is the listen-flow sibling of `TunnelSession`: it owns
the listen channel's teardown (ADR-005's ownership posture extended to
the listen flow); it presents `bound()` and the teardown API, and the
forwarded channels themselves arrive as separate adopted-side
`TunnelSession`s on the opener's manager. `TunnelSession::adopt` gains
nothing — forwarded channels are ordinary adopted channels. The
opener-side correlation registry (token → waiting listener session)
is assembly-side bookkeeping (consumer.md's posture); the crate's
`TunnelListener` type provides the `listen` token it needs to key it.
### 5. The accept-first shape stays
`listen_establisher` + `AcceptQueue` (the POC #5 composed shape)
remains a valid establisher shape for producers whose resource backing
IS an accept queue (an externally-fed or pre-bound listener shared
across opens). Bind-first is the shape for producers that own the
bind decision (the common hub case; alksocks' composed BIND). The two
shapes are per-registration assembly choices over the same op — no
wire distinction beyond `bound`'s presence.
## Consequences
- **Wire-stable from the first consumer:** the `bound` and `listen`
reply fields, the forwarded op id, its params, and `tunnel:forwarded`
are one-way doors. The reply fields are additive (absent = old-producer
compatible); the forwarded op is additive (old openers reject loudly).
- **Upstream coordination resolved:** the `bound`-projection ask
(ADR-049 amendment 3) and the flavor-form discovery derivation
(ADR-047 amendment) **landed in alkcall 0.8.0** (review 008, Units
1–2); the in-tree `ChannelRelay` + `HubLegTemplate` (ADR-051) came
with them — the hub re-produce machinery §6 assumed assembly-layer
is now upstream, and the relay passes reply fields through per-hop
truthful (which is what makes the Amendment 1 token compose through
hubs with zero tunnel-specific relay behavior). The bind-first
establisher is this crate's remaining implementation work
(tasks/tunnels).
- **`TargetHandle` does NOT gain a `peer` field.** The option-(i)
carrier from the graduation doc is superseded: with accept-as-open,
the peer rides the forwarded op's params; no process-local metadata
field, no sentinel. (If a future non-forwarded accept shape needs
metadata, revisit then — additive.)
- **The BIND reply#2 fidelity is end-to-end:** the opener-side
correlation writes reply#2 with the REAL peer from the forwarded
op's params. reply#1's `BND.ADDR` is the `bound` reply field. Both
POC stand-ins (carried `bind_addr`; sentinel `0.0.0.0:0`) retire.
- **Hub re-produce:** a hub re-producing a listen resource binds its
own listener (bind-first, its own `bound` + minted `listen` token)
and re-opens forwarded channels with the same token — the spoke's
forwarded opens carry the hub's token untouched (params are not
rewritten by the generic relay), and the metadata is per-hop
truthful, matching alkcall ADR-042's terminate-and-re-produce
posture. The end opener correlates on the hub's minted token — the
value it read from its own open's reply.
- **Local-variant BIND needs none of this** (the listener and the
accept are in-process — POC #5 variant 1, unchanged); the asks and
this ADR are scoped to the composed variant.
## References
- `docs/research/tunnels-graduation.md` §3/§3.1 (the ask ledger and
the `forwarded-tcpip` alternative this ADR adopts)
- alksocks `poc-bind-findings.md` (the two asks with evidence; the
deadlock and establishment-bound findings this shape dissolves)
- alkcall ADR-047 (open ops; connection-owner allocation; the
flavor-form amendment), ADR-049 (establishment; the reply-projection
amendment), ADR-042 (hub relay — re-produce id semantics)
- alktunnels ADR-001 (params per-op amendment), ADR-002 Amendment 1
(flavor-form op ids), ADR-005 (teardown ownership — extended to
`TunnelListener`), ADR-006 (scope posture amendment)
- AGENTS.md conventions 9 (no forced binding — the bind decision is
the producer's), 13 (access control), 8 (two-pump contract — the
accept dispatcher is a per-accept `pump_bidi`, not a hand-rolled
loop over one stream)
## Amendment 1 (the correlation token, 2026-09-18)
The decision stands — bind-first establisher, accept-as-open, the
`bound` reply field, the forwarded op. What changes: the forwarded
op's correlation key (§3's `channel` param) and the bind-first
establisher's reply surface (§2 gains `listen`).
- **The `channel`-as-listener-channel-ID key is superseded by a
producer-minted token.** Two facts forced the change, both found
while decomposing the implementation against the landed alkcall
0.8.0 surface:
1. **The serving side never learns its own channel ID.** The
open-op wrapper allocates the channel and replies with its ID,
but the `OpenHandler`/establisher surface is
`Fn(Value, Option<ChannelPlan>, Connection, AuthContext)` — no
ID anywhere. The §3 semantic ("the opener knows … without a
registry op") required the producer's accept dispatcher to know
its own listen channel's ID to stamp into forwarded opens;
unimplementable without a new upstream surface. (Filing one —
exposing the allocated ID to handlers — was considered and
rejected: it is an alkcall-surface change for a correlation
need this crate can solve in-protocol, and it would still not
fix (2).)
2. **Generic hub relays do not rewrite params.** The in-tree
`ChannelRelay` (ADR-051) forwards the open-op input untouched —
it strips/rewrites only the reply's `channel_id`. A spoke's
forwarded opens would carry the spoke-side listen channel's ID,
which the end opener never allocated and cannot correlate ("hubs
re-map the id naturally" assumed tunnel-specific relay behavior
the generic machinery deliberately does not have).
- **The token:** the bind-first establisher mints a fresh u32 per
open, unique on that producer; carries it in the plan (the accept
dispatcher reads it) and contributes it as the additive `listen`
reply field via the same `Establishment::with_reply_field` carrier
`bound` rides. Both fields pass through relays untouched
(reply fields ride per-hop-truthful, ADR-051), and the forwarded
open's `listen` param carries the minted token at every hop — the
end opener correlates on the value from its own open's reply.
- **Why not a consumer-minted token:** the opener choosing the token
and passing it in the listen open's params would work on the direct
path, but fails hub re-produce the same way (the hub's own
bind-first open is a NEW open — it cannot adopt the end opener's
token without a pass-through mechanism, which is exactly the
params-rewriting relays don't do). Producer-minted is the direction
information already flows (producer → opener via reply fields);
no new flow is needed.
- **Token lifecycle:** minted at establisher time (one per open —
two opens never share a token, so the opener's correlation map
needs no session disambiguation beyond the token); retired with
the listener session (the token's only consumer is the per-accept
forwarded opens of that listener). Uniqueness is per-producer;
the opener-side map keys on the token as read from its own reply.
- **Scope names, op ids, `bound`, `peer` — unchanged.** The wire
delta vs the pre-amendment ADR is exactly: the reply gains the
optional `listen` field, the forwarded params rename `channel` →
`listen` with token semantics. Nothing else shipped against either
(no consumer exists), so the change is pre-consumer and legal.
+79 -12
View File
@@ -1,6 +1,6 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-18 (OQ-TN-16 updated — ADR-008 Amendment 1: the correlation token)
---
# alktunnels — Open Questions
@@ -184,16 +184,23 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
### OQ-TN-12: Hub re-produce composition helper
- **Origin**: OQ-TN-05's remaining thread (2026-09-05 hub model)
- **Status**: deferred(scope)
- **Status**: resolved (upstream, 2026-09-18) — alkcall 0.8.0 ships
the in-tree `ChannelRelay` + `HubLegTemplate`/`HubLegImports`
(alkcall ADR-051, review 008 Unit 3): the relay openable wraps
from_call-imported marked specs on the consumer-leg registry via
the full open-op wrapper (ACL, cap/ledger, establishment bound,
teardown), the translate hop calls the producer leg with
`forwarded_for` from the consumer's per-call identity, the
byte-forward hop is `pump_bidi` awaited inline, the spoke reply's
`channel_id` is stripped/replaced (other fields ride through —
`bound` flows end-to-end per-hop truthful), and the adopted
producer-leg entry reclaims via a `Drop` guard on every plan path.
The composition helper a hub proxy needed is upstream — this crate
composes it (OQ-TN-05's resolution stands: no tunnel-side trait).
Tracker task: `architecture/oq-tn-12-tracker` (tasks/architecture/).
- **Priority**: low
- **Blocked on**: a concrete hub-proxy consumer (a crate that
re-produces a consumed tunnel resource) — the reverse POC's hub is
the seed shape (consumer + `register_openable` on the same
connection) but does not exercise re-export; if one materializes,
decide whether a composition helper belongs in this crate or is
assembly-layer wiring of the existing surface. Tracker task:
`architecture/oq-tn-12-tracker` (tasks/architecture/).
- **Cross-references**: OQ-TN-05, ADR-004, alkcall ADR-042 (relay)
- **Cross-references**: OQ-TN-05, ADR-004, alkcall ADR-042 (relay as
amended by ADR-051), ADR-008 §6 (the hub policy sentence)
### OQ-TN-13: UDP truncation semantics at the codec boundary
@@ -240,4 +247,64 @@ OQ-TN numbering. Format per `docs/sdd_process.md`.
the wire enum's `unix` value now has a v1 implementation path.
- **Cross-references**: ADR-003, ADR-004, overview.md; alktty
`tty-local.md` §pipe mode, alktty ADR-004 (exit code on a control
chunk)
chunk)
## Theme 5: The graduation (alksocks consumer, 2026-09-15/16)
### OQ-TN-15: The direct op (dynamic-target egress)
- **Origin**: `docs/research/tunnels-graduation.md` §2 (the alksocks
graduation discussion, 2026-09-15)
- **Priority**: high
- **Status**: resolved — [ADR-007](decisions/007-direct-open-op.md)
(2026-09-16). Option (a): a second `Sub`-typed open op
`channels/tunnel/direct` on the same `alk/tunnel` ALPN, params
`{substrate, target}` (self-describing JSON target keyed by the
sibling substrate — ATYP bytes rejected: not schema-describable, no
unix path form), scope `tunnel:direct` (never implied by
`tunnel:open` — structural ACL separation), dial-only (no
listen/direct-BIND form — ingress is the listen + forwarded
surface). The op-id convention needed an upstream alkcall amendment
(ADR-047's discovery derivation was `/sub`-shaped) — filed in
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
(ask #1) and **landed in alkcall 0.8.0** (ADR-047 amendment 3:
flavor-form op ids + the explicit `channel_open_alpn` discovery
field; hub relay reconstructs the marker).
- **Cross-references**: ADR-001 (per-op amendment), ADR-002
Amendment 1, ADR-006 Amendment 1, wire.md, producer.md
### OQ-TN-16: Listen metadata (bound-addr discovery + peer fidelity)
- **Origin**: `docs/research/tunnels-graduation.md` §3 (the alksocks
BIND POC's two asks: bound-addr without consuming an accept; peer
metadata on accepted handles)
- **Priority**: high
- **Status**: resolved — [ADR-008](decisions/008-listen-metadata-forwarded-op.md)
(2026-09-16; **amended 2026-09-18** — Amendment 1). The
`forwarded-tcpip` shape adopted over per-ask carriers: (1) a
bind-first listen establisher — establishment ends at bind time, so
the open-op reply carries the real OS-chosen address via an
additive `bound` field (ask #1; the carrier is the establisher →
reply projection — filed as the review's ask #2 and **landed in
alkcall 0.8.0** as ADR-049 amendment 3,
`Establishment::with_reply_field` + `open_channel_with_reply`),
and the address-first-vs-accept-first BIND deadlock + the 10s
establishment-bound problem dissolve with the shape; (2) the
forwarded op `channels/tunnel/forwarded` (accept-as-open) — the
producer opens a channel per accept with `{listen, substrate,
peer}` in params (ask #2's carrier; the `TargetHandle.peer` option
is superseded). Amendment 1: the correlation key is a
**producer-minted token** (`listen` reply field + forwarded
param), not the listen channel's ID — the serving-side handler
never sees its own channel ID (alkcall 0.8.0's `OpenHandler`
surface) and generic relays don't rewrite params, so a
producer-minted token (which rides reply fields and params through
hubs untouched) is the only key that works end-to-end. The
accept-first listen shape stays for accept-queue-backed resources.
Bonus from the same release: the in-tree `ChannelRelay` +
`HubLegTemplate` (alkcall ADR-051) pass `bound`/`listen` through
the hub per-hop truthful (ADR-008 §6 realized).
- **Cross-references**: ADR-002 Amendment 1 (the flavor-form op id),
ADR-006 Amendment 1 (`tunnel:forwarded`), ADR-005 (teardown
ownership extended to `TunnelListener`), producer.md,
consumer.md, wire.md
+63 -36
View File
@@ -1,6 +1,6 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-18 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008 as amended: the correlation token)
---
# alktunnels — Overview
@@ -16,30 +16,39 @@ details are in the sibling documents ([wire.md](wire.md),
## What
`alktunnels` registers the `alk/tunnel` ALPN's open op on alkcall
channels and provides the typed consumer session:
`alktunnels` registers the `alk/tunnel` ALPN's open ops on alkcall
channels and provides the typed consumer sessions:
- **The producer half** (a `channels/tunnel/sub` open op registered
via `ChannelCore::register_openable_with_establisher`, alkcall
ADR-047/049): the establisher validates params semantically
(registry lookup of `{resource, substrate}` → backing), dials (or
accepts for) the substrate, and returns the dialed handle via
`Establishment::new(plan)`; the pump handler awaits
- **The producer half** (open ops registered via
`ChannelCore::register_openable_with_establisher`, alkcall
ADR-047/049): the establisher validates params semantically, then
dials (or binds/accepts for) the substrate, and returns the handle
via `Establishment::new(plan)`; the pump handler awaits
`alkcall::channels::pump_bidi` over the channel and the substrate
halves (ADR-050, R-02's lifetime contract).
- **The consumer half** (`TunnelSession`, ADR-005): opens tunnel
channels (`ChannelClient::open_channel` on the forward path; the
reverse path's call + adopt equivalent), splits the channel
`BiStream`, presents the substrate-shaped data plane (raw halves or
datagram codec), and owns teardown (close/join/Drop).
halves (ADR-050, R-02's lifetime contract). Three ops on the one
ALPN (ADR-002 Amendment 1): `channels/tunnel/sub` (the resource
op), `channels/tunnel/direct` (dynamic-target egress, ADR-007), and
— served by the listen-opener side — `channels/tunnel/forwarded`
(accept-as-open, ADR-008). Three establisher shapes: dial, listen
(accept-first), and bind-first listen.
- **The consumer half** (`TunnelSession` / `TunnelListener`,
ADR-005/008): opens tunnel channels (`ChannelClient::open_channel`
on the forward path; the reverse path's call + adopt equivalent),
splits the channel `BiStream`, presents the substrate-shaped data
plane (raw halves or datagram codec), and owns teardown
(close/join/Drop). `TunnelListener` is the listen-flow session
(bound-address discovery + forwarded-connection receipt).
The guiding insight (the OQ-TN-01 reframe):
> A tunnel is a resource, not an address. `params` identify a produced
> resource + substrate; the producer owns the backing. Rich addressing
> (SOCKS5 ATYP, per-datagram remotes) enters only through the
> `-D`/dynamic composition path — inside the tunnel payload, never in
> the wire.
> A tunnel is a resource, not an address. Base params identify a
> produced resource + substrate; the producer owns the backing. The
> one additive exception is the direct op (ADR-007): a consumer may
> ask the producer to dial an arbitrary target — a different
> capability, its own op and its own scope (`tunnel:direct`), never
> implied by `tunnel:open`. Rich per-datagram addressing (SOCKS5 ATYP,
> udpgw-style remotes) still enters only through the `-D`/dynamic
> composition path — inside the tunnel payload, never in the wire.
## Why
@@ -68,12 +77,17 @@ A tunnel channel's life (full detail in [wire.md](wire.md)):
1. **Open (the establishment phase).** The consumer calls
`channels/tunnel/sub` with `params = {resource, substrate}`
(ADR-001). The producer's establisher — the open op's awaited
phase (alkcall ADR-049) — resolves the resource, dials the
(ADR-001) — or `channels/tunnel/direct` with
`params = {substrate, target}` (ADR-007, dynamic egress). The
producer's establisher — the open op's awaited phase (alkcall
ADR-049) — resolves the resource (or parses the target), dials the
substrate, and returns the handle in the plan. Failure is a typed
`channel:open_failed` (`dial_failed` / `unknown_resource` /
`resource_shortage` / `handler_error` / `timeout`); the channel
never exists on the opener's side afterward.
never exists on the opener's side afterward. A bind-first listen
open completes at bind time and may carry `"bound"` (the resolved
address) and `"listen"` (the correlation token) in the reply
(ADR-008 §2/Amendment 1).
2. **Pump (the data plane).** Two pumps, one per direction, via
`pump_bidi` (ADR-050): each shuts the opposite sink down on
completion; half-close semantics fall out. Stream substrates ride
@@ -88,14 +102,17 @@ Direction is not in the protocol: whoever can reach the target is the
producer; whoever wants the bytes is the consumer; connection
direction (who dialed the transport) is independent of tunnel
direction (OQ-TN-03's hub model). `-L`, `-R`, and `-D` are assembly
shapes over the same open op.
shapes over the same ops; the `-R` composed flow rides the bind-first
establisher + the forwarded op (ADR-008).
## Dependencies
```
alktunnels (default — wasm-clean)
├── alkcall 0.7.0 (core types, channels: ChannelCore/ChannelClient/manager,
│ pump_bidi, ServingConfig identity seam — CF-005/006)
├── alkcall 0.8.0 (core types, channels: ChannelCore/ChannelClient/manager,
│ pump_bidi, ServingConfig identity seam — CF-005/006,
│ establisher reply projection + flavor-form discovery
│ derivation + ChannelRelay/HubLegTemplate — review 008/ADR-051)
├── tokio (wasm-clean subset: rt, sync, io-util, macros, time)
├── bytes, futures, serde/serde_json, thiserror, tracing, async-trait
└── (no backend deps — sockets/process live in feature-gated modules)
@@ -122,14 +139,19 @@ trivial here, since the codec is a 2-byte length prefix).
```
src/
lib.rs — re-exports; crate docs
params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001)
params.rs — TunnelParams {resource, substrate} + open-op input schema (ADR-001);
TunnelTarget {substrate, target} + direct-op schema (ADR-007);
TunnelForwardedParams {listen, substrate, peer} (ADR-008 as amended);
the three scope constants
wire.rs — the data-plane codec: frame_datagram / DatagramReader (ADR-003)
producer.rs — the open op: tunnel_open_spec (scope-gated), establisher
shapes (dial + listen), pump handler (pump_bidi),
register_tunnel_openable (ADR-004 shapes)
consumer.rs — TunnelSession: open / adopt / stream_halves /
take_halves / send_datagram /
recv_datagram / pump_against / close / join / Drop (ADR-005)
producer.rs — the open ops: tunnel_open_spec + direct/forwarded spec builders
(scope-gated), establisher shapes (dial + listen + bind-first),
pump handler (pump_bidi / accept dispatcher),
register_tunnel_*_openable (ADR-004/007/008 shapes)
consumer.rs — TunnelSession: open / open_direct / adopt / stream_halves /
take_halves / send_datagram / recv_datagram / pump_against /
close / join / Drop (ADR-005); TunnelListener: open / bound /
forwarded-receipt / close / Drop (ADR-008)
error.rs — TunnelError + the typed open-error surface (ADR-049 §4)
local/ — the `local` feature module: real socket halves functions
```
@@ -147,7 +169,9 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [003](decisions/003-codec-and-udp-framing.md) | Data-plane codec | Raw pass-through (stream) / mandatory `[len: u16 BE]` (UDP — F-2 mandate); `len=0` is a legal empty datagram |
| [004](decisions/004-no-backend-trait.md) | No TunnelBackend trait | Halves functions at the assembly layer; listen is an establisher shape; trait re-evaluated at the 3-consumer threshold |
| [005](decisions/005-consumer-session-owns-teardown.md) | Consumer session owns teardown | `TunnelSession` with close/join/Drop — the W3 gap closes structurally |
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1 |
| [006](decisions/006-access-control-posture.md) | Access-control posture | The open gate is the boundary: `tunnel:open` scope (stable once published), op-level ACL, ownership seam, no allowlists in v1; amended — `tunnel:direct`/`tunnel:forwarded` scopes + the hub re-produce policy sentence |
| [007](decisions/007-direct-open-op.md) | The direct open op | `channels/tunnel/direct`: dynamic-target egress, `{substrate, target}` params, `tunnel:direct` scope, dial-only — the ssh `direct-tcpip` capability with structural ACL separation |
| [008](decisions/008-listen-metadata-forwarded-op.md) | Listen metadata + the forwarded op | Bind-first establisher (reply `bound` field = BND.ADDR fidelity; `listen` = the correlation token), `channels/tunnel/forwarded` accept-as-open (params carry `{listen, substrate, peer}` = reply#2 fidelity) — the `forwarded-tcpip` shape; amended — producer-minted correlation token supersedes the channel-ID key |
## Open Questions
@@ -155,10 +179,13 @@ Open questions are tracked in [open-questions.md](open-questions.md).
Key questions affecting this document:
- **OQ-TN-11**: resource naming collision domain + lifecycle (partially resolved)
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope))
- **OQ-TN-12**: hub re-produce composition helper (deferred(scope) —
the policy sentence is pinned: the hub's ACL governs the final dial)
- **OQ-TN-13**: UDP truncation semantics (resolved — fail-loud, ADR-003)
- **OQ-TN-14**: unix/stdio placement (resolved — unix in via
`local`, stdio out: alktty's pipe mode owns process stdio)
- **OQ-TN-15**: the direct op (resolved — ADR-007)
- **OQ-TN-16**: listen metadata (resolved — ADR-008)
## References
+193 -22
View File
@@ -1,22 +1,28 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-18 (the direct op, the bind-first establisher, the forwarded op — ADR-007/008 as amended: the correlation token)
---
# alktunnels — Producer Half
The producer half: the `channels/tunnel/sub` open op — its spec, its
establisher (dial + listen shapes), its pump handler, and its
registration. The producer is whoever can reach the tunnel target;
connection direction is irrelevant (OQ-TN-03's hub model). The reverse
POC validated the producer running on the connect side of its own
connection (`from_connection_with_serving`).
establishers (dial + listen shapes), its pump handler, and its
registration — plus the sibling ops on the same ALPN: the direct op
(`channels/tunnel/direct`, dynamic-target egress) and the forwarded op
(`channels/tunnel/forwarded`, accept-as-open). The producer is whoever
can reach the tunnel target; connection direction is irrelevant
(OQ-TN-03's hub model). The reverse POC validated the producer running
on the connect side of its own connection
(`from_connection_with_serving`).
Design decisions: [ADR-001](decisions/001-open-params-layout.md)
(params), [ADR-002](decisions/002-alpn-strategy.md) (ALPN),
[ADR-003](decisions/003-codec-and-udp-framing.md) (codec),
[ADR-004](decisions/004-no-backend-trait.md) (no backend trait). This
document is the normative WHAT.
[ADR-004](decisions/004-no-backend-trait.md) (no backend trait),
[ADR-007](decisions/007-direct-open-op.md) (the direct op),
[ADR-008](decisions/008-listen-metadata-forwarded-op.md) (listen
metadata, bind-first, the forwarded op). This document is the
normative WHAT.
## The Open-Op Spec
@@ -50,6 +56,35 @@ posture — the reverse POC's W2): the dispatcher reads through the
shared `Arc<OperationRegistry>` per dispatch. Late registration is a
supported assembly shape, not a race.
## The Direct Op (`channels/tunnel/direct`)
Dynamic-target egress (ADR-007): the same ALPN, a second `Sub`-typed
open op whose establisher dials a per-call target instead of resolving
a resource. One ALPN, two ops, two scopes — dial-what-the-producer-
registered (`tunnel:open`) and dial-anything (`tunnel:direct`) are
different capabilities.
```text
operation id: channels/tunnel/direct
op type: Sub (same reply/wrapper shape as …/sub)
ALPN marker: alk/tunnel (ChannelOpenSpec)
visibility: External
input schema: { substrate: enum [tcp, udp, unix] (required),
target: object, per-substrate sub-schema (required) }
output schema: { channel_id: integer > 0 }
access control: required_scopes: ["tunnel:direct"]
description: SHOULD carry a human-readable hint (dynamic-target egress)
```
- **No registry lookup** — the establisher parses `target` against
`substrate` (malformed → `invalid_input`-class; `unknown_resource`
can never fire) and injects the parsed `TunnelTarget` into the same
assembly-provided `DialFn` the base op's backings resolve to.
- **Dial-only** — no listen/direct-BIND form; the ingress capability
is the listen + forwarded surface (ADR-008).
- Registration: `register_tunnel_direct_openable` (same wrapper, same
plan-flow; the spec builder is public for assembly variants).
## The Establisher
The establisher is the open op's awaited, bounded establishment phase
@@ -62,7 +97,7 @@ reverse POC F-1, documented upstream). On failure:
`EstablishmentError` (the typed reason codes) → the wrapper tears the
channel down and replies `channel:open_failed`.
**Two shapes** (both return the same plan-flow; ADR-004):
**Three shapes** (all return the same plan-flow; ADR-004):
### 1. Dial establisher (the `-L`/forward shape)
@@ -79,6 +114,10 @@ never sees a socket type. Failure mapping:
| establisher-internal failure | `handler_error` |
| the bound exceeded | `timeout` (wrapper-level) |
The direct establisher is this shape minus the lookup row: target
malformed for the substrate is the schema/`invalid_input`-class
failure (ADR-007 §4's table).
### 2. Listen establisher (the producer-side listener variant)
For a producer whose resource is a LISTENER (SSH `-R` far-side
@@ -91,6 +130,58 @@ mapping: an empty queue during establishment is `resource_shortage`;
a closed listener is `dial_failed`-class. No new wire surface — the
listen variant is plan-flow with a different halves source.
**The accept-first shape stays** (ADR-008 §5): for producers whose
resource backing IS an accept queue (an externally-fed or pre-bound
listener shared across opens), this remains the correct shape — the
POC #5 composed-BIND shape. Its limits (no `bound` in the reply — the
accept hasn't happened at establishment; the POC's
establishment-bound-vs-wait problem) are inherent to accepting-first.
### 3. Bind-first listen establisher (the `-R`/ingress shape, ADR-008)
For a producer that OWNS the bind decision (the common hub case;
alksocks' composed BIND): the establisher **binds the listener** (via
the assembly-provided listen function — the protocol never binds,
OQ-TN-04) and returns the listener handle as the plan payload.
Establishment is fast — bounded by the bind, not by any app-server
dial:
- **The establishment phase ends at bind time**, so the open-op reply
can carry the REAL OS-chosen address: the establisher contributes
`"bound": {host, port}` (or `{path}`) to the reply — the
establisher → reply projection (ADR-049 amendment 3,
`Establishment::with_reply_field`; the `channel_id` key is
wrapper-reserved). **Landed in alkcall 0.8.0** (review 008 U-2);
consumers read it via `ChannelClient::open_channel_with_reply`.
- **The establisher mints the correlation token** (ADR-008
Amendment 1): a fresh u32 per open, contributed as the `"listen"`
reply field (the same projection) and carried in the plan — the
accept dispatcher reads it from the plan and stamps it into each
forwarded open's `listen` param. The plan carries
`{listener, token, substrate}` (a bind-first plan type; the
substrate is stamped at establisher time — the dispatcher reads it
from the plan, never re-parses wire input); the token is
relay-transparent by construction (reply fields and params both
pass hubs untouched — alkcall ADR-051; ADR-008 Amendment 1 has the
why-superseded of the channel-ID key).
- **The pump handler for this shape is an accept dispatcher**, not
`pump_bidi`: it accepts the channel's `BiStream` (the R-02
contract), then runs the assembly-provided accept loop — each
accepted connection produces a forwarded-channel open toward the
listen opener (§The Forwarded Op below); the accepted halves pump
against the adopted channel. The listen channel's own `BiStream`
stays idle; its EOF (the opener dropping the listener session)
cancels the loop and tears the listener down.
- Error mapping: unbindable/held port → `dial_failed`-class (the
bind IS the dial here); bind resource exhaustion →
`resource_shortage`; the rest of the table unchanged.
- The address-first-vs-accept-first BIND deadlock (the alksocks POC's
one real discovery) DISSOLVES in this shape: the open completes at
bind time, reply#1-class metadata (the `bound` field) precedes any
app-server dial, and the accept wait lives in the pump phase —
teardown-governed, not establishment-bounded (the 10s bound problem
dissolves with it).
**Identity:** the establisher receives the **per-call opener
identity** (the dispatch-resolved identity overlaid onto the
install-time context — alkcall 0.7.0 CF-006). On hub-forwarded opens
@@ -127,11 +218,49 @@ JoinHandle<()>`):
at runtime. Hand-rolling the two-pump shape is out (ADR-050).
4. UDP: the plan payload is the FRAMED adapter (ADR-003) — the pump
is unchanged; the codec lives at the substrate/pump boundary.
5. **Bind-first listen handler (the accept dispatcher, ADR-008 §1):**
the plan payload is the listener; after `accept_bi()`, the handler
runs the accept loop (assembly-provided) concurrently with holding
the channel's `BiStream` — each accepted connection produces a
forwarded-channel open toward the listen opener. The listen
channel's own stream stays idle (no pumps on it); its EOF cancels
the loop (the listener tears down with the session). Per-accept
data planes are still `pump_bidi` (convention 8 — per-channel,
never a hand-rolled loop over one stream).
**The handler is substrate-agnostic by construction:** it cannot know
whether the halves came from TCP, UDP, a unix socket, or an
in-process pipe, and must not (AGENTS.md convention 7).
## The Forwarded Op (opener side, ADR-008 §3)
Accept-as-open: when the bind-first listener accepts, the PRODUCER
opens a channel back toward the listen opener. The opener SERVES the
op (`channels/tunnel/forwarded`, scope `tunnel:forwarded` — ingress;
no establisher, the data plane starts immediately):
1. The producer's per-accept open carries `{listen, substrate, peer}`
— the correlation token from the plan (the value the establisher
minted; the opener matches it against the `listen` field of its
open listener sessions — ADR-008 Amendment 1), the substrate
(also from the plan — stamped at establisher time from the one
successful params parse; the dispatcher never re-parses wire
input), the accepted connection's remote address (reply#2-class
fidelity's carrier).
2. The opener's registry allocates the `channel_id`; the producer
adopts it (`TunnelSession::adopt`) and pumps the accepted halves
against it (`pump_against`).
3. The opener-side handler correlates the open (via `listen`) to the
waiting session (assembly-side bookkeeping — the protocol provides
the op and params, not the wait state machine), accepts the
`BiStream`, and pumps against the local session's halves.
4. Identity: the forwarded op's opener-side gate sees the PRODUCER's
per-call identity (CF-005 precedence — the producer may carry an
`auth_token` when it is itself forwarding); the peer address in
params is producer-asserted data, not transport truth — the ACL
trusts the producer's grant, the application treats `peer` as
metadata.
## Registration API (the spec surface)
```rust
@@ -146,6 +275,26 @@ pub fn register_tunnel_openable(
auth: AuthContext, // the install-time context (per-call overlay is upstream's)
dial: DialFn, // substrate halves function (ADR-004; `local` feature or assembly)
) -> Result<(), String>
pub fn register_tunnel_direct_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
dial: DialFn, // the SAME DialFn the base op uses; receives the parsed TunnelTarget
) -> Result<(), String>
pub fn register_tunnel_listen_openable( // accept-first (shape 2, unchanged)
... // as today: listen_establisher + AcceptQueue
)
pub fn register_tunnel_bindfirst_openable( // bind-first (shape 3, ADR-008)
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
bind: BindFn, // substrate bind function -> (listener, bound addr)
accept: AcceptFn, // per-accept: open the forwarded channel, pump
) -> Result<(), String>
```
```
- `ResourceRegistry` (assembly construct, OQ-TN-11): maps
@@ -153,24 +302,36 @@ pub fn register_tunnel_openable(
candidate; the collision domain is the producer's registry
(ADR-001 — two producers on one connection may expose the same
resource name independently).
- The scope constant `TUNNEL_OPEN_SCOPE = "tunnel:open"`; the open-op
spec builder is public so assembly layers can compose variants
(e.g. extra ACL fields) without forking the wire shape.
- The scope constants `TUNNEL_OPEN_SCOPE = "tunnel:open"`,
`TUNNEL_DIRECT_SCOPE = "tunnel:direct"`, and
`TUNNEL_FORWARDED_SCOPE = "tunnel:forwarded"` (ADR-006 Amendment 1);
the open-op spec builders are public so assembly layers can compose
variants (e.g. extra ACL fields) without forking the wire shape.
- The pump handler factory is public for the same reason.
## Access control posture
Tunnels reach local networks — the open gate is the security boundary
(OQ-TN-08):
(OQ-TN-08, ADR-006 + Amendment 1):
- The registry runs `AccessControl` before the establisher (the ACL
rides `register_openable`; free via alkcall).
- `TUNNEL_OPEN_SCOPE` is the base gate; the assembly layer MAY add
resource-scoped checks via the establisher (ownership on the
resource name) — the protocol provides the seam, not the policy.
- No target allowlists, no tunnel-specific policy machinery in v1 —
the far side owns the resource (OQ-TN-08 resolution); discovery is
the ACL-filtered ops listing.
- `TUNNEL_OPEN_SCOPE` is the base gate; `TUNNEL_DIRECT_SCOPE` gates
dynamic-target egress (never implied by `tunnel:open`);
`TUNNEL_FORWARDED_SCOPE` gates ingress (receiving forwarded
connections) on the opener side. Three scopes, three capabilities.
- The assembly layer MAY add resource-scoped checks via the
establisher (ownership on the resource name) — the protocol
provides the seam, not the policy. For the direct op the seam is
the parsed `TunnelTarget` (a deployment MAY enforce target
allowlists in its establisher — policy, not protocol).
- No target allowlists in the PROTOCOL, no tunnel-specific policy
machinery in v1 — the far side owns the resource (OQ-TN-08
resolution); discovery is the ACL-filtered ops listing.
- **Hub re-produce policy (the security model sentence, ADR-006
Amendment 1):** a hub re-producing the direct op applies ITS OWN
target policy at the re-produce hop — the hub's ACL governs the
final dial. The hub is the network boundary.
## Open Questions
@@ -179,12 +340,22 @@ Tunnels reach local networks — the open gate is the security boundary
candidate)
- **OQ-TN-14**: resolved (split: unix in, stdio out — alktty's pipe
mode owns process stdio; see open-questions.md)
- **OQ-TN-15/16**: resolved (ADR-007/008 — the direct op, listen
metadata, the forwarded op)
## References
- [wire.md](wire.md) (the open op's wire surface),
- [wire.md](wire.md) (the ops' wire surface),
[overview.md](overview.md) (module map)
- Forward POC `docs/research/poc-summary.md`; reverse POC
`docs/research/reverse-poc-summary.md` (both producer shapes ran)
- `docs/research/tunnels-graduation.md` (the graduation research;
§3.1 is the shape bind-first + the forwarded op adopt)
- alkcall ADR-047/049 (open ops + establishment), ADR-050 (`pump_bidi`),
ledger CF-005/CF-006 (identity precedence + per-call opener)
ledger CF-005/CF-006 (identity precedence + per-call opener)
- Upstream asks (review 008): `alkcall/docs/reviews/
008-tunnels-graduation-upstream-review.md` — both landed in
alkcall 0.8.0 (flavor-form discovery derivation, ADR-047
amendment 3; the establisher → reply `bound` projection, ADR-049
amendment 3), plus the in-tree `ChannelRelay`/`HubLegTemplate`
(alkcall ADR-051)
+156 -7
View File
@@ -1,6 +1,6 @@
---
status: implemented
last_updated: 2026-09-10
status: draft
last_updated: 2026-09-20 (review 002 D-1/D-2 sync: the params schemas' port bounds, `additionalProperties: false` gates, `listen` `minimum: 1`, and the registry-side gate ordering recorded; ADR-007's oneOf wording corrected to the shipped allOf/if-then)
---
# alktunnels — Wire Format
@@ -13,7 +13,8 @@ its own 8-byte header transparently (alknet ADR-093); the tunnel
protocol owns everything inside the `BiStream`.
Design decisions live in the ADRs ([001](decisions/001-open-params-layout.md),
[002](decisions/002-alpn-strategy.md), [003](decisions/003-codec-and-udp-framing.md));
[002](decisions/002-alpn-strategy.md), [003](decisions/003-codec-and-udp-framing.md),
[007](decisions/007-direct-open-op.md), [008](decisions/008-listen-metadata-forwarded-op.md));
this document is the normative WHAT.
## The Open Op (`channels/tunnel/sub`)
@@ -49,6 +50,8 @@ this document is the normative WHAT.
- Success: `{ "channel_id": <u32> }` — the channel is open, the
establishment phase (dial) succeeded, the pump handler is running.
Bind-first listen opens MAY add `"bound"` (§The Listen Open Op's
Reply below).
- Failure: a typed `channel:open_failed` call error with
`details: { reason, message }` — `reason ∈ dial_failed` /
`unknown_resource` / `resource_shortage` / `handler_error` /
@@ -64,6 +67,140 @@ this document is the normative WHAT.
the establisher and pump handler receive the per-call opener
identity (CF-006).
## The Direct Op (`channels/tunnel/direct`)
Dynamic-target egress (ADR-007) — "dial what I name," not "dial what
the producer registered." `Sub`-typed, ALPN marker `alk/tunnel`; the
same establishment phase and wrapper as `…/sub`. Scope gate:
`["tunnel:direct"]` (`TUNNEL_DIRECT_SCOPE`) — **never implied by
`tunnel:open`** (ADR-006 Amendment 1).
### Input (params)
```json
{
"substrate": "tcp",
"target": { "host": "db.internal", "port": 5432 }
}
```
- `substrate`: string, required, enum `["tcp", "udp", "unix"]` — the
same discriminator semantics as `…/sub` (ADR-001); selects the
data-plane framing.
- `target`: object, required, keyed by the sibling `substrate` —
- `tcp`/`udp` → `{ "host": <string>, "port": <u16> }` (domain-form
hosts stay unresolved on the wire — producer-side resolution, the
ssh `-D` semantic);
- `unix` → `{ "path": <string> }`.
- `input_schema` pins both required; the target sub-schema is
per-substrate — an `allOf` of two `if`/`then` branches keyed on the
sibling `substrate` value, validated **registry-side** (the schema
gate runs before the establisher — the behavior
`malformed_target_is_schema_class_and_leaves_no_channel` pins; the
establisher's serde parse re-checks as the backstop). Malformed
targets are the `invalid_input`-class failure; **`unknown_resource`
can never fire on the direct op** (no registry lookup).
- Schema bounds (wire-stable — the target sub-schema is part of the
one-way door, ADR-007 §"Wire-stable"): each branch's target object
carries `"type": "object"` and `"additionalProperties": false` —
- `tcp`/`udp` → `{ "host": {"type": "string"}, "port": {"type":
"integer", "minimum": 0, "maximum": 65535} }`, both required;
- `unix` → `{ "path": {"type": "string"} }`, required.
The `additionalProperties: false` gate is what makes a cross-shape
target (a `unix` open carrying `host`) fail the registry's
`INVALID_INPUT` class *before* the establisher — not a serde-level
quirk.
### Reply / errors
- Success: `{ "channel_id": <u32> }` — identical semantics to
`…/sub`; no `bound` field (dial establishers do not bind).
- Failure: the same typed surface as `…/sub` minus `unknown_resource`,
plus the schema/`invalid_input` class for malformed targets.
## The Listen Open Op's Reply — the `bound` and `listen` Fields
A bind-first listen open (ADR-008 §1, served by `…/sub` with the
bind-first establisher) MAY add optional, additive reply fields:
```json
{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 },
"listen": 918273 }
```
- `bound` is substrate-shaped like the direct op's target
(`{host, port}` / `{path}`); the REAL OS-chosen address (port `0`
resolved).
- `listen` is the correlation token (ADR-008 Amendment 1): a u32 the
establisher minted, unique per open on that producer — the key the
forwarded op's params carry (§The Forwarded Op below). Riding the
reply is what makes it relay-transparent: reply fields pass through
hub re-produce untouched (alkcall ADR-051), and the same minted
token reappears in every hop's reply.
- Present only when the establisher supplies them; absent for dial
establishers and accept-first listen opens. Old consumers ignore
unknown reply fields (plain JSON) — wire-additive.
- `bound` is informational surface, not addressing: a consumer MUST
NOT treat `bound` as a dial target (the producer owns the backing —
ADR-001). It exists for RFC-1928-reply-class fidelity (BND.ADDR)
and observability.
## The Forwarded Op (`channels/tunnel/forwarded`)
Accept-as-open (ADR-008 §3): when a bind-first listener accepts, the
PRODUCER opens a channel back toward the listen-opener side. The
producer *calls* the op (the opener SERVES it — the ADR-047 §5
connection-owner rule: the serving side allocates the `channel_id`,
the producer-side initiator adopts). `Sub`-typed, ALPN marker
`alk/tunnel`; no establisher (the data plane starts immediately).
Scope gate (opener side): `["tunnel:forwarded"]`
(`TUNNEL_FORWARDED_SCOPE`) — ingress, a separate grant from
`tunnel:open`/`tunnel:direct` (ADR-006 Amendment 1).
### Input (params)
```json
{
"listen": 918273,
"substrate": "tcp",
"peer": { "host": "198.51.100.4", "port": 55231 }
}
```
- `listen`: integer > 0, required — the correlation token from the
listen open's `listen` reply field (ADR-008 Amendment 1; the
producer-minted token, relay-transparent). The opener correlates
the accept to its waiting listener session by matching it. The
schema pins `"minimum": 1` and the serde type pins it as the
backstop (a `listen: 0` on the wire is a malformed open, rejected
at parse time — never silently accepted).
- `substrate`: string, required, enum — the listen tunnel's
substrate; shapes the forwarded channel's data-plane framing.
- `peer`: object, required — the accepted connection's remote address,
substrate-shaped (`{host, port}` / `{path}`). Reply#2-class
fidelity's carrier (ask #2).
- Schema bounds (wire-stable, mirroring the direct op's target
sub-schema): the per-substrate `peer` sub-schema is an `allOf` of
two `if`/`then` branches keyed on the sibling `substrate`, each
branch's object carrying `"type": "object"` and
`"additionalProperties": false` —
- `tcp`/`udp` → `{ "host": {"type": "string"}, "port": {"type":
"integer", "minimum": 0, "maximum": 65535} }`, both required;
- `unix` → `{ "path": {"type": "string"} }`, required.
Malformed peers fail the registry's schema gate before the
opener-side handler runs.
### Reply / errors
- Success: `{ "channel_id": <u32> }` — the opener-allocated ID the
producer adopts, then pumps the accepted halves against.
- Failure: a producer-side accept correlation miss is the typed
`channel:open_failed`/`handler_error`-class error; an opener without
the scope is `FORBIDDEN`; an old opener that does not serve the op
rejects loudly (`NOT_FOUND` — the SSH posture).
## The Data Plane
One data stream per direction on one channel — the channel ID is the
@@ -162,16 +299,28 @@ UDP substrate, one direction's wire view:
- **OQ-TN-13**: resolved (fail-loud, ADR-003) — see the Truncation
bullet
- **OQ-TN-02**: resolved — see the ADR-003 mandate
- **OQ-TN-15**: resolved — see ADR-007 (the direct op)
- **OQ-TN-16**: resolved — see ADR-008 (the `bound`/`listen` fields +
the forwarded op)
## References
- [ADR-001](decisions/001-open-params-layout.md) (params),
[ADR-002](decisions/002-alpn-strategy.md) (ALPN),
- [ADR-001](decisions/001-open-params-layout.md) (params, per-op
amendment),
[ADR-002](decisions/002-alpn-strategy.md) (ALPN + flavor-form op
ids),
[ADR-003](decisions/003-codec-and-udp-framing.md) (codec),
[ADR-005](decisions/005-consumer-session-owns-teardown.md) (session
teardown)
teardown),
[ADR-007](decisions/007-direct-open-op.md) (the direct op),
[ADR-008](decisions/008-listen-metadata-forwarded-op.md) (listen
metadata + the forwarded op)
- Forward POC `docs/research/poc-summary.md` (codec + sentinel
layering, 17 tests); reverse POC
`docs/research/reverse-poc-summary.md` (F-2 mandate, W4 half-close)
- alkcall ADR-047 (open ops), ADR-049 (establishment/typed errors),
ADR-050 (`pump_bidi`); alknet ADR-071/093 (channels wire)
ADR-050 (`pump_bidi`); alknet ADR-071/093 (channels wire)
- `docs/research/tunnels-graduation.md` (the graduation research the
new ops implement); the upstream asks (review 008) landed in
alkcall 0.8.0 — see
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
+321
View File
@@ -0,0 +1,321 @@
---
status: decided
last_updated: 2026-09-16 (§2 → ADR-007; §3/§3.1 → ADR-008 — the leans are pinned, see the status note)
---
# alksocks research — graduating proxy surface into alktunnels
**Status note (2026-09-16).** Phase 1 decided both asks; this doc is
the research record, the ADRs are now normative:
- **§2 (the direct op) →
[ADR-007](../architecture/decisions/007-direct-open-op.md)** —
option (a) confirmed, with one refinement: the `target` encoding is
a self-describing JSON object keyed by the sibling `substrate`
field (`{host, port}` / `{path}`), not the ATYP byte string — the
registry's `input_schema` validation (alkcall ADR-047) needs
schema-describable params, and the unix path form falls out for
free. Op id `channels/tunnel/direct`, scope `tunnel:direct`,
dial-only.
- **§3/§3.1 (listen metadata) →
[ADR-008](../architecture/decisions/008-listen-metadata-forwarded-op.md)** —
the §3.1 `forwarded-tcpip` alternative ADOPTED over the per-ask
carriers: a bind-first listen establisher (reply carries the real
`bound` address — ask #1; also dissolves the POC's deadlock and
10s-bound findings) + the forwarded op `channels/tunnel/forwarded`
with `{listen, substrate, peer}` params (ask #2; ADR-008 Amendment
1, 2026-09-18 — the correlation key is a producer-minted token, the
original channel-ID key was unimplementable against the landed
handler surface). The
`TargetHandle.peer` option (§3 ask #2, option i) is superseded.
- **A discovery this research missed:** alkcall ADR-047 pins
discovery's ALPN derivation to exactly `…/sub`/`…/pub` op-id shapes
(`derive_alpn_from_op_name`) — flavor-form op ids (both new ops)
need an upstream alkcall amendment before the first consumer. Filed:
`alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`
(both asks landed in alkcall 0.8.0, plus the in-tree
`ChannelRelay`/`HubLegTemplate` — alkcall ADR-051).
The upstream ask ledger (§5) is now: items 1–2 pinned by ADR-007/008
(implementation pending the two upstream alkcall asks), item 3
unchanged (fast-socks5, courtesy). The Phase 7 "open items" are all
resolved except OQ-SK-08's alksocks-side decision (item 3 of the ask
ledger), which is unchanged by this.
The downstream motivation (2026-09-15 discussion): alksocks' three
commands (CONNECT / BIND / UDP ASSOCIATE) need only a few alktunnels
additions, and those additions are generally useful — they are the
`ssh -L`/`-R` generalized shape that any dynamic-target consumer (SOCKS5
included) composes on top of. This doc records the proposed graduation:
what moves to alktunnels, what stays in alksocks, the design questions
each piece raises, and how the whole thing feeds the vpn-like endgame
(§The vpn-like endgame) and the planned `alknodes` assembly layer.
Prior reading (in the alksocks repo,
`/workspace/@alkdev/alksocks/docs/research/`): `phase-0.md` (the OQ
ledger + the POC register), `poc-bind-findings.md` (the BIND ask
ledger), `poc-udp-associate-findings.md` (the composed-egress shape +
open-cost measurement), `poc-connect-wasm-findings.md` (the
dial-at-command-read layering).
## 1. The precise gap (what "graduate the proxy" means mechanically)
Mapping the SOCKS5 command surface onto alktunnels 0.1.0:
| SOCKS5 piece | alktunnels today | Gap |
|---|---|---|
| CONNECT (tcp proxy, egress) | dial establisher + `pump_bidi` + `local::dial_tcp` | none for **local** egress; **no dynamic-target open** for **composed** egress — params are `{resource, substrate}` (ADR-001) |
| UDP ASSOCIATE (udp proxy) | connected-udp substrate + per-target tunnel table | none — the alksocks phase-0 note "no upstream ask exists here" holds; the composed form is per-target `udp` opens (POC #2 measured ~0.8 ms/destination) |
| BIND (reverse tcp) | `listen_establisher` + `AcceptQueue` + `bind_tcp` | the two ledger asks: bound-addr discovery (reply#1's `BND.ADDR`) and peer metadata (reply#2's) — alksocks `poc-bind-findings.md` §Upstream-ask ledger |
So the graduation reduces to:
1. **One wire addition: a direct/dynamic open form** — "dial an
arbitrary target," not "dial a named resource" (§2).
2. **The two BIND metadata asks** — carriers to be decided (§3).
The pumping, teardown, codec, and session machinery are already done
and POC-validated; the proxy producers on the alksocks side are thin
adapters over the three callback seams (`DialFn`, `EgressFactory`,
`BindAcceptFactory`), which the POCs validated in both local and
composed variants.
## 2. The direct open form — the real design decision
### The constraint behind it
alktunnels ADR-001 deliberately pinned params as "resource, not an
address": the producer owns the backing; rich addressing rides the
`-D`/dynamic composition path (inside the payload), never the wire.
That decision was correct for its use case and the no-forced-bind
posture (AGENTS.md #9) stands: nothing here forces any side to bind a
port. The use case that justifies an *additive* revisit is the one the
POC ledger itself names: a consumer asking the producer to dial an
arbitrary `host:port` — ssh's `direct-tcpip` shape, which ssh carries
as its own channel type precisely because it is a different capability
from opening a named resource. SOCKS5 CONNECT is the same capability:
a dynamic target chosen at session time.
The capability is dangerous in exactly the way ADR-001's "resource,
not address" framing protects against — arbitrary egress from the
producer's network. The mitigation is not refusing the capability (a
SOCKS5 service IS the capability) but separating it at the ACL: "dial
`postgres-primary`" and "dial anything from my network" must not be
the same grant.
### Options
**(a) Second op on the same ALPN — `channels/tunnel/direct`** with
params `{substrate, target}` and its own scope (`tunnel:direct`).
Clean in kind: this IS a different operation — dial what I name, vs.
dial what the producer registered. The ACL separation is structural
(scopes attach to op specs, not params); the POC #5 scope gate
(`socks5:bind`) proved the pattern matters. Old producers reject the
unknown op loudly (registry lookup fails — the SSH posture).
**(b) Optional `target` field in the existing params** + a
`target`-implies-scope establisher check. ADR-001's consequences bless
additive optional fields, but it couples the ACL to a field (scopes
attach to the op), muddies the resource invariant, and forces the
establisher to branch on the params shape. Weaker.
**(c) Resource-name convention** (`resource = "tcp:host:port"`). No
wire change, but the resource id becomes ACL-blind (the registry
grants by name; "any name of this shape" is not a scope), registry
bookkeeping gets weird, and discovery listings leak addresses.
Reject.
**Lean: (a).** One ALPN, two ops, two scopes — the same shape alktty's
scope-gating and the POC #5 command gates took. Wire-stable questions
(op id string, scope string, params field names, `target` address
encoding) are pinned by an ADR before the first consumer — timing is
right because alksocks is the first real consumer and nothing is
published yet.
### The target-encoding sub-question
The direct op's `target` must carry what `DialFn` today gets from the
registry's opaque backing string: `{host, port}` for tcp, `{host,
port}` (or resolved-destination semantics) for udp, `{path}` for unix.
The RFC-1928 ATYP encoding (IP v4/v6/domain + port — `TargetAddr`'s
`to_be_bytes`) is the natural cross-substrate carrier: self-contained,
no DNS dependency on the consumer side, and it is the encoding SOCKS5
itself uses — the alksocks adapter maps it verbatim. Domain-form
targets stay unresolved on the wire (producer-side resolution, the
ssh `-D` semantic; POC #1/#2 both validated egress-side resolution).
### What does NOT move to alktunnels
The RFC 1928 conversation, reply codes, the command scope gates, the
front doors, the `{addr, data}` datagram stage — all alksocks. The
direct op is substrate-agnostic transport capability; it knows
nothing about SOCKS5 (no `fast-socks5` dependency, no reply-code
semantics upstream). The layering assertion holds: alktunnels stays
SOCKS-ignorant; alksocks composes it.
## 3. The BIND metadata asks — carriers, and one alternative
alksocks' `poc-bind-findings.md` pinned two asks. The POC's stand-ins
(factory carries the expected `bind_addr`; sentinel `0.0.0.0:0` as
reply#2's peer) are honest interims, so the design question is how to
retire them.
### The load-bearing constraint
The open-op reply is the only pre-data-plane wire moment, and it
happens *after* the establisher. SOCKS5 BIND is address-first (reply#1
must carry `BND.ADDR` before the app server dials); alktunnels' listen
establisher is accept-first (its establishment phase pops an
already-accepted connection — the open resolves *because* the app
server dialed). The POC resolved the sequencing in shape (reply#1
first; the tunnel open rides the accept future) — the asks are about
fidelity, not feasibility.
### The two asks, restated as carriers
**Ask #1 — bound-addr discovery without consuming an accept.**
Options:
- **Inspect-only open / `listen/addr` query op** — the plan carries
`local_addr`, or a small query op on the listen resource. Wire
addition (a query op is a new op, but `Query`-typed — cheap, no data
plane).
- **Out-of-band discovery** — the `OperationSpec.description`
round-trips through discovery (OQ-TN-08); the producer's registry
description carries the advertised address. Zero new wire; the cost
is staleness (the description is install-time, the address may be
OS-chosen) and per-session dynamic listeners have no description.
- **Same-process assembly wiring** — the POC's stand-in formalized:
the alksocks producer's accept factory holds the far side's
`local_addr` directly when it constructed the listen resource
itself. Works only when the producer owns both halves in-process —
which is the common hub case (alknodes re-producers), but not the
general composition case.
**Ask #2 — peer metadata on accepted handles.** Impossible to carry
in the open-op reply (post-reply#2 does not exist as wire); the
option space is (i) `TargetHandle` gaining an optional `peer:
Option<SocketAddr>`-shaped metadata field (additive, no wire change —
the metadata rides the establishment phase, which is where the accept
happens), or (ii) accept-as-open: the accepted connection opens a
reverse channel whose open-op params carry the peer (§3.1).
### 3.1 The alternative worth weighing: the `forwarded-tcpip` shape
ssh's `-R` far-side listener opens `forwarded-tcpip` channels back
toward the client, with the listen target + peer address riding the
open params. alktunnels has the whole reverse path already
(`open_reverse_channel`, `TunnelSession::adopt`, producer-allocated
channel IDs — consumer.rs). The shape:
1. The alksocks producer, on BIND, opens the listen-tunnel (or binds
locally) and advertises reply#1 from whatever address source the
carrier question resolves.
2. When the far-side listener accepts, the *tunnel producer* opens a
reverse channel toward the alksocks producer with `{listen_target,
peer}` in params — peer fidelity for free.
3. The alksocks producer correlates the reverse open to the waiting
BIND session (a session-id ↔ quiet-wait-task registry), writes
reply#2 with the real peer, and pumps.
Costs: a correlation registry on the SOCKS5 side; a direction flip in
the composed flow (the accept-initiated open is toward the alksocks
producer); the quiet wait must accept opens, not await a popped
handle. Benefits: both asks' fidelity with zero special carriers —
the metadata rides the open op, which is the wire's native
"this connection is for X" moment. This is the wire-native answer and
should be compared against the two-ask ledger rather than assuming
the ledger's shapes.
Noted asymmetry: the local accept variant needs neither carrier (the
listener and the accept are in-process; reply#1/#2 come from the real
sockets, POC-validated). Both asks exist only for the composed
variant. That suggests scoping the asks to the composed variant and
letting the local variant stay as-is.
## 4. The callback→provider mapping (what alksocks ships)
The three seams stay callbacks; the graduation gives each a composed
provider next to the `local`-socket one:
| alksocks seam | local provider (`local` feature) | composed provider (alktunnels) |
|---|---|---|
| `DialFn` (CONNECT) | `dial_tcp`/`dial_unix` (`local`) | open `channels/tunnel/direct` (or the direct-op form chosen) toward the target |
| `EgressFactory` (ASSOCIATE) | one `UdpSocket` per association (POC #2's `LocalEgress`) | per-destination udp tunnel table (POC #2's `ComposedEgress` — the direct op dissolves its `resource_of` stand-in) |
| `BindAcceptFactory` (BIND) | real `TcpListener` per session (POC #5's local variant) | listen-tunnel session (POC #5's composed variant — the metadata carrier decides reply#1/#2 fidelity) |
All six paths are individually POC-validated; nothing here is
hypothesis-level except the metadata carriers (§3).
**Hub-relay transparency.** The terminate-and-re-produce hub
(alkcall ADR-042, alktunnels' refinement) forwards tunnel data
byte-for-byte. One structural note for the ADR: a hub that re-produces
the direct op applies its OWN target policy at the re-produce hop
(the hub's ACL, not the original opener's, governs the final dial) —
correct-by-default (the hub is the network boundary) but worth pinning
as the security model sentence.
## 5. The ask ledger, reconciled
The one-version alktunnels bump, post-graduation:
1. **The direct op** (§2) — the wire addition itself: op spec, scope
constant, params schema, establisher shape (dial only — no
listen/direct-BIND form), `DialFn` reuse.
2. **Listen metadata** (§3) — bound-addr discovery + peer metadata,
carriers per the ADR; OR the `forwarded-tcpip` shape as the
combined alternative (§3.1). The POC ledger's two items map onto
this item either way.
3. **(alksocks courtesy, fast-socks5)** — unchanged from POC #5's
ledger: publish `new_reply`; the `net` feature gate;
`run_udp_proxy_custom`'s unconditional bind.
Item 3 is independent; items 1–2 are the single alktunnels version
bump alksocks composes against.
## 6. The vpn-like layer — where this ends up
The graduation is the mechanical half of the vpn-like story; the
addressing half rides on top:
- **Virtual addressing (OQ-SK-05's addendum, now concrete).** With a
direct op, "map node/service → private IP" becomes a dial-callback
translation: intercept `connect(10.x.y.z:p)`, match the subnet
against a per-identity address table, open the direct op toward the
owning node's resource (or the named resource the address maps
to). No wire change — DST.ADDR/BND.ADDR are arbitrary RFC fields;
the intercept point is the SOCKS5 front door (tun2proxy routes the
host's traffic there, `--proxy socks5://...` natively). The
POC #2 `resource_of` seam is the same translation at the datagram
stage.
- **The signing half is not a tunnels concern.** Attesting "node N
serves 10.1.2.3:5432 in scope S" is a directory/identity-layer
pattern (alkcall identities + a signed address→service table);
alktunnels/alksocks consume it as dial-callback policy. Worth a
prior-art check on tun2proxy's virtual-DNS/virtual-network support
(alksocks phase-0 flagged this) when that layer is specced.
- **alknodes (the planned assembly layer).** The hub/spoke shape the
user describes — spokes expose resources (postgres, redis,
containers), the hub re-produces them to privileged spokes, spokes
can be hubs themselves (russian-doll: an internal private network
with selective exposure) — is the terminate-and-re-produce story
alktunnels' OQ-TN-12 deferred until a concrete consumer exists.
alknodes is that consumer. What it needs from this graduation:
the direct op (dynamic egress through the overlay), the listen
surface (accept-side resources), and the metadata carriers (BIND
fidelity through one or more hops). What it adds on its own layer:
resource directory, identity→subnet mapping, hub ACL policy —
all assembly, no new wire.
## 7. Open items for Phase 1 / ADRs
- The direct op's wire shape (op id, scope string, params encoding) —
one-way door; ADR before the first consumer.
- The listen-metadata carriers (or the `forwarded-tcpip` alternative)
— same gate.
- Whether BIND ships in alksocks' base crate + default accept variant
(OQ-SK-08's remaining decision) — unchanged by this doc, but the
carrier choice feeds it.
- The scope-separation posture: `tunnel:direct` ≠ `tunnel:open` ≠
ingress scopes; pin the convention sentence in the ADR.
- The hub re-produce security-model sentence (§4).
@@ -0,0 +1,674 @@
# Review 002 — The Graduation Implementation (direct op + bind-first + forwarded op)
## Status
**Conformant with findings.** Six sessions reviewed
(`52b6252..474844e` — the taskgraph fix + the six graduation feature
commits): the direct op end to end, the bind-first establisher + accept
dispatcher, the forwarded op's serving side, the local bind-first
halves, and `TunnelListener`. Wire surface, scope separation, typed
errors, teardown ownership, and the R-02/ADR-050 contracts all conform
(verified against the alkcall 0.8.0 source, not just its docs). No
[critical] findings. **One [minor] implementation gap (M-1) and a
cluster of [major]-labeled doc/spec-sync items (D-1..D-4) that are
doc-only in code impact** — the code is the wire-stable side and is
correct; the *docs* lag it. Nothing blocks the graduation consumers.
Findings use prefix `M` (minor code), `D` (doc/spec sync), `N` (notes).
This is the second review in this crate's series (numbering follows the
alkcall house pattern — each review numbers independently).
## Scope
The six sessions' work at tree `474844e` reviewed against ADR-007,
ADR-008 (as amended), the updated wire.md/consumer.md/producer.md, the
AGENTS.md conventions, and — load-bearing — the alkcall 0.8.0 source
(`/workspace/@alkdev/alkcall/src/channels/{operations,pump,manager,mux,source,client}.rs`,
`src/registry/registration.rs`), cross-checking the six contracts this
implementation leans on.
```
Verified against: alktunnels 474844e; alkcall 0.8.0 (source-read)
Reading list: src/{lib,params,producer,consumer,error,wire}.rs,
src/local/mod.rs, tests/{harness,producer_bindfirst,producer_direct_op,
producer_forwarded,consumer_listener,local_bindfirst}.rs,
docs/architecture/*.md incl. decisions/007, 008
Executed: cargo test (132 default / 157 --features local, doc-tests 2/2),
clippy --all-targets -D warnings (native + wasm32), fmt --check,
cargo doc --no-deps (0 warnings), cargo check + clippy on
wasm32-unknown-unknown — all green, matching the commit messages' claims;
cargo-llvm-cov 0.8.4 line/region coverage, default (95.63%) +
--features local (93.66%) — see the coverage appendix
Source-read (alkcall 0.8.0): pump_bidi shutdown-on-completion,
teardown_channel drop-sender EOF, mux implicit-EOF sentinel,
yield-once accept_bi + accepted-flag telemetry,
Establishment::with_reply_field + reserved-key guard,
open_channel_with_reply shape, registry input_schema gate
(compiled at registration, runs after ACL, INVALID_INPUT on violation)
```
## Severity legend
Same scale as review 001 / the alkcall reviews:
- **[critical]** — a decided spec invariant violated so a promised
capability is unreachable end-to-end; or data corruption.
- **[major]** — a core protocol path cannot serve a decided behavior;
or a wire/ABI promise is not delivered by the code.
- **[minor]** — drift, doc/spec inconsistency, or a missing
convenience with no correctness impact.
---
# Checklist verdicts
1. **Wire conformance (ADR-007/008 + wire.md) — PASS.** The direct op's
spec (`tunnel_direct_spec`) and the forwarded op's spec
(`tunnel_forwarded_spec`) match wire.md §The Direct Op / §The
Forwarded Op: op ids, `Sub` type, `alk/tunnel` ALPN marker, the
per-substrate `if`/`then` target/peer sub-schemas with
`additionalProperties: false`, and the three independent scopes
(`tunnel:open`/`tunnel:direct`/`tunnel:forwarded`) — each proven
never-implied by wire-level tests in both directions
(`producer_direct_op.rs` `tunnel_open_scope_does_not_imply_*`,
`producer_forwarded.rs::forwarded_scope_gate_is_independent_grant`).
The `listen` token's positivity is enforced at three layers
(schema `minimum: 1`, the `positive_u32` serde pin, the reply
projection's `> 0` filter) with the mint never yielding 0
(`mint_token` seeded at 1, wrap-skip pinned by test). The reply
projection's asymmetry is deliberate and tested both ways:
`parse_listen_reply` is the loud typed viewer (malformed →
`MalformedReply`), `listen_reply_fields` the lenient session-surface
projection (malformed → both-`None` + loud log) — the doc comments
on both correctly state which to use when, and the additive posture
(unknown reply fields tolerated) is pinned by test.
2. **Producer conformance — PASS.** The bind-first establisher is the
awaited bounded establishment phase (registry gate → mint → bind →
reply fields → `Establishment::new(plan)`), the plan is
typed-opaque (`BindFirstPlan` downcast test), and the accept
dispatcher is a faithful ADR-008 §1 implementation: `accept_bi`
FIRST (the R-02 accepted-flag telemetry — verified in upstream
`operations.rs:954-1000`, the wrapper's birth-teardown hint keys on
it), the idle-stream EOF watch races the accept loop in `select!`,
per-accept forwarded opens are isolated (drop the accept, keep
serving — pinned by two tests), loop-critical accept errors end the
loop, `drop(plan)` tears the listener down, and the handler's
`JoinHandle` awaits every per-accept pump (R-02 — no forwarded pump
outlives its listener). The forwarded-op serving handler parses
params (deny-unknown + positive-listen serde pin) and accepts the
`BiStream`, awaiting the injected handler's `JoinHandle` inline —
a malformed open tears down at birth with no phantom channel
(pinned: `forwarded_malformed_params_tear_down_loud`).
`register_openable` (no establisher) for the forwarded op matches
ADR-008 §3's no-establishment shape.
3. **Consumer conformance — PASS.** `TunnelSession::open_direct` is the
separate constructor ADR-007 §5 pins (not a params enum — two
scopes, the caller states which it holds); the session type, data
planes, and teardown API are identical to `open`. `TunnelListener`
holds the write half IDLE (never exposed, never pumped), projects
the reply fields via the lenient path, and owns teardown
(`close` = graceful `shutdown` → EOF sentinel → reap; `Drop` =
implicit-EOF chunk → reap — the mux's implicit-EOF path is real,
verified in upstream `mux.rs:67-82`). No `Clone` compile-asserted
for both session types. `adopt` gains nothing — pinned by test at
both the standalone and through-the-real-forwarded-registration
levels.
4. **Conventions sweep — PASS with notes.** No `unwrap`/`expect`/
panics outside tests (the `unreachable!` arms in
`take_halves`/`pump_against` are unreachable by the documented
data-plane-XOR-pump invariant and are the pre-existing v1 shape).
No substrate types outside `src/local/` (grep-verified). No
comments beyond doc comments (the inline `//` density in
`producer.rs`/`local/mod.rs` is high but they are documenting
ADR-pinned contracts, which AGENTS.md convention 1's exception
covers — correctness constraints that would otherwise be missed).
The wasm-clean invariant holds (`cargo check + clippy` on
wasm32-unknown-unknown pass; `tokio` features unchanged; no new
non-local deps). Feature-gate verification: both `cargo test`
(default) and `cargo test --features local` pass.
5. **Conformance to the amended ADR-008 — PASS.** Amendment 1's
producer-minted token is implemented exactly as pinned: minted at
establisher time (non-zero-seeded `AtomicU32`, per-open fresh,
per-producer unique, wrap-skip of the illegal 0), carried twice
(plan + `listen` reply field), stamped per-dispatcher from its own
plan (pinned by the two-open token-discipline test), and consumed
by the opener-side correlation (assembly-side bookkeeping — the
crate provides the op + params, not the wait state machine).
---
# Findings
## M-1 [minor] — The accept dispatcher's substrate fallback is silent and wrong-shaped
`src/producer.rs:766-768`
(`make_bindfirst_accept_handler`):
```rust
let substrate = serde_json::from_value::<TunnelParams>(input.clone())
.map(|p| p.substrate)
.unwrap_or(Substrate::Tcp);
```
The listen open's input params have already passed the registry's
schema gate (enum-validated) **and** the establisher's
`parse_params` (which rejects unparseable input with
`handler_error`), so in the wired path the fallback is unreachable.
But the fallback shape itself is the problem: if the parse ever fails
(a registry whose schema gate is looser, an assembly variant, future
drift), the dispatcher silently stamps `Tcp` into every forwarded
open — for a UDP listen tunnel that is a raw-vs-framed data-plane
mismatch on the *opener* side, the exact invariant class review 001
U-1 was about, and it degrades silently instead of loudly.
The same silent-fallback shape exists in no other handler: the
forwarded-op handler (`make_forwarded_op_handler`,
`src/producer.rs:993-1002`) parses and **returns** on failure
(teardown-at-birth, the correct loud shape); the pump handler
downcasts the plan and returns on failure.
**Suggested fix (small, mechanical):** on parse failure, log loud and
return (teardown-at-birth — the channel reaps, the far listener dies
with the plan, the same posture as every other early-return in this
file). Alternatively, since the establisher already parsed the params
successfully, stamp the substrate into `BindFirstPlan` at establisher
time and drop the second parse entirely — one parse, one source of
truth, no fallback branch at all. The second shape is strictly better
(the plan is already the per-open carrier; the substrate is
per-open-static) but changes a public-ish struct.
**Why not fixed inline:** the choice between "log-and-return" and
"stamp the plan" is a small API-shape decision (`BindFirstPlan`
gains a field — a plan-payload change, cheap now, pre-consumer) that
the next session should make deliberately, and both need a
failure-path test.
## D-1 [major-as-labeled, doc-only] — wire.md omits the `port` bounds the shipped schema enforces
The shipped direct-op and forwarded-op schemas pin
`"port": {"minimum": 0, "maximum": 65535}` plus
`"additionalProperties": false` on the target/peer objects
(`src/params.rs:208-222`, `453-467`), and `listen` with
`"minimum": 1` (`src/params.rs:442`). wire.md (the normative WHAT)
says only `port: <u16>` and "integer > 0" in prose — it does not
record the bounds as schema properties, nor the
`additionalProperties: false` gate that makes cross-shape targets
fail the registry's `INVALID_INPUT` class *before* the establisher
(the behavior `malformed_target_is_schema_class_and_leaves_no_channel`
pins on the wire). wire.md is the wire-stable contract doc: the
schema details are part of the one-way door (ADR-007 §"Wire-stable":
"the target sub-schema" is listed as wire-stable). The BAST doc
(`bast.md`) covers only the data-plane codec, not the params schemas,
so there is no second source recording these bounds.
**Impact:** none at runtime (the code is the gate); the risk is a
future consumer implementing from wire.md alone and accepting a
shape the reference producer rejects (or vice versa). Sync the doc
before the first consumer (alksocks) ships.
## D-2 [major-as-labeled, doc-only] — wire.md's `if`/`then` structure differs textually from ADR-007's `oneOf`
ADR-007 §2 says "the target sub-schema is per-substrate (`oneOf` on
the sibling `substrate` value, validated at establisher time — see
Consequences)"; the shipped schema (and wire.md §The Forwarded Op's
structure, and the unit tests pinning it) use **`allOf` of two
`if`/`then` branches** instead. `oneOf` of full-target-schemas would
actually be *wrong* here (a `unix`-substrate open would need to fail
the `tcp` branch — plain `oneOf` on sibling-conditioned subschemas is
awkward; `if`/`then` composes correctly with the base
`properties`/`required`). The code is right; ADR-007's one-word
schema-structure reference is stale. One-line ADR erratum (or an
amendment note), plus ADR-008 §3's "validated at establisher time"
(ADR-007 §2's parenthetical) vs the shipped registry-side gate: the
establisher-time sentence in ADR-007 §2 line 82 ("validated at
establisher time") contradicts both the shipped design and the
wire.md text ("validated at establisher time" appears in
wire.md §The Direct Op but the test proves the registry gate fires
first — `malformed_target_is_schema_class_and_leaves_no_channel`
asserts the establisher's dial never runs). The ADR/wire.md sentence
should read "validated registry-side (the schema gate), before the
establisher; re-checked by serde at establisher time as the
backstop" — which is exactly what `src/params.rs`'s doc comments
already say.
## D-3 [minor] — `jsonschema_valid` in `src/params.rs` tests is a stub, not a validator
`src/params.rs:628-637`: `jsonschema_valid` parses the value with
serde and pattern-matches the substrate string — it validates the
serde layer, not the JSON Schema. Both call-sites'
names (`schema_rejects_unknown_substrate_value`,
`direct_target_schema_gates_shape_per_substrate` — the latter
correctly does *structural* schema assertions instead and is honest
about it) overpromise for the former: `schema_rejects_unknown_
substrate_value` does not execute the spec's `input_schema` at all.
The registry-side gate IS exercised end-to-end by the integration
tests (`schema_rejects_unknown_substrate_loudly`,
`malformed_target_is_schema_class…`, the forwarded `listen: 0` gate),
so coverage is not lost — but the unit test's name promises more
than it delivers. Either rename it (e.g.
`serde_rejects_unknown_substrate_value`) or drop it in favor of the
integration probes. alkcall already compiles the real validators at
registration; a `jsonschema` dev-dependency purely for this unit
test is not warranted.
## D-4 [minor] — CHANGELOG "Unreleased" preamble is stale
`CHANGELOG.md`'s Unreleased section still opens with "implementation
is decomposed in `tasks/tunnels/` and lands in the next release…
the only behavior-affecting change so far (no wire or API change at
this level yet)" — written before the six implementation commits.
The section below it correctly lists all six features. The preamble
contradicts the section it introduces. Trim the preamble to a
one-liner (or drop it) before release.
## N-1 [note] — `parse_listen_reply`'s malformed-`listen` message vs the serde pin's semantics
`parse_listen_reply` requires `listen` to be "a positive u32" and
rejects 0 (`src/params.rs:329-339`) — matching the forwarded params'
wire contract (`minimum: 1`). Consistent and intentional, but note
the asymmetry with `bound`: a `bound: null` and a `listen: null` are
both treated as absent (`None | Some(Value::Null) => None`) — fine,
JSON-null-as-absent is the standard lenient reading, and the loud
path still fires for present-but-malformed shapes. No action; noting
the deliberate shapes for the record since `TunnelListenReply`
carries `deny_unknown_fields` while the *parse function* tolerates
unknown fields — the struct-level attribute is only load-bearing if
someone deserializes the struct directly from a full reply, which the
doc comment explicitly warns against ("Parse the full reply with
`parse_listen_reply`, never with `deny_unknown_fields`
deserialization of the whole reply"). The warning is doing real
work; keep both.
## N-2 [note] — The idle-stream EOF watch treats ANY byte as teardown
`make_bindfirst_accept_handler`'s `eof_watch` (`src/producer.rs:781-794`)
triggers teardown on `Ok(0)`, `Err(_)`, **and** `Ok(1..)` — a
misbehaving opener that writes to the idle listen channel also tears
the listener down. The doc comment owns this ("a peer that writes is
misbehaving; treat any non-empty read as an EOF trigger too"), and it
is the safe direction (fail closed — the channel is not a data
plane). Verified upstream that a `MpscSendStream` drop without
`shutdown` produces the implicit-EOF chunk (the `Ok(0)` path) and a
`shutdown` produces the sentinel — both arrive as `Ok(0)` at the far
reader, so both `TunnelListener::close` (explicit shutdown) and
`Drop` (implicit EOF) trigger the far teardown. Correct. No action;
recorded because a reviewer reading only ADR-008 §1 ("its EOF
cancels the loop") might implement the narrower `Ok(0)`-only check
and hang on a write-happy peer — the code is right, the ADR sentence
is thinner than the implementation.
## N-3 [note] — `make_forwarded_op_handler` parses params BEFORE `accept_bi`
`src/producer.rs:993-1006`: the malformed-params early return happens
before `conn.accept_bi()`, so the R-02 accepted-flag telemetry reads
`false` for a malformed-params open (the wrapper logs the
birth-teardown hint). This is the deliberate reading of R-02 — the
flag is telemetry for the "handler exited without accepting" *hint*,
and a malformed-params open IS a birth-teardown shape — but the
ordering means the hint fires for a handled-rejection case. Cosmetic
telemetry noise at worst (a `tracing::debug!`); the test suite pins
the teardown behavior, which is what matters. The alternative (accept
first, then parse) would hold a stream open for a params set the
producer already got wrong — the current order is the better one.
Documented here so a future alkcall reviewer doesn't flag the flag.
## N-4 [note] — `SubstrateAddr` untagged leniency is schema-gated, and that's the right call (but it is a latent sharp edge)
The untagged `SubstrateAddr` accepts extra fields (untagged variants
cannot `deny_unknown_fields`), so a cross-shape target like
`{"path": "/p", "host": "h"}` parses as the `Path` variant at serde
level. Both the direct-op and forwarded-op tests pin this asymmetry
with the same rationale: the SCHEMA is the shape-vs-substrate gate
(`additionalProperties: false` per branch). That holds for the
registry-dispatched path. Two things to keep in view:
- The establisher-level backstop (the serde parse inside
`direct_establisher`) keys `render_target` off the sibling
`substrate` — a cross-shape target that sneaked past a looser
registry would render *something* (`/p` for the unix case) rather
than fail. Acceptable (garbage-in handled by the schema's absence),
but worth an ADR-line if a future consumer runs a schema-less
registry variant.
- If `SubstrateAddr` grows a variant whose shapes overlap
(e.g. a future `{host, port, flowlabel}`), untagged disambiguation
starts ordering — at that point switch to
`#[serde(untagged)]`-with-content or an internally-tagged form.
Pre-consumer, additive; just don't let it happen by accident.
## N-5 [note] — The token-mint discipline is sound; the concurrent-mint shape is untestable at this layer and fine
`mint_token` is a relaxed-ordering `fetch_add` loop skipping 0. The
"2^32 concurrent opens" wrap-collision math in the doc comment is
right (and the collision consequence — an opener correlating a
forwarded open to the wrong listener — requires the same producer to
have 2^32 *live* listeners simultaneously, impossible under the
256-channel per-connection cap; the token is per-producer-connection,
and each open rides one connection). Worth noting the cap makes the
wrap analysis even more favorable than the comment states. No action.
## N-6 [note] — Test-only race window in `bindfirst_open_surfaces_reply_fields_and_teardown_cascades`
`tests/consumer_listener.rs:276` (and several sibling tests) settle
the forwarded open with `tokio::time::sleep(300ms)` then poll
`channel_ids()`. Sleep-then-assert is the established house pattern
in this suite (the harness has no push-based settle surface), but
these are the tests most likely to flake on a loaded CI box (the
forwarded open traverses: producer accept → call plane → opener
dispatch → registry → wrapper → handler spawn). The suite has been
3x-repeat stable historically (review 001's methodology); if CI ever
shows a flake here, the fix is a bounded poll loop on
`channel_ids()` (the teardown assertions already use that shape —
extend it to the arrivals). No action now.
## N-7 [note] — `test_bind()`'s listener is a bare `Arc<u32>`
The test bind closures in `producer_bindfirst.rs` /
`consumer_listener.rs` put `Arc::new(4242u32)` in the plan's opaque
listener slot. Fine for tests that feed accepts via side channels —
and the mismatch probe (`accept_pair_loop_downcast_miss_fails_loud`)
proves the real dispatch path fails loud on exactly this shape —
but if a test ever wired `local_bindfirst_accept_loop()` against
`test_bind()`, the failure would surface as the generic
`handler_error`, not a test bug message. The tests consistently use
the queue loops instead. No action; the downcast-miss test covers
the failure mode.
---
# What was checked and found conformant (highlights)
- **The token path end-to-end** (Amendment 1): mint (seed-1, wrap-skip)
→ reply field (reserved-key guard upstream keeps `channel_id`
wrapper-owned — verified in `operations.rs:927-946`) → lenient
projection → forwarded-param keying → opener-side correlation →
per-dispatcher own-plan stamping (two-open test) → hub-relay
pass-through (params untouched, reply fields per-hop-truthful —
upstream ADR-051 machinery, source-verified in the relay docs).
- **The teardown cascades**: listener drop → implicit-EOF chunk
(upstream `mux.rs` implicit-EOF path verified) → far dispatcher's
idle-read fires → loop breaks → `drop(plan)` (listener dies) →
pumps awaited → handler task exits → wrapper's handler-exit
teardown reaps the serving-side entry. Both directions (close's
explicit `shutdown` sentinel and Drop's implicit path) tested; the
leak-free guarantee asserted via `channel_ids()` on both managers.
- **Per-accept isolation**: forwarded-open failure (FORBIDDEN /
NOT_FOUND / correlation miss) drops only that accept; the loop
serves the next (two tests pin queue-drain + no-stray-channels).
- **Scope separation** proven structurally at the wire level in all
three pairwise directions, at both the raw-call and typed-session
layers.
- **CF-005/CF-006 identity surfaces**: the establisher witnesses the
END CALLER's identity on base/direct/bind-first paths (witness
wrapper for bind-first, inline for the others); the forwarded op's
opener-side gate sees the producer's per-call identity (the
upstream per-call overlay — tested via the scope gate).
- **Schema-gate ordering**: ACL → input_schema → establisher →
handler, verified in upstream `registration.rs` (all three dispatch
entry points) + pinned by the tests that assert the establisher's
dial never runs on a malformed target.
- **Doc-test parity**: both `compile_fail` asserts (no-Clone) live
and green.
---
# Remediation
Ordered smallest-first; nothing here blocks alksocks composition.
1. **M-1** — the accept dispatcher's substrate fallback (log-and-return,
or stamp the substrate into `BindFirstPlan`). Small; needs a
failure-path test.
2. **D-3, D-4** — rename/strengthen the unit-test helper; trim the
CHANGELOG preamble. Trivial.
3. **D-1, D-2** — sync wire.md's schema bounds + ADR-007's
oneOf→if/then wording + the "establisher-time" →
"registry-side-gate, serde-backstop" sentence. Doc-only but
wire-contract-adjacent: do it before the first consumer ships.
---
# Coverage review (appendix — `cargo-llvm-cov` 0.8.4, line/region mode)
Measured with `cargo llvm-cov` (default) and `cargo llvm-cov
--features local`; branch-level instrumentation needs a nightly
toolchain (`-Z coverage-options=branch`), so these are line/region
numbers — branch coverage is analyzed manually below where it matters.
## Headline numbers
| Configuration | Line coverage | Note |
|---|---|---|
| default | **95.63%** (74 missed of 1692) | the wire/protocol surface |
| `--features local` | **93.66%** (133 missed of 2098) | adds `src/local/mod.rs` (84.0% lines) |
By file (default / `local`): `error.rs` 100/100, `params.rs`
100/100, `wire.rs` 96.0/96.0, `producer.rs` 93.8/94.7,
`consumer.rs` 90.5/90.5, `local/mod.rs` —/84.0.
The numbers are healthy for this suite's design (the integration tests
drive the real paths over the harness duplex and — with `local` — real
sockets); what remains uncovered is overwhelmingly **defensive
early-return arms that no wired path can reach** plus a handful of
genuine, cheap-to-close gaps. Classification follows.
## Genuine gaps (worth closing)
### G-1 — `TunnelSession::open`/`open_direct`/`TunnelListener::open` params-serialize error closures
`src/consumer.rs:94-97, 134-137, 487-490` (and
`open_reverse_channel`'s twin at `src/consumer.rs:593-594`). All four
are `serde_json::to_value(..).map_err(..)` arms over types with no
non-string keys or fallible serializers — unreachable in practice, and
the established house shape for this conversion. Closing these needs a
test-only serializer poison (`serde_json` can't fail on these types
without `try_collect` tricks). **Recommendation: leave uncovered; do
not chase.** If the suite ever grows a shared "unreachable error arm"
prose note, these four are its members. (Alternative: collapse the four
duplicated arms into one `fn params_to_input<T: Serialize>(..)` helper
in `consumer.rs` — then only ONE closure exists and the gap shrinks by
3. Cosmetic; fine to skip.)
### G-2 — `TunnelListener::close`'s `shutdown` arm never observed at the far end
`src/consumer.rs:529-531`: `close()` runs
`write.shutdown().await` (the explicit EOF sentinel) then reaps — but
no test currently observes the FAR accept dispatcher's idle-read
firing from a `close()` (the graceful path). The Drop path (implicit
EOF → far teardown) IS observed end-to-end
(`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`), and
the close path is pinned at the reap level
(`close_and_drop_reap_the_listen_channel`) — but the sentinel's
far-side effect for `close()` specifically is asserted nowhere: the
"the pushed accept was never consumed" assertion runs only in the
Drop-shaped tests. **Recommendation: one small test** — open against a
bind-first producer, push an accept, `listener.close().await`, then
poll that the far accept loop is gone (the same queue-len assertion
the Drop tests use). This is the only gap that touches a *promised*
behavior (ADR-008 §1's teardown contract, graceful half) with no
direct observation. Cheap: mirror
`bindfirst_full_flow…`'s harness with a `close()` instead of a drop.
### G-3 — `TunnelSession::join`'s pump-`Err` arm (`src/consumer.rs:368-373`)
The `JoinHandle` error path (pump aborted → `JoinError` → warn + `(0,
0)`) never fires in tests: `close()`/`Drop` abort the pump and reap
without joining; `join()` is only ever exercised on live pumps. A
one-line test: open a session, `pump_against`, `close().await` (aborts
the pump), then… `join` consumes self — the shape needs a
`pump_against` session dropped into a spawn that is aborted before
`join`. Practical shape: `let s = session.pump_against(dup).await;`
take the pump out via `s.join()` racing a `s.abort()`-equivalent —
there is no public abort on the session, so the test should close the
channel out-of-band (`manager.teardown_channel`) while `join()` is
pending: the pump's copies error/EOF, `pump.await` still resolves Ok —
actually the JoinError arm fires only on abort/cancel, which the
session type never does before `join`. **Recommendation: leave the arm
as-is (defensive, log-only) but note it in the review as
unreachable-by-public-API** — the only way to hit it is a pump
panicking, which is out of contract. Alternatively add a
`#[cfg(test)]`-only unit test driving `join` on a session whose pump
handle was manually aborted via a test seam — not worth the surface
pollution on a warn-log arm.
### G-4 — `local/mod.rs` `UnixListenerHalves` accept-first loop never driven (591-609) + `TcpListenerHalves::accept_pair` downcast path in the *queue*-shaped tests
`UnixListenerHalves::accept_loop` (src/local/mod.rs:591-607) has zero
coverage: no test constructs it (the unix dial test hand-rolls its own
echo listener; the bind-first unix test uses `accept_pair`). It is the
unix parity twin of the covered `TcpListenerHalves::accept_loop`
(hit at line 107-125) and its body is 15 lines of the same shape.
**Recommendation: one small unix listen-producer test** (mirror
`producer_listen.rs::accept_loop_stops_feeding_after_close_and_maps_the_drop`
with `bind_unix` + a temp path) — closes the entire 593-607 range and
exercises `UnixListenerHalves` beyond the bind-first path. Low value
(copy-shaped), but it is the largest single uncovered block in the
crate.
### G-5 — The unnamed-peer error arms (unix)
`src/local/mod.rs:626-633` (the `accept_pair` unnamed-peer
`handler_error`) and `src/local/mod.rs:578-583` (the
`local_addr` unnamed-bind error) never fire: the unix bind-first test
binds a named client socket deliberately (the parity case), and no
test binds an abstract/autobind unix listener. Both are
fail-loud guards the ADR pins (ADR-008 §3 — "a producer that cannot
determine the peer does not serve a bind-first listener over that
substrate"). **Recommendation: one compact test** — bind an abstract
unix listener (Linux: a leading-`\0` path via socket2, or
`UnixListener::bind` on an unnamed autobind socket), call
`local_addr()` (expect the "no filesystem path" `handler_error`), and
drive one autobind-client accept through `accept_pair` expecting the
unnamed-peer error. Cheap, deterministic, and it pins the two ADR
sentences the doc comments promise.
### G-6 — UDP bind-family resolve edge (`src/local/mod.rs:180-181`)
`connect_udp`'s `no addresses` arm (the resolver returning an empty
list). `connect_udp_unresolvable_target_maps_to_dial_failed` covers
the `to_socket_addrs` Err arm; the empty-success arm needs a
resolvable-but-zero-address name, which is not deterministically
producible with std resolution. **Recommendation: leave uncovered**
(documented as the std resolver's unreachable-in-practice arm) — or
refactor `connect_udp` to take the resolution behind a small
`ResolveFn` seam so the empty case is unit-testable. The seam is the
better long-term shape (it also removes the blocking
`to_socket_addrs` call from the async context — see N-8 below), so if
this gap is addressed, do it via the seam.
### G-7 — `UdpHalf` `poll_read`/`poll_flush` readiness-error arms
(`src/local/mod.rs:369-377, 395-396, 422-423, 427, 520-521`): the
`poll_recv_ready`/`poll_send_ready` `Ready(Err)` arms and the
high-water `Pending`-after-drain arm. The veth sender-block test
(`udp_half_write_side_blocks_stashes_and_drains_on_resolve`) covers
the stash/queue/resolve spine (lines 505-528 mostly hit; 519-521's
`poll_send_ready` Pending arm never resolves Pending inside the test
window because the veth's EAGAIN pattern resolves readiness between
polls). These arms are one-poll-window states — deterministic capture
needs a mocked readiness source, not a socket. **Recommendation:
leave; the veth test already pins the observable behavior (stash →
retry → deliver), and the arms are unreachable differently per-kernel
(host-notes in the test document this).**
## Deliberately uncovered (defensive; no action)
- **Handler early-return arms** (`src/producer.rs:446-455, 759-764,
1004-1005`): the `accept_bi`-failed / wrong-plan-payload /
`try_unwrap`-failed telemetry returns. A wired open always has a
plan of the right type; these fire only on a protocol bug or a
mismatched registration (assembly error, not wire state). Covered in
spirit by the unit-level downcast tests. To hit them, a test would
need a handler registered against a plan the establisher never
produced — an upstream-invariant violation, not a code path.
- **`warn!`/`error!` telemetry bodies** (`src/producer.rs:825, 833,
838-849`; `src/consumer.rs:368-373`): per-accept forwarded-open
failure logging (the FORBIDDEN isolation test hits the *drop*
branch's effects but the tracing body's region shows missed because
the macro expands past the branch) and the loop-critical accept
error arm — wait: **the loop-critical arm IS behaviorally covered**
(`accept-loop-ended` via the queue_loop's closed-listener Err in
the teardown-cascade tests); the missed lines 838-849 are the
second `tracing::warn!` expansion inside that arm. Logging regions
in macro expansion show as missed lines while the branch executes.
Verified: `listen_channel_eof_cancels_loop_and_tears_listener_down`
drives a loop-critical accept error. No action.
- **`unreachable!` arms** (`src/wire.rs:113`,
`src/consumer.rs:248, 328, 333`): guarded by invariants
(`current` set before the `else` branch; data-plane XOR pump; the
spent-session double-take the type makes impossible). Standard
posture; the `debug_assert`-shaped gaps review 001 already passed.
- **`wire.rs:184`** — the `datagram_split_across_awkward_chunks_reassembles`
test's failure panic (the test always passes the reassembly).
- **`Default for DatagramReader`** (`src/wire.rs:82-84`): derive-shaped
boilerplate; all tests construct via `new()`. Trivial to close if
wanted (`let _: DatagramReader = Default::default();` in an existing
codec test) — otherwise delete the impl in favor of `#[derive(Default)]`.
- **`src/params.rs` 99.3%** — the two "missed regions" are the
`serde_json::from_value` error closures inside
`parse_listen_reply`'s bound/listen arms; the `listen_reply_rejects_malformed_shapes`
unit test exercises both paths (the macro-expansion region
accounting again). Line-level: 100%. No gap.
- **`Debug` impls** (`src/producer.rs:66, 109`): `TargetHandle`'s and
`BindFirstPlan`'s manual `fmt`. Never invoked in tests (no
`{:?}` on those types). One-line fix if desired: `assert_ne!` two
plans in the token-discipline unit test (forces `Debug`), or drop
the manual impls for `#[derive(Debug)]` with the opaque fields
skipped (`#[derive(Debug)]` on `Arc<dyn Any…>` prints the pointer —
the manual impl exists to keep the listener opaque). Keep the impls;
optionally add a `debug_fmt_smoke` unit test.
- **`witness_establisher`'s `None` arm** (`src/producer.rs:917`): the
registration wrapper's `None => establisher` branch — every test
registers with `Some(witness)`. One test registering
`register_tunnel_bindfirst_openable` with `identity_witness: None`
would cover it; trivial.
## Branch-coverage notes (manual, no nightly needed here)
The instrumented run is line/region only (stable; branch mode requires
`-Z coverage-options=branch`). The manually-reviewed decision points
and their test-pinned states:
- `AcceptQueue::pop`'s three arms — all covered (`producer_listen.rs`:
empty-then-push, closed-and-empty, the lost-wakeup window test).
- `UdpHalf::poll_read`'s staged-frame / fresh-recv / WouldBlock / error
arms — all covered except the `poll_recv_ready` Err/Pending arms
(G-7).
- `UdpHalf::poll_write`'s high-water arm — covered by the veth test
(Pending observed); the inner `Poll::Ready(Err)` return (422-423)
uncovered (G-7's class).
- `pump_queue_to_socket`'s WouldBlock/ENOBUFS stash arm — covered
(veth test); the `poll_send_ready` triple (519-521) uncovered except
via the flush-driven path (G-7).
- `parse_listen_reply`'s malformed arms — covered (unit test);
line-level 100%.
- The idle-read `Ok(_)` arm (`src/producer.rs:792`) — same body as the
`Ok(0) | Err(_)` arm; llvm-cov counts the second match arm's
region separately. The write-happy-peer teardown shape is
documented (N-2 in the main review) but untested — a test would
write a byte to the idle channel and assert far teardown. Same
harness shape as the EOF tests; cheap to add with G-2 if desired
(it pins the "any non-empty read tears down" posture).
## Verdict
No coverage *gap* hides a correctness risk in the shipped paths: the
uncovered set is defensive arms + two doc-promised fail-loud behaviors
(G-5) + one ADR-contract half (G-2). The suite's integration-first
design (real sockets with `local`, real forwarded-openable
registrations, real EOF sentinels) is why the uncovered residue is
this small. Recommended additions, in order of value: **G-2, G-5, G-4,
G-7's `poll_send_ready` Err arm (skip — kernel-dependent), G-6 via the
ResolveFn seam (optional), G-3 (document-only)**. Combined with M-1's
failure-path test, that is roughly one session's worth of test work.
## References
- The six commits: `0789ab6` (direct op), `d586a5d` (forwarded params
+ plan type), `7d23a89` (bind-first establisher + accept
dispatcher), `3fb26d9` (forwarded serving + reply projection),
`514e0c1` (local bind-first halves), `474844e` (`TunnelListener`)
- ADR-007, ADR-008 (as amended), wire.md, consumer.md, producer.md
- alkcall 0.8.0 surfaces this work rides: `open_channel_with_reply`,
`Establishment::with_reply_field` + the reserved-key guard,
flavor-form op ids (ADR-047 amendment 3), `pump_bidi` (ADR-050),
the registry `input_schema` gate, the R-02 accepted-flag telemetry
+ birth-teardown hint
- Review 001 (the prior review; U-1's framed-adapter fix is
re-verified here by the direct-op UDP round-trip test)
+189 -1
View File
@@ -22,7 +22,10 @@ use bytes::Bytes;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadHalf, WriteHalf};
use crate::error::{ReverseOpenError, TunnelIoError, TunnelOpenError};
use crate::params::{Substrate, TunnelParams, OP_TUNNEL_OPEN, TUNNEL_ALPN};
use crate::params::{
listen_reply_fields, Substrate, SubstrateAddr, TunnelDirectParams, TunnelParams,
OP_TUNNEL_DIRECT, OP_TUNNEL_OPEN, TUNNEL_ALPN,
};
use crate::wire::{frame_datagram, DatagramReader};
/// The typed consumer session (ADR-005): one session = one channel =
@@ -104,6 +107,46 @@ impl TunnelSession {
))
}
/// Open a tunnel channel toward a CALLER-NAMED target (the direct
/// op — dynamic-target egress, ADR-007; the ssh `direct-tcpip` /
/// SOCKS5 CONNECT shape). A separate constructor from [`open`](Self::open),
/// not a params enum: `tunnel:direct` and `tunnel:open` are two
/// scopes — two capabilities — and the caller states which one it
/// holds.
///
/// Serializes `{substrate, target}`, calls
/// `open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN)` (plain
/// `open_channel` — dial establishers never bind, so no reply
/// fields are expected), and presents the identical session type,
/// data planes, and teardown API as [`open`](Self::open).
///
/// Error surface: the base open errors minus `unknown_resource`
/// (no registry lookup); a malformed target is the schema /
/// `invalid_input`-class failure (registry-side); an old producer
/// rejects the unknown op loudly (`NOT_FOUND` — the SSH posture).
pub async fn open_direct(
client: &ChannelClient,
target: SubstrateAddr,
substrate: Substrate,
) -> Result<Self, TunnelOpenError> {
let input =
serde_json::to_value(&TunnelDirectParams { substrate, target }).map_err(|e| {
TunnelOpenError::Open(ChannelOpenError::CallFailed {
error: CallError::internal(format!("params serialize: {e}")),
})
})?;
let (channel_id, send, recv) = client
.open_channel(OP_TUNNEL_DIRECT, input, TUNNEL_ALPN)
.await?;
Ok(Self::from_halves(
channel_id,
substrate,
client.manager().clone(),
send,
recv,
))
}
/// Adopt a producer-allocated channel ID (the reverse path, `-R`):
/// install the data plane from the adopted halves. Early arrivals
/// are parked by the manager and drained on adopt (the adoption
@@ -364,6 +407,151 @@ pub struct TakenHalves {
pub write: Box<dyn AsyncWrite + Send + Unpin>,
}
/// The listen-flow consumer session (ADR-008 §4) — the sibling of
/// [`TunnelSession`]: it OPENS a listen tunnel (`channels/tunnel/sub`
/// — the establisher shape is the producer's per-registration choice,
/// invisible here), reads the bind-first reply fields (`bound` /
/// `listen`) via the lenient [`crate::params::listen_reply_fields`]
/// projection, and owns the listen channel's teardown (ADR-005's
/// ownership posture extended to the listen flow).
///
/// **The held stream (the one new thing):** a listen session's channel
/// stream has NO data-plane role — its only job is to EOF when the
/// session drops. The struct holds the channel's write half
/// (`MpscSendStream`, the mux sender) IDLE and never pumps it; the
/// drop → EOF ordering is structural (the mux sender drops → the mux
/// pump writes the zero-length EOF sentinel → the far accept
/// dispatcher's idle-stream watch fires and tears the far listener
/// down — ADR-008 §1's contract). The stream is deliberately not
/// exposed: pumping on a listen channel is meaningless (the far side's
/// dispatcher ignores it too).
///
/// **Forwarded sessions are NOT owned by the listener.** They arrive
/// as ordinary adopted-side [`TunnelSession`]s the assembly correlates
/// via the [`listen`](Self::listen) token and owns (the correlation
/// registry is assembly-side bookkeeping — consumer.md's posture).
/// Listener teardown does NOT cascade to already-adopted forwarded
/// sessions; the far listener dying stops NEW accepts only.
///
/// The session does **not** implement `Clone` (compile-asserted below):
/// aliasing a listener would alias its teardown.
///
/// ```compile_fail
/// use alktunnels::consumer::TunnelListener;
/// fn assert_clone<T: Clone>() {}
/// assert_clone::<TunnelListener>();
/// ```
///
/// Fields are `Option` so `close` can move them out of the `Drop`-impl
/// type; an emptied listener drops as a no-op (teardown is idempotent —
/// an already-reaped entry reaps `Err(UnknownChannel)`, ignored).
pub struct TunnelListener {
/// The adopted listen channel's ID (one session = one channel —
/// OQ-TN-02's resolution; observability + correlation).
pub channel_id: u32,
manager: Option<ChannelManager>,
/// The `bound` reply field (the real OS-chosen address) — `None`
/// for accept-first/dial establishers or old producers (the Option
/// posture). Informational surface, NOT addressing.
bound: Option<SubstrateAddr>,
/// The `listen` reply field (the producer-minted correlation
/// token) — `None` for establishers that mint nothing (the Option
/// posture).
token: Option<u32>,
/// The listen channel's write half, held IDLE — the structural
/// teardown signal (see the type docs). Never pumped, never
/// exposed.
write: Option<MpscSendStream>,
}
impl TunnelListener {
/// Open a listen tunnel (the listen flow, ADR-008 §4). Calls the
/// open op through [`ChannelClient::open_channel_with_reply`] (the
/// U-2 surface, alkcall 0.8.0) — the full reply arrives, the
/// bind-first reply fields are projected via
/// [`crate::params::listen_reply_fields`], and the already-adopted
/// channel entry is held (adopt-free construction — the client
/// adopted it). A failed open never yields a listener (typed
/// errors — ADR-049 §4; no phantom session).
///
/// `bound()`/`listen()` surface `None` when the establisher
/// supplied nothing (accept-first/dial producers, or old producers
/// — the additive posture); `bound` is informational surface and
/// MUST NOT be treated as a dial target (the producer owns the
/// backing — ADR-001's invariant).
pub async fn open(
client: &ChannelClient,
params: TunnelParams,
) -> Result<Self, TunnelOpenError> {
let input = serde_json::to_value(&params).map_err(|e| {
TunnelOpenError::Open(ChannelOpenError::CallFailed {
error: CallError::internal(format!("params serialize: {e}")),
})
})?;
let (channel_id, reply, send, _recv) = client
.open_channel_with_reply(OP_TUNNEL_OPEN, input, TUNNEL_ALPN)
.await?;
let projected = listen_reply_fields(&reply);
Ok(TunnelListener {
channel_id,
manager: Some(client.manager().clone()),
bound: projected.bound,
token: projected.listen,
write: Some(send),
})
}
/// The `bound` reply field — the real OS-chosen bound address
/// (port `0` resolved), present only when the establisher supplied
/// it (bind-first producers). reply#1-class fidelity (BND.ADDR).
/// Informational surface, never addressing.
pub fn bound(&self) -> Option<&SubstrateAddr> {
self.bound.as_ref()
}
/// The `listen` reply field — the correlation token (ADR-008
/// Amendment 1) the producer minted for this listen tunnel. The
/// assembly keys its forwarded-op correlation map on it; the
/// listener does not own the receipt queue (forwarded sessions
/// arrive as separate adopted sessions on the manager).
pub fn listen(&self) -> Option<u32> {
self.token
}
/// Tear the listen tunnel down: shut the held write half (the
/// graceful EOF sentinel — the far accept dispatcher's teardown
/// signal), then reap the adopted channel entry
/// (`teardown_channel`). Returns whether the entry existed. The
/// far-side teardown cascades (EOF → accept-loop cancel → far
/// listener drop); forwarded sessions already adopted own their
/// own teardown (per-session, ADR-005).
pub async fn close(mut self) -> bool {
if let Some(write) = self.write.as_mut() {
let _ = write.shutdown().await;
}
self.write.take();
self.manager
.take()
.map(|m| m.teardown_channel(self.channel_id).is_ok())
.unwrap_or(false)
}
}
impl Drop for TunnelListener {
/// Drop the held write half (the mux pump writes the implicit-EOF
/// chunk — the far dispatcher's teardown signal) and sync-reap the
/// adopted channel entry (`teardown_channel` is sync —
/// best-effort; `Drop` cannot await). Dropping without close never
/// leaks the channel entry (ADR-005's Drop posture, listen-
/// flavored).
fn drop(&mut self) {
self.write.take();
if let Some(manager) = self.manager.take() {
let _ = manager.teardown_channel(self.channel_id);
}
}
}
/// Read from the chunk stream until one complete datagram is
/// assembled (the incremental decode path — ADR-003). `Ok(None)` on
/// stream EOF; mid-datagram EOF is `TruncatedDatagram` (fail-loud).
+7
View File
@@ -20,6 +20,13 @@ pub enum TunnelError {
/// An I/O failure on the session's data plane.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// A reply field failed its typed projection — the wire carried a
/// malformed shape (e.g. [`crate::params::parse_listen_reply`]
/// found `bound` not substrate-shaped, a non-positive `listen`,
/// or a reply with no `channel_id` at all). A misbehaving
/// producer is never silently coerced.
#[error("malformed reply: {0}")]
MalformedReply(String),
}
/// A failed tunnel open or adopt. A failure never yields a session
+19 -5
View File
@@ -6,7 +6,9 @@
//! is the sibling precedent): the producer half registers the
//! `alk/tunnel` open op and pumps bytes between the channel and the
//! substrate; the consumer half is the typed session
//! ([`TunnelSession`]) that opens tunnel channels and owns teardown.
//! ([`TunnelSession`]) that opens tunnel channels and owns teardown —
//! plus [`TunnelListener`] (the listen-flow session, ADR-008) for the
//! bind-first listen shape.
//!
//! A tunnel is a resource, not an address: the open-op params identify
//! a produced resource + substrate ([`TunnelParams`], ADR-001); the
@@ -27,12 +29,24 @@ pub mod wire;
#[cfg(feature = "local")]
pub mod local;
pub use consumer::{open_reverse_channel, TakenHalves, TunnelSession};
pub use consumer::{open_reverse_channel, TakenHalves, TunnelListener, TunnelSession};
pub use error::{ReverseOpenError, TunnelError, TunnelIoError, TunnelOpenError};
pub use params::{establishment_reason, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
pub use params::{
establishment_reason, listen_reply_fields, parse_listen_reply, tunnel_direct_spec,
tunnel_forwarded_spec, SubstrateAddr, TunnelDirectParams, TunnelForwardedParams,
TunnelListenReply, OP_TUNNEL_DIRECT, OP_TUNNEL_FORWARDED, TUNNEL_DIRECT_SCOPE,
TUNNEL_FORWARDED_SCOPE,
};
pub use params::{tunnel_open_spec, ChannelOpenError, Substrate, TunnelParams, OP_TUNNEL_OPEN};
pub use params::{TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
pub use producer::{
register_tunnel_listen_openable, AcceptFn, AcceptQueue, DialFn, ResourceRegistry, TargetHandle,
TunnelEstablishError,
bindfirst_establisher, make_bindfirst_accept_handler, make_forwarded_op_handler,
register_tunnel_bindfirst_openable, register_tunnel_forwarded_openable,
};
pub use producer::{
direct_establisher, direct_establisher_with_witness, register_tunnel_direct_openable,
register_tunnel_listen_openable, AcceptFn, AcceptQueue, BindFirstAcceptLoop,
BindFirstInjections, BindFirstPlan, BindFn, BoundListener, DialFn, ForwardedHandler,
ForwardedOpener, ResourceRegistry, TargetHandle, TunnelEstablishError,
};
pub use wire::MAX_DATAGRAM_LEN;
+240 -2
View File
@@ -17,6 +17,16 @@
//! as TCP). **Stdio is NOT here** — a spawned process's stdio is
//! alktty's pipe mode (exit codes + signals are tty semantics);
//! remote command execution composes via alktty, not here.
//! - **Bind-first** (shape 3, ADR-008) — [`bind_tcp_first`] /
//! [`bind_unix_first`] (the real-socket [`BindFn`] sources: the
//! `bound` reply field's data — the REAL OS-chosen address, port 0
//! resolved), [`TcpListenerHalves::accept_pair`] /
//! [`UnixListenerHalves::accept_pair`] (one accept per call yielding
//! `(peer, halves)` — the [`BindFirstAcceptLoop`] contract), and the
//! assembly convenience closures [`local_bind_first()`] +
//! [`local_bindfirst_accept_loop()`]. UDP bind-first is NOT here —
//! a UDP socket has no accept; the per-peer associate shape is a
//! separate design decision (the bind closure fails loud for udp).
//!
//! Error mapping: [`TunnelEstablishError`] variants (the POC's
//! `Into<EstablishmentError>` path) — dial refusal → `dial_failed`,
@@ -28,8 +38,11 @@
use std::sync::Arc;
use crate::params::Substrate;
use crate::producer::{AcceptQueue, DialFn, TargetHandle, TunnelEstablishError};
use crate::params::{Substrate, SubstrateAddr};
use crate::producer::{
AcceptQueue, BindFirstAcceptLoop, BindFn, BoundListener, DialFn, TargetHandle,
TunnelEstablishError,
};
use crate::wire::{frame_datagram, DatagramCodecError, DatagramReader, MAX_DATAGRAM_LEN};
use tokio::io::{AsyncRead, AsyncWrite};
@@ -110,6 +123,37 @@ impl TcpListenerHalves {
})?;
}
}
/// ONE accept as a `(peer, halves)` pair — the BIND-FIRST shape's
/// substrate half (ADR-008 §1): the accept dispatcher invokes the
/// injected [`BindFirstAcceptLoop`] closure PER accept; this method
/// is what the closure calls. The peer address rides `accept()`'s
/// return value (the forwarded op's `peer` carrier — reply#2-class
/// fidelity, ADR-008 §3).
///
/// This is deliberately NOT the [`AcceptQueue`]-feeding
/// [`TcpListenerHalves::accept_loop`] (the accept-FIRST shape's
/// contract — the establisher pops pre-accepted handles): the
/// bind-first dispatcher owns the accept pacing, so one accept per
/// closure call with the peer attached is the honest shape.
pub async fn accept_pair(&self) -> Result<(SubstrateAddr, TargetHandle), TunnelEstablishError> {
let (stream, peer) = self
.listener
.accept()
.await
.map_err(|e| TunnelEstablishError::DialFailed(format!("accept: {e}")))?;
let (read, write) = stream.into_split();
Ok((
SubstrateAddr::HostPort {
host: peer.ip().to_string(),
port: peer.port(),
},
TargetHandle {
read: Box::new(read),
write: Box::new(write),
},
))
}
}
// --- UDP ----------------------------------------------------------------
@@ -501,6 +545,122 @@ pub async fn dial_unix(path: &str) -> Result<TargetHandle, TunnelEstablishError>
})
}
/// Bind a unix-socket listener (the listen shape's assembly-side half,
/// OQ-TN-14's dial parity — same wrapper shape as
/// [`TcpListenerHalves`]). The binding decision belongs to the caller
/// (OQ-TN-04); a stale socket file at `path` fails loud (the
/// caller unlinks first — `bind` never silently reuses).
pub fn bind_unix(path: &str) -> Result<UnixListenerHalves, TunnelEstablishError> {
let listener = tokio::net::UnixListener::bind(path)
.map_err(|e| TunnelEstablishError::ResourceShortage(format!("bind `{path}`: {e}")))?;
Ok(UnixListenerHalves {
listener: Arc::new(listener),
})
}
/// An assembly-owned unix-socket listener + its accept half. The
/// accept loop ([`UnixListenerHalves::accept_loop`]) is the
/// accept-FIRST shape's feed ([`AcceptQueue`]); the per-accept
/// [`UnixListenerHalves::accept_pair`] is the bind-FIRST shape's
/// (one accept = one `(peer, halves)` pair — the peer is the
/// connecting path, `accept()`'s return value).
pub struct UnixListenerHalves {
listener: Arc<tokio::net::UnixListener>,
}
impl UnixListenerHalves {
/// The bound listener's socket path.
pub fn local_addr(&self) -> Result<String, TunnelEstablishError> {
self.listener
.local_addr()
.map_err(|e| TunnelEstablishError::HandlerError(format!("local_addr: {e}")))?
.as_pathname()
.map(|p| p.to_string_lossy().into_owned())
.ok_or_else(|| {
TunnelEstablishError::HandlerError(
"unix listener has no filesystem path (unnamed/abstract bind)".to_string(),
)
})
}
/// The accept-FIRST shape's loop: accept until the listener fails,
/// pushing each accepted handle into the queue (the assembly layer
/// aborts this task on teardown). Unix peer addresses are not
/// reliably readable from an accepted stream — the queue contract
/// carries halves only, same as the TCP shape's loop.
pub async fn accept_loop(&self, queue: AcceptQueue) -> Result<(), TunnelEstablishError> {
loop {
let (stream, _peer) = self
.listener
.accept()
.await
.map_err(|e| TunnelEstablishError::DialFailed(format!("accept: {e}")))?;
let (read, write) = stream.into_split();
let handle = TargetHandle {
read: Box::new(read),
write: Box::new(write),
};
queue.push(handle).await.map_err(|dropped| {
TunnelEstablishError::HandlerError(format!(
"queue closed; accepted connection dropped: {dropped:?}"
))
})?;
}
}
/// ONE accept as a `(peer, halves)` pair — the BIND-FIRST shape's
/// substrate half for unix (OQ-TN-14 parity: the bind path is the
/// same shape as [`TcpListenerHalves::accept_pair`]). The peer is
/// the ACCEPTED side's bound path ([`UnixStream::peer_addr`]);
/// an unnamed peer (an autobind client) fails loud — the
/// forwarded op's `peer` param is required on the wire, so an
/// addressless peer cannot be represented (ADR-008 §3).
pub async fn accept_pair(&self) -> Result<(SubstrateAddr, TargetHandle), TunnelEstablishError> {
let (stream, peer) = self
.listener
.accept()
.await
.map_err(|e| TunnelEstablishError::DialFailed(format!("accept: {e}")))?;
let peer_path = peer
.as_pathname()
.map(|p| p.to_string_lossy().into_owned())
.ok_or_else(|| {
TunnelEstablishError::HandlerError(
"unix accept: the client socket has no bound path (unnamed peer); \
the forwarded `peer` param requires an address"
.to_string(),
)
})?;
let (read, write) = stream.into_split();
Ok((
SubstrateAddr::Path { path: peer_path },
TargetHandle {
read: Box::new(read),
write: Box::new(write),
},
))
}
}
/// Bind a unix-socket listener for the BIND-FIRST shape (the
/// real-socket [`BindFn`] source, OQ-TN-14's dial/bind parity): the
/// returned [`BoundListener::bound`] is the REAL bound socket path
/// (the `bound` reply field's data — reply#1-class fidelity; the
/// forwarded op's `peer` shape matches). The opaque `listener` half is
/// the [`UnixListenerHalves`] wrapper — the assembly's
/// [`BindFirstAcceptLoop`] closure downcasts to it and calls
/// [`UnixListenerHalves::accept_pair`] per accept.
pub async fn bind_unix_first(path: &str) -> Result<BoundListener, TunnelEstablishError> {
let halves = bind_unix(path)?;
let bound = halves
.local_addr()
.map(|path| SubstrateAddr::Path { path })?;
Ok(BoundListener {
listener: Arc::new(halves) as Arc<dyn std::any::Any + Send + Sync>,
bound,
})
}
/// The local dial closure (the ADR-004 injection point): one
/// [`DialFn`]-shaped function covering tcp/udp/unix — the assembly
/// layer injects this (closed over nothing substrate-specific beyond
@@ -517,3 +677,81 @@ pub fn local_dial() -> DialFn {
})
})
}
// --- Bind-first (shape 3, ADR-008 §1) ------------------------------------
/// Bind a TCP listener for the BIND-FIRST shape (the real-socket
/// [`BindFn`] source — shape 3's backing, ADR-008 §1): `port 0` = the
/// OS chooses, and the returned [`BoundListener::bound`] is the REAL
/// resolved address (`local_addr()` — the `bound` reply field's data,
/// reply#1-class fidelity). The caller-supplied address is deliberate
/// ([`bind_tcp`]'s wildcard-bind posture applies unchanged).
///
/// The opaque `listener` half is the [`TcpListenerHalves`] wrapper —
/// the assembly's [`BindFirstAcceptLoop`] closure downcasts to it and
/// calls [`TcpListenerHalves::accept_pair`] per accept.
pub async fn bind_tcp_first(addr: &str) -> Result<BoundListener, TunnelEstablishError> {
let halves = bind_tcp(addr).await?;
let bound = halves.local_addr().map(|a| SubstrateAddr::HostPort {
host: a.ip().to_string(),
port: a.port(),
})?;
Ok(BoundListener {
listener: Arc::new(halves) as Arc<dyn std::any::Any + Send + Sync>,
bound,
})
}
/// The TCP bind closure + accept-pair loop, bundled (the assembly
/// convenience): [`local_bind_first()`] renders the [`BindFn`] shape-3
/// expects (the dispatch substrate picks tcp/unix; udp fails loud —
/// a UDP socket has no accept, so bind-first is not defined there),
/// [`local_bindfirst_accept_loop()`] renders the [`BindFirstAcceptLoop`]
/// that downcasts the plan's opaque listener to [`TcpListenerHalves`] /
/// [`UnixListenerHalves`] and accepts one pair.
pub fn local_bind_first() -> BindFn {
Arc::new(move |substrate, backing: &str| {
let backing = backing.to_string();
Box::pin(async move {
match substrate {
Substrate::Tcp => bind_tcp_first(&backing).await,
Substrate::Unix => bind_unix_first(&backing).await,
Substrate::Udp => Err(TunnelEstablishError::HandlerError(
"udp has no bind-first shape (no accept on a UDP socket); \
use the dial shape"
.to_string(),
)),
}
})
})
}
/// The bind-first accept loop (the assembly convenience paired with
/// [`local_bind_first()`]): downcasts the plan's opaque listener to
/// the concrete wrapper the bind closure produced and accepts ONE
/// connection as a `(peer, halves)` pair per invocation (the
/// dispatcher's per-accept contract — one closure call = one accept;
/// the loop lives in the protocol's dispatcher).
///
/// The downcast is the assembly side of the type-erased injection
/// pattern: the protocol crate never knows the listener's type, so the
/// closure must — a listener not produced by this module's bind
/// closures (or a mismatched substrate/backing pairing in the
/// assembly's registry) fails loud as `handler_error`.
pub fn local_bindfirst_accept_loop() -> BindFirstAcceptLoop {
Arc::new(move |listener| {
Box::pin(async move {
if let Some(tcp) = listener.downcast_ref::<TcpListenerHalves>() {
return tcp.accept_pair().await;
}
if let Some(unix) = listener.downcast_ref::<UnixListenerHalves>() {
return unix.accept_pair().await;
}
Err(TunnelEstablishError::HandlerError(
"bind-first accept loop: the plan's listener is not a local listener \
(a mismatched bind closure / registry pairing)"
.to_string(),
))
})
})
}
+943 -13
View File
@@ -3,6 +3,23 @@
//! `OperationSpec` builder, the scope/ALPN/op-id constants, and the
//! typed establishment-reason helper (ADR-049 §4 surface).
//!
//! The direct op's params ([`TunnelDirectParams`] + [`SubstrateAddr`],
//! ADR-007) and its spec builder live here too: `{substrate, target}`
//! — the caller names the dial target at session time (the ssh
//! `direct-tcpip` / SOCKS5 CONNECT shape), scope-gated by
//! `tunnel:direct` (a separate grant from `tunnel:open`, never implied
//! by it).
//!
//! The forwarded op's params ([`TunnelForwardedParams`], ADR-008 §3
//! as amended) and its spec builder live here as well:
//! `{listen, substrate, peer}` — the accept-as-open ingress op, keyed
//! by the producer-minted correlation token (Amendment 1), scope-gated
//! by `tunnel:forwarded` (a separate grant again). The bind-first
//! listen open's reply projection ([`TunnelListenReply`] +
//! [`parse_listen_reply`]) is the typed view of the two additive
//! reply fields (`bound`, `listen`) the bind-first establisher
//! contributes.
//!
//! The params layout is a one-way door once a consumer exists
//! (ADR-001): `{resource, substrate}` only — the producer owns the
//! backing; no URL-style addressing. `deny_unknown_fields` is the
@@ -15,7 +32,9 @@ use alkcall::registry::spec::{
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::{json, Value};
use crate::error::TunnelError;
/// The `channels/tunnel/sub` operation id (the open op).
pub const OP_TUNNEL_OPEN: &str = "channels/tunnel/sub";
@@ -27,6 +46,29 @@ pub const TUNNEL_ALPN: &str = "alk/tunnel";
/// The scope gating tunnel opens (ADR-006). Stable once published.
pub const TUNNEL_OPEN_SCOPE: &str = "tunnel:open";
/// The `channels/tunnel/direct` operation id (the direct op —
/// dynamic-target egress, ADR-007; the flavor-form op-id convention,
/// ADR-002 Amendment 1).
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
/// The scope gating the direct op (ADR-007 §3 / ADR-006 Amendment 1).
/// A separate grant from [`TUNNEL_OPEN_SCOPE`] — **never implied by
/// it**: "dial `postgres-primary`" and "dial anything from my
/// network" are different capabilities. Stable once published.
pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
/// The `channels/tunnel/forwarded` operation id (the forwarded op —
/// accept-as-open ingress, ADR-008 §3; the flavor-form op-id
/// convention, ADR-002 Amendment 1).
pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded";
/// The scope gating the forwarded op (ADR-008 §3 / ADR-006 Amendment
/// 1). A separate grant from [`TUNNEL_OPEN_SCOPE`] and
/// [`TUNNEL_DIRECT_SCOPE`] — **never implied by either**: receiving
/// producer-initiated forwarded connections (ingress) is a different
/// capability from dialing egress. Stable once published.
pub const TUNNEL_FORWARDED_SCOPE: &str = "tunnel:forwarded";
/// The open-op input params (ADR-001). Wire-stable: both fields
/// required; renames are a breaking wire change.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -52,6 +94,50 @@ pub enum Substrate {
Unix,
}
/// The substrate-shaped address object (ADR-007 §2): keyed by the
/// sibling `substrate` field of the params — `tcp`/`udp` →
/// [`SubstrateAddr::HostPort`] (domain-form hosts stay unresolved on
/// the wire — producer-side resolution, the ssh `-D` semantic),
/// `unix` → [`SubstrateAddr::Path`]. Untagged: the shapes are
/// mutually exclusive in practice and self-describing. Also the
/// `peer` value shape of the forwarded op (ADR-008 §3) — defined
/// once here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SubstrateAddr {
HostPort { host: String, port: u16 },
Path { path: String },
}
/// The direct-op input params (ADR-007 §2). Wire-stable: both fields
/// required; renames are a breaking wire change. The target's
/// shape-vs-substrate correspondence is pinned by the spec's
/// `input_schema` (per-substrate `oneOf`, validated registry-side
/// before the establisher) and re-checked by serde here (the
/// backstop — a mismatch parses as the wrong variant or fails).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelDirectParams {
/// The substrate discriminator; selects the data-plane framing
/// (ADR-003) and the target's expected shape.
pub substrate: Substrate,
/// The dial target, keyed by `substrate` ([`SubstrateAddr`]).
pub target: SubstrateAddr,
}
impl TunnelDirectParams {
/// Render the target to the backing-string form the `DialFn` /
/// registry backings use: `"host:port"` for tcp/udp, the path for
/// unix. The direct establisher calls this before dialing — the
/// dial closure stays substrate-agnostic (ADR-007 §4).
pub fn render_target(&self) -> String {
match &self.target {
SubstrateAddr::HostPort { host, port } => format!("{host}:{port}"),
SubstrateAddr::Path { path } => path.clone(),
}
}
}
/// The open-op spec (`channels/tunnel/sub`): `Sub`-typed, external,
/// channel-open marker on the `alk/tunnel` ALPN, scope-gated by
/// `tunnel:open` (ADR-006), input/output schemas per wire.md §The
@@ -91,6 +177,339 @@ pub fn tunnel_open_spec() -> OperationSpec {
)
}
/// The direct-op spec (`channels/tunnel/direct`, ADR-007): `Sub`-typed,
/// external, channel-open marker on the `alk/tunnel` ALPN, scope-gated
/// by `tunnel:direct` (a separate grant from `tunnel:open` — never
/// implied by it, ADR-006 Amendment 1), input/output schemas per
/// wire.md §The Direct Op. The target sub-schema is per-substrate —
/// `if`/`then` conditioned on the sibling `substrate` value
/// (`tcp`/`udp` → `{host, port}`, `unix` → `{path}`) — so a malformed
/// target fails the registry's schema validation (the typed
/// `INVALID_INPUT` class) before the establisher runs.
pub fn tunnel_direct_spec() -> OperationSpec {
OperationSpec::new(
OP_TUNNEL_DIRECT,
OperationType::Sub,
Visibility::External,
json!({
"type": "object",
"properties": {
"substrate": { "type": "string", "enum": ["tcp", "udp", "unix"] }
},
"required": ["substrate", "target"],
"allOf": [
{
"if": {
"properties": { "substrate": { "enum": ["tcp", "udp"] } },
"required": ["substrate"]
},
"then": {
"properties": {
"target": {
"type": "object",
"properties": {
"host": { "type": "string" },
"port": {
"type": "integer",
"minimum": 0,
"maximum": 65535
}
},
"required": ["host", "port"],
"additionalProperties": false
}
}
}
},
{
"if": {
"properties": { "substrate": { "const": "unix" } },
"required": ["substrate"]
},
"then": {
"properties": {
"target": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
}
}
}
}
]
}),
json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer", "minimum": 1 }
},
"required": ["channel_id"]
}),
vec![],
AccessControl {
required_scopes: vec![TUNNEL_DIRECT_SCOPE.to_string()],
required_scopes_any: None,
resource_type: None,
resource_action: None,
},
None,
)
.with_channel_open(ChannelOpenSpec::new(TUNNEL_ALPN))
.with_description(
"Open a tunnel channel to a caller-named target (params: {substrate, target}; \
dynamic-target egress)",
)
}
/// The bind-first listen open's reply projection (ADR-008 §2 as
/// amended): the two additive fields the bind-first establisher
/// contributes, extracted from the full success reply.
///
/// **The struct is the projected pair, not a deny-unknown wire gate.**
/// The reply arrives as `{channel_id, bound?, listen?}` (the
/// wrapper-reserved `channel_id` key plus the establisher's additive
/// fields); unknown extra reply fields must be tolerated (the additive
/// posture — old consumers ignore unknown fields). Parse the full
/// reply with [`parse_listen_reply`], never with `deny_unknown_fields`
/// deserialization of the whole reply. Both fields are `None` for
/// accept-first/dial opens (the establisher supplied nothing).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelListenReply {
/// The real OS-chosen bound address (port `0` resolved),
/// substrate-shaped — present only when the establisher supplied
/// it. Informational surface, NOT addressing: a consumer MUST NOT
/// treat `bound` as a dial target (the producer owns the backing —
/// ADR-001's invariant); it exists for RFC-1928-reply-class
/// fidelity (BND.ADDR) and observability.
#[serde(skip_serializing_if = "Option::is_none")]
pub bound: Option<SubstrateAddr>,
/// The correlation token (ADR-008 Amendment 1): the u32 the
/// establisher minted — the key the forwarded op's `listen` param
/// carries. Present only when the establisher supplied it.
#[serde(skip_serializing_if = "Option::is_none")]
pub listen: Option<u32>,
}
/// Project the bind-first listen open's reply (ADR-008 §2 as amended)
/// out of the FULL success output [`Value`] — the shape
/// `ChannelClient::open_channel_with_reply` returns. Extracts the
/// optional `bound` + `listen` fields into [`TunnelListenReply`].
///
/// - Unknown extra reply fields are **tolerated** — the reply viewer
/// must not deny unknown fields (the additive posture; the wire is
/// plain JSON reply fields, and the projection is a typed view).
/// - `channel_id` must be present and a positive integer (the
/// wrapper-owned key — its absence means the caller is not looking
/// at an open reply at all; fail loud).
/// - A present-but-malformed `bound` (not substrate-shaped) or
/// `listen` (not a positive u32) is a loud error — a misbehaving
/// producer is never silently coerced ([`TunnelError::
/// MalformedReply`]).
pub fn parse_listen_reply(reply: &Value) -> Result<TunnelListenReply, TunnelError> {
match reply.get("channel_id").and_then(Value::as_u64) {
Some(id) if id > 0 => {}
_ => {
return Err(TunnelError::MalformedReply(
"listen reply missing channel_id".to_string(),
))
}
}
let bound = match reply.get("bound") {
None | Some(Value::Null) => None,
Some(v) => Some(
serde_json::from_value::<SubstrateAddr>(v.clone()).map_err(|e| {
TunnelError::MalformedReply(format!("bound not substrate-shaped: {e}"))
})?,
),
};
let listen = match reply.get("listen") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_u64()
.filter(|n| *n > 0 && *n <= u32::MAX as u64)
.map(|n| n as u32)
.ok_or_else(|| {
TunnelError::MalformedReply("listen not a positive u32".to_string())
})?,
),
};
Ok(TunnelListenReply { bound, listen })
}
/// Extract the bind-first listen open's reply fields (ADR-008 §2 as
/// amended) from an open-op success reply — the **lenient, infallible
/// projection** the typed consumer surface reads: both fields
/// [`Option`], absent for dial/accept-first producers, unknown extra
/// reply fields tolerated (the additive posture).
///
/// Delegates to [`parse_listen_reply`] (the loud typed viewer) and
/// projects a malformed reply to both-`None`: the session type
/// presents `Option` fields and never refuses construction over an
/// informational field (`bound`/`listen` are informational surface,
/// NOT addressing — ADR-008 §2's invariant; leniency here is safe by
/// construction). A malformed shape is logged loud (`tracing::error!`)
/// and surfaces as `None` — the session's caller cannot distinguish
/// "producer supplied nothing" from "producer misbehaved"; use
/// [`parse_listen_reply`] directly when that distinction matters (the
/// fail-loud posture for consumers that must reject misbehaving
/// producers). Shared with `TunnelListener` (`tunnels/tunnel-listener`
/// reads the reply through this projection).
pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply {
match parse_listen_reply(reply) {
Ok(projected) => projected,
Err(e) => {
tracing::error!(
error = %e,
"listen reply projection: malformed reply; both fields None (lenient view)"
);
TunnelListenReply {
bound: None,
listen: None,
}
}
}
}
/// The forwarded-op input params (ADR-008 §3 as amended). Wire-stable:
/// all three fields required; renames are a breaking wire change.
/// `deny_unknown_fields` is the loud-rejection posture (ADR-001's
/// precedent — unknown fields fail, never silently ignored).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelForwardedParams {
/// The correlation token (ADR-008 Amendment 1): the u32 the
/// bind-first establisher minted and returned in the listen
/// open's `listen` reply field — NOT the listen channel's ID
/// (that key is superseded: the serving-side handler never sees
/// its own channel ID, and generic relays don't rewrite params).
/// Positive by wire contract (schema `minimum: 1`; the serde pin
/// is the backstop).
#[serde(deserialize_with = "positive_u32")]
pub listen: u32,
/// The listen tunnel's substrate; shapes the forwarded channel's
/// data-plane framing (ADR-003).
pub substrate: Substrate,
/// The accepted connection's remote address, keyed by the sibling
/// `substrate` field ([`SubstrateAddr`]). Required in v1 — a
/// producer that cannot determine the peer does not serve a
/// bind-first listener over that substrate (ADR-008 §3).
pub peer: SubstrateAddr,
}
/// The serde-level pin behind `listen`'s schema `minimum: 1`: the
/// correlation token is positive by contract (the establisher seeds
/// the mint counter non-zero), so a `listen: 0` on the wire is a
/// malformed open — rejected at parse time, never silently accepted.
fn positive_u32<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = u32::deserialize(deserializer)?;
if v == 0 {
return Err(serde::de::Error::custom("listen must be > 0"));
}
Ok(v)
}
/// The forwarded-op spec (`channels/tunnel/forwarded`, ADR-008 §3 as
/// amended): `Sub`-typed, external, channel-open marker on the
/// `alk/tunnel` ALPN, scope-gated by `tunnel:forwarded` (ingress — a
/// separate grant from `tunnel:open` and `tunnel:direct`, ADR-006
/// Amendment 1), input/output schemas per wire.md §The Forwarded Op.
/// All three params required; the `peer` sub-schema is per-substrate —
/// `if`/`then` conditioned on the sibling `substrate` value (`tcp`/
/// `udp` → `{host, port}`, `unix` → `{path}`), so a malformed peer
/// fails the registry's schema validation (the typed
/// `INVALID_INPUT` class) before the opener-side handler runs.
///
/// **No establisher** on the forwarded op — the data plane starts
/// immediately (the opener-side serving registration is
/// `register_openable`, handler = correlate by `listen` + accept the
/// `BiStream` + pump; the correlation registry is assembly-side
/// bookkeeping, ADR-008 §3).
pub fn tunnel_forwarded_spec() -> OperationSpec {
OperationSpec::new(
OP_TUNNEL_FORWARDED,
OperationType::Sub,
Visibility::External,
json!({
"type": "object",
"properties": {
"listen": { "type": "integer", "minimum": 1 },
"substrate": { "type": "string", "enum": ["tcp", "udp", "unix"] }
},
"required": ["listen", "substrate", "peer"],
"allOf": [
{
"if": {
"properties": { "substrate": { "enum": ["tcp", "udp"] } },
"required": ["substrate"]
},
"then": {
"properties": {
"peer": {
"type": "object",
"properties": {
"host": { "type": "string" },
"port": {
"type": "integer",
"minimum": 0,
"maximum": 65535
}
},
"required": ["host", "port"],
"additionalProperties": false
}
}
}
},
{
"if": {
"properties": { "substrate": { "const": "unix" } },
"required": ["substrate"]
},
"then": {
"properties": {
"peer": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"],
"additionalProperties": false
}
}
}
}
]
}),
json!({
"type": "object",
"properties": {
"channel_id": { "type": "integer", "minimum": 1 }
},
"required": ["channel_id"]
}),
vec![],
AccessControl {
required_scopes: vec![TUNNEL_FORWARDED_SCOPE.to_string()],
required_scopes_any: None,
resource_type: None,
resource_action: None,
},
None,
)
.with_channel_open(ChannelOpenSpec::new(TUNNEL_ALPN))
.with_description(
"Receive a producer-initiated forwarded connection (accept-as-open; \
params: {listen, substrate, peer}; correlate by the `listen` token)",
)
}
/// The establishment-failure reason code of a
/// `channel:open_failed` error (`details.reason`): `dial_failed`,
/// `unknown_resource`, `resource_shortage`, `handler_error`,
@@ -180,12 +599,16 @@ mod tests {
}
#[test]
fn schema_rejects_unknown_substrate_value() {
let schema = tunnel_open_spec().input_schema;
fn serde_rejects_unknown_substrate_value() {
// The serde layer (the enum deserializer), not the JSON Schema —
// the registry's real schema gate is pinned end-to-end by the
// integration probes (`schema_rejects_unknown_substrate_loudly`,
// `malformed_target_is_schema_class_and_leaves_no_channel`, the
// forwarded `listen: 0` gate).
let substrate_ok = json!({"resource": "r", "substrate": "udp"});
let substrate_bad = json!({"resource": "r", "substrate": "sctp"});
assert!(jsonschema_valid(&schema, &substrate_ok));
assert!(!jsonschema_valid(&schema, &substrate_bad));
assert!(serde_json::from_value::<TunnelParams>(substrate_ok).is_ok());
assert!(serde_json::from_value::<TunnelParams>(substrate_bad).is_err());
}
#[test]
@@ -206,14 +629,521 @@ mod tests {
assert_eq!(establishment_reason(&local_only), None);
}
fn jsonschema_valid(_schema: &serde_json::Value, value: &serde_json::Value) -> bool {
let Ok(params) = serde_json::from_value::<TunnelParams>(value.clone()) else {
return false;
// --- direct op (ADR-007) -------------------------------------------
#[test]
fn direct_params_round_trip_per_substrate() {
let cases: Vec<(Substrate, serde_json::Value, SubstrateAddr)> = vec![
(
Substrate::Tcp,
json!({"host": "db.internal", "port": 5432}),
SubstrateAddr::HostPort {
host: "db.internal".to_string(),
port: 5432,
},
),
(
Substrate::Udp,
json!({"host": "10.0.0.7", "port": 53}),
SubstrateAddr::HostPort {
host: "10.0.0.7".to_string(),
port: 53,
},
),
(
Substrate::Unix,
json!({"path": "/run/postgresql/.s.PGSQL.5432"}),
SubstrateAddr::Path {
path: "/run/postgresql/.s.PGSQL.5432".to_string(),
},
),
];
for (substrate, target, addr) in cases {
let params = TunnelDirectParams {
substrate,
target: addr.clone(),
};
let v = serde_json::to_value(&params).unwrap();
assert_eq!(
v,
json!({"substrate": substrate_str(substrate), "target": target})
);
let back: TunnelDirectParams = serde_json::from_value(v).unwrap();
assert_eq!(back, params);
}
}
#[test]
fn substrate_addr_untagged_disambiguation() {
let hostport: SubstrateAddr =
serde_json::from_value(json!({"host": "h", "port": 1})).unwrap();
let path: SubstrateAddr = serde_json::from_value(json!({"path": "/p"})).unwrap();
assert_eq!(
hostport,
SubstrateAddr::HostPort {
host: "h".to_string(),
port: 1
}
);
assert_eq!(
path,
SubstrateAddr::Path {
path: "/p".to_string()
}
);
// Host/port without the host field fails; an array fails.
assert!(serde_json::from_value::<SubstrateAddr>(json!({"port": 1})).is_err());
assert!(serde_json::from_value::<SubstrateAddr>(json!([1, 2])).is_err());
}
#[test]
fn direct_params_unknown_fields_rejected() {
let v = json!({
"substrate": "tcp",
"target": {"host": "h", "port": 1},
"resource": "sneaky"
});
assert!(serde_json::from_value::<TunnelDirectParams>(v).is_err());
// The untagged target is lenient about extra target fields
// (untagged variants carry no deny_unknown_fields) — a
// cross-shape target parses as whichever variant fits. The
// SCHEMA is the gate that rejects shape-vs-substrate
// mismatches registry-side (additionalProperties: false per
// branch; direct_target_schema_gates_shape_per_substrate).
// The establisher's serde parse is the backstop for the
// shape it can actually use (render_target keys off the
// sibling substrate).
let cross_shape = json!({
"substrate": "unix",
"target": {"path": "/p", "host": "h"}
});
let parsed: TunnelDirectParams = serde_json::from_value(cross_shape).unwrap();
assert_eq!(
parsed.target,
SubstrateAddr::Path {
path: "/p".to_string()
}
);
}
#[test]
fn direct_params_missing_fields_rejected() {
let no_target = json!({"substrate": "tcp"});
let no_substrate = json!({"target": {"host": "h", "port": 1}});
assert!(serde_json::from_value::<TunnelDirectParams>(no_target).is_err());
assert!(serde_json::from_value::<TunnelDirectParams>(no_substrate).is_err());
}
#[test]
fn direct_spec_matches_wire_md() {
let spec = tunnel_direct_spec();
assert_eq!(spec.name, "channels/tunnel/direct");
assert_eq!(spec.op_type, OperationType::Sub);
assert_eq!(spec.visibility, Visibility::External);
assert_eq!(
spec.input_schema["properties"]["substrate"]["enum"],
json!(["tcp", "udp", "unix"])
);
assert_eq!(
spec.input_schema["required"],
json!(["substrate", "target"])
);
assert_eq!(spec.output_schema["properties"]["channel_id"]["minimum"], 1);
assert_eq!(
spec.access_control.required_scopes,
vec![TUNNEL_DIRECT_SCOPE.to_string()]
);
let channel_open = spec.channel_open.as_ref().expect("channel-open marker");
assert_eq!(channel_open.alpn, TUNNEL_ALPN);
assert!(spec.description.is_some());
}
#[test]
fn direct_target_schema_gates_shape_per_substrate() {
// The oneOf/if-then structure: per-substrate target shapes are
// enforced by the schema itself (registry-side, before the
// establisher). Structural assertions per tunnels/params'
// precedent (no jsonschema dep here).
let schema = tunnel_direct_spec().input_schema;
let allof = schema["allOf"].as_array().expect("allOf branches");
assert_eq!(allof.len(), 2, "tcp/udp branch + unix branch");
let tcpudp_then = &allof[0]["then"]["properties"]["target"];
let unix_then = &allof[1]["then"]["properties"]["target"];
assert_eq!(
allof[0]["if"]["properties"]["substrate"]["enum"],
json!(["tcp", "udp"])
);
assert_eq!(
allof[1]["if"]["properties"]["substrate"]["const"],
json!("unix")
);
assert_eq!(
tcpudp_then["required"],
json!(["host", "port"]),
"tcp/udp target requires host+port"
);
assert_eq!(tcpudp_then["properties"]["port"]["maximum"], json!(65535));
assert_eq!(unix_then["required"], json!(["path"]));
assert_eq!(
tcpudp_then["additionalProperties"],
json!(false),
"cross-shape fields fail the schema"
);
assert_eq!(unix_then["additionalProperties"], json!(false));
assert_eq!(allof[0]["then"]["properties"]["target"]["type"], "object");
assert_eq!(allof[1]["then"]["properties"]["target"]["type"], "object");
}
#[test]
fn render_target_matches_registry_backing_form() {
let tcp = TunnelDirectParams {
substrate: Substrate::Tcp,
target: SubstrateAddr::HostPort {
host: "db.internal".to_string(),
port: 5432,
},
};
let allowed = ["tcp", "udp", "unix"];
matches!(
substrate_str(params.substrate),
"tcp" | "udp" | "unix" if allowed.contains(&substrate_str(params.substrate))
)
assert_eq!(tcp.render_target(), "db.internal:5432");
let udp = TunnelDirectParams {
substrate: Substrate::Udp,
target: SubstrateAddr::HostPort {
host: "10.0.0.7".to_string(),
port: 53,
},
};
assert_eq!(udp.render_target(), "10.0.0.7:53");
let unix = TunnelDirectParams {
substrate: Substrate::Unix,
target: SubstrateAddr::Path {
path: "/run/postgresql/.s.PGSQL.5432".to_string(),
},
};
assert_eq!(unix.render_target(), "/run/postgresql/.s.PGSQL.5432");
}
// --- forwarded op + the listen-reply projection (ADR-008, amended) --
#[test]
fn forwarded_params_round_trip_per_substrate() {
let cases: Vec<(Substrate, serde_json::Value, SubstrateAddr)> = vec![
(
Substrate::Tcp,
json!({"host": "198.51.100.4", "port": 55231}),
SubstrateAddr::HostPort {
host: "198.51.100.4".to_string(),
port: 55231,
},
),
(
Substrate::Udp,
json!({"host": "10.0.0.7", "port": 53}),
SubstrateAddr::HostPort {
host: "10.0.0.7".to_string(),
port: 53,
},
),
(
Substrate::Unix,
json!({"path": "/run/postgresql/.s.PGSQL.5432"}),
SubstrateAddr::Path {
path: "/run/postgresql/.s.PGSQL.5432".to_string(),
},
),
];
for (substrate, peer, addr) in cases {
let params = TunnelForwardedParams {
listen: 918273,
substrate,
peer: addr,
};
let v = serde_json::to_value(&params).unwrap();
assert_eq!(
v,
json!({
"listen": 918273,
"substrate": substrate_str(substrate),
"peer": peer,
})
);
let back: TunnelForwardedParams = serde_json::from_value(v).unwrap();
assert_eq!(back, params);
}
}
#[test]
fn forwarded_params_unknown_fields_rejected() {
let v = json!({
"listen": 918273,
"substrate": "tcp",
"peer": {"host": "h", "port": 1},
"channel": 7
});
assert!(serde_json::from_value::<TunnelForwardedParams>(v).is_err());
}
#[test]
fn forwarded_params_missing_fields_rejected() {
let no_listen = json!({"substrate": "tcp", "peer": {"host": "h", "port": 1}});
let no_substrate = json!({"listen": 1, "peer": {"host": "h", "port": 1}});
let no_peer = json!({"listen": 1, "substrate": "tcp"});
assert!(serde_json::from_value::<TunnelForwardedParams>(no_listen).is_err());
assert!(serde_json::from_value::<TunnelForwardedParams>(no_substrate).is_err());
assert!(serde_json::from_value::<TunnelForwardedParams>(no_peer).is_err());
}
#[test]
fn forwarded_params_listen_zero_rejected() {
let v = json!({
"listen": 0,
"substrate": "tcp",
"peer": {"host": "h", "port": 1}
});
assert!(
serde_json::from_value::<TunnelForwardedParams>(v).is_err(),
"the correlation token is positive by contract (schema minimum 1; \
the serde pin is the backstop)"
);
}
#[test]
fn listen_reply_full_projection() {
let reply = json!({
"channel_id": 7,
"bound": {"host": "203.0.113.9", "port": 42113},
"listen": 918273
});
let projected = parse_listen_reply(&reply).unwrap();
assert_eq!(
projected,
TunnelListenReply {
bound: Some(SubstrateAddr::HostPort {
host: "203.0.113.9".to_string(),
port: 42113,
}),
listen: Some(918273),
}
);
}
#[test]
fn listen_reply_bare_projection() {
// The accept-first/dial shape: the establisher contributed
// nothing — both fields None.
let reply = json!({"channel_id": 7});
let projected = parse_listen_reply(&reply).unwrap();
assert_eq!(
projected,
TunnelListenReply {
bound: None,
listen: None,
}
);
}
#[test]
fn listen_reply_tolerates_unknown_extra_fields() {
// The additive posture — the reply viewer must NOT deny
// unknown fields (future additive reply fields keep old
// consumers working).
let reply = json!({
"channel_id": 7,
"bound": {"path": "/run/x.sock"},
"listen": 5,
"future_field": {"nested": true}
});
let projected = parse_listen_reply(&reply).unwrap();
assert_eq!(
projected,
TunnelListenReply {
bound: Some(SubstrateAddr::Path {
path: "/run/x.sock".to_string()
}),
listen: Some(5),
}
);
}
#[test]
fn listen_reply_rejects_malformed_shapes() {
// No channel_id at all — not an open reply.
assert!(parse_listen_reply(&json!({"bound": {"path": "/p"}})).is_err());
// channel_id = 0 (the wrapper never allocates 0 — channel 0 is
// the call plane).
assert!(parse_listen_reply(&json!({"channel_id": 0})).is_err());
// Malformed bound (not substrate-shaped).
let bad_bound = json!({"channel_id": 7, "bound": {"port": 1}});
assert!(parse_listen_reply(&bad_bound).is_err());
// Malformed listen (negative-shaped / not a u32 / zero).
let bad_listen = json!({"channel_id": 7, "listen": 0});
assert!(parse_listen_reply(&bad_listen).is_err());
let bad_listen_type = json!({"channel_id": 7, "listen": "token"});
assert!(parse_listen_reply(&bad_listen_type).is_err());
}
#[test]
fn forwarded_spec_matches_wire_md() {
let spec = tunnel_forwarded_spec();
assert_eq!(spec.name, "channels/tunnel/forwarded");
assert_eq!(spec.op_type, OperationType::Sub);
assert_eq!(spec.visibility, Visibility::External);
assert_eq!(
spec.input_schema["properties"]["listen"]["minimum"],
json!(1)
);
assert_eq!(
spec.input_schema["properties"]["substrate"]["enum"],
json!(["tcp", "udp", "unix"])
);
assert_eq!(
spec.input_schema["required"],
json!(["listen", "substrate", "peer"])
);
assert_eq!(spec.output_schema["properties"]["channel_id"]["minimum"], 1);
assert_eq!(
spec.access_control.required_scopes,
vec![TUNNEL_FORWARDED_SCOPE.to_string()]
);
let channel_open = spec.channel_open.as_ref().expect("channel-open marker");
assert_eq!(channel_open.alpn, TUNNEL_ALPN);
assert!(spec.description.is_some());
}
#[test]
fn forwarded_spec_peer_schema_gates_shape_per_substrate() {
// The per-substrate peer sub-schema: the same if/then
// structure the direct op pins its target with — structural
// assertions per tunnels/params' precedent (no jsonschema dep
// here).
let schema = tunnel_forwarded_spec().input_schema;
let allof = schema["allOf"].as_array().expect("allOf branches");
assert_eq!(allof.len(), 2, "tcp/udp branch + unix branch");
let tcpudp_then = &allof[0]["then"]["properties"]["peer"];
let unix_then = &allof[1]["then"]["properties"]["peer"];
assert_eq!(
allof[0]["if"]["properties"]["substrate"]["enum"],
json!(["tcp", "udp"])
);
assert_eq!(
allof[1]["if"]["properties"]["substrate"]["const"],
json!("unix")
);
assert_eq!(
tcpudp_then["required"],
json!(["host", "port"]),
"tcp/udp peer requires host+port"
);
assert_eq!(tcpudp_then["properties"]["port"]["maximum"], json!(65535));
assert_eq!(unix_then["required"], json!(["path"]));
assert_eq!(
tcpudp_then["additionalProperties"],
json!(false),
"cross-shape peer fields fail the schema"
);
assert_eq!(unix_then["additionalProperties"], json!(false));
assert_eq!(allof[0]["then"]["properties"]["peer"]["type"], "object");
assert_eq!(allof[1]["then"]["properties"]["peer"]["type"], "object");
}
#[test]
fn forwarded_params_parse_backstops_the_schema() {
// The serde parse is the backstop for the shape it can use:
// a peer that schema-gates passed but parse-time mismatches
// (or a `listen: 0` riding a registry without the schema gate)
// fails loud at the deserializer. `deny_unknown_fields` holds:
// unknown top-level params reject; the untagged peer is
// lenient about extra peer fields (same documented asymmetry
// as the direct op's target — the SCHEMA is the shape-vs-
// substrate gate).
let cross_shape = json!({
"listen": 1,
"substrate": "unix",
"peer": {"path": "/p", "host": "h"}
});
let parsed: TunnelForwardedParams = serde_json::from_value(cross_shape).unwrap();
assert_eq!(
parsed.peer,
SubstrateAddr::Path {
path: "/p".to_string()
}
);
}
// --- listen_reply_fields (the lenient session-surface projection) ----
#[test]
fn listen_reply_fields_full_projection() {
let reply = json!({
"channel_id": 7,
"bound": {"host": "203.0.113.9", "port": 42113},
"listen": 918273
});
assert_eq!(
listen_reply_fields(&reply),
TunnelListenReply {
bound: Some(SubstrateAddr::HostPort {
host: "203.0.113.9".to_string(),
port: 42113,
}),
listen: Some(918273),
}
);
}
#[test]
fn listen_reply_fields_bare_reply_is_both_none() {
// The dial/accept-first posture: the establisher supplied
// nothing.
let reply = json!({"channel_id": 7});
assert_eq!(
listen_reply_fields(&reply),
TunnelListenReply {
bound: None,
listen: None
}
);
}
#[test]
fn listen_reply_fields_tolerates_unknown_extra_fields() {
// The additive posture — the lenient projection must NOT gate
// unknown reply fields.
let reply = json!({
"channel_id": 7,
"bound": {"path": "/run/x.sock"},
"listen": 5,
"future_field": true
});
let projected = listen_reply_fields(&reply);
assert_eq!(projected.listen, Some(5));
assert_eq!(
projected.bound,
Some(SubstrateAddr::Path {
path: "/run/x.sock".to_string()
})
);
}
#[test]
fn listen_reply_fields_projects_malformed_to_none() {
// The lenient projection never refuses construction: a
// malformed shape (no `channel_id`, a malformed `bound`) logs
// loud and surfaces both-`None` (use `parse_listen_reply`
// directly for the loud typed view).
let not_an_open_reply = json!({"bound": {"path": "/p"}});
assert_eq!(
listen_reply_fields(&not_an_open_reply),
TunnelListenReply {
bound: None,
listen: None
}
);
let malformed_bound = json!({"channel_id": 7, "bound": {"port": 1}});
assert_eq!(
listen_reply_fields(&malformed_bound),
TunnelListenReply {
bound: None,
listen: None
}
);
}
}
+944 -5
View File
@@ -21,8 +21,17 @@
//!
//! The listen establisher variant (the producer-side listener — the
//! `-R` far-side listener shape) is [`listen_establisher`] +
//! [`AcceptQueue`] below (the `tunnels/producer-listen` shape).
//! [`AcceptQueue`] below (the `tunnels/producer-listen` shape). The
//! direct op's establisher ([`direct_establisher`] — dynamic-target
//! egress, ADR-007) is the dial shape minus the registry lookup.
//!
//! The bind-first shape's plan payload ([`BindFirstPlan`] — the opaque
//! listener + the correlation token, ADR-008 as amended) is defined
//! here; the establisher and accept dispatcher that consume it land
//! with `tunnels/bindfirst-producer` (the wire-stable types pin
//! first, same ordering `tunnels/params` used for the base op).
use std::any::Any;
use std::sync::Arc;
use alkcall::channels::operations::{
@@ -30,14 +39,19 @@ use alkcall::channels::operations::{
};
use alkcall::channels::pump::pump_bidi;
use alkcall::core::auth::AuthContext;
use alkcall::core::types::Connection;
use alkcall::core::types::{BiStream, Connection};
use alkcall::registry::registration::OperationRegistry;
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
use crate::params::{Substrate, TunnelParams};
use crate::params::{
Substrate, SubstrateAddr, TunnelDirectParams, TunnelForwardedParams, TunnelParams,
};
pub use crate::params::{OP_TUNNEL_OPEN, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
pub use crate::params::{
OP_TUNNEL_DIRECT, OP_TUNNEL_OPEN, TUNNEL_ALPN, TUNNEL_DIRECT_SCOPE, TUNNEL_FORWARDED_SCOPE,
TUNNEL_OPEN_SCOPE,
};
/// The dialed target handle — the plan payload (typed-opaque
/// `ChannelPlan`; establisher and handler agree on the concrete
@@ -57,6 +71,59 @@ impl std::fmt::Debug for TargetHandle {
}
}
/// The bind-first establisher's plan payload (ADR-008 §1 as amended):
/// the assembly's listener handle + the correlation token + the listen
/// tunnel's substrate.
///
/// **The listener is NOT `TargetHandle` halves** — a listener is
/// accepted FROM, not pumped. The plan carries whatever the assembly's
/// `BindFn` produced, typed-opaque ([`Any`]; the same
/// injection pattern as `DialFn`/`AcceptFn` — the accept dispatcher
/// downcasts it to the assembly's listener type via a type-erased
/// accept closure; the ADR's "listener handle in the plan" is
/// assembly-typed, and the protocol sees only this opaque pair).
///
/// **The correlation token (ADR-008 Amendment 1):** minted by the
/// bind-first establisher — a fresh u32 per open, unique per producer
/// (an `AtomicU32` counter seeded non-zero, e.g. 1, inside the
/// establisher's closure state; wraps are acceptable — u32, and a wrap
/// collision requires 2^32 concurrent opens on one producer). Carried
/// twice: in the plan (the accept dispatcher stamps it into every
/// forwarded open it opens for this listener) and as the additive
/// `listen` reply field (the opener correlates its waiting session on
/// it). It is NOT the listen channel's ID — the serving-side handler
/// never sees its own channel ID, and generic hub relays forward
/// params untouched (Amendment 1's rationale). Per-open fresh,
/// per-producer unique; retired with the listener session.
pub struct BindFirstPlan {
/// The assembly-typed listener handle (e.g. a `TcpListener`
/// wrapper the `BindFn` produced). Opaque to the protocol — the
/// accept dispatcher downcasts via a type-erased accept closure.
pub listener: Arc<dyn Any + Send + Sync>,
/// The correlation token (see above): minted fresh per open,
/// unique per producer, stamped into each forwarded open's
/// `listen` param.
pub token: u32,
/// The listen tunnel's substrate (ADR-008 §3), stamped here at
/// establisher time (the establisher already parsed the params
/// successfully — one parse, one source of truth). The accept
/// dispatcher reads it from the plan it already downcast; it must
/// NOT re-parse wire input (a silent `Tcp` fallback on a parse
/// failure would mismatch a UDP listen tunnel's data-plane
/// framing — review 002 M-1).
pub substrate: Substrate,
}
impl std::fmt::Debug for BindFirstPlan {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BindFirstPlan")
.field("listener", &"<opaque>")
.field("token", &self.token)
.field("substrate", &self.substrate)
.finish()
}
}
/// The typed establishment-failure vocabulary (ADR-049 §3's reason
/// codes as this crate's establisher errors; mapped 1:1 onto
/// `EstablishmentError`).
@@ -189,6 +256,57 @@ fn parse_params(input: &Value) -> Result<TunnelParams, TunnelEstablishError> {
.map_err(|e| TunnelEstablishError::HandlerError(format!("invalid params: {e}")))
}
/// The direct establisher (ADR-007 §4 — the dial shape minus the
/// registry lookup): parse `TunnelDirectParams`, render the target to
/// the same backing-string form the registry backings use
/// (`"host:port"` for tcp/udp, the path for unix), and call the
/// injected [`DialFn`] — the SAME dial function the base op uses, so
/// a producer that registered `dial_tcp` for the base op registers
/// the direct op with zero new substrate code (the UDP framed-adapter
/// wrap, ADR-003, happens inside the dial closure — the establisher
/// stays substrate-agnostic). No registry lookup — `unknown_resource`
/// can never fire.
///
/// The per-call `auth` carries the opener identity (CF-006); record it
/// into `identity_witness` when provided (the direct op is the
/// arbitrary-egress capability — identity visibility matters more,
/// not less).
///
/// Error mapping (ADR-007 §4's table minus the registry row): the
/// serde-parse failure of a schema-valid-shaped target maps
/// [`TunnelEstablishError::HandlerError`] (the schema gate should
/// have caught it — the parse is the backstop); dial failures pass
/// through the `DialFn`'s own typed errors unchanged (`dial_failed` /
/// `resource_shortage` / `handler_error`).
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher {
direct_establisher_with_witness(dial, None)
}
/// [`direct_establisher`] with an optional identity witness (the test
/// seam for the CF-006 contract, same as the base op's).
pub fn direct_establisher_with_witness(
dial: DialFn,
identity_witness: Option<IdentityWitness>,
) -> OpenEstablisher {
Arc::new(move |input: Value, auth| {
let dial = Arc::clone(&dial);
let witness = identity_witness.clone();
Box::pin(async move {
if let Some(w) = &witness {
*w.lock().await = auth.identity.as_ref().map(|i| i.id.clone());
}
let params: TunnelDirectParams =
serde_json::from_value(input.clone()).map_err(|e| {
TunnelEstablishError::HandlerError(format!("invalid direct params: {e}"))
})?;
let rendered = params.render_target();
let substrate = params.substrate;
let dialed: TargetHandle = dial(substrate, &rendered).await?;
Ok(Establishment::new(Arc::new(dialed) as ChannelPlan))
})
})
}
/// The listen establisher (shape 2 — producer.md §The Establisher):
/// the same open op, same params, same typed errors; the establisher
/// pops the next accepted connection from the injected [`AcceptFn`]
@@ -381,6 +499,33 @@ pub fn register_tunnel_openable(
)
}
/// Register the direct op (`channels/tunnel/direct`, ADR-007) on a
/// `ChannelCore` + the session's dispatch registry: the direct spec +
/// [`direct_establisher_with_witness`] + the SAME pump handler as the
/// base op (the direct op's plan payload is a [`TargetHandle`] exactly
/// like the base op's; plan-flow is unchanged), via
/// `register_openable_with_establisher` (`timeout: None` = the 10s
/// default bound). `dial` is the SAME [`DialFn`] the base op uses —
/// no new substrate code. `identity_witness` is the optional CF-006
/// probe seam (the establisher records the per-call opener identity
/// it saw).
pub fn register_tunnel_direct_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
dial: DialFn,
identity_witness: Option<IdentityWitness>,
) -> Result<(), String> {
core.register_openable_with_establisher(
crate::params::tunnel_direct_spec(),
Some(direct_establisher_with_witness(dial, identity_witness)),
make_tunnel_pump_handler(),
on_registry,
auth,
None,
)
}
/// Register the tunnel open op with a LISTEN establisher (shape 2):
/// the same spec + pump handler as [`register_tunnel_openable`]; the
/// establisher pops accepted handles from the injected [`AcceptFn`]
@@ -405,3 +550,797 @@ pub fn register_tunnel_listen_openable(
None,
)
}
// --- Bind-first (shape 3, ADR-008) ---------------------------------------
/// The substrate bind function the assembly layer injects (shape 3 —
/// ADR-008 §1): given a substrate + a registry backing (the same
/// backing-string form the dial shape's `DialFn` receives), **bind the
/// listener** and produce it. The protocol never binds (OQ-TN-04) —
/// this closure is the platform boundary, the same inversion point as
/// `DialFn`. The returned [`BoundListener`] pairs the opaque
/// assembly-typed listener (the accept loop's business) with the
/// OS-chosen bound address (the `bound` reply field's source).
pub type BindFn = Arc<
dyn Fn(
Substrate,
&str,
)
-> futures::future::BoxFuture<'static, Result<BoundListener, TunnelEstablishError>>
+ Send
+ Sync,
>;
/// What the assembly's bind produces (ADR-008 §1): the opaque listener
/// (the accept loop's business) + the OS-chosen bound address (the
/// `bound` reply field's source — the REAL resolved address, port `0`
/// resolved; informational surface, never addressing).
pub struct BoundListener {
/// The assembly-typed listener (e.g. the `TcpListener` wrapper the
/// assembly chose). Opaque to the protocol — rides the plan's
/// `Any`, downcast only inside the assembly's accept closure.
pub listener: Arc<dyn Any + Send + Sync>,
/// The real OS-chosen bound address (the `bound` reply field's
/// source).
pub bound: SubstrateAddr,
}
/// The token mint (the discipline pinned on [`BindFirstPlan`]): a
/// non-zero-seeded `AtomicU32` counter held in the establisher's
/// closure state — fresh per open, unique per producer, wraps
/// acceptable (a wrap collision requires 2^32 concurrent opens on one
/// producer — and that bound is loose: the token is
/// per-producer-CONNECTION and each open rides one connection, so the
/// alkcall 256-channels-per-connection cap makes a live-collision
/// window unreachable by a wide margin, review 002 N-5). The counter
/// seeds at 1: `fetch_add` returns the
/// PREVIOUS value, so the first mint is 1 and the minted stream is
/// 1, 2, 3, … — token 0 (an illegal wire value; the forwarded params'
/// `listen` pin rejects it) is never minted, and a u32 wrap that lands
/// on 0 re-increments (the loop skips it; the 2^32 collision window
/// makes wrap uniqueness a non-issue at the discipline's own terms).
fn mint_token(counter: &std::sync::atomic::AtomicU32) -> u32 {
use std::sync::atomic::Ordering;
loop {
let prev = counter.fetch_add(1, Ordering::Relaxed);
if prev != 0 {
return prev;
}
}
}
/// The bind-first establisher (shape 3 — producer.md §The Establisher,
/// ADR-008 §1): the same open op, same params, same typed-error table;
/// the establisher **binds** (via the injected [`BindFn`]) instead of
/// dialing or popping an accept, mints the correlation token
/// (per-open-fresh, per-producer-unique — the discipline pinned on
/// [`BindFirstPlan`]), contributes
/// the `bound` + `listen` reply fields (the establisher → reply
/// projection, ADR-049 amendment 3 — the wrapper merges them into the
/// success output after its reserved `channel_id` key), and returns
/// the [`BindFirstPlan`] as the plan payload.
///
/// The registry check is a namespace gate only (the same posture as
/// [`listen_establisher`]): the bind-first producer's "backing" is the
/// [`BindFn`], not a dial target; a resource the producer does not
/// produce is still `unknown_resource`. The resolved backing string
/// rides the same signature the dial shape's `DialFn` takes — the bind
/// closure sees the same `(substrate, backing)` form.
///
/// Error mapping (producer.md's shape-3 table): unbindable/held port →
/// `dial_failed`-class (the bind IS the dial here); bind resource
/// exhaustion → `resource_shortage`; registry miss →
/// `unknown_resource`; internal → `handler_error`. The `BindFn`
/// returns the typed errors; the establisher passes them through.
///
/// The token lives in BOTH the reply (`listen` — the opener's
/// correlation key) and the plan (the accept dispatcher stamps it into
/// every forwarded open). Two opens mint distinct tokens (the mint is
/// per-open); each dispatcher stamps ITS OWN plan's token (the plan
/// flows per-open — R-01).
pub fn bindfirst_establisher(registry: ResourceRegistry, bind: BindFn) -> OpenEstablisher {
use std::sync::atomic::AtomicU32;
let counter = Arc::new(AtomicU32::new(1));
Arc::new(move |input: Value, _auth| {
let registry = registry.clone();
let bind = Arc::clone(&bind);
let counter = Arc::clone(&counter);
Box::pin(async move {
let params: TunnelParams = parse_params(&input)?;
let backing = registry
.lookup(params.substrate, &params.resource)
.await
.ok_or_else(|| {
TunnelEstablishError::UnknownResource(format!(
"resource `{}` ({:?}) not produced by this producer",
params.resource, params.substrate
))
})?;
let token = mint_token(&counter);
let BoundListener { listener, bound } = bind(params.substrate, &backing).await?;
let plan = Arc::new(BindFirstPlan {
listener,
token,
substrate: params.substrate,
}) as ChannelPlan;
let bound = serde_json::to_value(&bound)
.map_err(|e| TunnelEstablishError::HandlerError(format!("bound serialize: {e}")))?;
Ok(Establishment::new(plan)
.with_reply_field("bound", bound)
.with_reply_field("listen", serde_json::json!(token)))
})
})
}
/// The accepted-connection source the assembly layer injects for the
/// BIND-FIRST producer (shape 3 — ADR-008 §1): pops one accepted
/// connection from the plan's opaque listener and yields
/// `(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`.
/// The closure receives the listener as the opaque `Any` — the
/// assembly downcasts it to ITS concrete listener type inside the
/// closure (the same type-erased injection pattern as
/// `DialFn`/`AcceptFn`; substrate code lives behind the closure,
/// ADR-004). Substrate code lives behind the closure (ADR-004); the
/// protocol crate never sees a socket type.
///
/// A returned `Err` is a LOOP-CRITICAL condition: the accept loop's
/// substrate source is broken (the listener closed, accept errored
/// unrecoverably) — the dispatcher ends the loop, tears the listener
/// down with the plan, and lets the handler task finish (the channel
/// reaps). Per-ACCEPT failures (forwarded-open failures) do NOT come
/// from this closure — see `make_bindfirst_accept_handler`'s error
/// posture.
pub type BindFirstAcceptLoop = Arc<
dyn Fn(
Arc<dyn Any + Send + Sync>,
) -> futures::future::BoxFuture<
'static,
Result<(SubstrateAddr, TargetHandle), TunnelEstablishError>,
> + Send
+ Sync,
>;
/// The per-accept forwarded-open caller (ADR-008 §3): opens
/// `OP_TUNNEL_FORWARDED` with `TunnelForwardedParams {listen,
/// substrate, peer}` on the channel-0 call plane toward the listen
/// opener, adopts the opener-allocated ID on the producer-side
/// manager, and returns the adopted channel's halves (the pump side).
///
/// **This closure is the call plane** (the design pin — producer.md
/// §The Pump Handler item 5): the per-channel `Connection` the
/// dispatcher receives is stream-less (`open_bi` is `StreamClosed` —
/// ADR-035) and carries no call surface, so the registration closes
/// over the serving session's `Arc<CallConnection>` (the assembly has
/// it — it constructed the serving session). The shape is the reverse
/// POC's `open_reverse_channel`: `call_with_payload` on channel 0 →
/// extract `channel_id` from the reply → `manager.adopt_channel`. The
/// accepted halves pump against the adopted channel's halves.
pub type ForwardedOpener = Arc<
dyn Fn(
TunnelForwardedParams,
) -> futures::future::BoxFuture<
'static,
Result<
(
alkcall::channels::reassembly::MpscSendStream,
alkcall::channels::reassembly::MpscRecvStream,
),
TunnelEstablishError,
>,
> + Send
+ Sync,
>;
/// The bind-first pump handler — the ACCEPT DISPATCHER (ADR-008 §1).
///
/// Handler contract (producer.md §The Pump Handler item 5 + R-02):
///
/// 1. `conn.accept_bi()` FIRST (the yield-once contract — the
/// wrapper's accepted-flag telemetry requires it), downcast the
/// plan to [`BindFirstPlan`].
/// 2. The listen channel's own `BiStream` stays idle (no pumps on
/// it); **its EOF cancels the loop and tears the listener down** —
/// a 1-byte `read` on the idle stream observes the opener dropping
/// the listener session (the zero-byte EOF sentinel; ADR-050's
/// teardown contract). `select!` races the EOF watch against the
/// accept loop.
/// 3. Per accept: open the forwarded channel ([`ForwardedOpener`] —
/// the call plane, `{listen: token, substrate, peer}` params),
/// then `pump_bidi` the accepted halves against the adopted
/// channel inside a spawned task tracked in the handler's handle
/// set. A forwarded-open failure is per-accept isolated: log and
/// drop that accepted connection — the loop continues (the
/// listener's overall health is not hostage to one failed accept;
/// v1: no retry/backoff). A loop-critical accept error ([`BindFirstAcceptLoop`]'s
/// contract) ends the loop.
/// 4. **Lifetime (R-02):** the handler's returned `JoinHandle` tracks
/// the whole accept loop + every per-accept pump it spawned — the
/// handler task awaits the pump handles on loop exit (a forwarded
/// pump outliving its listener is a leak). Handler exit → wrapper
/// teardown (the listen channel reaps) → the far listener tears
/// down (the plan's listener drops with the loop).
///
/// The substrate discriminator for the forwarded opens rides the
/// plan ([`BindFirstPlan`].substrate — stamped at establisher time
/// from the one successful `TunnelParams` parse; the listen tunnel's
/// substrate; ADR-008 §3 "the listen tunnel's substrate shapes the
/// forwarded channel's data-plane framing"). The dispatcher never
/// re-parses wire input — no fallback branch exists.
pub fn make_bindfirst_accept_handler(
accept_loop: BindFirstAcceptLoop,
open_forwarded: ForwardedOpener,
) -> OpenHandler {
Arc::new(
move |_input: Value, plan: Option<ChannelPlan>, conn: Connection, _auth: AuthContext| {
let accept_loop = Arc::clone(&accept_loop);
let open_forwarded = Arc::clone(&open_forwarded);
tokio::spawn(async move {
let Some(bidi) = conn.accept_bi().await.ok() else {
tracing::error!("tunnel bind-first: accept_bi failed");
return;
};
let Some(plan) = plan.and_then(|p| p.downcast::<BindFirstPlan>().ok()) else {
tracing::error!("tunnel bind-first: no BindFirstPlan in plan");
return;
};
// The substrate rides the plan (stamped at establisher
// time — one parse, one source of truth): the
// dispatcher must not re-parse wire input (a parse
// failure here would have nowhere loud to go; review
// 002 M-1 removed the silent `Tcp` fallback instead).
let substrate = plan.substrate;
let (mut idle_read, _idle_write) = tokio::io::split(bidi);
// The per-accept pump task set — tracked by the
// handler's JoinHandle (R-02: every pump the handler
// spawned is awaited on loop exit).
let mut pumps: Vec<tokio::task::JoinHandle<(u64, u64)>> = Vec::new();
let loop_result = loop {
// The idle-stream EOF watch vs the accept loop
// (select!): the opener dropping the listener
// session EOFs the listen channel's stream — the
// zero-byte sentinel — and cancels the loop.
let eof_watch = async {
// The 1-byte read on the idle stream: `Ok(0)`
// (or an error) = the opener dropped the
// session; `Ok(1..)` never happens on a well-
// behaved opener (the stream is idle — a peer
// that writes is misbehaving; treat any
// non-empty read as an EOF trigger too — the
// channel is not a data plane).
let mut byte = [0u8; 1];
match idle_read.read(&mut byte).await {
Ok(0) | Err(_) => true,
Ok(_) => true,
}
};
let accept = accept_loop(Arc::clone(&plan.listener));
tokio::select! {
_ = eof_watch => break "eof",
accepted = accept => match accepted {
Ok((peer, handle)) => {
let forwarded = TunnelForwardedParams {
listen: plan.token,
substrate,
peer: peer.clone(),
};
match open_forwarded(forwarded).await {
Ok((send, recv)) => {
let channel_bidi =
alkcall::core::types::BiStream::from_joined(recv, send);
pumps.push(tokio::spawn(pump_bidi(
channel_bidi,
handle.read,
handle.write,
)));
}
Err(e) => {
// Per-accept isolation: the
// forwarded open failed (scope-
// less opener → FORBIDDEN; old
// opener → NOT_FOUND; correlation
// miss → handler_error-class) —
// drop this accepted connection,
// keep serving. v1: no retry/
// backoff (documented posture).
tracing::warn!(
listen = plan.token,
error = %e,
"tunnel bind-first: forwarded open failed; dropping the accepted connection"
);
drop(handle);
}
}
}
Err(e) => {
// Loop-critical (the listener's
// substrate source broke): end the
// loop — the listener tears down with
// the plan.
tracing::warn!(
listen = plan.token,
error = %e,
"tunnel bind-first: accept loop ended; tearing the listener down"
);
break "accept-error";
}
}
}
};
tracing::debug!(
listen = plan.token,
reason = loop_result,
"tunnel bind-first: accept loop exited"
);
// The listener drops with the plan (the last Arc) — the
// far listener tears down.
drop(plan);
// R-02: the handler's JoinHandle tracks every per-accept
// pump it spawned — await them on loop exit (a forwarded
// pump outliving its listener is a leak).
for pump in pumps.drain(..) {
let _ = pump.await;
}
})
},
)
}
/// The bind-first registration's injected seams (the assembly layer
/// owns all four; bundling keeps the registration's signature to the
/// core pair + the bundle — the seams are one coherent injection
/// point, the same posture as `DialFn` riding the dial registration):
/// - `bind` — the substrate bind function ([`BindFn`]; the protocol
/// never binds, OQ-TN-04).
/// - `accept_loop` — the per-listener accept loop
/// ([`BindFirstAcceptLoop`]; receives the opaque listener, yields
/// `(peer, halves)` per accept).
/// - `open_forwarded` — the forwarded-open caller ([`ForwardedOpener`];
/// the call plane: the serving session's `Arc<CallConnection>` +
/// the producer-side `ChannelManager` for the adopt — the assembly
/// has both, it constructed the serving session; the reverse POC's
/// `open_reverse_channel` shape).
pub struct BindFirstInjections {
/// The substrate bind function (the platform boundary).
pub bind: BindFn,
/// The per-listener accept loop (pops the opaque listener).
pub accept_loop: BindFirstAcceptLoop,
/// The forwarded-open caller (the call plane + the adopt).
pub open_forwarded: ForwardedOpener,
}
/// Register the bind-first openable (shape 3, ADR-008 §1): the SAME
/// spec + scope gate (`tunnel:open`) as the dial/listen variants — the
/// bind-first shape is a per-registration establisher choice over the
/// same op (ADR-008 §5's per-registration posture) — with
/// [`bindfirst_establisher`] as the establisher and
/// [`make_bindfirst_accept_handler`] as the pump. One establisher per
/// op id per registry (same note as
/// `register_tunnel_listen_openable`).
///
/// `identity_witness` is the optional CF-006 probe seam (the
/// establisher records the per-call opener identity it saw).
pub fn register_tunnel_bindfirst_openable(
core: &ChannelCore,
registry: &ResourceRegistry,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
injections: BindFirstInjections,
identity_witness: Option<IdentityWitness>,
) -> Result<(), String> {
let BindFirstInjections {
bind,
accept_loop,
open_forwarded,
} = injections;
let establisher = bindfirst_establisher(registry.clone(), bind);
let establisher = match identity_witness {
Some(witness) => witness_establisher(establisher, witness),
None => establisher,
};
core.register_openable_with_establisher(
crate::params::tunnel_open_spec(),
Some(establisher),
make_bindfirst_accept_handler(accept_loop, open_forwarded),
on_registry,
auth,
None,
)
}
/// [`tunnel_establisher_with_witness`]-shaped CF-006 probe for the
/// bind-first establisher: records the per-call opener identity into
/// the witness, then delegates to the inner establisher (the same
/// seam the dial/direct variants inline).
fn witness_establisher(
inner: OpenEstablisher,
identity_witness: IdentityWitness,
) -> OpenEstablisher {
Arc::new(move |input: Value, auth| {
let inner = Arc::clone(&inner);
let witness = Arc::clone(&identity_witness);
Box::pin(async move {
*witness.lock().await = auth.identity.as_ref().map(|i| i.id.clone());
inner(input, auth).await
})
})
}
// --- The forwarded op's SERVING side (the opener half, ADR-008 §3) --------
/// The correlation material the opener-side handler yields per
/// forwarded open: the parsed params + the channel's `BiStream`. The
/// ASSEMBLY correlates (token → its waiting listener session
/// bookkeeping) and owns the pump — it awaits the returned
/// `JoinHandle` inline (R-02: the forwarded channel's lifetime tracks
/// it; early return = teardown-at-birth reaps the channel), because
/// the local halves are assembly state (the SOCKS5 BIND client's
/// socket) that only the assembly knows. The protocol provides the op
/// and params, not the wait state machine (producer.md §The Forwarded
/// Op; ADR-008 §3).
pub type ForwardedHandler =
Arc<dyn Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()> + Send + Sync>;
/// The forwarded op's opener-side pump handler (ADR-008 §3, the
/// no-establisher serving shape): parse `TunnelForwardedParams`
/// (`deny_unknown_fields` + the positive-`listen` serde pin — a
/// malformed open fails the parse LOUD; the wrapper then returns early
/// and the channel tears down at birth — no phantom channel, the
/// correct shape for a malformed ingress), accept the channel's
/// `BiStream` (yield-once; accepting BEFORE the assembly handler runs
/// keeps the R-02 accepted-flag telemetry truthful), and hand the
/// parsed params + the stream to the injected [`ForwardedHandler`].
///
/// **Parse-before-accept ordering (the R-02 telemetry note):** the
/// params parse happens BEFORE `conn.accept_bi()`, so for a
/// malformed-params open the R-02 accepted-flag telemetry reads
/// `false` — by design. A malformed-params open IS a birth-teardown
/// shape (the exact case the telemetry's birth-teardown hint exists
/// for); accepting first would hold a stream open for a params set
/// the producer already got wrong. The hint firing here is correct,
/// not noise — future alkcall reviewers: do not flag the flag
/// (review 002 N-3).
///
/// **Pump responsibility split (the honest-shape decision):** the
/// injected handler receives the channel `BiStream` and owns the pump
/// — `pump_bidi(forwarded_bidi, local_read, local_write)` — because
/// the local halves are assembly state. The protocol-side built-in
/// handler only bridges wrapper→assembly; it awaits the injected
/// handler's `JoinHandle` inline (R-02 — the forwarded channel's
/// lifetime tracks it, so a correlation miss or a dropped assembly
/// handler tears the channel down).
///
/// **Correlation-miss semantics (documented + test-pinned):** a
/// `listen` token with no matching waiting session is the ASSEMBLY's
/// rejection (it holds the map): its handler's error path drops the
/// stream and returns — teardown-at-birth reaps the forwarded channel.
/// The producer's accept loop drops the accepted connection when that
/// open fails (the shapes mirror — per-accept isolation,
/// `tunnels/bindfirst-producer`'s posture).
pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler {
Arc::new(
move |input: Value, _plan: Option<ChannelPlan>, conn: Connection, _auth: AuthContext| {
let handler = Arc::clone(&handler);
tokio::spawn(async move {
let forwarded: TunnelForwardedParams = match serde_json::from_value(input) {
Ok(p) => p,
Err(e) => {
tracing::error!(
error = %e,
"tunnel forwarded: malformed params; tearing the channel down"
);
return;
}
};
let Ok(bidi) = conn.accept_bi().await else {
tracing::error!("tunnel forwarded: accept_bi failed");
return;
};
let pump = handler(forwarded, bidi);
let _ = pump.await;
})
},
)
}
/// Register the forwarded op's opener-side serving openable (ADR-008
/// §3): `ChannelCore::register_openable` — **no establisher** (the
/// forwarded
/// channel needs no establishment; its data plane starts immediately)
/// — scope-gated `tunnel:forwarded` (ingress, a separate grant from
/// `tunnel:open`/`tunnel:direct`, never implied by either — ADR-006
/// Amendment 1), via [`crate::params::tunnel_forwarded_spec`]. The
/// wrapper's
/// `channel_id` allocation IS the reply (the producer adopts it).
///
/// Identity (CF-005 precedence, upstream machinery): the opener-side
/// gate sees the PRODUCER's per-call identity (the producer may carry
/// an `auth_token` when it is itself forwarding); the `peer` param is
/// producer-asserted metadata, not transport truth — the ACL trusts
/// the grant, the application treats `peer` as metadata. Nothing to
/// implement here (upstream's per-call overlay handles it); the test
/// suite pins it.
///
/// Post-hoc registration is supported (W2) — the dispatcher reads
/// through the shared `Arc<OperationRegistry>` per dispatch, the same
/// posture as [`register_tunnel_openable`]. One registration per op id
/// per registry; the op id is `channels/tunnel/forwarded` (distinct
/// from the base op — a session may serve both).
pub fn register_tunnel_forwarded_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
handler: ForwardedHandler,
) -> Result<(), String> {
core.register_openable(
crate::params::tunnel_forwarded_spec(),
make_forwarded_op_handler(handler),
on_registry,
auth,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The plan-payload contract: `BindFirstPlan` rides the
/// typed-opaque `ChannelPlan` (`Arc<dyn Any + Send + Sync>`) and
/// the accept dispatcher recovers it by downcast — the same
/// establisher/handler agreement `TargetHandle` pins. The opaque
/// listener half downcasts to the assembly's concrete type.
#[test]
fn bindfirst_plan_downcasts_through_channel_plan() {
struct TestListener(u32);
let plan: ChannelPlan = Arc::new(BindFirstPlan {
listener: Arc::new(TestListener(42)) as Arc<dyn Any + Send + Sync>,
token: 918273,
substrate: Substrate::Udp,
});
let unwrapped = plan
.downcast::<BindFirstPlan>()
.expect("BindFirstPlan plan");
assert_eq!(unwrapped.token, 918273);
assert_eq!(
unwrapped.substrate,
Substrate::Udp,
"the plan's substrate rides it"
);
let listener = unwrapped
.listener
.downcast_ref::<TestListener>()
.expect("assembly-typed listener");
assert_eq!(listener.0, 42);
// A wrong-type downcast fails (the opaque pair is not a
// TargetHandle).
let other: ChannelPlan = Arc::new(TargetHandle {
read: Box::new(tokio::io::empty()),
write: Box::new(tokio::io::sink()),
});
assert!(other.downcast_ref::<BindFirstPlan>().is_none());
}
// --- bind-first establisher (shape 3, ADR-008) -----------------------
/// The mint discipline: the counter seeds at 1, the first mint is
/// 1, the stream is per-open fresh + per-producer unique, and
/// token 0 is never minted (the loop skips it — a wrap that lands
/// on 0 re-increments).
#[test]
fn mint_token_seeds_nonzero_and_increments() {
let counter = std::sync::atomic::AtomicU32::new(1);
let first = mint_token(&counter);
let second = mint_token(&counter);
assert_eq!(first, 1, "a 1-seeded counter's first mint is 1");
assert_eq!(second, 2);
// Two mints never collide (per-open fresh).
assert_ne!(first, second);
}
/// Token 0 is never minted: a counter that has wrapped around to 0
/// re-increments (the loop skips the illegal wire value).
#[test]
fn mint_token_never_yields_zero() {
// Force the wrap shape: seed the counter at u32::MAX — the
// next fetch_add returns u32::MAX (legal), then wraps to 0,
// which the loop must skip (the mint after the wrap is 1).
let counter = std::sync::atomic::AtomicU32::new(u32::MAX);
let before_wrap = mint_token(&counter);
assert_eq!(before_wrap, u32::MAX);
let after_wrap = mint_token(&counter);
assert_eq!(after_wrap, 1, "the wrap lands on 0; the mint skips it");
}
/// The establisher flow (registry gate → mint → bind → reply
/// fields → plan), pinned at the `Establishment` surface: the plan
/// is the `BindFirstPlan`, the reply fields are `bound` (the
/// `BindFn`'s address) + `listen` (the minted token), and the
/// token in the plan matches the token in the reply.
#[tokio::test]
async fn bindfirst_establisher_flow_end_to_end() {
struct TestListener(u32);
let registry = ResourceRegistry::new();
registry
.register("listener", Substrate::Tcp, "assembly-owned")
.await;
let bind: BindFn = Arc::new(|_substrate, _backing| {
Box::pin(async {
Ok(BoundListener {
listener: Arc::new(TestListener(7)) as Arc<dyn Any + Send + Sync>,
bound: SubstrateAddr::HostPort {
host: "127.0.0.1".to_string(),
port: 42113,
},
})
})
});
let establisher = bindfirst_establisher(registry, bind);
let input = serde_json::json!({"resource": "listener", "substrate": "tcp"});
let establishment = establisher(input, AuthContext::anonymous(b"alk/tunnel"))
.await
.expect("establishment");
// The reply fields: `bound` (substrate-shaped, the BindFn's
// address) + `listen` (the token).
let fields = establishment.reply_fields().expect("reply fields");
assert_eq!(
fields.get("bound"),
Some(&serde_json::json!({"host": "127.0.0.1", "port": 42113}))
);
let listen = fields
.get("listen")
.and_then(|v| v.as_u64())
.expect("listen");
assert!(listen > 0, "the minted token is positive");
// The plan: the BindFirstPlan carries the SAME token + the
// parsed substrate.
let plan = establishment.plan.expect("plan");
let plan = plan.downcast::<BindFirstPlan>().expect("BindFirstPlan");
assert_eq!(plan.token as u64, listen, "plan token == reply token");
assert_eq!(
plan.substrate,
Substrate::Tcp,
"the plan stamps the parsed substrate"
);
let listener = plan
.listener
.downcast_ref::<TestListener>()
.expect("listener");
assert_eq!(listener.0, 7);
}
/// The token discipline across TWO opens on the SAME producer: the
/// establisher mints distinct tokens per open (the per-open-fresh
/// half; the per-producer-unique half is the same counter).
#[tokio::test]
async fn bindfirst_establisher_mints_distinct_tokens_per_open() {
struct TestListener;
let registry = ResourceRegistry::new();
registry
.register("listener", Substrate::Tcp, "assembly-owned")
.await;
let bind: BindFn = Arc::new(|_substrate, _backing| {
Box::pin(async {
Ok(BoundListener {
listener: Arc::new(TestListener) as Arc<dyn Any + Send + Sync>,
bound: SubstrateAddr::Path {
path: "/tmp/x".to_string(),
},
})
})
});
let establisher = bindfirst_establisher(registry, bind);
let input = serde_json::json!({"resource": "listener", "substrate": "tcp"});
let e1 = establisher(input.clone(), AuthContext::anonymous(b"alk/tunnel"))
.await
.expect("open 1");
let e2 = establisher(input, AuthContext::anonymous(b"alk/tunnel"))
.await
.expect("open 2");
let t1 = e1
.reply_fields()
.and_then(|m| m.get("listen"))
.and_then(|v| v.as_u64())
.expect("listen 1");
let t2 = e2
.reply_fields()
.and_then(|m| m.get("listen"))
.and_then(|v| v.as_u64())
.expect("listen 2");
assert_ne!(t1, t2, "two opens mint distinct tokens");
}
/// The registry miss → `unknown_resource` (the namespace gate —
/// the same posture as the listen establisher).
#[tokio::test]
async fn bindfirst_establisher_registry_miss_is_unknown_resource() {
let registry = ResourceRegistry::new();
let bind: BindFn = Arc::new(|_substrate, _backing| {
Box::pin(async {
panic!("the bind closure must not run past the gate");
})
});
let establisher = bindfirst_establisher(registry, bind);
let input = serde_json::json!({"resource": "ghost", "substrate": "tcp"});
let err = establisher(input, AuthContext::anonymous(b"alk/tunnel"))
.await
.expect_err("registry miss");
assert!(matches!(err, EstablishmentError::UnknownResource { .. }));
}
/// The `BindFn`'s typed errors pass through: a held port (the
/// bind IS the dial) surfaces `dial_failed`; bind resource
/// exhaustion surfaces `resource_shortage`.
#[tokio::test]
async fn bindfirst_establisher_bind_errors_pass_through() {
for expected in ["dial_failed", "resource_shortage"] {
let registry = ResourceRegistry::new();
registry
.register("listener", Substrate::Tcp, "assembly-owned")
.await;
let err = expected;
let bind: BindFn = Arc::new(move |_substrate, _backing| {
let e = match err {
"dial_failed" => TunnelEstablishError::DialFailed("held".to_string()),
_ => TunnelEstablishError::ResourceShortage("exhausted".to_string()),
};
Box::pin(async move { Err(e) })
});
let establisher = bindfirst_establisher(registry, bind);
let input = serde_json::json!({"resource": "listener", "substrate": "tcp"});
let got = establisher(input, AuthContext::anonymous(b"alk/tunnel"))
.await
.expect_err("bind failure");
let reason = match &got {
EstablishmentError::DialFailed { .. } => "dial_failed",
EstablishmentError::ResourceShortage { .. } => "resource_shortage",
other => panic!("unexpected error shape {other:?}"),
};
assert_eq!(reason, expected);
}
}
/// The witness seam (`register_tunnel_bindfirst_openable`'s
/// identity_witness): the wrapping establisher records the per-call
/// opener identity, then delegates (the CF-006 probe contract).
#[tokio::test]
async fn bindfirst_witness_wrapper_records_then_delegates() {
struct TestListener;
let registry = ResourceRegistry::new();
registry
.register("listener", Substrate::Tcp, "assembly-owned")
.await;
let bind: BindFn = Arc::new(|_substrate, _backing| {
Box::pin(async {
Ok(BoundListener {
listener: Arc::new(TestListener) as Arc<dyn Any + Send + Sync>,
bound: SubstrateAddr::Path {
path: "/p".to_string(),
},
})
})
});
let witness: IdentityWitness = Arc::new(tokio::sync::Mutex::new(None));
let inner = bindfirst_establisher(registry, bind);
let wrapped = witness_establisher(inner, Arc::clone(&witness));
let input = serde_json::json!({"resource": "listener", "substrate": "tcp"});
let auth = AuthContext {
identity: Some(alkcall::core::auth::Identity {
id: "opener-9".to_string(),
scopes: vec![],
resources: Default::default(),
}),
..AuthContext::anonymous(b"alk/tunnel")
};
let establishment = wrapped(input, auth).await.expect("establishment");
assert!(
establishment.plan.is_some(),
"delegation preserved the plan"
);
assert_eq!(
*witness.lock().await,
Some("opener-9".to_string()),
"the per-call opener identity was recorded"
);
}
}
+218
View File
@@ -0,0 +1,218 @@
---
id: tunnels/bindfirst-params
name: Bind-first params — ListenParams, the forwarded-op spec, the bind-first plan type (ADR-008 as amended)
status: completed
depends_on: []
scope: narrow
risk: medium
impact: component
level: implementation
tags: [wire, params, bind-first, forwarded-op, correlation-token]
---
## Description
Implement the bind-first wire surface's params half (ADR-008, as
amended 2026-09-18 — Amendment 1): the listen-open reply fields
(`bound` + `listen`), the forwarded-op params + spec, and the
bind-first plan payload type. Split from the establisher/pump task
(`tunnels/bindfirst-producer`) so the wire-stable types land and get
pinned first — the same ordering `tunnels/params` used for the base
op.
### params.rs — constants + types
```rust
pub const OP_TUNNEL_FORWARDED: &str = "channels/tunnel/forwarded";
pub const TUNNEL_FORWARDED_SCOPE: &str = "tunnel:forwarded";
/// The bind-first listen-open reply (ADR-008 §2 as amended): the two
/// additive fields the establisher contributes. Extracted from the
/// full success reply (`{channel_id, bound?, listen?}`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelListenReply {
#[serde(skip_serializing_if = "Option::is_none")]
pub bound: Option<SubstrateAddr>,
#[serde(skip_serializing_if = "Option::is_none")]
pub listen: Option<u32>,
}
/// The forwarded-op input params (ADR-008 §3 as amended).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelForwardedParams {
pub listen: u32,
pub substrate: Substrate,
pub peer: SubstrateAddr,
}
pub fn tunnel_forwarded_spec() -> OperationSpec
```
- **The correlation token (Amendment 1):** `listen` (u32) — minted by
the bind-first establisher, returned in the reply as the `listen`
field, stamped into each forwarded open's `listen` param. NOT the
listen channel's ID (the pre-amendment key is superseded — the
serving-side handler never sees its own channel ID, and generic
relays don't rewrite params; ADR-008 Amendment 1 has the full
rationale). `SubstrateAddr` is defined by `tunnels/direct-op` —
same value shape (`{host, port}` / `{path}`); if that task has not
landed, define it here and let `tunnels/direct-op` consume it
(either order works; the type lands once).
- `tunnel_forwarded_spec()` per wire.md §The Forwarded Op:
`Sub`-typed, `Visibility::External`, `ChannelOpenSpec::new(TUNNEL_ALPN)`,
scope `["tunnel:forwarded"]` (ADR-006 Amendment 1 — ingress, a
separate grant), input schema pinning all three fields required
(`listen` integer > 0; `peer` an object with the per-substrate
shape), output schema `{channel_id: integer > 0}`, description
carried. **No establisher** on the forwarded op — the data plane
starts immediately (ADR-008 §3).
- `TunnelListenReply` is the consumer-side reader (the wire itself is
plain JSON reply fields — additive, unknown fields ignored by old
consumers; the struct is the typed view, not a deny-unknown wire
gate on the reply — NOTE: the reply arrives as
`{channel_id, bound?, listen?}`; parse it with a dedicated
reply-viewing function that extracts the optional fields from the
full output Value, not with `deny_unknown_fields` deserialization
of the whole reply. The struct above is the projected pair; the
parse helper handles `channel_id`'s presence).
### The bind-first plan type (producer.rs or a small shared module)
```rust
/// The bind-first establisher's plan payload: the listener handle
/// (typed-opaque to the pump via downcast) + the correlation token
/// the accept dispatcher stamps into forwarded opens (ADR-008
/// Amendment 1 — the handler cannot learn its own channel ID, so the
/// establisher-minted token is the correlation key).
pub struct BindFirstPlan {
pub listener: TargetHandle, // the listener's halves? — NO: see the shape note
pub token: u32,
}
```
- **Shape note (decide during implementation, document the choice):**
the listener is NOT `TargetHandle` halves — a listener is accepted
FROM, not pumped. The plan carries whatever the assembly's `BindFn`
produced (opaque to the protocol), plus the token. Honest shape:
`pub struct BindFirstPlan { pub listener: Arc<dyn Any + Send + Sync>, pub token: u32 }`
— the accept dispatcher downcasts the `Any` to the assembly's
listener type via a type-erased accept closure (the same injection
pattern as `DialFn`/`AcceptFn`). The ADR's "listener handle" in the
plan is assembly-typed; the protocol sees `BindFirstPlan { inner,
token }`.
- The token mint: `std::sync::atomic::AtomicU32` counter seeded
non-zero (e.g. 1) inside the establisher closure state; wraps are
acceptable (u32, per-producer uniqueness is all that's required —
ADR-008 Amendment 1's token-space note; a wrap collision requires
2^32 concurrent opens on one producer).
- Wire-stability note for the implementer: `listen`'s u32 type and
field name, the forwarded params field names, and the op id are
one-way doors (ADR-008 Consequences). `deny_unknown_fields` on
`TunnelForwardedParams` (loud rejection).
### Tests (unit, params.rs)
- `TunnelForwardedParams` round-trip per substrate; unknown-field and
missing-field rejection; `listen: 0` rejected (schema minimum 1 +
serde-level pin).
- `TunnelListenReply` projected parse: full reply
`{channel_id: 7, bound: {...}, listen: 918}` → both fields; bare
`{channel_id: 7}` → both `None` (accept-first/dial shape); unknown
extra reply fields tolerated (the additive posture — the reply
viewer must not deny unknown fields).
- Spec-shape conformance: op id, Sub type, scope, schema structure
(all three required; peer object), ALPN marker, description.
- No forwarded establisher: the registration test lands in
`tunnels/bindfirst-producer` (this task pins the spec shape only).
## Acceptance Criteria
- [x] `OP_TUNNEL_FORWARDED`/`TUNNEL_FORWARDED_SCOPE` constants;
`tunnel_forwarded_spec()` matches wire.md §The Forwarded Op
- [x] `TunnelForwardedParams` (deny-unknown, `listen: u32 > 0`) + the
reply-projection viewer round-trip the pinned shapes
- [x] `BindFirstPlan` (opaque listener + token) defined; the token
mint documented (per-producer unique, per-open fresh)
- [x] Unit tests green; wasm32 check passes (pure protocol types)
- [x] Clippy/fmt clean
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§2 as amended — the `listen` reply field; §3 as amended — the
forwarded params; Amendment 1 — the token rationale)
- docs/architecture/wire.md §The Listen Open Op's Reply +
§The Forwarded Op
- tasks/tunnels/params.md (the ordering precedent: wire types first)
- tasks/tunnels/direct-op.md (`SubstrateAddr` — shared type)
## Notes
- `SubstrateAddr` had already landed (via `tunnels/direct-op`) —
consumed, not redefined (the "either order works" branch resolved
to the consuming side).
- The reply viewer is `parse_listen_reply(&Value) ->
Result<TunnelListenReply, TunnelError>`: parses the FULL success
output (the `open_channel_with_reply` shape), tolerates unknown
extra reply fields (the additive posture — no deny-unknown gate on
the reply), requires a positive `channel_id` (its absence/zero =
not an open reply — loud), and fails loud on malformed `bound`
(not substrate-shaped) or `listen` (not a positive u32) via the
new `TunnelError::MalformedReply` variant (error.rs). `null`
fields parse as `None` (defensive; a well-formed reply omits them).
- `TunnelForwardedParams.listen` carries a serde-level
`positive_u32` pin behind the schema's `minimum: 1` (the task's
"listen: 0 rejected (schema minimum 1 + serde-level pin)" — both
gates, test-pinned).
- The `peer` sub-schema follows the direct op's if/then precedent
(tcp/udp → `{host, port}` with `additionalProperties: false` +
`maximum: 65535`; unix → `{path}`), structural assertions per the
no-jsonschema-dep precedent. The untagged-peer lenient-extra-fields
asymmetry carries over from the direct op (schema = the shape gate;
serde = the backstop) — documented in the test.
- `BindFirstPlan` lives in `producer.rs` (the plan-payload family,
next to `TargetHandle`): `{ listener: Arc<dyn Any + Send + Sync>,
token: u32 }` per the honest shape — the listener is NOT
`TargetHandle` halves (a listener is accepted FROM, not pumped);
the accept dispatcher downcasts via a type-erased accept closure.
The token-mint discipline (AtomicU32 seeded non-zero, per-open
fresh, per-producer unique, wraps acceptable) is pinned in the
type's doc — the establisher that implements it lands with
`tunnels/bindfirst-producer`.
- Custom `Debug` impls (matching `TargetHandle`'s precedent): the
listener renders `<opaque>`, the token renders plainly.
- Tests: 10 new unit tests in params.rs (forwarded round-trips per
substrate, unknown/missing-field rejection, `listen: 0` serde
rejection, full/bare/extra-field reply projection, malformed-reply
rejection, spec conformance, peer-schema structure, the
parse-backstop asymmetry) + the `BindFirstPlan`
downcast-through-`ChannelPlan` test in producer.rs (incl. the
wrong-type downcast failure).
## Summary
Implemented the bind-first wire surface's params half per ADR-008 as
amended: `params.rs` — `OP_TUNNEL_FORWARDED` / `TUNNEL_FORWARDED_
SCOPE` constants; `TunnelForwardedParams {listen, substrate, peer}`
(deny-unknown, `listen` positive via schema `minimum: 1` + the serde
pin); `tunnel_forwarded_spec()` (`Sub`-typed, External,
`ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:forwarded"]`,
per-substrate if/then `peer` sub-schema, output
`{channel_id: integer > 0}`, description, no establisher);
`TunnelListenReply` + `parse_listen_reply` (the projected reply
viewer — unknown extra fields tolerated, `channel_id` presence
required, malformed fields fail loud via `TunnelError::
MalformedReply`). `producer.rs` — `BindFirstPlan {listener:
Arc<dyn Any + Send + Sync>, token: u32}` with the mint discipline
pinned in docs (non-zero-seeded AtomicU32, per-open fresh,
per-producer unique, wraps acceptable). Re-exported through
`lib.rs`; the establisher/dispatcher/registration that consume these
types land with `tunnels/bindfirst-producer`.
Verified: `cargo test` green (105 default; 125 with `--all-
features`), clippy `--all-targets -D warnings` clean (native +
wasm32), fmt clean, `wasm32-unknown-unknown` check passes (pure
protocol types — the default crate stays wasm-clean), `cargo doc
--no-deps` clean. CHANGELOG updated (Unreleased → Added).
+332
View File
@@ -0,0 +1,332 @@
---
id: tunnels/bindfirst-producer
name: Bind-first establisher + accept dispatcher + registration (ADR-008 shape 3)
status: completed
depends_on: [tunnels/bindfirst-params]
scope: broad
risk: high
impact: component
level: implementation
tags: [producer, establisher, bind-first, accept-dispatcher, forwarded-op]
---
## Description
Implement the bind-first establisher (producer.md §The Establisher,
shape 3 — ADR-008 §1): the establisher binds (via the injected
`BindFn`), mints the correlation token, contributes `bound` + `listen`
to the reply, and returns the plan; the pump handler is the **accept
dispatcher** — it accepts the channel's `BiStream`, runs the
assembly-provided accept loop, and per accept opens
`channels/tunnel/forwarded` back toward the listen opener and pumps
the accepted halves against the adopted channel. Plus
`register_tunnel_bindfirst_openable` wiring it.
### The establisher (shape 3 — bind-first)
```rust
pub type BindFn = Arc<
dyn Fn(&str) -> BoxFuture<'static, Result<BoundListener, TunnelEstablishError>>
+ Send + Sync,
>;
/// What the assembly's bind produces: the opaque listener (the accept
/// loop's business) + the OS-chosen bound address (the `bound` reply
/// field's source). The protocol never binds (OQ-TN-04).
pub struct BoundListener {
pub listener: Arc<dyn Any + Send + Sync>, // assembly-typed
pub bound: SubstrateAddr,
}
pub fn bindfirst_establisher(
registry: ResourceRegistry, // namespace gate only, as listen_establisher
bind: BindFn,
) -> OpenEstablisher
```
- Flow: parse `TunnelParams` → registry namespace gate
(`unknown_resource` on miss — same posture as
`listen_establisher`) → mint the correlation token
(per-open-fresh, per-producer-unique; `tunnels/bindfirst-params`)
→ `bind(substrate, backing)` → contribute reply fields
(`Establishment::with_reply_field("bound", …).with_reply_field("listen", token)`)
→ `Establishment::new(Arc::new(BindFirstPlan { listener, token }))`.
- Error mapping (producer.md shape-3 table): unbindable/held port →
`dial_failed` (the bind IS the dial here); bind resource
exhaustion → `resource_shortage`; registry miss →
`unknown_resource`; internal → `handler_error`. The `BindFn`
returns the typed errors; the establisher passes them through.
- The token lives in BOTH the reply and the plan (the dispatcher
needs it; the opener needs it via the reply).
### The accept dispatcher (the shape-3 pump handler)
```rust
pub fn make_bindfirst_accept_handler(
accept_loop: BindFirstAcceptLoop,
open_forwarded: ForwardedOpener,
) -> OpenHandler
```
- **Injection seams (the design pin — decide the exact closure
signatures during implementation, document in Notes):** the
dispatcher needs two assembly-provided capabilities the protocol
cannot own:
1. **The accept loop** — pops accepted connections from the
`BindFirstPlan`'s opaque listener and yields
`(SubstrateAddr /* peer */, TargetHandle /* accepted halves */)`.
Substrate code lives behind the closure (ADR-004).
2. **The forwarded-open caller** — opens `OP_TUNNEL_FORWARDED`
with `TunnelForwardedParams { listen: token, substrate, peer }`
on the channel-0 call plane toward the listen opener, adopts the
opener-allocated ID (`adopt_channel` on the producer-side
manager), and returns the streams. **This needs the call
plane**: the per-channel `Connection` the handler receives is
stream-less (`open_bi` is `StreamClosed` — ADR-035) and carries
no call surface, so the registration must close over the
serving session's `Arc<CallConnection>` (the reverse POC's
`open_reverse_channel` shape: `call_with_payload` on channel 0 →
extract `channel_id` from the reply → `manager.adopt_channel`).
The registration function takes the call-plane handle (the
assembly has it — it constructed the serving session); document
the wiring.
- Handler contract (producer.md §The Pump Handler item 5 + R-02):
`conn.accept_bi()` FIRST (the yield-once contract — the wrapper's
accepted-flag telemetry requires it), then run the accept loop
concurrently with holding the channel's stream. The listen
channel's own `BiStream` stays idle (no pumps on it); **its EOF
cancels the loop and tears the listener down** — `select!` on the
idle stream's EOF vs the accept loop (read from the stream and
observe EOF; the zero-byte sentinel is the opener dropping the
listener session). Per-accept data planes are `pump_bidi`
(convention 8 — never a hand-rolled loop over one stream): each
accepted handle pumps against its adopted forwarded channel inside
a spawned task tracked by the handler's JoinHandle set.
- **Lifetime (R-02):** the handler's returned JoinHandle tracks the
whole accept loop + every per-accept pump it spawned (collect the
pump handles; the handler task awaits them on loop exit — a
forwarded pump outliving its listener is a leak). Handler exit →
wrapper teardown (the listen channel reaps) → the far listener
tears down (the `BindFn`'s listener drops with the plan/loop).
- Error posture per accept: a forwarded-open failure (opener without
the scope → FORBIDDEN; old opener → NOT_FOUND; correlation miss →
handler_error-class) logs and drops that accepted connection — the
loop continues; the listener's overall health is not hostage to one
failed accept. (Decide: retry/backoff — v1 says no; document.)
### Registration
```rust
pub fn register_tunnel_bindfirst_openable(
core: &ChannelCore,
registry: &ResourceRegistry,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
call_plane: Arc<CallConnection>, // the producer-side call plane for forwarded opens
manager: &ChannelManager, // the producer-side manager (adopt the forwarded ids)
bind: BindFn,
accept_loop: BindFirstAcceptLoop,
identity_witness: Option<IdentityWitness>,
) -> Result<(), String>
```
- Same spec (`tunnel_open_spec()`) + scope gate (`tunnel:open`) as the
dial/listen variants — the bind-first shape is a per-registration
establisher choice over the same op (ADR-008 §5's per-registration
posture). One establisher per op id per registry — same note as
`register_tunnel_listen_openable`.
- The forwarded-op SERVING side (opener side) is a separate
registration shape (`register_openable` — no establisher) and lands
with `tunnels/forwarded-receipt`; this task's tests still need an
opener-side stub (see Tests).
### Tests (integration — the duplex harness, listen-topology extension)
- **The full bind-first flow e2e:** opener opens
`channels/tunnel/sub` with the bind-first producer registered →
reply carries `bound` (the real OS/duplex-chosen address) +
`listen` (the token) → the opener serves `channels/tunnel/forwarded`
(scope `tunnel:forwarded`) with a correlation map keyed on the
token → the test accept loop accepts a duplex connection → the
producer's dispatcher opens forwarded with
`{listen: token, substrate, peer}` → the opener correlates, adopts,
pumps against the "local BIND client" halves → byte round-trip
through the forwarded channel. This test IS the ADR-008 acceptance
gate.
- Reply-field projection e2e: `open_channel_with_reply` sees both
fields; a dial establisher's open sees neither (the reserved-key
guard upstream guarantees `channel_id` stays wrapper-owned).
- EOF teardown: drop the listener session (close the listen channel's
stream) → the accept loop cancels, the far listener is gone (a
follow-on accept attempt fails), the forwarded channels already
adopted own their teardown independently.
- Per-accept failure isolation: opener rejects one forwarded open
(e.g. scope-less identity on a second opener registry) → that
accept's connection drops, the loop serves the next accept.
- Establishment error paths: bind failure → `dial_failed`-class on
the wire (`channel:open_failed`); registry miss →
`unknown_resource`.
- Token discipline: two opens on the same producer mint distinct
tokens; each dispatcher stamps ITS OWN plan's token.
## Acceptance Criteria
- [x] `bindfirst_establisher`: registry gate → mint token → bind →
`bound`+`listen` reply fields → `BindFirstPlan` plan-flow
- [x] The accept dispatcher: `accept_bi` first (R-02 telemetry),
idle-stream EOF cancels the loop + tears the listener down,
per-accept `pump_bidi` tasks tracked by the handler JoinHandle
- [x] The forwarded-open caller rides the call plane
(`call_with_payload` + adopt — the reverse POC's shape); the
registration wiring documents where the call plane comes from
- [x] `register_tunnel_bindfirst_openable` wires spec + establisher +
dispatcher + injections
- [x] Integration tests: the full flow e2e (the acceptance gate),
reply-field projection, EOF teardown, per-accept isolation,
typed establishment errors, token discipline
- [x] Clippy/fmt clean; wasm32 check passes (closures injected; no
substrate code in the default crate)
- [x] `cargo test` green
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§1 shape 3, §2 the reply fields, §3 the forwarded op, Amendment 1
— the token; §6 the hub policy sentence)
- docs/architecture/producer.md §The Establisher (shape 3) + §The
Pump Handler (item 5) + §The Forwarded Op
- tasks/tunnels/bindfirst-params.md (the types this consumes)
- tasks/tunnels/producer-open-op.md + producer-listen.md (the
harness + registration shapes this extends)
- alkcall surface: `Establishment::with_reply_field` (ADR-049
amendment 3, landed 0.8.0), the `OpenHandler` signature (no channel
ID — Amendment 1's premise), `ChannelRelay` reply-field pass-through
(ADR-051 — the relay-transparency property the tests should pin)
## Notes
- **Closure signatures decided (the design pin):** `BindFn` /
`BindFirstAcceptLoop` / `ForwardedOpener` follow the type-erased
closure precedent:
- `BindFn = Arc<dyn Fn(Substrate, &str) -> BoxFuture<'static,
Result<BoundListener, TunnelEstablishError>> + Send + Sync>` —
same `(substrate, backing)` form as `DialFn` (the registry
backing string rides the same signature; the bind closure stays
substrate-agnostic from the protocol's view).
- `BindFirstAcceptLoop = Arc<dyn Fn(Arc<dyn Any + Send + Sync>) ->
BoxFuture<'static, Result<(SubstrateAddr, TargetHandle),
TunnelEstablishError>> + Send + Sync>` — the closure receives the
plan's OPAQUE listener (`Arc<dyn Any + Send + Sync>`, no
downcast in the protocol: the assembly downcasts to ITS concrete
type inside the closure; the same injection pattern as
`DialFn`/`AcceptFn`). A returned `Err` is LOOP-CRITICAL (the
substrate accept source broke) — the dispatcher ends the loop;
per-accept forwarded-open failures never come from this closure.
- `ForwardedOpener = Arc<dyn Fn(TunnelForwardedParams) ->
BoxFuture<'static, Result<(MpscSendStream, MpscRecvStream),
TunnelEstablishError>> + Send + Sync>` — closes over the serving
session's `Arc<CallConnection>` + the producer-side
`ChannelManager` (the reverse POC's `open_reverse_channel`
shape: `call_with_payload` on channel 0 → extract `channel_id` →
`manager.adopt_channel`). **Where the call plane comes from:** the
assembly layer holds it — it constructed the serving session
(the harness's `wire_bindfirst` shows the wiring: the
`ForwardedOpener` closure closes over the `Arc<ChannelClient>`
and calls `call_open_op` + `adopt_channel`; in production the
assembly hands its `CallConnection` the same way).
- **Registration signature deviation (documented):** the task sketch
had 8 parameters (clippy's `too_many_arguments`); the injected
seams bundle into `BindFirstInjections {bind, accept_loop,
open_forwarded}` — one coherent injection point passed beside the
core pair + the optional witness. Same posture as `DialFn` riding
the dial registration. The `identity_witness` seam is wired via a
small `witness_establisher` wrapper (records the per-call opener
identity, then delegates — the same CF-006 seam the dial/direct
variants inline).
- **The token mint** (`mint_token`, private): `AtomicU32` counter
seeded at 1 inside the establisher's closure state; `fetch_add`
returns the PREVIOUS value, so the minted stream is 1, 2, 3, … —
token 0 (an illegal wire value) is never minted, and a u32 wrap
that lands on 0 re-increments (the loop skips it). Per-open fresh,
per-producer unique; unit-pinned including the wrap shape.
- **The idle-stream EOF watch:** a 1-byte `read` on the split idle
half inside a `select!` against the accept loop — `Ok(0)` (the
zero-byte EOF sentinel), a read error, or (defensively) any
non-empty read on an idle stream all cancel the loop. The listen
channel's stream is never pumped (no `pump_bidi` on it).
- **Per-accept failure posture (v1, documented):** a forwarded-open
failure (scope-less opener → FORBIDDEN; old opener → NOT_FOUND;
correlation miss → handler_error-class) logs a warning and drops
that accepted connection — the loop continues; NO retry/backoff
(v1 posture per the task).
- **Lifetime (R-02):** the handler collects every per-accept
`pump_bidi` `JoinHandle` in a `Vec` and awaits them all on loop
exit (before the handler task returns) — a forwarded pump never
outlives its listener. Handler exit → wrapper teardown → the listen
channel reaps → the plan's listener drops with the loop.
- **Harness changes (test infrastructure):** `wire_bindfirst`
(RegistrationMode::BindFirst); the consumer's install hook gained a
shared serving registry + the accept-side identity propagation
(the transport identity attaches to channel 0 — the same posture
`wire_forward` models; needed because the forwarded opens arrive
ON the consumer's serving path); `Topology.consumer_registry`
exposed; the forwarded-op serving stub
(`register_forwarded_stub_on_consumer` /
`make_forwarded_stub_handler` — correlate by `listen` token →
accept the `BiStream` → `pump_bidi` against the correlated halves;
the opener-side registration shape lands for real with
`tunnels/forwarded-receipt`).
- **Test-data-plane shape:** the e2e round-trip drives the local
BIND client end directly (the correlation map's halves are one end
of a raw duplex; the stub's pump bridges forwarded channel ↔ those
halves; the accepted side's echo task closes the loop). The
forwarded channel on the opener side is wrapper-managed (the
serving wrapper allocated it — `adopt_channel` on it a second time
fails `ChannelExists`), so the test drives the client end, not a
re-adopt.
- **EOF-teardown observability:** the test pins the listen channel
reaping on the producer side after the opener drops its session
(`has_channel` false) and the accept loop's death via the queue
(a follow-on push is never consumed).
## Summary
Implemented the bind-first producer (shape 3, ADR-008 as amended) per
the task: `src/producer.rs` — `BindFn` + `BoundListener` (the
platform-boundary bind seam); the token mint (non-zero-seeded
`AtomicU32`, per-open fresh, per-producer unique, token 0 never
minted — the wrap shape unit-pinned); `bindfirst_establisher`
(registry namespace gate → mint → bind → `bound` + `listen` reply
fields → the `BindFirstPlan` plan; the typed errors pass through —
bind failure `dial_failed`, exhaustion `resource_shortage`, registry
miss `unknown_resource`); `BindFirstAcceptLoop` (the type-erased
accept closure receiving the opaque listener); `ForwardedOpener` (the
call-plane forwarded-open caller: call + adopt);
`make_bindfirst_accept_handler` (the accept dispatcher: `accept_bi`
first, the idle-stream EOF sentinel cancels the loop via `select!`,
per-accept `pump_bidi` in spawned tasks tracked by the handler's
`JoinHandle`, per-accept failure isolation, loop-critical accept
errors end the loop); `register_tunnel_bindfirst_openable` (same
spec + scope gate, `BindFirstInjections` bundle + witness seam) —
re-exported through `lib.rs`. `tests/harness.rs` —
`RegistrationMode::BindFirst` + `wire_bindfirst` (the consumer hook
gained the accept-side identity propagation + a shared serving
registry, `Topology.consumer_registry` exposed) + the forwarded-op
serving stub (`make_forwarded_stub_handler`). `tests/
producer_bindfirst.rs` — 6 integration tests: the full flow e2e (the
acceptance gate: reply projection → token correlation → forwarded
open → byte round-trip through the whole path), the dial-open
neither-field projection, EOF teardown (listen reaped, loop gone),
per-accept isolation (scope-less opener; the loop serves the next
accept), typed establishment errors, and the token discipline (two
opens distinct tokens; each dispatcher stamps its own plan's token).
Plus 6 unit tests in producer.rs (the mint discipline incl. the wrap
shape, the establisher flow at the `Establishment` surface — plan
token == reply token — distinct tokens per open, registry miss,
bind-error passthrough, the witness wrapper).
Verified: `cargo test` green (118 default; 138 `--all-features`),
clippy `--all-targets -D warnings` clean (native + wasm32), fmt
clean, `wasm32-unknown-unknown` check passes (closures injected; the
default crate stays wasm-clean), `cargo doc --no-deps` clean.
CHANGELOG updated (Unreleased → Added).
+1 -1
View File
@@ -1,7 +1,7 @@
---
id: tunnels/coverage-weak-spots
name: "Coverage weak spots — CLOSED (2026-09-09 second pass): the UdpHalf sender-block recipe found, feed infallible, wrappers covered"
status: done
status: completed
depends_on: [tunnels/review-impl]
scope: single
risk: low
+248
View File
@@ -0,0 +1,248 @@
---
id: tunnels/direct-op
name: Direct op — params, spec, establisher, consumer open_direct (ADR-007)
status: completed
depends_on: []
scope: broad
risk: medium
impact: component
level: implementation
tags: [wire, params, direct-op, producer, consumer, egress]
---
## Description
Implement the direct op (`channels/tunnel/direct`, ADR-007) end to
end: the params type + spec builder (`src/params.rs`), the establisher
+ registration (`src/producer.rs`), and the consumer constructor
(`src/consumer.rs`). The op rides the same `alk/tunnel` ALPN (ADR-002
Amendment 1's flavor-form op ids — the upstream discovery derivation
landed in alkcall 0.8.0, ADR-047 amendment 3). This is the ssh
`direct-tcpip` / SOCKS5 CONNECT egress capability with structural ACL
separation from `tunnel:open`.
### params.rs — types + spec
```rust
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
/// The substrate-shaped address object (ADR-007 §2): keyed by the
/// sibling `substrate` field of the params. Also the `peer` value
/// shape of the forwarded op (ADR-008 §3) — define once here.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SubstrateAddr {
HostPort { host: String, port: u16 },
Path { path: String },
}
/// The direct-op input params (ADR-007 §2). Wire-stable.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelDirectParams {
pub substrate: Substrate,
pub target: SubstrateAddr,
}
pub fn tunnel_direct_spec() -> OperationSpec
```
- Spec per wire.md §The Direct Op: `Sub`-typed, `Visibility::External`,
`ChannelOpenSpec::new(TUNNEL_ALPN)`, scope `["tunnel:direct"]`
(**never implied by `tunnel:open`** — ADR-006 Amendment 1), output
schema `{channel_id: integer > 0}`.
- `input_schema` pins both fields required and the target sub-schema
per-substrate — a `oneOf` on the sibling `substrate` value
(`tcp`/`udp` → `{host, port}`, `unix` → `{path}`), so malformed
targets fail the registry's schema validation (the typed
`INVALID_INPUT` class) **before** the establisher runs. The
establisher's serde parse is the second gate (defensive; see the
error-mapping note).
- `deny_unknown_fields` on both params types (the loud-rejection
posture, ADR-001's precedent).
- The `Substrate` enum is reused as the discriminator (no new
substrate values — ADR-001's additive rule).
### producer.rs — the establisher + registration
```rust
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher
pub fn register_tunnel_direct_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
dial: DialFn, // the SAME DialFn the base op uses
identity_witness: Option<IdentityWitness>,
) -> Result<(), String>
```
- **DialFn reuse is the design pin**: the establisher parses
`TunnelDirectParams`, renders the target to the same backing-string
form the registry backings use (`"host:port"` for tcp/udp, the path
for unix), and calls the injected `DialFn(substrate, rendered)`.
No second dial type — a producer that registered `dial_tcp` for the
base op registers the direct op with zero new substrate code
(ADR-007's `local` interaction note). The UDP framed-adapter wrap
(ADR-003) happens inside the dial closure (the `local` feature's
`connect_udp` already does this) — the establisher stays
substrate-agnostic.
- **No registry lookup** — `unknown_resource` can never fire
(ADR-007 §2). Error mapping (ADR-007 §4's table minus the registry
row): the serde-parse failure of a schema-valid-shaped target maps
`HandlerError` (the schema gate should have caught it — the parse
is the backstop); dial failures pass through the `DialFn`'s own
typed errors unchanged (`dial_failed` / `resource_shortage` /
`handler_error`).
- The per-call `auth` carries the opener identity (CF-006) — the
witness seam records it, same as the base op (the direct op is the
arbitrary-egress capability; identity visibility here matters more,
not less).
- Registration uses the SAME pump handler (`make_tunnel_pump_handler`
— the direct op's plan payload is a `TargetHandle` exactly like the
base op's; plan-flow is unchanged).
### consumer.rs — `open_direct`
```rust
pub async fn open_direct(
client: &ChannelClient,
target: SubstrateAddr,
substrate: Substrate,
) -> Result<Self, TunnelOpenError>
```
- A separate constructor, not a params enum (ADR-007 §5: two scopes
are two capabilities; the caller states which one it holds).
Serializes `{substrate, target}`, `open_channel(OP_TUNNEL_DIRECT, …,
TUNNEL_ALPN)`, `from_halves` — identical session type, data planes,
and teardown API as `open`.
- Uses plain `open_channel` (no reply fields expected on the direct
op — dial establishers never bind; wire.md §The Direct Op).
- Documented error surface: the base open errors minus
`unknown_resource`; malformed targets are the schema/
`invalid_input`-class failure (registry-side); an old producer
rejects the op loudly (`NOT_FOUND` — the SSH posture).
### Tests
Unit (params.rs): `TunnelDirectParams` round-trip per substrate;
untagged `SubstrateAddr` disambiguation (`{host, port}` vs `{path}`);
`deny_unknown_fields` rejection; spec-shape conformance to wire.md §The
Direct Op (op id, scope, oneOf schema structure — structural
assertions, no jsonschema dep here, per `tunnels/params`'s precedent).
Integration (extend the duplex harness from `tunnels/producer-open-op`):
- `open_direct` e2e round-trip through `pump_bidi` (tcp duplex target).
- `open_direct` udp e2e (the framed adapter wraps at the dial closure —
datagram boundary preserved).
- Malformed target (unix without `path`) → schema-class failure, no
phantom session.
- Producer without the direct op registered → `NOT_FOUND`-class.
- Identity without `tunnel:direct` (but with `tunnel:open`) →
FORBIDDEN (the scopes do not imply each other).
- Direct establisher sees the per-call opener identity (witness).
## Acceptance Criteria
- [x] `TunnelDirectParams`/`SubstrateAddr` round-trip; malformed
targets rejected (serde + schema shape pinned)
- [x] `tunnel_direct_spec()` matches wire.md §The Direct Op exactly
- [x] `direct_establisher`: no registry lookup, target rendering into
the `DialFn` backing string, typed errors per ADR-007 §4
- [x] `register_tunnel_direct_openable` reuses the base pump handler +
dial injection; witness seam present
- [x] `TunnelSession::open_direct` (separate ctor; identical
session/teardown semantics)
- [x] Integration tests: e2e round-trips (tcp + udp), malformed-target
rejection, scope separation (FORBIDDEN), NOT_FOUND posture,
per-call identity witness
- [x] Clippy/fmt clean; wasm32 check passes (no substrate code added)
- [x] `cargo test` green
## References
- docs/architecture/decisions/007-direct-open-op.md (the normative op)
- docs/architecture/producer.md §The Direct Op; consumer.md §Direct
path; wire.md §The Direct Op
- docs/architecture/decisions/006-access-control-posture.md Amendment 1
- tasks/tunnels/params.md + tasks/tunnels/producer-open-op.md (the
shapes this extends; the harness to extend)
## Notes
- The `input_schema` pins the per-substrate target sub-schema as two
`if`/`then` branches under `allOf`, keyed on the sibling
`substrate` value (tcp/udp → `{host, port}` with
`additionalProperties: false`; unix → `{path}`, same posture) —
JSON Schema `if/then` composes through alkcall's jsonschema 0.46
compile, and the integration tests prove the gate fires
(`INVALID_INPUT` before the establisher). Structural assertions
pin the branch shape per `tunnels/params`'s no-jsonschema-dep
precedent.
- Untagged `SubstrateAddr` is deliberately lenient about extra target
fields (untagged variants carry no `deny_unknown_fields`): a
cross-shape target parses as whichever variant fits. The schema is
the shape-vs-substrate gate; the establisher's serde parse is the
backstop only (unknown substrate value → `handler_error`,
`direct_establisher` maps `TunnelEstablishError::HandlerError`).
This asymmetry is documented in both the type docs and the test.
- `TunnelDirectParams::render_target()` centralizes the backing-string
form (`"host:port"` / path) — the establisher injects the SAME
`DialFn` with the rendered string; the render is pinned by test to
the registry-backing form.
- `TunnelSession::open_direct` uses plain `open_channel` (no reply
fields — dial establishers never bind); `SubstrateAddr` re-exports
as the future forwarded-op `peer` shape (ADR-008 §3, defined once).
- Harness: `RegistrationMode::Direct`, `wire_direct` /
`wire_direct_with_identity` (the raw-call topology),
`wire_direct_forward` / `wire_direct_forward_with_identity` (the
session-level forward topology; identity parameterized so the
session-level FORBIDDEN probe runs through the real client API),
`direct_identity` / `both_scopes_identity`.
## Summary
Implemented the direct op end to end per ADR-007:
- **params.rs** — `OP_TUNNEL_DIRECT` / `TUNNEL_DIRECT_SCOPE`
constants; `SubstrateAddr` (untagged `{host, port}` | `{path}`,
the ADR-008 §3 `peer` shape too); `TunnelDirectParams`
(`deny_unknown_fields`, `render_target()`); `tunnel_direct_spec()`
(`Sub`-typed, External, `ChannelOpenSpec::new(TUNNEL_ALPN)`, scope
`["tunnel:direct"]`, output `{channel_id: integer > 0}`,
per-substrate `if/then` target sub-schema). 7 unit tests: round-
trips per substrate, untagged disambiguation, loud unknown-field
rejection, spec-shape conformance, schema structure.
- **producer.rs** — `direct_establisher` /
`direct_establisher_with_witness` (parse → render → injected
`DialFn`; no registry lookup; typed-error mapping per ADR-007 §4
minus the registry row; CF-006 witness seam) +
`register_tunnel_direct_openable` (direct spec + establisher + the
SAME `make_tunnel_pump_handler`, plan-flow unchanged).
- **consumer.rs** — `TunnelSession::open_direct(client, target,
substrate)`: separate constructor, serializes `{substrate, target}`,
plain `open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN)`,
`from_halves` — identical session/data-planes/teardown as `open`.
- **lib.rs** — the new surface re-exported.
Tests: `tests/producer_direct_op.rs` — 15 integration tests over the
duplex harness: tcp + udp e2e round-trips through `pump_bidi`
(datagram boundary preserved via the framed dial closure), malformed
targets (`unix` without `path`, `tcp` without `port`, missing target)
→ `INVALID_INPUT` with no phantom channel/session, dial-failure
pass-through (`dial_failed`), NOT_FOUND on a producer without the op,
scope separation both ways (`tunnel:open` ↛ `tunnel:direct` and
`tunnel:direct` ↛ `tunnel:open` — FORBIDDEN, raw-call AND session-
level), CF-006 witness on the direct establisher (raw + session
paths), the establisher parse backstop (`handler_error`), and the
target-rendering pin (the dial closure observes the exact
backing-string form). Harness: `Direct` registration mode +
`wire_direct*` topologies + `direct_identity`/`both_scopes_identity`.
Verified: `cargo test` green (93 native; 94 with `--all-features`),
clippy `-D warnings` clean (native + wasm32), fmt clean, wasm32
check passes (default crate stays wasm-clean — no substrate code
added), `cargo doc --no-deps` clean. CHANGELOG updated (Unreleased →
Added).
+242
View File
@@ -0,0 +1,242 @@
---
id: tunnels/forwarded-receipt
name: Forwarded-op serving side + TunnelSession adopt path (the opener half)
status: completed
depends_on: [tunnels/bindfirst-params]
scope: moderate
risk: medium
impact: component
level: implementation
tags: [opener, forwarded-op, serving, adopt, correlation]
---
## Description
Implement the forwarded op's SERVING side (the listen-opener half,
ADR-008 §3): `register_tunnel_forwarded_openable` — the
`register_openable` shape (no establisher; the data plane starts
immediately), scope-gated `tunnel:forwarded`, whose handler parses
`TunnelForwardedParams`, hands the correlation material to the
assembly, accepts the `BiStream`, and presents it for pumping against
the local session's halves. Plus the typed consumer surface for
reading the listen reply (`bound`/`listen` extraction, shared with
`tunnels/tunnel-listener`).
### The serving registration (opener side)
```rust
/// The correlation material the opener-side handler yields per
/// forwarded open: the parsed params + the channel's BiStream
/// accessor. The assembly correlates (token → its waiting listener
/// session bookkeeping) and drives the pump against the local
/// halves. The protocol provides the op + params, not the wait
/// state machine (producer.md §The Forwarded Op).
pub type ForwardedHandler = Arc<
dyn Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()>
+ Send + Sync,
>;
pub fn make_forwarded_op_handler(handler: ForwardedHandler) -> OpenHandler
pub fn register_tunnel_forwarded_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
handler: ForwardedHandler,
) -> Result<(), String>
```
- **No establisher** (`register_openable` — the forwarded channel
needs no establishment; its data plane starts immediately — ADR-008
§3). The wrapper's `channel_id` allocation IS the reply (the
producer adopts it).
- The built-in handler shape: parse
`TunnelForwardedParams` (`deny_unknown_fields`; a malformed open →
the handler logs + returns early — teardown-at-birth is correct for
a malformed ingress), accept the `BiStream`, invoke the injected
assembly handler with the parsed params + the stream. The assembly
correlates (via `listen`), adopts nothing (the WRAPPER owns this
channel — the serving side allocated it; the assembly pumps the
BiStream against its local halves via `pump_bidi`, and the wrapper's
R-02 tracking reaps on handler exit).
- **Pump responsibility split (the honest-shape decision to make and
document):** the injected `ForwardedHandler` receives the channel
`BiStream` and the params — the ASSEMBLY owns the pump
(`pump_bidi(forwarded_bidi, local_read, local_write)`), because the
local halves are assembly state (the SOCKS5 BIND client's socket).
The protocol-side built-in handler only bridges wrapper→assembly.
Await the injected handler's JoinHandle inline (R-02 — the
forwarded channel's lifetime tracks it).
- Identity: the opener-side gate sees the PRODUCER's per-call
identity (CF-005 precedence — the producer may carry `auth_token`
when it is itself forwarding); the `peer` param is
producer-asserted metadata, not transport truth (producer.md's
posture — ACL trusts the grant, app treats `peer` as metadata).
The registration's `auth` + the per-call overlay handle this
automatically (upstream machinery); nothing to implement, but the
test suite pins it.
- The `listen`-token correlation failure (no matching session) is the
ASSEMBLY's rejection (it holds the map): the injected handler's
error path — drop the stream, return (teardown-at-birth reaps the
channel). Document that a correlation miss tears the forwarded
channel down; the producer's accept loop drops the accepted
connection on the open's failure (the shapes mirror).
### The typed reply reader (shared with TunnelListener)
```rust
/// Extract the bind-first reply fields from an open-op success reply
/// (the full output Value — `channel_id` + optional `bound`/`listen`).
pub fn listen_reply_fields(reply: &Value) -> TunnelListenReply
```
- Tolerates unknown fields (the additive posture); both fields
`Option` (absent for dial/accept-first producers).
- Lands here (not in `tunnels/tunnel-listener`) so the projection
logic is unit-testable independent of the session type.
### Tests
- Serving-side e2e with the harness's producer-leg stub: an open with
valid params + scope → handler invoked with the parsed params +
the stream; the assembly's test handler pumps against local
duplex halves → byte round-trip. (The full bind-first flow e2e
lives in `tunnels/bindfirst-producer`; this task's e2e is the
serving half standalone.)
- Scope gate: identity without `tunnel:forwarded` → FORBIDDEN (never
implied by `tunnel:open`/`tunnel:direct`).
- Malformed params (missing `peer`, `listen: 0`, unknown field) →
handler logs + early return → the channel tears down (no phantom).
- The reply reader: full/empty/extra-field replies parse per the
additive posture (unit).
- Old-opener posture is upstream's registry lookup (`NOT_FOUND`) —
pin it in the producer-side test in `tunnels/bindfirst-producer`
(cross-referenced, not duplicated here).
## Acceptance Criteria
- [ ] `register_tunnel_forwarded_openable`: no-establisher
registration, scope gate `tunnel:forwarded`, injected
assembly handler receives parsed params + BiStream
- [ ] The injected handler's JoinHandle is awaited inline (R-02);
correlation-miss teardown semantics documented + tested
- [ ] `listen_reply_fields` extraction (additive-tolerant)
- [ ] Integration tests: serving e2e round-trip, FORBIDDEN gate,
malformed-params teardown; unit tests for the reply reader
- [ ] Clippy/fmt clean; wasm32 check passes
- [ ] `cargo test` green
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§3 the forwarded op — the opener-side serving shape; Amendment 1)
- docs/architecture/producer.md §The Forwarded Op; consumer.md §The
Listen Flow; wire.md §The Forwarded Op
- docs/architecture/decisions/006-access-control-posture.md Amendment 1
- tasks/tunnels/bindfirst-params.md (the types);
tasks/tunnels/bindfirst-producer.md (the caller half — the tests
compose)
## Notes
- **`ForwardedHandler` (the injected assembly handler):** `Arc<dyn
Fn(TunnelForwardedParams, BiStream) -> tokio::task::JoinHandle<()> +
Send + Sync>` — the correlation material the opener-side handler
yields per forwarded open. The assembly owns the pump (the honest
split: the local halves — the SOCKS5 BIND client's socket — are
assembly state), correlating via the `listen` token against its own
waiting-listener map; the protocol provides the op + params, not the
wait state machine (ADR-008 §3's posture, realized).
- **`make_forwarded_op_handler` (the built-in wrapper):** parses
`TunnelForwardedParams` (`deny_unknown_fields` + the landed
positive-`listen` serde pin), accepts the channel `BiStream`
(yield-once; accepting BEFORE the assembly handler runs keeps the
R-02 accepted-flag telemetry truthful — a parse-gated early return
never accepted, which the wrapper's birth-teardown hint reports),
then awaits the injected handler's `JoinHandle` inline (R-02 — the
forwarded channel's lifetime tracks it; the wrapper's tracking reaps
on handler exit).
- **No establisher** — `register_openable` exactly (the forwarded
channel needs no establishment; the data plane starts immediately —
ADR-008 §3). The wrapper's `channel_id` allocation IS the reply (the
producer adopts it) — nothing to implement, upstream machinery.
- **Identity posture (upstream machinery, test-pinned):** the
opener-side gate sees the PRODUCER's per-call identity (CF-005
precedence — payload token > `ServingConfig.identity` >
transport); the `peer` param is producer-asserted metadata, not
transport truth. Nothing implemented here; the FORBIDDEN probe
pins the gate runs (identity without the grant → FORBIDDEN with the
missing scope named, never implied by `tunnel:open` — probed with a
`tunnel:open`-only identity).
- **Correlation-miss semantics (documented + test-pinned):** the
`listen`-token miss is the ASSEMBLY's rejection (it holds the map):
the injected handler's error path drops the stream and returns —
teardown-at-birth reaps the forwarded channel. The producer's accept
loop drops the accepted connection on the open's failure (the shapes
mirror; pinned producer-side in `tunnels/bindfirst-producer`'s
per-accept isolation).
- **Reply reader lands in `src/params.rs`** as `listen_reply_fields`
(the lenient, infallible session-surface projection) DELEGATING to
the landed `parse_listen_reply` (the loud typed viewer): unknown
extra reply fields tolerated (the additive posture — no deny-unknown
gate on the reply), a malformed shape logs `tracing::error!` loud
and surfaces both-`None` (the session presents `Option` fields and
never refuses construction over an informational field; use
`parse_listen_reply` directly when the loud typed distinction
matters). Both `Option` — absent for dial/accept-first producers;
the projection is unit-testable independent of the session type
(this task's placement rationale), consumed by `TunnelListener`
(`tunnels/tunnel-listener`, pending).
- **Test-data-plane shape (the harness):** the e2e drives the
forwarded channel through BOTH pumps — the opener-side assembly
pump pops correlated duplex halves for the token (pushed BEFORE the
forwarded open — the waiting-session posture; a late push is the
miss shape, probed separately) and the producer-side test adopts the
opener-allocated ID and pumps it against a driving echo duplex —
then round-trips bytes from the client end through the whole path
(assembly pump → forwarded channel → adopted pump → driving echo →
back). The malformed-params probes pin the no-phantom property at
both gates: `listen: 0` / missing `peer` fail the registry's schema
(`INVALID_INPUT`, no channel ever); an unknown top-level field
passes the schema (additive posture) but fails the serde parse →
the channel (whose ID the reply already carried) reaps promptly.
- **Harness note:** the suite reuses the existing harness's
`wire_with` + `Topology.consumer_registry` (the
`bindfirst-producer` shape) — no harness changes needed; the
forwarded-op serving stub helpers (`make_forwarded_stub_handler`
etc.) stay as-is (they remain the producer-leg stub stand-in; the
real opener-side registration is this task's, exercised directly).
## Summary
Implemented the forwarded op's SERVING side (the listen-opener half,
ADR-008 §3 as amended) per the task: `src/producer.rs` —
`ForwardedHandler` (the injected assembly handler: parsed params +
channel `BiStream`; the assembly owns the pump, correlating by the
`listen` token against its waiting-listener bookkeeping);
`make_forwarded_op_handler` (the built-in no-establisher wrapper:
deny-unknown params parse — a malformed open fails loud → early
return → teardown-at-birth reaps the channel, no phantom; `accept_bi`
FIRST per R-02 telemetry; the injected handler's `JoinHandle` awaited
inline); `register_tunnel_forwarded_openable` (`register_openable` —
no establisher, the data plane starts immediately; scope gate
`tunnel:forwarded` via `tunnel_forwarded_spec`). `src/params.rs` —
`listen_reply_fields` (the lenient, infallible reply projection:
unknown extra fields tolerated, a malformed shape logs loud and
surfaces both-`None`; shared with `TunnelListener`), delegating to the
landed `parse_listen_reply`. Re-exported through `lib.rs`.
Tests: 4 new unit tests in `params.rs` (full/bare/extra-field/malformed
reply projection) + 4 integration tests in `tests/producer_forwarded.rs`
(the serving-half e2e round-trip gate — reply projection → token
correlation → forwarded open → byte round-trip through the assembly
pump + the producer-side adopted pump; the FORBIDDEN scope gate —
`tunnel:open`-only identity, no channel allocated, handler never ran;
malformed params — both gates pinned loud, the serde-gated shape
tears down at birth; the correlation-miss teardown — the assembly's
rejection, drop the stream, the channel reaps, the invocation
witnessed). Verified: `cargo test` green (126 default; 146
`--all-features`), clippy `--all-targets -D warnings` clean (native +
wasm32), fmt clean, `wasm32-unknown-unknown` check passes (pure
protocol code; the default crate stays wasm-clean), `cargo doc
--no-deps` clean. CHANGELOG updated (Unreleased → Added).
+187
View File
@@ -0,0 +1,187 @@
---
id: tunnels/local-bind-halves
name: local feature — TCP bind halves + the bind-first assembly pieces
status: completed
depends_on: [tunnels/bindfirst-producer]
scope: narrow
risk: low
impact: component
level: implementation
tags: [local, tcp, bind, listener, feature-gated]
---
## Description
Extend the `local` feature (non-wasm, `tokio/net`-gated —
`tunnels/local-socket-halves` established the pattern) with the
bind-first shape's substrate half: a real TCP bind function
(`BindFn`-compatible) and an accept loop producing
`(peer, TargetHandle)` pairs, plus the direct-op convenience dial
closure composition (the `DialFn` the direct op shares with the base
op — likely already satisfied by `local_dial`; verify, don't
duplicate).
### The bind half
```rust
/// The real-socket BindFn source (the `local` feature's shape 3
/// backing): bind `addr` (port 0 = OS-chosen), report the REAL
/// bound address (the `bound` reply field's source — ADR-008 §2),
/// expose the accept loop that feeds the dispatcher.
pub async fn bind_tcp_first(addr: &str)
-> Result<BoundListener, TunnelEstablishError>;
pub fn local_bind_first() -> BindFn; // renders via bind_tcp_first
```
- `BoundListener.listener` carries the `TcpListener` (type-erased in
the plan — `tunnels/bindfirst-producer`'s shape note); the accept
loop is assembly code composed ON it — the protocol crate stays
socket-blind, so the loop lives here (`local` = substrate code,
ADR-004):
- **`TcpListenerHalves` already has an accept loop** (`accept_loop`
feeding an `AcceptQueue` — the accept-first shape). The bind-first
shape needs a DIFFERENT loop: per-accept it must produce
`(peer_addr, TargetHandle)` for the accept dispatcher (which opens
forwarded channels per accept), not feed a queue for the
establisher to pop. Add a sibling method/struct (e.g.
`TcpListenerHalves::accept_pairs` or a new
`BindFirstAcceptLoop` adapter) — decide the honest shape, document
in Notes. The peer address rides from `accept()`'s return value
(reply#2 fidelity's source — ADR-008 §3's `peer` carrier).
- The direct-op `DialFn`: `local_dial()` already renders
`(Substrate, backing)` → halves with the UDP framed adapter — the
direct establisher calls the SAME closure with the rendered target
string. Verify the UDP wrap happens INSIDE the closure (it does —
review 001 U-1) so direct-udp gets the framed adapter free; add a
test pinning direct-udp-through-`local_dial` framing if missing.
### Tests (`tests/`, real sockets — the `local` suite's posture)
- Bind port 0 → `bound` reports the REAL OS-chosen address (the
reply#1 fidelity pin).
- Two clients dial the bound address → two accepts → two forwarded
channels (via the bind-first harness) → both pump independently
(peer fidelity: each forwarded open carries the REAL accepted
peer).
- Unix-domain bind (OQ-TN-14 parity): `bind_unix_first`-equivalent —
decide whether unix bind-first ships in this task or is deferred
with a note (the wire enum already carries unix; the local dial
path exists — the bind path is the same shape; prefer shipping
both, matching dial parity).
## Acceptance Criteria
- [x] `bind_tcp_first` + the accept-pairs loop (peer + halves per
accept) behind `local`
- [x] The bound address is the REAL OS-chosen one (port 0 resolved —
pinned by test)
- [x] Direct-op `DialFn` reuse verified (no duplicate dial code;
UDP framed-adapter wrap composes)
- [x] Unix bind-first parity decided + shipped or explicitly deferred
with rationale
- [x] `cargo test --all-features` green; default-crate wasm32 checks
still clean (the feature gate holds)
- [x] Clippy/fmt clean (both feature sets)
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§1 shape 3 — the bind-first establisher; §3 the peer carrier)
- docs/architecture/producer.md §The Registration API (`BindFn` shape)
- tasks/tunnels/local-socket-halves.md (the `local` pattern + the
existing `bind_tcp`/`TcpListenerHalves`/`AcceptQueue` accept-first
shapes)
- tasks/tunnels/bindfirst-producer.md (the `BindFn`/
`BindFirstAcceptLoop` contracts this implements)
- OQ-TN-14 (unix ships with `local` v1; stdio out — alktty's)
## Notes
- **The task sketch's `bind_tcp_first(addr: &str)` signature held** —
the committed [`BindFn`] contract takes `(substrate, backing)`; the
substrate dispatch lives inside [`local_bind_first`] (tcp →
[`bind_tcp_first`], unix → [`bind_unix_first`], udp → fails loud),
matching the type-erased closure precedent (the protocol crate stays
socket-blind; the closure is the assembly side).
- **The accept-pairs shape: a `*_pair` method, not a sibling loop
struct** — [`TcpListenerHalves::accept_pair`] (one accept per call →
`(peer, halves)`). The task floated `accept_pairs` or a
`BindFirstAcceptLoop` adapter struct; one method is the honest shape:
the DISPATCHER owns the loop pacing (the committed
`BindFirstAcceptLoop` contract is one closure call = one accept), so
the substrate half is just the single-accept step with the peer
attached. `local_bindfirst_accept_loop()` is the adapter — it wraps
the `*_pair` methods with the downcast (the assembly side of the
type-erased injection; a foreign listener type fails loud as
`handler_error` — pinned by test). The accept-FIRST
[`TcpListenerHalves::accept_loop`] (queue-feeding) is untouched.
- **UDP bind-first is NOT here — refused loud** (a decision the task
left open): a UDP socket has no accept, so the bind-first shape is
not DEFINED for udp; [`local_bind_first`] maps `Substrate::Udp` to a
`handler_error` rather than faking a listener (pinned by
`udp_bind_first_fails_loud`). The per-peer associate shape (an
accept-analogue for UDP) would be a wire/protocol design decision —
an ADR, if it ever ships. The direct/forward udp dial shapes cover
the substrate.
- **Unix bind-first SHIPPED** (the task's preferred posture — OQ-TN-14
dial parity): `bind_unix` + `UnixListenerHalves` (a sibling of
[`TcpListenerHalves`]: `local_addr`, the queue-feeding
`accept_loop`, and the bind-first `accept_pair`) +
[`bind_unix_first`]. The bind-first `accept_pair`'s peer is the
ACCEPTED client's bound path; an unnamed (autobind) client fails
loud — the forwarded op's `peer` param is required on the wire, an
addressless peer cannot be represented (ADR-008 §3). The unix
parity test binds the CLIENT to a named path first (socket2 — std
has no `UnixStream::bind`), making the peer representable.
- **`bind_unix` maps bind failure to `ResourceShortage`** (matching
[`bind_tcp`]'s posture — the held-resource class), not
`DialFailed`: the bind IS the dial for the establisher's error table
(shape-3: unbindable/held → `dial_failed`-class rides the
establisher's passthrough), but the substrate function's own class
follows the sibling bind functions.
- **Direct-op `DialFn` reuse: verified, no duplication** —
[`local_dial`] is unchanged and shared; the UDP wrap happens INSIDE
the closure (review 001 U-1). The missing pin was added:
`direct_udp_through_local_dial_round_trips_framed` (tests/
local_halves.rs) drives `TunnelSession::open_direct` +
`Substrate::Udp` through [`local_dial`] against a REAL udp echo
socket — datagram boundary preserved through the direct op's
establisher path.
## Summary
Implemented the `local` feature's bind-first substrate half (shape 3,
ADR-008 as amended) per the task: `src/local/mod.rs` — `bind_tcp_first`
(the real-socket [`BindFn`] source: port 0 resolved via `local_addr`,
the `bound` reply field's data), `TcpListenerHalves::accept_pair` (one
accept → `(peer, halves)` — the peer rides `accept()`'s return, the
forwarded op's `peer` carrier), `bind_unix` + `UnixListenerHalves` +
`bind_unix_first` (unix bind-first parity SHIPPED — OQ-TN-14; unnamed
peer fails loud, the forwarded `peer` param requires an address),
`local_bind_first()` (the [`BindFn`] convenience: tcp/unix dispatch,
udp fails loud — no accept on a UDP socket, the shape is not defined
there, never a fake listener) and `local_bindfirst_accept_loop()` (the
[`BindFirstAcceptLoop`] convenience: downcasts the plan's opaque
listener to the module's wrappers, one pair per call; a foreign
listener fails loud). The accept-FIRST shapes (`accept_loop` +
`AcceptQueue`) are untouched.
Tests: `tests/local_bindfirst.rs` (4, `#![cfg(feature = "local")]`) —
the tcp e2e over REAL sockets (bind port 0 → the reply's `bound` is
the REAL OS-chosen address the test then DIALS — a fake bound would
strand the dial; two clients → two accepts → two forwarded channels →
independent interleaved pumps, each forwarded open carrying the REAL
accepted peer, distinct ports), unix parity (real socket path in
`bound`; a named-path client connects; the forwarded open carries the
REAL peer path), the udp refusal, and the downcast-miss probe. Plus
`direct_udp_through_local_dial_round_trips_framed` in tests/
local_halves.rs — the direct-op `DialFn` reuse pin (the framed udp
adapter composes through the direct op; review 001 U-1).
Verified: `cargo test` (default) and `cargo test --all-features` green
(46 lib + 151 integration total; the `local` suites 20 + 4), clippy
`--all-targets --all-features -D warnings` clean, fmt clean,
`wasm32-unknown-unknown` check passes (the default crate stays
wasm-clean; the bind-first half lives behind `local`, non-wasm by
design).
+134
View File
@@ -0,0 +1,134 @@
---
id: tunnels/review-002-coverage-gaps
name: "G-4 + G-5 — close the remaining cheap coverage gaps (unix accept-loop test, unnamed unix fail-loud guards)"
status: completed
depends_on: []
scope: narrow
risk: low
impact: component
level: implementation
tags: [tests, local, unix, coverage, review-002, review-remediation]
---
## Description
The two remaining cheap-to-close coverage gaps from review 002's
coverage appendix (`docs/reviews/
002-graduation-implementation-review.md`), batched — both are
`local`-feature test additions (no protocol-layer changes).
### G-4 — `UnixListenerHalves::accept_loop` never driven
`src/local/mod.rs:591-609` has zero coverage: no test constructs it
(the unix dial test hand-rolls its own echo listener; the bind-first
unix test uses `accept_pair`). It is the unix parity twin of the
covered `TcpListenerHalves::accept_loop` (hit at ~107-125) — 15 lines
of the same shape, and the largest single uncovered block in the
crate.
Test: mirror `tests/producer_listen.rs`'s
`accept_loop_stops_feeding_after_close_and_maps_the_drop` with
`bind_unix` + a temp path — closes the entire 593-607 range and
exercises `UnixListenerHalves` beyond the bind-first path. Low value
(copy-shaped) but the largest single uncovered block.
### G-5 — the unnamed-peer/unnamed-bind fail-loud arms (unix)
Two fail-loud guards the ADR pins (ADR-008 §3 — "a producer that
cannot determine the peer does not serve a bind-first listener over
that substrate") never fire:
- `src/local/mod.rs:626-633` — the `accept_pair` unnamed-peer
`handler_error` (an autobind client)
- `src/local/mod.rs:578-583` — the `local_addr` unnamed-bind error
The existing unix bind-first test binds a named client socket
deliberately (the parity case); no test binds an abstract/autobind
unix listener.
Test: bind an abstract unix listener (Linux: a leading-`\0` path via
`socket2` — already a `local` dependency, or `UnixListener::bind` on
an unnamed autobind socket), call `local_addr()` expecting the
"no filesystem path" `handler_error`, and drive one autobind-client
accept through `accept_pair` expecting the unnamed-peer error. Cheap,
deterministic, and it pins the two ADR sentences the doc comments
promise.
### Explicitly NOT in this task (review guidance)
- **G-7** — kernel-dependent readiness arms; leave uncovered.
- **G-6** — the UDP empty-resolve arm; only if the `ResolveFn` seam
refactor is ever wanted (optional, separate decision — do not do it
here).
- **G-1/G-3** — unreachable-by-construction serde/JoinError arms;
leave uncovered.
## Acceptance Criteria
- [ ] The unix `accept_loop` test added (G-4 — the 591-609 range
covered)
- [ ] The abstract-listener test added (G-5 — both fail-loud arms
covered: `local_addr` unnamed-bind + `accept_pair`
unnamed-peer)
- [ ] `cargo test --features local` green; clippy `--all-targets
-D warnings` clean (native + wasm32); fmt clean; the default
crate stays wasm-clean
## References
- docs/reviews/002-graduation-implementation-review.md §G-4, §G-5
(the coverage appendix)
- src/local/mod.rs:591-609 (`UnixListenerHalves::accept_loop`),
578-583 (`local_addr` unnamed-bind), 626-633 (`accept_pair`
unnamed-peer)
- tests/producer_listen.rs (the accept-loop test shape to mirror)
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md §3
(the fail-loud sentences the tests pin)
## Notes
> Agent fills during implementation.
### Implementation (2026-09-20)
**G-4** — `unix_accept_loop_stops_feeding_after_close_and_maps_the_drop`
in `tests/producer_listen.rs` (mirroring the TCP twin above it):
`bind_unix` + a temp path under the test's own pid-scoped dir, the
listener's `accept_loop` spawned against a pre-closed `AcceptQueue`,
one unix client connect, and the loop's `handler_error` exit asserted
within a 5s bound. Covers the full 591-609 range (the unix
accept-first loop was previously never driven).
**G-5** — `unix_unnamed_bind_and_unnamed_peer_fail_loud` in
`tests/local_bindfirst.rs` (`#[cfg(target_os = "linux")]` — abstract
addresses + autobind are Linux-shaped). Both fail-loud arms pinned via
the crate's own public API (no private-constructor seam needed):
- **Unnamed bind**: `bind_unix("")` — tokio passes the empty path
through to `bind(2)`, which the kernel autobinds (addlen ==
`offsetof(sun_path)`, an abstract name, no filesystem path) —
`local_addr` maps it to the "no filesystem path" handler error
(the 578-583 arm). Probed on this host: the bound addr reports
`as_pathname() == None`.
- **Unnamed peer**: a socket2 client bound to the empty abstract
sockaddr (the autobind shape — verified `Ok` on this kernel)
connects to a NAMED `bind_unix` listener; `accept_pair` refuses
with the unnamed-peer handler error (the 626-633 arm). A plain
unbound client behaves identically: mio's UDS accept maps ANY
abstract-namespace peer (`sun_path[0] == 0`, which includes the
autobind assignment) to an unnamed addr, so the fail-loud guard
catches both client shapes through tokio's accept.
`bind_unix_first` itself stays untested for the unnamed-bind arm at
the establisher level (it composes `bind_unix` + `local_addr` — the
arm is the same code path the test drives directly); the local_addr
assertion covers the composition's error leg.
**Verification** (all green):
- `cargo test` (135) + `cargo test --features local` (162 passed,
0 failed)
- `cargo clippy --all-targets -- -D warnings` (default + `local`)
- `cargo fmt --check`
- `cargo check` + `cargo clippy -D warnings --target
wasm32-unknown-unknown` (the default crate stays wasm-clean)
+120
View File
@@ -0,0 +1,120 @@
---
id: tunnels/review-002-doc-sync
name: "D-1 + D-2 — sync wire.md's schema bounds + ADR-007's oneOf→if/then + the establisher-time→registry-gate sentence"
status: completed
depends_on: []
scope: narrow
risk: low
impact: project
level: implementation
tags: [docs, wire-format, adr-007, review-002, review-remediation]
---
## Description
Two doc-only review 002 findings
(`docs/reviews/002-graduation-implementation-review.md` §D-1, §D-2)
batched: the shipped code is the wire-stable side and is correct; the
*docs* lag it. Wire-contract-adjacent — do this before the first
consumer (alksocks) ships.
### D-1 — wire.md omits the `port` bounds the shipped schema enforces
The shipped direct-op and forwarded-op schemas pin
`"port": {"minimum": 0, "maximum": 65535}` plus
`"additionalProperties": false` on the target/peer objects
(`src/params.rs:208-222, 453-467`), and `listen` with
`"minimum": 1` (`src/params.rs:442`). wire.md (the normative WHAT)
says only `port: <u16>` and "integer > 0" in prose — it does not
record the bounds as schema properties, nor the
`additionalProperties: false` gate that makes cross-shape targets
fail the registry's `INVALID_INPUT` class BEFORE the establisher (the
behavior `malformed_target_is_schema_class_and_leaves_no_channel`
pins on the wire). The BAST doc (`bast.md`) covers only the
data-plane codec, not the params schemas — no second source records
these bounds.
Fix: record the exact schema constraints in wire.md's §The Direct Op
and §The Forwarded Op (the `port` bounds, `additionalProperties:
false` per branch, the `listen` `minimum: 1`, and the gate-ordering
sentence). The risk addressed: a future consumer implementing from
wire.md alone accepting a shape the reference producer rejects (or
vice versa).
### D-2 — ADR-007's `oneOf` wording is stale; the "validated at establisher time" sentence contradicts the shipped design
1. ADR-007 §2 (line ~82) says "the target sub-schema is per-substrate
(`oneOf` on the sibling `substrate` value, validated at establisher
time — see Consequences)". The shipped schema (and wire.md §The
Forwarded Op, and the unit tests pinning it) use **`allOf` of two
`if`/`then` branches** instead. `oneOf` of full-target-schemas
would actually be WRONG here (a `unix`-substrate open would need to
fail the `tcp` branch — plain `oneOf` on sibling-conditioned
subschemas is awkward; `if`/`then` composes correctly with the base
`properties`/`required`). The code is right; the ADR's one-word
schema-structure reference is stale.
2. The same line's "validated at establisher time" contradicts the
shipped design and wire.md's own text: the test
(`malformed_target_is_schema_class_and_leaves_no_channel`) proves
the REGISTRY gate fires first (the establisher's dial never runs).
The ADR/wire.md sentence should read "validated registry-side (the
schema gate), before the establisher; re-checked by serde at
establisher time as the backstop" — which is exactly what
`src/params.rs`'s doc comments already say.
Fix: a one-line ADR-007 erratum (or an amendment note — house style
has precedent: ADR-008 carries numbered amendments) replacing
`oneOf` with the shipped `allOf`-of-`if`/`then` structure, plus the
corrected gate-ordering sentence; mirror the same
gate-ordering correction in wire.md §The Direct Op's
"validated at establisher time" sentence if it says the same thing
(verify both files while in there). No code changes.
## Acceptance Criteria
- [ ] wire.md records the shipped schema bounds (`port`
min/max, `additionalProperties: false`, `listen`
`minimum: 1`) in both op sections
- [ ] ADR-007 §2 corrected: `oneOf` → the shipped `allOf`/
`if`/`then` structure (erratum or amendment, house style)
- [ ] The "validated at establisher time" sentence corrected to
"registry-side schema gate first; serde backstop at
establisher time" in ADR-007 (and wire.md if it repeats it)
- [ ] `cargo doc --no-deps` clean (docs changed — the verification
battery's doc gate)
## References
- docs/reviews/002-graduation-implementation-review.md §D-1, §D-2
- docs/architecture/wire.md (§The Direct Op, §The Forwarded Op)
- docs/architecture/decisions/007-direct-open-op.md (§2, line ~82)
- src/params.rs:208-222, 442, 453-467 (the shipped schemas the docs
must match)
## Notes
> Agent fills during implementation.
## Implementation Notes (2026-09-20)
- **D-1 (wire.md §The Direct Op + §The Forwarded Op)**: both sections
now record the exact shipped schema constraints — the per-branch
target/peer objects (`type: object`, `additionalProperties: false`,
`port` bounds `minimum: 0`/`maximum: 65535`, the `unix` `path`
branch) and the forwarded `listen` `minimum: 1` + its serde-pin
backstop — with the gate-ordering sentence (registry-side schema
gate before the establisher, serde backstop) and the
cross-shape-INVALID_INPUT behavior the integration test pins. No
second source existed for these bounds (bast.md covers only the
data-plane codec); wire.md now is the complete record.
- **D-2 (ADR-007 §2)**: the stale sentence corrected in place
(`oneOf` → the shipped `allOf` of two `if`/`then` branches;
"validated at establisher time" → registry-side gate + serde
backstop), plus a dated **Erratum** section documenting why `oneOf`
would actually be wrong (exactly-one-branch-matches does not
compose with the base `properties`/`required`) and citing the
pinning test. wire.md §The Direct Op's mirrored sentence corrected
in the same edit. ADR-008 was checked and needs no change (it never
stated the `oneOf`/establisher-time claim).
- Verification: `cargo doc --no-deps` clean (0 warnings);
`cargo fmt --check` clean.
@@ -0,0 +1,113 @@
---
id: tunnels/review-002-g2-graceful-teardown-observation
name: "G-2 — observe close()'s EOF sentinel at the far accept dispatcher (+ N-2's write-happy-peer teardown pin)"
status: completed
depends_on: []
scope: narrow
risk: low
impact: component
level: implementation
tags: [tests, consumer, bind-first, teardown, review-002, review-remediation]
---
## Description
Review 002 coverage gaps G-2 and the N-2 branch note, batched (the
review itself suggests the same harness shape covers both — see its
§Branch-coverage notes, last bullet).
### G-2 — `TunnelListener::close`'s `shutdown` arm never observed at the far end
`src/consumer.rs:529-531`: `close()` runs `write.shutdown().await`
(the explicit EOF sentinel) then reaps — but no test currently
observes the FAR accept dispatcher's idle-read firing from a `close()`
(the graceful path). The Drop path (implicit EOF → far teardown) IS
observed end-to-end
(`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`), and
the close path is pinned only at the reap level
(`close_and_drop_reap_the_listen_channel`) — the sentinel's far-side
effect for `close()` specifically is asserted nowhere: the "the pushed
accept was never consumed" assertion runs only in the Drop-shaped
tests.
**This is the only coverage gap touching a *promised* behavior**
(ADR-008 §1's teardown contract, graceful half) with no direct
observation.
Test: mirror `bindfirst_full_flow…`'s harness (the
`tests/consumer_listener.rs` bind-first topology) with a `close()`
instead of a drop — open against a bind-first producer, push an
accept, `listener.close().await`, then assert the far accept loop is
gone (the same queue-len / never-consumed assertion the Drop tests
use) and the serving-side listen channel reaped.
### N-2's testable residue — the write-happy-peer teardown posture
The idle-stream EOF watch (`src/producer.rs:781-794`) triggers
teardown on `Ok(0)`, `Err(_)`, AND `Ok(1..)` — a misbehaving opener
that writes to the idle listen channel also tears the listener down
(fail-closed; the channel is not a data plane; the doc comment owns
this). N-2 records the shape as correct, but the review's
branch-coverage notes flag that the `Ok(_)` (non-empty read) arm is
untested: a test would write a byte to the idle channel and assert
far teardown, pinning the "any non-empty read tears down" posture.
Test: same harness — open the listen channel, write a byte into the
idle stream from the opener side, assert the accept loop cancels and
the listener tears down (the fail-closed posture).
## Acceptance Criteria
- [x] The graceful-`close()` far-teardown test added (the sentinel's
far-side effect observed)
- [x] The write-happy-peer teardown test added (the `Ok(_)` idle-read
arm pinned)
- [x] `cargo test --features local` green; clippy `--all-targets
-D warnings` clean (native + wasm32); fmt clean
## References
- docs/reviews/002-graduation-implementation-review.md §G-2 (Coverage
appendix), §N-2, and §Branch-coverage notes (last bullet)
- src/consumer.rs:529-531 (`TunnelListener::close`), src/producer.rs
(the idle-stream EOF watch)
- tests/consumer_listener.rs (the harness shape to mirror)
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md §1
(the teardown contract's graceful half)
## Notes
> Agent fills during implementation.
Filled (2026-09-20):
- Both tests landed in `tests/consumer_listener.rs` — no `src/`
changes needed (pure coverage additions):
- `graceful_close_eof_sentinel_observed_at_far_accept_dispatcher`
(G-2): mirrors the bind-first full-flow harness with a `close()`
instead of a drop. Proves the accept loop alive first (a consumed
accept), then asserts after `listener.close().await` that the
serving-side listen channel reaped AND a follow-on pushed accept
is never consumed (the same queue-len assertion the Drop-shaped
tests use) — the sentinel's far-side effect for the graceful path.
- `write_happy_peer_on_idle_listen_channel_tears_down_far_listener`
(N-2 residue): opens the listen channel via the raw client
surface (`open_channel_with_reply`, both halves held alive), proves
the accept loop alive, writes one byte into the idle stream, and
asserts the fail-closed teardown (channel reaped + the follow-on
accept never consumed). The typed `TunnelListener` never exposes
its idle write half by contract, so the misbehaving-opener shape
requires the raw client — that shape difference is the point.
- Harness notes: the write-happy test installs NO forwarded-op
serving stub — the pre-write consumed accept fails its forwarded
open (per-accept isolation), which is fine: the assertion only
needs the queue emptied (loop alive), and the isolation path is
pinned by `open_only_opener_still_opens_listen_but_forwarded_fails`.
The listener session's own `_recv` half is dropped in the
write-happy test (the write half alone keeps the pump alive; the
recv half is the opener's unused read side of the idle channel).
- Verification: `cargo test --features local` green (163 tests across
all targets + 2 doc-tests), `cargo clippy --all-targets --features
local -- -D warnings` clean, `cargo clippy --target
wasm32-unknown-unknown -- -D warnings` clean, `cargo check --target
wasm32-unknown-unknown` clean, `cargo fmt --check` clean.
@@ -0,0 +1,152 @@
---
id: tunnels/review-002-m1-substrate-fallback
name: "M-1 remediation — the accept dispatcher's silent substrate fallback (stamp the plan, fail loud)"
status: completed
depends_on: []
scope: narrow
risk: low
impact: component
level: implementation
tags: [producer, bind-first, review-002, review-remediation]
---
## Description
Review 002 finding M-1
(`docs/reviews/002-graduation-implementation-review.md` §M-1): the
bind-first accept dispatcher's substrate extraction
(`src/producer.rs:766-768`, inside `make_bindfirst_accept_handler`)
silently falls back to `Substrate::Tcp` when the second
`serde_json::from_value::<TunnelParams>` parse fails:
```rust
let substrate = serde_json::from_value::<TunnelParams>(input.clone())
.map(|p| p.substrate)
.unwrap_or(Substrate::Tcp);
```
In the wired path the fallback is unreachable (the registry schema gate
and the establisher's `parse_params` both validate first), but the
shape itself is wrong: a future drift would silently stamp `Tcp` into
every forwarded open — for a UDP listen tunnel that is a
raw-vs-framed data-plane mismatch on the opener side (the exact
invariant class review 001's U-1 was about), degrading silently
instead of loudly.
### The fix (decide deliberately, then implement)
The review offers two shapes; **the second is recommended** (the
review calls it strictly better, and the pre-consumer window is open —
`BindFirstPlan` has no external consumer yet):
1. Log-loud-and-return on parse failure (teardown-at-birth — the
same posture as every other early return in the file). Smaller,
but keeps the second parse and the fallback branch.
2. **Stamp the substrate into `BindFirstPlan` at establisher time and
drop the second parse entirely** — one parse, one source of truth,
no fallback branch at all. The establisher already parsed the
params successfully; the substrate is per-open-static; the plan is
already the per-open carrier. `BindFirstPlan` gains a
`substrate: Substrate` field (a plan-payload change — cheap now,
pre-consumer); the dispatcher reads it from the plan it already
downcast.
If shape 2: the establisher's construction site
(`bindfirst_establisher`) carries the parsed substrate into the plan;
the per-dispatcher token-discipline test continues to pass; check the
`Debug` impl for `BindFirstPlan` still compiles (opaque listener
field) and the doc comment on the new field states why it rides the
plan (the dispatcher must not re-parse wire input).
### Tests
- Keep the existing suite green (the two-open token-discipline test
pins per-dispatcher stamping from its own plan — it now also pins
the substrate's origin implicitly).
- A failure-path test: with shape 2 there is no failure branch left
in the dispatcher (assert the removed fallback is gone — e.g. the
forwarded opens for a UDP bind-first listen carry `substrate: udp`
from the plan, not a re-parse). With shape 1: a malformed-input
accept teardown test (loud return, channel reaps, no phantom
channel).
## Acceptance Criteria
- [x] The silent `unwrap_or(Substrate::Tcp)` fallback is gone from
`make_bindfirst_accept_handler`
- [x] The chosen shape documented in this task's Notes (plan-stamp vs
log-and-return, with the rationale)
- [x] A test pins the forwarded-op substrate source (plan-stamped, or
the loud teardown on parse failure)
- [x] `cargo test` green; clippy `--all-targets -D warnings` clean;
fmt clean; wasm32 check + clippy clean
## References
- docs/reviews/002-graduation-implementation-review.md §M-1 (the
finding, both fix shapes, and why not fixed inline)
- src/producer.rs — `BindFirstPlan` (~line 97), the establisher, and
`make_bindfirst_accept_handler` (~line 755)
- tasks/tunnels/bindfirst-producer.md (the original implementation
task + Notes on the plan's shape)
## Notes
> Agent fills during implementation.
**Chosen shape: 2 — stamp the plan** (the review's recommendation).
Rationale: one parse (the establisher's `parse_params` — which
rejects malformed input with `handler_error` before any channel
exists), one source of truth; the substrate is per-open-static and
the plan is already the per-open carrier; the fallback branch is not
just guarded against, it is REMOVED — a future drift cannot degrade
silently because there is no fallback to degrade into. Pre-consumer
window used (`BindFirstPlan` has no external consumer yet — a
plan-payload change is cheap).
Implemented:
- `BindFirstPlan` gains `substrate: Substrate` (doc comment states
why it rides the plan — the dispatcher must not re-parse wire
input; the removed fallback's data-plane mismatch risk is cited).
The `Debug` impl stamps it (the opaque listener field stays).
- `bindfirst_establisher` stamps the parsed `params.substrate` into
the plan at construction time.
- `make_bindfirst_accept_handler`: the second
`serde_json::from_value::<TunnelParams>` parse and the
`unwrap_or(Substrate::Tcp)` fallback are gone; the dispatcher
reads `plan.substrate` from the plan it already downcast. The
handler's `input` param is now unused (`_input`).
Tests:
- Existing suite kept green — the two-open token-discipline test
(`token_discipline_two_opens_distinct_tokens`) still pins
per-dispatcher stamping from its own plan.
- `bindfirst_plan_downcasts_through_channel_plan` (unit): the plan's
substrate rides the downcast (`Substrate::Udp` constructed →
recovered).
- `bindfirst_establisher_flow_end_to_end` (unit): the establisher
stamps the parsed substrate into the plan (`Tcp` input →
`plan.substrate == Tcp`).
- **`forwarded_opens_carry_plan_stamped_substrate` (integration,
the M-1 pin):** a UDP bind-first listen over the wire — the
forwarded open the dispatcher makes carries `substrate: udp` (the
plan's stamp), observed through a substrate-recording
forwarded-op stub on the opener side. The removed fallback would
have stamped `Tcp`; the assert is `vec![Substrate::Udp]`.
Docs synced: `docs/architecture/producer.md` (§3 — the plan carries
`{listener, token, substrate}`; §The Forwarded Op item 1 — the
substrate also comes from the plan, never re-parsed),
`docs/architecture/decisions/008-listen-metadata-forwarded-op.md`
(§1 — the plan's substrate stamp + the removed fallback, review 002
M-1 referenced).
Verification (all green): `cargo test` (145 tests across the lib +
10 integration binaries), `cargo test --all-features` (153),
`cargo clippy --all-targets -- -D warnings`,
`cargo clippy --all-targets --all-features -- -D warnings`,
`cargo fmt --check`, `cargo doc --no-deps`,
`cargo check --target wasm32-unknown-unknown` +
`cargo clippy --target wasm32-unknown-unknown -- -D warnings`.
+179
View File
@@ -0,0 +1,179 @@
---
id: tunnels/review-002-recorder
name: "N-1..N-7 — record the review's no-action notes in the code/ADR trail (N-4's ADR line + N-3's telemetry note)"
status: completed
depends_on: []
scope: narrow
risk: trivial
impact: isolated
level: implementation
tags: [docs, notes, review-002, review-remediation]
---
## Description
Review 002's [note] findings
(`docs/reviews/002-graduation-implementation-review.md` §N-1..N-7)
are correct-as-is with **no code action** — but two of them ask for a
durable trail beyond the review doc itself, and the rest are recorded
here as the tracker (the review's own remediation list only orders
M-1/D-*/the coverage gaps).
### The two notes wanting a durable trail
- **N-3** — `make_forwarded_op_handler` parses params BEFORE
`accept_bi` (`src/producer.rs:993-1006`), so the R-02 accepted-flag
telemetry reads `false` for a malformed-params open — the
birth-teardown hint fires for a handled-rejection case. The order
is deliberately the better one (accepting first would hold a stream
open for a params set the producer already got wrong). Record one
sentence in the code's doc comment ("the accepted-flag telemetry
reads false here by design — a malformed-params open IS a
birth-teardown shape") so a future alkcall reviewer doesn't flag
the flag.
- **N-4** — `SubstrateAddr`'s untagged leniency is schema-gated (the
schema's `additionalProperties: false` per branch is the real
gate). Two latent sharp edges to keep in view, in the ADR trail:
1. The establisher-level serde backstop keys `render_target` off
the sibling `substrate` — a cross-shape target that sneaked past
a looser registry would render *something* rather than fail.
Worth an ADR line if a future consumer runs a schema-less
registry variant. Add the line to ADR-007 (Consequences or the
wire-stable section) while the doc-sync task's context is fresh
— one sentence: "the serde backstop renders the sibling-keyed
target; cross-shape garbage is the schema's absence, not a
serde failure."
2. If `SubstrateAddr` grows a variant whose shapes overlap (e.g. a
future `{host, port, flowlabel}`), untagged disambiguation
starts ordering — switch to an internally-tagged form at that
point. This is additive-only, pre-consumer; the ADR line should
say "don't let it happen by accident."
### The no-action notes (recorded here as the tracker)
- **N-1** — `parse_listen_reply`'s positive-`listen` requirement
matches the forwarded params' wire contract (`minimum: 1`);
JSON-null-as-absent is the standard lenient reading; the struct
`deny_unknown_fields` warning in the doc comment is doing real
work — keep both. No action.
- **N-2** — the idle-stream EOF watch treats ANY byte as teardown
(fail-closed). Correct; the ADR sentence is thinner than the
implementation — the write-happy-peer test pin is tracked in
`tunnels/review-002-g2-graceful-teardown-observation`. Optionally
widen ADR-008 §1's "its EOF cancels the loop" sentence to name the
any-byte posture while editing (coordinate with
`tunnels/review-002-doc-sync` if both touch ADR-008).
- **N-5** — the token-mint discipline is sound; the 256-channel
per-connection cap makes the wrap-collision math even more
favorable than the doc comment states. No action (optionally
tighten the doc comment's math note).
- **N-6** — the sleep-then-assert test pattern
(`tests/consumer_listener.rs:276` + siblings) is the established
house pattern; if CI ever flakes, the fix is a bounded poll loop on
`channel_ids()` extended to the arrivals. No action now.
- **N-7** — `test_bind()`'s bare `Arc<u32>` listener is fine (the
downcast-miss probe covers the failure mode). No action.
### G-1/G-3 — the unreachable-by-construction arms (document-only)
Per the coverage appendix's recommendations: leave both uncovered,
noted here as the review's "unreachable error arm" record —
`TunnelSession::open`/`open_direct`/`TunnelListener::open` (+
`open_reverse_channel`'s twin) params-serialize closures (serde_json
cannot fail on these types) and `TunnelSession::join`'s pump-`Err`
arm (the pump handle is never aborted before `join` through the
public API). If a shared "unreachable error arm" prose note ever
lands in the test suite, these are its members. Optionally collapse
G-1's four duplicated closures into one `params_to_input` helper
(cosmetic; the review says fine to skip).
## Acceptance Criteria
- [ ] The N-3 telemetry-ordering sentence added near
`make_forwarded_op_handler`'s parse (doc comment)
- [ ] The N-4 ADR lines added to ADR-007 (the serde-backstop render
+ the overlapping-variants caution)
- [ ] The N-2 ADR-008 §1 widening applied (or explicitly declined
with rationale in Notes)
- [ ] The no-action notes' disposition recorded in this task's Notes
section (so the tracker carries the review's decisions)
## References
- docs/reviews/002-graduation-implementation-review.md §N-1..N-7,
§G-1, §G-3
- src/producer.rs:993-1006 (`make_forwarded_op_handler`'s
parse-before-accept ordering)
- docs/architecture/decisions/007-direct-open-op.md,
docs/architecture/decisions/008-listen-metadata-forwarded-op.md
- src/params.rs:329-339 (`parse_listen_reply`), src/producer.rs:97
(`BindFirstPlan`), src/consumer.rs (the unreachable arms)
## Notes
> Agent fills during implementation.
## Implementation Notes (2026-09-20) — the review's no-action dispositions, recorded
The two durable-trail items landed as specified; the no-action notes'
dispositions follow (this section is the tracker the task asked for).
### Landed
- **N-3**: the parse-before-accept telemetry sentence added to
`make_forwarded_op_handler`'s doc comment
(`src/producer.rs`) — "the accepted-flag telemetry reads false here
by design; a malformed-params open IS a birth-teardown shape; do
not flag the flag (review 002 N-3)".
- **N-4**: both ADR-007 Consequences lines added — (1) the
`SubstrateAddr` untagged-leniency-is-schema-gated paragraph (the
sibling-keyed `render_target` backstop; "cross-shape garbage is the
schema's absence, not a serde failure"; the schema-less-registry
ADR-line-if-ever requirement) and (2) the overlapping-variants
caution (untagged disambiguation starts ordering → switch to an
internally-tagged form, deliberately, never by accident).
- **N-2 (ADR-008 §1 widening — APPLIED, not declined)**: §1's
accept-dispatcher bullet now states the any-byte idle-read posture
(`Ok(0)` / error / any non-empty read all tear down; fail closed;
the write-happy-peer test pins it) and names the `Ok(0)`-only
hang-on-write-happy-peer trap the narrower reading would produce.
Note: the doc-sync task and this task both touched ADR files but
never ADR-008, so no coordination conflict materialized.
- **N-5 (optionally tighten — done)**: `mint_token`'s doc comment now
records the review's cap analysis (the token is
per-producer-connection; the 256-channels-per-connection cap makes
the live-collision window unreachable by a wide margin).
### No-action dispositions (unchanged from the review)
- **N-1**: no action. `parse_listen_reply`'s positive-`listen`
requirement matches the forwarded params' `minimum: 1`; JSON-null-
as-absent is the standard lenient reading; the `deny_unknown_fields`
warning in `TunnelListenReply`'s doc comment is doing real work —
kept as-is.
- **N-2's code**: no action (fail-closed any-byte watch is correct as
shipped; only the ADR sentence was widened, above).
- **N-6**: no action. Sleep-then-assert is the established house
pattern (3x-repeat stable historically); if CI ever flakes on the
forwarded-arrival tests, the fix is a bounded poll loop on
`channel_ids()` extended to the arrivals — recorded here, not
implemented.
- **N-7**: no action. `test_bind()`'s bare `Arc<u32>` listener is
fine; the downcast-miss probe
(`accept_pair_loop_downcast_miss_fails_loud`) covers the failure
mode.
- **G-1/G-3 (the unreachable-by-construction arms)**: left uncovered,
per the review's recommendations — `open`/`open_direct`/
`TunnelListener::open` (+ `open_reverse_channel`'s twin)
params-serialize closures (serde_json cannot fail on these types)
and `join`'s pump-`Err` arm (unreachable through the public API; the
only path in is a panicking pump, out of contract). This section is
the "unreachable error arm" record the coverage appendix asked for;
the optional `params_to_input` helper collapse was skipped
(cosmetic; the review says fine to skip).
### Verification
- `cargo fmt --check`; `cargo clippy --all-targets -D warnings`
(native + wasm32); `cargo test` (all suites + doc-tests);
`cargo doc --no-deps` — all green.
+87
View File
@@ -0,0 +1,87 @@
---
id: tunnels/review-002-trivial-batch
name: "Trivial batch — D-3 (rename the overpromising schema test helper) + D-4 (trim the stale CHANGELOG preamble)"
status: completed
depends_on: []
scope: narrow
risk: trivial
impact: isolated
level: implementation
tags: [tests, changelog, review-002, review-remediation]
---
## Description
Two trivial review 002 findings
(`docs/reviews/002-graduation-implementation-review.md`) batched —
each a one-line-scale change; no behavior impact.
### D-3 — the test helper's name overpromises
`src/params.rs:628-637`: `jsonschema_valid` parses the value with
serde and pattern-matches the substrate string — it validates the
serde layer, NOT the JSON Schema (the registry compiles the real
validators; adding a `jsonschema` dev-dependency purely for this unit
test is not warranted). The call-site
`schema_rejects_unknown_substrate_value` therefore promises more than
it delivers; the sibling
`direct_target_schema_gates_shape_per_substrate` is honest (it does
structural schema assertions and says so).
Fix: rename `jsonschema_valid` → `serde_valid` (or inline the check)
and rename the call-site test
`schema_rejects_unknown_substrate_value` →
`serde_rejects_unknown_substrate_value` (its doc comment should say
"the serde layer; the registry's real schema gate is pinned
end-to-end by the integration probes
(`schema_rejects_unknown_substrate_loudly`,
`malformed_target_is_schema_class…`, the forwarded `listen: 0`
gate)"). Do NOT add a `jsonschema` dev-dependency.
### D-4 — the CHANGELOG "Unreleased" preamble is stale
`CHANGELOG.md`'s Unreleased section still opens with "implementation
is decomposed in `tasks/tunnels/` and lands in the next release…
the only behavior-affecting change so far (no wire or API change at
this level yet)" — written before the six graduation implementation
commits. The `### Added` section below it correctly lists all six
features; the preamble contradicts the section it introduces.
Fix: trim the preamble to a one-liner (e.g. "The graduation
implementation (ADRs 007/008) landed — see Added below." — final
wording agent's choice) or drop it.
## Acceptance Criteria
- [ ] The overpromising unit test/helper renamed (no `jsonschema`
dev-dependency added)
- [ ] The CHANGELOG Unreleased preamble trimmed or dropped
- [ ] `cargo test` green (the renamed test included); clippy + fmt
clean
## References
- docs/reviews/002-graduation-implementation-review.md §D-3, §D-4
- src/params.rs:628-637 (the helper + call-site)
- CHANGELOG.md (the Unreleased preamble)
## Notes
> Agent fills during implementation.
## Implementation Notes (2026-09-20)
- **D-3**: inlined the honest serde check directly into the renamed
test `serde_rejects_unknown_substrate_value` (the `jsonschema_valid`
helper deleted — it had exactly one call-site, and its
`matches!`-with-guard body was convoluted beyond its job). The doc
comment names the integration probes that pin the real registry-side
gate end-to-end. No `jsonschema` dev-dependency added (the
structural-assertion sibling `direct_target_schema_gates_shape_per_
substrate` is untouched and stays honest).
- **D-4**: trimmed the preamble to the one-liner ("The graduation
implementation … landed — see Added below."); the stale
`tasks/tunnels/` sentence and the "only behavior-affecting change"
claim are gone.
- Verification: `cargo fmt --check`, `cargo clippy --all-targets -D
warnings`, `cargo test` (86 default tests + 2 doc-tests) all green.
+234
View File
@@ -0,0 +1,234 @@
---
id: tunnels/tunnel-listener
name: TunnelListener — the listen-flow consumer session (ADR-008 §4)
status: completed
depends_on: [tunnels/bindfirst-params, tunnels/forwarded-receipt]
scope: moderate
risk: medium
impact: component
level: implementation
tags: [consumer, listener, listen-flow, teardown, tunnel-listener]
---
## Description
Implement `TunnelListener` (consumer.md §The Listen Flow — ADR-008
§4): the listen-flow sibling of `TunnelSession`. It OPENS a listen
tunnel (`channels/tunnel/sub` — the establisher shape is the
producer's choice, invisible here), reads the bind-first reply fields
(`bound`/`listen`), presents them, and owns the listen channel's
teardown (ADR-005's ownership posture extended to the listen flow).
Forwarded connections arrive as ordinary adopted-side `TunnelSession`s
on the opener's manager — `adopt` gains nothing.
### The type
```rust
pub struct TunnelListener {
channel_id: u32,
manager: Option<ChannelManager>,
bound: Option<SubstrateAddr>, // from the `bound` reply field
token: Option<u32>, // from the `listen` reply field
}
```
```rust
impl TunnelListener {
pub async fn open(client: &ChannelClient, params: TunnelParams)
-> Result<Self, TunnelOpenError>;
pub fn bound(&self) -> Option<&SubstrateAddr>;
pub fn listen(&self) -> Option<u32>;
pub async fn close(self) -> bool; // tear the listen channel down
// Drop: sync-reap, best-effort — ADR-005's Drop posture
}
```
- **`open`** — `open_channel_with_reply(OP_TUNNEL_OPEN, params,
TUNNEL_ALPN)` (the U-2 surface, landed alkcall 0.8.0): the full
reply arrives, `listen_reply_fields` extracts
`bound`/`listen`, `adopt`-free construction (the client already
adopted the channel — the streams come back from
`open_channel_with_reply`), and the listener holds the channel
entry. The listen channel's own `BiStream` is held but IDLE — the
listener session's drop/EOF is the teardown signal the far accept
dispatcher watches (ADR-008 §1's contract); `TunnelListener` never
pumps its own stream.
- **The held BiStream (the one new thing):** unlike
`TunnelSession` (which presents a data plane), a listen session's
stream has NO data-plane role — its only job is to EOF when the
session drops. Keep the stream (or its write half) alive in the
struct so the drop → EOF ordering is structural (the mux sender
drops → EOF sentinel). Do NOT expose the stream publicly —
pumping on a listen channel is meaningless (the far side's
dispatcher ignores it too).
- **`close(self)`/`Drop`** — abort nothing (no pump), reap the
adopted channel entry (`teardown_channel`). The far-side teardown
cascades (EOF → accept-loop cancel → listener drop). Forwarded
sessions are NOT owned by the listener (they arrive as separate
adopted sessions the assembly correlates + owns) — document the
split (listener teardown does not cascade to already-adopted
forwarded sessions; the far listener dying stops NEW accepts).
- **No `Clone`** — same posture as `TunnelSession` (aliasing would
alias teardown). Compile-assert with a doc-test like the session's.
- **`forwarded()`**: NOT a method on `TunnelListener` — forwarded
sessions arrive on the opener's manager as adopted channels the
assembly correlates via the `listen` token (consumer.md's posture;
the correlation registry is assembly-side). The type hands the
assembly the token (`listen()`); it does not own the receipt
queue. (The task sketch in consumer.md showed `listener.forwarded()`
— the honest shape is the assembly's map; document if a v1
convenience accessor is still wanted — decide, don't silently
drop.)
### The direct path (ADR-007's consumer ctor lives in `tunnels/direct-op`; this task adds nothing to `TunnelSession`)
Per ADR-008 §4: "`TunnelSession::adopt` gains nothing." Verify the
claim while implementing: a forwarded channel adopted by the opener's
assembly (`manager.adopt_channel` or `TunnelSession::adopt`) presents
the same session/data-plane/teardown semantics — pin it with a test
(adopt a forwarded channel, pump, teardown).
### Tests
- `open` against a bind-first producer (the harness from
`tunnels/bindfirst-producer`): reply fields surface via
`bound()`/`listen()`; teardown cascades (drop listener → far
listener gone → subsequent forwarded opens fail).
- `open` against a dial/listen producer: both fields `None` (the
Option posture).
- `close` → the listen channel reaps; `Drop` without close → reaped
(the ADR-005 leak-free guarantee, listen-flavored).
- Adopt-path pin: the forwarded session adopt + pump + teardown.
- Scope interaction: an opener identity with `tunnel:open` but NOT
`tunnel:forwarded` still opens the listen channel (that gate is on
the producer side) — forwarded opens to it then fail FORBIDDEN
(the per-accept isolation path from `tunnels/bindfirst-producer`).
## Acceptance Criteria
- [x] `TunnelListener::open` uses `open_channel_with_reply`;
`bound()`/`listen()` surface the reply fields (Option posture)
- [x] Teardown: `close`/`Drop` reap the listen channel; the held
BiStream's EOF is the structural teardown trigger; no
`Clone`
- [x] Forwarded sessions are assembly-adopted ordinary
`TunnelSession`s (adopt gains nothing — pinned by test)
- [x] Integration tests: bind-first e2e with the listener type,
teardown cascade, Option posture, adopt-path pin
- [x] Clippy/fmt clean; wasm32 check passes
- [x] `cargo test` green
## References
- docs/architecture/decisions/008-listen-metadata-forwarded-op.md
(§4 the consumer API; Amendment 1 — the token the `listen()` view
reads)
- docs/architecture/decisions/005-consumer-session-owns-teardown.md
(the ownership posture extended to the listen flow)
- docs/architecture/consumer.md §The Listen Flow
- tasks/tunnels/consumer-session.md (the TunnelSession shape this
siblings — close/join/Drop posture, the no-Clone assert)
- tasks/tunnels/bindfirst-params.md (the reply reader);
tasks/tunnels/forwarded-receipt.md (the serving half the tests
compose against)
## Notes
- **The held stream is the write half (`MpscSendStream`), not a
`BiStream`.** The task sketch said "hold the stream (or its write
half) alive"; the honest shape is the write half alone: the mux
sender's drop is the structural EOF trigger (the mux pump writes
the implicit-EOF chunk for the channel — REQ-CH-01's implicit path —
which the far accept dispatcher's idle-stream watch observes as the
zero-byte sentinel). Holding the read half too would add nothing
(nothing ever reads it) and would keep the demux receiver alive.
`close()` additionally calls `shutdown()` first (the explicit
sentinel — the graceful path) before the reap; `Drop` relies on the
implicit path.
- **`forwarded()` decided: not provided.** Per the task's "decide,
don't silently drop": the honest shape is the assembly's map — the
listener hands the assembly the token via `listen()`; it does not
own the receipt queue. A v1 convenience accessor would either own a
queue (duplicating assembly state) or wrap one (an accessor over
bookkeeping the type doesn't own). consumer.md's `listener
.forwarded()` sketch line was already marked as the task-sketch
shape; the docs (type docs + consumer.md) state the split.
- **`open` is the ONLY construction path.** No `adopt` (the listener
is an opener-flow type — the open reply carries the correlation
material) and no `join` (there is no pump to await; the task's
sketch has no `join` either — teardown is `close`/`Drop` only).
- **Teardown ordering nuance (test-discovered, documented):** the
far-side wrapper's teardown waits on its handler's R-02 await,
which tracks every per-accept pump it spawned. The listener's EOF
cancels the ACCEPT loop promptly, but the listen channel's own
reap on the producer side lands when the last forwarded pump also
winds down. The e2e drops the local BIND client's end before
asserting the far reap (the forwarded pump's own EOF path).
- **`close()` on a dial-producer listener (both fields `None`)** is
ordinary: the Option posture does not affect teardown — the entry
reaps regardless of what the establisher contributed.
- **Harness addition (`wire_listener_bindfirst`, ListenerTopology):**
the listen-flow topology — the OPENER dials the transport (connect
side, `from_connection_with_serving`: it can both open the listen
tunnel and serve the forwarded op post-hoc via its captured
registry); the producer accepts (serving the bind-first openable;
its forwarded-open caller rides the serving session's
`CallConnection` + the producer-side manager — the design pin from
`tunnels/bindfirst-producer`). The existing topologies could not
serve this shape: `wire_bindfirst`'s consumer is a hook-captured
`CallConnection` (no `ChannelClient`), and `wire_forward`'s
producer-side install hook registers only dial establishers.
- **The adopt-path pin lives in its own test** (`forwarded_channel
_adopt_presents_ordinary_session_semantics`, the forwarded-serving
standalone topology): the gate e2e's forwarded channel on the
opener side is wrapper-managed (the serving wrapper allocated it —
a second `adopt_channel` fails `ChannelExists`, the same property
producer_bindfirst.rs pinned), so the pin drives the PRODUCER-side
adoption — `TunnelSession::adopt` + pump + teardown on the
opener-allocated ID — proving the session type gains nothing.
## Summary
Implemented `TunnelListener` (src/consumer.rs, ~130 lines) per
consumer.md §The Listen Flow + ADR-008 §4 as amended: `open` (rides
`ChannelClient::open_channel_with_reply` — the full reply arrives and
`listen_reply_fields` projects `bound`/`listen`; adopt-free
construction), `bound()`/`listen()` (the Option posture), `close`
(the graceful path: `shutdown` on the held write half — the explicit
EOF sentinel — then the sync reap), and `Drop` (the held write half
drops → the mux pump's implicit-EOF chunk — the far accept
dispatcher's teardown signal — then the sync reap; never leaks).
The held stream is the WRITE HALF (`MpscSendStream`) only, IDLE and
private — a listen channel has no data-plane role. No `Clone`
(compile_fail doc-test, the session's precedent). `forwarded()`
decided: NOT provided — the receipt queue is assembly-side
bookkeeping; the type hands the assembly the token (`listen()`) and
the docs state the split (listener teardown does not cascade to
already-adopted forwarded sessions; the far listener dying stops NEW
accepts). `TunnelSession` gained nothing (the direct path lives in
`tunnels/direct-op`, untouched).
Tests: `tests/consumer_listener.rs` — 5 integration tests: the
bind-first gate (both reply fields surface; the forwarded flow
round-trips through the REAL `register_tunnel_forwarded_openable`
+ correlation map; the teardown cascade — drop listener → far
listener gone → a follow-on accept is never consumed), the Option
posture (a dial producer's open: both fields `None`; close reaps),
close/Drop reaping (the ADR-005 leak-free guarantee,
listen-flavored), the adopt-path pin (a forwarded channel adopted
via `TunnelSession::adopt` presents the ordinary session semantics —
adopt gains nothing), and the scope interaction (`tunnel:open`-only
opener still opens the listen channel; forwarded opens to it fail
FORBIDDEN before the assembly handler runs). Harness addition:
`wire_listener_bindfirst` + `ListenerTopology` (the opener-dials
shape — client + serving on the opener, bind-first serving +
call-plane forwarded-open caller on the producer). One doc-fix drive-
by in producer.rs (two broken intra-doc links, pre-existing).
Verified: `cargo test` green (132 default; 157 `--all-features`), doc
tests pass (both `compile_fail` asserts), clippy `--all-targets -D
warnings` clean (native + wasm32), fmt clean,
`wasm32-unknown-unknown` check passes (the default crate stays
wasm-clean), `cargo doc --no-deps` clean (0 warnings — the 2
pre-existing broken links fixed). CHANGELOG updated (Unreleased →
Added).
+750
View File
@@ -0,0 +1,750 @@
//! Integration tests for `TunnelListener` (the listen-flow consumer
//! session — ADR-008 §4, the `tunnels/tunnel-listener` shape): the
//! listen-flow sibling of `TunnelSession`. `open` rides
//! `open_channel_with_reply` (the U-2 surface), the bind-first reply
//! fields surface via `bound()`/`listen()` (the Option posture), the
//! held-idle write half's drop/EOF is the structural teardown trigger
//! the far accept dispatcher watches, and `close`/`Drop` reap the
//! adopted listen channel (ADR-005's leak-free guarantee,
//! listen-flavored).
//!
//! Covered (the task's test plan):
//! - `open` against a bind-first producer: both reply fields surface;
//! teardown cascades (drop listener → far listener gone →
//! subsequent forwarded opens fail).
//! - `open` against a dial producer: both fields `None` (the Option
//! posture).
//! - `close` reaps the listen channel; `Drop` without close reaps.
//! - Adopt-path pin: a forwarded channel adopted via
//! `TunnelSession::adopt` presents the ordinary session semantics
//! (adopt gains nothing — pump + teardown on the adopted entry).
//! - Scope interaction: an opener identity with `tunnel:open` but NOT
//! `tunnel:forwarded` still opens the listen channel; forwarded
//! opens to it then fail FORBIDDEN (per-accept isolation).
//! - Graceful `close` (review 002 G-2): the explicit shutdown
//! sentinel is observed at the FAR accept dispatcher — the idle-read
//! fires, the loop cancels, the serving side reaps.
//! - Write-happy peer (review 002 N-2's testable residue): a
//! non-empty read on the idle listen stream tears the listener down
//! (the fail-closed posture).
mod harness;
use std::sync::Arc;
use alktunnels::params::{Substrate, SubstrateAddr, TunnelParams};
use alktunnels::producer::{
BindFirstAcceptLoop, BindFn, BoundListener, ResourceRegistry, TargetHandle,
TunnelEstablishError,
};
use alktunnels::TunnelListener;
use harness::{wire_listener_bindfirst, ListenerTopology};
/// The correlation map: `listen` token → the TargetHandle the
/// forwarded channel pumps against (the "local BIND client" halves).
type CorrelationMap = Arc<tokio::sync::Mutex<std::collections::HashMap<u32, TargetHandle>>>;
fn listen_params(resource: &str) -> TunnelParams {
TunnelParams {
resource: resource.to_string(),
substrate: Substrate::Tcp,
}
}
/// The test bind (the assembly's bind seam): produces a queue-backed
/// "listener" + a fake bound address (a real `bind_tcp_first` lives in
/// the `local` feature).
fn test_bind() -> BindFn {
Arc::new(|_substrate, _backing| {
Box::pin(async {
Ok(BoundListener {
listener: Arc::new(4242u32) as Arc<dyn std::any::Any + Send + Sync>,
bound: SubstrateAddr::HostPort {
host: "127.0.0.1".to_string(),
port: 42113,
},
})
})
})
}
/// The test accept loop: pops from the test's VecDeque + Notify pair
/// (one accepted pair per closure call — the dispatcher invokes the
/// closure per accept).
fn queue_loop(
queue: Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
notify: Arc<tokio::sync::Notify>,
) -> BindFirstAcceptLoop {
Arc::new(move |_listener| {
let queue = Arc::clone(&queue);
let notify = Arc::clone(&notify);
Box::pin(async move {
let popped = {
let notified = std::pin::pin!(notify.notified());
let mut q = queue.lock().await;
if let Some(pair) = q.pop_front() {
Some(pair)
} else {
drop(q);
notified.await;
let mut q = queue.lock().await;
q.pop_front()
}
};
popped.ok_or(TunnelEstablishError::DialFailed(
"listener closed".to_string(),
))
})
})
}
/// The accept queue pair (VecDeque + Notify) the test accept loop
/// pops from.
type AcceptQueue = (
Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
Arc<tokio::sync::Notify>,
);
fn fresh_queue() -> AcceptQueue {
(
Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new())),
Arc::new(tokio::sync::Notify::new()),
)
}
/// An accepted "connection" for the test accept loop: a duplex pair
/// with an echo task on the far end (the app server's socket stand-in).
fn accepted_halves(peer_addr: (u32, u16)) -> (SubstrateAddr, TargetHandle) {
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
let (mut t_read, mut t_write) = tokio::io::split(target_side);
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = vec![0u8; 8192];
loop {
match t_read.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
if t_write.write_all(&buf[..n]).await.is_err() {
return;
}
}
}
}
});
let (c_read, c_write) = tokio::io::split(consumer_side);
(
SubstrateAddr::HostPort {
host: peer_addr.0.to_string(),
port: peer_addr.1,
},
TargetHandle {
read: Box::new(c_read),
write: Box::new(c_write),
},
)
}
/// Push an accepted pair into the test's accept queue.
async fn push_accept(
queue: &Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
notify: &Arc<tokio::sync::Notify>,
peer: (u32, u16),
) {
let (peer_addr, handle) = accepted_halves(peer);
queue.lock().await.push_back((peer_addr, handle));
notify.notify_waiters();
}
/// Register the forwarded op on an opener-side serving registry with a
/// correlation map (token → the local "BIND client" halves the
/// assembly's pump bridges to) + an invocation witness (which tokens
/// were correlated).
fn install_forwarded_openable_on(
registry: &Arc<alkcall::registry::registration::OperationRegistry>,
manager: &alkcall::channels::manager::ChannelManager,
) -> (CorrelationMap, Arc<tokio::sync::Mutex<Vec<u32>>>) {
let correlations: CorrelationMap = Arc::new(tokio::sync::Mutex::new(Default::default()));
let invoked: Arc<tokio::sync::Mutex<Vec<u32>>> = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let map = Arc::clone(&correlations);
let seen = Arc::clone(&invoked);
let handler: alktunnels::producer::ForwardedHandler = Arc::new(move |forwarded, bidi| {
let map = Arc::clone(&map);
let seen = Arc::clone(&seen);
tokio::spawn(async move {
seen.lock().await.push(forwarded.listen);
let target = map.lock().await.remove(&forwarded.listen);
if let Some(target) = target {
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
}
})
});
let core = alkcall::channels::operations::ChannelCore::new(
manager.clone(),
alkcall::channels::policy::default_policy(),
);
alktunnels::producer::register_tunnel_forwarded_openable(
&core,
registry,
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
handler,
)
.expect("register forwarded openable on the opener");
(correlations, invoked)
}
/// The "local BIND client" pair (the opener-side pump target): the
/// map's halves are the assembly pump's target, the far end is the
/// test's client end (driven directly).
fn bind_client_pair() -> (
tokio::io::ReadHalf<tokio::io::DuplexStream>,
tokio::io::WriteHalf<tokio::io::DuplexStream>,
TargetHandle,
) {
let (a, b) = tokio::io::duplex(64 * 1024);
let (a_read, a_write) = tokio::io::split(a);
let (b_read, b_write) = tokio::io::split(b);
(
b_read,
b_write,
TargetHandle {
read: Box::new(a_read),
write: Box::new(a_write),
},
)
}
fn opener_only_channel_0(topo: &ListenerTopology) -> bool {
topo.opener
.manager()
.channel_ids()
.iter()
.all(|&id| id == 0)
}
/// THE gate: `open` against the bind-first producer — both reply
/// fields surface via `bound()`/`listen()`, the forwarded flow runs
/// through a REAL `TunnelSession::adopt` of the producer-allocated
/// forwarded channel (adopt gains nothing — the session type is
/// ordinary), and dropping the listener tears the far listener down
/// (subsequent forwarded opens fail).
#[tokio::test]
async fn bindfirst_open_surfaces_reply_fields_and_teardown_cascades() {
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let (queue, notify) = fresh_queue();
let topo = wire_listener_bindfirst(
registry,
test_bind(),
queue_loop(queue.clone(), notify.clone()),
harness::forwarded_opener_identity(),
)
.await;
// The listener opens the listen tunnel.
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
.await
.expect("listen open");
let channel_id = listener.channel_id;
assert!(
topo.producer_manager.has_channel(channel_id),
"the listen channel is live on the producer side"
);
// Reply fields (the Option posture, bind-first present case).
assert_eq!(
listener.bound(),
Some(&SubstrateAddr::HostPort {
host: "127.0.0.1".to_string(),
port: 42113,
}),
"the `bound` field is the OS-chosen (test-chosen) address"
);
let token = listener.listen().expect("the correlation token");
assert!(token > 0);
// The opener-side serving half + the correlation map (keyed on the
// token from THIS listener's reply).
let (correlations, _invoked) =
install_forwarded_openable_on(&topo.opener_registry, topo.opener.manager());
let (mut b_read, mut b_write, local) = bind_client_pair();
correlations.lock().await.insert(token, local);
// The producer's accept loop accepts (the app server dialed).
push_accept(&queue, &notify, (198, 55231)).await;
// The forwarded channel arrives as a PRODUCER-INITIATED open the
// opener serves: the opener-allocated ID appears on the opener's
// manager (wrapper-managed — the serving side allocates, ADR-047
// §5; the PRODUCER adopts it inside the harness's forwarded-open
// caller and pumps the accepted halves against it).
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let forwarded_ids: Vec<u32> = topo
.opener
.manager()
.channel_ids()
.into_iter()
.filter(|&id| id != 0 && id != channel_id)
.collect();
assert_eq!(forwarded_ids.len(), 1, "one forwarded channel arrived");
// THE round-trip (the adopt-path pin's data half): bytes the test
// writes on the local BIND client's end cross the assembly pump →
// the forwarded channel → the producer-side adopted pump → the
// accepted handle's echo → and back. The forwarded session is an
// ordinary channel — `adopt` gains nothing (the standalone adopt
// pin below drives the same session type directly).
use tokio::io::{AsyncReadExt, AsyncWriteExt};
b_write.write_all(b"bind-ping").await.expect("write");
b_write.flush().await.expect("flush");
let mut buf = vec![0u8; 9];
tokio::time::timeout(
std::time::Duration::from_secs(5),
b_read.read_exact(&mut buf),
)
.await
.expect("round trip")
.expect("read");
assert_eq!(&buf, b"bind-ping");
// Teardown cascade: drop the listener → the held write half EOFs
// the listen channel → the far accept loop cancels → the far
// listener is gone (a follow-on accept is never consumed). The
// client end drops first (the forwarded session's own teardown —
// the pump winds down; the handler's R-02 await unblocks only
// when every per-accept pump finished).
drop(b_read);
drop(b_write);
drop(listener);
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if !topo.producer_manager.has_channel(channel_id) {
break;
}
}
assert!(
!topo.producer_manager.has_channel(channel_id),
"the listen channel reaped after the listener dropped"
);
push_accept(&queue, &notify, (198, 55232)).await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(
queue.lock().await.len(),
1,
"the far accept loop is gone: the pushed accept was never consumed"
);
}
/// The adopt-path pin (ADR-008 §4: "`TunnelSession::adopt` gains
/// nothing — forwarded channels are ordinary adopted channels"): in
/// the forwarded-serving standalone shape (the test plays the
/// producer), a forwarded channel adopted via `TunnelSession::adopt`
/// presents the ordinary session semantics — substrate-shaped data
/// plane, pump, teardown — identical to any other adopted session.
#[tokio::test]
async fn forwarded_channel_adopt_presents_ordinary_session_semantics() {
// The forwarded-serving topology: the producer leg (connect side,
// `topo.producer`) holds the client + manager; the opener leg
// (accept side) serves the forwarded op via the REAL
// registration.
let topo = harness::wire_with(
alktunnels::producer::ResourceRegistry::new(),
harness::RegistrationMode::Dial(harness::failing_dial("base op unused")),
Some(harness::forwarded_opener_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let (correlations, _invoked) =
install_forwarded_openable_on(&topo.consumer_registry, &topo.consumer_manager);
// The opener's assembly correlated the local halves for the token
// BEFORE the forwarded open arrives (the waiting-session posture).
const TOKEN: u32 = 918273;
let (mut b_read, b_write, local) = bind_client_pair();
correlations.lock().await.insert(TOKEN, local);
// The producer calls the forwarded op (valid params + scope) and
// adopts the opener-allocated ID — via `TunnelSession::adopt` (the
// session type's adopt path; `adopt gains nothing`).
let out = topo
.producer
.call_open_op(
alktunnels::OP_TUNNEL_FORWARDED,
serde_json::json!({
"listen": TOKEN,
"substrate": "tcp",
"peer": {"host": "198", "port": 55231},
}),
)
.await
.result
.expect("forwarded open");
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
let mut forwarded_session = alktunnels::TunnelSession::adopt(
topo.producer.manager(),
channel_id,
Substrate::Tcp,
alktunnels::TUNNEL_ALPN,
)
.await
.expect("adopt the forwarded channel (ordinary session semantics)");
// The adopted session pumps like any session: bytes cross the
// adopted channel → the assembly pump → the local BIND client
// halves → and back.
let (_f_read, f_write) = forwarded_session
.stream_halves()
.expect("stream halves on the adopted forwarded session");
use tokio::io::{AsyncReadExt, AsyncWriteExt};
f_write.write_all(b"adopt-ping").await.expect("write");
f_write.flush().await.expect("flush");
let mut buf = vec![0u8; 10];
tokio::time::timeout(
std::time::Duration::from_secs(5),
b_read.read_exact(&mut buf),
)
.await
.expect("round trip")
.expect("read");
assert_eq!(&buf, b"adopt-ping");
// Teardown: the adopted session reaps its OWN entry (per-session,
// ADR-005) — independent of any listener. The far end (the local
// BIND client end) drops first: the assembly pump's `pump_bidi`
// (R-02, the wrapper's handler task) unwinds when both pumps
// finish, and only THEN does the wrapper's handler-exit teardown
// reap the serving side.
drop(b_read);
drop(b_write);
let reaped = forwarded_session.close().await;
assert!(reaped, "close reaped the adopted forwarded entry");
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if !topo.consumer_manager.has_channel(channel_id) {
break;
}
}
assert!(
!topo.consumer_manager.has_channel(channel_id),
"the forwarded channel reaped on the opener side (wrapper teardown)"
);
}
/// The Option posture: `open` against a plain DIAL producer — both
/// reply fields `None`, the session is ordinary. (`wire_forward`'s
/// producer (accept side) serves the base openable with a DIAL
/// establisher — a dial establisher never binds, so the reply carries
/// neither field; the listener session is otherwise ordinary.)
#[tokio::test]
async fn dial_producer_open_both_fields_none() {
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = harness::wire_forward(registry, harness::echo_dial()).await;
let listener = TunnelListener::open(&topo.consumer, listen_params("echo"))
.await
.expect("dial open");
assert_eq!(listener.bound(), None, "a dial establisher binds nothing");
assert_eq!(listener.listen(), None, "no token minted");
// The listener still owns the channel entry; close reaps it.
let channel_id = listener.channel_id;
assert!(topo.producer_manager.has_channel(channel_id));
let reaped = listener.close().await;
assert!(reaped, "close reaped the adopted entry");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(!topo.producer_manager.has_channel(channel_id));
assert!(
topo.consumer
.manager()
.channel_ids()
.iter()
.all(|&id| id == 0)
&& topo
.producer_manager
.channel_ids()
.iter()
.all(|&id| id == 0),
"no leaked entries after close"
);
}
/// `close` reaps the listen channel; `Drop` without close reaps (the
/// ADR-005 leak-free guarantee, listen-flavored).
#[tokio::test]
async fn close_and_drop_reap_the_listen_channel() {
// close(): the graceful path (shutdown → teardown).
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let (queue, notify) = fresh_queue();
let topo = wire_listener_bindfirst(
registry,
test_bind(),
queue_loop(queue, notify),
harness::forwarded_opener_identity(),
)
.await;
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
.await
.expect("listen open");
let id1 = listener.channel_id;
assert!(listener.close().await, "close reaped the entry");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(!topo.producer_manager.has_channel(id1));
// Drop(): the best-effort path — no leak.
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let (queue, notify) = fresh_queue();
let topo = wire_listener_bindfirst(
registry,
test_bind(),
queue_loop(queue, notify),
harness::forwarded_opener_identity(),
)
.await;
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
.await
.expect("listen open");
let id2 = listener.channel_id;
drop(listener);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
!topo.producer_manager.has_channel(id2),
"Drop reaped the adopted entry (no leak)"
);
assert!(opener_only_channel_0(&topo), "no leaked entries after Drop");
}
/// Scope interaction: an opener identity with `tunnel:open` but NOT
/// `tunnel:forwarded` still opens the listen channel (that gate is on
/// the producer side, `tunnel:open`-shaped); forwarded opens to it
/// then fail FORBIDDEN (the per-accept isolation path).
#[tokio::test]
async fn open_only_opener_still_opens_listen_but_forwarded_fails() {
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let (queue, notify) = fresh_queue();
let topo = wire_listener_bindfirst(
registry,
test_bind(),
queue_loop(queue.clone(), notify.clone()),
harness::forwarded_scopeless_identity(),
)
.await;
// The listener opens fine (the producer-side gate is `tunnel:open`).
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
.await
.expect("listen open succeeds with tunnel:open alone");
assert!(listener.bound().is_some());
let token = listener.listen().expect("token");
// The opener serves the forwarded op with a correlation map (the
// map holds the token, but the ACL rejects the open before the
// handler runs).
let (correlations, invoked) =
install_forwarded_openable_on(&topo.opener_registry, topo.opener.manager());
correlations.lock().await.insert(token, {
let (_l_read, _l_write, local) = bind_client_pair();
local
});
// The accept: the producer's dispatcher opens forwarded → the
// scope-less opener's ACL rejects FORBIDDEN → the accepted
// connection drops, the loop continues (per-accept isolation).
push_accept(&queue, &notify, (198, 55233)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert_eq!(
queue.lock().await.len(),
0,
"the accept was consumed (the loop is alive)"
);
let listen_id = listener.channel_id;
assert!(
topo.opener
.manager()
.channel_ids()
.iter()
.all(|&id| id == 0 || id == listen_id),
"a FORBIDDEN forwarded open leaves no channel on the opener side"
);
assert!(
invoked.lock().await.is_empty(),
"the assembly handler never ran (the ACL rejected before it)"
);
drop(listener);
}
/// The graceful-teardown half of ADR-008 §1's contract (review 002
/// G-2): `TunnelListener::close`'s explicit `shutdown` (the EOF
/// sentinel) is observed at the FAR accept dispatcher — the idle-read
/// fires, the accept loop cancels, the pushed accept is never
/// consumed, and the serving-side listen channel reaps. The Drop path
/// (`bindfirst_open_surfaces_reply_fields_and_teardown_cascades`) and
/// the reap level (`close_and_drop_reap_the_listen_channel`) are
/// pinned elsewhere; this test observes the sentinel's far-side
/// effect for `close()` specifically.
#[tokio::test]
async fn graceful_close_eof_sentinel_observed_at_far_accept_dispatcher() {
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let (queue, notify) = fresh_queue();
let topo = wire_listener_bindfirst(
registry,
test_bind(),
queue_loop(queue.clone(), notify.clone()),
harness::forwarded_opener_identity(),
)
.await;
let listener = TunnelListener::open(&topo.opener, listen_params("bind"))
.await
.expect("listen open");
let channel_id = listener.channel_id;
assert!(topo.producer_manager.has_channel(channel_id));
let token = listener.listen().expect("the correlation token");
// A pushed accept consumed BEFORE close would prove the loop is
// alive at that point; it also leaves the queue-len assertion
// below unambiguous (one unconsumed accept = the loop is gone).
let (correlations, _invoked) =
install_forwarded_openable_on(&topo.opener_registry, topo.opener.manager());
correlations.lock().await.insert(token, {
let (_l_read, _l_write, local) = bind_client_pair();
local
});
push_accept(&queue, &notify, (198, 55234)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert_eq!(
queue.lock().await.len(),
0,
"the accept loop is alive before close (the accept was consumed)"
);
// THE graceful path: `close` runs the explicit shutdown sentinel,
// then reaps. The far dispatcher's idle read observes the
// sentinel; the loop cancels.
assert!(listener.close().await, "close reaped the entry");
// The serving-side listen channel reaps (the handler-exit
// teardown, the same posture the Drop test pins).
let mut reaped = false;
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if !topo.producer_manager.has_channel(channel_id) {
reaped = true;
break;
}
}
assert!(reaped, "the listen channel reaped after the graceful close");
// The far accept loop is gone: a follow-on accept is never
// consumed (the queue-len assertion the Drop-shaped tests use).
push_accept(&queue, &notify, (198, 55235)).await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(
queue.lock().await.len(),
1,
"the far accept loop is gone after the graceful close: the pushed accept was never consumed"
);
}
/// The write-happy-peer teardown posture (review 002 N-2's testable
/// residue): a non-empty read on the idle listen stream tears the
/// listener down — the fail-closed arm (`Ok(_)` in the idle-stream EOF
/// watch, src/producer.rs). The channel is not a data plane; ANY
/// non-empty read is a teardown trigger, not just the zero-byte
/// sentinels. The typed `TunnelListener` never writes, so the test
/// plays the misbehaving opener with the raw client surface (open +
/// hold both halves; write one byte into the idle stream).
#[tokio::test]
async fn write_happy_peer_on_idle_listen_channel_tears_down_far_listener() {
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let (queue, notify) = fresh_queue();
let topo = wire_listener_bindfirst(
registry,
test_bind(),
queue_loop(queue.clone(), notify.clone()),
harness::forwarded_opener_identity(),
)
.await;
// The misbehaving opener: the raw client surface (the typed
// `TunnelListener` never exposes the write half — it holds it
// idle by contract). Hold the write half ALIVE: the idle read
// must fire from the written byte, not from a dropped session.
let input = serde_json::to_value(listen_params("bind")).expect("params serialize");
let (channel_id, _reply, mut write, _recv) = topo
.opener
.open_channel_with_reply(
alktunnels::params::OP_TUNNEL_OPEN,
input,
alktunnels::params::TUNNEL_ALPN,
)
.await
.expect("listen open via the raw client");
assert!(topo.producer_manager.has_channel(channel_id));
// Prove the accept loop is alive FIRST (a consumed accept), then
// write a byte into the idle channel — the dispatcher's idle read
// observes the non-empty read and tears the listener down
// (fail-closed).
push_accept(&queue, &notify, (198, 55236)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert_eq!(
queue.lock().await.len(),
0,
"the accept loop is alive before the misbehaved write (no forwarded-op serving stub is installed; the open fails, per-accept isolation)"
);
// The misbehavior: one byte on the idle stream.
use tokio::io::AsyncWriteExt;
write.write_all(b"x").await.expect("write a byte");
write.flush().await.expect("flush");
// The fail-closed posture: the accept loop cancels and the
// listener tears down with the plan (the serving-side channel
// reaps via the handler-exit teardown).
let mut reaped = false;
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if !topo.producer_manager.has_channel(channel_id) {
reaped = true;
break;
}
}
assert!(
reaped,
"the listen channel reaped after the write-happy peer misbehaved"
);
// The far accept loop is gone: a follow-on accept is never
// consumed.
push_accept(&queue, &notify, (198, 55237)).await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(
queue.lock().await.len(),
1,
"the far accept loop is gone after the write-happy peer (the pushed accept was never consumed)"
);
// The misbehaving opener's own halves drop (test hygiene).
drop(write);
}
+625 -8
View File
@@ -22,6 +22,7 @@
//! dialing). CF-006 (the per-call opener identity on the establisher)
//! is probed via the `identity_witness` the establisher records.
use serde_json::Value;
use std::sync::Arc;
use alkcall::channels::adapter::{ChannelsAdapter, InstallChannelZero};
@@ -34,8 +35,12 @@ use alkcall::protocol::connection::{split_single_stream, CallConnection};
use alkcall::protocol::dispatch::Dispatcher;
use alkcall::registry::registration::OperationRegistry;
use alktunnels::params::{Substrate, TUNNEL_OPEN_SCOPE};
use alktunnels::producer::{register_tunnel_openable, ResourceRegistry};
use alktunnels::params::{
Substrate, TUNNEL_DIRECT_SCOPE, TUNNEL_FORWARDED_SCOPE, TUNNEL_OPEN_SCOPE,
};
use alktunnels::producer::{
register_tunnel_direct_openable, register_tunnel_openable, ResourceRegistry,
};
/// The consumer identity the transport carries (the mTLS/QUIC
/// analogue — key-based identity resolved out-of-band, attached to
@@ -48,6 +53,29 @@ pub fn consumer_identity() -> Identity {
}
}
/// The direct-egress identity: `tunnel:direct` ONLY (no
/// `tunnel:open`) — the scope-separation probe's identity.
pub fn direct_identity() -> Identity {
Identity {
id: "consumer-direct".to_string(),
scopes: vec![TUNNEL_DIRECT_SCOPE.to_string()],
resources: std::collections::HashMap::new(),
}
}
/// Both scopes: `tunnel:open` AND `tunnel:direct` (a deployment
/// granting both grants both explicitly — ADR-006 Amendment 1).
pub fn both_scopes_identity() -> Identity {
Identity {
id: "consumer-both".to_string(),
scopes: vec![
TUNNEL_OPEN_SCOPE.to_string(),
TUNNEL_DIRECT_SCOPE.to_string(),
],
resources: std::collections::HashMap::new(),
}
}
/// The scoped token (the hub-forwarding path) — resolves to
/// [`consumer_identity`] via [`TestIdProvider`].
pub const TEST_AUTH_TOKEN: &str = "test-token-with-tunnel-open-scope";
@@ -82,6 +110,9 @@ pub struct Topology {
pub consumer_call: Arc<CallConnection>,
/// The producer's serving registry (probes).
pub producer_registry: Arc<OperationRegistry>,
/// The consumer's serving registry (the opener-side op stubs —
/// e.g. the forwarded-op serving stub — pre-register here).
pub consumer_registry: Arc<OperationRegistry>,
/// What the establisher's per-call auth carried as the opener
/// identity id (the CF-006 probe — `None` until an open runs).
pub identity_witness: Arc<tokio::sync::Mutex<Option<String>>>,
@@ -105,6 +136,22 @@ pub enum RegistrationMode {
/// The no-witness dial establisher (`tunnel_establisher` — the
/// public default shape, no CF-006 probe seam).
DialNoWitness(alktunnels::producer::DialFn),
/// The direct op (`channels/tunnel/direct`, ADR-007): the direct
/// spec + the direct establisher (no registry lookup) over the
/// SAME injected dial, with the CF-006 witness seam. The witness
/// rides the topology's `identity_witness` (the direct op's
/// witness and the base op's never mix in one test).
Direct(alktunnels::producer::DialFn),
/// The bind-first establisher (shape 3, ADR-008): the bind-first
/// establisher + the accept dispatcher over the injected bind +
/// accept loop + forwarded-open caller. The forwarded-op caller
/// rides the producer-side call plane (`producer_client`'s call
/// connection — captured in the topology) and adopts on the
/// producer-side manager.
BindFirst(
alktunnels::producer::BindFn,
alktunnels::producer::BindFirstAcceptLoop,
),
}
pub async fn wire_with(
@@ -120,28 +167,47 @@ pub async fn wire_with(
let (consumer_manager_tx, mut consumer_manager_rx) =
tokio::sync::mpsc::channel::<ChannelManager>(1);
// The consumer's serving registry: empty by default; a test that
// installs opener-side ops (the forwarded-op serving stub — the
// ADR-008 §3 ingress shape) pre-registers them here so the
// dispatch loop serves them from the first inbound call.
let consumer_serving_registry = Arc::new(OperationRegistry::new());
let registry_for_hook = Arc::clone(&consumer_serving_registry);
let identity_for_hook = transport_identity.clone();
// --- consumer (accept side): ChannelsAdapter + capturing hook ------
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, _auth| {
let call_tx = consumer_call_tx.clone();
let manager_tx = consumer_manager_tx.clone();
let serving_registry = Arc::clone(&registry_for_hook);
let peer_identity = identity_for_hook.clone();
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
// The accept-side transport-identity posture (the same
// propagation `wire_forward` models): the transport
// authenticated the dialer; attach the resolved identity
// to channel 0 so the serving dispatch sees the caller
// (CF-005 (b)'s accept-side analogue).
if let Some(identity) = peer_identity {
let _ = channel0_conn.set_identity(identity);
}
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let _ = call_tx.send(Arc::clone(&call_connection)).await;
let _ = manager_tx.send(manager).await;
// The consumer serves no ops — an empty registry. The
// dispatcher keeps the call loop alive (its read loop
// resolves the producer's responses to the consumer's
// pending entries).
// The consumer serves the ops the test pre-registered on
// its serving registry (empty in the default topology —
// the dispatcher keeps the call loop alive either way; its
// read loop resolves the producer's responses to the
// consumer's pending entries).
let dp = Dispatcher::new(
Arc::new(OperationRegistry::new()),
serving_registry,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
);
dp.serve_single_stream(call_connection, reader, writer)
@@ -200,6 +266,7 @@ pub async fn wire_with(
producer_client.manager().clone(),
alkcall::channels::policy::default_policy(),
);
let producer_client = Arc::new(producer_client);
match mode {
RegistrationMode::Dial(dial) => {
register_tunnel_openable(
@@ -212,6 +279,16 @@ pub async fn wire_with(
)
.expect("register tunnel openable");
}
RegistrationMode::Direct(dial) => {
register_tunnel_direct_openable(
&core,
&producer_op_registry,
AuthContext::anonymous(b"alk/tunnel"),
dial,
Some(Arc::clone(&identity_witness)),
)
.expect("register tunnel direct openable");
}
RegistrationMode::Listen(accept) => {
alktunnels::producer::register_tunnel_listen_openable(
&core,
@@ -222,6 +299,75 @@ pub async fn wire_with(
)
.expect("register tunnel listen openable");
}
RegistrationMode::BindFirst(bind, accept_loop) => {
// The bind-first shape (ADR-008). The forwarded-op caller
// closes over the producer-side call plane + manager (the
// design pin: the per-channel `Connection` the handler
// receives is stream-less and carries no call surface, so
// the registration takes the call-plane handle — here the
// serving session's `CallConnection`, which the client
// holds; in production the assembly layer has the same
// handle because it constructed the serving session). The
// harness reaches it via `producer_client.call_open_op` +
// `adopt_channel` — the reverse POC's
// `open_reverse_channel` shape, inline (the reply's extra
// fields are not needed for the forwarded op; the channel
// ID extraction + adopt is the whole dance).
let manager = producer_client.manager().clone();
let client_for_opener = Arc::clone(&producer_client);
let open_forwarded: alktunnels::producer::ForwardedOpener = Arc::new(
move |forwarded: alktunnels::params::TunnelForwardedParams| {
let client = Arc::clone(&client_for_opener);
let manager = manager.clone();
Box::pin(async move {
let input = serde_json::to_value(&forwarded).map_err(|e| {
alktunnels::producer::TunnelEstablishError::HandlerError(format!(
"forwarded params serialize: {e}"
))
})?;
let response = client
.call_open_op(alktunnels::params::OP_TUNNEL_FORWARDED, input)
.await;
let out = response.result.map_err(|e| {
alktunnels::producer::TunnelEstablishError::HandlerError(format!(
"forwarded open: {}",
e.message
))
})?;
let channel_id =
out.get("channel_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| {
alktunnels::producer::TunnelEstablishError::HandlerError(
"forwarded open reply missing channel_id".to_string(),
)
})? as u32;
let (send, recv) = manager
.adopt_channel(channel_id, alktunnels::params::TUNNEL_ALPN, None)
.await
.map_err(|e| {
alktunnels::producer::TunnelEstablishError::HandlerError(format!(
"forwarded adopt: {e}"
))
})?;
Ok((send, recv))
})
},
);
alktunnels::producer::register_tunnel_bindfirst_openable(
&core,
&registry,
&producer_op_registry,
AuthContext::anonymous(b"alk/tunnel"),
alktunnels::producer::BindFirstInjections {
bind,
accept_loop,
open_forwarded,
},
Some(Arc::clone(&identity_witness)),
)
.expect("register tunnel bindfirst openable");
}
RegistrationMode::DialNoWitness(dial) => {
// The public no-witness wrapper (tunnel_establisher) —
// registration + establisher body identical to the
@@ -279,7 +425,6 @@ pub async fn wire_with(
.expect("register hanging establisher");
}
}
let producer_client = Arc::new(producer_client);
// --- consumer pieces (captured from the hook) -------------------------
let consumer_call = consumer_call_rx.recv().await.expect("consumer call conn");
@@ -290,6 +435,7 @@ pub async fn wire_with(
consumer_manager,
consumer_call,
producer_registry: producer_op_registry,
consumer_registry: consumer_serving_registry,
identity_witness,
}
}
@@ -308,6 +454,33 @@ pub async fn wire(registry: ResourceRegistry, dial: alktunnels::producer::DialFn
.await
}
/// The direct-op topology (ADR-007): the direct op registered with
/// the CF-006 witness; the consumer's transport identity is
/// [`both_scopes_identity`] (both grants — the opens authorize).
pub async fn wire_direct(dial: alktunnels::producer::DialFn) -> Topology {
wire_with(
ResourceRegistry::new(),
RegistrationMode::Direct(dial),
Some(both_scopes_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await
}
/// [`wire_direct`] with an explicit transport identity (the
/// scope-separation probes construct their own identities).
pub async fn wire_direct_with_identity(identity: Identity) -> Topology {
wire_with(
ResourceRegistry::new(),
RegistrationMode::Direct(failing_dial("unused")),
Some(identity),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await
}
/// A topology with the consumer's transport identity REPLACED by the
/// serving-side override (`ServingConfig.identity` — CF-005 (a) probe
/// shape) or stripped entirely (the fail-closed probe). The transport
@@ -345,6 +518,137 @@ pub async fn wire_listen(
.await
}
/// The bind-first topology (shape 3, ADR-008): the producer's
/// establisher binds via the injected [`BindFn`]; the accept
/// dispatcher runs the injected accept loop and opens forwarded
/// channels back toward the opener (whose serving stub the test
/// installs on the consumer side — see
/// [`serve_forwarded_on_consumer`]). Same identity posture as
/// [`wire`], plus the forwarded scope (the opener-side ingress grant
/// — the consumer identity must hold `tunnel:forwarded` to receive
/// producer-initiated forwarded opens).
pub async fn wire_bindfirst(
registry: ResourceRegistry,
bind: alktunnels::producer::BindFn,
accept_loop: alktunnels::producer::BindFirstAcceptLoop,
) -> Topology {
wire_with(
registry,
RegistrationMode::BindFirst(bind, accept_loop),
Some(forwarded_opener_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await
}
/// The forwarded-op opener identity: `tunnel:forwarded` AND
/// `tunnel:open` (the opener receives producer-initiated forwarded
/// opens — the ingress grant — and opens the listen tunnel itself —
/// the egress grant; ADR-008 §3 / ADR-006 Amendment 1).
pub fn forwarded_opener_identity() -> Identity {
Identity {
id: "opener".to_string(),
scopes: vec![
TUNNEL_OPEN_SCOPE.to_string(),
TUNNEL_FORWARDED_SCOPE.to_string(),
],
resources: std::collections::HashMap::new(),
}
}
/// [`forwarded_opener_identity`] minus the forwarded grant (the
/// per-accept isolation probe's identity — the forwarded open fails
/// FORBIDDEN).
pub fn forwarded_scopeless_identity() -> Identity {
Identity {
id: "opener-scopeless".to_string(),
scopes: vec![TUNNEL_OPEN_SCOPE.to_string()],
resources: std::collections::HashMap::new(),
}
}
/// Serve the forwarded op (`channels/tunnel/forwarded`, ADR-008 §3) on
/// the CONSUMER side (the listen opener's serving stub — the test
/// stand-in for the opener-side registration that lands with
/// `tunnels/forwarded-receipt`): a `register_openable` (no
/// establisher — the data plane starts immediately) whose handler
/// correlates the open by the `listen` token against the test's
/// correlation map, accepts the `BiStream`, and pumps against the
/// "local BIND client" halves the test registered for that token.
///
/// `correlations` maps `listen` token → the pump side to pump the
/// forwarded channel against (the assembly-side bookkeeping posture —
/// the protocol provides the op + params, the test provides the map).
/// The registration is post-hoc on the consumer's captured registry —
/// but the CONSUMER serves no ops in the default harness (its
/// dispatch registry is empty and the dispatcher runs on it), so this
/// helper must be called BEFORE the topology is wired; hence it is a
/// registry-level helper the caller wires via a custom `wire_with`
/// variant when needed.
pub fn register_forwarded_stub_on_consumer(
consumer_registry: &Arc<OperationRegistry>,
manager: ChannelManager,
correlations: CorrelationMap,
) -> Result<(), String> {
let core = alkcall::channels::operations::ChannelCore::new(
manager,
alkcall::channels::policy::default_policy(),
);
core.register_openable(
alktunnels::params::tunnel_forwarded_spec(),
make_forwarded_stub_handler(correlations),
consumer_registry,
AuthContext::anonymous(b"alk/tunnel"),
)
}
/// The correlation map: `listen` token → the TargetHandle the
/// forwarded channel pumps against (the "local BIND client" halves).
pub type CorrelationMap =
Arc<tokio::sync::Mutex<std::collections::HashMap<u32, alktunnels::producer::TargetHandle>>>;
/// The forwarded-op handler (the opener-side serving stub's handler):
/// correlate by `listen` → accept the `BiStream` → pump against the
/// correlated halves (`pump_bidi`, convention 8). A correlation miss
/// is a loud `handler_error`-class open failure (the typed posture —
/// the accept dies at birth; the loop keeps serving).
pub fn make_forwarded_stub_handler(
correlations: CorrelationMap,
) -> alkcall::channels::operations::OpenHandler {
Arc::new(
move |input: Value,
_plan: Option<alkcall::channels::operations::ChannelPlan>,
conn: alkcall::core::types::Connection,
_auth: AuthContext| {
let correlations = Arc::clone(&correlations);
tokio::spawn(async move {
let forwarded: alktunnels::params::TunnelForwardedParams =
match serde_json::from_value(input) {
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "forwarded stub: params parse failed");
return;
}
};
let target = correlations.lock().await.remove(&forwarded.listen);
let Some(target) = target else {
tracing::error!(
listen = forwarded.listen,
"forwarded stub: correlation miss"
);
return;
};
let Some(bidi) = conn.accept_bi().await.ok() else {
tracing::error!("forwarded stub: accept_bi failed");
return;
};
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
})
},
)
}
/// The forward topology (the `-L` direction): the consumer dials the
/// transport (connect side — `ChannelClient::from_connection`, a pure
/// consumer serving nothing) and the producer accepts (the adapter +
@@ -472,6 +776,319 @@ pub async fn wire_forward(
}
}
/// The DIRECT-op forward topology (ADR-007): the producer (accept
/// side, serving) registers the direct op — [`register_tunnel_direct_openable`]
/// over the injected dial, CF-006 witness attached — and the consumer
/// (connect side, pure client) holds the identity the session tests
/// authorize with. This is the topology `TunnelSession::open_direct`
/// is normative for.
pub async fn wire_direct_forward(dial: alktunnels::producer::DialFn) -> ForwardTopology {
wire_direct_forward_with_identity(dial, both_scopes_identity()).await
}
/// [`wire_direct_forward`] with an explicit transport identity (the
/// session-level scope-separation probes construct their own).
pub async fn wire_direct_forward_with_identity(
dial: alktunnels::producer::DialFn,
identity: Identity,
) -> ForwardTopology {
let producer_op_registry = Arc::new(OperationRegistry::new());
let (producer_manager_tx, mut producer_manager_rx) =
tokio::sync::mpsc::channel::<ChannelManager>(1);
let identity_witness = Arc::new(tokio::sync::Mutex::new(None::<String>));
let dial_witness = Arc::clone(&identity_witness);
let expected_caller = identity.clone();
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, _auth| {
let registry_arc = Arc::clone(&producer_op_registry);
let dial = Arc::clone(&dial);
let witness = Arc::clone(&dial_witness);
let manager_tx = producer_manager_tx.clone();
let caller = expected_caller.clone();
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
// The accept-side transport-identity posture: the transport
// authenticated the dialer; attach the RESOLVED identity
// (scopes included — resolved out-of-band) to channel 0.
let _ = channel0_conn.set_identity(caller);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
let core = alkcall::channels::operations::ChannelCore::new(
manager.clone(),
alkcall::channels::policy::default_policy(),
);
let ops = ChannelOperations::with_default_policy(manager.clone());
ops.register_on(&registry_arc)
.expect("register channel ops on producer");
register_tunnel_direct_openable(
&core,
&registry_arc,
AuthContext::anonymous(b"alk/tunnel"),
dial,
Some(witness),
)
.expect("register tunnel direct openable");
let _ = manager_tx.send(manager).await;
let dp = Dispatcher::new(
registry_arc,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
);
dp.serve_single_stream(call_connection, reader, writer)
.await;
})
});
let (consumer_end, producer_end) = tokio::io::duplex(64 * 1024);
let consumer_conn = CoreConnection::from_bidi(consumer_end, b"alk/channels".to_vec(), None);
let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None);
consumer_conn
.set_identity(identity)
.expect("transport identity set once");
let adapter = ChannelsAdapter::new(install_hook, Arc::new(alkcall::channels::policy::NoCap));
let producer_transport_auth = AuthContext::anonymous(b"alk/channels");
let _adapter_task = tokio::spawn(async move {
let _ = alkcall::core::types::ProtocolHandler::handle(
&adapter,
producer_conn,
&producer_transport_auth,
)
.await;
});
let consumer_client = ChannelClient::from_connection(consumer_conn)
.await
.expect("consumer channel client init");
let consumer_client = Arc::new(consumer_client);
let producer_manager = producer_manager_rx
.recv()
.await
.expect("producer manager captured");
ForwardTopology {
consumer: consumer_client,
producer_manager,
identity_witness,
}
}
/// The listen-flow topology (ADR-008 §4 — the `TunnelListener` shape):
/// the OPENER dials the transport (connect side —
/// `ChannelClient::from_connection_with_serving`, so it can both open
/// the listen tunnel and serve the forwarded op), and the producer
/// accepts (the adapter + an install hook registering the bind-first
/// openable over the injected bind + accept loop). The producer-side
/// forwarded-open caller rides the accept-side call plane (the
/// serving session's `CallConnection` — the same surface the serving
/// dispatch loop runs on; both-sides sanity, ADR-022 §2) and adopts on
/// the producer-side manager. The forwarded op registers post-hoc (W2)
/// on the opener's captured `opener_registry`.
pub struct ListenerTopology {
/// The opener's own client (connect side, serving). Its manager
/// adopted the producer-allocated listen channel ID (via
/// `open_channel_with_reply`); the listener reaps via this
/// manager.
pub opener: Arc<ChannelClient>,
/// The opener's serving registry (the forwarded op registers here
/// post-hoc).
pub opener_registry: Arc<OperationRegistry>,
/// The producer's manager (accept side, serving) — for asserting
/// wrapper-managed reaping + for the test's adopt dance.
pub producer_manager: ChannelManager,
/// The producer-side call plane (`Arc<CallConnection>`) — the
/// forwarded-op caller's surface (and the test's direct-call
/// surface for the adopt-pin dance).
pub producer_call: Arc<CallConnection>,
/// What the establisher's per-call auth carried (the CF-006 probe).
pub identity_witness: Arc<tokio::sync::Mutex<Option<String>>>,
}
pub async fn wire_listener_bindfirst(
registry: ResourceRegistry,
bind: alktunnels::producer::BindFn,
accept_loop: alktunnels::producer::BindFirstAcceptLoop,
opener_identity: Identity,
) -> ListenerTopology {
use alktunnels::producer::{
register_tunnel_bindfirst_openable, ForwardedOpener, TunnelEstablishError,
};
let producer_op_registry = Arc::new(OperationRegistry::new());
let opener_op_registry = Arc::new(OperationRegistry::new());
let (producer_manager_tx, mut producer_manager_rx) =
tokio::sync::mpsc::channel::<ChannelManager>(1);
let (producer_call_tx, mut producer_call_rx) =
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
let identity_witness = Arc::new(tokio::sync::Mutex::new(None::<String>));
let bind_witness = Arc::clone(&identity_witness);
let opener_identity_for_hook = opener_identity.clone();
// --- producer (accept side, serving): adapter + install hook ------
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, _auth| {
let registry_arc = Arc::clone(&producer_op_registry);
let resource_registry = registry.clone();
let bind = Arc::clone(&bind);
let accept_loop = Arc::clone(&accept_loop);
let witness = Arc::clone(&bind_witness);
let manager_tx = producer_manager_tx.clone();
let call_tx = producer_call_tx.clone();
let caller = opener_identity_for_hook.clone();
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
// The accept-side transport-identity posture (modeled): the
// transport authenticated the dialer (the opener); attach
// the resolved identity to channel 0 so the serving
// dispatch sees the caller (CF-005 (b)'s accept-side
// analogue).
let _ = channel0_conn.set_identity(caller);
let call_connection = Arc::new(CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
));
// Register the generic channel ops + the bind-first
// openable (the producer.md recipe) before the dispatch
// loop runs.
let core = alkcall::channels::operations::ChannelCore::new(
manager.clone(),
alkcall::channels::policy::default_policy(),
);
let ops = ChannelOperations::with_default_policy(manager.clone());
ops.register_on(&registry_arc)
.expect("register channel ops on producer");
// The forwarded-open caller (the call plane): call + adopt
// (the reverse POC's `open_reverse_channel` shape — the
// serving session's `CallConnection` is the per-accept call
// plane; the producer-side manager adopts the
// opener-allocated ID).
let call_for_opener = Arc::clone(&call_connection);
let manager_for_opener = manager.clone();
let open_forwarded: ForwardedOpener = Arc::new(
move |forwarded: alktunnels::params::TunnelForwardedParams| {
let call = Arc::clone(&call_for_opener);
let manager = manager_for_opener.clone();
Box::pin(async move {
let input = serde_json::to_value(&forwarded).map_err(|e| {
TunnelEstablishError::HandlerError(format!(
"forwarded params serialize: {e}"
))
})?;
let response = call
.call(alktunnels::params::OP_TUNNEL_FORWARDED, input)
.await;
let out = response.result.map_err(|e| {
TunnelEstablishError::HandlerError(format!(
"forwarded open: {}",
e.message
))
})?;
let channel_id =
out.get("channel_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| {
TunnelEstablishError::HandlerError(
"forwarded open reply missing channel_id".to_string(),
)
})? as u32;
let (send, recv) = manager
.adopt_channel(channel_id, alktunnels::params::TUNNEL_ALPN, None)
.await
.map_err(|e| {
TunnelEstablishError::HandlerError(format!("forwarded adopt: {e}"))
})?;
Ok((send, recv))
})
},
);
register_tunnel_bindfirst_openable(
&core,
&resource_registry,
&registry_arc,
AuthContext::anonymous(b"alk/tunnel"),
alktunnels::producer::BindFirstInjections {
bind,
accept_loop,
open_forwarded,
},
Some(witness),
)
.expect("register tunnel bindfirst openable");
let _ = manager_tx.send(manager).await;
let _ = call_tx.send(Arc::clone(&call_connection)).await;
let dp = Dispatcher::new(
registry_arc,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
);
dp.serve_single_stream(call_connection, reader, writer)
.await;
})
});
// --- transport ------------------------------------------------------
let (opener_end, producer_end) = tokio::io::duplex(64 * 1024);
let opener_conn = CoreConnection::from_bidi(opener_end, b"alk/channels".to_vec(), None);
let producer_conn = CoreConnection::from_bidi(producer_end, b"alk/channels".to_vec(), None);
// The dialer's transport identity (the mTLS/QUIC posture) — set
// before from_connection_with_serving. The accept side resolves it
// out-of-band (modeled in the hook above).
opener_conn
.set_identity(opener_identity.clone())
.expect("transport identity set once");
let adapter = ChannelsAdapter::new(install_hook, Arc::new(alkcall::channels::policy::NoCap));
let producer_transport_auth = AuthContext::anonymous(b"alk/channels");
let _adapter_task = tokio::spawn(async move {
let _ = alkcall::core::types::ProtocolHandler::handle(
&adapter,
producer_conn,
&producer_transport_auth,
)
.await;
});
// --- opener (connect side): client + serving (the forwarded op) -----
let opener_client = ChannelClient::from_connection_with_serving(
opener_conn,
Some(ServingConfig {
registry: Arc::clone(&opener_op_registry),
identity_provider: Arc::new(alkcall::core::auth::NoopIdentityProvider),
identity: None,
}),
)
.await
.expect("opener channel client init");
let opener_client = Arc::new(opener_client);
let producer_manager = producer_manager_rx
.recv()
.await
.expect("producer manager captured");
let producer_call = producer_call_rx
.recv()
.await
.expect("producer call plane captured");
ListenerTopology {
opener: opener_client,
opener_registry: opener_op_registry,
producer_manager,
producer_call,
identity_witness,
}
}
/// An echo dial closure (the ADR-004 injection point under test): an
/// in-process pipe per dial — the "target" echoes bytes until EOF.
/// The consumer side gets split `DuplexStream` halves; a spawned task
+474
View File
@@ -0,0 +1,474 @@
//! Tests for the `local` feature's BIND-FIRST half (shape 3, ADR-008):
//! the real-socket `BindFn` sources (`bind_tcp_first` /
//! `bind_unix_first` — the `bound` reply field's data, port 0
//! resolved), the accept-pair loops (peer + halves per accept), and
//! the assembly convenience closures (`local_bind_first` +
//! `local_bindfirst_accept_loop`), through the real bind-first
//! establisher path over REAL sockets.
//!
//! Covered (the task's test plan): the bound address is the REAL
//! OS-chosen one (a fake `bound` would strand the test's dials), two
//! clients dial → two accepts → two forwarded channels pumping
//! independently (peer fidelity: each forwarded open carries the REAL
//! accepted peer), unix bind-first parity (OQ-TN-14 — the bind path is
//! the same shape as the dial path), and the udp bind-first refusal
//! (a UDP socket has no accept).
#![cfg(feature = "local")]
mod harness;
use std::sync::Arc;
use std::time::Duration;
use alkcall::channels::operations::{ChannelCore, OpenHandler};
use alkcall::core::auth::AuthContext;
use alkcall::protocol::wire::CallError;
use alktunnels::params::{parse_listen_reply, Substrate, SubstrateAddr, TunnelForwardedParams};
use alktunnels::producer::ResourceRegistry;
use serde_json::Value;
use tracing_subscriber::EnvFilter;
fn init_tracing() {
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.try_init();
}
/// The forwarded-op recording stub's peer log: the `peer` param of
/// every forwarded open the producer's dispatcher made (the
/// reply#2-fidelity probe surface — ADR-008 §3).
type PeerLog = Arc<tokio::sync::Mutex<Vec<SubstrateAddr>>>;
/// The forwarded-op opener-side serving stub (the queue-fed shape):
/// each forwarded open pops the NEXT app-side pair (the "local BIND
/// client" halves the test registered) and pumps the forwarded
/// channel against it, recording the open's `peer` param (the
/// reply#2-fidelity probe surface — ADR-008 §3). One listener session
/// = one token = MANY forwarded opens (per accept), so the
/// correlation is a queue, not a one-shot map.
fn recording_forwarded_handler(app_sides: AppSideQueue, peers: PeerLog) -> OpenHandler {
Arc::new(
move |input: Value, _plan, conn: alkcall::core::types::Connection, _auth| {
let app_sides = Arc::clone(&app_sides);
let peers = Arc::clone(&peers);
tokio::spawn(async move {
let forwarded: TunnelForwardedParams = match serde_json::from_value(input) {
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "recording stub: params parse failed");
return;
}
};
peers.lock().await.push(forwarded.peer.clone());
let target = app_sides.lock().await.pop_front();
let Some(target) = target else {
tracing::error!(
listen = forwarded.listen,
"recording stub: no app-side pair registered"
);
return;
};
let Ok(bidi) = conn.accept_bi().await else {
tracing::error!("recording stub: accept_bi failed");
return;
};
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
})
},
)
}
/// The app-side pairs the stub pops per forwarded open (the "local
/// BIND client" halves — the stub's pump target; the far end is the
/// test's echo task).
type AppSideQueue =
Arc<tokio::sync::Mutex<std::collections::VecDeque<alktunnels::producer::TargetHandle>>>;
/// Install the recording stub on the opener (consumer) side (post-hoc
/// on the captured serving registry — W2).
async fn install_recording_stub(topo: &harness::Topology) -> (AppSideQueue, PeerLog) {
let app_sides: AppSideQueue = Arc::new(tokio::sync::Mutex::new(Default::default()));
let peers: PeerLog = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let core = ChannelCore::new(
topo.consumer_manager.clone(),
alkcall::channels::policy::default_policy(),
);
core.register_openable(
alktunnels::params::tunnel_forwarded_spec(),
recording_forwarded_handler(Arc::clone(&app_sides), Arc::clone(&peers)),
&topo.consumer_registry,
AuthContext::anonymous(b"alk/tunnel"),
)
.expect("register recording forwarded stub on the opener");
(app_sides, peers)
}
/// Call the producer's open op on channel 0 with the FULL reply (the
/// bind-first reply projection probe — `bound` + `listen`).
async fn open_listen_with_reply(
topo: &harness::Topology,
resource: &str,
substrate: Substrate,
) -> Result<(u32, Value), CallError> {
let substrate_str = match substrate {
Substrate::Tcp => "tcp",
Substrate::Udp => "udp",
Substrate::Unix => "unix",
};
let payload = serde_json::json!({
"operationId": "channels/tunnel/sub",
"input": {
"resource": resource,
"substrate": substrate_str,
},
});
let response = topo.consumer_call.call_with_payload(payload).await;
let out = response.result?;
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
Ok((channel_id, out))
}
/// One app-side session: a raw duplex pair whose FAR end echoes (the
/// app-side session shape) — the stub pumps the forwarded channel
/// against the near end (pushed into the stub's queue); the test
/// drives the REAL accepted socket at the tunnel's other side, whose
/// bytes echo through the app-side session.
fn echo_app_side() -> alktunnels::producer::TargetHandle {
let (a, b) = tokio::io::duplex(64 * 1024);
let (a_read, a_write) = tokio::io::split(a);
let (b_read, b_write) = tokio::io::split(b);
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (mut r, mut w) = (b_read, b_write);
let mut buf = vec![0u8; 8192];
loop {
match r.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
if w.write_all(&buf[..n]).await.is_err() {
return;
}
}
}
}
});
alktunnels::producer::TargetHandle {
read: Box::new(a_read),
write: Box::new(a_write),
}
}
/// THE bind-first flow over REAL TCP sockets (the `local` shape-3
/// gate): `bind_tcp_first` binds port 0 → the reply's `bound` is the
/// REAL OS-chosen address (the test dials it — a fake bound would
/// strand the dial) → two clients dial → two accepts → two forwarded
/// channels → both pump independently, and each forwarded open
/// carries the REAL accepted peer (peer fidelity, reply#2 class).
#[tokio::test]
async fn bindfirst_tcp_real_sockets_bound_fidelity_and_two_pumps() {
init_tracing();
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "127.0.0.1:0")
.await;
let topo = harness::wire_bindfirst(
registry,
alktunnels::local::local_bind_first(),
alktunnels::local::local_bindfirst_accept_loop(),
)
.await;
let (channel_id, reply) = open_listen_with_reply(&topo, "bind", Substrate::Tcp)
.await
.expect("listen open");
let projected = parse_listen_reply(&reply).expect("projected reply");
let bind_addr = match &projected.bound {
Some(SubstrateAddr::HostPort { host, port }) => {
assert_eq!(host, "127.0.0.1", "the bound host is the bound loopback");
assert_ne!(*port, 0, "port 0 was resolved to the OS-chosen port");
format!("{host}:{port}")
}
other => panic!("bind-first open carries the real bound address: {other:?}"),
};
let token = projected.listen.expect("the `listen` token");
assert_ne!(token, 0, "token 0 is never minted (the wire-illegal value)");
let (app_sides, peers) = install_recording_stub(&topo).await;
// The opener adopts the LISTEN channel (the idle side). The
// halves are HELD — dropping them EOFs the idle stream and tears
// the listener down (the ADR-008 sentinel is live even here).
let (_idle_send, _idle_recv) = topo
.consumer_manager
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
.await
.expect("adopt listen channel");
// Two app-side sessions (the stub pops one per forwarded open).
app_sides.lock().await.push_back(echo_app_side());
app_sides.lock().await.push_back(echo_app_side());
// Two clients dial the reported bound address (the reply#1
// fidelity pin: `bound` is the REAL address, not a placeholder).
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut client1 = tokio::net::TcpStream::connect(&bind_addr)
.await
.expect("client 1 dials the bound address");
let mut client2 = tokio::net::TcpStream::connect(&bind_addr)
.await
.expect("client 2 dials the bound address");
// Both pump independently: interleaved writes, per-socket echoes.
client1.write_all(b"client-one").await.expect("write 1");
client2.write_all(b"client-two").await.expect("write 2");
let mut buf1 = vec![0u8; 10];
let mut buf2 = vec![0u8; 10];
let (r1, r2) = tokio::join!(
tokio::time::timeout(Duration::from_secs(5), client1.read_exact(&mut buf1)),
tokio::time::timeout(Duration::from_secs(5), client2.read_exact(&mut buf2)),
);
r1.expect("round trip 1").expect("read 1");
r2.expect("round trip 2").expect("read 2");
assert_eq!(&buf1, b"client-one", "pump 1 serves ITS socket");
assert_eq!(&buf2, b"client-two", "pump 2 serves ITS socket");
// Peer fidelity: each forwarded open carried the REAL accepted
// peer (loopback + a real ephemeral port; distinct per socket).
let logged = peers.lock().await.clone();
assert_eq!(logged.len(), 2, "one forwarded open per accept");
let mut ports = std::collections::HashSet::new();
for peer in &logged {
match peer {
SubstrateAddr::HostPort { host, port } => {
assert_eq!(host, "127.0.0.1", "the REAL peer host");
assert_ne!(*port, 0, "the REAL peer port");
ports.insert(*port);
}
other => panic!("tcp peer is HostPort-shaped: {other:?}"),
}
}
assert_eq!(ports.len(), 2, "two accepts carried distinct real peers");
}
/// Unix bind-first parity (OQ-TN-14 — the bind path ships with the
/// dial path): `bind_unix_first` binds a temp-path listener → the
/// reply's `bound` is the REAL bound socket path → a unix client
/// (bound to its own path, so the peer is representable) connects →
/// one accept → one forwarded channel carrying the REAL peer path →
/// byte round-trip.
#[tokio::test]
async fn bindfirst_unix_real_sockets_bound_and_pumped() {
init_tracing();
let dir = std::env::temp_dir().join(format!("alktunnels-bindfirst-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let listen_path = dir.join("listen.sock");
let client_path = dir.join("client.sock");
let _ = std::fs::remove_file(&listen_path);
let _ = std::fs::remove_file(&client_path);
let listen_str = listen_path.to_string_lossy().into_owned();
let registry = ResourceRegistry::new();
registry
.register("ubind", Substrate::Unix, &listen_str)
.await;
let topo = harness::wire_bindfirst(
registry,
alktunnels::local::local_bind_first(),
alktunnels::local::local_bindfirst_accept_loop(),
)
.await;
let (channel_id, reply) = open_listen_with_reply(&topo, "ubind", Substrate::Unix)
.await
.expect("listen open");
let projected = parse_listen_reply(&reply).expect("projected reply");
assert_eq!(
projected.bound,
Some(SubstrateAddr::Path {
path: listen_str.clone()
}),
"the `bound` field is the REAL bound socket path"
);
let token = projected.listen.expect("the `listen` token");
assert_ne!(token, 0, "token 0 is never minted (the wire-illegal value)");
let (app_sides, peers) = install_recording_stub(&topo).await;
// The opener adopts the LISTEN channel (the idle side) — held for
// the test's lifetime (dropping the halves EOFs the idle stream
// and tears the listener down, the ADR-008 sentinel).
let (_idle_send, _idle_recv) = topo
.consumer_manager
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
.await
.expect("adopt listen channel");
// One app-side session (the stub pops it for the forwarded open).
app_sides.lock().await.push_back(echo_app_side());
// The unix client binds its own socket at a named path FIRST (an
// unnamed peer cannot be represented in the forwarded `peer`
// param — ADR-008 §3; the named-peer shape is the parity case).
// socket2: std has no `UnixStream::bind`; a socket2 socket binds
// the sockaddr at the path, then connects (the peer address is
// the client's own bound path).
let client_sock = socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None)
.expect("socket2 client");
client_sock
.bind(&socket2::SockAddr::unix(&client_path).expect("client sockaddr"))
.expect("bind client path");
client_sock
.connect(&socket2::SockAddr::unix(&listen_path).expect("listen sockaddr"))
.expect("client dials");
let client_std: std::os::unix::net::UnixStream = client_sock.into();
client_std.set_nonblocking(true).expect("nonblocking");
let mut client = tokio::net::UnixStream::from_std(client_std).expect("tokio client");
use tokio::io::{AsyncReadExt, AsyncWriteExt};
client.write_all(b"unix-bind").await.expect("write");
let mut buf = vec![0u8; 9];
tokio::time::timeout(Duration::from_secs(5), client.read_exact(&mut buf))
.await
.expect("round trip")
.expect("read");
assert_eq!(&buf, b"unix-bind");
// Peer fidelity: the forwarded open carried the client's REAL
// bound path.
let logged = peers.lock().await.clone();
assert_eq!(
logged,
vec![SubstrateAddr::Path {
path: client_path.to_string_lossy().into_owned()
}],
"the forwarded open carried the REAL accepted peer path"
);
let _ = std::fs::remove_file(&listen_path);
let _ = std::fs::remove_file(&client_path);
}
/// The udp bind-first refusal: a UDP socket has no accept, so the
/// bind closure fails loud (`handler_error`-class) — the shape is not
/// defined there, never a silent fake listener.
#[tokio::test]
async fn udp_bind_first_fails_loud() {
let registry = ResourceRegistry::new();
registry
.register("ubind", Substrate::Udp, "127.0.0.1:0")
.await;
let topo = harness::wire_bindfirst(
registry,
alktunnels::local::local_bind_first(),
alktunnels::local::local_bindfirst_accept_loop(),
)
.await;
let payload = serde_json::json!({
"operationId": "channels/tunnel/sub",
"input": {"resource": "ubind", "substrate": "udp"},
});
let response = topo.consumer_call.call_with_payload(payload).await;
let err = response.result.expect_err("udp bind-first must fail");
assert_eq!(err.code, "channel:open_failed");
let reason = err
.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str())
.expect("reason in details");
assert_eq!(reason, "handler_error");
}
/// The accept-pair loop's mismatch probe: a listener NOT produced by
/// this module's bind closures fails loud inside the closure (the
/// downcast is the assembly side of the type-erased injection — a
/// foreign listener type is `handler_error`, never a phantom accept).
#[tokio::test]
async fn accept_pair_loop_downcast_miss_fails_loud() {
let loop_ = alktunnels::local::local_bindfirst_accept_loop();
let foreign: Arc<dyn std::any::Any + Send + Sync> = Arc::new(4242u32);
let err = loop_(foreign)
.await
.expect_err("a foreign listener fails loud");
assert!(
matches!(
err,
alktunnels::producer::TunnelEstablishError::HandlerError(_)
),
"the downcast miss is handler_error-class: {err:?}"
);
}
/// The unnamed-peer/unnamed-bind fail-loud arms (G-5 — ADR-008 §3's
/// two pinned sentences: a producer that cannot determine the peer
/// does not serve a bind-first listener over that substrate):
///
/// - **Unnamed bind** — an empty-path `bind_unix` is the kernel's
/// autobind (the kernel assigns an abstract name, addlen ==
/// offsetof(sun_path)); the bound addr has no filesystem path, so
/// `local_addr` (and thus `bind_unix_first`) fails loud with the
/// "no filesystem path" handler error instead of serving an
/// unrepresentable `bound`.
/// - **Unnamed peer** — an addressless client (socket2 autobind
/// here; a plain unbound client behaves identically through tokio
/// accept — mio maps any abstract-namespace peer to unnamed) is
/// accepted by a NAMED listener; `accept_pair` fails loud because
/// the forwarded op's `peer` param is required on the wire.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn unix_unnamed_bind_and_unnamed_peer_fail_loud() {
// Arm 1: the autobind listener — `bind_unix("")`. tokio passes the
// empty path through to `bind(2)` with addlen == offsetof(sun_path):
// the kernel autobinds an abstract name, no filesystem path exists.
let halves = alktunnels::local::bind_unix("")
.expect("autobind bind succeeds (the kernel assigns the abstract name)");
let err = halves
.local_addr()
.expect_err("an autobound listener has no filesystem path");
assert!(
matches!(
err,
alktunnels::producer::TunnelEstablishError::HandlerError(ref m)
if m.contains("no filesystem path"),
),
"unnamed bind maps to the handler error: {err:?}"
);
// Arm 2: the unnamed peer — a socket2 autobind client connects to a
// NAMED listener; `accept_pair` must refuse (the forwarded `peer`
// param requires an address).
let dir = std::env::temp_dir().join(format!("alktunnels-g5-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let listen_path = dir.join("named.sock");
let _ = std::fs::remove_file(&listen_path);
let named =
alktunnels::local::bind_unix(&listen_path.to_string_lossy()).expect("bind named listener");
assert!(named.local_addr().is_ok(), "the named listener has a path");
let client = socket2::Socket::new(socket2::Domain::UNIX, socket2::Type::STREAM, None)
.expect("socket2 client");
client
.bind(&socket2::SockAddr::unix("").expect("empty sockaddr (the autobind shape)"))
.expect("autobind client bind");
client
.connect(&socket2::SockAddr::unix(&listen_path).expect("listen sockaddr"))
.expect("autobind client dials");
let err = named
.accept_pair()
.await
.expect_err("an unnamed peer cannot be represented in the forwarded op");
assert!(
matches!(
err,
alktunnels::producer::TunnelEstablishError::HandlerError(ref m)
if m.contains("unnamed peer"),
),
"unnamed peer maps to the handler error: {err:?}"
);
let _ = std::fs::remove_file(&listen_path);
}
+35 -2
View File
@@ -14,11 +14,11 @@ mod harness;
use std::sync::Arc;
use alktunnels::params::{Substrate, TunnelParams};
use alktunnels::params::{Substrate, SubstrateAddr, TunnelParams};
use alktunnels::producer::ResourceRegistry;
use alktunnels::TunnelSession;
use harness::wire_forward;
use harness::{wire_direct_forward, wire_forward};
/// A TCP echo server on an ephemeral port.
async fn tcp_echo_server() -> std::net::SocketAddr {
@@ -111,6 +111,39 @@ async fn udp_datagram_round_trip_through_the_framed_adapter() {
session.close().await;
}
#[tokio::test]
async fn direct_udp_through_local_dial_round_trips_framed() {
// The DIRECT op's DialFn reuse pin (the local-bind-halves task):
// `local_dial` is the ONE dial closure the base op and the direct
// op share — the UDP wrap (the FRAMED `UdpHalf` adapter, ADR-003)
// happens INSIDE the closure (review 001 U-1), so direct-udp gets
// datagram-boundary framing free. Driven against a REAL UDP echo
// socket: the session sends the codec frame, the direct establisher
// dials via `local_dial`, the framed adapter delivers ONE datagram
// to the target, and the echo returns through the same path.
let addr = udp_echo_server().await;
let topo = wire_direct_forward(alktunnels::local::local_dial()).await;
let mut session = TunnelSession::open_direct(
&topo.consumer,
SubstrateAddr::HostPort {
host: "127.0.0.1".to_string(),
port: addr.port(),
},
Substrate::Udp,
)
.await
.expect("udp direct open through local_dial");
session.send_datagram(b"direct-udp").await.expect("send");
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
.await
.expect("recv timed out")
.expect("io ok")
.expect("datagram");
assert_eq!(&dg[..], b"direct-udp");
session.close().await;
}
#[tokio::test]
async fn udp_large_datagram_through_bounded_buffers() {
// The POC's sizing test: a 1400-byte datagram (max ethernet MTU
+772
View File
@@ -0,0 +1,772 @@
//! Integration tests for the bind-first establisher + accept
//! dispatcher (shape 3 — ADR-008 §1): the producer whose establisher
//! BINDS (via the injected `BindFn`), mints the correlation token,
//! contributes `bound` + `listen` to the open reply, and whose pump
//! handler is the accept dispatcher — per accept it opens
//! `channels/tunnel/forwarded` back toward the listen opener and pumps
//! the accepted halves against the adopted channel.
//!
//! Covered (the task's test plan): the full bind-first flow e2e (the
//! acceptance gate), the reply-field projection (both fields seen; a
//! dial open sees neither), EOF teardown (the idle-stream sentinel
//! cancels the loop; the far listener is gone), per-accept failure
//! isolation (a rejected forwarded open drops that accept; the loop
//! serves the next), the typed establishment errors (bind failure →
//! `dial_failed`; registry miss → `unknown_resource`), and the token
//! discipline (two opens mint distinct tokens; each dispatcher stamps
//! ITS OWN plan's token).
mod harness;
use tracing_subscriber::EnvFilter;
fn init_tracing() {
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.try_init();
}
use std::sync::Arc;
use alkcall::protocol::wire::CallError;
use alktunnels::params::{parse_listen_reply, Substrate, SubstrateAddr, TunnelParams};
use alktunnels::producer::{
BindFirstAcceptLoop, BindFn, BoundListener, ResourceRegistry, TargetHandle,
TunnelEstablishError,
};
use harness::{
make_forwarded_stub_handler, wire, wire_bindfirst, wire_with, CorrelationMap, RegistrationMode,
Topology,
};
fn listen_params(resource: &str) -> TunnelParams {
TunnelParams {
resource: resource.to_string(),
substrate: Substrate::Tcp,
}
}
/// Call the producer's open op on channel 0 with the FULL reply
/// (the consumer's bind-first surface — the reply projection probe).
async fn open_listen_with_reply(
topo: &Topology,
params: &TunnelParams,
) -> Result<(u32, serde_json::Value), CallError> {
let payload = serde_json::json!({
"operationId": "channels/tunnel/sub",
"input": serde_json::json!({
"resource": params.resource,
"substrate": "tcp",
}),
});
let response = topo.consumer_call.call_with_payload(payload).await;
let out = response.result?;
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
Ok((channel_id, out))
}
/// The in-process "accepted connection" the test accept loop yields:
/// a duplex pair, the far end an echo task (the same shape the dial
/// harness echoes with). The peer address is the loop's choice.
fn accepted_halves(peer_addr: (u32, u16)) -> (SubstrateAddr, TargetHandle) {
let (consumer_side, target_side) = tokio::io::duplex(64 * 1024);
let (mut t_read, mut t_write) = tokio::io::split(target_side);
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = vec![0u8; 8192];
loop {
match t_read.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
if t_write.write_all(&buf[..n]).await.is_err() {
return;
}
}
}
}
});
let (c_read, c_write) = tokio::io::split(consumer_side);
(
SubstrateAddr::HostPort {
host: peer_addr.0.to_string(),
port: peer_addr.1,
},
TargetHandle {
read: Box::new(c_read),
write: Box::new(c_write),
},
)
}
/// The test accept loop's feed point (the test plays the app server):
/// push an accepted pair; the dispatcher's loop picks it up.
async fn push_accept(
queue: &Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
notify: &Arc<tokio::sync::Notify>,
peer: (u32, u16),
) {
let (peer_addr, handle) = accepted_halves(peer);
queue.lock().await.push_back((peer_addr, handle));
notify.notify_waiters();
}
/// The assembly's bind: produces a "listener" (the queue's identity)
/// and the OS-chosen bound address (the test's fake — a real
/// `bind_tcp` variant lives in the `local` feature).
fn test_bind() -> BindFn {
Arc::new(|_substrate, _backing| {
Box::pin(async {
Ok(BoundListener {
listener: Arc::new(4242u32) as Arc<dyn std::any::Any + Send + Sync>,
bound: SubstrateAddr::HostPort {
host: "127.0.0.1".to_string(),
port: 42113,
},
})
})
})
}
/// A failing bind (the `dial_failed`-class probe — the bind IS the
/// dial here).
fn failing_bind(message: &'static str) -> BindFn {
Arc::new(move |_substrate, _backing| {
Box::pin(async move { Err(TunnelEstablishError::DialFailed(message.to_string())) })
})
}
fn only_channel_0(topo: &Topology) -> bool {
topo.consumer_manager
.channel_ids()
.iter()
.all(|&id| id == 0)
&& topo
.producer
.manager()
.channel_ids()
.iter()
.all(|&id| id == 0)
}
/// The bind-first topology keeps the LISTEN channel open (the
/// dispatcher holds it) — the "no stray channels" probe excludes it
/// (and channel 0, the call plane) on both sides.
fn no_stray_channels(topo: &Topology, listen_id: u32) -> bool {
topo.consumer_manager
.channel_ids()
.iter()
.all(|&id| id == 0 || id == listen_id)
&& topo
.producer
.manager()
.channel_ids()
.iter()
.all(|&id| id == 0 || id == listen_id)
}
/// The forwarded-op serving stub on the opener (consumer) side +
/// its correlation map (the assembly-side bookkeeping the real
/// opener-side registration owns).
async fn install_forwarded_stub(topo: &Topology) -> CorrelationMap {
let correlations: CorrelationMap = Arc::new(tokio::sync::Mutex::new(Default::default()));
let core = alkcall::channels::operations::ChannelCore::new(
topo.consumer_manager.clone(),
alkcall::channels::policy::default_policy(),
);
core.register_openable(
alktunnels::params::tunnel_forwarded_spec(),
make_forwarded_stub_handler(Arc::clone(&correlations)),
&topo.consumer_registry,
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
)
.expect("register forwarded stub on the opener");
correlations
}
#[tokio::test]
async fn bindfirst_full_flow_end_to_end() {
init_tracing();
// THE acceptance gate (ADR-008): opener opens `channels/tunnel/sub`
// with the bind-first producer registered → the reply carries
// `bound` + `listen` → the opener serves `channels/tunnel/forwarded`
// with a correlation map keyed on the token → the test accept loop
// accepts → the producer's dispatcher opens forwarded with
// `{listen: token, substrate, peer}` → the opener correlates,
// pumps against the local BIND client halves → byte round-trip
// through the forwarded channel.
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
let notify = Arc::new(tokio::sync::Notify::new());
let topo = wire_bindfirst(
registry,
test_bind(),
harness_queue_loop(queue.clone(), notify.clone()),
)
.await;
// The opener opens the listen tunnel (with-reply surface — the
// bind-first reply projection).
let (channel_id, reply) = open_listen_with_reply(&topo, &listen_params("bind"))
.await
.expect("listen open");
// Reply-field projection: `bound` + `listen` ride the success
// output alongside the wrapper's `channel_id`.
let projected = parse_listen_reply(&reply).expect("projected reply");
assert_eq!(
projected.bound,
Some(SubstrateAddr::HostPort {
host: "127.0.0.1".to_string(),
port: 42113,
}),
"the `bound` field is the OS-chosen (test-chosen) address"
);
let token = projected.listen.expect("the `listen` token");
assert!(token > 0);
// The opener correlates on the token (the assembly-side map).
let correlations = install_forwarded_stub(&topo).await;
// The LOCAL BIND client (the app-server-side session on the
// opener): a raw duplex pair — the map's halves are the stub's
// pump target, the far end is the test's client end. The stub's
// `pump_bidi` bridges the forwarded channel ↔ these halves, so
// bytes the test writes on the far end traverse the whole path
// (local BIND client ↔ opener stub pump ↔ forwarded channel ↔
// producer-side pump ↔ accepted handle's echo task) and echo back.
let (client_read, client_write, l_read, l_write) = bind_client_pair();
correlations.lock().await.insert(
token,
TargetHandle {
read: Box::new(l_read),
write: Box::new(l_write),
},
);
// The opener adopts the LISTEN channel (the idle side — its EOF
// is the teardown signal).
topo.consumer_manager
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
.await
.expect("adopt listen channel");
// The test accept loop accepts (the app server dialed).
push_accept(&queue, &notify, (198, 55231)).await;
// The dispatcher opens forwarded; the opener's stub handler
// correlates by token and pumps against the local halves. The
// forwarded channel ID arrives on the opener's manager — find it
// (the only non-0, non-listen channel on the opener side after
// the adopt + forwarded open settle).
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let forwarded_ids: Vec<u32> = topo
.consumer_manager
.channel_ids()
.into_iter()
.filter(|&id| id != 0 && id != channel_id)
.collect();
assert_eq!(forwarded_ids.len(), 1, "one forwarded channel arrived");
// The round-trip: bytes the test writes on the local BIND
// client's end cross the stub's pump → the forwarded channel →
// the producer's per-accept pump → the accepted handle's echo →
// and back (the full bind-first data path, ADR-008's gate).
let (mut client_read, mut client_write) = (client_read, client_write);
use tokio::io::{AsyncReadExt, AsyncWriteExt};
client_write.write_all(b"bind-ping").await.expect("write");
client_write.flush().await.expect("flush");
let mut buf = vec![0u8; 9];
tokio::time::timeout(
std::time::Duration::from_secs(5),
client_read.read_exact(&mut buf),
)
.await
.expect("round trip")
.expect("read");
assert_eq!(&buf, b"bind-ping");
// Teardown follows the accepted side: the far echo closes when the
// accepted handle's pump sees the test client's EOF. (No channel
// assertions here — the listen channel legitimately stays open.)
}
/// The listen-channel EOF teardown: drop the listener session (close
/// the listen channel's stream) → the accept loop cancels, the far
/// listener is gone (a follow-on accept attempt fails), and the
/// forwarded channels already adopted own their teardown
/// independently.
#[tokio::test]
async fn listen_channel_eof_cancels_loop_and_tears_listener_down() {
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
let notify = Arc::new(tokio::sync::Notify::new());
let topo = wire_bindfirst(
registry,
test_bind(),
harness_queue_loop(queue.clone(), notify.clone()),
)
.await;
let (channel_id, reply) = open_listen_with_reply(&topo, &listen_params("bind"))
.await
.expect("listen open");
let token = parse_listen_reply(&reply)
.expect("projected")
.listen
.expect("token");
// The opener adopts the listen channel and then DROPS its stream
// (the listener session is dropped — the zero-byte EOF sentinel).
let (send, _recv) = topo
.consumer_manager
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
.await
.expect("adopt listen channel");
drop(send);
// The opener-side teardown reaps the listen channel.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert!(
!topo.producer.manager().has_channel(channel_id),
"the listen channel reaped after the session dropped"
);
// A follow-on accept attempt: the loop is gone — the accept queue
// accepts nothing new (the listener was torn down with the plan).
// Observable via the queue: nothing pumps it anymore.
let _ = token;
push_accept(&queue, &notify, (198, 55232)).await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(
queue.lock().await.len(),
1,
"the accept loop is gone: the pushed accept was never consumed"
);
}
/// Per-accept failure isolation: the opener's forwarded registry
/// rejects one forwarded open (scope-less identity) → that accept's
/// connection drops, the loop serves the next accept.
#[tokio::test]
async fn forwarded_open_failure_isolates_the_accept() {
init_tracing();
// The topology's consumer identity holds `tunnel:open` but NOT
// `tunnel:forwarded` — every forwarded open fails FORBIDDEN at the
// opener's ACL. The loop must keep serving (a second accept
// produces a second forwarded-open attempt).
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
let notify = Arc::new(tokio::sync::Notify::new());
let topo = wire_with(
registry,
RegistrationMode::BindFirst(
test_bind(),
harness_queue_loop(queue.clone(), notify.clone()),
),
Some(harness::forwarded_scopeless_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let (listen_id, _reply) = open_listen_with_reply(&topo, &listen_params("bind"))
.await
.expect("listen open");
// First accept: the forwarded open fails (scope-less opener) —
// the accepted connection drops, the loop continues.
push_accept(&queue, &notify, (198, 55233)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert!(
no_stray_channels(&topo, listen_id),
"a failed forwarded open leaves no channel beyond the listen channel"
);
// Second accept: the loop is still alive (per-accept isolation) —
// this one fails the same way, but the LOOP's health is what the
// test pins: the accept was CONSUMED (the queue drained).
push_accept(&queue, &notify, (198, 55234)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let remaining = queue.lock().await.len();
assert_eq!(
remaining, 0,
"the second accept was consumed too (the loop served it)"
);
assert!(no_stray_channels(&topo, listen_id));
}
/// Establishment error paths: bind failure → `dial_failed`-class on
/// the wire (`channel:open_failed`); registry miss →
/// `unknown_resource`.
#[tokio::test]
async fn establishment_errors_are_typed_on_the_wire() {
// Bind failure → dial_failed (the bind IS the dial here).
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let topo = wire_bindfirst(
registry,
failing_bind("address already in use"),
noop_loop(),
)
.await;
match open_listen_with_reply(&topo, &listen_params("bind")).await {
Ok(_) => panic!("bind failure must fail the open"),
Err(e) => {
assert_eq!(e.code, "channel:open_failed");
let reason = e
.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str())
.expect("reason in details");
assert_eq!(reason, "dial_failed");
}
}
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert!(only_channel_0(&topo), "a failed open leaves no channel");
// Registry miss → unknown_resource.
let registry = ResourceRegistry::new();
let topo = wire_bindfirst(registry, test_bind(), noop_loop()).await;
match open_listen_with_reply(&topo, &listen_params("ghost")).await {
Ok(_) => panic!("registry miss must fail the open"),
Err(e) => {
assert_eq!(e.code, "channel:open_failed");
let reason = e
.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str())
.expect("reason in details");
assert_eq!(reason, "unknown_resource");
}
}
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert!(only_channel_0(&topo));
}
/// Token discipline: two opens on the same producer mint distinct
/// tokens; each dispatcher stamps ITS OWN plan's token (the second
/// open's forwarded opens carry the second token — pinned via the
/// correlation stub's map: the FIRST token's entry is left
/// unconsumed).
#[tokio::test]
async fn token_discipline_two_opens_distinct_tokens() {
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Tcp, "assembly-owned")
.await;
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
let notify = Arc::new(tokio::sync::Notify::new());
let topo = wire_bindfirst(
registry,
test_bind(),
harness_queue_loop(queue.clone(), notify.clone()),
)
.await;
let correlations = install_forwarded_stub(&topo).await;
let (id1, reply1) = open_listen_with_reply(&topo, &listen_params("bind"))
.await
.expect("open 1");
let (id2, reply2) = open_listen_with_reply(&topo, &listen_params("bind"))
.await
.expect("open 2");
assert_ne!(id1, id2);
let t1 = parse_listen_reply(&reply1)
.expect("reply 1")
.listen
.expect("token 1");
let t2 = parse_listen_reply(&reply2)
.expect("reply 2")
.listen
.expect("token 2");
assert_ne!(t1, t2, "two opens mint distinct tokens");
// Adopt both listen channels (both sessions live).
topo.consumer_manager
.adopt_channel(id1, alktunnels::TUNNEL_ALPN, None)
.await
.expect("adopt listen 1");
topo.consumer_manager
.adopt_channel(id2, alktunnels::TUNNEL_ALPN, None)
.await
.expect("adopt listen 2");
// Both dispatchers are live. Correlate BOTH tokens (each opener
// session keys its own), but only register the local halves for
// t1 — a forwarded open stamped t2 would correlation-miss. Push
// one accept AFTER open 2: whichever dispatcher consumed it, the
// STAMPED token is the plan's own. To pin "each dispatcher stamps
// ITS OWN plan's token", correlate both but track which token the
// forwarded open carried.
correlations.lock().await.insert(t1, {
let (l_read, l_write, _, _) = bind_client_pair();
TargetHandle {
read: Box::new(l_read),
write: Box::new(l_write),
}
});
correlations.lock().await.insert(t2, {
let (l_read, l_write, _, _) = bind_client_pair();
TargetHandle {
read: Box::new(l_read),
write: Box::new(l_write),
}
});
push_accept(&queue, &notify, (198, 55235)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
// The accept was consumed by exactly one dispatcher, and the
// forwarded open succeeded (correlated by its stamped token).
assert_eq!(queue.lock().await.len(), 0, "one accept consumed");
assert_eq!(
correlations.lock().await.len(),
1,
"one token's halves were consumed"
);
}
/// The forwarded-op serving stub's forwarded-substrate log: the
/// `substrate` param of every forwarded open (the M-1 remediation
/// probe surface — the substrate's SOURCE: the plan, not a re-parse).
pub type SubstrateLog = Arc<tokio::sync::Mutex<Vec<alktunnels::params::Substrate>>>;
/// The forwarded-op serving stub variant that records each forwarded
/// open's `substrate` param, then pumps against the correlated halves
/// (the same shape as `make_forwarded_stub_handler` + the log).
fn make_substrate_recording_stub_handler(
correlations: CorrelationMap,
substrates: SubstrateLog,
) -> alkcall::channels::operations::OpenHandler {
Arc::new(
move |input: serde_json::Value,
_plan: Option<alkcall::channels::operations::ChannelPlan>,
conn: alkcall::core::types::Connection,
_auth: alkcall::core::auth::AuthContext| {
let correlations = Arc::clone(&correlations);
let substrates = Arc::clone(&substrates);
tokio::spawn(async move {
let forwarded: alktunnels::params::TunnelForwardedParams =
match serde_json::from_value(input) {
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "substrate stub: params parse failed");
return;
}
};
substrates.lock().await.push(forwarded.substrate);
let target = correlations.lock().await.remove(&forwarded.listen);
let Some(target) = target else {
tracing::error!(
listen = forwarded.listen,
"substrate stub: correlation miss"
);
return;
};
let Some(bidi) = conn.accept_bi().await.ok() else {
tracing::error!("substrate stub: accept_bi failed");
return;
};
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
})
},
)
}
/// The forwarded-op serving stub recording substrates, installed on
/// the opener (consumer) side (the same post-hoc registration posture
/// as `install_forwarded_stub`).
async fn install_substrate_recording_stub(
topo: &Topology,
substrates: SubstrateLog,
) -> CorrelationMap {
let correlations: CorrelationMap = Arc::new(tokio::sync::Mutex::new(Default::default()));
let core = alkcall::channels::operations::ChannelCore::new(
topo.consumer_manager.clone(),
alkcall::channels::policy::default_policy(),
);
core.register_openable(
alktunnels::params::tunnel_forwarded_spec(),
make_substrate_recording_stub_handler(Arc::clone(&correlations), substrates),
&topo.consumer_registry,
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
)
.expect("register substrate-recording forwarded stub on the opener");
correlations
}
/// M-1 remediation pin: the forwarded opens' `substrate` comes from
/// the PLAN (stamped at establisher time), not a re-parse of wire
/// input — a UDP bind-first listen produces forwarded opens carrying
/// `substrate: udp` even though the re-parse-and-fallback shape would
/// have stamped `tcp` if it ever fell back. (The fallback itself is
/// REMOVED — this test pins the plan as the source by observing the
/// forwarded params through a real dispatcher round trip.)
#[tokio::test]
async fn forwarded_opens_carry_plan_stamped_substrate() {
init_tracing();
// A UDP-flavored bind-first registry: the open is
// `{resource: "bind", substrate: "udp"}` — the plan must stamp
// Udp (the old fallback shape would have stamped Tcp on any
// parse failure).
let registry = ResourceRegistry::new();
registry
.register("bind", Substrate::Udp, "assembly-owned")
.await;
let queue = Arc::new(tokio::sync::Mutex::new(std::collections::VecDeque::new()));
let notify = Arc::new(tokio::sync::Notify::new());
let topo = wire_bindfirst(
registry,
test_bind(),
harness_queue_loop(queue.clone(), notify.clone()),
)
.await;
let substrates: SubstrateLog = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let correlations = install_substrate_recording_stub(&topo, Arc::clone(&substrates)).await;
// The listen open over the UDP substrate (the harness's
// open-call helper hardcodes "tcp" — call with the udp input
// directly; the schema gate accepts the registered UDP resource).
let payload = serde_json::json!({
"operationId": "channels/tunnel/sub",
"input": {"resource": "bind", "substrate": "udp"},
});
let response = topo.consumer_call.call_with_payload(payload).await;
let reply = response.result.expect("udp listen open");
let listen_id = reply["channel_id"].as_u64().expect("channel_id") as u32;
let token = parse_listen_reply(&reply)
.expect("projected reply")
.listen
.expect("the `listen` token");
// The opener correlates on the token and adopts the listen
// channel (the idle side).
let (l_read, l_write, _, _) = bind_client_pair();
correlations.lock().await.insert(
token,
TargetHandle {
read: Box::new(l_read),
write: Box::new(l_write),
},
);
topo.consumer_manager
.adopt_channel(listen_id, alktunnels::TUNNEL_ALPN, None)
.await
.expect("adopt listen channel");
// One accept: the dispatcher opens forwarded — the forwarded
// params' `substrate` must be the PLAN's (Udp), not a fallback.
push_accept(&queue, &notify, (198, 55236)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let logged = substrates.lock().await.clone();
assert_eq!(
logged,
vec![alktunnels::params::Substrate::Udp],
"the forwarded open's substrate is plan-stamped (Udp), not re-parsed"
);
}
/// A plain dial open sees NEITHER reply field (the projection
/// property — the reserved-key guard upstream guarantees `channel_id`
/// stays wrapper-owned; a dial establisher contributes nothing).
#[tokio::test]
async fn dial_open_reply_has_neither_field() {
let registry = ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire(registry, harness::echo_dial()).await;
let payload = serde_json::json!({
"operationId": "channels/tunnel/sub",
"input": {"resource": "echo", "substrate": "tcp"},
});
let response = topo.consumer_call.call_with_payload(payload).await;
let out = response.result.expect("dial open");
let projected = parse_listen_reply(&out).expect("projected");
assert_eq!(
projected,
alktunnels::params::TunnelListenReply {
bound: None,
listen: None
},
"a dial establisher's open carries neither field"
);
}
/// The accept loop's queue-shaped stand-in (the harness_queue_loop in
/// the tests above): a closure popping from the test's VecDeque +
/// Notify pair. (Named separately from the generic helper so the
/// tests read: queue in, loop out.)
fn harness_queue_loop(
queue: Arc<tokio::sync::Mutex<std::collections::VecDeque<(SubstrateAddr, TargetHandle)>>>,
notify: Arc<tokio::sync::Notify>,
) -> BindFirstAcceptLoop {
Arc::new(move |_listener| {
let queue = Arc::clone(&queue);
let notify = Arc::clone(&notify);
Box::pin(async move {
// The queue-driven accept: check once, wait for a push,
// check again (the Notify lost-wakeup pattern — the
// `Notified` future is created BEFORE the state check on
// the waiting path). One accepted pair per closure call
// (the dispatcher invokes the closure per accept).
let popped = {
let notified = std::pin::pin!(notify.notified());
let mut q = queue.lock().await;
if let Some(pair) = q.pop_front() {
Some(pair)
} else {
drop(q);
notified.await;
let mut q = queue.lock().await;
q.pop_front()
}
};
match popped {
Some(pair) => Ok(pair),
None => Err(TunnelEstablishError::DialFailed(
"listener closed".to_string(),
)),
}
})
})
}
/// A never-accepting loop (the establishment-error probes — the loop
/// never runs: the open fails at the establisher).
fn noop_loop() -> BindFirstAcceptLoop {
Arc::new(|_listener| {
Box::pin(async { Err(TunnelEstablishError::DialFailed("noop loop".to_string())) })
})
}
/// The "local BIND client" pair (the opener-side pump target): a raw
/// duplex pair — the map's halves are the stub's pump target, the far
/// end is the test's client end (driven directly; the accepted side's
/// echo task closes the loop).
fn bind_client_pair() -> (
tokio::io::ReadHalf<tokio::io::DuplexStream>,
tokio::io::WriteHalf<tokio::io::DuplexStream>,
tokio::io::ReadHalf<tokio::io::DuplexStream>,
tokio::io::WriteHalf<tokio::io::DuplexStream>,
) {
let (a, b) = tokio::io::duplex(64 * 1024);
let (a_read, a_write) = tokio::io::split(a);
let (b_read, b_write) = tokio::io::split(b);
(b_read, b_write, a_read, a_write)
}
+558
View File
@@ -0,0 +1,558 @@
//! Integration tests for the direct op (`channels/tunnel/direct`,
//! ADR-007) end to end over the duplex harness: the params → spec →
//! establisher → registration → `TunnelSession::open_direct` chain.
//!
//! Covered: e2e round-trips through `pump_bidi` (tcp + udp — the
//! framed adapter wraps at the dial closure, datagram boundary
//! preserved), the schema-class failure for malformed targets (no
//! phantom session), the `NOT_FOUND` posture on a producer without
//! the direct op registered, the structural scope separation
//! (`tunnel:open` does NOT imply `tunnel:direct` — FORBIDDEN, and
//! vice versa), and the CF-006 per-call opener identity on the
//! direct establisher (witness).
mod harness;
use std::sync::Arc;
use alkcall::protocol::wire::CallError;
use alktunnels::params::{Substrate, SubstrateAddr};
use alktunnels::{TunnelEstablishError, TunnelSession};
use harness::{
all_substrate_dial, consumer_identity, echo_dial, failing_dial, framed_udp_echo_dial, wire,
wire_direct, wire_direct_forward, wire_direct_with_identity, Topology,
};
fn target_hostport(host: &str, port: u16) -> SubstrateAddr {
SubstrateAddr::HostPort {
host: host.to_string(),
port,
}
}
/// Call the producer's direct op on channel 0 (the raw call surface),
/// returning the wire `CallError` on failure.
async fn call_direct(
topo: &Topology,
input: serde_json::Value,
) -> Result<serde_json::Value, CallError> {
let payload = serde_json::json!({
"operationId": "channels/tunnel/direct",
"input": input,
});
let response = topo.consumer_call.call_with_payload(payload).await;
response.result
}
/// Adopt the producer-allocated channel and pump it against in-process
/// halves (the consumer-side data plane, driven manually).
async fn adopt_and_pump(
topo: &Topology,
channel_id: u32,
) -> (
tokio::io::ReadHalf<tokio::io::DuplexStream>,
tokio::io::WriteHalf<tokio::io::DuplexStream>,
tokio::task::JoinHandle<(u64, u64)>,
) {
let (send, recv) = topo
.consumer_manager
.adopt_channel(channel_id, "alk/tunnel", None)
.await
.expect("adopt producer-allocated channel");
let bidi = alkcall::core::types::BiStream::from_joined(recv, send);
let (channel_end, local_end) = tokio::io::duplex(64 * 1024);
let (c_read, c_write) = tokio::io::split(channel_end);
let pump = tokio::spawn(alkcall::channels::pump::pump_bidi(bidi, c_read, c_write));
let (l_read, l_write) = tokio::io::split(local_end);
(l_read, l_write, pump)
}
/// Only channel 0 may remain — a failed open leaves no data channel
/// on either side (the phantom-channel property).
fn no_data_channels(topo: &Topology) -> bool {
let consumer = topo.consumer_manager.channel_ids();
let producer = topo.producer.manager().channel_ids();
consumer.iter().all(|&id| id == 0) && producer.iter().all(|&id| id == 0)
}
fn establishment_reason_of(err: &CallError) -> Option<&str> {
err.details
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|r| r.as_str())
}
#[tokio::test]
async fn direct_tcp_round_trips_through_pump_bidi() {
let topo = wire_direct(echo_dial()).await;
let out = call_direct(
&topo,
serde_json::json!({
"substrate": "tcp",
"target": {"host": "db.internal", "port": 5432}
}),
)
.await
.expect("direct open must succeed");
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
assert!(topo.producer.manager().has_channel(channel_id));
let (mut read, mut write, pump) = adopt_and_pump(&topo, channel_id).await;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
write.write_all(b"ping-direct").await.expect("write");
write.flush().await.expect("flush");
let mut buf = vec![0u8; 11];
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
.await
.expect("round trip timed out")
.expect("read");
assert_eq!(&buf, b"ping-direct");
// EOF propagates and the wrapper reaps the producer-side channel.
drop(write);
drop(read);
let (c2p, p2c) = tokio::time::timeout(std::time::Duration::from_secs(5), pump)
.await
.expect("pump completion timed out")
.expect("pump task");
assert!(c2p > 0 && p2c > 0, "both pumps moved bytes: {c2p}, {p2c}");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
!topo.producer.manager().has_channel(channel_id),
"wrapper reaped the producer-side channel after pump completion"
);
}
#[tokio::test]
async fn direct_udp_round_trips_framed() {
// The UDP dial closure wraps the framed adapter (ADR-003) at the
// boundary — the establisher stays substrate-agnostic; the
// session's datagram plane sees boundary-preserved frames.
let topo = wire_direct(all_substrate_dial()).await;
let out = call_direct(
&topo,
serde_json::json!({
"substrate": "udp",
"target": {"host": "10.0.0.7", "port": 53}
}),
)
.await
.expect("udp direct open must succeed");
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
let mut session = TunnelSession::adopt(
&topo.consumer_manager,
channel_id,
Substrate::Udp,
"alk/tunnel",
)
.await
.expect("adopt into a udp session");
session
.send_datagram(b"datagram-direct")
.await
.expect("send");
let dg = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
.await
.expect("round trip timed out")
.expect("recv")
.expect("datagram");
assert_eq!(dg.as_ref(), b"datagram-direct");
session.close().await;
}
#[tokio::test]
async fn malformed_target_is_schema_class_and_leaves_no_channel() {
// The registry's input-schema gate runs before the establisher: a
// unix target without `path` (and a tcp target without `port`)
// fails as `INVALID_INPUT` — never a phantom session, never a
// establisher dial.
let topo = wire_direct(echo_dial()).await;
let err = call_direct(
&topo,
serde_json::json!({
"substrate": "unix",
"target": {"host": "nope", "port": 1}
}),
)
.await
.expect_err("unix target without path must fail the schema gate");
assert_eq!(err.code, "INVALID_INPUT");
assert!(establishment_reason_of(&err).is_none());
assert!(no_data_channels(&topo));
let err = call_direct(
&topo,
serde_json::json!({
"substrate": "tcp",
"target": {"host": "nope"}
}),
)
.await
.expect_err("tcp target without port must fail the schema gate");
assert_eq!(err.code, "INVALID_INPUT");
assert!(no_data_channels(&topo));
// Missing target entirely.
let err = call_direct(&topo, serde_json::json!({"substrate": "tcp"}))
.await
.expect_err("missing target must fail the schema gate");
assert_eq!(err.code, "INVALID_INPUT");
assert!(no_data_channels(&topo));
}
#[tokio::test]
async fn dial_failure_passes_through_typed() {
// The dial closure's own typed errors pass through unchanged
// (ADR-007 §4's table minus the registry row).
let topo = wire_direct(failing_dial("no route to target")).await;
let err = call_direct(
&topo,
serde_json::json!({
"substrate": "tcp",
"target": {"host": "blackhole.internal", "port": 9}
}),
)
.await
.expect_err("failed dial must fail the open");
assert_eq!(err.code, "channel:open_failed");
assert_eq!(establishment_reason_of(&err), Some("dial_failed"));
assert!(no_data_channels(&topo));
}
#[tokio::test]
async fn producer_without_direct_op_rejects_loudly() {
// An old producer (only the base op registered) rejects the
// unknown op loudly — NOT_FOUND, the SSH "unknown channel type"
// posture.
let registry = alktunnels::producer::ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = wire(registry, echo_dial()).await;
assert!(topo
.producer_registry
.registration("channels/tunnel/direct")
.is_none());
let err = call_direct(
&topo,
serde_json::json!({
"substrate": "tcp",
"target": {"host": "db.internal", "port": 5432}
}),
)
.await
.expect_err("unknown op must be rejected loudly");
assert_eq!(err.code, "NOT_FOUND");
assert!(no_data_channels(&topo));
}
#[tokio::test]
async fn tunnel_open_scope_does_not_imply_tunnel_direct() {
// ADR-006 Amendment 1: the scopes are structurally separate
// grants — an identity holding `tunnel:open` (but not
// `tunnel:direct`) is FORBIDDEN on the direct op.
let topo = wire_direct_with_identity(consumer_identity()).await;
let err = call_direct(
&topo,
serde_json::json!({
"substrate": "tcp",
"target": {"host": "db.internal", "port": 5432}
}),
)
.await
.expect_err("tunnel:open must NOT authorize the direct op");
assert_eq!(err.code, "FORBIDDEN");
assert!(no_data_channels(&topo));
}
#[tokio::test]
async fn direct_scope_does_not_imply_tunnel_open() {
// The separation is symmetric: `tunnel:direct` does not authorize
// the base open op either (two capabilities; a deployment
// granting both grants both explicitly). A tunnel:direct-only
// identity is FORBIDDEN on `channels/tunnel/sub` even though the
// producer registered it.
let registry = alktunnels::producer::ResourceRegistry::new();
registry
.register("echo", Substrate::Tcp, "in-process")
.await;
let topo = harness::wire_with(
registry,
harness::RegistrationMode::Dial(echo_dial()),
Some(harness::direct_identity()),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await;
let payload = serde_json::json!({
"operationId": "channels/tunnel/sub",
"input": {"resource": "echo", "substrate": "tcp"},
});
let response = topo.consumer_call.call_with_payload(payload).await;
let err = response
.result
.expect_err("tunnel:direct must NOT authorize the base op");
assert_eq!(err.code, "FORBIDDEN");
assert!(no_data_channels(&topo));
}
#[tokio::test]
async fn direct_establisher_witnesses_per_call_identity() {
// CF-006: the direct establisher's per-call auth carried the END
// CALLER's identity (the direct op is the arbitrary-egress
// capability — identity visibility matters more, not less).
let topo = wire_direct(echo_dial()).await;
let out = call_direct(
&topo,
serde_json::json!({
"substrate": "tcp",
"target": {"host": "db.internal", "port": 5432}
}),
)
.await
.expect("direct open authorized (both-scopes identity)");
assert!(out["channel_id"].is_u64());
let witness = topo.identity_witness.lock().await.clone();
assert_eq!(
witness.as_deref(),
Some("consumer-both"),
"direct establisher saw the per-call opener identity (CF-006)"
);
}
#[tokio::test]
async fn establisher_parse_backstop_maps_handler_error() {
// The establisher's serde parse is the backstop behind the schema
// gate: feed an input the schema would have rejected but a direct
// establisher call never schema-checks (the raw-call shape —
// assembly variants, or a registry whose schema gate is looser)
// and observe the typed `handler_error` mapping (ADR-007 §4).
// An unknown substrate value fails the enum parse.
let establisher = alktunnels::producer::direct_establisher(echo_dial());
let err = establisher(
serde_json::json!({
"substrate": "sctp",
"target": {"host": "h", "port": 1}
}),
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
)
.await;
match err {
Err(alkcall::channels::operations::EstablishmentError::HandlerError { message }) => {
assert!(message.contains("invalid direct params"), "{message}");
}
other => panic!("expected HandlerError backstop, got {other:?}"),
}
}
#[tokio::test]
async fn direct_establisher_renders_target_to_backing_string() {
// The design pin: the establisher renders the target to the SAME
// backing-string form the registry backings use and calls the
// injected DialFn with it — the dial closure observes the exact
// rendered form ("host:port" / path), per ADR-007 §4.
let observed: Arc<tokio::sync::Mutex<Vec<(Substrate, String)>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
let obs = Arc::clone(&observed);
let dial: alktunnels::producer::DialFn =
Arc::new(move |substrate: Substrate, backing: &str| {
let obs = Arc::clone(&obs);
let backing = backing.to_string();
Box::pin(async move {
obs.lock().await.push((substrate, backing));
Err(TunnelEstablishError::DialFailed("stop here".to_string()))
})
});
let establisher = alktunnels::producer::direct_establisher(dial);
let auth = alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel");
let _ = establisher(
serde_json::json!({"substrate": "tcp", "target": {"host": "db.internal", "port": 5432}}),
auth.clone(),
)
.await;
let _ = establisher(
serde_json::json!({"substrate": "unix", "target": {"path": "/run/pg/.s.PGSQL.5432"}}),
auth,
)
.await;
let seen = observed.lock().await.clone();
assert_eq!(
seen,
vec![
(Substrate::Tcp, "db.internal:5432".to_string()),
(Substrate::Unix, "/run/pg/.s.PGSQL.5432".to_string()),
],
"targets rendered to the registry backing-string form"
);
}
// --- consumer session level (TunnelSession::open_direct) ---------------
#[tokio::test]
async fn open_direct_session_round_trip_and_teardown() {
let topo = wire_direct_forward(echo_dial()).await;
let mut session = TunnelSession::open_direct(
&topo.consumer,
target_hostport("db.internal", 5432),
Substrate::Tcp,
)
.await
.expect("open_direct");
assert!(topo.producer_manager.has_channel(session.channel_id));
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (read, write) = session.stream_halves().expect("stream session has halves");
write.write_all(b"ping-direct").await.expect("write");
write.flush().await.expect("flush");
let mut buf = vec![0u8; 11];
tokio::time::timeout(std::time::Duration::from_secs(5), read.read_exact(&mut buf))
.await
.expect("round trip")
.expect("read");
assert_eq!(&buf, b"ping-direct");
// Teardown semantics identical to `open` (ADR-007 §5).
let channel_id = session.channel_id;
let reaped = session.close().await;
assert!(reaped, "close reaped the adopted entry");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
!topo.producer_manager.has_channel(channel_id),
"producer side reaped after pump completion"
);
}
#[tokio::test]
async fn open_direct_session_datagram_boundary_preserved() {
let topo = wire_direct_forward(framed_udp_echo_dial()).await;
let mut session = TunnelSession::open_direct(
&topo.consumer,
target_hostport("10.0.0.7", 53),
Substrate::Udp,
)
.await
.expect("udp open_direct");
session.send_datagram(b"one").await.expect("send");
session.send_datagram(b"").await.expect("send empty");
let first = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
.await
.expect("round trip")
.expect("recv")
.expect("datagram");
assert_eq!(first.as_ref(), b"one");
let second = tokio::time::timeout(std::time::Duration::from_secs(5), session.recv_datagram())
.await
.expect("round trip")
.expect("recv")
.expect("datagram");
assert!(
second.is_empty(),
"the empty datagram crosses framed (F-2 layering)"
);
session.close().await;
}
#[tokio::test]
async fn open_direct_malformed_target_no_phantom_session() {
// The session-level failure surface: a malformed target is the
// schema/invalid_input-class failure (registry-side); a failed
// open never yields a session.
let topo = wire_direct_forward(echo_dial()).await;
let err = match TunnelSession::open_direct(
&topo.consumer,
SubstrateAddr::Path {
path: "/nope".to_string(),
},
Substrate::Tcp,
)
.await
{
Err(e) => e,
Ok(_) => panic!("path-shaped target on tcp must fail"),
};
let call = err.open_ref().call_error().expect("wire error");
assert_eq!(call.code, "INVALID_INPUT");
// Both managers hold only channel 0 (the phantom-channel property).
assert!(
topo.consumer
.manager()
.channel_ids()
.iter()
.all(|&id| id == 0)
&& topo
.producer_manager
.channel_ids()
.iter()
.all(|&id| id == 0),
"no phantom session"
);
}
#[tokio::test]
async fn open_direct_scope_separated_session_level() {
// Session-level scope separation (ADR-006 Amendment 1): a
// tunnel:open-only consumer client is FORBIDDEN on open_direct —
// the scopes are separate grants, and the session constructor
// states which capability it invokes.
let topo = harness::wire_direct_forward_with_identity(echo_dial(), consumer_identity()).await;
let err = match TunnelSession::open_direct(
&topo.consumer,
target_hostport("db.internal", 5432),
Substrate::Tcp,
)
.await
{
Err(e) => e,
Ok(_) => panic!("tunnel:open-only identity must NOT open_direct"),
};
let call = err.open_ref().call_error().expect("wire error");
assert_eq!(call.code, "FORBIDDEN");
assert!(
topo.consumer
.manager()
.channel_ids()
.iter()
.all(|&id| id == 0)
&& topo
.producer_manager
.channel_ids()
.iter()
.all(|&id| id == 0),
"no phantom session"
);
}
#[tokio::test]
async fn open_direct_witness_carries_session_identity() {
let topo = wire_direct_forward(echo_dial()).await;
let _session = TunnelSession::open_direct(
&topo.consumer,
target_hostport("db.internal", 5432),
Substrate::Tcp,
)
.await
.expect("open_direct");
let witness = topo.identity_witness.lock().await.clone();
assert_eq!(
witness.as_deref(),
Some("consumer-both"),
"the session path's establisher saw the per-call opener identity"
);
}
+452
View File
@@ -0,0 +1,452 @@
//! Integration tests for the forwarded op's SERVING side (the
//! listen-opener half — ADR-008 §3, the `tunnels/forwarded-receipt`
//! shape): `register_tunnel_forwarded_openable` — the
//! `register_openable` shape (no establisher; the data plane starts
//! immediately), scope-gated `tunnel:forwarded`, whose handler parses
//! `TunnelForwardedParams`, hands the correlation material (parsed
//! params + `BiStream`) to the assembly, and awaits the assembly's
//! pump inline (R-02).
//!
//! Covered (the task's test plan): the serving-side e2e (an open with
//! valid params + scope → the assembly handler invoked with the parsed
//! params + the stream; the assembly pumps against local duplex halves
//! → byte round-trip), the scope gate (identity without
//! `tunnel:forwarded` → FORBIDDEN, never implied by
//! `tunnel:open`/`tunnel:direct`), malformed params (unknown field,
//! `listen: 0`, missing `peer` → loud rejection; the serde-gated shape
//! tears the channel down at birth — no phantom), and the
//! correlation-miss teardown (the assembly's rejection — drop the
//! stream, the channel reaps).
//!
//! The reply reader's unit tests land in `src/params.rs` (this task's
//! projection logic, unit-testable independent of the session type);
//! the full bind-first flow e2e lives in `tunnels/bindfirst-producer`.
//!
//! Topology (the harness's producer-leg stub, mirrored): the producer
//! leg is the connect side (it holds the `ChannelClient` whose
//! `call_open_op` is the per-accept call plane — the reverse POC's
//! `open_reverse_channel` shape, reduced); the opener leg is the
//! accept side whose install-hook serving registry carries the
//! forwarded op under test. The test plays the producer: per probe it
//! CALLS `channels/tunnel/forwarded` on the opener's channel-0 call
//! plane, adopts the opener-allocated `channel_id` on the
//! producer-side manager, and pumps the adopted channel against a
//! driving duplex (the dispatcher-pump dance, test-driven). The
//! opener-side assembly correlates by the `listen` token and pumps the
//! forwarded channel against the local "BIND client" halves — bytes
//! the test writes on the client end traverse the whole path and echo
//! back.
mod harness;
use std::sync::Arc;
use alkcall::channels::manager::ChannelManager;
use alkcall::core::auth::Identity;
use alkcall::protocol::wire::CallError;
use alktunnels::params::{TUNNEL_FORWARDED_SCOPE, TUNNEL_OPEN_SCOPE};
use alktunnels::producer::{register_tunnel_forwarded_openable, TargetHandle};
use harness::{wire_with, RegistrationMode};
/// The opener-side identity holding ONLY `tunnel:forwarded` (the
/// ingress grant — pinned separate from `tunnel:open`/`tunnel:direct`,
/// never implied by either; this identity holds neither).
fn forwarded_only_identity() -> Identity {
Identity {
id: "opener-forwarded-only".to_string(),
scopes: vec![TUNNEL_FORWARDED_SCOPE.to_string()],
resources: std::collections::HashMap::new(),
}
}
/// A plain dial-egress identity (`tunnel:open` only — the
/// never-implied probe's identity).
fn open_only_identity() -> Identity {
Identity {
id: "opener-open-only".to_string(),
scopes: vec![TUNNEL_OPEN_SCOPE.to_string()],
resources: std::collections::HashMap::new(),
}
}
/// What the assembly's test handler received per forwarded open (the
/// invocation probe): the parsed params as the wrapper handed them
/// over.
#[derive(Default)]
struct Received {
params: Option<alktunnels::params::TunnelForwardedParams>,
}
type ReceivedWitness = Arc<tokio::sync::Mutex<Received>>;
/// Serve the forwarded op on the opener side with the injected
/// assembly handler: record the parsed params, pop the correlated
/// halves for the token, and `pump_bidi` the channel `BiStream`
/// against them (convention 8 — the assembly owns the pump; the
/// protocol-side built-in handler only bridges wrapper→assembly). A
/// correlation miss (no halves for the token) is the assembly's
/// rejection: drop the stream, return — teardown-at-birth reaps the
/// forwarded channel.
fn serve_forwarded_on(
consumer_registry: &Arc<alkcall::registry::registration::OperationRegistry>,
manager: ChannelManager,
received: ReceivedWitness,
local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>>,
) {
let handler: alktunnels::producer::ForwardedHandler = Arc::new(move |forwarded, bidi| {
let received = Arc::clone(&received);
let local_halves = Arc::clone(&local_halves);
tokio::spawn(async move {
*received.lock().await = Received {
params: Some(forwarded),
};
let target = local_halves.lock().await.pop();
if let Some(target) = target {
alkcall::channels::pump::pump_bidi(bidi, target.read, target.write).await;
}
})
});
let core = alkcall::channels::operations::ChannelCore::new(
manager,
alkcall::channels::policy::default_policy(),
);
register_tunnel_forwarded_openable(
&core,
consumer_registry,
alkcall::core::auth::AuthContext::anonymous(b"alk/tunnel"),
handler,
)
.expect("register forwarded openable");
}
/// A topology for the forwarded-op serving probes: the producer leg
/// (connect side) serves only the unused base op (failing dial — the
/// base op is not what's under test); the opener leg is the accept
/// side whose serving registry carries the forwarded op. The test
/// plays the producer: `topo.producer.call_open_op(OP_TUNNEL_FORWARDED,
/// …)` is the per-accept call; the opener-allocated `channel_id` in
/// the reply is what the producer adopts.
async fn wire_forwarded_serving(identity: Identity) -> harness::Topology {
wire_with(
alktunnels::producer::ResourceRegistry::new(),
RegistrationMode::Dial(harness::failing_dial("base op unused in forwarded probes")),
Some(identity),
None,
Arc::new(alkcall::core::auth::NoopIdentityProvider),
)
.await
}
fn forwarded_input(listen: u32, peer: (u32, u16)) -> serde_json::Value {
serde_json::json!({
"listen": listen,
"substrate": "tcp",
"peer": {"host": peer.0.to_string(), "port": peer.1},
})
}
/// Call the forwarded op on the producer's per-accept call plane
/// (`call_open_op` — the producer-side initiator's surface; the
/// opener's wrapper allocates the channel ID, the producer adopts).
async fn call_forwarded(
producer: &alkcall::channels::client::ChannelClient,
input: serde_json::Value,
) -> Result<serde_json::Value, CallError> {
producer
.call_open_op(alktunnels::params::OP_TUNNEL_FORWARDED, input)
.await
.result
}
/// The accepted-side echo pair (the producer-side pump target): a
/// duplex pair — the driving end an echo task (the app server's
/// socket stand-in), the far end the pump's halves.
fn driving_halves() -> TargetHandle {
let (drive_end, target_end) = tokio::io::duplex(64 * 1024);
let (mut t_read, mut t_write) = tokio::io::split(target_end);
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = vec![0u8; 8192];
loop {
match t_read.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => {
if t_write.write_all(&buf[..n]).await.is_err() {
return;
}
}
}
}
});
let (d_read, d_write) = tokio::io::split(drive_end);
TargetHandle {
read: Box::new(d_read),
write: Box::new(d_write),
}
}
/// The "local BIND client" pair (the opener-side pump target): a raw
/// duplex pair — the map's halves are the assembly pump's target, the
/// far end is the test's client end (driven directly).
fn bind_client_pair() -> (
tokio::io::ReadHalf<tokio::io::DuplexStream>,
tokio::io::WriteHalf<tokio::io::DuplexStream>,
TargetHandle,
) {
let (a, b) = tokio::io::duplex(64 * 1024);
let (a_read, a_write) = tokio::io::split(a);
let (b_read, b_write) = tokio::io::split(b);
(
b_read,
b_write,
TargetHandle {
read: Box::new(a_read),
write: Box::new(a_write),
},
)
}
/// THE serving-half e2e (this task's gate): an open with valid params
/// + scope → the assembly handler invoked with the parsed params +
/// the stream → the assembly pumps against local duplex halves → byte
/// round-trip through the forwarded channel. (The full bind-first
/// flow e2e lives in `tunnels/bindfirst-producer`; this task's e2e is
/// the serving half standalone.)
#[tokio::test]
async fn forwarded_serving_round_trip_end_to_end() {
let topo = wire_forwarded_serving(forwarded_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
serve_forwarded_on(
&topo.consumer_registry,
topo.consumer_manager.clone(),
Arc::clone(&received),
Arc::clone(&local_halves),
);
// Correlate FIRST (the waiting listener session's bookkeeping —
// the assembly pre-registered the local halves for the token
// before the forwarded open arrives; a late push is the
// correlation-miss shape, probed separately below).
const TOKEN: u32 = 918273;
let (mut b_read, mut b_write, local) = bind_client_pair();
local_halves.lock().await.push(local);
// The producer calls the forwarded op (valid correlation token +
// substrate-shaped peer).
let out = call_forwarded(&topo.producer, forwarded_input(TOKEN, (198, 55231)))
.await
.expect("forwarded open");
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
assert!(channel_id > 0);
// The wrapper allocated the channel on the opener's manager (the
// serving side allocates; the producer adopts) and the assembly
// pump is live against the correlated halves.
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
assert!(
topo.consumer_manager.has_channel(channel_id),
"the forwarded channel is live on the opener side"
);
// The assembly's test handler saw the parsed params verbatim.
let got = received.lock().await.params.clone().expect("invoked");
assert_eq!(got.listen, TOKEN, "the correlation token rides `listen`");
assert_eq!(got.substrate, alktunnels::params::Substrate::Tcp);
assert_eq!(
got.peer,
alktunnels::params::SubstrateAddr::HostPort {
host: "198".to_string(),
port: 55231,
}
);
// The producer-side adopted pump (the test drives the accepted
// side's echo): adopt the opener-allocated ID on the
// producer-side manager and pump the adopted channel against a
// driving duplex — the dispatcher-pump dance, test-driven.
let (send, recv) = topo
.producer
.manager()
.adopt_channel(channel_id, alktunnels::TUNNEL_ALPN, None)
.await
.expect("producer adopts the opener-allocated ID");
let driving = driving_halves();
let _producer_pump = tokio::spawn(alkcall::channels::pump::pump_bidi(
alkcall::core::types::BiStream::from_joined(recv, send),
driving.read,
driving.write,
));
// THE round-trip: bytes the test writes on the local BIND client's
// end cross the assembly pump → the forwarded channel → the
// producer-side adopted pump → the driving echo → and back (the
// forwarded channel's data path, ADR-008 §3's serving half).
use tokio::io::{AsyncReadExt, AsyncWriteExt};
b_write.write_all(b"forwarded-ping").await.expect("write");
b_write.flush().await.expect("flush");
let mut buf = vec![0u8; 14];
tokio::time::timeout(
std::time::Duration::from_secs(5),
b_read.read_exact(&mut buf),
)
.await
.expect("round trip")
.expect("read");
assert_eq!(&buf, b"forwarded-ping");
}
/// The scope gate: an identity WITHOUT `tunnel:forwarded` is FORBIDDEN
/// — never implied by `tunnel:open` (this identity holds exactly that
/// grant; ADR-006 Amendment 1's separation).
#[tokio::test]
async fn forwarded_scope_gate_is_independent_grant() {
let topo = wire_forwarded_serving(open_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
serve_forwarded_on(
&topo.consumer_registry,
topo.consumer_manager.clone(),
Arc::clone(&received),
local_halves,
);
match call_forwarded(&topo.producer, forwarded_input(7, (10, 1))).await {
Ok(_) => panic!("a scope-less opener must not receive forwarded opens"),
Err(e) => {
assert_eq!(
e.code, "FORBIDDEN",
"missing `tunnel:forwarded` → FORBIDDEN"
);
assert!(
e.message.contains("tunnel:forwarded"),
"the denial names the missing scope: {}",
e.message
);
}
}
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
// No channel was ever allocated (the ACL runs before the wrapper).
assert!(
topo.consumer_manager
.channel_ids()
.iter()
.all(|&id| id == 0),
"a FORBIDDEN open leaves no channel on the opener side"
);
// The assembly handler never ran (no invocation witnessed).
assert!(received.lock().await.params.is_none());
}
/// Malformed params: `listen: 0` and a missing `peer` fail the
/// registry's schema gate LOUD (`INVALID_INPUT`, before the wrapper —
/// no channel ever); an unknown top-level field passes the schema (the
/// additive posture) but fails the serde parse (the loud backstop) →
/// the wrapper logs + returns early → the channel tears down at birth
/// (no phantom channel).
#[tokio::test]
async fn forwarded_malformed_params_tear_down_loud() {
let topo = wire_forwarded_serving(forwarded_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
serve_forwarded_on(
&topo.consumer_registry,
topo.consumer_manager.clone(),
Arc::clone(&received),
local_halves,
);
// `listen: 0` — the schema gate (minimum: 1) fires first.
match call_forwarded(
&topo.producer,
serde_json::json!({
"listen": 0,
"substrate": "tcp",
"peer": {"host": "10", "port": 53},
}),
)
.await
{
Ok(_) => panic!("`listen: 0` must fail the open"),
Err(e) => {
assert_eq!(e.code, "INVALID_INPUT", "schema minimum 1 gate");
}
}
// Missing `peer` — the schema gate again (all three required).
match call_forwarded(
&topo.producer,
serde_json::json!({"listen": 7, "substrate": "tcp"}),
)
.await
{
Ok(_) => panic!("missing `peer` must fail the open"),
Err(e) => {
assert_eq!(e.code, "INVALID_INPUT");
}
}
// Unknown top-level field — passes the schema (additive posture),
// fails the serde parse (deny-unknown backstop) → the wrapper
// returns early → teardown-at-birth (the channel was allocated —
// the reply IS the channel_id — but reaps promptly).
let out = call_forwarded(
&topo.producer,
serde_json::json!({
"listen": 918273,
"substrate": "tcp",
"peer": {"host": "198", "port": 55232},
"sneaky": true
}),
)
.await
.expect("the malformed open rides the no-establisher reply shape");
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert!(
!topo.consumer_manager.has_channel(channel_id),
"a malformed-params open tears the channel down at birth (no phantom)"
);
// The assembly handler never ran (the parse gate is protocol-side).
assert!(received.lock().await.params.is_none());
}
/// The correlation-miss teardown (the assembly's rejection — it holds
/// the map): a valid-shaped open whose `listen` token matches no
/// waiting session → the assembly's error path drops the stream and
/// returns → teardown-at-birth reaps the forwarded channel. The
/// producer's accept loop drops the accepted connection on the open's
/// failure (the shapes mirror — pinned producer-side in
/// `tunnels/bindfirst-producer`).
#[tokio::test]
async fn forwarded_correlation_miss_tears_channel_down() {
let topo = wire_forwarded_serving(forwarded_only_identity()).await;
let received: ReceivedWitness = Arc::new(tokio::sync::Mutex::new(Received::default()));
// NO correlated halves for the token — the map is empty.
let local_halves: Arc<tokio::sync::Mutex<Vec<TargetHandle>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
serve_forwarded_on(
&topo.consumer_registry,
topo.consumer_manager.clone(),
Arc::clone(&received),
local_halves,
);
let out = call_forwarded(&topo.producer, forwarded_input(42113, (198, 55233)))
.await
.expect("the open itself resolves (allocation is wrapper-side)");
let channel_id = out["channel_id"].as_u64().expect("channel_id") as u32;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert!(
!topo.consumer_manager.has_channel(channel_id),
"a correlation miss tears the forwarded channel down (teardown-at-birth)"
);
// The assembly handler RAN (the invocation was witnessed — the
// rejection is the assembly's) and its map missed.
let witnessed = received.lock().await.params.clone();
assert_eq!(witnessed.expect("invoked").listen, 42113);
}
+38
View File
@@ -384,6 +384,44 @@ async fn accept_loop_stops_feeding_after_close_and_maps_the_drop() {
);
}
/// The unix accept loop's queue-closed mapping (G-4 — the unix parity
/// twin of the TCP test above): the same loop shape over
/// `UnixListenerHalves` — a `push` after close errors the loop with
/// the handler error, the loop exits on the mapped error, and the
/// unaccepted far end is dropped.
#[tokio::test]
#[cfg(feature = "local")]
async fn unix_accept_loop_stops_feeding_after_close_and_maps_the_drop() {
let queue = AcceptQueue::new();
queue.close().await;
let dir = std::env::temp_dir().join(format!("alktunnels-unixaccept-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("accept-loop.sock");
let _ = std::fs::remove_file(&path);
let listen_str = path.to_string_lossy().into_owned();
let listener = alktunnels::local::bind_unix(&listen_str).expect("bind");
let loop_queue = queue.clone();
let outcome = tokio::spawn(async move { listener.accept_loop(loop_queue).await });
let _ = tokio::net::UnixStream::connect(&path)
.await
.expect("connect");
let err = tokio::time::timeout(std::time::Duration::from_secs(5), outcome)
.await
.expect("accept loop timed out (did it keep feeding a closed queue?)")
.expect("accept loop task")
.expect_err("closed queue must end the accept loop");
assert!(
matches!(
err,
alktunnels::producer::TunnelEstablishError::HandlerError(_)
),
"queue-closed push maps to the handler error, got {err:?}"
);
let _ = std::fs::remove_file(&path);
}
/// The lost-wakeup regression (review 001 C-1): a `push` landing in
/// the wall-clock window between a parked `pop`'s state check and its
/// `Notified` registration must still wake the pop. The window only