Files
alkcall/docs/architecture/decisions/051-channel-relay-and-hub-leg.md
T
glm-5.3-flash 50182d7298 feat(review 008 Unit 3c): the gate-2 e2e harness; 0.8.0 release bookkeeping
- src/channels/gate2_tests.rs (ADR-051 gates 6/7, review 008 gate 2):
  the full consumer -> hub (HubLegTemplate) -> spoke relay e2e —
  the open resolves with the hub-allocated channel_id, `bound`
  survives the relay, data flows both directions with a fake 8-byte
  chunk header riding verbatim (the hub never parses the data plane,
  ADR-034/035), spoke-side close cascades to clean reclaim on both
  legs; hub-side disconnect (the consumer's duplex end dropped via a
  killable transport proxy) tears down both legs with the ledger
  decremented; the mid-establishment window (ADR-051 §6) pinned with
  its two reclaim signals — the consumer leg reclaims at its own
  transport EOF, the spoke channel (allocated before the establisher
  replied) is the honest residual, reclaimed when the spoke-leg
  transport ends; the channels/tty/sub standard-shape companion pins
  no derivation regression.
- src/channels/relay.rs: the relay's adopted producer-leg channel
  entry now reclaims when the relayed pump completes (teardown after
  pump_bidi) — the consumer-leg wrapper's teardown cannot see the
  producer leg's manager; the RelayPlan carries the spoke id for the
  reclaim.
- Release bookkeeping: 0.7.1 -> 0.8.0, the CHANGELOG entry covering
  Units 1-3 (Establishment reply projection, open_channel_with_reply,
  flavor-form discovery derivation, ChannelRelay, HubLegTemplate,
  gate-2 harness); review 008 Status -> Resolved with the U-1/U-2
  commit refs and the 955->945 errata note; ADR-051 Status ->
  all units landed + the §6 mid-establishment residual expanded to
  the two-reclaim-signal shape the gate pins; the stale "0.7.2"
  version mentions in ADR-047/049 corrected to 0.8.0 (the units land
  unreleased).

Verification: cargo test 669 passed / 0 failed; clippy --all-targets
-- -D warnings clean; fmt --check clean; cargo doc --no-deps clean;
cargo check + clippy on wasm32-unknown-unknown clean;
cargo publish --dry-run --allow-dirty passed.
2026-09-18 05:15:29 +00:00

16 KiB
Raw Blame History

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 returns Establishment::new(plan) whose plan carries the spoke-leg streams. The hub allocates its own consumer-leg channel_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_fieldsbound 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 CallErrorEstablishmentError)

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:

  1. Discover/stashfrom_call against the producer leg returns the imported HandlerRegistration bundles. Marked specs (rebuilt WITH the channel_open marker, ADR-047 amendment 3) are separated from plain bundles by the marker. The stash is a Cloneable template: HandlerRegistration and HandlerKind are Clone, and OperationRegistry::fork deep-copies registrations, so one discovered set serves any number of consumer legs.
  2. 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's register_relay_openable (the fork is the consumer_registry argument), 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 only Query/Mutation-typed ops (plain forwarding) and Sub-typed marked ops (relay openables); a discovered Pub-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.
  • 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_bidi handler, 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_call gains a real in-tree consumer for the marker-branching seam Gap C named (ADR-047 §Gap C): downstream crates compose ChannelRelay instead 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 Clone keeps the per-connection work cheap.
  • The hub holds Arc<CallConnection> + ChannelManager per 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):

  1. register_relay_openable on a forked consumer-leg registry resolves the open op end-to-end; the establisher populates forwarded_for from the consumer's per-call identity (ADR-026 §3) and strips the spoke reply's channel_id.
  2. A spoke channel:open_failed maps to the consumer-leg channel:open_failed with the mapped reason (the §3 table) and the spoke's message preserved; the consumer leg tears down (ledger decremented); no spoke channel leaks.
  3. bound (Unit 1's reply projection) survives the relay to the consumer reply.
  4. Pub-typed marked specs are rejected at registration.

Unit 3b (the hub-leg template):

  1. The template composes fork + generic ops + bootstrap discovery + plain bundles + relay openables + serving identity and dispatches channel 0; services/list on the consumer leg shows the re-exposed ops.

Unit 3c (the gate-2 e2e):

  1. The review's full harness: consumer → hub (forked channel-0 registry + relay) → producer, opening channels/tunnel/direct end to end: the open resolves with the hub-allocated channel_id, the establisher-contributed bound field 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).
  2. A companion channels/tty/sub relay 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 bound rides 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 bound field's per-hop truthfulness)
  • Review 008 remediation plan Units 13 (the sequencing this ADR lands inside)