Post-landing audit of the 0.7.1 -> 0.8.0 remediation diff: two hardening guards, one rejection-posture fix, two log/message corrections, and the deferred coverage debt filed as review 009. - RelayPlan owns the producer-leg ChannelManager and reclaims the adopted spoke channel_id via a Drop guard (replaces the pump handler's post-pump_bidi explicit reclaim). Closes the leak windows the pump's normal path cannot reach: the wrapper's establishment bound expiring after the adopt, and the pump handler's early-return arms (plan absent, downcast failure, try_unwrap failure, accept_bi failure). The send-half drop still EOFs the spoke leg via the mux pump's implicit-EOF sentinel, so the spoke-side cascade is unchanged. ADR-051 §6 documents the closed post-adopt window (the pre-adopt §6 window and the inside-adopt_channel cancellation point stay as documented). - rebuild_spec_for trims and rejects empty/whitespace channel_open_alpn strings — an empty explicit string previously overrode a sane name-derived ALPN. - op_name_is_standard_channel_open_shape applies the same empty-segment guard as the derivation: channels//sub no longer serializes boolean-only and then reconstructs unmarked (silent stub for a marked op); the explicit string rides instead. - reserved_reply_key_call_error interpolates RESERVED_REPLY_KEY; the establisher-bug log fires at warn! (programming error). - Regression tests: the plan drop guard, the empty-ALPN fallback, the empty-segment shape check (672 tests, 3 new). - CHANGELOG [Unreleased] entry for the audit fixes. - docs/reviews/009 — the audit's deferred test-coverage gaps (template failure arms, filtered/only, batch reserved key, wire failure path, golden pins, derivation edge shapes, builder overwrite semantics), each with the test to add and gates. Verification: cargo test 672 passed; clippy --all-targets -D warnings clean; fmt --check clean; doc --no-deps clean; wasm32 check clean.
17 KiB
ADR-051: In-Tree Channel Relay (ChannelRelay) and the Hub-Leg Assembly
Status
Accepted (2026-09-17) — amends ADR-042 (the relay contract's
implementation home moves into alkcall; the translate-not-forward
contract is unchanged). Sequenced after review 008 remediation Units 1
and 2 (both landed, alkcall 0.8.0): the relay's establisher projects
the spoke reply's extra fields via Establishment reply fields
(ADR-049 amendment 3), and the spoke's flavor-form open ops arrive
reconstructed WITH the channel_open marker through discovery
(ADR-047 amendment 3). All three implementation units are landed:
Unit 3a (the ChannelRelay component, §1–§4, §6) in
src/channels/relay.rs, Unit 3b (the hub-leg install template, §5) in
src/channels/hub_leg.rs, and Unit 3c (the review's gate-2 e2e
harness, gates 6/7) in src/channels/gate2_tests.rs — alkcall 0.8.0.
Context
Review 008's remediation plan (Unit 3) pinned the in-tree relay as the
implementation of the review's U-1 gate 2 — the hub-relay round trip
that proves flavor-form marked ops survive discovery into a genuine
relay. The as-filed ADR-042 §"Scope note" put the relay implementation
in alknet-hub/downstream; the consumers that now exist (alktunnels'
graduation, alkhttp's fallback hub, alknodes, and the hub/spoke family
behind them) all need the same shape, so the implementation moves into
alkcall as a reusable export.
The consumer family the relay must serve is the hub/spoke composition:
spokes connect to a hub and expose services; the hub re-exposes those
services to other spokes (and consumers) based on ACL. Both a hub and
a spoke can be a relay depending on the setup. A canonical example:
spoke A exposes channels/tunnel/direct; the hub imports it via
from_call, gates it with its own ACL, and re-exposes it on its
consumer-leg channel-0 registries; spoke B opens through the hub and
the hub relays the channel to spoke A. The hub performs no port
binding and no protocol work on the data plane — the property that
makes the hub-acl-relay role cheap (alktunnels' no-bind posture, the
same property ADR-042 §What the hub runs pins: "the hub never runs a
handler for alk/tty, alk/ssh, or alk/tunnel").
Two implementation surfaces were evaluated for the data plane; the decision records why the wrapper-composition shape wins over byte-forward-with-rewrite.
Decision
1. The relay is the wrapper composition — translate-not-forward, implemented with the existing open-op machinery
ADR-042's two-layer contract is implemented as composition of pieces this crate already has:
- Layer 1 (call-protocol translate) is the ADR-049 establisher:
the hub's consumer-leg open op resolves through the full open-op
wrapper (registry ACL → per-identity cap → allocation), then the
establisher calls the producer leg's open op, adopts the returned
spoke channel id into the producer-leg
ChannelManager, and returnsEstablishment::new(plan)whose plan carries the spoke-leg streams. The hub allocates its own consumer-legchannel_id; the spoke allocated its own producer-leg id; the two ids never appear in each other's namespaces. - Layer 2 (byte-forward) is the ADR-050
OpenHandler:pump_bidi(consumer_leg_bistream, spoke_recv, spoke_send)— the pump's generic bounds already match the relay's two legs. The relay never parses chunk framing on either leg; the mux/demux on each leg absorbs the 8-byte header (ADR-034) and the channel-id namespaces are per-connection, so no header rewrite exists. Awaited inline per the R-02 lifetime contract; on completion both legs tear down (the wrapper's handler-exit teardown covers the consumer leg; the pump's send/recv drops EOF the spoke leg).
The relay map dissolves. ADR-042 §channel_id mapping pinned a
HashMap<channel_id, channel_id> per (browser, spoke) pair maintained
by the hub. The implementation needs no map: each leg's ChannelManager
already holds its own id → routing state, and the per-channel pump
closure binds both legs' streams. The mapping is implicit-per-channel.
This is a two-way-door implementation detail within ADR-042's contract
(the ADR itself marked the mapping strategy two-way).
channel/close and channel/control need no translation surface.
Consumer-side close → wrapper teardown → pump abort → send-half drop →
EOF chunk → spoke handler exits → spoke's own wrapper decrements its
ledger. Close propagates through the EOF cascade with correct ledger
accounting on both legs; the hub never sees a channel/close to
translate. Control stays per-leg (channel/control is not implemented
upstream, OQ-39 — a hub wanting cross-leg control translation composes
it downstream). This supersedes the ADR-042 §mapping bullet's
close-translation sentence as an implementation matter: the mapping
entry the sentence's removal depended on does not exist.
2. What the relay holds on the producer leg: CallConnection + ChannelManager
The relay's producer-leg surface is Arc<CallConnection> (for the
translate hop's open call) plus the producer-leg ChannelManager (for
adopt_channel and teardown visibility) — not a ChannelClient.
Rationale: ChannelClient's calling surface detaches on
take_call_connection (the hub needs the connection for from_call
import AND for the relay's establisher AND possibly its own ops —
three claimants), while Arc<CallConnection> is Clone and the
client's manager accessor yields the same ChannelManager the relay
needs. A hub that dials the spoke with ChannelClient::from_connection
and then tears the client down after import keeps the relay alive on
the Arcs. The establisher builds its forwarded payload directly
(build_forwarded_payload semantics — the hub as caller, the consumer
as forwarded_for, ADR-026 §3) rather than through
call_open_op, which carries no payload variant.
The spoke reply's channel_id is stripped before projection (the
consumer-leg reply carries the hub-allocated id, not the spoke's);
the spoke reply's other fields ride through with_reply_fields —
bound flows end-to-end with per-hop truthfulness (a hub re-producing
a listen resource binds its own listener and contributes its own
bound; alktunnels ADR-008 §Hub re-produce).
3. Reason-code mapping (spoke CallError → EstablishmentError)
The spoke's open can fail with the full call-error vocabulary; the
establisher maps it into EstablishmentError by class:
| Spoke failure | EstablishmentError variant |
Wire reason |
|---|---|---|
channel:open_failed with a typed reason |
the same class — DialFailed for dial_failed/timeout, UnknownResource for unknown_resource, ResourceShortage for resource_shortage, HandlerError for handler_error |
the mapped reason |
channel:open_failed reason timeout |
DialFailed (the spoke establishment exceeded the spoke bound — from the consumer's side it is unreachable-target-shaped) |
dial_failed, spoke message preserved |
NOT_FOUND (spoke predates the op) |
HandlerError carrying the code |
handler_error |
FORBIDDEN (hub lacks the spoke grant) |
HandlerError carrying the code |
handler_error |
other CallError |
HandlerError carrying the code |
handler_error |
The spoke's original code and message are preserved in the mapped
variant's message (never discarded — a consumer debugging a
FORBIDDEN-through-relay must see the spoke's grant failure, not a bare
handler_error). The timeout mapping is the one non-1:1 case: the
consumer-visible reason is dial_failed because establishment
timeouts are wrapper-generated (ADR-049 §3) and not an establisher
vocabulary word; the spoke's message carries the truth.
4. The registration seam: two phases, fork-registry registration
Openable ops register on a per-connection fork of the hub's base
registry installed as the consumer leg's dispatch registry (the
ADR-047 §4 amendment mechanism). Discovery of the producer leg's ops
happens once (hub startup or reconnect); registration happens per
consumer-leg connection inside the install_channel_zero hook. The
one-call signature the review sketched cannot exist without adapter
changes — the two phases are the API:
- Discover/stash —
from_callagainst the producer leg returns the importedHandlerRegistrationbundles. Marked specs (rebuilt WITH thechannel_openmarker, ADR-047 amendment 3) are separated from plain bundles by the marker. The stash is aCloneable template:HandlerRegistrationandHandlerKindareClone, andOperationRegistry::forkdeep-copies registrations, so one discovered set serves any number of consumer legs. - Register per connection — the hub-leg install hook forks the
base registry, registers the generic channel ops
(
ChannelOperations::register_on), registers the stashed plain bundles as-is (the existing forwarding handlers), and registers each stashed marked spec via the relay'sregister_relay_openable(the fork is theconsumer_registryargument), then dispatches channel 0 over the fork.
Per-consumer ACL differentiation is a composition consequence, not a
mechanism: the fork is per consumer leg, so a hub re-exposing
different op subsets to different consumers filters the stash per
fork. The imported spec's own access_control remains the gate for
the re-exposed op; the hub composes additional ACL at its assembly
layer.
5. The hub-leg install hook is an in-tree export (the call-half support)
Every existing test hand-rolls the hub-side install hook
(make_install_channel_zero-shaped); the hub/spoke family all need
the same composition. The relay lands with a documented hub-leg
template — fork, generic ops, bootstrap discovery, stashed bundles
(plain + relay), per-call identity resolution (ServingConfig-shaped),
single-stream dispatch loop — as an in-tree composition (unit-scoped:
Unit 3b). This is composition of existing pieces, not a new protocol
surface; its exact API shape is a two-way-door implementation detail.
The spoke side needs nothing new: a spoke serving ops through a hub is
the existing connect-side serving shape (from_connection_with_serving)
plus the producer-leg registration it already does.
ACL layering note (pinned, because it surprises): the spoke's
AccessControl sees only the hub's identity — the end consumer's
identity rides forwarded_for as metadata and is never consulted by
any AccessControl::check (ADR-024/ADR-026). "The hub re-exposes
based on ACL" therefore means: the hub-side gate is the imported
spec's ACL (+ hub policy) on the consumer leg; the spoke-side grant
goes to the hub identity, which needs scopes on every spoke op it
relays. The end consumer never authenticates to the spoke directly.
6. Bounds: rejected shapes and residual notes
- Pub-typed open ops — rejected loudly (
channel:pub_open_not_implemented, the C-08 blocker). The relay registers onlyQuery/Mutation-typed ops (plain forwarding) andSub-typed marked ops (relay openables); a discoveredPub-typed marked spec is an assembly error, not a silent stub. - Compounding establishment bounds — each hop's establisher is
bounded independently (ADR-049 §2); an N-hop chain adds N bounds.
The consumer's dispatch deadline (
min(deadline, 10s)per hop) bounds each hop, not the chain. No fix — noted for capacity planning. - Mid-establishment consumer disconnect — the one teardown window
with a delay, with two reclaim signals pinned by the Unit 3c gate
(
gate2_mid_establishment_disconnect_reclaims_without_leak): (a) the consumer leg's just-allocated channel reclaims at the leg's own transport EOF (clear_all+ ledger decrement) — the hub has not adopted, so the producer leg stays clean; (b) the spoke's channel — the spoke allocates before its establisher replies — is the honest residual: it cannot be reclaimed by the hub (no adoption exists), so it lingers until a spoke-side teardown signal (the spoke↔hub leg ending, or the spoke's own establishment bound). The gate bounds the window deterministically by severing the spoke leg and asserts the spoke-side reclaim + ledger decrement. A channel never outlives its leg lifetimes; never a leak past the connection's lifetime. - Adopted-entry teardown on every plan path — the hub's
producer-leg entry (the
adopt_channelstate) is owned by the relay'sRelayPlanvia aDropguard, so it reclaims whenever the plan dies: the normal pump completion, the pump handler's early-return arms (plan absent, downcast failure,try_unwrapfailure,accept_bifailure), and the wrapper's establishment bound expiring after the adopt (the plan dropped before the pump ever spawns). Dropping the plan's send half also EOFs the spoke side through the mux pump's implicit-EOF sentinel (REQ-CH-01), so the spoke handler reclaims through the same cascade the normal path uses. This closes the adopt-then-timeout window the landing review surfaced (the §6 window above is the pre-adopt window; this is the post-adopt one, now closed by construction). The one remaining cancellation point insideadopt_channel's own await is the same pre-existing client-side window (ChannelClient::open_channel's adopt has it too) — bounded by the leg's transport-EOFclear_all, not by the plan. - Multi-hop relays — a chain of relays composes (each hop is a hub leg pair), with the compounding-bound note above. No special machinery.
Consequences
Positive:
- The relay is O(1) new machinery: an establisher closure, a
pump_bidihandler, and a registration entry point. No parallel auth path (the consumer leg's full wrapper machinery applies), no new wire surface (the relay speaks only already-stable ops), no data-plane parsing (ADR-034's one-way door stays closed). from_callgains a real in-tree consumer for the marker-branching seam Gap C named (ADR-047 §Gap C): downstream crates composeChannelRelayinstead of re-deriving the hub shape.- U-1's gate 2 (the review's silent-failure scenario — flavor-form ops relaying as plain forwarding stubs) becomes a permanent e2e gate.
Negative:
- New pub API (
ChannelRelay, the registration entry point, the hub-leg template) — minor bump, one release with the review's other units, review-007 precedent. - The two-phase discover/stash-then-register shape is more assembly
code for a trivial hub than a one-call import would be. The fork
mechanism forces it; the stash being
Clonekeeps the per-connection work cheap. - The hub holds
Arc<CallConnection>+ChannelManagerper spoke leg for the connection's lifetime — per-leg state, bounded by the spoke count.
Door type
Two-way (implementation shape) within a one-way posture. The translate-not-forward contract (ADR-042) and the ACL layering are unchanged one-way decisions; the in-tree export, the implicit-mapping implementation, and the two-phase registration shape are revisable implementation details — downstream crates compose the export, not the internals. The rejection posture for Pub-typed marked ops is loud (an assembly error), so a future implementation of the Pub path is additive.
Verification gates (implementation units)
Unit 3a (the relay component):
register_relay_openableon a forked consumer-leg registry resolves the open op end-to-end; the establisher populatesforwarded_forfrom the consumer's per-call identity (ADR-026 §3) and strips the spoke reply'schannel_id.- A spoke
channel:open_failedmaps to the consumer-legchannel:open_failedwith the mapped reason (the §3 table) and the spoke's message preserved; the consumer leg tears down (ledger decremented); no spoke channel leaks. bound(Unit 1's reply projection) survives the relay to the consumer reply.- Pub-typed marked specs are rejected at registration.
Unit 3b (the hub-leg template):
- The template composes fork + generic ops + bootstrap discovery +
plain bundles + relay openables + serving identity and dispatches
channel 0;
services/liston the consumer leg shows the re-exposed ops.
Unit 3c (the gate-2 e2e):
- The review's full harness: consumer → hub (forked channel-0
registry + relay) → producer, opening
channels/tunnel/directend to end: the open resolves with the hub-allocatedchannel_id, the establisher-contributedboundfield survives to the consumer reply, data flows both directions (the hub never parses it), and hub-side disconnect / spoke-side close tears down both legs (including the mid-establishment window from §6). - A companion
channels/tty/subrelay run pins no regression on standard shapes.
References
- ADR-042 (amended by this ADR — the implementation home; the contract unchanged; the relay map and close-translation bullets superseded by the implicit-per-channel shape)
- ADR-047 amendment 3 (flavor-form ops reconstruct WITH the marker — the discovery input this relay consumes), §Gap C (the marker-branching seam), §4 amendment (the fork mechanism Unit 3b composes)
- ADR-049 amendments 2/3 (the plan payload the relay's establisher
carries; the reply projection
boundrides through) - ADR-026 §3 (forwarded_for on the translate hop), ADR-050
(
pump_bidi— the byte-forward hop), ADR-040/041 (the caps the consumer leg keeps enforcing) - alktunnels ADR-007/008 (the first consumers: the direct op's relay
and the listen
boundfield's per-hop truthfulness) - Review 008 remediation plan Units 1–3 (the sequencing this ADR lands inside)