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).
This commit is contained in:
2026-09-17 06:47:48 +00:00
parent 620180d615
commit 91765446b7
5 changed files with 505 additions and 69 deletions
+2 -1
View File
@@ -13,7 +13,7 @@ This crate unifies `alknet-call` and `alknet-channels` from the alknet
mono-repo, plus the vendored core types formerly in `alknet-core`. The
source architecture docs were ported from
`/workspace/@alkdev/alknet/docs/architecture/` and renumbered as alkcall
ADRs (ADR-001..050). The ALPN strings (`alk/call`, `alk/channels`)
ADRs (ADR-001..051). The ALPN strings (`alk/call`, `alk/channels`)
are wire-stable and unchanged — see ADR-004.
## Documents
@@ -103,6 +103,7 @@ are wire-stable and unchanged — see ADR-004.
| [048](decisions/048-dispatch-spine-gateway-module.md) | Dispatch Spine (feature-gated `gateway` module) | `alkcall::gateway` behind the `gateway` feature; `GatewayDispatch` invoke spine (deadline knob, re-rooted context) + `schema_disclosure_denial` (FORBIDDEN for ACL deny, spec-404 for Internal); promoted from alkhttp for hub/spoke reuse |
| [049](decisions/049-channel-open-establishment-phase.md) | Channel-Open Establishment Phase | `OpenEstablisher` + `register_openable_with_establisher` (awaited, bounded); typed `channel:open_failed` with `details.reason`; `Establishment.plan` (`ChannelPlan`) threaded to the `OpenHandler` (amendment 2); the `JoinHandle` data-plane lifetime contract |
| [050](decisions/050-pump-bidi-two-pump-helper.md) | `pump_bidi` Two-Pump Helper | `channels::pump_bidi` — shutdown-on-completion two-pump data plane pinned in one place (alknet ADR-078) |
| [051](decisions/051-channel-relay-and-hub-leg.md) | In-Tree Channel Relay (`ChannelRelay`) and the Hub-Leg Assembly | ADR-042 amended — the relay implementation is an alkcall export; translate hop = ADR-049 establisher (`register_relay_openable`), byte-forward hop = `pump_bidi`; implicit per-channel id mapping; two-phase registration (discover/stash → fork-register); hub-leg install template; ACL layering note |
## Relevant Open Questions
@@ -2,7 +2,52 @@
## Status
Accepted
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
@@ -98,7 +98,8 @@ multi-segment `channels/custom/proto/sub`) round-trip byte-stable
string without the boolean never marks; `op/register` announced
flavor-form specs round-trip with the marker. Gate 2 (the hub-relay
round trip) lands with the in-tree relay component (review 008
remediation plan Unit 3, amending ADR-042).
remediation plan Unit 3c, amending ADR-042 — the relay export is
ADR-051).
## Amendment (§6 listing enrichment, 2026-09-06)
@@ -0,0 +1,301 @@
# 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.7.2): 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).
## 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 `Arc`s. 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_fields`
`bound` 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 `CallError` → `EstablishmentError`)
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/stash**`from_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 `Clone`able
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: the spoke channel can exist briefly after the consumer
leg dies. Not a leak: once the wrapper spawns the pump, hub-leg
`clear_all` (transport EOF) EOFs the consumer recv, the pump exits,
the spoke leg EOFs, and both wrappers decrement their ledgers. The
e2e gate asserts this window explicitly.
- **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):
5. 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):
6. 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).
7. 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)
@@ -213,6 +213,23 @@ from the as-filed text, adopted in planning:
Design decisions pinned above the as-filed option lists:
- **2026-09-17 (Unit 3 planning session): Unit 3 split into 3a/3b/3c
and design pinned in ADR-051** (`docs/architecture/decisions/
051-channel-relay-and-hub-leg.md`) — the session's discussion
expanded the unit's scope beyond the as-pinned sketch: 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 (the
hub-leg install template — now Unit 3b). Key decisions, all in
ADR-051: 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 — it maps to `dial_failed`); the registration
seam is two-phase (fork mechanism, §4); the relay map and
`channel/close` translation dissolve (implicit mapping + EOF
cascade, recorded as the ADR-042 amendment's mechanism supersessions);
Pub-typed marked specs are a loud assembly error; establishment
bounds compound per hop (noted, no fix).
- **U-1 shape: combined (b) + (c).** The as-filed option (a) is
unsound as stated — the derivation runs consumer-side against a wire
JSON payload, so an allowlist "threaded from registration" has no
@@ -233,7 +250,7 @@ Design decisions pinned above the as-filed option lists:
## Unit 1 — U-2: establisher reply projection (ADR-049 amendment 3)
Land first: the relay component (Unit 3) projects a spoke's extra reply
Land first: the relay component (Unit 3a) projects a spoke's extra reply
fields through `Establishment`, so the projection must exist before the
e2e can assert `bound` flowing through a relay.
@@ -311,82 +328,153 @@ Verification gates:
flavor-form spec serializes with the explicit string and rebuilds
from it; `op/register` announced flavor-form spec round-trips with
the marker.
- Review gate 2 (hub-relay e2e) lands with Unit 3.
- Review gate 2 (hub-relay e2e) lands with Unit 3c.
## Unit 3 — the in-tree relay component (ADR-042 amendment) + gate 2 e2e
## Unit 3 — the in-tree relay component (ADR-042 amendment, ADR-051) + gate 2 e2e
The relay wrapper becomes a reusable alkcall export, composed from the
existing machinery rather than a parallel path:
Adopted 2026-09-17, design pinned in ADR-051 (`docs/architecture/
decisions/051-channel-relay-and-hub-leg.md`): the relay is the wrapper
composition (translate hop = the ADR-049 establisher shape; byte-
forward hop = `pump_bidi` per ADR-050), the ADR-042 relay map
dissolves (implicit per-channel mapping), `channel/close` needs no
translation surface (the EOF cascade propagates with correct ledger
accounting on both legs), and the registration seam is two-phase
(discover/stash → per-connection fork-register — the ADR-047 §4
amendment's fork mechanism makes a one-call import impossible without
adapter changes). The call-half support the hub/spoke family needs is
the hub-leg install template (Unit 3b) — composition of existing
pieces, not a new protocol surface. Three sub-units, three sessions'
scope:
1. New module `src/channels/relay.rs` —
`ChannelRelay::register_relay_openable(consumer_registry, spec,
producer_client)` (naming bikeshed at implementation): for a
from_call-imported spec with `channel_open` set, registers on the
hub's consumer-leg channel-0 registry via
`register_openable_with_establisher`, where:
- the **establisher** is the translate hop (ADR-042 layer 1):
`call_open_op` on the producer leg's `ChannelClient`, populating
`forwarded_for` from the consumer's per-call identity (ADR-026 §3
— the same overlay the existing forwarding handlers do),
`adopt_channel`ing the returned `spoke_id` into the producer-leg
manager, and returning `Establishment::new(plan)` whose plan
carries the spoke-leg streams; a spoke `channel:open_failed`
maps into `EstablishmentError` by reason code (the vocabularies
align 1:1); with U-2 landed, the establisher ALSO carries the
spoke reply's extra fields via `with_reply_field` — the wrapper
projects them onto the consumer reply with only `channel_id`
rewritten (wrapper-owned key).
- the **OpenHandler** is the byte-forward hop (ADR-042 layer 2):
pumps the consumer-leg channel `BiStream` ↔ the producer-leg
stream pair (the mux/demux absorbs the 8-byte header + ID rewrite
— the relay never parses chunk framing), awaited inline per the
R-02 lifetime contract; on completion it tears down BOTH legs
(the wrapper's handler-exit teardown covers the consumer leg; the
handler explicitly closes the producer leg).
### Unit 3a — `ChannelRelay` (the relay component)
New module `src/channels/relay.rs`:
1. `ChannelRelay::register_relay_openable(consumer_registry, spec,
producer_leg)` — for a from_call-imported spec with `channel_open`
set, registers on the hub's consumer-leg channel-0 fork registry
via `register_openable_with_establisher`, where:
- **`producer_leg`** is `Arc<CallConnection>` +
producer-leg `ChannelManager` (ADR-051 §2 — not a
`ChannelClient`: the hub's `CallConnection` is shared by import,
relay establisher, and hub ops, so `take_call_connection`'s
detach is wrong; the establisher builds its forwarded payload
directly — the hub as caller, the consumer as `forwarded_for`
from the per-call identity, ADR-026 §3).
- The **establisher** (the translate hop, ADR-042 layer 1): calls
the producer leg's open op with the forwarded payload, adopts the
returned spoke `channel_id` into the producer-leg manager, and
returns `Establishment::new(plan)` whose plan carries the
spoke-leg streams. The spoke reply's `channel_id` is stripped;
the reply's other fields ride `with_reply_fields` (`bound`
flows end-to-end, per-hop truthful). Failure mapping per
ADR-051 §3 (the reason table; spoke `CallError` code + message
preserved in the mapped variant).
- The **OpenHandler** (the byte-forward hop, ADR-042 layer 2):
`pump_bidi(consumer_leg_bistream, spoke_recv, spoke_send)` —
the relay never parses chunk framing (per-leg mux/demux absorbs
the 8-byte header; no ID rewrite exists — ids are
per-connection). Awaited inline (R-02); on completion both legs
tear down (the wrapper's handler-exit teardown covers the
consumer leg; the pump's drops EOF the spoke leg).
- The consumer leg keeps the full wrapper machinery: ACL,
per-identity cap/ledger, establishment bound (bounds the spoke
dial round-trip), teardown-on-failure — no parallel
authorization path.
2. `build_bundles` (`from_call.rs:128`) or a documented follow-on
helper branches on the rebuilt spec's marker: marked op → relay
openable (given a `ChannelClient` for the producer leg via
`FromCallConfig`); unmarked the plain forwarding handlers as
today. This is the "consumer branches on the marker" seam Gap C
named — now with an in-tree implementation downstream crates
compose directly.
3. ADR-042 amendment: the §"Scope note" (implementation lives in
`alknet-hub`/downstream) is revised — the relay implementation is
an alkcall export (`ChannelRelay`), hub crates compose it; the
translate-not-forward contract and the auth-model rationale are
unchanged.
4. Changelog + minor version bump (new pub API: `ChannelRelay`,
`open_channel_with_reply`, `Establishment::with_reply_field*`;
`Establishment` field addition is additive behind
`#[non_exhaustive]`).
2. Rejection postures (ADR-051 §6): a `Pub`-typed marked spec is an
assembly error at registration (`channel:pub_open_not_implemented`
class, C-08 blocker) — never a silent stub; an unmarked spec
passed to the relay registration is likewise an error (branch on
the marker before calling).
Verification gate 2 (the review's full harness): consumer → hub
(`ChannelsAdapter` on both legs + forked channel-0 registry +
`register_relay_openable`) → producer, opening `channels/tunnel/direct`
end to end: the open resolves on the consumer with the hub-allocated
`channel_id`, the establisher-contributed `bound` field (Unit 1)
survives the relay to the consumer reply, data flows both directions on
the data channel (the hub never parses it), and hub-side disconnect /
spoke-side close tears down both legs' channels. A companion
`channels/tty/sub` relay run pins no regression on standard shapes.
Verification gates:
- The establisher populates `forwarded_for` from the consumer's
per-call identity; the spoke reply's `channel_id` is stripped (the
consumer reply carries the hub-allocated id).
- A spoke `channel:open_failed` maps to the consumer-leg
`channel:open_failed` with the mapped reason and the spoke's
message preserved; consumer leg tears down (ledger decremented);
no spoke channel leaks (the adopt-then-fail window is covered).
- `bound` (Unit 1's projection) survives the relay to the consumer
reply.
- Pub-typed and unmarked specs are rejected at registration.
### Unit 3b — the hub-leg install template (the call-half support)
The hub-side composition every test hand-rolls
(`make_install_channel_zero`-shaped), exported in-tree (ADR-051 §5 —
two-way-door API shape):
1. Fork the hub's base registry (ADR-047 §4 amendment mechanism).
2. Register the generic channel ops (`ChannelOperations::register_on`)
and the bootstrap discovery ops (closed over the fork, so
`services/list` sees the fork's ops — review 004 F-06).
3. Register the stashed plain bundles as-is (the existing forwarding
handlers) and the marked specs via Unit 3a's relay registration.
The stash is the `Clone`able `Vec<HandlerRegistration>` `from_call`
returned (registrations and `HandlerKind` are `Clone`; one
discovered set serves any number of consumer legs).
4. Per-call identity resolution for the serving dispatch
(`ServingConfig`-shaped precedence: payload token → explicit
override → transport identity), then the single-stream dispatch
loop.
5. Per-consumer op-subset filtering composes here (the fork is per
consumer leg — ADR-051 §4's composition note). ACL layering is
pinned in ADR-051 §5: the spoke's `AccessControl` sees only the
hub identity; the end consumer's identity is `forwarded_for`
metadata, never checked by any `AccessControl::check`.
Verification gates:
- 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; a plain imported op round-trips through the hub
(the call-half forwarding path exercised for real).
### Unit 3c — gate 2, the review's full e2e harness
The review's verification gate 2, now with the in-tree components:
1. Consumer → hub (Unit 3b's template on the consumer leg;
`ChannelsAdapter` both legs) → producer, opening
`channels/tunnel/direct` end to end: the open resolves on the
consumer with the hub-allocated `channel_id`, the
establisher-contributed `bound` field (Unit 1) survives the relay
to the consumer reply, data flows both directions on the data
channel (the hub never parses it), and hub-side disconnect /
spoke-side close tears down both legs' channels.
2. The mid-establishment disconnect window (ADR-051 §6): consumer
leg dies between spoke open and pump spawn — the spoke channel is
reclaimed when the pumps run (EOF cascade), both ledgers
decremented, no leak.
3. A companion `channels/tty/sub` relay run pins no regression on
standard shapes.
Remaining bookkeeping for the final unit: ADR-042 amendment (landed
with ADR-051 — the §Scope-note revision and the two mechanism
supersessions), changelog + minor version bump (new pub API:
`ChannelRelay`, `register_relay_openable`, the hub-leg template;
`open_channel_with_reply` and `Establishment::with_reply_field*`
landed with Units 12).
## Sequencing and non-goals
Order: Unit 1 → Unit 2 → Unit 3 (Unit 3's e2e asserts `bound` through
the relay, so Units 12 are prerequisites). One release (minor bump),
matching the review-007 precedent. Not in scope (extends the review's
non-asks): the data-plane wire format (untouched — the relay rides
`MpscSendStream`/`MpscRecvStream` and the existing pumps), any
`channel/control` translation surface (the relay wrapper translates the
open op; close/control ride the existing generic ops per leg — a hub
wanting cross-leg control translation composes it downstream), and the
alktunnels-side work (the bind-first establisher, the listen-op spec's
`bound` documentation, ADR-007/008 implementation) which sequences
AFTER these land.
Order: Unit 1 → Unit 2 → Unit 3a → Unit 3b → Unit 3c (Unit 3a is the
component; 3b the assembly; 3c the e2e gate asserting `bound` through
the relay — Units 12 are prerequisites of 3a; 3b and 3c of each
other). One release (minor bump), matching the review-007 precedent.
Not in scope (extends the review's non-asks): the data-plane wire
format (untouched — the relay rides `MpscSendStream`/`MpscRecvStream`
and `pump_bidi`; no header rewrite exists — ids are per-connection,
ADR-051), any `channel/control` translation surface (close propagates
through the EOF cascade; control is per-leg, OQ-39 — a hub wanting
cross-leg control translation composes it downstream), the Pub-typed
open path (rejected loudly; C-08 blocker), and the alktunnels-side
work (the bind-first establisher, the listen-op spec's `bound`
documentation, ADR-007/008 implementation) which sequences AFTER these
land.
Review-008 status updates when units land: mark U-1/U-2 resolved with
commit refs (the review-007 Status pattern), and correct the U-2 line