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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
373
docs/architecture/decisions/047-openable-alpns-are-operations.md
Normal file
373
docs/architecture/decisions/047-openable-alpns-are-operations.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user