- CHANNELS_ALPN: b"alknet/channels" → b"alk/channels" - CallAdapter::alpn(): b"alknet/call" → b"alk/call" - derive_alpn_from_op_name: alknet/ prefix → alk/ prefix - All ALPN string literals in src/ and docs/ updated - ADR-004 amended with prefix rename rationale - AGENTS.md, README.md updated - Version bumped to 0.1.1 Review: docs/reviews/003-alpn-prefix-rename.md Verification: - cargo test: 542 passed, 0 failed - cargo clippy --all-targets -- -D warnings: clean - cargo fmt --check: clean - cargo doc --no-deps: clean
24 KiB
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)" below)
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'sOperationRegistryandOperationEnvare unchanged. Noas_any()is added toOperationEnv(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 aChannelCoreclosing 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>/subop registered only on a channels connection's overlay is not reachable on a barealk/callconnection (the overlay isn't attached there) — the dispatch path returnsNOT_FOUND, which is the correct behavior for "no channels session" (thechannel:no_channels_sessionerror code from the original §4 is no longer reached;NOT_FOUNDis 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(theregister_openable-does-not-exist andresolve_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
OperationEnvintegration-point pattern this amendment preserves by NOT addingas_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:openvstunnel:open) have no enforcement home. ADR-011'sresource_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/containerfor tty-docker,/params/targetfor tunnel). The twodirectionvalues 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 duplicatingAccessControlone layer down. - The resolution. An openable ALPN is an operation. Each openable
ALPN registers its own ops on the call
OperationRegistry, with its ownaccess_control,input_schema,resource_id_path, and achannel_openmarker that tells the channels layer "this op's stream is binary, not JSON." The call protocol's existingAccessControl::checkis the ACL — unchanged. Thedirectionfield is gone;OperationTypecarries 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 viacall.publishedevents; the handler consumes the stream and returns a singleResponseEnvelope. - Gap E (
OperationEnv::channel_manager()couples call to channels) — resolved by the extension-trait pattern: analknet-channels-call-local trait (ChannelOperationEnv: OperationEnv) adds thechannel_manager()accessor, and the open-op wrapper downcastscontext.envat 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↔Submatching by(op_name, params_hash), N-consumer fan-out) is a hub/consumer concern, not an alkcall concern. alkcall provides thePub/Subprimitives (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_callrelay wrapper) — thefrom_callforwarding 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_callconsumer code, e.g. the hub), not in alkcall'sfrom_callcore, preserving the layering. alkcall'sfrom_callreconstructs thechannel_openmarker from discovery (Gap F) so the consumer can branch on it. - Gap D (
channel_idallocation in Pub case) — the real invariant is "the side that holds theChannelManagerallocates." In theSubcase that's the responder (the side that received the open op); in thePubcase 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_openmarker wire format) — the marker is a boolean field"channel_open": trueon theservices/schemapayload. The ALPN is derivable from the op name (channels/<alpn>/sub→ ALPNalk/<alpn>); the marker is the dispatch hint, not a carrier for the ALPN string.spec_to_jsonemits it;rebuild_spec_forparses it. - Gap G (
resource_id_pathACL vs handler ownership) — complementary, not redundant. The ACL check (viaresource_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":
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::alpnisCow<'static, str>(not&'static stras originally decided). The&'static strshape forcedfrom_call's discovery path toBox::leakeach runtime-parsed ALPNStringto satisfy the'staticbound, accumulating a leak on every rediscovery. TheCow<'static, str>keeps the common case (ALPN crates register at compile time with a&'static strliteral — zero allocation) cheap while lettingfrom_callsupply an ownedStringwithout leaking. This is a two-way-door type detail (the wire format — a booleanchannel_openmarker — is unchanged).
3. ChannelCore wrapper (the open-op composition seam)
The ALPN crate provides:
- The
OperationSpec(withchannel_openmarker,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
ProtocolHandlerto spawn on the channel'sBiStream).
channels-call provides:
- The
ChannelCore(channel-id allocation,ChannelManagerintegration, per-connection opener ledger,ChannelLifecyclePolicyconsultation, 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 callOperationRegistry.
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.envto&dyn ChannelOperationEnvat invocation time) was found unworkable as written (context.envis aPeerCompositeEnv, not a single concrete type; the downcast cannot reach the manager without fragile cross-layer traversal). The operative decision is per-connection registration:register_openableis called on the connection overlay registry (Layer 2), closing over a per-connectionChannelCore; the wrapper usesChannelCore::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:
// 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-opaccess_controlandchannel_openmarker). The existing server-side ACL-filtered discovery is preserved; the spec is the authority.channel:forbiddenon 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/subscribeaggregates 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 byOperationRegistry::invokebefore the handler runs, like every other op. No new auth machinery. - ADR-011's
resource_id_pathworks for channel-open ops: the path is per-op (/params/containerfor tty-docker), not per-ALPN-branch-of-a- generic-op. The coarse ownership gate runs before the handler. - The
directionfield 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_openmarker is the dispatch hint — orthogonal to the ACL. The samePub/Submodel 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:
OperationSpecgains a field (channel_open: Option<ChannelOpenSpec>, defaultsNone). Every spec-constructing site adds the field, defaulting toNone. This is a mechanical, additive change — theOption/None-default keeps it non-breaking.- The ALPN→path-segment mapping (
alk/tty→tty) needs pinning. The op namechannels/tty/subimplies the path segment istty; non-alk/*ALPNs need a rule. The rule: the path segment is the ALPN with thealk/prefix stripped; ALPNs without that prefix use their full ALPN string as the path segment (rare case, two-way- door). - The
from_callrelay wrapper (Gap C) is a consumer concern, not in alkcall'sfrom_callcore. The consumer (hub) wraps marked ops with relay machinery afterfrom_callreturns. This preserves the layering but means the hub has more to do than a plainregister_imported_all. - The broker (Gap B) is out of scope. The
Pub/Subprimitives 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/opendissolves; generic ops stay;directionremoved) - 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(thePub/Subprimitives this ADR builds on; Gap A resolved) - ADR-024: peer-graph routing model (the
AccessControl::checkpath this ADR preserves; the precedent for avoiding a parallel auth system) - ADR-011: dynamic resource ownership (
resource_id_pathworks again under per-ALPN ops) - ADR-019: operation registry layering (
OperationEnvas 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