Design session outcome for the relay unit. The hub/spoke family (hub or spoke may relay; the hub re-exposes spoke services by ACL without binding ports) needs the call-half support too, so the unit's scope grew beyond the as-pinned sketch and is now three sub-units. - ADR-051 (new): the relay is the wrapper composition — translate hop = the ADR-049 establisher shape, byte-forward hop = pump_bidi per ADR-050; the relay holds Arc<CallConnection> + ChannelManager per producer leg (not a ChannelClient — take_call_connection's detach is wrong for a hub with three CallConnection claimants); the reason mapping preserves the spoke's code+message (timeout is the one non-1:1 case, mapping to dial_failed); the registration seam is two-phase (discover/stash → per-connection fork-register — the ADR-047 §4 fork mechanism makes a one-call import impossible); the ADR-042 relay map dissolves (implicit per-channel mapping) and channel/close needs no translation surface (EOF cascade propagates with correct ledger accounting on both legs); Pub-typed marked specs are a loud assembly error; establishment bounds compound per hop (noted, no fix); the ACL layering note is pinned (the spoke's AccessControl sees only the hub identity; forwarded_for is never checked). - ADR-042 amended: the §Scope note is revised (implementation is an alkcall export; hub crates compose it) and the two mechanism supersessions are recorded — the contract and auth-model rationale unchanged. - ADR-047 amendment 3's forward reference and the README ADR index updated (001..051). - Review 008 remediation plan: Unit 3 split into 3a (ChannelRelay component + gates), 3b (hub-leg install template — the call-half support), 3c (gate-2 e2e incl. the mid-establishment disconnect window); sequencing note updated; adoption note records the session's decisions. Verified: cargo doc --no-deps (markdown-only change).
12 KiB
ADR-042: Hub Relay — Translate, Not Transparently Forward
Status
Accepted (amended 2026-09-17, ADR-051 — the relay implementation is an
alkcall export (ChannelRelay), not downstream-only code; the §"Scope
note" below is revised, and the §channel_id mapping / close-translation
mechanism is superseded by an implicit-per-channel shape — see
"Amendment (ADR-051, in-tree relay export, 2026-09-17)" below;
the translate-not-forward contract and the auth-model rationale are
unchanged)
Amendment (ADR-051, in-tree relay export, 2026-09-17)
The relay implementation moved into alkcall as a reusable export
(ChannelRelay, ADR-051) — consumers that exist (alktunnels'
graduation, alkhttp's fallback hub, alknodes) all need the same shape,
so the §"Scope note" decision ("the relay implementation lives in
alknet-hub/downstream") is revised: hub crates COMPOSE the export.
Two implementation findings supersede mechanism text above, both within the two-way door this ADR already marked:
- The
channel_idmapping dissolves. §channel_idmapping pinned aHashMap<channel_id, channel_id>per (browser, spoke) pair. The implementation needs no map: each leg'sChannelManagerholds its own id → routing state, and the per-channel pump closure binds both legs. The mapping is implicit-per-channel — achannel_idnever crosses legs, so the translate hop produces per-leg-truthful ids (this also refines alktunnels ADR-008 §Hub re-produce's "re-map the id at their re-produce hop naturally": the re-map is two independent allocations, not a rewrite). channel/closeneeds no translation surface. The §mapping bullet's "Onchannel/close(translated the same way), the mapping is removed" assumed the map; without it, close propagates through the EOF cascade (consumer-side close → open-op wrapper teardown → pump abort → send-half drop → EOF chunk → spoke handler exits → spoke's own wrapper decrements its ledger) with correct accounting on both legs. The hub never sees achannel/closeto translate.channel/controlstays per-leg (OQ-39 — a hub wanting cross-leg control translation composes it downstream), unchanged.
The byte-forward layer is ADR-050's pump_bidi per channel (the
hub never parses chunk framing — stronger than §"Data-channel layer"'s
rewrite phrasing, which assumed the map-based rewrite shape). The
translate layer is the ADR-049 establisher shape
(register_relay_openable), keeping the consumer leg's full wrapper
machinery (registry ACL, per-identity cap/ledger, establishment bound,
teardown-on-failure) — the auth-model rationale above is unchanged and
now has a concrete in-tree carrier.
Context
The hub is the architectural role (ADR-024, ADR-034) that bridges peers and
browsers. With channels, the hub holds one channels connection per leg
(browser↔hub, hub↔spoke) and relays channels between them. The phase-0
research (docs/research/alknet-channels/phase-0-findings.md §OQ-CH-11,
§The hub relay) identified the key question: does the hub translate
channel/open (terminate channel 0 on both legs, re-issue the open on the
spoke leg) or transparently forward (pass the call operation through
unchanged)?
This is the most under-specified part of the research for something that is
the primary motivation for the channels crate (§Hub Motivation: the
multi-transport collapse). The research said "Phase 1 must specify whether
the hub translates or transparently forwards, and how the channel_id
mapping is maintained."
The answer is derivable from the existing machinery:
- The hub terminates channel 0 on both legs (it runs its own
CallAdapterper leg — ADR-036). - The hub's
CallAdapterreceives the browser'schannel/openas a call operation, runsAccessControl::checkwith the browser's identity, then forwards viafrom_callto the spoke (the hub as caller, the browser asforwarded_for— ADR-026 §3). - The spoke allocates its
channel_idand returns it; the hub maps browser-id ↔ spoke-id.
Transparent forwarding (passing the channel/open call operation through
without the hub's CallAdapter terminating it) would bypass the hub's
AccessControl::check and the forwarded_for auth chain — the hub would
not authenticate the open, and the spoke would see the browser as the direct
caller (not the hub), breaking the ADR-026/ADR-011 auth model. Translation
is the only option that preserves the auth model.
Decision
The hub translates, not transparently forwards
The hub's relay has two layers:
-
Call-protocol layer (channel 0): translate. The hub terminates channel 0 on both legs. A
channel/openfrom the browser is received by the hub'sCallAdapter, which:- Runs
AccessControl::checkonchannel/openwith the browser's identity (bearer token resolved per ADR-034). If denied →channel:forbiddento the browser. - Issues a new
channel/openon the spoke's channel 0 viafrom_call, with the hub as caller and the browser asforwarded_for(ADR-026 §3). The spoke'sAccessControl::checksees the hub as the direct peer (authorized per ADR-011) and the browser asforwarded_for. - The spoke allocates its
channel_idand returns it. - The hub opens a matching channel on the browser's side (the hub is now
the responder for the browser leg, initiator for the spoke leg)
and records the
channel_idmapping:browser_id ↔ spoke_id.
- Runs
-
Data-channel layer: byte-forward with
channel_idrewrite. Once the mapping is established, the relay reads chunks forbrowser_idoff the browser's channels connection, rewrites thechannel_idfield tospoke_id, and writes them onto the spoke's channels connection — and vice versa. The relay does not parse the payload; it does not know if the bytes are TTY chunks, SSH frames, or tunnel data. The channels layer on each end does the chunk↔stream conversion; the relay just moves bytes between twoAsyncRead + AsyncWritepairs with a 4-byte header rewrite.
channel_id mapping
The hub maintains a HashMap<channel_id, channel_id> per (browser, spoke)
pair — the relay map. On channel/open (translated), the mapping is
inserted. On channel/close (translated the same way), the mapping is
removed. The relay task per channel reads the map to determine the rewrite
target.
channel/control operations on channel 0 carry channel_id in their JSON
payload (not in the chunk header). The hub's CallAdapter translates these
too: the browser's channel/control for browser_id is re-issued on the
spoke leg with spoke_id in the payload. The relay does not touch
channel/control — it's a call operation, translated by the hub's
CallAdapter, not byte-forwarded.
What the hub runs
| Leg | What the hub runs |
|---|---|
| Browser leg | ChannelsAdapter (the relay's read/demux) + CallAdapter (channel 0, for the hub's own ops + translating the browser's ops) |
| Spoke leg | ChannelsAdapter + CallAdapter (same) |
| Relay | Per-channel byte-forward tasks with channel_id rewrite |
The hub never runs a handler for alk/tty, alk/ssh, or
alk/tunnel. It runs alk/channels (the relay) and alk/call
(for its own hub-level operations + translation). The endpoints at each end
do the protocol work.
What the hub still owns (unchanged from phase-0 §What the hub does still own)
- Routing: which spoke serves
container:abc123? The hub's resource registry / ownership store (ADR-011), queried via call operations on channel 0. Channels doesn't touch this. - ACL at the hub: does this browser's identity have
channel:openscope foralk/sshtospoke-X?AccessControl::checkonchannel/open, run by the hub'sCallAdapterbefore it forwards. Channels doesn't touch this. - Relay lifecycle: when a browser disconnects, the hub tears down the
spoke-side channels (and vice versa).
channel/closeon each channel, or a transport-level close the channels layer observes (REQ-CH-02).
Scope note: this is a hub-crate concern, not a channels-crate concern
This ADR defines the relay contract (translate channel 0, byte-forward
data channels with ID rewrite) so the channels crate's ChannelManager
exposes the interface the relay needs (open_channel_stream(channel_id) -> BiStream for the byte-forward pumps). The relay implementation
lives in alknet-hub (or a downstream hub like alkapi), not in
alknet-channels. The channels crate is ALPN-blind and does not know it
is being relayed. The channel_id rewrite is a 4-byte field rewrite
within the 8-byte header (per ADR-035); the relay does not parse the
payload.
Consequences
Positive:
- The auth model reuses cleanly: the hub's
AccessControl::check+forwarded_for(ADR-026) is the existing machinery, not a new one. The spoke sees the hub as caller, the browser asforwarded_for— the kernel/user-land + forwarded-for model from ADR-011. - The relay is one pump function per channel, not per (protocol × transport) cell. The hub's complexity is O(channels), not O(protocols × transports × spokes).
- The hub never runs protocol-specific handlers — it doesn't parse TTY chunks, SSH frames, or tunnel data. It moves bytes and translates call operations.
channel/resources/subscribe(ADR-037) gives the hub a live view of each spoke's resources, which the hub aggregates and exposes to the browser.
Negative:
- The hub maintains a
channel_idmapping per (browser, spoke) pair. This is per-channel state, not per-connection — a hub with many concurrent browser sessions each with multiple channels has a non-trivial map. The map isHashMap<u32, u32>per pair — cheap per entry, but the entry count is (browsers × channels-per-browser). Bounded bymax_channels(ADR-040) per connection. - The translate path adds one
channel/openround-trip per relayed channel (browser→hub, hub→spoke). This is the same cost as any hub-relayed call operation and is not avoidable without transparent forwarding, which breaks the auth model. channel/controltranslation requires the hub'sCallAdapterto rewritechannel_idin the JSON payload. This is a small but real translation step — the hub is not a pure byte relay for channel 0.
Door type
One-way. The translate-vs-forward decision is structural: transparent
forwarding would bypass the hub's AccessControl::check and the
forwarded_for chain, breaking the auth model. Reversing to transparent
forwarding after deployments exist would require re-architecting the hub's
auth path. The channel_id mapping strategy (HashMap per pair) is two-way
— an implementation detail that can change without breaking the contract.
References
- ADR-024: peer-graph routing model (the hub's role)
- ADR-026: forwarded-for identity (the auth chain the translate path uses)
- ADR-034: outgoing-only X.509 and the three peer roles (browser identity resolution)
- ADR-011: dynamic resource ownership (the ownership store the hub queries)
- ADR-036: channel 0 is pre-negotiated
alk/call(what the hub terminates on each leg) - ADR-037: channel lifecycle operations (what the hub translates)
- ADR-039: ChannelsAdapter and ChannelManager (the interface the relay uses)
- ADR-035: channels pure channel multiplexing (the 8-byte header the relay
reads/writes; the 4-byte
channel_idrewrite; theBiStream-yieldingopen_channel_streaminterface) docs/research/alknet-channels/phase-0-findings.md§Hub Motivation, §The hub relay, §OQ-CH-11docs/architecture/crates/hub/README.md— the hub crate (the relay implementation's home)