- 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
12 KiB
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 }
{
"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 siblingsubstratefield:tcp→{ "host": <string>, "port": <u16> }—hostis a hostname, IPv4/IPv6 literal, or the JSON form ofSocketAddr-shaped values; domain-form targets stay unresolved on the wire (producer-side resolution — the ssh-Dsemantic; 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
allOfof twoif/thenbranches keyed on the siblingsubstratevalue, 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 typedinvalid_input-class failure;unknown_resourcecan 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_addrdecomposes to host/port at the adapter), but a self-describing JSON object is schema-describable (alkcall ADR-047's registryinput_schemaruns 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:
- Parse and validate
targetagainstsubstrate(a mismatch —unixwithoutpath,tcpwith a bad port — isinvalid_inputclass). - Inject the parsed
TunnelTargetinto the assembly-providedDialFn(the same halves function the base op's registry backings resolve to; ADR-004 — the protocol never sees a socket type). - 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
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
substratevalue + 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 cannotdeny_unknown_fields, so a cross-shape target (e.g.{path, host}) parses at the serde layer — the SCHEMA'sadditionalProperties: falseper 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 keysrender_targetoff the siblingsubstrate— a cross-shape target that sneaked past would render something (/pfor 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.SubstrateAddrmust 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_schemacarries{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). localfeature interaction: the direct establisher composes the sameDialFnthe base op uses; a producer that registereddial_tcpfor the base op can register the direct op with no new substrate code. Thelocalfeature ships the dial functions; the direct op itself is protocol-layer (default crate, wasm-clean).- SOCKS5 CONNECT composes directly: the alksocks adapter's
DialFnseam maps the SOCKS5 CONNECT target (ATYP-decoded) ontoopen_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):
- The shipped structure is an
allOfof twoif/thenbranches, notoneOf.oneOfof full-target-schemas would actually be wrong here: aunix-substrate open would need to FAIL thetcpbranch, and plainoneOfon sibling-conditioned subschemas is awkward (exactly-one-branch-matches semantics does not compose with the baseproperties/required);if/thencomposes correctly with them. - Validation is registry-side, not establisher-time. The
registry's
input_schemagate runs BEFORE the establisher (malformed_target_is_schema_class_and_leaves_no_channelasserts 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) andpoc-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)