CrateStack Transport Architecture
Status
Proposed target architecture. This document is the canonical transport design reference to use before changing routing, client runtime, or generated contracts. Current implementation has closed most of this document’s original gap, though it started narrower:- generated Axum routers can negotiate multiple response codecs per router today via
CodecSet<Primary, Secondary>(anHttpTransportimpl covering exactly two codec slots) — a router is only single-codec if its owner passes a singleCratestackCodec(e.g.CborCodecalone) instead of aCodecSet. See./http-transport-contract.md’s “Current Repo Mapping” for a real example (catalog-servicewiringCodecSet::new(CborCodec, JsonCodec)). cratestack-codec-cborandcratestack-codec-jsonare both dedicated, checked-in first-party codec crates today — JSON is no longer inline-only incratestack-client-rust.- COSE remains an unimplemented envelope seam.
application/cbor-seqis implemented on both bindings for the shapes each has opted into: RPC’sSequence-kind ops negotiate it directly, and REST procedures explicitly marked@streamgenuinely stream (flush-per-item, not buffered) rather than being buffered through a single-value response helper. It is not implemented for CRUD/model routes on either binding, and not implemented for request bodies on either binding.
RPC binding update
Since this document was first written, CrateStack also ships a second binding style for.cstack schemas — see ./../internals/rpc-transport-adr.md for the canonical ADR. The codec / framing / envelope layering below is unchanged and applies to both bindings; the addition is at the routing layer:
- A
.cstackschema picks one generation style with the top-leveltransport rest|rpcdirective. Default isrest(back-compat with everything written before the directive existed). transport rpcschemas mountPOST /rpc/{op_id}(unary) andPOST /rpc/batchinstead of REST-shaped per-model routes. Streaming forSequence-kind ops works on the same unary route viaAccept: application/cbor-seq— same negotiated framing as below.- Errors on the RPC binding go on the wire as
RpcErrorBody { code, message, details? }with gRPC-style lowercase codes (not_found,invalid_argument,permission_denied, …) rather than the RESTCratestackErrorResponseshape. @@subscribemodel-event subscriptions shipped in v0.7.2 (#183, #390) over Server-Sent Events, not WebSocket — see “Subscriptions: shipped over SSE, not WebSocket” below. A true bidirectional WebSocket binding remains pending, gated on a concrete use case that needs client-to-server frames on the same channel.
gRPC binding — removed
transport grpc was a third binding style: protobuf over tonic, with
.proto emission and a field-number lockfile, plus native gRPC clients
for Rust and Dart. It was removed in 0.8.5
(ADR 0017).
The cratestack-grpc and cratestack-proto crates are gone, transport grpc and @pb no longer parse, and REST and RPC are the only two
bindings. This section is kept as a pointer for readers arriving from
older material.
Purpose
CrateStack needs a transport model that stays correct as the project grows from today’s CBOR-first bootstrap slice to a broader multi-client, multi-service, and optional signed-envelope platform. This document fixes the architecture vocabulary first so implementation work does not blur distinct concerns.Core Model
CrateStack transport is composed from three separate layers:- codec
- framing
- envelope
Definitions
Codec
A codec converts a typed value graph into bytes and back. Examples:- JSON
- CBOR
- value serialization and deserialization
- codec-specific content rules
- typed error reporting for encode and decode failures
- request authentication
- response negotiation policy
- body streaming semantics
- signing or encryption
Framing
Framing defines how one or more encoded values are arranged inside a single HTTP body. Examples:- single value
- sequence
- define whether a body contains one payload or many
- define how multiple payloads are delimited or concatenated
- constrain which endpoints can legally use the framing mode
- typed value serialization rules
- cryptographic protection
Envelope
An envelope wraps already-encoded and already-framed bytes. Examples:- none
- COSE Sign1 in a future implementation
- sealing and opening transport bytes
- binding transport bytes to signatures or future cryptographic metadata
- optionally using auth context or host-provided signing material
- primary typed serialization format
- list versus sequence semantics
Design Rules
Rule 1: COSE is an envelope, not a codec
COSE must not be modeled as a peer alternative to CBOR or JSON. Correct model:- choose codec
- choose framing
- optionally apply COSE
- choose one of JSON, CBOR, COSE
Rule 2: application/cbor-seq is not just another codec label
application/cbor and application/cbor-seq share a CBOR value model, but they do not have the same body semantics.
application/cbormeans one CBOR data item per bodyapplication/cbor-seqmeans multiple top-level CBOR data items in sequence
cbor-seq belongs at the framing layer, even if media-type handling ends up representing it as a distinct transport option in code.
Rule 3: Transport capability is route-specific
Not every generated route should support every transport shape. Examples:GET /products/{id}is naturally a single-value responsePOST /productsis naturally a single-value request and response- an export, feed, or watch procedure may support sequence responses
Rule 4: Request and response negotiation are related but separate
For HTTP:- request decoding is driven by
Content-Type - response encoding is driven by
Accept
Rule 5: Error bodies follow the negotiated response transport
Once the server has successfully selected a response transport, both success and error bodies should use it. Before response transport selection is possible, the server may fall back to a plain text or minimal host-defined error response only for truly pre-negotiation failures.Media-Type Direction
Implemented today
application/cboron every generated server route (the default codec parameter for a generatedClient<C = CborCodec>and the codec every router accepts at minimum)application/jsonas well, on any router built withJsonCodecalone or with aCodecSetthat includes it (e.g.CodecSet::new(CborCodec, JsonCodec))
Framing-aware media types
application/cbor-seq— implemented for RPCSequence-kind ops and for REST procedures marked@stream; not implemented for CRUD/model routes or for request bodies on either binding
Planned future envelope-aware media types
This repo has not yet committed to final envelope media types for COSE-wrapped payloads. That decision must happen explicitly rather than being implied by implementation. Questions to settle before COSE implementation:- whether the outer response type is a generic COSE media type or a CrateStack-specific profile
- how the inner codec and framing are declared or discoverable
- whether some routes require envelopes while others merely allow them
Recommended Runtime Shape
The currentCratestackCodec and CratestackEnvelope split is still directionally correct, but it is not sufficient on its own for content negotiation and sequence framing.
The long-term runtime should represent three concepts:
- codec registry
- framing policy
- envelope policy
- keep
CratestackCodecfor typed encoding - add a framing abstraction for single versus sequence bodies
- keep
CratestackEnvelopefor post-framing wrapping - add a transport selector or registry that resolves request and response behavior from HTTP headers plus route capability metadata
Route Capability Model
Generated routes should eventually declare transport capabilities instead of inheriting one implicit codec for every path. A route capability model should answer:- which request media types are accepted
- which response media types are supported
- whether sequence responses are allowed
- whether an envelope is optional, forbidden, or required
This table is directional guidance, not a hard commitment that list routes must always support sequence framing.
cbor-seq Guidance
application/cbor-seq should be introduced as a selective transport mode rather than a blanket replacement for list responses.
Good early fits:
- export procedures
- event feeds
- watch or tail style responses
- large result streams where incremental processing matters
- standard CRUD create or update requests
- simple detail fetches
- small procedure responses that already fit the single-value model cleanly
- implement negotiated JSON and CBOR single-value transport first
- add route capability metadata
- add response-side
cbor-seqfor explicitly sequence-oriented endpoints - consider request-side
cbor-seqonly after a concrete use case exists
Client Architecture Direction
Clients should mirror the same transport split. Client responsibilities:- choose a request transport explicitly when a request body exists
- advertise one or more acceptable response transports
- decode responses based on actual response
Content-Type - expose explicit sequence APIs instead of forcing sequence responses through single-value decode helpers
- default request transport: CBOR for internal first-party clients
- default accepted response transports: CBOR first, JSON second
- optional route- or request-level override when interoperability needs differ
cratestack-client-rust offers a buffered list helper for ordinary reads plus explicit incremental client APIs (RpcClient::call_streaming, CratestackClient::post_list_streamed) for sequence responses, rather than forcing every sequence through the buffered path. See ./client-runtime.md’s “Streaming surfaces” section for the full set, including the Flutter/dio equivalents.
Current Repo Mapping
This section previously described a state that has since been overtaken by real negotiation work — see the “Status” section at the top of this document for the current, corrected picture. What’s still genuinely ahead of the checked-in implementation:- generated Axum routes validate
AcceptandContent-Typeagainst whichever codec(s) the router was actually built with — a singleCratestackCodecfor a single-codec router, or both slots of aCodecSet<Primary, Secondary>for a negotiated one;cratestack-codec-cborandcratestack-codec-jsonare both real, dedicated checked-in codec crates today cratestack-client-rustandcratestack-client-flutterexpose runtime codec configuration for CBOR and JSON, and decode responses by actualContent-Typerather than assuming the configured codec — but each client instance still sends requests through one primary configured codec, so multi-codec request negotiation from a single client instance is not a thing- COSE envelope configuration exists as a reserved runtime option, but the runtime rejects it because implementation is missing
Implementation Phasing
Recommended order:- document the transport model and HTTP contract first
- add a dedicated JSON codec crate
- add negotiated JSON and CBOR request and response handling for generated routes
- update Rust client decoding to respect actual response
Content-Type - expose response preference ordering in client runtime config
- add route capability metadata for transport support
- add selective
application/cbor-seqsupport for sequence-oriented routes - add COSE envelope support only after codec and framing boundaries are proven in code
Non-Goals For The First Transport Expansion
- supporting every route under every media type from day one
- implementing COSE and multi-codec negotiation in the same patch set
- treating sequence framing as required for all list endpoints
- hiding transport differences behind vague automatic magic that clients cannot reason about
Canonical Companion Document
./http-transport-contract.md should be read alongside this document. This architecture file explains the model and boundaries. The HTTP contract file explains concrete request, response, and negotiation behavior.
Subscriptions: shipped over SSE, not WebSocket
An earlier draft of this document proposed a WebSocket binding as the vehicle for model-event subscriptions (six-variant frame envelope, upgrade-time HMAC,cratestack-rpc-v1+cbor subprotocol). That WS design was superseded before implementation: v0.7.2 shipped subscriptions over Server-Sent Events instead (#183, #390), reusing the existing sequence-streaming machinery rather than building a new bidirectional transport. The WS proposal’s cancellation objection — a WebSocket needs an explicit Cancel frame and upgrade-time auth because the channel is genuinely bidirectional — doesn’t apply to SSE for this specific shape: a @@subscribe feed is fire-and-forget, no-replay, one subscription per connection, so plain header-based auth (the same convention every other HTTP RPC route uses) and an ordinary client disconnect are enough.
What actually shipped:
@@subscribeschema directive. A bare model attribute —@@subscribetakes no arguments (@@subscribe(filter: "...")is a parse error) — valid only undertransport rpcand only alongside@@emit(...)on the same model. It lowers toOpKind::Subscription.GET /rpc/subscribe/{op_id}endpoint, dispatched through the existing outbox-drain pipeline (crates/cratestack-macros/src/transport/subscribe_dispatch.rs). The client asks for it withAccept: text/event-stream; anything else is rejected before aCratestackEventBussubscription is even registered.CratestackEventBusfan-out, already present incratestack-core, is what the subscription rides on. Backpressure is a bounded per-subscription channel that closes on overflow, surfaced to the client as a terminalevent: errorSSE frame — there is no replay and no reconnection/resume semantics.- Row-level
@@allowpolicy is not replayed against streamed events. That machinery lives in the SQL query builders and has no analogue for an in-memory outbox-sourced event — a documented scope limit for this first cut, not an oversight.