Files
alkcall/docs/architecture/decisions/047-openable-alpns-are-operations.md
T
glm-5.3-flash 50182d7298 feat(review 008 Unit 3c): the gate-2 e2e harness; 0.8.0 release bookkeeping
- src/channels/gate2_tests.rs (ADR-051 gates 6/7, review 008 gate 2):
  the full consumer -> hub (HubLegTemplate) -> spoke relay e2e —
  the open resolves with the hub-allocated channel_id, `bound`
  survives the relay, data flows both directions with a fake 8-byte
  chunk header riding verbatim (the hub never parses the data plane,
  ADR-034/035), spoke-side close cascades to clean reclaim on both
  legs; hub-side disconnect (the consumer's duplex end dropped via a
  killable transport proxy) tears down both legs with the ledger
  decremented; the mid-establishment window (ADR-051 §6) pinned with
  its two reclaim signals — the consumer leg reclaims at its own
  transport EOF, the spoke channel (allocated before the establisher
  replied) is the honest residual, reclaimed when the spoke-leg
  transport ends; the channels/tty/sub standard-shape companion pins
  no derivation regression.
- src/channels/relay.rs: the relay's adopted producer-leg channel
  entry now reclaims when the relayed pump completes (teardown after
  pump_bidi) — the consumer-leg wrapper's teardown cannot see the
  producer leg's manager; the RelayPlan carries the spoke id for the
  reclaim.
- Release bookkeeping: 0.7.1 -> 0.8.0, the CHANGELOG entry covering
  Units 1-3 (Establishment reply projection, open_channel_with_reply,
  flavor-form discovery derivation, ChannelRelay, HubLegTemplate,
  gate-2 harness); review 008 Status -> Resolved with the U-1/U-2
  commit refs and the 955->945 errata note; ADR-051 Status ->
  all units landed + the §6 mid-establishment residual expanded to
  the two-reclaim-signal shape the gate pins; the stale "0.7.2"
  version mentions in ADR-047/049 corrected to 0.8.0 (the units land
  unreleased).

Verification: cargo test 669 passed / 0 failed; clippy --all-targets
-- -D warnings clean; fmt --check clean; cargo doc --no-deps clean;
cargo check + clippy on wasm32-unknown-unknown clean;
cargo publish --dry-run --allow-dirty passed.
2026-09-18 05:15:29 +00:00

35 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)"; 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 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):

  • 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.requesteditself. An open op registered on the overlay resolvesNOT_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":

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:

// 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