Files
alkcall/docs/architecture/decisions/051-channel-relay-and-hub-leg.md
T
glm-5.3-flash 54c2a3f941 fix(review-008 audit): adopted-entry drop guard; explicit-ALPN guards; review 009
Post-landing audit of the 0.7.1 -> 0.8.0 remediation diff: two
hardening guards, one rejection-posture fix, two log/message
corrections, and the deferred coverage debt filed as review 009.

- RelayPlan owns the producer-leg ChannelManager and reclaims the
  adopted spoke channel_id via a Drop guard (replaces the pump
  handler's post-pump_bidi explicit reclaim). Closes the leak
  windows the pump's normal path cannot reach: the wrapper's
  establishment bound expiring after the adopt, and the pump
  handler's early-return arms (plan absent, downcast failure,
  try_unwrap failure, accept_bi failure). The send-half drop still
  EOFs the spoke leg via the mux pump's implicit-EOF sentinel, so
  the spoke-side cascade is unchanged. ADR-051 §6 documents the
  closed post-adopt window (the pre-adopt §6 window and the
  inside-adopt_channel cancellation point stay as documented).
- rebuild_spec_for trims and rejects empty/whitespace
  channel_open_alpn strings — an empty explicit string previously
  overrode a sane name-derived ALPN.
- op_name_is_standard_channel_open_shape applies the same
  empty-segment guard as the derivation: channels//sub no longer
  serializes boolean-only and then reconstructs unmarked (silent
  stub for a marked op); the explicit string rides instead.
- reserved_reply_key_call_error interpolates RESERVED_REPLY_KEY;
  the establisher-bug log fires at warn! (programming error).
- Regression tests: the plan drop guard, the empty-ALPN fallback,
  the empty-segment shape check (672 tests, 3 new).
- CHANGELOG [Unreleased] entry for the audit fixes.
- docs/reviews/009 — the audit's deferred test-coverage gaps
  (template failure arms, filtered/only, batch reserved key,
  wire failure path, golden pins, derivation edge shapes, builder
  overwrite semantics), each with the test to add and gates.

Verification: cargo test 672 passed; clippy --all-targets -D
warnings clean; fmt --check clean; doc --no-deps clean; wasm32
check clean.
2026-09-18 06:19:56 +00:00

17 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.
  • Adopted-entry teardown on every plan path — the hub's producer-leg entry (the adopt_channel state) is owned by the relay's RelayPlan via a Drop guard, so it reclaims whenever the plan dies: the normal pump completion, the pump handler's early-return arms (plan absent, downcast failure, try_unwrap failure, accept_bi failure), and the wrapper's establishment bound expiring after the adopt (the plan dropped before the pump ever spawns). Dropping the plan's send half also EOFs the spoke side through the mux pump's implicit-EOF sentinel (REQ-CH-01), so the spoke handler reclaims through the same cascade the normal path uses. This closes the adopt-then-timeout window the landing review surfaced (the §6 window above is the pre-adopt window; this is the post-adopt one, now closed by construction). The one remaining cancellation point inside adopt_channel's own await is the same pre-existing client-side window (ChannelClient::open_channel's adopt has it too) — bounded by the leg's transport-EOF clear_all, not by the plan.
  • 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)