Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 decodeExtensionOf<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:

TargetResponse envelopeprepare_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.