Files
alkcall/docs/architecture/decisions/042-hub-relay-translate-not-forward.md
glm-5.2 cc470a363a docs: port architecture specs + 45 ADRs from alknet, renumbered
Port the call + channels architecture documentation from the alknet
mono-repo into docs/architecture/, renumbered as alkcall ADR-001..045.

Renumbering map (alknet -> alkcall):
  Core:        001,002,004,006,007,011,065,070,092,014,050,091 -> 001-012
  Call:        005,064,012,023,015,022,024,016,049,017,028,029,030,032,066,069,067,068 -> 013-030
  Shared:      003,009,013 -> 031-033
  Channels:    071,093,072,073,074,075,076,094,079,080,081,089 -> 034-045

3 superseded/reversed ADRs kept for historical trail:
  - ADR-013 (irpc foundation, superseded by ADR-014)
  - ADR-023 (peer-scoped filtering, superseded by ADR-024)
  - ADR-077 (TTY inside channels, reversed by ADR-035 — not ported, TTY-only)

Ported docs (11 spec files + README + open-questions):
  - call-README.md, call-protocol.md, operation-registry.md, client-and-adapters.md
  - channels-README.md, channels-overview.md, channels-wire.md, channels-connection.md, channels-adapter.md, channel-operations.md, channel-client.md
  - README.md (index with doc table, ADR table grouped by category, key principles)
  - open-questions.md (lean — 30 OQs, renumbered OQ-01..030; includes new OQ-22 for the pub/sub gap)

Cross-reference rewriting:
  - All ADR-NNN references rewritten single-pass (no chaining bug)
  - Markdown link paths fixed
  - Title lines aligned with filenames
  - Non-ported ADR refs (052, 082, 086, etc.) left as-is with README note

The open-questions.md includes OQ-22 (new): the call protocol pub/sub
gap — subscribe exists but pub does not, needed for channels
channel/resources/subscribe fan-out. This is the next ADR to write
(alkcall ADR-046).
2026-08-12 07:06:57 +00:00

181 lines
9.0 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
## 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 `alknet/tty`, `alknet/ssh`, or
`alknet/tunnel`. It runs `alknet/channels` (the relay) and `alknet/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 `alknet/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 `alknet/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)