# ADR-001: Open-Op Params Layout — Resource + Substrate ## Status Accepted (2026-09-07) ## Context The tunnel open op's `params` (the `input` of the `channels/tunnel/sub` operation, alkcall ADR-047) is the wire-stable carrier of "what to open." alknet ADR-071 §ALPN table noted `alknet/tunnel` as `[0, 1]` data in/out only; the addressing scheme was never decided there. Phase 0 resolved the framing of the problem (the OQ-TN-01 reframe, 2026-09-05): the rich "remote addressing" framing was an XY problem — except in the `-D`/dynamic case (which composes at the assembly layer), a tunnel is either TCP or UDP, and `params` need only *identify a produced resource*, not carry a general-purpose address. Constraints that shaped the decision: - `params` is ALPN-specific JSON, interpreted by the open op's establisher/handler — never by the channels layer (alkcall ADR-039). The layout is a one-way door once a consumer exists (wire-stable once published; additive changes only). - The identifier must be substrate-extensible without format changes (a new substrate is an additive `substrate` value, not a v2 format). SSH's `direct-streamlocal` (same open-op shape, degenerate address slots, new type string) and SOCKS5's ATYP (scheme-tagged addresses) are the prior-art anchors; both support the "one identifier + one discriminator" reduction. - The producer owns the backing: the consumer never learns an address. A `host:port` in params would leak the producer's topology into the wire and pin the wire to address-shaped targets. - alkcall's registry schema-validates the open op's input (`input_schema` runs before the establisher — review 007 Part B), so the layout must be schema-describable (no per-substrate polymorphic payloads in v1). - Both POCs used exactly this shape (`TunnelParams {resource, substrate}`); 17 + 16 tests rode it end-to-end. ## Decision `params` for the `channels/tunnel/sub` open op is a self-contained JSON object: ```json { "resource": "postgres-primary", "substrate": "tcp" } ``` - **`resource`** (string, required) — the produced resource identifier: the producer's stable name for the tunnel target. NOT an address; the producer's registry (an assembly-side construct, OQ-TN-11) maps the name to its backing (a local port, a docker container's port, an in-process service, a unix socket path). - **`substrate`** (string, required) — the extensible discriminator: `"tcp"` | `"udp"` | `"unix"` in v1. A new substrate is a new value, not a format change — the same additive property SSH gets from channel-type strings and SOCKS5 gets from ATYP values. The discriminator also selects the data-plane framing (ADR-003): the establisher and both pumps must agree on it, so it rides the open op rather than being inferred. - The open op's `input_schema` pins both fields as required strings; `substrate` is enum-validated against the v1 set. Unknown substrate values are a schema failure (typed open error) — a consumer speaking a newer substrate to an older producer fails loudly, not silently. - Substrate-specific detail (the path of a unix socket, the UDP associate semantics) is owned by the producer's registry, not the wire. The resource name is opaque to the protocol. - The open-failure error path rides alkcall ADR-049 (typed `channel:open_failed` with `details.reason` ∈ `dial_failed` / `unknown_resource` / `resource_shortage` / `handler_error` / `timeout`): an unknown resource name is `unknown_resource` — no params-level error field, no establishment frame (the pre-ADR-049 OQ-TN-09 hunch is superseded). Rich in-band addressing (SOCKS5 ATYP, per-datagram remote addresses) enters only through the `-D`/dynamic-target composition path — it is carried INSIDE the tunnel payload by whatever protocol the tunnel carries (socks5, udpgw-style framing), never in base params. ## Consequences - **Wire-stable from the first consumer.** Both fields are required; renames are a breaking wire change (do not). Adding an optional field is additive-safe; adding a `substrate` value is consumer-progressive (an older producer rejects with `invalid_input`-class schema error — the SSH "unknown channel type" posture, loud not silent). - **The BAST question is moot for params.** Params is JSON inside the channels open op (which is already a JSON envelope); the data plane's binary framing (ADR-003) carries its BAST doc at `docs/architecture/bast.md`. Per AGENTS.md convention 12, the hand-rolled codec remains the runtime implementation with the BAST doc as the contract. - **Producers own a naming discipline.** Resource ids are producer-scoped names (OQ-TN-11: collision domain = the producer's registry). Consumers learn available resources through the ACL-filtered ops listing (OQ-TN-08) — the spec's `OperationSpec.description` SHOULD carry a human-readable hint (e.g. "postgres on the worker's tailnet") since it round-trips through discovery. - **No per-connection dynamic targets in base params.** `-D`/SOCKS composes by tunneling a socks5 connection (OQ-TN-08); a future multi-endpoint UDP gateway resource would be self-describing framing inside the channel payload, invisible to params. ## References - Phase 0: OQ-TN-01 (the reframe + survey input), `docs/research/ssh-socks5-survey.md` §OQ-TN-01 residue - alkcall ADR-047 (openable ALPNs are operations; `input_schema`), 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)