Files
alkcall/docs/architecture/decisions/042-hub-relay-translate-not-forward.md
T
glm-5.3-flash 91765446b7 docs(review 008 Unit 3 planning): ADR-051 — in-tree ChannelRelay + hub-leg assembly; plan split into 3a/3b/3c
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).
2026-09-17 06:47:48 +00:00

226 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
1. **The `channel_id` mapping dissolves.** §`channel_id` mapping
pinned a `HashMap<channel_id, channel_id>` per (browser, spoke)
pair. The implementation needs no map: each leg's `ChannelManager`
holds its own id → routing state, and the per-channel pump closure
binds both legs. The mapping is implicit-per-channel — a
`channel_id` never 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).
2. **`channel/close` needs no translation surface.** The §mapping
bullet's "On `channel/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 a `channel/close` to translate.
`channel/control` stays 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 `CallAdapter`
per leg — ADR-036).
- The hub's `CallAdapter` receives the browser's `channel/open` as a call
operation, runs `AccessControl::check` with the browser's identity, then
forwards via `from_call` to the spoke (the hub as caller, the browser as
`forwarded_for` — ADR-026 §3).
- The spoke allocates its `channel_id` and 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:
1. **Call-protocol layer (channel 0): translate.** The hub terminates
channel 0 on both legs. A `channel/open` from the browser is received by
the hub's `CallAdapter`, which:
1. Runs `AccessControl::check` on `channel/open` with the browser's
identity (bearer token resolved per ADR-034). If denied →
`channel:forbidden` to the browser.
2. Issues a *new* `channel/open` on the spoke's channel 0 via `from_call`,
with the hub as caller and the browser as `forwarded_for` (ADR-026
§3). The spoke's `AccessControl::check` sees the hub as the direct
peer (authorized per ADR-011) and the browser as `forwarded_for`.
3. The spoke allocates its `channel_id` and returns it.
4. 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_id` mapping: `browser_id ↔ spoke_id`.
2. **Data-channel layer: byte-forward with `channel_id` rewrite.** Once the
mapping is established, the relay reads chunks for `browser_id` off the
browser's channels connection, rewrites the `channel_id` field to
`spoke_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 two `AsyncRead + AsyncWrite` pairs 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:open` scope
for `alk/ssh` to `spoke-X`? `AccessControl::check` on `channel/open`,
run by the hub's `CallAdapter` before 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/close` on 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 as `forwarded_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_id` mapping 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 is `HashMap<u32, u32>` per pair — cheap per entry, but the entry count
is (browsers × channels-per-browser). Bounded by `max_channels` (ADR-040)
per connection.
- The translate path adds one `channel/open` round-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/control` translation requires the hub's `CallAdapter` to rewrite
`channel_id` in 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_id` rewrite; the `BiStream`-yielding
`open_channel_stream` interface)
- `docs/research/alknet-channels/phase-0-findings.md` §Hub Motivation,
§The hub relay, §OQ-CH-11
- `docs/architecture/crates/hub/README.md` — the hub crate (the relay
implementation's home)