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).
This commit is contained in:
glm-5.2 committed 2026-08-12 07:06:57 +00:00
1 parent 1ceb9b785d
commit cc470a363a
58 files changed
+16729

No files matched your search

@@ -0,0 +1,181 @@
# 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)