No panic paths in library code, no misleading public fields, honest
error envelopes, latent bugs in derive_alpn_from_op_name and the mux
pump map fixed, per-discovery Box::leak removed, demux observability
counter added, resources-snapshot unbounded loop replaced with an
O(open channels) iterator.
C-21 — write_header (wire.rs) sliced out[..CHUNK_HEADER_LEN] and
panicked on short buffers; the doc comment declared the panic. Returns
Result<_, ChunkError::HeaderTooShort> instead (the variant already
existed for the parse side). write_chunk and all callers updated (they
pass [0u8; 8] today so the Result is always Ok, but the API contract
is typed, not panic-documented). Added a short-buffer test.
C-23 — dispatch_requested's Sink and Stream error arms used
String::new() as the request id (dispatch.rs), producing envelopes
with an empty id. The request_id is now cloned before the move into
dispatch and used in the error arms, so the envelope carries the real
id (matching the Once arm, which already did this correctly).
P-13 — SinkDispatch::chunk_tx was pub (dispatch.rs), exposing the
futures::mpsc::Sender type and the 64-slot buffer size as effective
public API. Made pub(crate); handle_stream and pump_sink (the only
readers) are in the same module, and the dispatch tests are in the
same module too.
C-19 (mux pump map leak + duplicate registration) — MuxRunner::run
inserted each pump's JoinHandle into self.pumps and never removed
finished ones (they accumulated for the connection's lifetime).
Duplicate register(channel_id) silently overwrote the old handle while
the old pump kept running (two pumps, one channel id). Fixed: before
insert, check for an existing entry; if it is_finished(), reap it and
proceed (leak fix); if it is still running, reject the registration by
dropping the responder without sending (the caller's receiver.await
yields RecvError, which ChannelManager maps to ChannelExists). Added
two tests: duplicate-rejected-while-running and
reap-finished-then-reregister.
C-19 (derive_alpn_from_op_name) — from_call.rs took only the first
path segment (rest.split('/').next()), so channels/custom/proto/sub
derived alknet/custom instead of the full ALPN custom/proto. The
segment.starts_with("alknet/") branch was dead (a single segment
cannot contain /), and segment == "alknet" yielded the nonsense ALPN
"alknet". Fixed: strip the known /sub or /pub suffix from rest
instead of taking the first segment, so multi-segment ALPNs survive.
The rule (ADR-047 Negative): alknet/*-prefixed segments and
multi-segment non-alknet/* ALPNs are returned as-is; single-segment
non-alknet names get the alknet/ prefix prepended. Added tests for the
multi-segment non-alknet case, the explicit alknet/ prefix case, the
/pub suffix, and the no-suffix (non-channel-open-op) case.
C-19 (Box::leak per discovery) — from_call.rs leaked a String to
'static on every leak_alpn call (bounded per unique ALPN in theory,
but leaks on every rediscovery of every marked op). Refactored
ChannelOpenSpec::alpn from &'static str to Cow<'static, str>
(spec.rs); the common case (ALPN crates register at compile time with a
&'static str literal) pays no allocation via Into<Cow>, while from_call
supplies an owned String without leaking. Removed leak_alpn. Amended
ADR-047 §2 to record the Cow<'static, str> shape (two-way-door type
detail; the wire format — a boolean channel_open marker — is
unchanged).
C-19 (REQ-CH-04 error counter) — the demux's lenient unknown-channel
drop (manager.rs) only debug!-logged; there was no counter or stats
surface. Added an AtomicU64 dropped_unknown_chunks counter to
ChannelManager, incremented on the unknown-channel drop in
route_payload, with a dropped_unknown_chunks() accessor for
observability. Added two tests: counter increments per drop, and
known-channel routing does not increment it.
C-19 (resources-snapshot unbounded loop) — operations.rs iterated
0..u32::MAX with a per-iteration mutex lock, breaking when
resources.len() >= manager.open_count(). With sparse ids (monotonic
after churn) or a channel closing mid-iteration, this could iterate
millions of times. Replaced with an iterator over ChannelManager::
channel_ids() — a new accessor that returns a point-in-time Vec<u32>
of open channel ids — so the snapshot is O(open channels). Added
channel_ids tests and behavioral tests for the resources/subscribe
handler (open channels emit their ALPNs; no channels emit an empty
set). Also added close/control handler tests (C-25 #8 coverage the
review noted was missing): close rejects channel 0, close on unknown
channel returns NOT_FOUND, control on unknown channel returns
NOT_FOUND, control missing channel_id returns INVALID_INPUT.
Verification:
- cargo test → 465 passed, 0 failed (was 449; +16 new tests)
- cargo clippy --all-targets -- -D warnings → clean
- cargo fmt --check → clean
- cargo doc --no-deps → 0 warnings
479 lines
24 KiB
Markdown
479 lines
24 KiB
Markdown
# ADR-047: Openable ALPNs Are Operations
|
|
|
|
## Status
|
|
|
|
Accepted (amends ADR-037; refines ADR-044, ADR-046; §4 amended
|
|
2026-08-13 — open ops are registered per-connection, not resolved via
|
|
`context.env` downcast — see "Amendment (§4 per-connection
|
|
registration, 2026-08-13)" below)
|
|
|
|
## Amendment (§4 per-connection registration, 2026-08-13)
|
|
|
|
ADR-047 §4 specified that the open-op wrapper resolves the
|
|
per-connection `ChannelManager` by downcasting `context.env` to
|
|
`&dyn ChannelOperationEnv` at invocation time — "static registration,
|
|
dynamic resolution." Implementation (review 001, C-03) found this
|
|
shape is not workable as written: `context.env` is a `PeerCompositeEnv`
|
|
(a composite of base + session + per-connection overlays), not a single
|
|
concrete type, so a direct `as_any()` downcast of `context.env` to
|
|
`ChannelsSessionEnv` cannot reach the `ChannelManager`. Traversing the
|
|
composite's layers to find the channels-backed overlay would hardcode
|
|
`PeerCompositeEnv`'s internal structure into the channels module —
|
|
fragile, leaky, and a layering violation (the call crate's composite-env
|
|
shape is not part of the channels crate's contract).
|
|
|
|
**The amendment: open ops are registered per-connection.** The
|
|
`ChannelCore` is constructed per channels connection (in the
|
|
`install_channel_zero` hook, which already runs per-connection and
|
|
already receives the `ChannelManager`). `ChannelCore::register_openable`
|
|
is called on that connection's `OperationRegistry` (the connection
|
|
overlay registry, Layer 2 per ADR-019), closing over the per-connection
|
|
`ChannelCore`. The wrapper uses `ChannelCore::manager()` directly — no
|
|
`context.env` downcast, no dynamic resolution. The open op lives on the
|
|
connection overlay, which is where per-connection state naturally
|
|
belongs (ADR-019, ADR-024).
|
|
|
|
This preserves every invariant ADR-047 §4 was written to protect:
|
|
|
|
- **Layering (ADR-044):** the call crate stays free of channels types.
|
|
The open-op wrapper is in `channels-call` (`src/channels/operations.rs`);
|
|
the call crate's `OperationRegistry` and `OperationEnv` are unchanged.
|
|
No `as_any()` is added to `OperationEnv` (the trait stays concrete-rpc-
|
|
shaped, preserving the session/connection overlay patterns from
|
|
ADR-024 — AGENTS.md §6).
|
|
- **Per-connection resolution:** the open op gets the *right*
|
|
`ChannelManager` (the one for the connection it was invoked on) because
|
|
the op is registered on that connection's overlay registry, with a
|
|
`ChannelCore` closing over that connection's manager. A globally-
|
|
registered handler (Layer 0) is not on this path.
|
|
- **"Marked ops invoked outside a channels session" (ADR-047 §2):**
|
|
unchanged. A `channels/<alpn>/sub` op registered only on a channels
|
|
connection's overlay is not reachable on a bare `alknet/call`
|
|
connection (the overlay isn't attached there) — the dispatch path
|
|
returns `NOT_FOUND`, which is the correct behavior for "no channels
|
|
session" (the `channel:no_channels_session` error code from the
|
|
original §4 is no longer reached; `NOT_FOUND` is the natural
|
|
reachable-but-not-here result).
|
|
|
|
The `ChannelOperationEnv` extension trait and `ChannelsSessionEnv` impl
|
|
(`src/channels/env.rs`) are retained as a two-way-door implementation
|
|
detail — they are not on the open-op path, but remain available for
|
|
future per-connection routing (e.g., nested channels where each
|
|
connection's overlay carries its own manager reference for
|
|
non-open-op queries). The `resolve_channel_manager` stub is removed
|
|
(it described the rejected dynamic-resolution shape).
|
|
|
|
### Door type
|
|
|
|
**Two-way (implementation detail).** The ADR's door-type section
|
|
already marks "The `ChannelCore` wrapper shape, the extension-trait
|
|
pattern, and the opener ledger are two-way-door implementation details
|
|
within the one-way decision." Per-connection registration vs. dynamic
|
|
resolution is a choice within the wrapper-shape detail — the one-way
|
|
decisions (per-ALPN op names, the `channel_open` marker, removal of
|
|
`channel/open`/`direction`) are unchanged. A future revision could move
|
|
open ops back to Layer 0 with real `as_any()` downcast machinery if the
|
|
composite-env structure stabilizes enough to make the traversal
|
|
non-fragile; the per-connection shape is the simpler choice today.
|
|
|
|
### References
|
|
|
|
- C-02, C-03 in `docs/reviews/001-pub-and-channels-integration-review.md`
|
|
(the `register_openable`-does-not-exist and
|
|
`resolve_channel_manager`-is-a-stub findings this amendment resolves)
|
|
- ADR-019: operation registry layering (the connection overlay the open
|
|
op is registered on)
|
|
- ADR-024: peer-graph routing model (the `OperationEnv` integration-point
|
|
pattern this amendment preserves by NOT adding `as_any()`)
|
|
|
|
## Context
|
|
|
|
The channels spec (ADR-037) framed channel lifecycle as **one generic
|
|
`channel/open` operation** whose input carries the authorization-relevant
|
|
facts (`alpn`, `params`, `direction`) inside its JSON body. The call
|
|
protocol's ACL machinery (`AccessControl::check` on `OperationSpec`) runs
|
|
*before* the handler — but the generic op hides everything the ACL would
|
|
need to see inside one op's input, where the call ACL machinery can't
|
|
reach it.
|
|
|
|
The research findings
|
|
(`/workspace/@alkdev/alknet/docs/research/call-channels-unification/
|
|
findings.md`) surfaced the structural miss and the resolution together:
|
|
|
|
- **The miss.** Per-ALPN scopes (`tty:open` vs `tunnel:open`) have no
|
|
enforcement home. ADR-011's `resource_id_path` (JSON pointer into input
|
|
for the resource ID, checked by the ACL before the handler runs) can't
|
|
work on a single generic op — the pointer differs per ALPN
|
|
(`/params/container` for tty-docker, `/params/target` for tunnel). The
|
|
two `direction` values are wildly different grants ("may you consume my
|
|
TTY" vs "may you register a service I will consume as a client")
|
|
squeezed under one ACL. The shape re-commits the structural miss
|
|
ADR-024 (superseding ADR-023) was written to avoid: a parallel
|
|
authorization system duplicating `AccessControl` one layer down.
|
|
- **The resolution.** An openable ALPN is an operation. Each openable
|
|
ALPN registers its own ops on the call `OperationRegistry`, with its
|
|
own `access_control`, `input_schema`, `resource_id_path`, and a
|
|
`channel_open` marker that tells the channels layer "this op's stream
|
|
is binary, not JSON." The call protocol's existing
|
|
`AccessControl::check` is the ACL — unchanged. The `direction` field is
|
|
gone; `OperationType` carries the direction (`Sub` = consumer
|
|
subscribes, `Pub` = producer publishes), building on ADR-046.
|
|
|
|
This is the cheapest moment to amend: no channels code exists, no
|
|
deployments exist, the develop branch is pre-alpha. ADR-037's four op
|
|
names were declared one-way doors, but ADR-035/046 just demonstrated that
|
|
amendment is the normal mode here.
|
|
|
|
### What the research findings resolved (Gap A, E)
|
|
|
|
- **Gap A** (Pub handler shape) — resolved by ADR-046:
|
|
`HandlerKind::Sink` / `SinkHandler` / `invoke_sink()`. The initiator
|
|
streams *to* the responder via `call.published` events; the handler
|
|
consumes the stream and returns a single `ResponseEnvelope`.
|
|
- **Gap E** (`OperationEnv::channel_manager()` couples call to channels)
|
|
— resolved by the extension-trait pattern: an
|
|
`alknet-channels-call`-local trait (`ChannelOperationEnv: OperationEnv`)
|
|
adds the `channel_manager()` accessor, and the open-op wrapper
|
|
downcasts `context.env` at invocation time. `alkcall` (the call crate)
|
|
stays free of any channels types; the layering is preserved.
|
|
|
|
### What this ADR decides (Gap B, C, D, F, G)
|
|
|
|
- **Gap B** (hub broker) — **out of scope for alkcall.** The broker
|
|
(topic registry, `Pub`↔`Sub` matching by `(op_name, params_hash)`,
|
|
N-consumer fan-out) is a hub/consumer concern, not an alkcall concern.
|
|
alkcall provides the `Pub`/`Sub` primitives (ADR-046) and the channel
|
|
machinery (this ADR); the hub composes the broker on top, the same way
|
|
the hub relay composes on top of call ops. This ADR names it as
|
|
out-of-scope so it stops re-tangling every channels-control-plane
|
|
conversation.
|
|
- **Gap C** (`from_call` relay wrapper) — the `from_call` forwarding
|
|
handler for a marked op wraps with relay machinery (forward + allocate
|
|
local-leg channel + record id mapping + byte-forward pumps) instead of
|
|
the plain forwarding stub. The wrapping lives in a separate layer
|
|
(`from_call` consumer code, e.g. the hub), not in alkcall's `from_call`
|
|
core, preserving the layering. alkcall's `from_call` reconstructs the
|
|
`channel_open` marker from discovery (Gap F) so the consumer can branch
|
|
on it.
|
|
- **Gap D** (`channel_id` allocation in Pub case) — the real invariant is
|
|
"the side that holds the `ChannelManager` allocates." In the `Sub` case
|
|
that's the responder (the side that received the open op); in the `Pub`
|
|
case that's the initiator (the side that sent the open op, which owns
|
|
its own channels connection). ADR-037's "responder allocates" is
|
|
amended to "connection-owner allocates."
|
|
- **Gap F** (`channel_open` marker wire format) — the marker is a boolean
|
|
field `"channel_open": true` on the `services/schema` payload. The ALPN
|
|
is derivable from the op name (`channels/<alpn>/sub` → ALPN
|
|
`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: Cow<'static, str>, // e.g., "alknet/tty"; Cow so from_call can supply an owned String without Box::leak
|
|
}
|
|
```
|
|
|
|
The marker is **registry metadata, not auth machinery** — parallel to
|
|
how `resource_id_path` tells ADR-011 where to find the resource ID. The
|
|
op's `access_control` is the ACL (unchanged); the marker is the dispatch
|
|
hint. The `OperationRegistry` is opaque to the marker; it's
|
|
`channels-call` that reads it.
|
|
|
|
**Wire-visible.** The marker survives discovery serialization — it is
|
|
part of the `services/schema` payload, not just the in-process struct.
|
|
`spec_to_json` emits `"channel_open": true` when set (boolean — the ALPN
|
|
is derivable from the op name). `rebuild_spec_for` parses it back. A
|
|
`from_call` importer can branch on the marker to wrap with relay
|
|
machinery (Gap C).
|
|
|
|
**Marked ops invoked outside a channels session.** `channels/tty/sub` is
|
|
registered on the call registry — which means it's also visible/invocable
|
|
on a bare top-level `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`.
|
|
|
|
> **Amended 2026-08-13** — `ChannelOpenSpec::alpn` is
|
|
> `Cow<'static, str>` (not `&'static str` as originally decided). The
|
|
> `&'static str` shape forced `from_call`'s discovery path to
|
|
> `Box::leak` each runtime-parsed ALPN `String` to satisfy the
|
|
> `'static` bound, accumulating a leak on every rediscovery. The
|
|
> `Cow<'static, str>` keeps the common case (ALPN crates register at
|
|
> compile time with a `&'static str` literal — zero allocation) cheap
|
|
> while letting `from_call` supply an owned `String` without leaking.
|
|
> This is a two-way-door type detail (the wire format — a boolean
|
|
> `channel_open` marker — is unchanged).
|
|
|
|
### 3. `ChannelCore` wrapper (the open-op composition seam)
|
|
|
|
The ALPN crate provides:
|
|
- The `OperationSpec` (with `channel_open` marker, `access_control`,
|
|
`input_schema`, `resource_id_path`).
|
|
- The open handler (ALPN-specific work — validate params, consult
|
|
ownership, prepare the backend, return a "channel plan" — a
|
|
`ProtocolHandler` to spawn on the channel's `BiStream`).
|
|
|
|
`channels-call` provides:
|
|
- The `ChannelCore` (channel-id allocation, `ChannelManager` integration,
|
|
per-connection opener ledger, `ChannelLifecyclePolicy` consultation,
|
|
teardown hooks).
|
|
- A `register_openable(spec, open_handler, channel_core)` helper that
|
|
wraps the ALPN's open handler with the channel machinery and registers
|
|
the op on the call `OperationRegistry`.
|
|
|
|
The wrapper shape (not the invoke shape) is preferred: the ALPN's open
|
|
handler returns a channel plan; channels-call's wrapper does the
|
|
allocation/ledger/policy/spawn. The alternative (the handler calls
|
|
`context.channel_core.open(...)` itself) would require either
|
|
`OperationContext` to carry a `channel_core` reference (inverting the
|
|
call → channels-call dependency) or a generic extension mechanism on
|
|
`OperationContext` (more complexity than the wrapper). The exact API
|
|
shape of the channel plan is a two-way-door implementation detail; the
|
|
architectural point is the wrapper.
|
|
|
|
### 4. Per-connection `ChannelManager` resolution (Gap E)
|
|
|
|
> **Amended 2026-08-13** — see "Amendment (§4 per-connection
|
|
> registration, 2026-08-13)" at the top of this file. The
|
|
> dynamic-resolution shape described below (downcast `context.env` to
|
|
> `&dyn ChannelOperationEnv` at invocation time) was found
|
|
> unworkable as written (`context.env` is a `PeerCompositeEnv`, not a
|
|
> single concrete type; the downcast cannot reach the manager without
|
|
> fragile cross-layer traversal). The operative decision is
|
|
> **per-connection registration**: `register_openable` is called on the
|
|
> connection overlay registry (Layer 2), closing over a per-connection
|
|
> `ChannelCore`; the wrapper uses `ChannelCore::manager()` directly.
|
|
> The body below is the **original** (rejected) shape, retained for
|
|
> rationale continuity.
|
|
|
|
The `register_openable` helper registers ops at assembly time (Layer 0,
|
|
curated, static per ADR-019). But the wrapper needs the
|
|
**per-connection** `ChannelManager` — the op arrives on channel 0 of one
|
|
specific channels connection, and the channel must be allocated on
|
|
*that* connection's manager. A globally-registered handler closing over
|
|
a static `ChannelCore` has no way to know which channels connection
|
|
invoked it.
|
|
|
|
The resolution: an extension trait in `channels-call` (not in the call
|
|
crate's core `OperationEnv` trait) adds the `channel_manager()` accessor:
|
|
|
|
```rust
|
|
// in channels-call
|
|
pub trait ChannelOperationEnv: OperationEnv {
|
|
fn channel_manager(&self) -> Option<&ChannelManager>;
|
|
}
|
|
```
|
|
|
|
The wrapper handler downcasts `context.env` to
|
|
`&dyn ChannelOperationEnv` at invocation time — static registration,
|
|
dynamic resolution. If the downcast fails (no channels session), the
|
|
wrapper returns `channel:no_channels_session`. This keeps `alkcall`'s
|
|
call crate free of any channels types and preserves the layering
|
|
(ADR-044). The `OperationEnv` is already the integration point for
|
|
per-connection state (ADR-019); adding a `ChannelManager` accessor via
|
|
an extension trait is the natural extension.
|
|
|
|
Each channels connection's `OperationEnv` overlay carries its own
|
|
`ChannelManager` reference, so nested connections resolve correctly.
|
|
|
|
### 5. `channel_id` allocation: connection-owner allocates (Gap D)
|
|
|
|
ADR-037's "responder allocates" invariant is amended: **the side that
|
|
holds the `ChannelManager` for that connection allocates the
|
|
`channel_id`.** In the `Sub` case that's the responder (the side that
|
|
received the open op — it owns its channels connection). In the `Pub`
|
|
case that's the initiator (the side that sent the open op — it owns its
|
|
own channels connection).
|
|
|
|
This is the same invariant stated correctly: the connection owner
|
|
allocates. The "responder allocates" framing was a special case that
|
|
happened to hold for `Sub` (the common case) but broke for `Pub` (the
|
|
initiator owns the connection it's publishing from).
|
|
|
|
### 6. The discovery split
|
|
|
|
- **"What may I open"** (static, per-op): `services/list` (visibility-
|
|
filtered + `AccessControl::check(calling_peer_identity)` server-side,
|
|
per ADR-024 §6) + `services/schema` (per-op `access_control` and
|
|
`channel_open` marker). The existing server-side ACL-filtered
|
|
discovery is preserved; the spec is the authority. `channel:forbidden`
|
|
on the open op is the real check; the preview is "here's what the spec
|
|
says, you can fail fast."
|
|
- **"What is currently there"** (dynamic, ALPN-level):
|
|
`channel/resources/subscribe`. Each ALPN crate that registers open ops
|
|
also provides a resource enumerator (which containers are running,
|
|
which TTY sessions are active). `channel/resources/subscribe`
|
|
aggregates across all registered openable ALPNs. The data source lives
|
|
in the ALPN crate, not in channels-call.
|
|
|
|
The `access` preview in `resources/subscribe` (ADR-037) becomes
|
|
redundant — it's on the op spec, available via `services/schema`. It is
|
|
dropped from the `resources/subscribe` payload; the spec is the
|
|
authority, and carrying a preview in a different shape invites
|
|
staleness.
|
|
|
|
### 7. Quota lifecycle: the opener ledger (Gap 2 from findings)
|
|
|
|
ADR-041's `ChannelLifecyclePolicy::on_close(&op_ctx.identity)` is called
|
|
from the `channel/close` handler with the **closer's** identity, not the
|
|
opener's — and is not called at all on transport drop. A peer whose
|
|
connection dies at cap is permanently at cap (a self-DoS).
|
|
|
|
The fix: `channels-call` keeps a per-connection opener ledger
|
|
(`channel_id → opener PeerId`). The decrement is keyed by the opener
|
|
(from the ledger), not the closer. The decrement is called from **every
|
|
teardown path** — close received, close sent locally, handler exit,
|
|
connection drop — not just `channel/close`. The ledger entry is removed
|
|
atomically with its decrement (teardown paths can race; a
|
|
double-decrement under-counts and weakens the cap).
|
|
|
|
The `ChannelLifecyclePolicy` trait shape (`check_open`, `on_close`)
|
|
survives; the change is where `on_close` is called from and where the
|
|
opener identity comes from. `channels-core` stays auth-blind (the
|
|
ledger lives in `channels-call`, not `channels-core`).
|
|
|
|
### 8. ALPN category reframe
|
|
|
|
ADR-086 §4 (alknet source) split the foundational handlers into
|
|
"channels data-channel ALPNs" and "SSH (endpoint ALPN wrapping
|
|
channels)." Under the unified model, the first category dissolves —
|
|
they're **call apps** (the same shape as docker). They register ops on
|
|
the call `OperationRegistry`. Some ops return JSON; some ops carry the
|
|
`channel_open` marker and produce a binary stream. The endpoint
|
|
(channels or bare call) determines the framing, not the app.
|
|
|
|
The ALPN crates served under channels (tty, tunnel, socks5, fs, sftp)
|
|
stop being "just ALPNs" and become call apps. They inherit call's
|
|
auth/composition/identity by construction (they *are* call apps). The
|
|
binary-stream part is the `channel_open` marker on the op spec. SSH
|
|
stays distinct (endpoint ALPN wrapping channels).
|
|
|
|
## Consequences
|
|
|
|
**Positive:**
|
|
|
|
- Per-ALPN ACLs have an enforcement home: each openable ALPN's op has
|
|
its own `access_control`, checked by `OperationRegistry::invoke`
|
|
before the handler runs, like every other op. No new auth machinery.
|
|
- ADR-011's `resource_id_path` works for channel-open ops: the path is
|
|
per-op (`/params/container` for tty-docker), not per-ALPN-branch-of-a-
|
|
generic-op. The coarse ownership gate runs before the handler.
|
|
- The `direction` field is gone; `OperationType` (`Sub`/`Pub`) carries
|
|
the direction. Separate op types → separate ACLs. The hub-as-proxy
|
|
pattern (worker publishes, hub owns, consumers subscribe) composes on
|
|
top.
|
|
- The `channel_open` marker is the dispatch hint — orthogonal to the
|
|
ACL. The same `Pub`/`Sub` model works for JSON streams (marker absent)
|
|
and binary streams (marker present).
|
|
- The ALPN crates are call apps — the gating is a consequence, not the
|
|
definition. They inherit call's auth by construction.
|
|
- The per-connection opener ledger fixes the quota self-DoS (Gap 2).
|
|
- The extension-trait pattern (Gap E) keeps the call crate free of
|
|
channels types.
|
|
|
|
**Negative:**
|
|
|
|
- `OperationSpec` gains a field (`channel_open: Option<ChannelOpenSpec>`,
|
|
defaults `None`). Every spec-constructing site adds the field,
|
|
defaulting to `None`. This is a mechanical, additive change — the
|
|
`Option`/`None`-default keeps it non-breaking.
|
|
- The ALPN→path-segment mapping (`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 |