feat: implement channels protocol + ADR-047 (openable ALPNs are operations)

ADR-047: the unifying decision that  dissolves into
per-ALPN ops (, ) with a
 marker on . Each openable ALPN registers
its own ops with their own , ,
, and the marker. The  field is replaced
by  (Sub/Pub). The generic ops (channel/close,
channel/control, channel/resources/subscribe) stay, keyed by
channel_id. Resolves Gaps A-G from the research findings (Gap B broker
named out-of-scope for alkcall; Gap C relay wrapper is consumer
concern; Gap D connection-owner allocates; Gap E extension trait;
Gap F boolean marker on wire; Gap G ACL/ownership complementary).

ADR-037 amended:  dissolves;  removed; generic
ops stay;  preview dropped from resources/subscribe.

Spec docs updated: channel-operations.md (unified model, opener ledger,
ACL flow), operation-registry.md (channel_open marker, ChannelOpenSpec),
README.md (ADR-047), open-questions.md (OQ-31..38 resolved).

Source changes:
- spec.rs: ChannelOpenSpec struct, channel_open field on OperationSpec,
  with_channel_open builder, 3 tests
- discovery.rs: spec_to_json emits channel_open boolean,
  operation_spec_schema includes channel_open, 2 tests
- from_call.rs: rebuild_spec_for parses channel_open marker,
  derive_alpn_from_op_name helper, 6 tests

Channels module (src/channels/, 10 files, ~2400 lines):
- wire.rs: 8-byte chunk header (ChunkHeader, parse/write_header,
  read_header/write_chunk/write_eof async helpers), 12 tests
- reassembly.rs: MpscRecvStream (tokio::mpsc::Receiver<Bytes> →
  AsyncRead), MpscSendStream (AsyncWrite → tokio::mpsc::Sender<Bytes>),
  REQ-CH-01 shutdown sentinel, REQ-CH-02 sender-drop EOF, 10 tests
- mux.rs: MuxHandle (clone-able, register(channel_id)),
  MuxRunner (per-channel pump tasks, exits when handles drop),
  OpenerLedger (ADR-047 §7), 4 tests
- manager.rs: ChannelManager (channel map, open_channel,
  install_channel_zero, route_payload, teardown_channel, clear_all),
  11 tests
- source.rs: ChannelBidiStreamSource (yield-once accept_bi),
  channel_source helper, 4 tests
- adapter.rs: ChannelsAdapter (ProtocolHandler for alknet/channels,
  demux loop, install_channel_zero hook), 1 test
- operations.rs: ChannelOperations (registers channel/close,
  channel/control, channel/resources/subscribe), ChannelCore
  (check_open/on_close wrappers), 4 tests
- policy.rs: ChannelLifecyclePolicy trait, NoCap, PerIdentityChannelPolicy
  (default 256, per_identity_caps override), default_policy, 8 tests
- env.rs: ChannelOperationEnv extension trait (ADR-047 §4),
  ChannelsSessionEnv impl, 2 tests
- client.rs: ChannelClient (from_connection, call_open_op,
  take_call_connection), 1 test

Verification: 432 tests pass (66 new channels + 10 marker + 356
existing), clippy clean, fmt clean, cargo doc generates.

Cargo.toml: +bytes dependency.
This commit is contained in:
2026-08-12 12:13:53 +00:00
parent ea66398c88
commit f305f8c0a5
23 changed files with 3753 additions and 204 deletions

View File

@@ -99,6 +99,7 @@ are wire-stable and unchanged — see ADR-004.
| [044](decisions/044-channels-subcrate-decomposition.md) | Channels Sub-Crate Decomposition | channels-core / channels-call (modules in alkcall) |
| [045](decisions/045-alknetclient-native-dial-seam.md) | AlknetClient Dial Seam | spawn_dispatch / from_connection take-over; dial in consumer |
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Publish Operation Type and HandlerKind::Sink | `OperationType::Pub` (client→server streaming); `SinkHandler` + `HandlerKind::Sink`; `call.published` wire event; `invoke_sink()` dispatch; `Subscription` renamed to `Sub` |
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | `channel/open` dissolves into per-ALPN ops `channels/<alpn>/sub`/`pub`; `channel_open` marker on `OperationSpec`; `ChannelCore` wrapper; extension-trait `ChannelOperationEnv`; connection-owner allocates `channel_id`; opener ledger (Gap 2 fix); ALPNs are call apps |
## Relevant Open Questions
@@ -107,7 +108,8 @@ questions affecting this crate:
- **OQ-01**: Call protocol pub/sub primitive (partially resolved) —
ADR-046 adds the `Pub` primitive (client→server streaming). The
fan-out/broker is deferred to channels.
fan-out/broker is deferred to channels (Gap B in ADR-047 is named
out-of-scope for alkcall; the hub composes the broker on top).
- **OQ-02**: Full channel-level flow-control windowing (deferred(scope))
— bounded-buffer is decided (ADR-040); full windowing blocked on a real
HOL-blocking deployment observation.

View File

@@ -1,67 +1,103 @@
---
status: draft
last_updated: 2026-07-18
last_updated: 2026-08-12
---
# channel-operations.md — Channel Lifecycle on the Call Protocol
Channel lifecycle is orchestrated by the call protocol on channel 0
(ADR-036). Four operations on channel 0's `OperationRegistry` (ADR-037)
handle open, close, control, and resource discovery. All four go through
the existing `OperationContext` / `AccessControl::check` path — no new auth
machinery, no new framing.
(ADR-036). Under the unified model (ADR-047), `channel/open` dissolves
into **per-ALPN ops** (`channels/<alpn>/sub`, `channels/<alpn>/pub`),
each with its own `access_control`, `input_schema`, `resource_id_path`,
and a `channel_open` marker. The generic ops `channel/close`,
`channel/control`, `channel/resources/subscribe` stay (keyed by
`channel_id`). All go through the existing `OperationContext` /
`AccessControl::check` path — no new auth machinery, no new framing.
## The four operations
## The open ops (per-ALPN, ADR-047)
### `channel/open` — open a data channel
Each openable ALPN registers its own ops on the call
`OperationRegistry` at assembly time, named `channels/<alpn>/sub`
and/or `channels/<alpn>/pub`:
Request (on channel 0):
| Op | `OperationType` | Initiator | Responder | Stream direction |
|----|-----------------|-----------|-----------|------------------|
| `channels/<alpn>/sub` | `Sub` | consumer (subscribes) | producer (streams) | server → client |
| `channels/<alpn>/pub` | `Pub` | producer (publishes) | consumer (receives) | client → server |
The `channels/<alpn>/...` path segment is the ALPN with the `alknet/`
prefix stripped (ADR-047 §"Negative"). An ALPN may register one or both
ops; separate op types → separate ACLs. The op spec carries the
`channel_open: Option<ChannelOpenSpec>` marker (ADR-047 §2) — the
dispatch hint that tells the channels layer "this op's stream is
binary, allocate a channel for it."
### `channels/<alpn>/sub` — subscribe to a binary stream
Request (`call.requested` on channel 0):
```json
{
"operation": "channel/open",
"input": {
"alpn": "alknet/tty",
"params": { "backend": "docker", "cmd": ["bash"], "container": "abc123" },
"direction": "initiator-to-responder"
}
"operation": "channels/tty/sub",
"input": { "backend": "docker", "cmd": ["bash"], "container": "abc123" }
}
```
The `input` is ALPN-specific params (the former `params` field, now the
op's `input_schema`). For `alknet/tty` this is the `NegotiateRequest`;
for `alknet/tunnel` this is the target resource. The channels layer does
not interpret `input`.
Response (`call.responded`):
```json
{
"output": { "channel_id": 7 }
}
```
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN the channel will carry. Responder looks this up in its `HandlerRegistry`. |
| `params` | object | ALPN-specific parameters. For `alknet/tty` this is `NegotiateRequest`. For `alknet/tunnel` this is the target resource. The channels layer does not interpret `params`. |
| `direction` | string | `initiator-to-responder` or `responder-to-initiator`. See "Direction semantics" below. |
| `channel_id` | u32 | Allocated by the connection owner (ADR-047 §5). In the `Sub` case, the responder (producer). Both sides route chunks with this ID to the new channel. |
Response:
**`channel_id` allocation (ADR-047 §5): connection-owner allocates.**
The side that holds the `ChannelManager` for that connection allocates
the `channel_id` via a monotonic `AtomicU32` (`next_id.fetch_add(1,
Relaxed)`) and returns it in the response. In the `Sub` case the
responder owns the connection and allocates; in the `Pub` case the
initiator owns and allocates. One round-trip before data flows — the
same round-trip the call protocol makes for every operation. All
current channel types (TTY, tunnel, SSH) already require a negotiation
round-trip, so the open round-trip is not additive latency.
```json
{
"output": {
"channel_id": 7
}
}
```
| field | type | meaning |
|-------|------|---------|
| `channel_id` | u32 | Server-assigned (DP-1). The responder allocates via monotonic `AtomicU32`. |
**Channel ID allocation: server-assigned (DP-1).** One round-trip before
data flows — the same round-trip the call protocol makes for every
operation. All current channel types (TTY, tunnel, SSH) already require a
negotiation round-trip, so the open round-trip is not additive latency.
**Error codes** (new `CallError.code` strings, not new framing):
**Error codes** (`CallError.code` strings):
| code | meaning | retryable |
|------|---------|-----------|
| `channel:unknown_alpn` | ALPN not in responder's `HandlerRegistry` | false |
| `channel:forbidden` | `AccessControl::check` denied the open | false |
| `channel:allocation_failed` | Handler allocate failed | true (often transient) |
| `channel:invalid_params` | `params` JSON didn't satisfy the ALPN's expectations | false |
| `channel:too_many_channels` | Per-connection channel limit hit (ADR-040) | false |
| `channel:allocation_failed` | Handler allocate failed (e.g., backend couldn't start) | true (often transient) |
| `channel:too_many_channels` | Per-connection or per-identity channel limit hit (ADR-040, ADR-041) | false |
| `channel:no_channels_session` | The op was invoked outside a channels session (no `ChannelManager`ADR-047 §2) | false |
`channel:unknown_alpn` and `channel:invalid_params` (ADR-037) are gone —
an unregistered ALPN is an ordinary `NOT_FOUND` (op not registered); a
bad `input` is ordinary schema rejection.
### `channels/<alpn>/pub` — publish a binary stream
`OperationType::Pub` (ADR-046). The initiator publishes a stream of
`call.published` events to the responder; the responder's `SinkHandler`
consumes them and returns a single `call.responded`. The
`channel_open` marker on the op spec tells the channels layer the
stream is binary — the `call.published` chunks carry binary payloads
on a data channel (not JSON on channel 0).
The hub-as-proxy pattern (ADR-042, ADR-047 §1) composes on top: a
worker publishes to the hub, the hub owns the resource, and consumers
subscribe from the hub. The broker (topic registry, `Pub``Sub`
matching) is a hub concern, not an alkcall concern (ADR-047 §1, Gap B).
## The generic ops (keyed by `channel_id`)
### `channel/close` — tear down a channel
@@ -72,21 +108,29 @@ negotiation round-trip, so the open round-trip is not additive latency.
}
```
The responder (the side that didn't send the close) drains its reassembled
stream for `channel_id`, signals EOF to the handler, and returns
`{ "closed": true }`. The `channel_id` is eligible for reuse after the drain
completes (ADR-040 — monotonic IDs with wrap-around, not a free-list).
`reason` is free-form for observability — not semantically required.
The responder (the side that didn't send the close) drains its
reassembled stream for `channel_id`, signals EOF to the handler, and
returns `{ "closed": true }`. The `channel_id` is eligible for reuse
after the drain completes (ADR-040 — monotonic IDs with wrap-around,
not a free-list). `reason` is free-form for observability — not
semantically required.
**REQ-CH-06: exit-chunk-before-close ordering.** The channel's data chunks
MUST be written and flushed before the `channel/close` operation is sent on
channel 0. The side closing must observe the data-channel pump complete
before issuing the call operation. For TTY this is the exit-chunk-is-last
invariant (ADR-055) carried forward — the exit control message rides on
TTY's `STREAM_CTRL_OUT` (stream_type 4, inside TTY's 5-byte payload
format); for tunnels it is the last data byte before close. This invariant
crosses two channels (the data channel and channel 0), so the channels
layer owns the ordering guarantee.
**Per-identity quota decrement (ADR-047 §7).** The decrement is keyed
by the **opener** (from the per-connection opener 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.
**REQ-CH-06: exit-chunk-before-close ordering.** The channel's data
chunks MUST be written and flushed before the `channel/close` operation
is sent on channel 0. The side closing must observe the data-channel
pump complete before issuing the call operation. For TTY this is the
exit-chunk-is-last invariant (ADR-055) carried forward — the exit
control message rides on TTY's `STREAM_CTRL_OUT` (stream_type 4, inside
TTY's 5-byte payload format); for tunnels it is the last data byte
before close. This invariant crosses two channels (the data channel and
channel 0), so the channels layer owns the ordering guarantee.
### `channel/control` — out-of-band control on channel 0
@@ -104,15 +148,16 @@ keepalive):
```
The channels layer routes `message` to the handler's control handle for
`channel_id`. The `message` JSON is ALPN-specific; the channels layer does
not interpret it.
`channel_id`. The `message` JSON is ALPN-specific; the channels layer
does not interpret it.
### `channel/resources/subscribe` — live resource discovery
**This is a `Subscription` operation (ADR-021), not a polled Query.** The
call protocol has `StreamingHandler` / `invoke_streaming` (implemented and
tested). The first consumer (the hub aggregating worker resources) needs
live updates when workers connect/disconnect or containers start/stop.
**This is a `Sub` operation (ADR-021), not a polled Query.** The call
protocol has `StreamingHandler` / `invoke_streaming` (implemented and
tested). The first consumer (the hub aggregating worker resources)
needs live updates when workers connect/disconnect or containers
start/stop.
```json
{
@@ -121,8 +166,8 @@ live updates when workers connect/disconnect or containers start/stop.
}
```
The responder registers a `StreamingHandler` that emits a `ResponseEnvelope`
whenever the resource set changes. Each event:
The responder registers a `StreamingHandler` that emits a
`ResponseEnvelope` whenever the resource set changes. Each event:
```json
{
@@ -130,13 +175,11 @@ whenever the resource set changes. Each event:
"resources": [
{
"alpn": "alknet/tty",
"backends": ["docker", "local"],
"access": { "required_scopes": ["tty:open"] }
"backends": ["docker", "local"]
},
{
"alpn": "alknet/tunnel",
"targets": ["container:*", "service:postgres"],
"access": { "required_scopes_any": ["tunnel:open", "admin"] }
"targets": ["container:*", "service:postgres"]
}
]
}
@@ -145,43 +188,21 @@ whenever the resource set changes. Each event:
| field | type | meaning |
|-------|------|---------|
| `alpn` | string | The ALPN this side accepts `channel/open` for. |
| `backends` / `targets` | `[string]` | ALPN-specific enumeration of what's available. The channels layer doesn't interpret these. |
| `access` | object | A preview of the `AccessControl` that `channel/open` will check. Advisory — lets the initiator fail fast. The real check happens on `channel/open`. |
| `alpn` | string | The ALPN this side accepts open ops for. |
| `backends` / `targets` | `[string]` | ALPN-specific enumeration of what's available. The channels layer doesn't interpret these; they're for the initiator to know what `input` to send. |
The stream emits an initial snapshot immediately, then subsequent events on
any change. The stream is long-lived; the subscriber cancels by dropping the
subscription (ADR-020 abort cascade applies).
The `access` preview (ADR-037) is **dropped** (ADR-047 §6) — it's on the
op spec, available via `services/schema`. Carrying a preview in a
different shape invites staleness; the spec is the authority.
The stream emits an initial snapshot immediately, then subsequent events
on any change. The stream is long-lived; the subscriber cancels by
dropping the subscription (ADR-020 abort cascade applies).
A `channel/resources` (non-subscribe, `Query`) operation is NOT provided.
The subscription's initial snapshot serves the poll use case (subscribe,
read the first event, cancel). Providing both would be redundant and would
pressure consumers toward the stale-poll path.
## Direction semantics (OQ-CH-09 — pinned)
Channel open is **bidirectional** — either side can initiate. The
`direction` field determines who is the ALPN-server (allocates the handler,
writes the negotiation response) vs the ALPN-client (writes the first
request).
| `direction` | Initiator role | Responder role | Who writes first |
|-------------|----------------|----------------|-------------------|
| `initiator-to-responder` | ALPN-client | ALPN-server | Initiator writes first (the request data); responder's handler is the server side. The common case: "open me a TTY on your docker container." |
| `responder-to-initiator` | ALPN-server | ALPN-client | Responder writes first (the negotiation response); initiator's handler is the client side. The "worker exposes, hub consumes" case: the worker initiates the open to make itself available; the hub is the client. |
**The channels layer does not enforce write order.** Write order is
ALPN-specific, determined by which side is the ALPN-server. The channels
layer routes chunks; the handlers negotiate who writes first via their
ALPN's `params` contract.
**`channel_id` allocation is always by the responder** (DP-1), regardless of
`direction`. The responder is the side that receives the `channel/open` call
operation; it allocates the ID and returns it. In the `responder-to-
initiator` case, the initiator (worker) sends the `channel/open`, so the
responder (hub) allocates the ID — even though the worker is the ALPN-server
for the channel's data. This keeps ID allocation in one place and avoids the
collision-prone client-assigned alternative.
read the first event, cancel). Providing both would be redundant and
would pressure consumers toward the stale-poll path.
## Control-message division (DP-4 — pinned)
@@ -192,50 +213,61 @@ collision-prone client-assigned alternative.
The TTY crate's exit-chunk-is-last invariant (ADR-055) is the canonical
example of data-ordered control — it rides on TTY's `STREAM_CTRL_OUT`
(stream_type 4, inside TTY's 5-byte payload format) because it must arrive
after the last data on TTY's stdout stream_type, guaranteed by TTY's
per-stream_type chunk ordering within its own 5-byte format, not by a
call-protocol round-trip. The `channel/close` operation that follows is
on channel 0 and is ordered after the data pump completes (REQ-CH-06).
(stream_type 4, inside TTY's 5-byte payload format) because it must
arrive after the last data on TTY's stdout stream_type, guaranteed by
TTY's per-stream_type chunk ordering within its own 5-byte format, not
by a call-protocol round-trip. The `channel/close` operation that
follows is on channel 0 and is ordered after the data pump completes
(REQ-CH-06).
**The control-message division is handler-internal.** Under ADR-035, the
channels layer has no `stream_type` concept — it carries the handler's
framing transparently in the payload. TTY's `STREAM_CTRL_IN` (stream_type
3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types in TTY's 5-byte
format (ADR-052, amended by Phase 7), not channels-layer concepts. The
channels layer routes by `channel_id` only; the handler owns its
sub-stream multiplexing on the `BiStream` it receives. The
"bidirectional control channel" property is a TTY-layer concern, fixed
at the TTY layer by Phase 7's split — the channels layer doesn't know
about it.
**The control-message division is handler-internal.** Under ADR-035,
the channels layer has no `stream_type` concept — it carries the
handler's framing transparently in the payload. TTY's `STREAM_CTRL_IN`
(stream_type 3) and `STREAM_CTRL_OUT` (stream_type 4) are stream_types
in TTY's 5-byte format (ADR-052, amended by Phase 7), not
channels-layer concepts. The channels layer routes by `channel_id` only;
the handler owns its sub-stream multiplexing on the `BiStream` it
receives.
## ACL flow (end-to-end)
A browser opening a TTY channel to a spoke through a hub (ADR-042):
1. Browser's channel 0 → hub's channel 0: `channel/open`
`{ alpn: "alknet/tty", params: { backend: "docker", cmd: ["bash"], container: "abc123" } }`.
The browser's identity is a bearer token (ADR-034).
2. Hub's `CallAdapter` runs `AccessControl::check` on `channel/open` with
the browser's identity. If denied → `channel:forbidden`.
3. Hub forwards to spoke via `from_call`: the hub's `forwarded_for` handler
constructs a `call.requested` with the hub as caller and the browser as
`forwarded_for` (ADR-026 §3). The spoke receives `channel/open` with
`caller = hub`, `forwarded_for = browser`.
4. Spoke's `CallAdapter` runs `AccessControl::check` with the hub as caller
(the spoke authorizes the hub — ADR-011). The spoke's ownership store
verifies the hub (or the `forwarded_for` browser, per policy) owns
`container:abc123`.
5. Spoke allocates the channel via `TtyAdapter` / `DockerTtyBackend`,
returns `channel_id`.
6. Hub opens a matching channel on the browser's side and bridges them
(byte-forward with `channel_id` rewrite — ADR-042).
1. Browser's channel 0 → hub's channel 0: `channels/tty/sub`
`{ backend: "docker", cmd: ["bash"], container: "abc123" }`.
The browser's identity is a bearer token (ADR-004).
2. Hub's `CallAdapter` runs `AccessControl::check` on
`channels/tty/sub` with the browser's identity. If denied →
`channel:forbidden`.
3. Hub forwards to spoke via `from_call`: the hub's `forwarded_for`
handler constructs a `call.requested` with the hub as caller and the
browser as `forwarded_for` (ADR-026 §3). The spoke receives
`channels/tty/sub` with `caller = hub`, `forwarded_for = browser`.
4. Spoke's `CallAdapter` runs `AccessControl::check` with the hub as
caller (the spoke authorizes the hub — ADR-011). The spoke's
ownership store verifies the hub owns `container:abc123`
(ADR-050 §4c — `forwarded_for` is metadata, not authority; the spoke
sees the hub as the owner).
5. Spoke's `ChannelCore` allocates `channel_id` via `next_id.fetch_add`
(ADR-047 §5 — connection-owner allocates; the spoke is the responder
for `Sub`, so it allocates), spawns `TtyAdapter` on the channel's
`BiStream` with the docker backend, records opener (hub) in the
ledger (ADR-047 §7), returns `{channel_id}`.
6. Hub receives the spoke's `{channel_id}`, opens a matching channel on
the browser's side (hub is the responder for the browser leg),
records the `channel_id` mapping `browser_id ↔ spoke_id`, returns
`{channel_id: browser_id}` to the consumer.
7. Hub byte-forwards between `browser_id` and `spoke_id` with 4-byte
`channel_id` rewrite (ADR-042 unchanged).
The hub ran **zero** protocol-specific auth. It ran `channel/open`'s
`AccessControl::check` (call-protocol machinery) and forwarded. The channels
layer inherited the auth model by being a call-protocol operation.
The hub ran **zero** protocol-specific auth. It ran `channels/tty/sub`'s
`AccessControl::check` (call-protocol machinery) and forwarded. The
relay contract from ADR-042 holds unchanged in shape; only the op name
changed (from generic `channel/open` to per-ALPN `channels/tty/sub`),
and the `channel_open` marker (not prefix-matching) is how the hub
recognizes and translates channel-open ops.
## Per-identity channel cap (ADR-041)
## Per-identity channel cap (ADR-041, ADR-047 §7)
A channel slot is a resource. The cap on how many channels an identity
may hold open is a quota check on that resource — parallel to
@@ -243,7 +275,7 @@ may hold open is a quota check on that resource — parallel to
primitive, different resource. The cap is a **peer concern**, not a
hub-specific concern: any accepting peer (worker or hub) enforces the
cap on its inbound channels, just as it enforces `AccessControl::check`
on `channel/open`. The cap is also **symmetric** — both sides of a
on the open op. The cap is also **symmetric** — both sides of a
channels connection enforce their cap on the other's channels.
### Why the cap is not in the channels layer
@@ -266,22 +298,23 @@ documented here; see ADR-041 for the corrected DoS-defense framing.
### The `ChannelLifecyclePolicy` trait
```rust
/// Per-identity channel lifecycle policy. Consulted by the
/// `channel/open` handler (after `AccessControl::check`, before
/// allocation) and the `channel/close` handler (after deallocation).
/// Both handlers have the identity via `OperationContext`.
/// Per-identity channel lifecycle policy. Consulted by the open-op
/// wrapper (after `AccessControl::check`, before allocation) and on
/// every teardown path (after deallocation). Both have the identity
/// via `OperationContext` (for open) or the opener ledger (for
/// teardown — ADR-047 §7).
pub trait ChannelLifecyclePolicy: Send + Sync + 'static {
/// Before channel allocation. Deny with `channel:too_many_channels`
/// (ADR-037) when the identity is over its cap. The identity is
/// the direct caller (the peer that opened this channels
/// connection); `forwarded_for` is metadata and is NOT consulted
/// (ADR-026).
/// when the identity is over its cap. The identity is the direct
/// caller (the peer that opened this channels connection);
/// `forwarded_for` is metadata and is NOT consulted (ADR-026).
fn check_open(&self, identity: &Identity) -> Result<(), ChannelError>;
/// After channel deallocation. Decrement the per-identity count.
/// Called by the `channel/close` handler after the drain completes
/// (ADR-040 §channel-id-reuse).
fn on_close(&self, identity: &Identity);
/// Called on every teardown path (close received, close sent
/// locally, handler exit, connection drop), keyed by the opener
/// from the per-connection ledger (ADR-047 §7) — not the closer.
fn on_close(&self, opener: &Identity);
}
```
@@ -317,28 +350,25 @@ channel_ops.register_on(&mut call_registry)?;
### Enforcement point: between `AccessControl::check` and allocation
The `channel/open` handler (above) gains the policy check after ACL
and before `next_id.fetch_add`:
The open-op wrapper (ADR-047 §3 — the `ChannelCore` wrapper around the
ALPN's open handler) gains the policy check after ACL and before
`next_id.fetch_add`:
1. ACL is already checked by `OperationRegistry::invoke` (the existing
`AccessControl::check` path — unchanged).
2. **NEW:** `policy.check_open(&op_ctx.identity)?` — deny with
`channel:too_many_channels` if over cap.
3. Allocate the `channel_id` via `next_id.fetch_add(1, Relaxed)`
(DP-1: server-assigned — unchanged).
4. Construct the `ChannelBidiStreamSource`, spawn the handler, record
(ADR-047 §5 — connection-owner allocates).
4. Record the opener in the per-connection ledger (ADR-047 §7).
5. Construct the `ChannelBidiStreamSource`, spawn the handler, record
the `ChannelState` (unchanged).
5. Return the `channel_id`.
6. Return the `channel_id`.
The `channel/close` handler gains the decrement after the drain
completes (the same point ADR-040 marks the `channel_id` as eligible
for reuse):
1. Drain the reassembly buffer for `channel_id` (existing — ADR-040
§channel-id-reuse).
2. **NEW:** `policy.on_close(&op_ctx.identity)` — decrement the
per-identity count.
3. Return `{ "closed": true }` (unchanged).
Every teardown path (close received, close sent locally, handler exit,
connection drop) walks the ledger and calls `policy.on_close(opener)`
per open channel, removing the ledger entry atomically with its
decrement (ADR-047 §7).
### Relay consequence: the spoke caps the hub, not the browser
@@ -354,20 +384,13 @@ channels. The hub's per-browser caps are the hub's own concern
(enforced on the browser leg by the hub's own policy), not the
spoke's.
This is correct and consistent — the spoke authorizes the hub for
container access the same way it authorizes any peer, and the hub's
browser-relay ACL is the hub's own layer. The channel cap follows the
same pattern as any other resource ACL.
**Deployment consequence:** a spoke that serves a hub relaying for
many browsers must set the hub peer's cap higher than a worker peer's
cap, or the spoke denies legitimate relayed channels when the hub's
aggregate count exceeds a worker-sized cap. This is a per-peer-role
policy, set by the spoke via `with_per_identity_caps`. The
architecture provides the mechanism; the deployment sets the numbers.
This is not a flaw — it is the same shape as any per-peer ACL (a
spoke may authorize one peer for 1000 containers and another for 10;
the channel cap is the same kind of per-peer policy).
This is not a flaw — it is the same shape as any per-peer ACL.
### Recursive channels do not bypass the cap
@@ -384,23 +407,27 @@ are an edge case for edge cases and not specced further.
The hub **translates**, not transparently forwards:
1. **Call-protocol layer (channel 0): translate.** The hub terminates
channel 0 on both legs. `channel/open` from the browser → hub's
`AccessControl::check` → hub re-issues `channel/open` on the spoke leg
with `forwarded_for` → spoke returns its `channel_id` → hub maps
browser-id ↔ spoke-id.
channel 0 on both legs. `channels/<alpn>/sub` from the browser →
hub's `AccessControl::check` → hub re-issues `channels/<alpn>/sub`
on the spoke leg with `forwarded_for` → spoke returns its
`channel_id` → hub maps browser-id ↔ spoke-id.
2. **Data-channel layer: byte-forward with `channel_id` rewrite.** The
relay reads chunks for `browser_id`, rewrites the `channel_id` field to
`spoke_id`, writes onto the spoke's channels connection — and vice versa.
The relay does not parse the payload.
relay reads chunks for `browser_id`, rewrites the `channel_id` field
to `spoke_id`, writes onto the spoke's channels connection — and
vice versa. The relay does not parse the payload.
`channel/control` operations on channel 0 carry `channel_id` in their JSON
payload; the hub's `CallAdapter` translates these too (rewrites
`channel_id` in the payload). The relay does not touch `channel/control`
it's a call operation, translated, not byte-forwarded.
`channel/control` operations on channel 0 carry `channel_id` in their
JSON payload; the hub's `CallAdapter` translates these too (rewrites
`channel_id` in the payload). The relay does not touch
`channel/control`it's a call operation, translated, not
byte-forwarded.
The hub never runs a handler for `alknet/tty`, `alknet/ssh`, or
`alknet/tunnel`. It runs `alknet/channels` (the relay) and `alknet/call`
(for its own hub-level operations + translation).
(for its own hub-level operations + translation). The `channel_open`
marker (ADR-047 §2) is how the hub recognizes a channel-open op during
`from_call` discovery (ADR-047 §1, Gap C) — the `from_call` relay
wrapper wraps marked ops with relay machinery.
## Design Decisions
@@ -408,20 +435,25 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [073](decisions/073-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The four ops; `direction` pinned; subscribe not poll |
| [072](decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = `alknet/call` |
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
| [094](decisions/094-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per `PeerId`, enforced via `ChannelLifecyclePolicy` in `channels-call`; per-connection `max_channels` reframed as a memory bound |
| [093](decisions/093-channels-pure-channel-multiplexing.md) | channels Pure Channel Multiplexing | No `stream_types` on `channel/open`; no `stream_type` on `channel/control`; handler owns sub-stream multiplexing |
| [049](decisions/049-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
| [032](decisions/032-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
| [050](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check |
| [037](decisions/037-channel-lifecycle-operations.md) | Channel Lifecycle Operations | The generic ops; `direction` pinned (amended by ADR-047 — `channel/open` dissolves; `direction` removed) |
| [047](decisions/047-openable-alpns-are-operations.md) | Openable ALPNs Are Operations | Per-ALPN open ops; `channel_open` marker; `ChannelCore` wrapper; opener ledger |
| [036](decisions/036-channel-0-pre-negotiated-call.md) | Channel 0 Pre-Negotiated | Channel 0 = alknet/call |
| [042](decisions/042-hub-relay-translate-not-forward.md) | Hub Relay | Translate channel 0, byte-forward data channels |
| [041](decisions/041-per-identity-channel-cap.md) | Per-Identity Channel Cap | 256 per PeerId, enforced via ChannelLifecyclePolicy in channels-call (amended by ADR-047 §7 — opener ledger, every teardown path) |
| [035](decisions/035-channels-pure-channel-multiplexing.md) | Pure Channel Multiplexing | No stream_types; handler owns sub-mux |
| [021](decisions/021-streaming-handler-for-subscriptions.md) | StreamingHandler | The machinery `channel/resources/subscribe` uses |
| [046](decisions/046-publish-operation-type-and-handler-kind-sink.md) | Pub Operation Type | The `Pub`/`Sub` primitives the per-ALPN open ops build on |
| [026](decisions/026-forwarded-for-identity.md) | Forwarded-For Identity | The auth chain for hub-relayed opens (and why the cap is per direct-caller, not per `forwarded_for`) |
| [011](decisions/011-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership | The parallel — a channel slot is a resource, the cap is a quota check; `resource_id_path` works again under per-ALPN ops |
## References
- ADR-037: channel lifecycle operations (the decision)
- ADR-041: per-identity channel cap (the cap, the trait, the relay
consequence)
- ADR-047: openable ALPNs are operations (the unifying ADR — per-ALPN
open ops, `channel_open` marker, `ChannelCore` wrapper, opener ledger)
- ADR-037: channel lifecycle operations (amended by ADR-047)
- ADR-041: per-identity channel cap (amended by ADR-047 §7 — opener
ledger, every teardown path)
- ADR-042: hub relay (the translate contract)
- `docs/research/alknet-channels/phase-0-findings.md` §Channel Open
Negotiation, §ACL and Security Model
- ADR-046: Pub operation type (the `Pub`/`Sub` primitives)
- `/workspace/@alkdev/alknet/docs/research/call-channels-unification/
findings.md` — the research that surfaced the unification and the gaps

View File

@@ -6,7 +6,46 @@ Accepted (amended 2026-07-18 by ADR-035 — `stream_types` field removed
from `channel/open`; `stream_type` field removed from `channel/control`;
`channel:stream_type_unavailable` error code removed; the channels layer
has no `stream_type` concept — see "Amendment (ADR-035, 2026-07-18)"
below)
below; amended 2026-08-12 by ADR-047 — `channel/open` dissolves into
per-ALPN ops `channels/<alpn>/sub` and `channels/<alpn>/pub`; the
`direction` field is removed (replaced by `OperationType`); the generic
ops `channel/close`, `channel/control`, `channel/resources/subscribe`
stay; error codes `channel:unknown_alpn` and `channel:invalid_params`
become ordinary `NOT_FOUND` / schema rejection — see "Amendment
(ADR-047, 2026-08-12)" below)
## Amendment (ADR-047, 2026-08-12)
The generic `channel/open` operation is **removed**. Each openable ALPN
registers its own ops on the call `OperationRegistry`, named
`channels/<alpn>/sub` (`OperationType::Sub` — consumer subscribes to a
binary stream) and/or `channels/<alpn>/pub` (`OperationType::Pub`
producer publishes a binary stream). The `direction` field is **removed**
`OperationType` carries the direction (building on ADR-046). The
`alpn` and `params` fields move into the op's `input_schema` (the
`alpn` is derivable from the op name; `params` is the op's input).
The generic ops `channel/close`, `channel/control`,
`channel/resources/subscribe` **stay** (keyed by `channel_id`). The
`access` preview in `channel/resources/subscribe` is **dropped** — it's
on the op spec, available via `services/schema`. The error codes
`channel:unknown_alpn` and `channel:invalid_params` become ordinary
`NOT_FOUND` (op not registered) and schema rejection (input doesn't
match `input_schema`).
`OperationSpec` gains a `channel_open: Option<ChannelOpenSpec>` marker
(ADR-047 §2) — the dispatch hint that tells the channels layer "this
op's stream is binary, allocate a channel." The op's `access_control` is
the ACL (unchanged); the marker is orthogonal. The marker is
wire-visible (`"channel_open": true` in `services/schema`).
`channel_id` allocation is amended to "the connection owner allocates"
(ADR-047 §5) — the side that holds the `ChannelManager`. In the `Sub`
case that's the responder; in the `Pub` case that's the initiator.
The body below describes the **original** (with generic `channel/open`
and `direction`) shape; the amendments above are the operative decision.
See ADR-047 for the unification rationale and the resolved gaps.
## Amendment (ADR-035, 2026-07-18)

View File

@@ -0,0 +1,373 @@
# ADR-047: Openable ALPNs Are Operations
## Status
Accepted (amends ADR-037; refines ADR-044, ADR-046)
## 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
`alknet/<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: &'static str, // e.g., b"alknet/tty" as str
}
```
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 `alknet/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`.
### 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)
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 (`alknet/tty``tty`) needs pinning.
The op name `channels/tty/sub` implies the path segment is `tty`;
non-`alknet/*` ALPNs need a rule. The rule: the path segment is the
ALPN with the `alknet/` 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

View File

@@ -41,7 +41,7 @@ status, priority, and (when resolved) a resolution citing the ADR.
| OQ | Title | Status | Priority | Resolution |
|----|-------|--------|----------|------------|
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | partially resolved | high | ADR-046 resolves the primitive: `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch path. The fan-out/broker mechanism (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. See §"Pub/Sub Gap" below. |
| OQ-22 | Call protocol pub/sub primitive — pub to go with sub | partially resolved | high | ADR-046 resolves the primitive: `OperationType::Pub` + `HandlerKind::Sink` + `call.published` wire event + `invoke_sink()` dispatch path. The fan-out/broker mechanism (one producer, N consumers, topic matching) is deferred to the channels session — the call protocol is point-to-point; the broker is a routing concern that sits above it. ADR-047 §1 names the broker (Gap B) as out-of-scope for alkcall; the hub composes it on top of the `Pub`/`Sub` primitives. See §"Pub/Sub Gap" below. |
### Pub/Sub Gap
@@ -74,6 +74,14 @@ is the load-bearing piece the broker composes on.
|----|-------|--------|----------|------------|
| OQ-23 | Full channel-level flow-control windowing | deferred(scope) | low | Bounded-buffer decided (ADR-040); full windowing blocked on HOL-blocking deployment observation |
| OQ-24 | Channels add/strip API shape | open | low | Whether the 8-byte header add/strip is built into the read/write path or a standalone utility. The contract (ADR-035) is decided; the function surface is not |
| OQ-31 | `channel/open` ACL granularity | resolved | high | ADR-047 — `channel/open` dissolves into per-ALPN ops; each op has its own `access_control` |
| OQ-32 | Quota lifecycle (opener vs closer, transport drop) | resolved | high | ADR-047 §7 — the per-connection opener ledger; decrement on every teardown path, keyed by opener |
| OQ-33 | Per-identity connection cap (endpoint layer) | deferred(scope) | low | Named as a separate layer (ADR-047 §"ALPN category reframe" references the findings); belongs at `alknet-endpoint`, not channels. Named to stop the re-tangle |
| OQ-34 | `channel_open` marker wire format | resolved | medium | ADR-047 §2 — boolean `"channel_open": true` in `services/schema`; ALPN derivable from op name |
| OQ-35 | `OperationEnv::channel_manager()` coupling | resolved | high | ADR-047 §4 — extension trait `ChannelOperationEnv` in `channels-call`; call crate stays free of channels types |
| OQ-36 | `channel_id` allocation in Pub case | resolved | medium | ADR-047 §5 — "connection owner allocates" (the side that holds the `ChannelManager`); amends "responder allocates" |
| OQ-37 | `from_call` relay wrapper for marked ops | open | medium | ADR-047 §1 names it as a consumer (hub) concern; alkcall's `from_call` reconstructs the marker (Gap F resolved) so the consumer can branch on it |
| OQ-38 | ALPN→path-segment mapping | resolved | low | ADR-047 §"Negative" — strip the `alknet/` prefix; ALPNs without that prefix use the full ALPN string (rare, two-way-door) |
## Core Types

View File

@@ -33,7 +33,7 @@ Every registered operation has a spec that declares its name, type, schemas, and
pub struct OperationSpec {
pub name: String, // e.g., "fs/readFile", "agent/chat" (no leading slash)
pub namespace: String, // e.g., "fs", "agent"
pub op_type: OperationType, // Query, Mutation, Subscription
pub op_type: OperationType, // Query, Mutation, Sub, Pub
pub visibility: Visibility, // External (wire-callable) or Internal (composition-only)
pub input_schema: Value, // JSON Schema for input
pub output_schema: Value, // JSON Schema for output
@@ -48,6 +48,18 @@ pub struct OperationSpec {
/// and passes it to `AccessControl::check`. `None` for operations
/// with no `resource_type` or with static resource sets.
pub resource_id_path: Option<String>,
/// Schema for each published chunk's `input` (Pub ops only, ADR-046).
/// `None` for Query/Mutation/Sub ops.
pub publish_schema: Option<Value>,
/// Marker telling the channels layer "this op's stream is binary,
/// allocate a data channel for it" (ADR-047 §2). `None` for ops
/// whose stream is JSON. When set, the op is a channel-open op
/// (`channels/<alpn>/sub` or `channels/<alpn>/pub`).
pub channel_open: Option<ChannelOpenSpec>,
}
pub struct ChannelOpenSpec {
pub alpn: &'static str, // e.g., "alknet/tty" — derivable from op name, carried for convenience
}
pub enum OperationType {