- params.rs: OP_TUNNEL_DIRECT / TUNNEL_DIRECT_SCOPE constants;
SubstrateAddr (untagged {host,port} | {path}; also the ADR-008 §3
peer shape); TunnelDirectParams with render_target();
tunnel_direct_spec() — Sub-typed, External, alk/tunnel ALPN
marker, scope ["tunnel:direct"] (never implied by tunnel:open),
per-substrate if/then target sub-schema (malformed targets fail
the registry's schema gate as INVALID_INPUT before the
establisher). 7 unit tests.
- producer.rs: direct_establisher (+ witness variant) — parse,
render the target to the registry backing-string form, dial via
the SAME injected DialFn; no registry lookup (unknown_resource
can never fire); typed errors per ADR-007 §4 minus the registry
row; CF-006 witness seam. register_tunnel_direct_openable reuses
the base pump handler (plan-flow unchanged).
- consumer.rs: TunnelSession::open_direct — separate constructor
(two scopes are two capabilities), plain open_channel (dial
establishers never bind), identical session/data-planes/teardown.
- tests/producer_direct_op.rs: 15 integration tests — tcp + udp e2e
round-trips, malformed-target rejection (no phantom session),
dial-failure pass-through, NOT_FOUND posture, scope separation
both ways (FORBIDDEN, raw-call + session-level), CF-006 witness
(raw + session paths), parse backstop, target-rendering pin.
Harness: Direct registration mode, wire_direct* topologies,
direct_identity/both_scopes_identity.
- CHANGELOG: Unreleased → Added.
Verified: cargo test green (93 native, 94 --all-features); clippy
-D warnings clean (native + wasm32); fmt clean; wasm32 check passes
(default crate stays wasm-clean); cargo doc clean.
Task: tasks/tunnels/direct-op.md (status: completed)
11 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| tunnels/direct-op | Direct op — params, spec, establisher, consumer open_direct (ADR-007) | completed | broad | medium | component | implementation |
|
Description
Implement the direct op (channels/tunnel/direct, ADR-007) end to
end: the params type + spec builder (src/params.rs), the establisher
- registration (
src/producer.rs), and the consumer constructor (src/consumer.rs). The op rides the samealk/tunnelALPN (ADR-002 Amendment 1's flavor-form op ids — the upstream discovery derivation landed in alkcall 0.8.0, ADR-047 amendment 3). This is the sshdirect-tcpip/ SOCKS5 CONNECT egress capability with structural ACL separation fromtunnel:open.
params.rs — types + spec
pub const OP_TUNNEL_DIRECT: &str = "channels/tunnel/direct";
pub const TUNNEL_DIRECT_SCOPE: &str = "tunnel:direct";
/// The substrate-shaped address object (ADR-007 §2): keyed by the
/// sibling `substrate` field of the params. Also the `peer` value
/// shape of the forwarded op (ADR-008 §3) — define once here.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SubstrateAddr {
HostPort { host: String, port: u16 },
Path { path: String },
}
/// The direct-op input params (ADR-007 §2). Wire-stable.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TunnelDirectParams {
pub substrate: Substrate,
pub target: SubstrateAddr,
}
pub fn tunnel_direct_spec() -> OperationSpec
- Spec per wire.md §The Direct Op:
Sub-typed,Visibility::External,ChannelOpenSpec::new(TUNNEL_ALPN), scope["tunnel:direct"](never implied bytunnel:open— ADR-006 Amendment 1), output schema{channel_id: integer > 0}. input_schemapins both fields required and the target sub-schema per-substrate — aoneOfon the siblingsubstratevalue (tcp/udp→{host, port},unix→{path}), so malformed targets fail the registry's schema validation (the typedINVALID_INPUTclass) before the establisher runs. The establisher's serde parse is the second gate (defensive; see the error-mapping note).deny_unknown_fieldson both params types (the loud-rejection posture, ADR-001's precedent).- The
Substrateenum is reused as the discriminator (no new substrate values — ADR-001's additive rule).
producer.rs — the establisher + registration
pub fn direct_establisher(dial: DialFn) -> OpenEstablisher
pub fn register_tunnel_direct_openable(
core: &ChannelCore,
on_registry: &Arc<OperationRegistry>,
auth: AuthContext,
dial: DialFn, // the SAME DialFn the base op uses
identity_witness: Option<IdentityWitness>,
) -> Result<(), String>
- DialFn reuse is the design pin: the establisher parses
TunnelDirectParams, renders the target to the same backing-string form the registry backings use ("host:port"for tcp/udp, the path for unix), and calls the injectedDialFn(substrate, rendered). No second dial type — a producer that registereddial_tcpfor the base op registers the direct op with zero new substrate code (ADR-007'slocalinteraction note). The UDP framed-adapter wrap (ADR-003) happens inside the dial closure (thelocalfeature'sconnect_udpalready does this) — the establisher stays substrate-agnostic. - No registry lookup —
unknown_resourcecan never fire (ADR-007 §2). Error mapping (ADR-007 §4's table minus the registry row): the serde-parse failure of a schema-valid-shaped target mapsHandlerError(the schema gate should have caught it — the parse is the backstop); dial failures pass through theDialFn's own typed errors unchanged (dial_failed/resource_shortage/handler_error). - The per-call
authcarries the opener identity (CF-006) — the witness seam records it, same as the base op (the direct op is the arbitrary-egress capability; identity visibility here matters more, not less). - Registration uses the SAME pump handler (
make_tunnel_pump_handler— the direct op's plan payload is aTargetHandleexactly like the base op's; plan-flow is unchanged).
consumer.rs — open_direct
pub async fn open_direct(
client: &ChannelClient,
target: SubstrateAddr,
substrate: Substrate,
) -> Result<Self, TunnelOpenError>
- A separate constructor, not a params enum (ADR-007 §5: two scopes
are two capabilities; the caller states which one it holds).
Serializes
{substrate, target},open_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN),from_halves— identical session type, data planes, and teardown API asopen. - Uses plain
open_channel(no reply fields expected on the direct op — dial establishers never bind; wire.md §The Direct Op). - Documented error surface: the base open errors minus
unknown_resource; malformed targets are the schema/invalid_input-class failure (registry-side); an old producer rejects the op loudly (NOT_FOUND— the SSH posture).
Tests
Unit (params.rs): TunnelDirectParams round-trip per substrate;
untagged SubstrateAddr disambiguation ({host, port} vs {path});
deny_unknown_fields rejection; spec-shape conformance to wire.md §The
Direct Op (op id, scope, oneOf schema structure — structural
assertions, no jsonschema dep here, per tunnels/params's precedent).
Integration (extend the duplex harness from tunnels/producer-open-op):
open_directe2e round-trip throughpump_bidi(tcp duplex target).open_directudp e2e (the framed adapter wraps at the dial closure — datagram boundary preserved).- Malformed target (unix without
path) → schema-class failure, no phantom session. - Producer without the direct op registered →
NOT_FOUND-class. - Identity without
tunnel:direct(but withtunnel:open) → FORBIDDEN (the scopes do not imply each other). - Direct establisher sees the per-call opener identity (witness).
Acceptance Criteria
TunnelDirectParams/SubstrateAddrround-trip; malformed targets rejected (serde + schema shape pinned)tunnel_direct_spec()matches wire.md §The Direct Op exactlydirect_establisher: no registry lookup, target rendering into theDialFnbacking string, typed errors per ADR-007 §4register_tunnel_direct_openablereuses the base pump handler + dial injection; witness seam presentTunnelSession::open_direct(separate ctor; identical session/teardown semantics)- Integration tests: e2e round-trips (tcp + udp), malformed-target rejection, scope separation (FORBIDDEN), NOT_FOUND posture, per-call identity witness
- Clippy/fmt clean; wasm32 check passes (no substrate code added)
cargo testgreen
References
- docs/architecture/decisions/007-direct-open-op.md (the normative op)
- docs/architecture/producer.md §The Direct Op; consumer.md §Direct path; wire.md §The Direct Op
- docs/architecture/decisions/006-access-control-posture.md Amendment 1
- tasks/tunnels/params.md + tasks/tunnels/producer-open-op.md (the shapes this extends; the harness to extend)
Notes
- The
input_schemapins the per-substrate target sub-schema as twoif/thenbranches underallOf, keyed on the siblingsubstratevalue (tcp/udp →{host, port}withadditionalProperties: false; unix →{path}, same posture) — JSON Schemaif/thencomposes through alkcall's jsonschema 0.46 compile, and the integration tests prove the gate fires (INVALID_INPUTbefore the establisher). Structural assertions pin the branch shape pertunnels/params's no-jsonschema-dep precedent. - Untagged
SubstrateAddris deliberately lenient about extra target fields (untagged variants carry nodeny_unknown_fields): a cross-shape target parses as whichever variant fits. The schema is the shape-vs-substrate gate; the establisher's serde parse is the backstop only (unknown substrate value →handler_error,direct_establishermapsTunnelEstablishError::HandlerError). This asymmetry is documented in both the type docs and the test. TunnelDirectParams::render_target()centralizes the backing-string form ("host:port"/ path) — the establisher injects the SAMEDialFnwith the rendered string; the render is pinned by test to the registry-backing form.TunnelSession::open_directuses plainopen_channel(no reply fields — dial establishers never bind);SubstrateAddrre-exports as the future forwarded-oppeershape (ADR-008 §3, defined once).- Harness:
RegistrationMode::Direct,wire_direct/wire_direct_with_identity(the raw-call topology),wire_direct_forward/wire_direct_forward_with_identity(the session-level forward topology; identity parameterized so the session-level FORBIDDEN probe runs through the real client API),direct_identity/both_scopes_identity.
Summary
Implemented the direct op end to end per ADR-007:
- params.rs —
OP_TUNNEL_DIRECT/TUNNEL_DIRECT_SCOPEconstants;SubstrateAddr(untagged{host, port}|{path}, the ADR-008 §3peershape too);TunnelDirectParams(deny_unknown_fields,render_target());tunnel_direct_spec()(Sub-typed, External,ChannelOpenSpec::new(TUNNEL_ALPN), scope["tunnel:direct"], output{channel_id: integer > 0}, per-substrateif/thentarget sub-schema). 7 unit tests: round- trips per substrate, untagged disambiguation, loud unknown-field rejection, spec-shape conformance, schema structure. - producer.rs —
direct_establisher/direct_establisher_with_witness(parse → render → injectedDialFn; no registry lookup; typed-error mapping per ADR-007 §4 minus the registry row; CF-006 witness seam) +register_tunnel_direct_openable(direct spec + establisher + the SAMEmake_tunnel_pump_handler, plan-flow unchanged). - consumer.rs —
TunnelSession::open_direct(client, target, substrate): separate constructor, serializes{substrate, target}, plainopen_channel(OP_TUNNEL_DIRECT, …, TUNNEL_ALPN),from_halves— identical session/data-planes/teardown asopen. - lib.rs — the new surface re-exported.
Tests: tests/producer_direct_op.rs — 15 integration tests over the
duplex harness: tcp + udp e2e round-trips through pump_bidi
(datagram boundary preserved via the framed dial closure), malformed
targets (unix without path, tcp without port, missing target)
→ INVALID_INPUT with no phantom channel/session, dial-failure
pass-through (dial_failed), NOT_FOUND on a producer without the op,
scope separation both ways (tunnel:open ↛ tunnel:direct and
tunnel:direct ↛ tunnel:open — FORBIDDEN, raw-call AND session-
level), CF-006 witness on the direct establisher (raw + session
paths), the establisher parse backstop (handler_error), and the
target-rendering pin (the dial closure observes the exact
backing-string form). Harness: Direct registration mode +
wire_direct* topologies + direct_identity/both_scopes_identity.
Verified: cargo test green (93 native; 94 with --all-features),
clippy -D warnings clean (native + wasm32), fmt clean, wasm32
check passes (default crate stays wasm-clean — no substrate code
added), cargo doc --no-deps clean. CHANGELOG updated (Unreleased →
Added).