RPC transport

CrateStack ships two generation styles for a .cstack schema. The default is REST — per-model /users, /users/{id}, /$procs/<name> routes, the shape this framework was built around. The alternative is RPC — a single POST /rpc/{op_id} route per callable, a POST /rpc/batch endpoint that takes N frames at a time, and content-negotiated streaming on the same unary route. One binding per schema; the macro emits exactly one binding’s worth of routes and client surface. There is no runtime flip and no schema runs both. This guide covers what the RPC binding does today and when to pick it. The full design is in ADR 0005.

Pick the binding

Declare the directive at the top of your .cstack file:
Omit the directive for REST behavior unchanged. Defaults preserve everything written before the directive existed.

Mounting the router

include_server_schema! emits an rpc_router(...) builder when transport rpc is set, same shape as the existing model_router / procedure_router:
The router mounts two paths:
  • POST /rpc/{op_id} — unary for every CRUD verb + every procedure
  • POST /rpc/batch — sequence of RpcRequest frames

Op identity

Every callable in a transport rpc schema gets a stable dotted id. The id is the only dispatch key and appears in the URL: The op id appearing in the URL (not the body) is deliberate — it lets nginx, CDNs, and HTTP tracing tools route and instrument per-op without parsing payloads. On transport rpc the op id is also the canonical request identity used for signature verification and tracing — not the REST shape. A signed RPC request must sign over the op id (model.Widget.update, procedure.ping), and the cratestack_route tracing field carries that same op id. The REST /$procs/<name> and /<plural>[/<id>] paths never appear on the RPC binding, for url, dispatch, signing, or logs. (On the REST binding the canonical stays the REST route path.)

Unary

Body shape per verb:
Response on success: the codec-encoded output directly (no envelope wrapper). Auth, codec negotiation, content-type rules — same as the REST binding.

Batch — POST /rpc/batch

Send N requests in one round-trip, get N responses back in the same order:
Three deliberate behaviors:
  1. Per-frame errors don’t poison the batch. The envelope returns 200 OK as long as the batch parsed; each frame’s success or failure is on its own response frame.
  2. No transactional mode, no in-batch dependencies. Each frame runs in its own transaction. A batch like [create A, update B referencing A.id] is not supported — use two roundtrips or a single @procedure that owns the composite operation.
  3. Per-frame idempotency only. Send idem on each RpcRequest. The Idempotency-Key HTTP header is rejected on /rpc/batch as ambiguous.
A malformed batch envelope (body that isn’t a sequence of frames) returns 400.

Streaming — Accept: application/cbor-seq

List-return procedures (those declared as ... : T[]) get OpKind::Sequence from the macro and stream over the same POST /rpc/{op_id} route as unary. Switch by content negotiation:
The response is a stream of codec-encoded chunks, terminated by end-of-body. With the default Accept the same op returns a single CBOR Vec<T> — the route doesn’t change, only the wire shape. SSE (text/event-stream) is wired in the codec layer and works the same way for clients that need EventSource compatibility.

Consuming streams

The wire side is one paragraph; the interesting question is what a client looks like on the other end of that pipe. CrateStack ships three client paths and you can pick per-app or per-request.

The wire shape

application/cbor-seq is a sequence of self-delimiting CBOR top-level items concatenated back-to-back — no envelope, no length prefix, no framing bytes between items. The server emits it from reqwest/axum’s bytes_stream() so the body flushes as items are produced; the response is never fully buffered on the wire. The URL is the same POST /rpc/{op_id} that serves unary; only Accept: application/cbor-seq (the codec’s sequence_accept_header_value()) flips the response shape. Op kind is decided by the schema (OpKind::Sequence for list-return procedures and the model list verb), not by the request.

Path 1 — Rust client via RpcClient::call_streaming

The typed Rust path. The method returns a bounded tokio::sync::mpsc::Receiver so memory stays tight: 16 in-flight items max, with backpressure flowing back through reqwest’s chunk stream when the consumer falls behind.
Two shape notes worth pinning down:
  1. Non-2xx responses surface before the channel opens. call_streaming returns Err(RpcClientError) from its await, not as the first channel item. The channel exists only after the server has accepted the request and started streaming.
  2. Per-item errors are terminal. Each Err in the channel is the last item; the pump task exits after sending it. Consumers don’t need an inner loop guard — a single while let Some(item) = rx.recv().await covers happy path, transport mid-stream failure, and clean end-of-stream.

Path 2 — Flutter via callback + frb StreamSink

The reqwest-in-Rust path for Flutter apps. FlutterRuntime::rpc_call_streamed takes a callback that returns bool (false cancels); the natural wrap with flutter_rust_bridge is a StreamSink<FlutterChunkWire> so Dart code consumes a regular Stream. The full Rust shim lives in cratestack-client-flutter/README.md; the gist:
On the Dart side one switch over FlutterChunkWire covers every termination path:
Item carries one CBOR-encoded item’s raw bytes — decode it on the Dart side with the cbor package (or anything else that speaks CBOR). End and Error are both terminal: no further variants follow either.

Path 3 — Flutter via dio + CborSeqStreamTransformer

For apps that want HTTP to live in Dart — native NSURLSession/OkHttp visibility, dio interceptors for auth/retry/idempotency, Flutter DevTools network inspection, system proxy and certificate pinning — the generated Dart RPC runtime ships two primitives:
  • CborSeqDecoderHandle — abstract interface; Future<List<Uint8List>> feed(Uint8List) plus int pendingLen(). The FFI-backed FlutterCborSeqDecoder (from cratestack-client-flutter) satisfies it; pure-Dart impls work for web or server-side Dart.
  • CborSeqStreamTransformer — a plain StreamTransformer<Uint8List, Uint8List> that wraps any decoder handle. Composes with anything that produces Stream<Uint8List>.
Interceptors plug in at the dio level, not at the transformer level — the streaming path looks the same whether you’ve stacked auth, retry, and idempotency or not:
Errors flow through Dart’s normal stream error channel: decoder exceptions propagate as the underlying type; a stream that closes mid-frame raises a FormatException. Cancellation through subscription.cancel() propagates upstream into dio’s request cancellation contract.

Pick one

For a worked end-to-end Rust example see examples/rpc-streaming-client-rust. For the three-crate client split see Client Runtime; for the framing decisions see ADR 0005 §3.3.

Errors — uniform RpcErrorBody shape

Every error on the RPC binding — whether raised inside the dispatcher (decode failure, unknown op id) or inside a handler (auth denied, not found, validation failed) — wire-shapes as:
The code field uses gRPC-style lowercase strings: not_found, invalid_argument, permission_denied, failed_precondition, conflict, unauthenticated, internal. Never the REST binding’s SCREAMING_CASE (NOT_FOUND, FORBIDDEN, …). HTTP status codes match the error category. Clients that catch by status work unchanged from REST; clients that parse the body get a stable string vocabulary.

When to pick RPC

Schemas can’t switch styles without migrating clients, so pick deliberately. If you’re unsure, REST is the back-compat default.

What’s not yet built — WebSocket + subscriptions

The HTTP surface of the RPC binding is feature-complete. The remaining direction is a WebSocket binding that would unlock subscriptions — model.<X>.subscribe ops that stream ModelEvent<X> frames over a long-lived channel. The wire-side design is captured in ADR 0005 §3.4; the runtime work is gated on a concrete subscription use case. Streaming shipped without ceremony because the shape was concrete — list-return procedures, audit feeds, paginated reads, all naturally producing finite sequences with an existing encoder ready to go. Subscriptions don’t have that profile yet: CrateStack’s audit and event-bus consumers today are server-to-server and poll or consume from the audit sink. External clients are the natural fit, but no concrete CrateStack consumer is asking for subscriptions right now. When a concrete use case appears, the WS binding becomes the next cool upgrade. Until then, the gap is deliberate.
  1. ADR 0005: RPC Binding for transport rpc schemas — the canonical design, including the design decisions made along the way (URL routing, dispatcher delegation, error wire shape) and the deferred items.
  2. Transport architecture — the codec / framing / envelope model that both bindings sit on top of.
  3. Idempotency, Batches — closely related primitives that work the same way on either binding.