ADR 0005: RPC Binding for transport rpc Schemas
Status
Accepted. HTTP surface shipped onmain across PRs #20–#24, slated for the next minor release.
Date
2026-05-15Context
The original core architecture ADR committed to “HTTP REST only” + “No RPC transport in v0” — see its “RPC binding addendum” for the supersession note. That position served the bootstrap slice well, but as the framework grew it left three real gaps that REST does not address cleanly:- Batching. Doing N writes in one round-trip without re-implementing per-route batch shapes for every model.
- Streaming. A clean way to ask “give me this list as it’s produced” rather than buffer-then-return.
- Subscriptions (future). Long-lived push channels for
ModelEvent<X>fan-out.
.cstack schema. The macro emits exactly one binding’s worth of routes, descriptors, and client surface — there is no runtime flip and no schema runs both. A schema’s transport directive picks the style up-front; everything else (auth, codecs, policy, idempotency, models, procedures, audit) is unchanged.
This ADR captures the decisions made implementing that, in the order they had to be settled.
Decision
Generation style is a schema-level choice
.cstack schemas declare their binding via a top-level directive:
rest when omitted (back-compat with everything written before the directive existed). The parser surfaces it on Schema.transport: TransportStyle. The macro branches on it at emission time:
transport rest→ existing REST router (per-model/users,/users/{id}, etc., per-procedure/$procs/<name>).transport rpc→rpc_routermountingPOST /rpc/{op_id}+POST /rpc/batch.
ROUTE_TRANSPORTS: &[RouteTransportDescriptor] or OPS: &[OpDescriptor] is non-empty for any given schema; the other is emitted empty so downstream code can introspect uniformly. Client codegen branches on TRANSPORT_STYLE so generated SDKs ship one client’s worth of code, not both.
Rejected alternative. Mount both bindings on the same schema so users get REST and RPC for free. Rejected because the two bindings have different semantic contracts for errors (status codes vs RpcErrorBody), idempotency (header vs per-frame field), and cancellation (close conn vs Cancel frame). Trying to keep both consistent in one runtime invites subtle “works on REST, breaks on RPC” bugs and forces every client SDK to ship dual code paths.
Op identity in the URL, not the body
Every callable in an RPC schema gets a stable dotted id:| Schema construct | Op id | Kind |
|---|---|---|
model User { ... } | model.User.list | Unary |
model User { ... } | model.User.get | Unary |
model User { ... } | model.User.create | Unary |
model User { ... } | model.User.update | Unary |
model User { ... } | model.User.delete | Unary |
procedure foo(...) | procedure.foo | Unary |
procedure foo(...): X[] | procedure.foo | Sequence |
model User { ... } @@subscribe(...) (future) | model.User.subscribe | Subscription |
POST /rpc/model.User.list), not the body. This is deliberate:
- nginx, CDNs, and HTTP tracing tools work per-route without parsing payloads.
curl http://.../rpc/model.User.list -d '...'is a debuggable artifact in tickets and runbooks.- Per-op metrics fall out of standard HTTP middleware.
POST /rpc with {op, input} in the body, more uniform with batch/WS frames. Rejected for operability — losing per-route routing in nginx/CDNs is a real cost that uniformity doesn’t justify.
One handler per verb, RPC dispatcher delegates via constructed extractors
The RPC dispatcher does not duplicate handler logic. Each dispatch arm:- Decodes the RPC body into a typed input from
cratestack-axum::rpc(RpcPkInput<Pk>,RpcUpdateInput<Pk, UpdateXInput>,RpcListInput). - Reconstructs the axum extractor the existing CRUD handler expects (
Path(id),RawQuery(qs),Bytes). - Calls the existing
handle_*function directly.
model.<X>.update: the patch is decoded as the concrete Update<X>Input (not serde_json::Value), then re-encoded through the same codec before being handed to the existing handler as Bytes. The concrete-type round-trip is required because minicbor-serde encodes Option::None as the CBOR null marker (0xf6) but serde_json::Value::Null encodes as the CBOR empty-array marker (0x80) — round-tripping through Value would silently corrupt nullable patch fields. The list arm similarly synthesizes a URL query string from the body via synthesize_list_query and hands that to the existing list handler.
Rejected alternative. Refactor each CRUD handler to expose a *_rpc_inner function taking a typed input, with the axum handler as a thin wrapper. Cleaner long-term but a meaningful refactor across three callsites per CRUD verb plus every procedure — and unnecessary for v1 given the dispatcher-side delegation works without it.
Streaming via content negotiation, not a separate route
Sequence-kind ops (list-return procedures today, future @stream annotations) stream over the same POST /rpc/{op_id} route as unary. Clients send Accept: application/cbor-seq (or text/event-stream for SSE — when implemented); the existing axum handler does the rest via encode_transport_sequence_result_with_status_for. No new code path was needed in the RPC dispatcher — the existing 1:1 delegation works.
This is the cleanest part of the design. The framework already had content-negotiated sequence encoding for the REST binding; the RPC dispatcher just inherited it.
Strict batch, no in-batch dependencies
POST /rpc/batch decodes a sequence of RpcRequest { id, op, input, idem? } frames and emits a sequence of RpcResponseFrame { id, output?, error? } in request order (so order-only clients can zip without id lookup). Three deliberate non-features:
- Not transactional. Each frame runs in its own transaction.
- No in-batch dependencies. A batch like
[create A, update B referencing A.id]is not supported. The correct shapes are (a) two roundtrips, or (b) a single@procedurethat owns the composite operation. - Sequential processing in v1. The design permits parallelization but v1 doesn’t implement it — defer until contention is observable.
Idempotency-Key HTTP header is rejected on /rpc/batch as ambiguous; idempotency is always per-frame via the idem field. A malformed batch envelope returns 400; per-frame errors don’t poison the batch (200 with per-frame error field).
Rejected alternative. A $ref mechanism for in-batch frame dependencies. Rejected because encoding workflow into the wire protocol is how RPC frameworks rot — composite operations belong in @procedure.
Uniform RpcErrorBody on every error path
Every error that exits the RPC binding — whether raised inside the dispatcher (decode failure, unknown op id) or inside a handler — wire-shapes as:
code field uses gRPC-style lowercase: not_found, invalid_argument, permission_denied, failed_precondition, conflict, unauthenticated, internal, and reserved unavailable / deadline_exceeded / canceled. Never the REST binding’s SCREAMING_CASE.
Implementation has two halves:
- Dispatcher-side errors call
encode_rpc_error(&codec, &headers, &error)directly — emitsRpcErrorBodyfrom the start. - Handler-emitted errors are post-processed in
rpc_dispatch_inner: buffer the response body, decodeCoolErrorResponse(the REST shape the handler produced), translate the code viacool_error_code_to_rpc_code, re-encode asRpcErrorBody, same HTTP status.
cool_error_code_to_rpc_code) to agree with the direct mapping (rpc_code(&CoolError)) variant-by-variant, so the two can’t drift silently.
Rejected alternative. Pass an error-encoder strategy into every handler so REST and RPC bindings produce different bodies natively. Cleaner but invasive across every CRUD verb and every procedure handler — and unnecessary given the post-process hop is in-memory, only on error paths, and well-localized.
Consequences
Positive
- One handler per verb, two bindings. REST schemas keep working unchanged; RPC schemas get the new shape without forking the handler layer.
- HTTP-feature-complete for v1. Unary, batch, streaming, uniform error shape — all working end-to-end with test coverage at every layer.
- Client SDKs ship one binding’s worth of code. No “speak both” complexity downstream.
OpDescriptoris a stable introspection surface. Code generators, OpenAPI-equivalent tooling, and clients can iterateOPSwithout parsing routes.- Streaming was free. The content-negotiated sequence encoder shipped in the REST binding paid off at the RPC dispatcher with zero new code.
Negative
- Two binding styles to maintain. Bug fixes in routing, codec negotiation, or auth flow may need to land in two places — though the RPC dispatcher’s delegation to existing handlers keeps that surface small.
- The
updatepatch round-trip costs one encode + one decode per call. Not measured in practice; assumed negligible against the DB call cost. - Batch errors round-trip through
CoolErrorResponsebefore becomingRpcErrorBody. Same kind of cost, only on the error path.
Deferred
These are explicit non-features in v1. Each becomes a real ADR when a concrete use case appears.- WebSocket binding + subscriptions. Wire-side design fully captured (see “Next cool upgrade” in the transport architecture). Pending a concrete subscription use case — see below.
- In-batch transactional mode. Each batch frame is its own tx.
- Resumable subscriptions. Even when subscriptions ship, the v1 will be fire-and-forget — no cursors, no replay buffer.
- Batch parallelization. Server processes frames sequentially. The design permits parallelization once contention is observable.
- Cross-schema dispatch. Each schema has its own op registry; mounting two schemas in one binary produces two independent registries under different prefixes.
Subscriptions are designed but not built — why
Streaming and subscriptions look similar at the wire level (server emits a sequence of frames over time), but their use-case profiles are very different and CrateStack’s consumer base today reflects that. Streaming has clear demand:- List-return procedures naturally produce finite sequences (audit feeds, paginated reads, search results).
- Clients can opt in by sending one HTTP header — no protocol upgrade, no long-lived connection state.
- The encoder already existed for the REST binding.
- CrateStack’s audit and event-bus consumers today are server-to-server. They poll or consume from the audit sink directly; they don’t need a WS channel.
- External clients (mobile apps, browser SPAs) are the natural fit, but no concrete CrateStack consumer is asking for subscriptions right now.
- Implementing them requires new schema syntax (
@@subscribe), a WS frame loop in the macro-emitted dispatcher, andCoolEventBusper-subscription fan-out with bounded buffers — a real ADR’s worth of design effort that should be motivated by an actual user, not by symmetry with streaming.
docs/design/rpc-transport.md) stays as the target; the runtime work waits. When a concrete subscription use case appears, that becomes ADR 0006.
Shipping order
| PR | Scope | Status |
|---|---|---|
| #20 | transport directive + OpDescriptor vocabulary | merged |
| #21 | Unary runtime for procedures + cratestack-axum::rpc primitives | merged |
| #22 | CRUD over RPC unary + POST /rpc/batch | merged |
| #23 | Uniform RpcErrorBody with gRPC-style codes | merged |
| #24 | Streaming test coverage for Sequence-kind ops (no code change) | merged |
| — | WS binding + @@subscribe directive + subscription runtime | pending — see above |
Read Next
../architecture/transport-architecturefor the codec / framing / envelope model that both bindings sit on top of../core-architecture-adrfor the original v0 REST-only stance — its “RPC binding addendum” notes the supersession.- The local repo’s
docs/design/rpc-transport.mdfor the v1 design in the voice it was drafted — useful when implementing follow-ups like the WS binding, since the frame shapes and auth model are captured there in more detail than this ADR.