- open-questions.md: OQ-39 → ADR-052, OQ-40 → ADR-053 (resolutions replace the deferred-until-needed posture) - channel-operations.md: the put contract (initiator allocates + streams, reserved input key, one response, acks-in-result sentence), the control routing (ADR-052), the enumerator aggregation (ADR-053), the connection-owner paragraph's pub-era correction, the ADR table rows - ADR-051 §6: PubTypedOpen → the put translation; ADR-047's 2026-09-06 amendment records its era honestly (the dynamic half is now ADR-053) - review 010: all four findings resolved (with the S-01 sketch correction so the published record stops carrying the wrong-graph reading) - review 011: closed (all seven pins implemented + gates landed) - review 001 C-08: resolved note (odd/even + adopt, implemented) - AGENTS.md: the 'Stub Guard' section — the not_implemented grep is part of cargo test; the registration invariants and the implement-or-stop ruling are release-blocking Verified: cargo test (696+guard), clippy -D warnings, fmt, doc clean.
952 lines
49 KiB
Markdown
952 lines
49 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 3 (2026-09-16, review 008 U-1) — the op-name convention
|
|
extends to the flavor form and discovery carries an explicit
|
|
`channel_open_alpn` for non-derivable names — see "Amendment 3" below;
|
|
amendment 4 (2026-10-13, reviews 010 S-01 / 011) — the marked-pub open
|
|
contract: the initiator-allocated channel, the adopt path, the wrapper
|
|
as responder of record, zero `call.published` on marked ops, the relay
|
|
translation, and the client-side put-open capability — see "Amendment 4"
|
|
below)
|
|
|
|
## Amendment 4 (the marked-pub open contract, 2026-10-13 — review 010 S-01, review 011)
|
|
|
|
`channels/<alpn>/pub` open ops were registered, advertised, and stubbed
|
|
(`channel:pub_open_not_implemented` on every invocation — review 010
|
|
S-01, shipped nine published versions). This amendment pins the
|
|
implemented contract in place of the stub; review 011 corrected the
|
|
remediation sketch before this amendment was drafted (the serving-loop
|
|
`PublishStream` shape is the **unmarked** pub semantics and does not
|
|
apply to marked ops).
|
|
|
|
The shape, in one sentence: **a marked pub open is an ordinary channel
|
|
open whose `channel_id` the initiator allocated on its own side** — the
|
|
initiator streams bytes on that channel before the open resolves (the
|
|
early-arrival parking covers the race), and the responder adopts and
|
|
serves the data plane as the responder of record.
|
|
|
|
### 4.1 Marker semantics (pin 1)
|
|
|
|
The marked pub op's data plane is the adopted channel's `BiStream` —
|
|
one direction, initiator → responder, with the ADR-040 bounded buffer
|
|
as the backpressure mechanism. **Zero `call.published` data on the call
|
|
plane for marked pub ops**: a nonempty publish-chunk stream on a marked
|
|
op is rejected loudly with a new wire error (code
|
|
`channel:publish_chunks_rejected`, retryable false, message stating
|
|
that the data plane is the adopted channel) written in the initiator's
|
|
direction when the first chunk arrives; the chunks are never consumed,
|
|
and the marked-pub flow continues to its own single response. Two
|
|
competing data paths on a marked op must never exist. Unmarked `Pub`
|
|
ops are unaffected — they remain the ADR-046 `call.published` stream
|
|
through `publish_schema` validation.
|
|
|
|
### 4.2 `publish_schema` refused at registration for marked ops (pin 2)
|
|
|
|
Publish chunks never exist on a marked op (§4.1), so `publish_schema`
|
|
is dead machinery on a marked spec — the registered-but-incapable-of-
|
|
mattering class review 010 outlaws. `OperationRegistry::register`
|
|
refuses a marked spec that carries `publish_schema` (registration-time
|
|
loudness, per the house pattern).
|
|
|
|
### 4.3 Reserved input key + dispatch-layer strip (pin 3)
|
|
|
|
The adopted channel id crosses the open invocation as the reserved
|
|
input key **`"channel_id"`** — the same key the wrapper owns on the
|
|
reply side (`RESERVED_REPLY_KEY`, ADR-049 amendment 3); the wrapper
|
|
owns the `channel_id` key on both sides of a marked open op. Placement
|
|
rationale (review 011 §1.2): schema validation runs in `invoke_*`
|
|
before any handler, so the reserved key must be stripped
|
|
**pre-validation** in the sink resolve path (`resolve_sink_handler` —
|
|
the single source of truth shared by the wire dispatch and
|
|
registry-level `invoke_sink`, per review 001 P-08) before
|
|
`input_schema` validation; the stripped id is handed to the wrapper via
|
|
`OperationContext.metadata` under the key `channel_open_channel_id`.
|
|
The producing crate's `input_schema` never contains the key, and
|
|
dispatch rejects a marked pub op whose input **lacks** it — the
|
|
initiator-allocated id is mandatory, not optional plumbing
|
|
(`INVALID_INPUT`, message naming the key). An op input carrying
|
|
`channel_id` on a NON-pub or unmarked op is not stripped (their
|
|
schemas own their full input surface; the strip and the requirement are
|
|
both scoped to marked pub ops).
|
|
|
|
### 4.4 The wrapper as responder of record (pin 4)
|
|
|
|
The marked-pub open registration is
|
|
`ChannelCore::register_pub_openable(spec, establisher, pub_open_handler,
|
|
registry, auth, timeout)`. The entry points refuse wrong shapes at
|
|
registration: the `Query`/`Mutation`/`Sub` wrapper entry points
|
|
(`register_openable*`) refuse `Pub`-typed specs, and
|
|
`register_pub_openable` refuses non-`Pub` or unmarked specs. The
|
|
handler type is a separate one from `OpenHandler`:
|
|
|
|
```rust
|
|
pub type PubOpenHandler = Arc<
|
|
dyn Fn(Value, Option<ChannelPlan>, Connection, AuthContext)
|
|
-> BoxFuture<'static, Result<Value, CallError>>
|
|
+ Send + Sync,
|
|
>;
|
|
```
|
|
|
|
(`JoinHandle<()>` — the `OpenHandler` return — cannot carry the
|
|
handler result the merged response needs; see §4.6 for why this is a
|
|
separate type rather than an `OpenHandler` change.)
|
|
|
|
The wrapper flow (the responder of record):
|
|
|
|
1. `policy.check_open(identity)` — unchanged (cap gate, pre-adopt).
|
|
2. **Adopt** the initiator-allocated id:
|
|
`manager.adopt_channel(reserved_id, alpn, None)` — the existing
|
|
demux/mux install + early-arrival drain; no new machinery. Adoption
|
|
failure (collision or cap) tears nothing down (nothing was created)
|
|
and replies `channel:open_failed` with a new reason **`adopt_failed`**
|
|
in the ADR-049 §3 vocabulary.
|
|
3. The establisher (bounded, ADR-049 §1/§2 machinery, unchanged) may
|
|
validate the offer and contribute ack fields
|
|
(`Establishment.reply_fields`); its plan threads to the handler.
|
|
4. Spawn the pub handler on the channel's `Connection` — the wrapper
|
|
**awaits the handler inline** (review 011 §1.3), which is what makes
|
|
R-02's lifetime contract structural here: the handler cannot return
|
|
before its pumps finish, so the teardown-at-birth trap does not
|
|
exist on this path, and the channel's `ChannelState.handler_task`
|
|
stays `None` (teardown is EOF-driven: a `channel/close` during the
|
|
put drops the demux sender, the handler's reads see EOF, the handler
|
|
finishes early, and the single response is still written — the
|
|
handler decides the semantics of a truncated stream).
|
|
5. **One response, merge order pinned:** `{ "channel_id": <adopted> }`
|
|
→ extended with the establisher's ack fields (ADR-049 amendment 3,
|
|
unchanged, including the reserved-key establisher-bug refusal) →
|
|
extended with the handler's result fields (handler result wins on
|
|
key collision — its computed result postdates the ack). A handler
|
|
returning `Err` responds that error directly and the channel tears
|
|
down; racing initiator bytes hit EOF and are discarded.
|
|
6. The standard completion teardown runs after the await (demux
|
|
sender drop, ledger take, `policy.on_close` — the same atomic gate
|
|
every teardown path runs, ADR-047 §7).
|
|
|
|
A marked pub op registered outside the wrapper family (a plain
|
|
`SinkHandler` on a marked pub spec) can never adopt — the bytes would
|
|
park and drop. That class is refused at registration (§4.8).
|
|
|
|
### 4.5 The ack-timing sentence (pin 5 — protocol semantics)
|
|
|
|
**A sink op gets exactly one `call.responded`, produced after the
|
|
handler completes — so a putter stream-waits nothing: the
|
|
establishment ack (the adopt + establishment reply fields) rides
|
|
informatively in that one result, never as a gating ack before the
|
|
byte stream.** The fetch/put asymmetry is direction-inherent: fetch
|
|
(`Sub`) acks at establishment — the `{channel_id}` response precedes
|
|
any byte (and the `{channel_id}` is the ack); put (`Pub`) acks in the
|
|
result. This is protocol semantics, not per-consumer folklore: a
|
|
consumer protocol layered on marked pub ops (alkblobs' put) cannot
|
|
wait for the ack without deadlocking (the response cannot exist until
|
|
the stream completes), and the amendment states it so no consumer
|
|
re-derives it.
|
|
|
|
### 4.6 Why `PubOpenHandler` is a separate type (pin record)
|
|
|
|
The alternative — changing `OpenHandler`'s return type — breaks every
|
|
published consumer's open handlers (alktty, alkhttp, alktunnels,
|
|
alkty, alksocks all register `Sub`-type openables) for zero benefit:
|
|
the `()`-returning shape is correct for the spawn-and-reconcile `Sub`
|
|
path (the channel outlives the open call; the wrapper never awaits
|
|
it), while the put path needs completion-in-response. The split keeps
|
|
S-01 capability-additive (review 011 §5: no shipped consumer registers
|
|
a `Pub`-typed openable); the put path's type is new API surface,
|
|
one-way like its siblings (ADR-046's handler-type door).
|
|
|
|
### 4.7 Relay translation replaces `PubTypedOpen` (pin 6)
|
|
|
|
`RelayError::PubTypedOpen` is removed. The hub's pub-openable
|
|
translation (the put-forwarding shape):
|
|
|
|
- The consumer-leg registration composes
|
|
`ChannelCore::register_pub_openable` with a relay establisher: the
|
|
hub **allocates** its connect-side id on the producer leg
|
|
(`open_channel` — odd, the implemented split), sends the open op
|
|
carrying the reserved key to the spoke **in the background** (never
|
|
awaited inside the establisher — the spoke's put response cannot
|
|
exist before its byte stream completes; awaiting it in the
|
|
establisher deadlocks the leg), and the `RelayPlan` carries the
|
|
spoke-leg send half, the early-arrival-drained read half, the
|
|
producer-leg entry reclaim guard (as before, ADR-051), and the
|
|
response receiver.
|
|
- The relay pub handler (the consumer-leg pub handler): pumps the
|
|
consumer-leg channel bytes → spoke send half (one direction, pub);
|
|
on consumer EOF, EOFs the spoke side; awaits the spoke's single
|
|
response; strips the producer's reserved reply key from the spoke
|
|
output (the hub's reply must carry the hub-allocated consumer-leg
|
|
id — a spoke `channel_id` merged through would shadow it, the same
|
|
reserved-key class the wrapper refuses) and returns the remainder as
|
|
the handler result — which the consumer-leg wrapper merges into the
|
|
single response with the pinned order.
|
|
- The spoke's open-time establishment failure surfaces in the final
|
|
response (put-acks-in-result), mapped through the existing
|
|
`map_spoke_error` table; the plan's reclaim guard tears down the
|
|
producer-leg entry on every handler path, as ADR-051 §6 already
|
|
pins for the sub direction.
|
|
- The hub-leg template's pub-typed assembly error becomes unreachable
|
|
(registered through the relay like the sub ones).
|
|
|
|
### 4.8 The registration invariants (the stub-class guard)
|
|
|
|
`OperationRegistry::register` enforces, at registration time:
|
|
|
|
- A marked spec with `publish_schema` → refused (§4.2).
|
|
- A marked spec registered through the generic path → refused: marked
|
|
specs are registered only through the channel-machinery seam
|
|
(`register_openable*`, `register_pub_openable`, the relay). The
|
|
wrapper-family registrations carry an internal registration flag the
|
|
generic paths cannot set; `CallConnection::register_imported` (the
|
|
overlay landing) and direct `registry.register` both hit the refusal
|
|
— for overlay imports because a marked op is unreachable by
|
|
composition (`OperationEnv::invoke` is request/response-only; the
|
|
marker's data plane has no overlay path), for direct registrations
|
|
because the channel machinery would silently not exist.
|
|
|
|
### 4.9 The client-side initiator-allocates capability (pin 7)
|
|
|
|
`ChannelClient::open_pub_channel(operation_id, input, alpn)`: allocates
|
|
the connect-side odd id locally (`open_channel` on its own manager),
|
|
inserts the reserved input key, sends the open op **in a background
|
|
task**, and returns `(id, send, recv, reply)` immediately — the caller
|
|
writes bytes on `send` without waiting for anything (put acks in
|
|
result, §4.5). `reply` is the awaited single merged response
|
|
(`Ok(Value)` — the full output; `Err(ChannelOpenError)` — the wire
|
|
error passthrough, with the local adopt entry torn down on failure).
|
|
The existing `open_channel`/`open_channel_with_reply` await the reply
|
|
before returning the halves — unusable for a put; this is the
|
|
putter-facing shape the wrapper is untestable end-to-end without.
|
|
|
|
### Door type
|
|
|
|
**One-way (wire-visible).** The reserved input key and its
|
|
strip-before-validation, the missing-key rejection, the
|
|
chunk-rejection code, the merge order, the single-response semantics,
|
|
and the ack-timing sentence are wire-visible from the first consumer
|
|
(alkblobs' put — which pends behind this amendment, so this is the
|
|
cheapest moment to pin; no shipped consumer rides any of it, review
|
|
011 §5). `PubOpenHandler`, `register_pub_openable`,
|
|
`open_pub_channel`, and the `adopt_failed` reason are new API surface,
|
|
one-way per the handler-API precedent. The early-arrival parking, the
|
|
wrapper internals, and the relay plan internals are two-way-door
|
|
details inside the one-way contract.
|
|
|
|
### Verification gates (implementation units, review 011 §3)
|
|
|
|
- known-length put e2e: offer + adopt + bytes → one response,
|
|
`{channel_id, ack fields, handler result}` merge order verified;
|
|
- unknown-length put e2e: the handler's digest rides the merged
|
|
response;
|
|
- publish-chunk rejection: a marked pub op carrying nonempty publish
|
|
chunks → loud rejection, no handler consumption;
|
|
- establishment failure: `channel:open_failed`, channel torn down,
|
|
ledger un-incremented, racing bytes EOF-discarded;
|
|
- `publish_schema` registration refusal on a marked op;
|
|
- relay translation: a hub translating a pub openable end-to-end.
|
|
- plus: missing reserved key rejection; `open_pub_channel` happy +
|
|
wire-failure paths (the client capability's gates).
|
|
|
|
### References
|
|
|
|
- Review 010 S-01 (the four stubs; the implement-or-stop-advertising
|
|
ruling), review 011 (the corrected shape this amendment pins)
|
|
- alkblobs ADR-014 (`014-marked-pub-put-contract-and-alkcall-gate`) —
|
|
the downstream ruling this amendment unblocks
|
|
- ADR-046 (the unmarked pub semantics that marked ops do NOT ride),
|
|
ADR-049 (establishment machinery + `RESERVED_REPLY_KEY` + merge),
|
|
ADR-040 (bounded buffer — the data-plane backpressure), ADR-050
|
|
(pumps), ADR-051 (the relay this amendment extends to pub), ADR-043
|
|
(`ChannelClient` — the `open_pub_channel` home)
|
|
- `src/channels/manager.rs` (`next_channel_id` odd/even;
|
|
`drain_early_arrivals`), `src/channels/operations.rs` (the wrapper
|
|
family), `src/channels/relay.rs`, `src/registry/registration.rs`
|
|
(`resolve_sink_handler` — the strip site)
|
|
|
|
## Amendment 3 (flavor-form open-op ids + explicit `channel_open_alpn`, 2026-09-16 — review 008 U-1)
|
|
|
|
alktunnels' graduation (ADR-002 Amendment 1 at
|
|
`/workspace/@alkdev/alktunnels/docs/architecture/decisions/002-alpn-strategy.md`)
|
|
pins two NEW `Sub`-typed open ops on the existing `alk/tunnel` ALPN
|
|
with flavor-form op ids (`channels/tunnel/direct`,
|
|
`channels/tunnel/forwarded`) — the ssh
|
|
`direct-tcpip`/`forwarded-tcpip` shape: one ALPN, several channel
|
|
flavors, one open op per flavor. §1 pinned the open-op naming to
|
|
exactly two shapes per ALPN (`channels/<alpn>/sub`,
|
|
`channels/<alpn>/pub`), and Gap F's marker reconstruction derived the
|
|
ALPN by stripping exactly those two suffixes — so a flavor-form op id
|
|
failed the derivation and a hub consuming through discovery rebuilt
|
|
the spec WITHOUT the marker (the silent plain-forwarding-stub failure,
|
|
the worst mode for a relay). Runtime was never blocked (registration
|
|
sets the ALPN explicitly; `open_channel` takes it as an argument);
|
|
discovery + hub relay are the re-produce path that must survive.
|
|
|
|
**The convention sentence (mirroring alktunnels ADR-002 Amendment 1):**
|
|
`channels/<alpn>/<flavor>` is a valid open-op name where `<flavor>`
|
|
is a bare path segment (no `/`), `Sub`-typed, served through the same
|
|
establishment wrapper and relay path as `…/sub`. New flavors are new
|
|
op ids — additive; the `…/sub` op is never reused for a different
|
|
meaning. `OperationType` (`Sub`/`Pub`) continues to carry the
|
|
direction; the flavor is the channel-type discriminator the producing
|
|
crate owns (per-ALPN params semantics, per alktunnels ADR-001
|
|
Amendment 1).
|
|
|
|
**The wire shape (Gap F refinement):** the boolean marker's
|
|
round-trip is derivable only for the standard shapes. The rule:
|
|
|
|
- `spec_to_json_pub` keeps emitting `"channel_open": true` for every
|
|
marked op (unchanged). When the op name is NOT the standard
|
|
`channels/<segment>/(sub|pub)` shape, it ALSO emits
|
|
`"channel_open_alpn": "<alpn>"` — the explicit string rides beside
|
|
the boolean. Standard shapes stay byte-identical to the
|
|
pre-amendment payload (no new key).
|
|
- `rebuild_spec_for` prefers the explicit string when present; else
|
|
(the boolean alone — the deployment-skew case of an old producer)
|
|
the derivation generalizes from "strip `/sub`|`/pub`" to "strip the
|
|
LAST path segment." The boolean marker remains the gate: the
|
|
derivation is consulted only for marked ops, so a plain op named
|
|
`channels/tty/query` is unaffected, and a `channel_open_alpn`
|
|
string without the boolean never marks an op.
|
|
- `services/schema`'s advertised `operation_spec_schema` documents
|
|
`channel_open_alpn` as an optional `["string", "null"]` property
|
|
(schema-type widening, additive — old consumers ignore unknown
|
|
properties).
|
|
- Both wire consumers of the shape — the `from_call` import and the
|
|
`op/register` announced-spec path — parse through the same
|
|
`rebuild_spec_for`, so one parser change covers both.
|
|
|
|
**Residual ambiguity, pinned:** an op name whose ALPN segment itself
|
|
ends in a flavor-like name (`channels/x/direct` where the intended
|
|
ALPN is `alk/x/direct`-shaped) is undecidable from the name alone —
|
|
strip-last reads `alk/x`, and the true ALPN is unknowable from the
|
|
name. The explicit `channel_open_alpn` string is the disambiguator:
|
|
producing crates with non-`alk/*`-derivable names set it; the
|
|
derivation is the fallback for derivable shapes. Old producers
|
|
(alkcall ≤ 0.7.1) cannot serve flavor-form marked ops discoverably
|
|
until upgraded — the review's one-way-door timing note.
|
|
|
|
Door type: the flavor-form names and the additive
|
|
`channel_open_alpn` key are wire-stable from the first consumer
|
|
(alksocks composes against them); the boolean's meaning for
|
|
standard-shape ops is unchanged (the pre-amendment byte-stability is
|
|
pinned by test). The `op_name_is_standard_channel_open_shape` helper
|
|
and the strip-last derivation are two-way-door implementation details
|
|
within the one-way wire shape.
|
|
|
|
Implemented surface (alkcall 0.8.0): the generalized
|
|
`derive_alpn_from_op_name` (strip-last), the explicit-field emission
|
|
(`spec_to_json_pub` + the advertised schema property), and the
|
|
preference order in `rebuild_spec_for`. Verification gates landed as
|
|
tests: `channels/tunnel/direct` reconstructs WITH
|
|
`channel_open = alk/tunnel` (gate 1, both the explicit-string and the
|
|
skew-case paths); standard boolean ops (`channels/tty/sub`,
|
|
multi-segment `channels/custom/proto/sub`) round-trip byte-stable
|
|
(gate 3); the explicit string overrides a colliding derivation; a
|
|
string without the boolean never marks; `op/register` announced
|
|
flavor-form specs round-trip with the marker. Gate 2 (the hub-relay
|
|
round trip) lands with the in-tree relay component (review 008
|
|
remediation plan Unit 3c, amending ADR-042 — the relay export is
|
|
ADR-051).
|
|
|
|
## Amendment (§6 listing enrichment, 2026-09-06)
|
|
|
|
Review 006 E-02 (from the alktunnels Phase 0 sweep) made §6's dynamic
|
|
half load-bearing for the first time and asked for a decision on the
|
|
static half. Decision: **both halves of the "static per-op" split gain
|
|
what is cheap today; the dynamic half stays deferred** (OQ-40, now
|
|
with a "load-bearing for alktunnels discovery UI" note in
|
|
`open-questions.md`; alktunnels v1 uses config-known op names).
|
|
*[Amendment 4 era, 2026-10-13: the dynamic half is now implemented —
|
|
ADR-053; this 2026-09-06 text records the state at its writing.]*
|
|
|
|
- `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 |