The Capability Plane: Lanes, Extensions, and Transport Vocabulary
Every endpoint names a lane — Requires = Http on an ordinary call,
Requires = WebSocket on a socket, Requires = Grpc
on a gRPC call — and a client is seated for each lane it
can perform by an ApiClient impl beside a Speaks<Cap> one. This chapter is
that plane end to end: how a lane is declared, how the compiler matches
endpoints to clients, what a lane delivers to a decoder beyond the response,
and the two ways back into the traversal — the door a decoder follows up
through, and the capture surface that
produces a request without sending it. It is a chapter for authors of transports,
wrappers, and protocol add-ons; an everyday endpoint never touches it directly.
Capability and Supports
A capability is a zero-sized marker for a transport shape — a protocol seated on the HTTP request pipeline — and the trait on it says what one exchange of that shape produces and what the pipeline owes it:
pub trait Capability: Protocol<Outbound = Request, Inbound = Self::Response> {
type Response: ResponseEnvelope; // the plain Response, or (Response, extension)
fn prepare_request(head: &mut RequestHead, ctx: &ApiContext) {} // protocol setup; default no-op
fn follow_up_channel<Client: Supports<Self>>(client: Client) -> FollowUpChannel<Self>;
}
The supertrait is where the request envelope is fixed. Every capability is
seated on the pipeline, and the pipeline hands it a Request — so a capability
declares only its response envelope, and a bare Cap: Capability bound is
already proof that Cap’s outbound message is the framework Request. The
floor’s Protocol is the more general
contract underneath: a marker binding an Outbound to an Inbound, with no
pipeline in the picture at all.
prepare_request runs during request assembly, before any pipeline member, the
seal, or a fixture recorder sees the head, so what it writes is part of the
signed and recorded request. It carries capability-wide mechanics only — the
WebSocket marker writes its upgrade headers here — while request facts that
depend on the endpoint belong to the decoder’s own preparation hook
(the decoder contract).
follow_up_channel is the lane’s answer to “can a decoder send again through
you?”, covered below.
A client advertises a lane with two impls, and the second is where the one send in the system lives:
impl ApiClient for MyClient {}
impl Speaks<Http> for MyClient {
type Error = MyClientError;
fn capi_send(&self, ctx: ApiContext, request: Request)
-> Result<Response, SendError<Self::Error>>; // async: impl Future<..> + MaybeSend
}
The seat the pipeline bounds on follows from them:
pub trait Supports<Cap: Capability>: ApiClient + Speaks<Cap> {}
impl<Cap: Capability, Client> Supports<Cap> for Client where Client: ApiClient + Speaks<Cap> {}
The seat has no method of its own, and nothing implements it directly — a
hand-written impl Supports<Cap> for MyClient overlaps the blanket and is
refused, which is what makes “a client is a client that speaks the lane” true by
type rather than by convention. The error is the Speaks impl’s, one per client
per protocol; a client speaking several conventionally reports them all through
one error type, and a consumer bounded on two names the one it means with
<Client as Speaks<Cap>>::Error. There is no implication hierarchy between
lanes: providing Http says nothing about Grpc, and a gRPC-only adapter that
speaks neither plain REST nor WebSocket declares only the marker it holds.
The two meet at the query funnel. Every query entry point is bound
Client: Supports<Endpt::Requires>, so handing a plain-HTTP client a WebSocket
endpoint is a compile error at the .query() call site — and a curated one: the
trait carries a #[diagnostic::on_unimplemented] message naming the client and
the lane, and pointing at the adapter feature that would enable it
(the client capability table).
The envelope and the typed extension
The subtle part of the plane is how a lane delivers more than a response to
its decoder without widening the common path. The traversal’s members are
Response-typed — the lane rides to the wire, not through the phases — so the
lane’s Response envelope must split:
pub trait ResponseEnvelope {
type Extension: Default + MaybeSend + 'static;
fn into_parts(self) -> (Response, Self::Extension);
fn from_parts(response: Response, extension: Self::Extension) -> Self;
}
Two shapes ship and cover the vocabulary. The plain Response is the
response-shaped envelope, Extension = (), for a lane whose exchange yields
nothing beyond the response. The pair (Response, Ext) is the extended
envelope, for a lane with an out-of-band deliverable. A lane names whichever
fits, and the split and merge come with the type; a protocol whose envelope is
its own struct implements the trait directly and gets the same machinery.
At the adapter’s return the traversal calls into_parts, unwinds the response
half through the members, and holds the extension aside; the decoder then
receives it by value, exactly once, as the extension parameter of its
decode — ExtensionOf<Cap> is the alias signatures use. The Default bound
is what an exchange yields when a member answers without the wire being reached:
a cache hit under Http yields (), under WebSocket an absent connection,
which reads as a refused upgrade rather than an error at the seam.
The shipped lanes
Http (capip_http) is the plain exchange: Request in, Response
out, no preparation.
Grpc (capip_http) is response-shaped too — the status trailers
ride the response head in its TrailerCell — but the marker promises more than a
shape. A client declaring it holds an exchange whose request body is read lazily
and never drained (a client-streaming source is never materialized whole);
whose te: trailers is the adapter’s own, an application-set one refused; whose
response head carries a trailer cell the adapter fills at body EOF, so
grpc-status is always readable; whose grpc-timeout is derived from the
context deadline through Grpc::timeout_header_value, never from the endpoint;
and which is half-duplex — the response is returned once the request reaches
EOF or the server stops the upload. The transport realization is the adapter’s:
native HTTP/2, or a complete gRPC-Web mapping in the browser. A client that
cannot surface trailers cannot declare the marker truthfully.
WebSocket (capi_websocket) is the extended lane, and it lives in its own
crate because its envelope names that crate’s connection types. The extension is
Option-wrapped, because a server may refuse the upgrade with an ordinary
response — a 401 challenge, a redirect — that decoders and members still need
to see: a 101 Switching Protocols comes back as Some, anything else as None
with the decoder reporting the failed handshake from the response itself. The
envelope differs by platform, which is the one place the plane is
target-dependent:
| Target | Response envelope | prepare_request |
|---|---|---|
| native and WASI | (Response, Option<UpgradedConnection>) | generates a WsKey from the context’s RNG and writes Connection, Upgrade, Sec-WebSocket-Version, Sec-WebSocket-Key; the adapter validates Sec-WebSocket-Accept against it |
| browser wasm | (Response, Option<Box<dyn WsMessageStream>>) | none — the browser owns the handshake and the framing |
A WebSocket endpoint’s code is the same on both; only the lane’s envelope
changes underneath it.
The door: sending again from inside a decode
A decoder that needs another exchange to finish — the next page, a form
submission, a resumed stream — is not handed a client. It is handed a door,
Access: FollowUpAccess<Endpt::Requires>, captured on the endpoint’s own lane.
FollowUpAccess has two methods, the lane send and detach(), and a hidden
marker supertrait: the set of doors is the framework’s, and an exchange composed
anywhere else is not expressible. What an implementor supplies is the wire and
only the wire — every invariant that must hold on a transmitted request lives
inside the traversal, where no re-entry path can rebuild it.
The doors are two kinds. A borrowed door holds a concrete client — the one
the interface named, or the one a runner was handed — so its
error type stays concrete all the way to the query boundary. The detached
door is a FollowUpChannel: the owned, 'static, erased form detach()
returns, which an output that outlives the decode call (a page iterator, a
resumable stream) carries with it. The channel implements neither ApiClient
nor Supports, so nothing can seat it where a client belongs; its one inherent
operation is a bare send, and the way to use it is the scope.
The scope
FollowUp — imported explicitly as capi_transport::FollowUp (or
capi_rs::transport::FollowUp); it rides no prelude, because the import
is the “this is unusual” signal — opens a scope on any door:
use capi_transport::FollowUp;
let page = client.follow_up(config.clone(), &context).fetch_page(next)?; // a paged continuation
let output = client.follow_up(config.clone(), &context).query(endpoint)?; // decoded through its own decoder
let rsp = client.follow_up(config.clone(), &context).exchange(endpoint)?; // the undecoded ApiResponse
Each runs one full traversal for the follow-up endpoint: prepared against the
parent context, planned from this endpoint’s own rate limiter and signing, and
traversed over the config’s pipeline through the door’s captured lane. The
parent ApiContext is a required parameter — the follow-up derives its context
from it, inheriting the budget (deadline, stall bound, cancel token) and the
lineage (extensions, notification sender) while taking a ContextId of its own.
A primary call site has no parent context to supply, which is the deterrent
against routing a primary request through a door. The caller’s
RequestOverrides ride the context too, and every follow-up applies them
freshly to its own assembled request, exactly once.
exchange is the custom-decode seam: the ApiResponse it returns carries the
follow-up’s own derived context and the door’s detached form, so decoding it can
issue further follow-ups — the HTML form flow submits through
it. without_auth_overrides() on the scope opens it as a credential exchange:
the transport overrides forward, the auth overrides drop, and the traversal runs
as a mint (below).
The scope is typed by the door’s lane, and its methods accept only endpoints
requiring exactly that lane — a Grpc endpoint through an Http door is a
compile error at the call site. They also exist only when the lane is
response-shaped, Cap: Capability<Response = Response>. A door for an extended
lane can be held and stored but has no queryable surface at all, which is the
honest answer to “send a WebSocket upgrade through a channel that returns a
Response”: the connection would have to be discarded.
follow_up_channel and the honest choice
That is what a lane’s follow_up_channel decides. A response-shaped lane returns
FollowUpChannel::with_capability(client), which captures the client’s send
behind an Arc and a type-erased closure — the Supports<Cap> bound is proved
once, at the site that knows the concrete client, which is why the channel needs
none. The construction bound is Cap: Capability<Response = Response>, so an
extended lane physically cannot take it; it returns
FollowUpChannel::unsupported(), the empty capture that refuses every send with
an error naming the lane rather than performing a lossy exchange. In practice
that channel is never reached — the decoders that follow up are all on
response-shaped lanes — but the refusal is typed and loud, not silent.
The three entries
A traversal runs one of three ways, chosen by constructor so no caller can spell
an entry the framework does not offer. Traversal::new is the ordinary exchange:
every phase, the gate, the seal, the wire, the unwind. Traversal::credential_exchange
is a mint — the phases and the transmit invariants run exactly as for any
exchange, but the seal places whatever the store holds without renewing it, and
the gate is skipped, since a config’s limiter models the API service rather than
the identity provider and queueing a mint behind the requests waiting for it can
deadlock freshness. Traversal::capture is the shape of an exchange without the
exchange: the phases, the seal, and the transmit invariants, no gate (a limiter
meters real exchanges), and no response, so no unwind. The scope picks the first
two; the capture surface is the third.
The capture surface: a request without a send
Some carriers put a request inside another message — a frame on an upgraded
connection, a queue payload, a part of a batch envelope. They need the exact
wire-form request a solo send would have produced, and no send. Finalize is
that, sealed to ApiRequest and, like FollowUp, imported explicitly:
use capi_transport::{Finalize, PackagedRequest};
let mut prepared = ApiRequest::prepare(config.clone(), endpoint)?;
prepared.apply_overrides(&overrides);
let request = prepared.finalize()?; // the wire-form Request, body stream still live
let packaged = PackagedRequest::drain(request)?; // head + owned bytes, for the carrier's own message
finalize runs everything short of transport: the request phases, credential
placement, service signing and cosigning, hop-by-hop validation, the Host
strip. FinalizeError names what stopped it — Prepare, Middleware, or
ShortCircuited { at } when a member answered the request, a cache hit say,
which at a capture site is a programming error rather than a service condition:
keep answering members off any pipeline a captured request is finalized against.
The one-step sugars config.finalize_request(endpoint) and
finalize_request_with(endpoint, &overrides) fuse preparation and finalization,
and a carrier that needs nothing but the finished request takes either.
A PackagedRequest is the drained form: the finalized head whole — method,
URL, headers, and the framework’s request specs, so masking vocabulary and the
credential classification stay reachable — plus the body as owned bytes.
drain refuses a live stream with DrainError::StreamingBody rather than
buffering something unbounded to fit it in a frame. There is no Option on
the body: an absent body is head.specs().body_source == BodySource::Empty, and
which of “empty” and “absent” a carrier’s message spells is the carrier’s
decision.
A PackagedResponse is what a carrier lifts an answer into: the head it can
assemble from its own message — a status, the headers that message carried —
plus the answer’s bytes. That pair is exactly what an endpoint’s body decoder
reads, beside the config and the context the carrier supplies. Decoding one is
decode fidelity, not pipeline fidelity: response members, transfer stages, and
client wrappers observe an unwind, and none ran.
Which context decodes a packaged reply is one rule, stated in
capi_transport::capture’s module docs: a follow-up of the carrier’s own
exchange context — the envelope send for a batch, the upgrade for a socket — so
the decode inherits that exchange’s deadline, stall bound, cancellation,
extensions, and notification lineage.
Splice or drain is settled by what the carrier assembles: one that splices a
live body stream into its own message keeps the finalized Request and never
drains — the batch envelope is that carrier — and PackagedRequest is the
drained form for one that needs owned bytes inside its message.
Three things a captured request is not. It is not metered: the gate meters egress, a packaged request may be built and never sent or sent twice, and what leaves the process is a message on the carrier’s channel, metered by the carrier’s own limiter. It is not firewall-checked: the policy sees the request that opened the carrier — the upgrade URL, the envelope’s URL — and a carrier whose tunneled targets go elsewhere is outside the firewall’s contract. And it is not masked at the carrier’s layer: the packaged request keeps the head’s classes, but the carrier’s own message has no structural mask. The WebSocket session and the batch envelope are the two shipped carriers.
Wrappers
A wrapper is a client that holds a client. The framework forwards lanes through
Arc<Client> and Box<Client> with one blanket each — an Arc<Client> supports
exactly what Client supports, no per-lane maintenance — and its two shipped
wrappers follow the same shape.
Firewall<Client> (capi_firewall) checks every send’s destination against
an EgressPolicy before the wrapped client sees it:
let policy = EgressPolicy::allow(["*.parcel.example"])? // exact hosts, or *.apex at label boundaries
.require_https(true) // default: any scheme
.mode(Mode::Enforce); // default; ReportOnly logs what it would block
let api = ParcelApi::new(Firewall::new(reqwest_client, policy));
Its Speaks<Proto> impl is generic over every protocol whose Outbound is the
framework Request — the bound is load-bearing, because the check reads the
head’s URL and an opaque message it could not inspect would pass unchecked — and
the inner client is unreachable by construction: no accessor, a private field,
and a Debug that omits it. The opacity survives erasure. When a lane mints a
follow-up channel, the channel captures the seated client’s send — the
firewalled one — and exposes no way back, so a decoder’s next page is checked by
the same policy. It forwards the budget markers (TimeoutCapable, StallCapable,
CancelCapable) of the client it wraps, so a with_timeout does not die at the
boundary. Wrap innermost, in your own code, and disable the transport’s native
redirect following: a 3xx the transport followed internally would be fetched
unchecked, while the framework’s redirect runner re-enters the wrapped client on
every hop.
DebugClient<Client> (capi_debug_dump) is the same shape for a different
purpose: it writes down every exchange that passes through it — request head,
response head, trailer block, as much of each body as asked — in the canonical
text format the test fixtures use, unredacted, credentials included. It is a
development tool; treat a dump as the secret it contains.
The taxonomy
The capability plane sits alongside four kinds of request-shaping machinery, and knowing which is which says where a new concern belongs:
- Planes — the rate-limit gate and the seal (resolution, placement, signing). Fixed, config-fed, always in the same place in the traversal.
- Pipeline members — optional, phase-seated, run once per attempt (Middleware).
- Runners — endpoint-owning re-dispatchers above the decoder (Runners).
- Adapters and wrappers — the wire, and the clients that hold clients: opaque to everything above them, checked or recorded per send.
One primitive threads through all four: capi_notification’s typed broadcast
(NotificationSender / NotificationReceiver, LogNotification), wired into
ApiContext so a subsystem can emit a structured event — a traversal step, a
stream’s reconnect, a transfer stage’s progress — that bubbles up through derived
contexts to whoever subscribed on the base one.
Two checklists
Adding a lane: declare a unit-struct marker and implement Protocol and
Capability for it; name the envelope — the plain Response, or
(Response, extension) for an out-of-band deliverable; write prepare_request
if the wire needs a handshake;
return FollowUpChannel::with_capability from follow_up_channel for a
response-shaped lane and FollowUpChannel::unsupported for an extended one;
seat the true adapters with Speaks<YourLane>, validating the handshake where
it is performed; and have a decoder receive the typed extension.
Adding a pipeline member: implement RequestMiddleware,
ResponseMiddleware, or ExchangeMiddleware; seat it in a phase from the
config’s middleware; and override serves_lane only if the member is genuinely
wrong on some lane. A concern that needs to send again is a runner, and a
concern that needs to send from inside a decode goes through the door.
This is the machinery under “write the endpoints, ship everywhere”: the type system, not runtime dispatch, guarantees an endpoint only runs on a transport that can perform it. The part closes with the outside view — consuming and patching a client.