- OperationSpec gains description: Option<String> (builder with_description, defaults None; no struct-literal construction sites exist, so additive by construction) - spec_to_json_pub emits description when set; rebuild_spec_for parses it back — the field survives from_call discovery and op/register announcement (same round-trip pattern as resource_id_path / publish_schema) - services/list and the local-ops half of services/list-peers emit description when set; output-schema docs on both listing specs and operation_spec_schema advertise the field - Tests: builder/default, emit/omit, listing emission, schema disclosure, schema-doc presence, round-trip + absent-stays-absent (8 new; 616 total) - Docs: review 006 Unit 2 marked IMPLEMENTED; ADR-047 §6 amendment records the E-02 discovery decision (listing enrichment lands, the channel/resources/subscribe half stays deferred); OQ-40 gains the load-bearing note; operation-registry.md struct + listing docs; CHANGELOG Verification: cargo test (616 pass), clippy -D warnings (host + wasm32), fmt --check, cargo doc --no-deps, wasm32 check — all clean
604 lines
31 KiB
Markdown
604 lines
31 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)"; amendment #2 (2026-09-03) — the
|
|
per-connection registration mechanism is the **per-session fork of the
|
|
base registry installed as the session's dispatch registry**, not the
|
|
connection overlay — see "Amendment (§4 mechanism, 2026-09-03)" below;
|
|
§6 amended 2026-09-06 — the static half of discovery gains an additive
|
|
per-op `description` on the listing, the dynamic half stays deferred
|
|
— see "Amendment (§6 listing enrichment, 2026-09-06)" below)
|
|
|
|
## Amendment (§6 listing enrichment, 2026-09-06)
|
|
|
|
Review 006 E-02 (from the alktunnels Phase 0 sweep) made §6's dynamic
|
|
half load-bearing for the first time and asked for a decision on the
|
|
static half. Decision: **both halves of the "static per-op" split gain
|
|
what is cheap today; the dynamic half stays deferred** (OQ-40, now
|
|
with a "load-bearing for alktunnels discovery UI" note in
|
|
`open-questions.md`; alktunnels v1 uses config-known op names):
|
|
|
|
- `OperationSpec` gains `description: Option<String>` — a human-
|
|
readable op description, set via `with_description` at registration.
|
|
Additive (defaults `None`; no struct-literal construction sites
|
|
exist — all sites use `OperationSpec::new`).
|
|
- `services/list` (and the local-ops half of `services/list-peers`)
|
|
emit `description` when set — one round-trip answers "which ops
|
|
exist and what are they for" without the N+1 `services/schema`
|
|
sweep. The output-schema docs on both listing specs and on
|
|
`services/schema`'s `operation_spec_schema` advertise the field.
|
|
- `spec_to_json_pub` emits it when set; `rebuild_spec_for` parses it
|
|
back — the description survives discovery and peer announcement
|
|
(`from_call`, `op/register`) like every other additive spec field
|
|
(`resource_id_path`, `publish_schema` round-trip the same way).
|
|
|
|
Scope note from E-02 stands: the listing field describes **the op**,
|
|
not **the produced resource set** (a tunnel producer registers one op
|
|
and N resources). "Which tunnel resources may I open, live" remains
|
|
`channel/resources/subscribe`'s job (§6's dynamic half, ADR-037 §
|
|
`channel/resources/subscribe`) — deferred until a consumer needs live
|
|
resource discovery.
|
|
|
|
## Amendment (§4 mechanism, 2026-09-03)
|
|
|
|
The 2026-08-13 amendment named the registration target as "the
|
|
connection overlay registry (Layer 2 per ADR-019)". Review 004 (F-01)
|
|
verified that this shape cannot dispatch: the top-level dispatch path
|
|
(`Dispatcher::dispatch` / `run_loop_single_stream`) resolves and
|
|
invokes against the dispatcher's **base registry only**; the
|
|
connection overlay is reachable solely as a layer of `context.env`
|
|
(for nested invocations — a handler calling `env.invoke(...)), never
|
|
for resolving the incoming `call.requested` itself. An open op
|
|
registered on the overlay resolves `NOT_FOUND` on the wire.
|
|
|
|
The one shape proven end-to-end (alkcall's own e2e gate) is different:
|
|
the `install_channel_zero` hook builds a **fresh per-connection
|
|
registry containing the open op and passes it as the dispatcher's base
|
|
registry**. This amendment makes that the operative mechanism.
|
|
|
|
**The decision: per-connection registration happens on a fork of the
|
|
deployment's base registry, installed as the session's dispatch
|
|
registry.** The `install_channel_zero` hook (and any future
|
|
session-establishment seam):
|
|
|
|
1. **Forks** the deployment's base registry
|
|
(`OperationRegistry::fork` — a deep copy carrying handlers,
|
|
provenance, composition authority, capabilities, and the cached
|
|
publish-schema validators; review 004 F-02/F-03).
|
|
2. **Registers the per-session ops on the fork** — the generic channel
|
|
ops (`ChannelOperations::register_on`), the openables
|
|
(`ChannelCore::register_openable`), and the bootstrap discovery ops
|
|
(`install_bootstrap_discovery`, closed over the fork itself so
|
|
`services/list` sees the fork's per-session ops — review 004 F-06).
|
|
3. **Dispatches channel 0 over the fork** (`Dispatcher::new(fork,
|
|
...)`).
|
|
|
|
The fork is possible because `OperationRegistry` is internally
|
|
mutable (`parking_lot::RwLock` around both maps) — a fork shared as an
|
|
`Arc<OperationRegistry>` can receive bootstrap ops after the
|
|
dispatcher was built, and the self-referential discovery closure sees
|
|
every post-install registration.
|
|
|
|
The **connection overlay (Layer 2) remains what ADR-019/ADR-024
|
|
describe**: the landing zone for peer-announced ops (`op/register`,
|
|
review 004 F-05 — ADR-022 amendment) and the nested-invocation target
|
|
for imported ops. It is not the dispatch-resolution path for the
|
|
session's own ops.
|
|
|
|
Rationale for the fork shape over an overlay-aware dispatch fallback
|
|
(F-02 option (b)): the fork is the only shape with an end-to-end
|
|
proof, it needs no change to the shared dispatch loop, and it keeps
|
|
the overlay's `invoke_with_policy` shape (namespace-scoped,
|
|
parent-context-driven — built for nested composition) out of the
|
|
top-level call path, where it does not match the frame-handling
|
|
contract.
|
|
|
|
This preserves every invariant the 2026-08-13 amendment protected:
|
|
|
|
- **Layering (ADR-044):** unchanged — the open-op wrapper is in
|
|
`channels-call`; the call crate's `OperationRegistry` gains only
|
|
`fork` (and interior mutability), no channels types.
|
|
- **Per-connection resolution:** the open op gets the *right*
|
|
`ChannelManager` because the fork is built per-connection and its
|
|
openable closes over that connection's `ChannelCore`.
|
|
- **"Marked ops invoked outside a channels session" (ADR-047 §2):**
|
|
unchanged in effect — a `channels/<alpn>/sub` op registered only on
|
|
a session fork is not reachable on a bare `alk/call` connection (the
|
|
fork isn't that session's dispatch registry) — the dispatch path
|
|
returns `NOT_FOUND`.
|
|
|
|
### Door type
|
|
|
|
**Two-way (implementation detail), as before.** The registration
|
|
*target* mechanism (fork as base registry) sits within the same
|
|
wrapper-shape detail the 2026-08-13 amendment already marked two-way.
|
|
The one-way decisions (per-ALPN op names, the `channel_open` marker,
|
|
removal of `channel/open`/`direction`) are unchanged.
|
|
|
|
### References
|
|
|
|
- Review 004 F-01/F-02/F-03/F-06
|
|
(`docs/reviews/004-per-connection-dispatch-and-client-serving-review.md`)
|
|
— the verification and the mechanism decision
|
|
- ADR-022 amendment (2026-09-03) — the bootstrap-op set (`services/list`,
|
|
`services/schema`, `op/register`) and the connect-side serving loop
|
|
- ADR-019: operation registry layering (the overlay stays the nested
|
|
invocation / peer-announced-ops landing zone)
|
|
- The e2e gate: `fork_registry_open_op_resolves_and_is_discoverable`
|
|
(`src/channels/client.rs`) — open op resolves through the fork,
|
|
per-session openable in `services/list`, `services/schema` validates
|
|
|
|
## Amendment (§4 per-connection registration, 2026-08-13)
|
|
|
|
ADR-047 §4 specified that the open-op wrapper resolves the
|
|
per-connection `ChannelManager` by downcasting `context.env` to
|
|
`&dyn ChannelOperationEnv` at invocation time — "static registration,
|
|
dynamic resolution." Implementation (review 001, C-03) found this
|
|
shape is not workable as written: `context.env` is a `PeerCompositeEnv`
|
|
(a composite of base + session + per-connection overlays), not a single
|
|
concrete type, so a direct `as_any()` downcast of `context.env` to
|
|
`ChannelsSessionEnv` cannot reach the `ChannelManager`. Traversing the
|
|
composite's layers to find the channels-backed overlay would hardcode
|
|
`PeerCompositeEnv`'s internal structure into the channels module —
|
|
fragile, leaky, and a layering violation (the call crate's composite-env
|
|
shape is not part of the channels crate's contract).
|
|
|
|
**The amendment: open ops are registered per-connection.** The
|
|
`ChannelCore` is constructed per channels connection (in the
|
|
`install_channel_zero` hook, which already runs per-connection and
|
|
already receives the `ChannelManager`). `ChannelCore::register_openable`
|
|
is called on that connection's `OperationRegistry` (the connection
|
|
overlay registry, Layer 2 per ADR-019), closing over the per-connection
|
|
`ChannelCore`. The wrapper uses `ChannelCore::manager()` directly — no
|
|
`context.env` downcast, no dynamic resolution. The open op lives on the
|
|
connection overlay, which is where per-connection state naturally
|
|
belongs (ADR-019, ADR-024).
|
|
|
|
This preserves every invariant ADR-047 §4 was written to protect:
|
|
|
|
- **Layering (ADR-044):** the call crate stays free of channels types.
|
|
The open-op wrapper is in `channels-call` (`src/channels/operations.rs`);
|
|
the call crate's `OperationRegistry` and `OperationEnv` are unchanged.
|
|
No `as_any()` is added to `OperationEnv` (the trait stays concrete-rpc-
|
|
shaped, preserving the session/connection overlay patterns from
|
|
ADR-024 — AGENTS.md §6).
|
|
- **Per-connection resolution:** the open op gets the *right*
|
|
`ChannelManager` (the one for the connection it was invoked on) because
|
|
the op is registered on that connection's overlay registry, with a
|
|
`ChannelCore` closing over that connection's manager. A globally-
|
|
registered handler (Layer 0) is not on this path.
|
|
- **"Marked ops invoked outside a channels session" (ADR-047 §2):**
|
|
unchanged. A `channels/<alpn>/sub` op registered only on a channels
|
|
connection's overlay is not reachable on a bare `alk/call`
|
|
connection (the overlay isn't attached there) — the dispatch path
|
|
returns `NOT_FOUND`, which is the correct behavior for "no channels
|
|
session" (the `channel:no_channels_session` error code from the
|
|
original §4 is no longer reached; `NOT_FOUND` is the natural
|
|
reachable-but-not-here result).
|
|
|
|
The `ChannelOperationEnv` extension trait and `ChannelsSessionEnv` impl
|
|
(`src/channels/env.rs`) are retained as a two-way-door implementation
|
|
detail — they are not on the open-op path, but remain available for
|
|
future per-connection routing (e.g., nested channels where each
|
|
connection's overlay carries its own manager reference for
|
|
non-open-op queries). The `resolve_channel_manager` stub is removed
|
|
(it described the rejected dynamic-resolution shape).
|
|
|
|
### Door type
|
|
|
|
**Two-way (implementation detail).** The ADR's door-type section
|
|
already marks "The `ChannelCore` wrapper shape, the extension-trait
|
|
pattern, and the opener ledger are two-way-door implementation details
|
|
within the one-way decision." Per-connection registration vs. dynamic
|
|
resolution is a choice within the wrapper-shape detail — the one-way
|
|
decisions (per-ALPN op names, the `channel_open` marker, removal of
|
|
`channel/open`/`direction`) are unchanged. A future revision could move
|
|
open ops back to Layer 0 with real `as_any()` downcast machinery if the
|
|
composite-env structure stabilizes enough to make the traversal
|
|
non-fragile; the per-connection shape is the simpler choice today.
|
|
|
|
### References
|
|
|
|
- C-02, C-03 in `docs/reviews/001-pub-and-channels-integration-review.md`
|
|
(the `register_openable`-does-not-exist and
|
|
`resolve_channel_manager`-is-a-stub findings this amendment resolves)
|
|
- ADR-019: operation registry layering (the connection overlay the open
|
|
op is registered on)
|
|
- ADR-024: peer-graph routing model (the `OperationEnv` integration-point
|
|
pattern this amendment preserves by NOT adding `as_any()`)
|
|
|
|
## Context
|
|
|
|
The channels spec (ADR-037) framed channel lifecycle as **one generic
|
|
`channel/open` operation** whose input carries the authorization-relevant
|
|
facts (`alpn`, `params`, `direction`) inside its JSON body. The call
|
|
protocol's ACL machinery (`AccessControl::check` on `OperationSpec`) runs
|
|
*before* the handler — but the generic op hides everything the ACL would
|
|
need to see inside one op's input, where the call ACL machinery can't
|
|
reach it.
|
|
|
|
The research findings
|
|
(`/workspace/@alkdev/alknet/docs/research/call-channels-unification/
|
|
findings.md`) surfaced the structural miss and the resolution together:
|
|
|
|
- **The miss.** Per-ALPN scopes (`tty:open` vs `tunnel:open`) have no
|
|
enforcement home. ADR-011's `resource_id_path` (JSON pointer into input
|
|
for the resource ID, checked by the ACL before the handler runs) can't
|
|
work on a single generic op — the pointer differs per ALPN
|
|
(`/params/container` for tty-docker, `/params/target` for tunnel). The
|
|
two `direction` values are wildly different grants ("may you consume my
|
|
TTY" vs "may you register a service I will consume as a client")
|
|
squeezed under one ACL. The shape re-commits the structural miss
|
|
ADR-024 (superseding ADR-023) was written to avoid: a parallel
|
|
authorization system duplicating `AccessControl` one layer down.
|
|
- **The resolution.** An openable ALPN is an operation. Each openable
|
|
ALPN registers its own ops on the call `OperationRegistry`, with its
|
|
own `access_control`, `input_schema`, `resource_id_path`, and a
|
|
`channel_open` marker that tells the channels layer "this op's stream
|
|
is binary, not JSON." The call protocol's existing
|
|
`AccessControl::check` is the ACL — unchanged. The `direction` field is
|
|
gone; `OperationType` carries the direction (`Sub` = consumer
|
|
subscribes, `Pub` = producer publishes), building on ADR-046.
|
|
|
|
This is the cheapest moment to amend: no channels code exists, no
|
|
deployments exist, the develop branch is pre-alpha. ADR-037's four op
|
|
names were declared one-way doors, but ADR-035/046 just demonstrated that
|
|
amendment is the normal mode here.
|
|
|
|
### What the research findings resolved (Gap A, E)
|
|
|
|
- **Gap A** (Pub handler shape) — resolved by ADR-046:
|
|
`HandlerKind::Sink` / `SinkHandler` / `invoke_sink()`. The initiator
|
|
streams *to* the responder via `call.published` events; the handler
|
|
consumes the stream and returns a single `ResponseEnvelope`.
|
|
- **Gap E** (`OperationEnv::channel_manager()` couples call to channels)
|
|
— resolved by the extension-trait pattern: an
|
|
`alknet-channels-call`-local trait (`ChannelOperationEnv: OperationEnv`)
|
|
adds the `channel_manager()` accessor, and the open-op wrapper
|
|
downcasts `context.env` at invocation time. `alkcall` (the call crate)
|
|
stays free of any channels types; the layering is preserved.
|
|
|
|
### What this ADR decides (Gap B, C, D, F, G)
|
|
|
|
- **Gap B** (hub broker) — **out of scope for alkcall.** The broker
|
|
(topic registry, `Pub`↔`Sub` matching by `(op_name, params_hash)`,
|
|
N-consumer fan-out) is a hub/consumer concern, not an alkcall concern.
|
|
alkcall provides the `Pub`/`Sub` primitives (ADR-046) and the channel
|
|
machinery (this ADR); the hub composes the broker on top, the same way
|
|
the hub relay composes on top of call ops. This ADR names it as
|
|
out-of-scope so it stops re-tangling every channels-control-plane
|
|
conversation.
|
|
- **Gap C** (`from_call` relay wrapper) — the `from_call` forwarding
|
|
handler for a marked op wraps with relay machinery (forward + allocate
|
|
local-leg channel + record id mapping + byte-forward pumps) instead of
|
|
the plain forwarding stub. The wrapping lives in a separate layer
|
|
(`from_call` consumer code, e.g. the hub), not in alkcall's `from_call`
|
|
core, preserving the layering. alkcall's `from_call` reconstructs the
|
|
`channel_open` marker from discovery (Gap F) so the consumer can branch
|
|
on it.
|
|
- **Gap D** (`channel_id` allocation in Pub case) — the real invariant is
|
|
"the side that holds the `ChannelManager` allocates." In the `Sub` case
|
|
that's the responder (the side that received the open op); in the `Pub`
|
|
case that's the initiator (the side that sent the open op, which owns
|
|
its own channels connection). ADR-037's "responder allocates" is
|
|
amended to "connection-owner allocates."
|
|
- **Gap F** (`channel_open` marker wire format) — the marker is a boolean
|
|
field `"channel_open": true` on the `services/schema` payload. The ALPN
|
|
is derivable from the op name (`channels/<alpn>/sub` → ALPN
|
|
`alk/<alpn>`); the marker is the dispatch hint, not a carrier for
|
|
the ALPN string. `spec_to_json` emits it; `rebuild_spec_for` parses it.
|
|
- **Gap G** (`resource_id_path` ACL vs handler ownership) — complementary,
|
|
not redundant. The ACL check (via `resource_id_path` +
|
|
`OwnershipProvider`) is the coarse gate ("may this identity touch
|
|
resources of this type, and if a specific resource is targeted, does
|
|
this identity own it?"). The handler's ownership check is ALPN-specific
|
|
business logic the ACL can't express ("is this container in a state
|
|
that allows TTY attachment?"). The two run at different layers and
|
|
answer different questions.
|
|
|
|
## Decision
|
|
|
|
### 1. `channel/open` dissolves into per-ALPN ops
|
|
|
|
The generic `channel/open` operation (ADR-037) is **removed**. Each
|
|
openable ALPN registers its own ops on the call `OperationRegistry`,
|
|
named `channels/<alpn>/sub` and/or `channels/<alpn>/pub`:
|
|
|
|
| Op | `OperationType` | Initiator role | Responder role | Stream |
|
|
|----|-----------------|----------------|----------------|--------|
|
|
| `channels/<alpn>/sub` | `Sub` | consumer (subscribes) | producer (streams) | server→client |
|
|
| `channels/<alpn>/pub` | `Pub` | producer (publishes) | consumer (receives) | client→server |
|
|
|
|
An ALPN may register one or both ops. A peer that may subscribe is not
|
|
the same grant as a peer that may publish — separate op types, separate
|
|
ACLs.
|
|
|
|
The `direction` field from ADR-037 is **removed**. `OperationType`
|
|
carries the direction. The "who writes first" question is
|
|
ALPN-specific (determined by the handler's `params` contract), unchanged
|
|
from ADR-037's "the channels layer does not enforce write order."
|
|
|
|
**The generic ops stay:** `channel/close`, `channel/control`,
|
|
`channel/resources/subscribe` remain generic (keyed by `channel_id`),
|
|
registered by `channels-call` at assembly time. Only `channel/open`
|
|
dissolves.
|
|
|
|
### 2. `channel_open` marker on `OperationSpec`
|
|
|
|
`OperationSpec` gains an optional `channel_open` field — the marker that
|
|
tells the channels layer "this op's stream is binary, allocate a channel
|
|
for it":
|
|
|
|
```rust
|
|
pub struct OperationSpec {
|
|
// ... existing fields ...
|
|
pub channel_open: Option<ChannelOpenSpec>,
|
|
}
|
|
|
|
pub struct ChannelOpenSpec {
|
|
pub alpn: Cow<'static, str>, // e.g., "alk/tty"; Cow so from_call can supply an owned String without Box::leak
|
|
}
|
|
```
|
|
|
|
The marker is **registry metadata, not auth machinery** — parallel to
|
|
how `resource_id_path` tells ADR-011 where to find the resource ID. The
|
|
op's `access_control` is the ACL (unchanged); the marker is the dispatch
|
|
hint. The `OperationRegistry` is opaque to the marker; it's
|
|
`channels-call` that reads it.
|
|
|
|
**Wire-visible.** The marker survives discovery serialization — it is
|
|
part of the `services/schema` payload, not just the in-process struct.
|
|
`spec_to_json` emits `"channel_open": true` when set (boolean — the ALPN
|
|
is derivable from the op name). `rebuild_spec_for` parses it back. A
|
|
`from_call` importer can branch on the marker to wrap with relay
|
|
machinery (Gap C).
|
|
|
|
**Marked ops invoked outside a channels session.** `channels/tty/sub` is
|
|
registered on the call registry — which means it's also visible/invocable
|
|
on a bare top-level `alk/call` connection, where there is no
|
|
`ChannelManager`. The open-op wrapper resolves `channel_manager()` at
|
|
invocation time via the extension trait (Gap E); if it returns `None`,
|
|
the wrapper returns `channel:no_channels_session`.
|
|
|
|
> **Amended 2026-08-13** — `ChannelOpenSpec::alpn` is
|
|
> `Cow<'static, str>` (not `&'static str` as originally decided). The
|
|
> `&'static str` shape forced `from_call`'s discovery path to
|
|
> `Box::leak` each runtime-parsed ALPN `String` to satisfy the
|
|
> `'static` bound, accumulating a leak on every rediscovery. The
|
|
> `Cow<'static, str>` keeps the common case (ALPN crates register at
|
|
> compile time with a `&'static str` literal — zero allocation) cheap
|
|
> while letting `from_call` supply an owned `String` without leaking.
|
|
> This is a two-way-door type detail (the wire format — a boolean
|
|
> `channel_open` marker — is unchanged).
|
|
|
|
### 3. `ChannelCore` wrapper (the open-op composition seam)
|
|
|
|
The ALPN crate provides:
|
|
- The `OperationSpec` (with `channel_open` marker, `access_control`,
|
|
`input_schema`, `resource_id_path`).
|
|
- The open handler (ALPN-specific work — validate params, consult
|
|
ownership, prepare the backend, return a "channel plan" — a
|
|
`ProtocolHandler` to spawn on the channel's `BiStream`).
|
|
|
|
`channels-call` provides:
|
|
- The `ChannelCore` (channel-id allocation, `ChannelManager` integration,
|
|
per-connection opener ledger, `ChannelLifecyclePolicy` consultation,
|
|
teardown hooks).
|
|
- A `register_openable(spec, open_handler, channel_core)` helper that
|
|
wraps the ALPN's open handler with the channel machinery and registers
|
|
the op on the call `OperationRegistry`.
|
|
|
|
The wrapper shape (not the invoke shape) is preferred: the ALPN's open
|
|
handler returns a channel plan; channels-call's wrapper does the
|
|
allocation/ledger/policy/spawn. The alternative (the handler calls
|
|
`context.channel_core.open(...)` itself) would require either
|
|
`OperationContext` to carry a `channel_core` reference (inverting the
|
|
call → channels-call dependency) or a generic extension mechanism on
|
|
`OperationContext` (more complexity than the wrapper). The exact API
|
|
shape of the channel plan is a two-way-door implementation detail; the
|
|
architectural point is the wrapper.
|
|
|
|
### 4. Per-connection `ChannelManager` resolution (Gap E)
|
|
|
|
> **Amended 2026-08-13** — see "Amendment (§4 per-connection
|
|
> registration, 2026-08-13)" at the top of this file. The
|
|
> dynamic-resolution shape described below (downcast `context.env` to
|
|
> `&dyn ChannelOperationEnv` at invocation time) was found
|
|
> unworkable as written (`context.env` is a `PeerCompositeEnv`, not a
|
|
> single concrete type; the downcast cannot reach the manager without
|
|
> fragile cross-layer traversal). The operative decision is
|
|
> **per-connection registration**: `register_openable` is called on the
|
|
> connection overlay registry (Layer 2), closing over a per-connection
|
|
> `ChannelCore`; the wrapper uses `ChannelCore::manager()` directly.
|
|
> The body below is the **original** (rejected) shape, retained for
|
|
> rationale continuity.
|
|
|
|
The `register_openable` helper registers ops at assembly time (Layer 0,
|
|
curated, static per ADR-019). But the wrapper needs the
|
|
**per-connection** `ChannelManager` — the op arrives on channel 0 of one
|
|
specific channels connection, and the channel must be allocated on
|
|
*that* connection's manager. A globally-registered handler closing over
|
|
a static `ChannelCore` has no way to know which channels connection
|
|
invoked it.
|
|
|
|
The resolution: an extension trait in `channels-call` (not in the call
|
|
crate's core `OperationEnv` trait) adds the `channel_manager()` accessor:
|
|
|
|
```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 (`alk/tty` → `tty`) needs pinning.
|
|
The op name `channels/tty/sub` implies the path segment is `tty`;
|
|
non-`alk/*` ALPNs need a rule. The rule: the path segment is the
|
|
ALPN with the `alk/` prefix stripped; ALPNs without that prefix
|
|
use their full ALPN string as the path segment (rare case, two-way-
|
|
door).
|
|
- The `from_call` relay wrapper (Gap C) is a consumer concern, not in
|
|
alkcall's `from_call` core. The consumer (hub) wraps marked ops with
|
|
relay machinery after `from_call` returns. This preserves the
|
|
layering but means the hub has more to do than a plain
|
|
`register_imported_all`.
|
|
- The broker (Gap B) is out of scope. The `Pub`/`Sub` primitives are the
|
|
building blocks; the hub composes the broker on top. Designing the
|
|
broker blind would produce a half-design.
|
|
|
|
## Door type
|
|
|
|
**One-way (control-plane shape).** The per-ALPN op names
|
|
(`channels/<alpn>/sub`, `channels/<alpn>/pub`), the `channel_open`
|
|
marker on `OperationSpec`, and the removal of `channel/open` /
|
|
`direction` are one-way: once consumers register open ops and clients
|
|
call them by name, changing the shape requires a protocol migration.
|
|
The `ChannelCore` wrapper shape, the extension-trait pattern, and the
|
|
opener ledger are two-way-door implementation details within the
|
|
one-way decision.
|
|
|
|
The `channel_open` marker's wire shape (`"channel_open": true` boolean)
|
|
is one-way: `services/schema` consumers will read it, and changing the
|
|
shape would break them. The `ChannelOpenSpec` in-process struct carrying
|
|
the ALPN is a two-way-door detail (the wire shape is boolean; the
|
|
in-process struct is free to evolve).
|
|
|
|
## References
|
|
|
|
- ADR-037: channel lifecycle operations (amended by this ADR —
|
|
`channel/open` dissolves; generic ops stay; `direction` removed)
|
|
- ADR-041: per-identity channel cap (amended by this ADR — the opener
|
|
ledger; the trait shape survives)
|
|
- ADR-046: Publish operation type and `HandlerKind::Sink` (the
|
|
`Pub`/`Sub` primitives this ADR builds on; Gap A resolved)
|
|
- ADR-024: peer-graph routing model (the `AccessControl::check` path
|
|
this ADR preserves; the precedent for avoiding a parallel auth
|
|
system)
|
|
- ADR-011: dynamic resource ownership (`resource_id_path` works again
|
|
under per-ALPN ops)
|
|
- ADR-019: operation registry layering (`OperationEnv` as the
|
|
integration point; the extension-trait pattern extends it)
|
|
- ADR-044: channels sub-crate decomposition (the layering this ADR
|
|
preserves — channels types stay out of the call crate)
|
|
- ADR-042: hub relay (the relay contract unchanged in shape; the op
|
|
name changes, the marker replaces prefix-matching)
|
|
- `/workspace/@alkdev/alknet/docs/research/call-channels-unification/
|
|
findings.md` — the research that surfaced the unification and the
|
|
gaps this ADR resolves |