From 0d6b23b7cfb88654a2eb2fd8e02ff193f1688305 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 16 Sep 2026 08:31:56 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20the=20graduation=20=E2=80=94=20A?= =?UTF-8?q?DR-007=20(direct=20op),=20ADR-008=20(listen=20metadata=20+=20fo?= =?UTF-8?q?rwarded=20op)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/architecture/README.md | 74 +++--- docs/architecture/consumer.md | 92 +++++++- .../decisions/001-open-params-layout.md | 19 +- .../decisions/002-alpn-strategy.md | 35 ++- .../decisions/006-access-control-posture.md | 33 ++- .../decisions/007-direct-open-op.md | 192 +++++++++++++++ .../008-listen-metadata-forwarded-op.md | 219 ++++++++++++++++++ docs/architecture/open-questions.md | 54 ++++- docs/architecture/overview.md | 92 +++++--- docs/architecture/producer.md | 197 ++++++++++++++-- docs/architecture/wire.md | 123 +++++++++- docs/research/tunnels-graduation.md | 36 ++- 12 files changed, 1054 insertions(+), 112 deletions(-) create mode 100644 docs/architecture/decisions/007-direct-open-op.md create mode 100644 docs/architecture/decisions/008-listen-metadata-forwarded-op.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3ebb293..6020cc2 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 pending upstream alkcall + asks are filed at `/workspace/@alkdev/alkcall/docs/reviews/ + 008-tunnels-graduation-upstream-review.md` (flavor-form discovery + derivation; the establisher → reply `bound` projection) - Sibling precedent: alktty (`/workspace/@alkdev/alktty`) — the producer/consumer-on-channels shape, wasm-clean default, feature-gated local backend, BAST convention diff --git a/docs/architecture/consumer.md b/docs/architecture/consumer.md index 7af7e36..04d29b3 100644 --- a/docs/architecture/consumer.md +++ b/docs/architecture/consumer.md @@ -1,18 +1,22 @@ --- -status: implemented -last_updated: 2026-09-10 +status: draft +last_updated: 2026-09-16 (`open_direct`, `TunnelListener`, the forwarded-op serving side — ADR-007/008) --- # 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,47 @@ 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 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). +- **`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). +- **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). The opener-side assembly correlates the + open (via the `channel` param) to the waiting session, adopts the + allocated channel (`TunnelSession::adopt`), and pumps against the + local BIND client's halves. The correlation registry is + assembly-side (alksocks') bookkeeping. +- **`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 +211,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 +224,9 @@ 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: `alkcall/docs/reviews/ + 008-tunnels-graduation-upstream-review.md` - alkcall ADR-047 §5 (allocation — the initiator adopts), ADR-050 (`pump_bidi`), ledger CF-005/CF-006 (identity on the reverse path) \ No newline at end of file diff --git a/docs/architecture/decisions/001-open-params-layout.md b/docs/architecture/decisions/001-open-params-layout.md index 28717bf..a70676b 100644 --- a/docs/architecture/decisions/001-open-params-layout.md +++ b/docs/architecture/decisions/001-open-params-layout.md @@ -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) \ No newline at end of file +- 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. \ No newline at end of file diff --git a/docs/architecture/decisions/002-alpn-strategy.md b/docs/architecture/decisions/002-alpn-strategy.md index 3d571bf..278f1c6 100644 --- a/docs/architecture/decisions/002-alpn-strategy.md +++ b/docs/architecture/decisions/002-alpn-strategy.md @@ -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) \ No newline at end of file +- 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//sub` + (the original resource op, reserved and unchanged), + `channels//direct` (dial an arbitrary target), and + `channels//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. \ No newline at end of file diff --git a/docs/architecture/decisions/006-access-control-posture.md b/docs/architecture/decisions/006-access-control-posture.md index b86e095..6211c8a 100644 --- a/docs/architecture/decisions/006-access-control-posture.md +++ b/docs/architecture/decisions/006-access-control-posture.md @@ -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) \ No newline at end of file +- 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. \ No newline at end of file diff --git a/docs/architecture/decisions/007-direct-open-op.md b/docs/architecture/decisions/007-direct-open-op.md new file mode 100644 index 0000000..44f719b --- /dev/null +++ b/docs/architecture/decisions/007-direct-open-op.md @@ -0,0 +1,192 @@ +# 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//`, +`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. + +### 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": , "port": }` — `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": , "port": }` — the connected-UDP + destination (endpoint-at-open; ADR-003 framing applies as for base + udp). + - `unix` → `{ "path": }` — the socket path on the + producer's filesystem. +- The schema pins both fields required; the target sub-schema is + per-substrate (`oneOf` on the sibling `substrate` value, validated at + establisher time — see 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. +- **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. + +## 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) \ No newline at end of file diff --git a/docs/architecture/decisions/008-listen-metadata-forwarded-op.md b/docs/architecture/decisions/008-listen-metadata-forwarded-op.md new file mode 100644 index 0000000..2c5bc45 --- /dev/null +++ b/docs/architecture/decisions/008-listen-metadata-forwarded-op.md @@ -0,0 +1,219 @@ +# ADR-008: Listen Metadata — Bind-First Establisher and the Forwarded Op + +## Status + +Accepted (2026-09-16) + +## 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 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. + +### 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`. +- `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 +{ + "channel": 7, + "substrate": "tcp", + "peer": { "host": "198.51.100.4", "port": 55231 } +} +``` + + - **`channel`** (u32, required) — the listen channel's ID: the + correlation key. The opener knows which of its open listener + sessions the accept belongs to without a registry op or an + extra wire field; terminate-and-re-produce hubs re-map the id at + their re-produce hop naturally (the hub's re-produced forwarded + open carries the hub's channel id). + - **`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 `channel`) to the waiting + session, accept the `BiStream`, pump against the local BIND client's + halves. The correlation registry is assembly-side (alksocks') + 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` +let bound = listener.bound(); // the resolved address +// ... per forwarded accept (opener side, served op): +// correlate by `channel`, 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. + +### 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` reply field, + the forwarded op id, its params, and `tunnel:forwarded` are one-way + doors. The `bound` field is additive (absent = old-producer + compatible); the forwarded op is additive (old openers reject loudly). +- **Upstream coordination before the first consumer:** the + `bound`-projection ask (ADR-049 amendment 3) and the flavor-form + discovery derivation (ADR-047 amendment) land in alkcall first — + the review records both. The bind-first establisher itself is this + crate's work and does not wait on upstream (§2's `bound` is the only + upstream-gated piece). +- **`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`) and re-opens forwarded + channels with its own channel ids — the metadata is per-hop + truthful, matching alkcall ADR-042's terminate-and-re-produce + posture. +- **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) \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index ad43f2b..7f671cb 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -1,6 +1,6 @@ --- -status: implemented -last_updated: 2026-09-10 +status: draft +last_updated: 2026-09-16 (OQ-TN-15/16 promoted + resolved by ADR-007/008) --- # alktunnels — Open Questions @@ -240,4 +240,52 @@ 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) \ No newline at end of file + 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 is `/sub`-shaped) — filed in + `alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md` + (ask #1); the runtime was never blocked (explicit ALPN marker + + explicit ALPN argument on `open_channel`). +- **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). 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 an + upstream alkcall ask — the establisher → reply projection, filed as + the review's ask #2; ADR-049's `#[non_exhaustive]` anticipates it), + 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 `{channel, substrate, + peer}` in params (ask #2's carrier; the `TargetHandle.peer` option + is superseded). The accept-first listen shape stays for + accept-queue-backed resources. +- **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 \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 8603418..0ade3f5 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,6 +1,6 @@ --- -status: implemented -last_updated: 2026-09-10 +status: draft +last_updated: 2026-09-16 (the graduation: the direct op, listen metadata, the forwarded op — ADR-007/008) --- # 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,16 @@ 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"` in the reply + (ADR-008 §2 — the resolved address). 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,7 +101,8 @@ 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 @@ -122,14 +136,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 {channel, substrate, peer} (ADR-008); + 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 +166,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), `channels/tunnel/forwarded` accept-as-open (params carry `{channel, substrate, peer}` = reply#2 fidelity) — the `forwarded-tcpip` shape | ## Open Questions @@ -155,10 +176,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 diff --git a/docs/architecture/producer.md b/docs/architecture/producer.md index db14e28..f74bf08 100644 --- a/docs/architecture/producer.md +++ b/docs/architecture/producer.md @@ -1,22 +1,28 @@ --- -status: implemented -last_updated: 2026-09-10 +status: draft +last_updated: 2026-09-16 (the direct op, the bind-first establisher, the forwarded op — ADR-007/008) --- # 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` 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,48 @@ 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 is an upstream alkcall ask (ADR-049 + amendment 3; `Establishment` gains optional reply fields, the + `channel_id` key wrapper-reserved — see + `alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md`, + ask #2). Until upstream lands, the field is simply absent. +- **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 +208,45 @@ 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 `{channel, substrate, peer}` + — the listen channel's ID (correlation), the substrate, 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 `channel`) 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 +261,25 @@ 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, + 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, + 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 +287,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 +325,19 @@ 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) \ No newline at end of file + ledger CF-005/CF-006 (identity precedence + per-call opener) +- Upstream asks: `alkcall/docs/reviews/ + 008-tunnels-graduation-upstream-review.md` (flavor-form discovery + derivation; the establisher → reply `bound` projection) \ No newline at end of file diff --git a/docs/architecture/wire.md b/docs/architecture/wire.md index 7e0147d..a6c7c21 100644 --- a/docs/architecture/wire.md +++ b/docs/architecture/wire.md @@ -1,6 +1,6 @@ --- -status: implemented -last_updated: 2026-09-10 +status: draft +last_updated: 2026-09-16 (the graduation wire surface: direct + forwarded ops, `bound` reply field — ADR-007/008) --- # 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": }` — 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,101 @@ 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": , "port": }` (domain-form + hosts stay unresolved on the wire — producer-side resolution, the + ssh `-D` semantic); + - `unix` → `{ "path": }`. +- `input_schema` pins both required; the target sub-schema is + per-substrate, validated at establisher time. Malformed targets are + the `invalid_input`-class failure; **`unknown_resource` can never + fire on the direct op** (no registry lookup). + +### Reply / errors + +- Success: `{ "channel_id": }` — 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` Field + +A bind-first listen open (ADR-008 §1, served by `…/sub` with the +bind-first establisher) MAY add an optional, additive reply field: + +```json +{ "channel_id": 7, "bound": { "host": "203.0.113.9", "port": 42113 } } +``` + +- Substrate-shaped like the direct op's target (`{host, port}` / + `{path}`); the REAL OS-chosen address (port `0` resolved). +- Present only when the establisher supplies it; absent for dial + establishers and accept-first listen opens. Old consumers ignore + unknown reply fields (plain JSON) — wire-additive. +- 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 +{ + "channel": 7, + "substrate": "tcp", + "peer": { "host": "198.51.100.4", "port": 55231 } +} +``` + +- `channel`: integer > 0, required — the listen channel's ID (the + correlation key; hubs re-map it at their re-produce hop). +- `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). + +### Reply / errors + +- Success: `{ "channel_id": }` — 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 +260,27 @@ 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` field + 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) \ No newline at end of file + 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: + `alkcall/docs/reviews/008-tunnels-graduation-upstream-review.md` \ No newline at end of file diff --git a/docs/research/tunnels-graduation.md b/docs/research/tunnels-graduation.md index 29f5178..c43486f 100644 --- a/docs/research/tunnels-graduation.md +++ b/docs/research/tunnels-graduation.md @@ -1,10 +1,42 @@ --- -status: draft -last_updated: 2026-09-15 (initial draft — the alktunnels graduation discussion) +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 `{channel, substrate, peer}` params (ask #2). 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`. + +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