Rewrote every prose em dash under docs/architecture/ with natural grammar (sentence splits, colons, semicolons, parentheses, commas); wire-format fences, JSON/BAST examples, and byte diagrams untouched. Verified: grep -rln '—' docs/architecture/ returns only overview.md's fenced dependency/module-map blocks (12 dashes), left verbatim per the fenced-code rule; all other files at zero.
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, i.e. 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, so 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, i.e. 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, which is 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, since registration sets the
ALPN explicitly and open_channel(op_id, params, alpn) takes the ALPN
as an argument; 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> }, wherehostis 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, per 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, such asunixwithoutpathortcpwith 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, which is why 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), which means switching 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 per 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; that is acceptable, since 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, which is the graduation's §4 table 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)