Files
alkcall/docs/architecture/decisions/047-openable-alpns-are-operations.md
T
glm-5.3-flash 3317ea0963 docs: step-3 rewrites — OQ-39/40 resolved, C-08 closed, channel-operations.md put/control/resources contracts, review 010/011 closed with the S-01 sketch corrected
- open-questions.md: OQ-39 → ADR-052, OQ-40 → ADR-053 (resolutions replace
  the deferred-until-needed posture)
- channel-operations.md: the put contract (initiator allocates + streams,
  reserved input key, one response, acks-in-result sentence), the control
  routing (ADR-052), the enumerator aggregation (ADR-053), the
  connection-owner paragraph's pub-era correction, the ADR table rows
- ADR-051 §6: PubTypedOpen → the put translation; ADR-047's 2026-09-06
  amendment records its era honestly (the dynamic half is now ADR-053)
- review 010: all four findings resolved (with the S-01 sketch correction
  so the published record stops carrying the wrong-graph reading)
- review 011: closed (all seven pins implemented + gates landed)
- review 001 C-08: resolved note (odd/even + adopt, implemented)
- AGENTS.md: the 'Stub Guard' section — the not_implemented grep is part
  of cargo test; the registration invariants and the implement-or-stop
  ruling are release-blocking

Verified: cargo test (696+guard), clippy -D warnings, fmt, doc clean.
2026-10-13 09:04:13 +00:00

952 lines
49 KiB
Markdown

# ADR-047: Openable ALPNs Are Operations
## Status
Accepted (amends ADR-037; refines ADR-044, ADR-046; §4 amended
2026-08-13 — open ops are registered per-connection, not resolved via
`context.env` downcast — see "Amendment (§4 per-connection
registration, 2026-08-13)"; amendment #2 (2026-09-03) — the
per-connection registration mechanism is the **per-session fork of the
base registry installed as the session's dispatch registry**, not the
connection overlay — see "Amendment (§4 mechanism, 2026-09-03)" below;
§6 amended 2026-09-06 — the static half of discovery gains an additive
per-op `description` on the listing, the dynamic half stays deferred
— see "Amendment (§6 listing enrichment, 2026-09-06)" below;
amendment 3 (2026-09-16, review 008 U-1) — the op-name convention
extends to the flavor form and discovery carries an explicit
`channel_open_alpn` for non-derivable names — see "Amendment 3" below;
amendment 4 (2026-10-13, reviews 010 S-01 / 011) — the marked-pub open
contract: the initiator-allocated channel, the adopt path, the wrapper
as responder of record, zero `call.published` on marked ops, the relay
translation, and the client-side put-open capability — see "Amendment 4"
below)
## Amendment 4 (the marked-pub open contract, 2026-10-13 — review 010 S-01, review 011)
`channels/<alpn>/pub` open ops were registered, advertised, and stubbed
(`channel:pub_open_not_implemented` on every invocation — review 010
S-01, shipped nine published versions). This amendment pins the
implemented contract in place of the stub; review 011 corrected the
remediation sketch before this amendment was drafted (the serving-loop
`PublishStream` shape is the **unmarked** pub semantics and does not
apply to marked ops).
The shape, in one sentence: **a marked pub open is an ordinary channel
open whose `channel_id` the initiator allocated on its own side** — the
initiator streams bytes on that channel before the open resolves (the
early-arrival parking covers the race), and the responder adopts and
serves the data plane as the responder of record.
### 4.1 Marker semantics (pin 1)
The marked pub op's data plane is the adopted channel's `BiStream` —
one direction, initiator → responder, with the ADR-040 bounded buffer
as the backpressure mechanism. **Zero `call.published` data on the call
plane for marked pub ops**: a nonempty publish-chunk stream on a marked
op is rejected loudly with a new wire error (code
`channel:publish_chunks_rejected`, retryable false, message stating
that the data plane is the adopted channel) written in the initiator's
direction when the first chunk arrives; the chunks are never consumed,
and the marked-pub flow continues to its own single response. Two
competing data paths on a marked op must never exist. Unmarked `Pub`
ops are unaffected — they remain the ADR-046 `call.published` stream
through `publish_schema` validation.
### 4.2 `publish_schema` refused at registration for marked ops (pin 2)
Publish chunks never exist on a marked op (§4.1), so `publish_schema`
is dead machinery on a marked spec — the registered-but-incapable-of-
mattering class review 010 outlaws. `OperationRegistry::register`
refuses a marked spec that carries `publish_schema` (registration-time
loudness, per the house pattern).
### 4.3 Reserved input key + dispatch-layer strip (pin 3)
The adopted channel id crosses the open invocation as the reserved
input key **`"channel_id"`** — the same key the wrapper owns on the
reply side (`RESERVED_REPLY_KEY`, ADR-049 amendment 3); the wrapper
owns the `channel_id` key on both sides of a marked open op. Placement
rationale (review 011 §1.2): schema validation runs in `invoke_*`
before any handler, so the reserved key must be stripped
**pre-validation** in the sink resolve path (`resolve_sink_handler` —
the single source of truth shared by the wire dispatch and
registry-level `invoke_sink`, per review 001 P-08) before
`input_schema` validation; the stripped id is handed to the wrapper via
`OperationContext.metadata` under the key `channel_open_channel_id`.
The producing crate's `input_schema` never contains the key, and
dispatch rejects a marked pub op whose input **lacks** it — the
initiator-allocated id is mandatory, not optional plumbing
(`INVALID_INPUT`, message naming the key). An op input carrying
`channel_id` on a NON-pub or unmarked op is not stripped (their
schemas own their full input surface; the strip and the requirement are
both scoped to marked pub ops).
### 4.4 The wrapper as responder of record (pin 4)
The marked-pub open registration is
`ChannelCore::register_pub_openable(spec, establisher, pub_open_handler,
registry, auth, timeout)`. The entry points refuse wrong shapes at
registration: the `Query`/`Mutation`/`Sub` wrapper entry points
(`register_openable*`) refuse `Pub`-typed specs, and
`register_pub_openable` refuses non-`Pub` or unmarked specs. The
handler type is a separate one from `OpenHandler`:
```rust
pub type PubOpenHandler = Arc<
dyn Fn(Value, Option<ChannelPlan>, Connection, AuthContext)
-> BoxFuture<'static, Result<Value, CallError>>
+ Send + Sync,
>;
```
(`JoinHandle<()>` — the `OpenHandler` return — cannot carry the
handler result the merged response needs; see §4.6 for why this is a
separate type rather than an `OpenHandler` change.)
The wrapper flow (the responder of record):
1. `policy.check_open(identity)` — unchanged (cap gate, pre-adopt).
2. **Adopt** the initiator-allocated id:
`manager.adopt_channel(reserved_id, alpn, None)` — the existing
demux/mux install + early-arrival drain; no new machinery. Adoption
failure (collision or cap) tears nothing down (nothing was created)
and replies `channel:open_failed` with a new reason **`adopt_failed`**
in the ADR-049 §3 vocabulary.
3. The establisher (bounded, ADR-049 §1/§2 machinery, unchanged) may
validate the offer and contribute ack fields
(`Establishment.reply_fields`); its plan threads to the handler.
4. Spawn the pub handler on the channel's `Connection` — the wrapper
**awaits the handler inline** (review 011 §1.3), which is what makes
R-02's lifetime contract structural here: the handler cannot return
before its pumps finish, so the teardown-at-birth trap does not
exist on this path, and the channel's `ChannelState.handler_task`
stays `None` (teardown is EOF-driven: a `channel/close` during the
put drops the demux sender, the handler's reads see EOF, the handler
finishes early, and the single response is still written — the
handler decides the semantics of a truncated stream).
5. **One response, merge order pinned:** `{ "channel_id": <adopted> }`
→ extended with the establisher's ack fields (ADR-049 amendment 3,
unchanged, including the reserved-key establisher-bug refusal) →
extended with the handler's result fields (handler result wins on
key collision — its computed result postdates the ack). A handler
returning `Err` responds that error directly and the channel tears
down; racing initiator bytes hit EOF and are discarded.
6. The standard completion teardown runs after the await (demux
sender drop, ledger take, `policy.on_close` — the same atomic gate
every teardown path runs, ADR-047 §7).
A marked pub op registered outside the wrapper family (a plain
`SinkHandler` on a marked pub spec) can never adopt — the bytes would
park and drop. That class is refused at registration (§4.8).
### 4.5 The ack-timing sentence (pin 5 — protocol semantics)
**A sink op gets exactly one `call.responded`, produced after the
handler completes — so a putter stream-waits nothing: the
establishment ack (the adopt + establishment reply fields) rides
informatively in that one result, never as a gating ack before the
byte stream.** The fetch/put asymmetry is direction-inherent: fetch
(`Sub`) acks at establishment — the `{channel_id}` response precedes
any byte (and the `{channel_id}` is the ack); put (`Pub`) acks in the
result. This is protocol semantics, not per-consumer folklore: a
consumer protocol layered on marked pub ops (alkblobs' put) cannot
wait for the ack without deadlocking (the response cannot exist until
the stream completes), and the amendment states it so no consumer
re-derives it.
### 4.6 Why `PubOpenHandler` is a separate type (pin record)
The alternative — changing `OpenHandler`'s return type — breaks every
published consumer's open handlers (alktty, alkhttp, alktunnels,
alkty, alksocks all register `Sub`-type openables) for zero benefit:
the `()`-returning shape is correct for the spawn-and-reconcile `Sub`
path (the channel outlives the open call; the wrapper never awaits
it), while the put path needs completion-in-response. The split keeps
S-01 capability-additive (review 011 §5: no shipped consumer registers
a `Pub`-typed openable); the put path's type is new API surface,
one-way like its siblings (ADR-046's handler-type door).
### 4.7 Relay translation replaces `PubTypedOpen` (pin 6)
`RelayError::PubTypedOpen` is removed. The hub's pub-openable
translation (the put-forwarding shape):
- The consumer-leg registration composes
`ChannelCore::register_pub_openable` with a relay establisher: the
hub **allocates** its connect-side id on the producer leg
(`open_channel` — odd, the implemented split), sends the open op
carrying the reserved key to the spoke **in the background** (never
awaited inside the establisher — the spoke's put response cannot
exist before its byte stream completes; awaiting it in the
establisher deadlocks the leg), and the `RelayPlan` carries the
spoke-leg send half, the early-arrival-drained read half, the
producer-leg entry reclaim guard (as before, ADR-051), and the
response receiver.
- The relay pub handler (the consumer-leg pub handler): pumps the
consumer-leg channel bytes → spoke send half (one direction, pub);
on consumer EOF, EOFs the spoke side; awaits the spoke's single
response; strips the producer's reserved reply key from the spoke
output (the hub's reply must carry the hub-allocated consumer-leg
id — a spoke `channel_id` merged through would shadow it, the same
reserved-key class the wrapper refuses) and returns the remainder as
the handler result — which the consumer-leg wrapper merges into the
single response with the pinned order.
- The spoke's open-time establishment failure surfaces in the final
response (put-acks-in-result), mapped through the existing
`map_spoke_error` table; the plan's reclaim guard tears down the
producer-leg entry on every handler path, as ADR-051 §6 already
pins for the sub direction.
- The hub-leg template's pub-typed assembly error becomes unreachable
(registered through the relay like the sub ones).
### 4.8 The registration invariants (the stub-class guard)
`OperationRegistry::register` enforces, at registration time:
- A marked spec with `publish_schema` → refused (§4.2).
- A marked spec registered through the generic path → refused: marked
specs are registered only through the channel-machinery seam
(`register_openable*`, `register_pub_openable`, the relay). The
wrapper-family registrations carry an internal registration flag the
generic paths cannot set; `CallConnection::register_imported` (the
overlay landing) and direct `registry.register` both hit the refusal
— for overlay imports because a marked op is unreachable by
composition (`OperationEnv::invoke` is request/response-only; the
marker's data plane has no overlay path), for direct registrations
because the channel machinery would silently not exist.
### 4.9 The client-side initiator-allocates capability (pin 7)
`ChannelClient::open_pub_channel(operation_id, input, alpn)`: allocates
the connect-side odd id locally (`open_channel` on its own manager),
inserts the reserved input key, sends the open op **in a background
task**, and returns `(id, send, recv, reply)` immediately — the caller
writes bytes on `send` without waiting for anything (put acks in
result, §4.5). `reply` is the awaited single merged response
(`Ok(Value)` — the full output; `Err(ChannelOpenError)` — the wire
error passthrough, with the local adopt entry torn down on failure).
The existing `open_channel`/`open_channel_with_reply` await the reply
before returning the halves — unusable for a put; this is the
putter-facing shape the wrapper is untestable end-to-end without.
### Door type
**One-way (wire-visible).** The reserved input key and its
strip-before-validation, the missing-key rejection, the
chunk-rejection code, the merge order, the single-response semantics,
and the ack-timing sentence are wire-visible from the first consumer
(alkblobs' put — which pends behind this amendment, so this is the
cheapest moment to pin; no shipped consumer rides any of it, review
011 §5). `PubOpenHandler`, `register_pub_openable`,
`open_pub_channel`, and the `adopt_failed` reason are new API surface,
one-way per the handler-API precedent. The early-arrival parking, the
wrapper internals, and the relay plan internals are two-way-door
details inside the one-way contract.
### Verification gates (implementation units, review 011 §3)
- known-length put e2e: offer + adopt + bytes → one response,
`{channel_id, ack fields, handler result}` merge order verified;
- unknown-length put e2e: the handler's digest rides the merged
response;
- publish-chunk rejection: a marked pub op carrying nonempty publish
chunks → loud rejection, no handler consumption;
- establishment failure: `channel:open_failed`, channel torn down,
ledger un-incremented, racing bytes EOF-discarded;
- `publish_schema` registration refusal on a marked op;
- relay translation: a hub translating a pub openable end-to-end.
- plus: missing reserved key rejection; `open_pub_channel` happy +
wire-failure paths (the client capability's gates).
### References
- Review 010 S-01 (the four stubs; the implement-or-stop-advertising
ruling), review 011 (the corrected shape this amendment pins)
- alkblobs ADR-014 (`014-marked-pub-put-contract-and-alkcall-gate`) —
the downstream ruling this amendment unblocks
- ADR-046 (the unmarked pub semantics that marked ops do NOT ride),
ADR-049 (establishment machinery + `RESERVED_REPLY_KEY` + merge),
ADR-040 (bounded buffer — the data-plane backpressure), ADR-050
(pumps), ADR-051 (the relay this amendment extends to pub), ADR-043
(`ChannelClient` — the `open_pub_channel` home)
- `src/channels/manager.rs` (`next_channel_id` odd/even;
`drain_early_arrivals`), `src/channels/operations.rs` (the wrapper
family), `src/channels/relay.rs`, `src/registry/registration.rs`
(`resolve_sink_handler` — the strip site)
## Amendment 3 (flavor-form open-op ids + explicit `channel_open_alpn`, 2026-09-16 — review 008 U-1)
alktunnels' graduation (ADR-002 Amendment 1 at
`/workspace/@alkdev/alktunnels/docs/architecture/decisions/002-alpn-strategy.md`)
pins two NEW `Sub`-typed open ops on the existing `alk/tunnel` ALPN
with flavor-form op ids (`channels/tunnel/direct`,
`channels/tunnel/forwarded`) — the ssh
`direct-tcpip`/`forwarded-tcpip` shape: one ALPN, several channel
flavors, one open op per flavor. §1 pinned the open-op naming to
exactly two shapes per ALPN (`channels/<alpn>/sub`,
`channels/<alpn>/pub`), and Gap F's marker reconstruction derived the
ALPN by stripping exactly those two suffixes — so a flavor-form op id
failed the derivation and a hub consuming through discovery rebuilt
the spec WITHOUT the marker (the silent plain-forwarding-stub failure,
the worst mode for a relay). Runtime was never blocked (registration
sets the ALPN explicitly; `open_channel` takes it as an argument);
discovery + hub relay are the re-produce path that must survive.
**The convention sentence (mirroring alktunnels ADR-002 Amendment 1):**
`channels/<alpn>/<flavor>` is a valid open-op name where `<flavor>`
is a bare path segment (no `/`), `Sub`-typed, served through the same
establishment wrapper and relay path as `…/sub`. New flavors are new
op ids — additive; the `…/sub` op is never reused for a different
meaning. `OperationType` (`Sub`/`Pub`) continues to carry the
direction; the flavor is the channel-type discriminator the producing
crate owns (per-ALPN params semantics, per alktunnels ADR-001
Amendment 1).
**The wire shape (Gap F refinement):** the boolean marker's
round-trip is derivable only for the standard shapes. The rule:
- `spec_to_json_pub` keeps emitting `"channel_open": true` for every
marked op (unchanged). When the op name is NOT the standard
`channels/<segment>/(sub|pub)` shape, it ALSO emits
`"channel_open_alpn": "<alpn>"` — the explicit string rides beside
the boolean. Standard shapes stay byte-identical to the
pre-amendment payload (no new key).
- `rebuild_spec_for` prefers the explicit string when present; else
(the boolean alone — the deployment-skew case of an old producer)
the derivation generalizes from "strip `/sub`|`/pub`" to "strip the
LAST path segment." The boolean marker remains the gate: the
derivation is consulted only for marked ops, so a plain op named
`channels/tty/query` is unaffected, and a `channel_open_alpn`
string without the boolean never marks an op.
- `services/schema`'s advertised `operation_spec_schema` documents
`channel_open_alpn` as an optional `["string", "null"]` property
(schema-type widening, additive — old consumers ignore unknown
properties).
- Both wire consumers of the shape — the `from_call` import and the
`op/register` announced-spec path — parse through the same
`rebuild_spec_for`, so one parser change covers both.
**Residual ambiguity, pinned:** an op name whose ALPN segment itself
ends in a flavor-like name (`channels/x/direct` where the intended
ALPN is `alk/x/direct`-shaped) is undecidable from the name alone —
strip-last reads `alk/x`, and the true ALPN is unknowable from the
name. The explicit `channel_open_alpn` string is the disambiguator:
producing crates with non-`alk/*`-derivable names set it; the
derivation is the fallback for derivable shapes. Old producers
(alkcall ≤ 0.7.1) cannot serve flavor-form marked ops discoverably
until upgraded — the review's one-way-door timing note.
Door type: the flavor-form names and the additive
`channel_open_alpn` key are wire-stable from the first consumer
(alksocks composes against them); the boolean's meaning for
standard-shape ops is unchanged (the pre-amendment byte-stability is
pinned by test). The `op_name_is_standard_channel_open_shape` helper
and the strip-last derivation are two-way-door implementation details
within the one-way wire shape.
Implemented surface (alkcall 0.8.0): the generalized
`derive_alpn_from_op_name` (strip-last), the explicit-field emission
(`spec_to_json_pub` + the advertised schema property), and the
preference order in `rebuild_spec_for`. Verification gates landed as
tests: `channels/tunnel/direct` reconstructs WITH
`channel_open = alk/tunnel` (gate 1, both the explicit-string and the
skew-case paths); standard boolean ops (`channels/tty/sub`,
multi-segment `channels/custom/proto/sub`) round-trip byte-stable
(gate 3); the explicit string overrides a colliding derivation; a
string without the boolean never marks; `op/register` announced
flavor-form specs round-trip with the marker. Gate 2 (the hub-relay
round trip) lands with the in-tree relay component (review 008
remediation plan Unit 3c, amending ADR-042 — the relay export is
ADR-051).
## Amendment (§6 listing enrichment, 2026-09-06)
Review 006 E-02 (from the alktunnels Phase 0 sweep) made §6's dynamic
half load-bearing for the first time and asked for a decision on the
static half. Decision: **both halves of the "static per-op" split gain
what is cheap today; the dynamic half stays deferred** (OQ-40, now
with a "load-bearing for alktunnels discovery UI" note in
`open-questions.md`; alktunnels v1 uses config-known op names).
*[Amendment 4 era, 2026-10-13: the dynamic half is now implemented —
ADR-053; this 2026-09-06 text records the state at its writing.]*
- `OperationSpec` gains `description: Option<String>` — a human-
readable op description, set via `with_description` at registration.
Additive (defaults `None`; no struct-literal construction sites
exist — all sites use `OperationSpec::new`).
- `services/list` (and the local-ops half of `services/list-peers`)
emit `description` when set — one round-trip answers "which ops
exist and what are they for" without the N+1 `services/schema`
sweep. The output-schema docs on both listing specs and on
`services/schema`'s `operation_spec_schema` advertise the field.
- `spec_to_json_pub` emits it when set; `rebuild_spec_for` parses it
back — the description survives discovery and peer announcement
(`from_call`, `op/register`) like every other additive spec field
(`resource_id_path`, `publish_schema` round-trip the same way).
Scope note from E-02 stands: the listing field describes **the op**,
not **the produced resource set** (a tunnel producer registers one op
and N resources). "Which tunnel resources may I open, live" remains
`channel/resources/subscribe`'s job (§6's dynamic half, ADR-037 §
`channel/resources/subscribe`) — deferred until a consumer needs live
resource discovery.
## Amendment (§4 mechanism, 2026-09-03)
The 2026-08-13 amendment named the registration target as "the
connection overlay registry (Layer 2 per ADR-019)". Review 004 (F-01)
verified that this shape cannot dispatch: the top-level dispatch path
(`Dispatcher::dispatch` / `run_loop_single_stream`) resolves and
invokes against the dispatcher's **base registry only**; the
connection overlay is reachable solely as a layer of `context.env`
(for nested invocations — a handler calling `env.invoke(...)), never
for resolving the incoming `call.requested` itself. An open op
registered on the overlay resolves `NOT_FOUND` on the wire.
The one shape proven end-to-end (alkcall's own e2e gate) is different:
the `install_channel_zero` hook builds a **fresh per-connection
registry containing the open op and passes it as the dispatcher's base
registry**. This amendment makes that the operative mechanism.
**The decision: per-connection registration happens on a fork of the
deployment's base registry, installed as the session's dispatch
registry.** The `install_channel_zero` hook (and any future
session-establishment seam):
1. **Forks** the deployment's base registry
(`OperationRegistry::fork` — a deep copy carrying handlers,
provenance, composition authority, capabilities, and the cached
publish-schema validators; review 004 F-02/F-03).
2. **Registers the per-session ops on the fork** — the generic channel
ops (`ChannelOperations::register_on`), the openables
(`ChannelCore::register_openable`), and the bootstrap discovery ops
(`install_bootstrap_discovery`, closed over the fork itself so
`services/list` sees the fork's per-session ops — review 004 F-06).
3. **Dispatches channel 0 over the fork** (`Dispatcher::new(fork,
...)`).
The fork is possible because `OperationRegistry` is internally
mutable (`parking_lot::RwLock` around both maps) — a fork shared as an
`Arc<OperationRegistry>` can receive bootstrap ops after the
dispatcher was built, and the self-referential discovery closure sees
every post-install registration.
The **connection overlay (Layer 2) remains what ADR-019/ADR-024
describe**: the landing zone for peer-announced ops (`op/register`,
review 004 F-05 — ADR-022 amendment) and the nested-invocation target
for imported ops. It is not the dispatch-resolution path for the
session's own ops.
Rationale for the fork shape over an overlay-aware dispatch fallback
(F-02 option (b)): the fork is the only shape with an end-to-end
proof, it needs no change to the shared dispatch loop, and it keeps
the overlay's `invoke_with_policy` shape (namespace-scoped,
parent-context-driven — built for nested composition) out of the
top-level call path, where it does not match the frame-handling
contract.
This preserves every invariant the 2026-08-13 amendment protected:
- **Layering (ADR-044):** unchanged — the open-op wrapper is in
`channels-call`; the call crate's `OperationRegistry` gains only
`fork` (and interior mutability), no channels types.
- **Per-connection resolution:** the open op gets the *right*
`ChannelManager` because the fork is built per-connection and its
openable closes over that connection's `ChannelCore`.
- **"Marked ops invoked outside a channels session" (ADR-047 §2):**
unchanged in effect — a `channels/<alpn>/sub` op registered only on
a session fork is not reachable on a bare `alk/call` connection (the
fork isn't that session's dispatch registry) — the dispatch path
returns `NOT_FOUND`.
### Door type
**Two-way (implementation detail), as before.** The registration
*target* mechanism (fork as base registry) sits within the same
wrapper-shape detail the 2026-08-13 amendment already marked two-way.
The one-way decisions (per-ALPN op names, the `channel_open` marker,
removal of `channel/open`/`direction`) are unchanged.
### References
- Review 004 F-01/F-02/F-03/F-06
(`docs/reviews/004-per-connection-dispatch-and-client-serving-review.md`)
— the verification and the mechanism decision
- ADR-022 amendment (2026-09-03) — the bootstrap-op set (`services/list`,
`services/schema`, `op/register`) and the connect-side serving loop
- ADR-019: operation registry layering (the overlay stays the nested
invocation / peer-announced-ops landing zone)
- The e2e gate: `fork_registry_open_op_resolves_and_is_discoverable`
(`src/channels/client.rs`) — open op resolves through the fork,
per-session openable in `services/list`, `services/schema` validates
## Amendment (§4 per-connection registration, 2026-08-13)
ADR-047 §4 specified that the open-op wrapper resolves the
per-connection `ChannelManager` by downcasting `context.env` to
`&dyn ChannelOperationEnv` at invocation time — "static registration,
dynamic resolution." Implementation (review 001, C-03) found this
shape is not workable as written: `context.env` is a `PeerCompositeEnv`
(a composite of base + session + per-connection overlays), not a single
concrete type, so a direct `as_any()` downcast of `context.env` to
`ChannelsSessionEnv` cannot reach the `ChannelManager`. Traversing the
composite's layers to find the channels-backed overlay would hardcode
`PeerCompositeEnv`'s internal structure into the channels module —
fragile, leaky, and a layering violation (the call crate's composite-env
shape is not part of the channels crate's contract).
**The amendment: open ops are registered per-connection.** The
`ChannelCore` is constructed per channels connection (in the
`install_channel_zero` hook, which already runs per-connection and
already receives the `ChannelManager`). `ChannelCore::register_openable`
is called on that connection's `OperationRegistry` (the connection
overlay registry, Layer 2 per ADR-019), closing over the per-connection
`ChannelCore`. The wrapper uses `ChannelCore::manager()` directly — no
`context.env` downcast, no dynamic resolution. The open op lives on the
connection overlay, which is where per-connection state naturally
belongs (ADR-019, ADR-024).
This preserves every invariant ADR-047 §4 was written to protect:
- **Layering (ADR-044):** the call crate stays free of channels types.
The open-op wrapper is in `channels-call` (`src/channels/operations.rs`);
the call crate's `OperationRegistry` and `OperationEnv` are unchanged.
No `as_any()` is added to `OperationEnv` (the trait stays concrete-rpc-
shaped, preserving the session/connection overlay patterns from
ADR-024 — AGENTS.md §6).
- **Per-connection resolution:** the open op gets the *right*
`ChannelManager` (the one for the connection it was invoked on) because
the op is registered on that connection's overlay registry, with a
`ChannelCore` closing over that connection's manager. A globally-
registered handler (Layer 0) is not on this path.
- **"Marked ops invoked outside a channels session" (ADR-047 §2):**
unchanged. A `channels/<alpn>/sub` op registered only on a channels
connection's overlay is not reachable on a bare `alk/call`
connection (the overlay isn't attached there) — the dispatch path
returns `NOT_FOUND`, which is the correct behavior for "no channels
session" (the `channel:no_channels_session` error code from the
original §4 is no longer reached; `NOT_FOUND` is the natural
reachable-but-not-here result).
The `ChannelOperationEnv` extension trait and `ChannelsSessionEnv` impl
(`src/channels/env.rs`) are retained as a two-way-door implementation
detail — they are not on the open-op path, but remain available for
future per-connection routing (e.g., nested channels where each
connection's overlay carries its own manager reference for
non-open-op queries). The `resolve_channel_manager` stub is removed
(it described the rejected dynamic-resolution shape).
### Door type
**Two-way (implementation detail).** The ADR's door-type section
already marks "The `ChannelCore` wrapper shape, the extension-trait
pattern, and the opener ledger are two-way-door implementation details
within the one-way decision." Per-connection registration vs. dynamic
resolution is a choice within the wrapper-shape detail — the one-way
decisions (per-ALPN op names, the `channel_open` marker, removal of
`channel/open`/`direction`) are unchanged. A future revision could move
open ops back to Layer 0 with real `as_any()` downcast machinery if the
composite-env structure stabilizes enough to make the traversal
non-fragile; the per-connection shape is the simpler choice today.
### References
- C-02, C-03 in `docs/reviews/001-pub-and-channels-integration-review.md`
(the `register_openable`-does-not-exist and
`resolve_channel_manager`-is-a-stub findings this amendment resolves)
- ADR-019: operation registry layering (the connection overlay the open
op is registered on)
- ADR-024: peer-graph routing model (the `OperationEnv` integration-point
pattern this amendment preserves by NOT adding `as_any()`)
## Context
The channels spec (ADR-037) framed channel lifecycle as **one generic
`channel/open` operation** whose input carries the authorization-relevant
facts (`alpn`, `params`, `direction`) inside its JSON body. The call
protocol's ACL machinery (`AccessControl::check` on `OperationSpec`) runs
*before* the handler — but the generic op hides everything the ACL would
need to see inside one op's input, where the call ACL machinery can't
reach it.
The research findings
(`/workspace/@alkdev/alknet/docs/research/call-channels-unification/
findings.md`) surfaced the structural miss and the resolution together:
- **The miss.** Per-ALPN scopes (`tty:open` vs `tunnel:open`) have no
enforcement home. ADR-011's `resource_id_path` (JSON pointer into input
for the resource ID, checked by the ACL before the handler runs) can't
work on a single generic op — the pointer differs per ALPN
(`/params/container` for tty-docker, `/params/target` for tunnel). The
two `direction` values are wildly different grants ("may you consume my
TTY" vs "may you register a service I will consume as a client")
squeezed under one ACL. The shape re-commits the structural miss
ADR-024 (superseding ADR-023) was written to avoid: a parallel
authorization system duplicating `AccessControl` one layer down.
- **The resolution.** An openable ALPN is an operation. Each openable
ALPN registers its own ops on the call `OperationRegistry`, with its
own `access_control`, `input_schema`, `resource_id_path`, and a
`channel_open` marker that tells the channels layer "this op's stream
is binary, not JSON." The call protocol's existing
`AccessControl::check` is the ACL — unchanged. The `direction` field is
gone; `OperationType` carries the direction (`Sub` = consumer
subscribes, `Pub` = producer publishes), building on ADR-046.
This is the cheapest moment to amend: no channels code exists, no
deployments exist, the develop branch is pre-alpha. ADR-037's four op
names were declared one-way doors, but ADR-035/046 just demonstrated that
amendment is the normal mode here.
### What the research findings resolved (Gap A, E)
- **Gap A** (Pub handler shape) — resolved by ADR-046:
`HandlerKind::Sink` / `SinkHandler` / `invoke_sink()`. The initiator
streams *to* the responder via `call.published` events; the handler
consumes the stream and returns a single `ResponseEnvelope`.
- **Gap E** (`OperationEnv::channel_manager()` couples call to channels)
— resolved by the extension-trait pattern: an
`alknet-channels-call`-local trait (`ChannelOperationEnv: OperationEnv`)
adds the `channel_manager()` accessor, and the open-op wrapper
downcasts `context.env` at invocation time. `alkcall` (the call crate)
stays free of any channels types; the layering is preserved.
### What this ADR decides (Gap B, C, D, F, G)
- **Gap B** (hub broker) — **out of scope for alkcall.** The broker
(topic registry, `Pub`↔`Sub` matching by `(op_name, params_hash)`,
N-consumer fan-out) is a hub/consumer concern, not an alkcall concern.
alkcall provides the `Pub`/`Sub` primitives (ADR-046) and the channel
machinery (this ADR); the hub composes the broker on top, the same way
the hub relay composes on top of call ops. This ADR names it as
out-of-scope so it stops re-tangling every channels-control-plane
conversation.
- **Gap C** (`from_call` relay wrapper) — the `from_call` forwarding
handler for a marked op wraps with relay machinery (forward + allocate
local-leg channel + record id mapping + byte-forward pumps) instead of
the plain forwarding stub. The wrapping lives in a separate layer
(`from_call` consumer code, e.g. the hub), not in alkcall's `from_call`
core, preserving the layering. alkcall's `from_call` reconstructs the
`channel_open` marker from discovery (Gap F) so the consumer can branch
on it.
- **Gap D** (`channel_id` allocation in Pub case) — the real invariant is
"the side that holds the `ChannelManager` allocates." In the `Sub` case
that's the responder (the side that received the open op); in the `Pub`
case that's the initiator (the side that sent the open op, which owns
its own channels connection). ADR-037's "responder allocates" is
amended to "connection-owner allocates."
- **Gap F** (`channel_open` marker wire format) — the marker is a boolean
field `"channel_open": true` on the `services/schema` payload. The ALPN
is derivable from the op name (`channels/<alpn>/sub` → ALPN
`alk/<alpn>`); the marker is the dispatch hint, not a carrier for
the ALPN string. `spec_to_json` emits it; `rebuild_spec_for` parses it.
- **Gap G** (`resource_id_path` ACL vs handler ownership) — complementary,
not redundant. The ACL check (via `resource_id_path` +
`OwnershipProvider`) is the coarse gate ("may this identity touch
resources of this type, and if a specific resource is targeted, does
this identity own it?"). The handler's ownership check is ALPN-specific
business logic the ACL can't express ("is this container in a state
that allows TTY attachment?"). The two run at different layers and
answer different questions.
## Decision
### 1. `channel/open` dissolves into per-ALPN ops
The generic `channel/open` operation (ADR-037) is **removed**. Each
openable ALPN registers its own ops on the call `OperationRegistry`,
named `channels/<alpn>/sub` and/or `channels/<alpn>/pub`:
| Op | `OperationType` | Initiator role | Responder role | Stream |
|----|-----------------|----------------|----------------|--------|
| `channels/<alpn>/sub` | `Sub` | consumer (subscribes) | producer (streams) | server→client |
| `channels/<alpn>/pub` | `Pub` | producer (publishes) | consumer (receives) | client→server |
An ALPN may register one or both ops. A peer that may subscribe is not
the same grant as a peer that may publish — separate op types, separate
ACLs.
The `direction` field from ADR-037 is **removed**. `OperationType`
carries the direction. The "who writes first" question is
ALPN-specific (determined by the handler's `params` contract), unchanged
from ADR-037's "the channels layer does not enforce write order."
**The generic ops stay:** `channel/close`, `channel/control`,
`channel/resources/subscribe` remain generic (keyed by `channel_id`),
registered by `channels-call` at assembly time. Only `channel/open`
dissolves.
### 2. `channel_open` marker on `OperationSpec`
`OperationSpec` gains an optional `channel_open` field — the marker that
tells the channels layer "this op's stream is binary, allocate a channel
for it":
```rust
pub struct OperationSpec {
// ... existing fields ...
pub channel_open: Option<ChannelOpenSpec>,
}
pub struct ChannelOpenSpec {
pub alpn: Cow<'static, str>, // e.g., "alk/tty"; Cow so from_call can supply an owned String without Box::leak
}
```
The marker is **registry metadata, not auth machinery** — parallel to
how `resource_id_path` tells ADR-011 where to find the resource ID. The
op's `access_control` is the ACL (unchanged); the marker is the dispatch
hint. The `OperationRegistry` is opaque to the marker; it's
`channels-call` that reads it.
**Wire-visible.** The marker survives discovery serialization — it is
part of the `services/schema` payload, not just the in-process struct.
`spec_to_json` emits `"channel_open": true` when set (boolean — the ALPN
is derivable from the op name). `rebuild_spec_for` parses it back. A
`from_call` importer can branch on the marker to wrap with relay
machinery (Gap C).
**Marked ops invoked outside a channels session.** `channels/tty/sub` is
registered on the call registry — which means it's also visible/invocable
on a bare top-level `alk/call` connection, where there is no
`ChannelManager`. The open-op wrapper resolves `channel_manager()` at
invocation time via the extension trait (Gap E); if it returns `None`,
the wrapper returns `channel:no_channels_session`.
> **Amended 2026-08-13** — `ChannelOpenSpec::alpn` is
> `Cow<'static, str>` (not `&'static str` as originally decided). The
> `&'static str` shape forced `from_call`'s discovery path to
> `Box::leak` each runtime-parsed ALPN `String` to satisfy the
> `'static` bound, accumulating a leak on every rediscovery. The
> `Cow<'static, str>` keeps the common case (ALPN crates register at
> compile time with a `&'static str` literal — zero allocation) cheap
> while letting `from_call` supply an owned `String` without leaking.
> This is a two-way-door type detail (the wire format — a boolean
> `channel_open` marker — is unchanged).
### 3. `ChannelCore` wrapper (the open-op composition seam)
The ALPN crate provides:
- The `OperationSpec` (with `channel_open` marker, `access_control`,
`input_schema`, `resource_id_path`).
- The open handler (ALPN-specific work — validate params, consult
ownership, prepare the backend, return a "channel plan" — a
`ProtocolHandler` to spawn on the channel's `BiStream`).
`channels-call` provides:
- The `ChannelCore` (channel-id allocation, `ChannelManager` integration,
per-connection opener ledger, `ChannelLifecyclePolicy` consultation,
teardown hooks).
- A `register_openable(spec, open_handler, channel_core)` helper that
wraps the ALPN's open handler with the channel machinery and registers
the op on the call `OperationRegistry`.
The wrapper shape (not the invoke shape) is preferred: the ALPN's open
handler returns a channel plan; channels-call's wrapper does the
allocation/ledger/policy/spawn. The alternative (the handler calls
`context.channel_core.open(...)` itself) would require either
`OperationContext` to carry a `channel_core` reference (inverting the
call → channels-call dependency) or a generic extension mechanism on
`OperationContext` (more complexity than the wrapper). The exact API
shape of the channel plan is a two-way-door implementation detail; the
architectural point is the wrapper.
### 4. Per-connection `ChannelManager` resolution (Gap E)
> **Amended 2026-08-13** — see "Amendment (§4 per-connection
> registration, 2026-08-13)" at the top of this file. The
> dynamic-resolution shape described below (downcast `context.env` to
> `&dyn ChannelOperationEnv` at invocation time) was found
> unworkable as written (`context.env` is a `PeerCompositeEnv`, not a
> single concrete type; the downcast cannot reach the manager without
> fragile cross-layer traversal). The operative decision is
> **per-connection registration**: `register_openable` is called on the
> connection overlay registry (Layer 2), closing over a per-connection
> `ChannelCore`; the wrapper uses `ChannelCore::manager()` directly.
> The body below is the **original** (rejected) shape, retained for
> rationale continuity.
The `register_openable` helper registers ops at assembly time (Layer 0,
curated, static per ADR-019). But the wrapper needs the
**per-connection** `ChannelManager` — the op arrives on channel 0 of one
specific channels connection, and the channel must be allocated on
*that* connection's manager. A globally-registered handler closing over
a static `ChannelCore` has no way to know which channels connection
invoked it.
The resolution: an extension trait in `channels-call` (not in the call
crate's core `OperationEnv` trait) adds the `channel_manager()` accessor:
```rust
// in channels-call
pub trait ChannelOperationEnv: OperationEnv {
fn channel_manager(&self) -> Option<&ChannelManager>;
}
```
The wrapper handler downcasts `context.env` to
`&dyn ChannelOperationEnv` at invocation time — static registration,
dynamic resolution. If the downcast fails (no channels session), the
wrapper returns `channel:no_channels_session`. This keeps `alkcall`'s
call crate free of any channels types and preserves the layering
(ADR-044). The `OperationEnv` is already the integration point for
per-connection state (ADR-019); adding a `ChannelManager` accessor via
an extension trait is the natural extension.
Each channels connection's `OperationEnv` overlay carries its own
`ChannelManager` reference, so nested connections resolve correctly.
### 5. `channel_id` allocation: connection-owner allocates (Gap D)
ADR-037's "responder allocates" invariant is amended: **the side that
holds the `ChannelManager` for that connection allocates the
`channel_id`.** In the `Sub` case that's the responder (the side that
received the open op — it owns its channels connection). In the `Pub`
case that's the initiator (the side that sent the open op — it owns its
own channels connection).
This is the same invariant stated correctly: the connection owner
allocates. The "responder allocates" framing was a special case that
happened to hold for `Sub` (the common case) but broke for `Pub` (the
initiator owns the connection it's publishing from).
### 6. The discovery split
- **"What may I open"** (static, per-op): `services/list` (visibility-
filtered + `AccessControl::check(calling_peer_identity)` server-side,
per ADR-024 §6) + `services/schema` (per-op `access_control` and
`channel_open` marker). The existing server-side ACL-filtered
discovery is preserved; the spec is the authority. `channel:forbidden`
on the open op is the real check; the preview is "here's what the spec
says, you can fail fast."
- **"What is currently there"** (dynamic, ALPN-level):
`channel/resources/subscribe`. Each ALPN crate that registers open ops
also provides a resource enumerator (which containers are running,
which TTY sessions are active). `channel/resources/subscribe`
aggregates across all registered openable ALPNs. The data source lives
in the ALPN crate, not in channels-call.
The `access` preview in `resources/subscribe` (ADR-037) becomes
redundant — it's on the op spec, available via `services/schema`. It is
dropped from the `resources/subscribe` payload; the spec is the
authority, and carrying a preview in a different shape invites
staleness.
### 7. Quota lifecycle: the opener ledger (Gap 2 from findings)
ADR-041's `ChannelLifecyclePolicy::on_close(&op_ctx.identity)` is called
from the `channel/close` handler with the **closer's** identity, not the
opener's — and is not called at all on transport drop. A peer whose
connection dies at cap is permanently at cap (a self-DoS).
The fix: `channels-call` keeps a per-connection opener ledger
(`channel_id → opener PeerId`). The decrement is keyed by the opener
(from the ledger), not the closer. The decrement is called from **every
teardown path** — close received, close sent locally, handler exit,
connection drop — not just `channel/close`. The ledger entry is removed
atomically with its decrement (teardown paths can race; a
double-decrement under-counts and weakens the cap).
The `ChannelLifecyclePolicy` trait shape (`check_open`, `on_close`)
survives; the change is where `on_close` is called from and where the
opener identity comes from. `channels-core` stays auth-blind (the
ledger lives in `channels-call`, not `channels-core`).
### 8. ALPN category reframe
ADR-086 §4 (alknet source) split the foundational handlers into
"channels data-channel ALPNs" and "SSH (endpoint ALPN wrapping
channels)." Under the unified model, the first category dissolves —
they're **call apps** (the same shape as docker). They register ops on
the call `OperationRegistry`. Some ops return JSON; some ops carry the
`channel_open` marker and produce a binary stream. The endpoint
(channels or bare call) determines the framing, not the app.
The ALPN crates served under channels (tty, tunnel, socks5, fs, sftp)
stop being "just ALPNs" and become call apps. They inherit call's
auth/composition/identity by construction (they *are* call apps). The
binary-stream part is the `channel_open` marker on the op spec. SSH
stays distinct (endpoint ALPN wrapping channels).
## Consequences
**Positive:**
- Per-ALPN ACLs have an enforcement home: each openable ALPN's op has
its own `access_control`, checked by `OperationRegistry::invoke`
before the handler runs, like every other op. No new auth machinery.
- ADR-011's `resource_id_path` works for channel-open ops: the path is
per-op (`/params/container` for tty-docker), not per-ALPN-branch-of-a-
generic-op. The coarse ownership gate runs before the handler.
- The `direction` field is gone; `OperationType` (`Sub`/`Pub`) carries
the direction. Separate op types → separate ACLs. The hub-as-proxy
pattern (worker publishes, hub owns, consumers subscribe) composes on
top.
- The `channel_open` marker is the dispatch hint — orthogonal to the
ACL. The same `Pub`/`Sub` model works for JSON streams (marker absent)
and binary streams (marker present).
- The ALPN crates are call apps — the gating is a consequence, not the
definition. They inherit call's auth by construction.
- The per-connection opener ledger fixes the quota self-DoS (Gap 2).
- The extension-trait pattern (Gap E) keeps the call crate free of
channels types.
**Negative:**
- `OperationSpec` gains a field (`channel_open: Option<ChannelOpenSpec>`,
defaults `None`). Every spec-constructing site adds the field,
defaulting to `None`. This is a mechanical, additive change — the
`Option`/`None`-default keeps it non-breaking.
- The ALPN→path-segment mapping (`alk/tty` → `tty`) needs pinning.
The op name `channels/tty/sub` implies the path segment is `tty`;
non-`alk/*` ALPNs need a rule. The rule: the path segment is the
ALPN with the `alk/` prefix stripped; ALPNs without that prefix
use their full ALPN string as the path segment (rare case, two-way-
door).
- The `from_call` relay wrapper (Gap C) is a consumer concern, not in
alkcall's `from_call` core. The consumer (hub) wraps marked ops with
relay machinery after `from_call` returns. This preserves the
layering but means the hub has more to do than a plain
`register_imported_all`.
- The broker (Gap B) is out of scope. The `Pub`/`Sub` primitives are the
building blocks; the hub composes the broker on top. Designing the
broker blind would produce a half-design.
## Door type
**One-way (control-plane shape).** The per-ALPN op names
(`channels/<alpn>/sub`, `channels/<alpn>/pub`), the `channel_open`
marker on `OperationSpec`, and the removal of `channel/open` /
`direction` are one-way: once consumers register open ops and clients
call them by name, changing the shape requires a protocol migration.
The `ChannelCore` wrapper shape, the extension-trait pattern, and the
opener ledger are two-way-door implementation details within the
one-way decision.
The `channel_open` marker's wire shape (`"channel_open": true` boolean)
is one-way: `services/schema` consumers will read it, and changing the
shape would break them. The `ChannelOpenSpec` in-process struct carrying
the ALPN is a two-way-door detail (the wire shape is boolean; the
in-process struct is free to evolve).
## References
- ADR-037: channel lifecycle operations (amended by this ADR —
`channel/open` dissolves; generic ops stay; `direction` removed)
- ADR-041: per-identity channel cap (amended by this ADR — the opener
ledger; the trait shape survives)
- ADR-046: Publish operation type and `HandlerKind::Sink` (the
`Pub`/`Sub` primitives this ADR builds on; Gap A resolved)
- ADR-024: peer-graph routing model (the `AccessControl::check` path
this ADR preserves; the precedent for avoiding a parallel auth
system)
- ADR-011: dynamic resource ownership (`resource_id_path` works again
under per-ALPN ops)
- ADR-019: operation registry layering (`OperationEnv` as the
integration point; the extension-trait pattern extends it)
- ADR-044: channels sub-crate decomposition (the layering this ADR
preserves — channels types stay out of the call crate)
- ADR-042: hub relay (the relay contract unchanged in shape; the op
name changes, the marker replaces prefix-matching)
- `/workspace/@alkdev/alknet/docs/research/call-channels-unification/
findings.md` — the research that surfaced the unification and the
gaps this ADR resolves