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.
| Marker | Crate | Outbound → Inbound |
|---|---|---|
Http | capip_http | Request → Response |
Grpc | capip_http | Request → Response |
WebSocket | capi_websocket | Request → (Response, Option<UpgradedConnection>), and the browser’s message-stream twin |
Redis | capip_redis | RedisConnect → RedisSession |
Sip | capip_voip | SipConnect → SipEndpoint |
Media | capip_voip | MediaPlan → MediaSession |
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.