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 Protocol Core and the Two Planes

Everything so far has been HTTP’s: endpoints, a request pipeline, decoders, an interface. Underneath all of it sits something smaller that knows no wire at all — capi_protocol, the protocol-generic core — and beside HTTP stand the family’s other protocols, which use that same core and never meet a pipeline. This chapter is the floor: what the core names, what stands on it, where the message plane meets the transport plane, and the rules that keep one protocol’s vocabulary out of another’s.

It is a chapter for protocol, transport, and wrapper authors; writing endpoints against a service needs none of it. Reading it once is still worth the time, because it is what Requires = Http means when it says Http.

What the core names

Two traits and a marker, and they fit on one screen:

pub trait Protocol: MaybeSend + MaybeSync + 'static {
    type Outbound: MaybeSend;                  // what one exchange consumes
    type Inbound: MaybeSend;                   // what it produces
}

pub trait Speaks<Proto: Protocol> {
    type Error: core::error::Error + Send + Sync + 'static;

    fn capi_send(&self, ctx: ApiContext, outbound: Proto::Outbound)
        -> Result<Proto::Inbound, SendError<Self::Error>>;   // async: impl Future<..> + MaybeSend
}

pub trait ApiClient: Clone + MaybeSend + MaybeSync + 'static {}

A protocol is a marker binding two types and stopping there — no method, no configuration, no way to observe a message on its way past. Neither type is called Request or Response, because an exchange that dials, authenticates, and hands back a live session is as ordinary here as one that carries a single message, and the names have to fit both.

A client that can perform the exchange implements Speaks<Proto> for the marker: one impl per protocol, naming the failures this client reports for that protocol and carrying the send. The error belongs here rather than on the client because the failures do — a name that would not resolve, a handshake that failed, a pool that emptied, a background reactor that died — and no protocol vocabulary describes those. By convention a client reports every protocol it speaks through one error type; a consumer bounded on two protocols names the one it means, <Client as Speaks<Proto>>::Error.

ApiClient is the capability-free marker: a handle that can be cloned into a caller, held as long as the caller lives, and moved wherever the platform allows. No method, no error, no claim about any protocol. It is what a consumer bounds on when it wants a client to hold rather than a client to send through — an interface generic over its backend, a wrapper storing one, a builder taking one.

Beside the three sit the budget markers (TimeoutCapable, StallCapable, CancelCapable, and what each claims), the StdError bound, Duplex, and re-exports of the transport vocabulary as capi_protocol::carrier, the execution vocabulary as capi_protocol::context, and the platform bounds MaybeSend / MaybeSync. The Arc<Client> and Box<Client> pass-throughs live in the core as well — one Speaks impl each, covering every protocol at once — so no protocol crate ever mints a rival.

graph TD
    core["capi_protocol — Protocol, Speaks, ApiClient, Duplex, the budget markers, the carrier module"]
    context["capi_context — ApiContext, ContextId, CancelToken, SendError"]
    core --> context
    http["capip_http — the Http marker and the capability plane"] --> core
    framework["capi_core → capi_transport → capi_rs"] --> http
    engines["capi_engine → capip_http1 / capip_http2 / capip_sip / capip_stun / capip_rtp"] --> core
    protocols["capip_redis, capip_voip — markers and the sessions they yield"] --> core
    protocols --> engines
    adapters["capic_* clients"] --> core
    adapters --> http

The two crates at the top are the core: capi_context is its root, and capi_protocol is the crate with the contract in it and the transport vocabulary as its carrier module. Everything carrying a wire’s semantics stands above — the sans-io engines, the protocol crates they drive, the HTTP framework, and the clients that speak one protocol or several over a real transport.

The independent libraries underneath

Below the core sit ordinary Rust libraries that name no protocol and no client, and that a project can use without Capi at all: capi_bytestream (the body stream, and the MaybeSend / MaybeSync bounds), capi_lock (the mutex, cell, and Arc aliases), capi_time (the injectable clock), capi_notification (the typed broadcast a context carries), and capi_feature_state (the alignment guard). The dependency runs one way: the core reaches all five, and none of them reaches the core.

capi_wire_model is the sixth of that class and the one the core never touches. The wire model enters above, where HTTP’s bodies are — a protocol that defines its own commands has its own typed vocabulary and no use for a codec.

capi_feature_state is a special case worth naming: it holds no type and no trait. It exists only because the family’s use of the async and single-threaded axes is non-standard, and nothing that should outlive it may live there.

The two planes, and where they meet

There are two vocabularies at the floor, and they are deliberately separate.

The message plane is Protocol and Speaks: one exchange, typed at both ends, with no statement about how bytes get anywhere.

The transport plane is capi_protocol::carrier: the duplex byte boundary a protocol runs over. It divides the citizens by who waits and how the caller learns the carrier is ready — Carrier blocks, AsyncCarrier returns futures, PollDuplex never waits and never wakes, WakeCarrier never waits but does wake (which is why it is the erasure target). Dial and AsyncDial produce a connected carrier, and the datagram family draws the same division for message-boundary transports. Nothing in Carrier says “network”: a TCP stream, a TLS session, a pipe, a subprocess’s stdio, an in-memory channel, and another protocol’s session are all carriers.

The planes meet in exactly two places.

Duplex is the first, and it is the one type that belongs to both. It erases any carrier behind a single concrete type, so a protocol can declare a duplex as its Outbound (“run over this” — the client negotiates over the transport it was handed instead of dialing) or hand one back as its Inbound (session-as-transport — the session is a pipe another protocol can run over). Those two conventions are how layered protocols compose, and they are the reason no session trait exists at the floor: sessions stay bespoke types in their own crates. Duplex lives in capi_protocol because it needs both vocabularies plus the platform axis, which the core has and a root does not.

The engine-backed client is the second. capic_native_client’s impl Speaks<Redis> for NativeClient dials a carrier, hands capip_redis’s handshake to its engine, pumps the resulting conversation with an capi_engine driver, checks each negotiation reply, disarms the exchange budget, and hands the carrier over to the RedisSession. The client is the composition of carrier, engine, and protocol, and nothing else; the protocol knowledge stays in capip_redis and the I/O stays in the client. A transport over a third-party HTTP stack meets the carrier plane only if it hands out a transport at all.

Two platform styles at the foundation

The feature axes are not carried uniformly down here, and that is deliberate.

The Capi framework’s root crates carry the axis and name one shape per build. Duplex is one name whose definition is cfg-twinned: on the blocking lane its surface is Carrier’s write_all/read, and on the async lane (and browser wasm, where the async surface is target-implied) it is WakeCarrier’s waker-poll shape. capi_lock names its async locks only where the async lane exists. And capi_protocol and capi_context both carry assert_feature_alignment!("async", "single-threaded"), so a misaligned graph fails at compile time rather than at a trait bound.

capi_engine declares no cargo features and names every shape unconditionally: Carrier and AsyncCarrier both exist in every build, and the drivers are per-lane. A conversation is written once and every lane runs it.

The carrier vocabulary follows the engine’s style: every shape named, nothing gated on a feature or an axis. capi_engine stands on capi_protocol with its default features off and re-exports the traits from capi_protocol::carrier at the paths its engines use — an axis-indifferent crate over an axis-carrying one, the shape the manifest lint permits because the crates that declare an axis forward it to capi_protocol themselves. Feature choices made above the engines therefore reach the core directly, never through them.

Where a marker lives

The placement rule is one line: a protocol’s marker lives in the lowest crate that can name both its Outbound and its Inbound. Follow it and a contract author for one wire never acquires another wire’s types transitively.

MarkerCrateOutboundInbound
Httpcapip_httpRequestResponse
Grpccapip_httpRequestResponse
WebSocketcapi_websocketRequest(Response, Option<UpgradedConnection>), and the browser’s message-stream twin
Rediscapip_redisRedisConnectRedisSession
Sipcapip_voipSipConnectSipEndpoint
Mediacapip_voipMediaPlanMediaSession

WebSocket is the clearest case: its Inbound names the upgraded-connection type capi_websocket owns, so the marker can live nowhere lower. Redis names capip_redis’s own connect facts and session, and Sip and Media name capip_voip’s.

The same rule explains the capip_ prefix, which marks the protocol class rather than one crate shape. capip_http1 and capip_http2 are engines with no marker; capip_http is a marker and its capability plane with no engine; and capip_redis and capip_voip are both at once — each a marker beside the machinery that performs its exchange. What they share is the wire, not the shape.

And the seat is one impl per protocol per client: NativeClient writes Speaks<Http>, Speaks<Grpc>, Speaks<WebSocket>, Speaks<Redis>, Speaks<Sip>, and Speaks<Media> — each in its own module, all but the first behind the adapter feature that enables it, and every one of them naming NativeClientError as the error it reports.

No implication hierarchy

Capabilities are orthogonal. Providing Http says nothing about Grpc or WebSocket, and a gRPC-only adapter that speaks neither plain REST nor a socket upgrade declares only the marker it holds. That invariant’s home is the Http and Grpc doc blocks in capip_http, beside the markers it governs — it is a statement about the HTTP capability plane, where the temptation to arrange a hierarchy would arise.

The core has nothing to imply. Speaks<Redis> and Speaks<Http> are two impls of one trait, and there is no supertrait anywhere that would let holding either one drag the other in. The seat the pipeline bounds on, Supports<Cap>, is derived from ApiClient + Speaks<Cap> by a single blanket impl in capip_http; the whole mechanism is the capability plane.

Sessions for protocols, an interface for HTTP

A protocol whose exchange produces something live produces a session. RedisSession, SipEndpoint, and MediaSession are each built from connect facts, a client, and one exchange — and retain neither the facts nor the client afterwards. Each owns its carrier and keeps running after the exchange returns, which is why per-operation governance from there belongs to the session’s own API rather than to the exchange’s budget.

HTTP’s exchange produces no session, so its long-lived handle is an interface: BaseInterface is a config and a client and nothing else, connectionless by construction.

Stated as a rule: a protocol gets a session, HTTP gets an interface, and nothing is minted to unify the two shapes. They differ in what they are built from, in what they retain, and in when they connect; a common trait over them would have to erase all three to say anything at all.

Connect timing

The floor’s exchange connects when it runs. RedisConnect::to(..).open(&client, ctx) dials, sends the connect sequence, reads each reply, and hands back a negotiated session — all inside one capi_send. The context governs the dial and the negotiation, and is disarmed before the carrier goes to the session: an Inbound that owns a carrier outlives the exchange, so the exchange’s budget must not.

HTTP connects nowhere in particular. BaseInterface::new(config, client) opens no socket; every query hands the adapter a Request, and whether that means a new connection or a pooled one is the adapter’s business and invisible above it.

The four call sites

Four shapes cover everything the family does. The async lane is shown; the blocking lane is the same code without .await, because every capi_send is cfg-twinned at its definition.

// 1. A protocol with a session — connect facts, opened against a client,
//    then talked through. The dial and the negotiation happen at `open`.
let client = NativeClient::new();
let mut session = RedisConnect::to("cache.internal:6379")
    .auth(password)
    .open(&client, ApiContext::default())
    .await?;
session.set("k", "v").await?;
let hit: Option<Vec<u8>> = session.get("k").await?;
// 2. The HTTP pipeline — what every client crate in this book builds.
let api = ParcelApi::new_with_defaults(ReqwestClient::default());
api.authenticate("sk-live-…");
let shipment = api.query(GetShipment::new("shp_123")).await?;
// underneath: query → the traversal → transmit → Speaks<Http>::capi_send
// 3. Plain HTTP with no endpoint machinery — one message move.
let response: Response = client.capi_send(ApiContext::default(), request).await?;

// and its erased form, which the framework already ships:
let channel = FollowUpChannel::new(client);
let response = channel.capi_send(ctx, request).await?;
// 4. A socket session at the floor — same shape as 1, different protocol.
let mut endpoint = SipConnect::to("sip.example:5060", config, seed)
    .open(&client, ApiContext::default())
    .await?;
let expires = endpoint.register().await?;
match endpoint.next_event().await? {
    SipEvent::IncomingCall { call, .. } => {
        endpoint.answer(call, StatusCode::RINGING, None).await?;
    }
    _ => {}
}

A WebSocket is the interesting non-member of that list: it is a socket session, but it keeps riding query, because the upgrade is an HTTP request the seal, the middleware, the firewall, and the recorder must all see (WebSocket: Connections and Sessions).

Two asymmetries, documented rather than hidden

Put the first and second call sites side by side and two differences stand out. Both are real, and neither is worth papering over with a shared signature.

What the call takes. The floor’s open consumes the connect facts, borrows the client, and takes an explicit ApiContext. Each of those has a reason: the facts are consumed by the machine (a handshake holds the credentials, and the machine needs exactly one of them), Speaks has no Clone bound, and the context is the dial’s budget. The pipeline’s construction takes its config and its client by value, because an interface keeps both for its whole life.

Where the context comes from. At the floor the caller supplies one per exchange, and it governs that exchange and nothing else. In the pipeline nobody writes a context at a call site: ApiRequest::prepare derives one with config.base_context().prepare(), so every request gets its own ContextId and its own forked RNG without the caller naming either.

Both asymmetries follow from the exchanges being genuinely different — one produces a live session, the other a decoded value — which is exactly the difference the two shapes exist to carry.

With the floor in place, Features and Targets finishes the Foundations runway: which axes exist, what picks a platform, and how the lanes are tested.