Under the Hood: Transport, Context, Time, Locking, Notification, Firewall
The Five Ideas and the Request Lifecycle gave the lifecycle as a mental model. This chapter is the mechanical trace — the exact order of operations for one query, and the runtime primitives underneath — for contributors and for anyone debugging deep. Nothing here is needed to use the framework; it is the machine room.
The full trace
api.query(endpoint) unfolds as follows.
1. The scoped request converts the input. Every query entry point accepts
an IntoEndpoint; a plain endpoint converts to itself infallibly, a derived
input runs its own conversion, and a failure is RequestError::Convert. The
scoped request then hands the endpoint and its RequestOverrides to the
seated runner — DirectRunner unless a verb
seated another — through a RunnerAccess, the borrowed door. Everything below
happens inside RunnerAccess::exchange, once per dispatch.
2. ApiRequest::prepare(config, endpoint) derives the context and reads the
endpoint. The context is config.base_context().prepare(): same clock,
a forked RNG stream, a fresh ContextId, an empty extensions map, a child
notification sender. A follow-up uses prepare_with_context(parent) and
prepare_follow_up() instead. Assembly then gathers every &self-taking
value in one pass, each receiving (config, &context):
auth()— the credential declaration, resolved intoAuthSettings(one requirement per credential: the key to look up, whether a miss is tolerated, where it rides);Auth::NONEattaches nothing;query_string(config, context)— one call yields the query text and its decode recipe, so the spec riding the request can never describe bytes other than the ones it travels with;url(config, context).to_url(),headers(config, context),accept(config, context);rate_limit(config, context).to_rate_limiter()andsigner(config, context).to_signer()— the limiter and the service signer, resolved now and carried as typed fields of the plan.
Then, while the endpoint is still whole, <Endpt::Decoder>::state(&endpoint)
snapshots the decoder’s State, and method() and content_type(config, context) are read.
3. The head is assembled. A non-empty config user_agent is inserted —
it replaces any User-Agent the endpoint declared, and an endpoint-declared
one survives only when the config supplies none; Accept is set; Host is
removed — an end-to-end header the client derives from the URL; and the URL is
built with the query string. The auth and query specs are recorded on the
head’s RequestSpecs.
4. The two preparation hooks run, in a fixed order and against the head
the members will see: the required capability’s prepare_request(head, ctx)
first — protocol mechanics, the WebSocket upgrade headers — then the decoder’s
prepare_request(endpoint, config, ctx, head), which records the ResponseSpecs
the fixture tooling reads and any header the decode contract implies. Both run
before middleware, signing, or recording observe the head, so everything they
write is part of the signed and recorded request.
5. body(self, config, context) consumes the endpoint. Its ToBody
becomes a RequestBody; a PolicySigned body is signed here against the
config’s store. The body’s own content type wins over the endpoint’s (a
multipart body knows its boundary); the body spec and its runtime key classes
are recorded; body_source is derived from the stream itself — Empty,
Buffered, or Streaming — never hand-declared; and Content-Length is set
only when the body knows its exact length, so a bodiless request stays bare.
The result is a Request (head plus Bytestream), the decoder state, and the
RequestPlan — RequestPlan::resolved(rate_limiter, signing, cosigner, auth_store): everything this exchange is gated, sealed, shaped, and metered
by, with the two signing layers held apart so a per-send cosigner swap cannot
disturb the endpoint’s service signature.
6. The overrides apply, once. apply_overrides patches the head, the plan,
and the context with the caller’s RequestOverrides — headers, a substituted
limiter, with_auth/as_role, the pipeline delta, transfer stages — and
stores them on the context as a lane-typed extension, the one place the
lineage reads them from: every follow-up issued under this request applies
them freshly at its own assembly, and a credential exchange forwards the
transport half and drops the auth half.
7. send(client) runs one traversal. ApiRequest::send opens the client
door, mints the detached FollowUpChannel for the endpoint’s lane, and calls
traverse with Entry::Primary. The traversal merges the config’s pipeline
with the plan’s delta and runs the eight steps:
the four request phases, the gate (the plan’s limiter), the seal (resolution
and placement from the plan’s store, then the signer with the cosigner folded
over it), transmit (hop-by-hop validation, the Host strip again, the
capability send, the transfer stages), and the unwind. The response
envelope splits at the adapter’s return: the Response unwinds through the
members, the extension is held aside. On the final attempt’s response, before
any member transforms it, the reactive limiter is fed the head.
8. ApiResponse bundles the reply: the response head and body, the typed
extension, the config snapshot, the detached channel, the request’s context,
and the decoder State from step 2. ResponseParts is that bundle taken
apart.
9. body_decoded() runs the decoder —
<Endpt::Decoder>::decode(rsp, extension, config, context, client, state),
with the RunnerAccess as the door — turning bytes into the Output through
a Codec<Format> or laddering to an error. The runner
returns the output, the scoped request maps it through the input’s
MapOutput, and the query resolves.
capi_transport
The transport crate owns the spine: ApiRequest (assembly, overrides, send),
ApiResponse and ResponseParts, ScopedRequest — the per-call override
handle api.scoped() opens, onto which the runner verbs and the budget knobs
are installed — and the Runner/RunnerAccess/Dispatcher vocabulary. It
owns the Traversal with its three entries (new, credential_exchange,
capture), the seal, and the three ways back in: FollowUp (the scope a door
opens), Finalize with PackagedRequest and PackagedResponse (the capture
surface), and PrepareInContext. RequestOverrides and RequestPlan are
capi_core’s and re-exported here. The capability plane
chapter covers the doors and the capture surface as a user would meet them.
capi_middleware
The member-facing half of the traversal vocabulary: Pipeline, the four
Phases, the three member traits with their RequestFlow/ResponseFlow
verbs, MemberId, MwError, LaneId, Transmit, TransportError, and the
Body view a member shapes a body’s identity and dressing through. Members are
non-blocking, bounded, and plan-time; a Resend resumes the request walk at
the resending member’s own seat, which is why a member that must act on every
wire exchange seats after a re-sender and one that must act once per operation
seats before it. capi_transport owns the traversal that drives them.
capi_http_types
The bottom-of-graph vocabulary every transport compiles against: Method,
StatusCode, Version, the header types and constants, RequestHead /
ResponseHead / Request / Response, TrailerCell / TrailerState, the
Invalid* errors, and validate. A request target is a url::Url,
re-exported; the builder that applies the framework’s construction rules is
capi_url, which this crate does not depend on — a transport reads an address
and never builds one. Framework metadata about a request is not here: it
rides the head’s Extensions, one entry per type, owned by the crate that
reads it — capi_core::specs::RequestSpecs (the auth settings, the body and
query specs, the response recipe, body_source), capi_authentication‘s
credential-placement record, capi_base_decoders’ event-stream framing, a
WebSocket session’s message schema. Adding a fact never touches the vocabulary.
The text module, behind text-format, is the canonical head rendering the
fixtures and capi_debug_dump share.
capi_context
ApiContext is the execution state threaded through the whole pipeline: an
ApiClock, an ApiRng, an optional ContextId, an optional notification
sender, a typed extensions map, and the request’s budget when one is set —
an absolute deadline() minted by the context’s own clock, a per-wait
stall() bound, and a cancel_token(). The budget is read by whatever
enforces it, which is the adapter performing the exchange; an adapter that
cannot enforce a knob refuses the send with a typed Refusal rather than
dropping it silently. A token set on a config’s base context binds every
request derived from it; scoped().with_cancel(token) is the per-request form.
| Property | prepare() (consumes) | prepare_follow_up() (borrows) |
|---|---|---|
| Clock | inherited | inherited |
| RNG | forked | forked |
ContextId | fresh | fresh |
| Extensions | fresh, empty | shared with the parent |
| Notifications | child sender, bubbling to the base | inherited lineage |
| Budget | carried across | carried across — the deadline keeps binding the pages, cancelling the parent reaches them |
ApiRng is a facade over a pluggable RngProvider. A ContextId is drawn
from it and nothing else: a context whose source cannot answer carries no ID
rather than a derived stand-in, because a value that only looks random is worse
than one that is absent. Injecting a clock and RNG is what makes
replay tests and PKCE
deterministic.
capi_time and capi_lock
Two small primitive crates absorb the platform difference:
capi_time—Instantfor elapsed time and deadlines,SystemTimefor epoch timestamps, andApiClock, a clonable facade over aClockProviderwithnowandsleep. The backend is compile-time: the standard library withfutures-timerunderstdon native and WASI; the JS host clocks and gloo timers underjson browser wasm (included bystd, the platform switch there);embassy-timein embedded builds; and a stub — monotonic time fixed at zero, no wall clock, no meaningful sleep — when no backend is selected.capi_lock—SyncMutex,SyncRwLock,SharedCell,SyncCondvar(understd), andAsyncMutex/AsyncRwLock(underasync):parking_loton nativestd,spineverywhere else including browser wasm,async-lockfor the async family. No lock poisoning by design, and a spin backend means contended acquisition burns CPU — keep critical sections short.
capi_notification, capi_firewall, capi_debug_dump
capi_notification— a typed broadcast wired intoApiContext. ANotificationSender(attached withwith_notifications) fans structured events — aNotificationwrapping aNotificationKind, the built-inLogNotification, the transport’sTraversalEvent, a resumable stream’sReconnectNotification, a transfer stage’sProgressNotification— to everyNotificationReceiver. Delivery is non-blocking, disconnected receivers are pruned, andctx.notify(..)with no sender attached costs nothing. Senders propagate throughprepare/prepare_follow_up, so a base context’s children all publish into one stream.capi_firewall— the opt-in egress allow-list:Firewall::new(client, policy)wraps any client and checks anEgressPolicyagainst each send’s URL before delegating, opaque to everything above it and forwarding every lane of the client it wraps. The capability plane chapter has the policy, the modes, and what the firewall does not see.capi_debug_dump— the same wrapper shape for development:DebugClientwrites every exchange that passes through it in the canonical text format, unredacted.
The error model in full is next — the exhaustive reference behind the error-modeling tutorial.