Files
alktunnels/docs/architecture/decisions/007-direct-open-op.md
T
glm-5.3-flash 20834d297e docs: review 002 recorder — N-3/N-4 durable trail + N-2 ADR-008 widening + the no-action dispositions
- N-3: parse-before-accept telemetry note on make_forwarded_op_handler
  (the accepted-flag reads false for a malformed-params open by design)
- N-4: ADR-007 Consequences lines — the untagged-leniency-is-
  schema-gated paragraph + the overlapping-variants caution
- N-2: ADR-008 §1 widened to name the any-byte idle-read posture
  (the Ok(0)-only reading hangs on a write-happy peer)
- N-5: mint_token's doc comment records the 256-channel cap analysis
- The N-1/N-6/N-7 + G-1/G-3 no-action dispositions recorded in the
  task file (the tracker the task asked for)

Verified: fmt, clippy (native + wasm32), test, cargo doc — all green
2026-09-20 08:32:45 +00:00

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 sibling substrate field:
    • tcp → { "host": <string>, "port": <u16> } — host is a hostname, IPv4/IPv6 literal, or the JSON form of SocketAddr-shaped values; domain-form targets stay unresolved on the wire (producer-side resolution — the ssh -D semantic; both POCs validated egress-side resolution). No DNS dependency on the consumer.
    • udp → { "host": <string>, "port": <u16> } — the connected-UDP destination (endpoint-at-open; ADR-003 framing applies as for base udp).
    • unix → { "path": <string> } — the socket path on the producer's filesystem.
  • The schema pins both fields required; the target sub-schema is per-substrate (an allOf of two if/then branches keyed on the sibling substrate value, validated registry-side — the schema gate runs before the establisher; the establisher's serde parse re-checks as the backstop; see the Erratum below and Consequences). Malformed targets are the typed invalid_input-class failure; unknown_resource can never fire on the direct op (there is no registry lookup).
  • Encoding choice over the RFC-1928 ATYP byte string: the ATYP form is SOCKS5's wire carrier and alksocks maps TargetAddr → this JSON trivially (to_target_addr decomposes to host/port at the adapter), but a self-describing JSON object is schema-describable (alkcall ADR-047's registry input_schema runs before the establisher), carries the unix path form without an extension, and is human-readable in discovery listings and error messages. SOCKS5 remains the reason the capability exists, not the carrier the wire encodes.
  • Dial-only: no listen/direct-BIND form. The reverse shape (-R-style ingress) is the listen-tunnel + the forwarded op (ADR-008) — a direct-BIND form would duplicate its capability with none of its metadata fidelity.

3. Scope: TUNNEL_DIRECT_SCOPE = "tunnel:direct"

The op's AccessControl.required_scopes is ["tunnel:direct"] — a separate grant from tunnel:open, never implied by it. "Dial postgres-primary" and "dial anything from my network" are different capabilities; the ACL separation is structural (scopes attach to op specs, not params — ADR-006 §2's additivity rule). Scope strings are ACL-stable once published (ADR-006 §2): renames are breaking; new gates are new scopes on new ops.

4. Establisher: the dial shape without the registry

Same establisher contract as the dial shape (producer.md), minus the lookup:

  1. Parse and validate target against substrate (a mismatch — unix without path, tcp with a bad port — is invalid_input class).
  2. Inject the parsed TunnelTarget into the assembly-provided DialFn (the same halves function the base op's registry backings resolve to; ADR-004 — the protocol never sees a socket type).
  3. Return the dialed handle via Establishment::new(plan) — unchanged plan-flow.

Error mapping (the dial table, minus the registry row):

Producer condition Typed reason
target malformed for the substrate schema / invalid_input-class
dial refused / timed out / unreachable dial_failed
bind/connect resource exhaustion resource_shortage
establisher-internal failure handler_error
the bound exceeded timeout (wrapper-level)

5. Consumer API

let session = TunnelSession::open_direct(&client, target, substrate).await?;

A separate constructor, not a params enum: two scopes are two capabilities, and the caller states which one it holds. The session type, data planes, and teardown API are identical to open (ADR-005).

6. Hub re-produce: the hub's policy governs the final dial

A hub that re-produces the direct op (terminate-and-re-produce, alkcall ADR-042) applies its own target policy at the re-produce hop: the hub's ACL, not the original opener's, governs the final dial. The hub is the network boundary; the original opener's tunnel:direct grant gets it to the hub, and the hub's own grant decides what it will dial on the far side. Correct-by-default; pinned here as the security model sentence (also ADR-006 amendment).

Consequences

  • Wire-stable from the first consumer. Op id, scope string, params field names, and the target sub-schema are one-way doors; renames are breaking. Additive evolution: a new substrate is a new substrate value + a new target key (never a format change); new fields inside a target object must be optional-with-default.
  • SubstrateAddr's untagged leniency is schema-gated (review 002 N-4): untagged serde variants cannot deny_unknown_fields, so a cross-shape target (e.g. {path, host}) parses at the serde layer — the SCHEMA's additionalProperties: false per branch is the real shape-vs-substrate gate. One caution for future registry variants: a schema-less registry would leave the establisher's serde backstop as the only gate, and it keys render_target off the sibling substrate — a cross-shape target that sneaked past would render something (/p for the unix case) rather than fail; the serde backstop renders the sibling-keyed target, so cross-shape garbage is the schema's absence, not a serde failure. If this crate is ever consumed against a schema-less registry variant, pin an ADR line there before the first such consumer.
  • SubstrateAddr must not grow overlapping untagged variants. If a future variant's shapes overlap (e.g. a {host, port, flowlabel} form), untagged disambiguation starts ordering (first-match wins becomes load-bearing and silent) — switch to an internally-tagged or untagged-with-content form at that point, deliberately (an ADR line), never by accident. Pre-consumer and additive until then.
  • The BAST question is moot — params is JSON inside the channels open op; the data plane is unchanged (raw pass-through for tcp/unix, the [len: u16 BE] codec for udp — ADR-003).
  • Discovery exposure: the direct op's input_schema carries {substrate, target}, so a discovery listing reveals that dynamic egress exists and of which substrates — acceptable; the target values (what was dialed) never enter discovery (they ride per-call params). A producer wanting to hide the capability entirely simply does not register the op (and old producers reject it loudly anyway).
  • local feature interaction: the direct establisher composes the same DialFn the base op uses; a producer that registered dial_tcp for the base op can register the direct op with no new substrate code. The local feature ships the dial functions; the direct op itself is protocol-layer (default crate, wasm-clean).
  • SOCKS5 CONNECT composes directly: the alksocks adapter's DialFn seam maps the SOCKS5 CONNECT target (ATYP-decoded) onto open_direct — the graduation's §4 table, now with the stand-in dissolved.

Erratum (2026-09-20, review 002 D-2)

§2's original wording said the target sub-schema is "oneOf on the sibling substrate value, validated at establisher time". Both halves of that sentence were stale against the shipped schema (the decision itself was not — the code is the wire-stable side and is correct):

  1. The shipped structure is an allOf of two if/then branches, not oneOf. oneOf of full-target-schemas would actually be wrong here: a unix-substrate open would need to FAIL the tcp branch, and plain oneOf on sibling-conditioned subschemas is awkward (exactly-one-branch-matches semantics does not compose with the base properties/required); if/then composes correctly with them.
  2. Validation is registry-side, not establisher-time. The registry's input_schema gate runs BEFORE the establisher (malformed_target_is_schema_class_and_leaves_no_channel asserts the establisher's dial never runs on a malformed target); the establisher's serde parse re-checks as the backstop. §2's text above is corrected in place; wire.md §The Direct Op carries the same corrected sentence.

References

  • docs/research/tunnels-graduation.md §2 (the ask, options, and the lean) — the design discussion this ADR records
  • alksocks poc-bind-findings.md (the scope-gate precedent) and poc-connect-wasm-findings.md (the dial-at-command-read layering)
  • alkcall ADR-047 (open ops + input_schema + discovery marker), ADR-049 (establishment/typed errors), Gap F (op-name → ALPN derivation — the flavor-form amendment target)
  • alktunnels ADR-001 (the resource-params invariant the direct op extends), ADR-002 Amendment 1 (flavor-form op ids), ADR-006 (scope posture + the hub-policy amendment)
  • AGENTS.md conventions 9 (no forced binding — dial-only, no listen form), 13 (access control), 17 (ALPN naming)