CrateStack Client Runtime Architecture
Status
Proposed and partially spiked.Why this change
CrateStack’s transport contract is not JSON-first. The documented direction is:- generated HTTP routes remain the canonical API surface
- CBOR is the primary codec
- JSON stays optional and isolated
- COSE is an envelope over codec bytes, not a codec
- sequence transports such as
application/cbor-seqmust be treated as framing concerns, not just renamed codecs
cratestack-client-dart slice is still an experiment, but it no longer owns Dio directly. It now targets a byte-oriented bridge/runtime abstraction plus repo-managed templates, while still relying on generic value graphs for typed model conversion. That remaining typing gap is why the generated typed Dart list and get helpers intentionally stop short of claiming fully exact selection-aware response typing.
Contract guardrails
The Rust client core must preserve these boundaries:- the generated HTTP API remains the public contract
- generated client APIs are layered on top of that HTTP contract, not a private non-HTTP runtime API
- codec, framing, and envelope handling stay explicit runtime concerns
- typed value -> codec bytes -> framing -> optional envelope -> HTTP body remains the ordering
Recommended crate split
cratestack-client-rust
Owns the Rust runtime for generated clients.
Responsibilities:
- HTTP execution
- CBOR-first request and response handling in the current slice
- negotiated JSON and CBOR support in the target slice
- framing seam for future sequence-capable responses such as
application/cbor-seq - envelope seam for future COSE support
- request journaling and client-local persistence hooks
- a future FFI-safe surface for Dart and Flutter wrappers
- additive selected
get/listhelpers that keep projecting through the canonical HTTP query contract
cratestack-client-flutter
Owns the Dart and Flutter-facing safe Rust wrapper over the runtime core.
Responsibilities:
- opaque runtime lifecycle for Dart or Flutter callers
- byte-oriented request and response wrapper types
- a future persisted-state projection for Dart or Flutter callers
- safe-Rust bridge methods that remain compatible with a future ABI wrapper
cratestack-client-dart
Owns generated Dart contracts and typed facades that target a runtime abstraction rather than dio directly.
Responsibilities:
- generated Dart models and inputs
- generated typed APIs and query builders, including canonical query params such as
fields,include,includeFields[path],sort,limit,offset,where, and legacyor - a byte-oriented bridge-facing runtime abstraction plus Riverpod adapter code at the composition boundary
- repo-managed MiniJinja templates that callers can override through a template directory
- generated field/include constant groups so callers can assemble safer
fieldsandincludeselections without stringly-typed literals everywhere
Local persistence
The client runtime needs a narrow state store for durable client-side behavior. Scope:- request journal
- idempotency and replay metadata
- local cache metadata
- runtime schema or state version markers
- general application data modeling
- long-lived secret storage
- replacing Flutter app databases such as Drift
Default direction
- SQLite-backed store for durable production use
- JSON-file store for tests, local tooling, and narrow bootstrap slices
- runtime config currently exposes only
InMemoryandJsonFile - the SQLite-backed store exists as
cratestack-client-store-sqlite, and the Redis-backed store exists ascratestack-client-store-redis - Rust backend services can attach the Redis store directly with
CratestackClient::with_state_store(...)orwith_optional_state_store(...); Vaam uses one Redis prefix per caller/target pair for generated backend-to-backend clients - neither SQLite nor Redis is yet selectable through the public Dart or Flutter runtime config surface
- idempotency, replay metadata, and cache metadata are still deferred beyond the current request journal and state version markers
Dart and FFI boundary
The future Dart runtime should wrap a Rust core rather than expose transport details to feature code. Rust should own:- codec selection
- framing selection
- envelope handling
- transport execution
- request signing and canonicalization when enabled
- response decoding and structured remote error mapping
- Rust already classifies remote and transport failures in the runtime layer
- the generated Dart package does not yet surface a first-class typed remote-error API; bridge consumers still need to treat non-success responses as a follow-up integration concern
- feature composition
- Riverpod wiring
- app-facing repositories and controllers
- typed generated facades over the runtime
- repo-managed MiniJinja templates that callers can override through a template directory
- a generated Flutter-package-style layout with
pubspec.yaml, root docs,lib/,lib/src/,example/, andtest/instead of a single monolithic output file
fields, include, and relation-specific includeFields[path], because generated clients still ride the canonical HTTP contract directly. The ergonomic path is to express those through generated selection builders and lower them with toListQuery(...) or toFetchQuery() when you want plain model-returning calls.
Schema enums now flow through that generated surface as real Dart enums rather than plain String fields. Generated fromWire() and toWire() helpers map enum wire names at the package edge, while the underlying runtime still transports generic value graphs.
That means callers get typed enums at the API edge, while the transport still does the boring but reliable wire-format work underneath. 🧰
Example:
- one builder expresses root fields and nested includes together
- relation payload trimming stays colocated with the relation itself instead of being split across
includeandincludeFields[path] - screens can evolve faster because adding one more field or nested relation is just one more builder call instead of coordinated string-list edits
- use a generated selection builder plus
toListQuery(...)ortoFetchQuery()for most projection-shaped reads - drop to raw
fields,include, andincludeFields[path]only when app code needs to assemble those query parts dynamically - use
selection.asProjection()withgetView(...)orlistView(...)when the caller should receive projected wrapper types instead of full models
@@paged full-model example:
@@paged projection example:
@@paged models:
list(...)returnsPage<Model>listView(...)returnsPage<ProjectedModel>- the paging envelope stays stable at
items,totalCount, andpageInfo - only the item type changes between full-model and projected flows
- root
Selectionbuilders can choose root scalar fields and nested include paths IncludeSelectionbuilders can choose scalar fields and further nested include paths on the already-included relation- the generated Rust client facade still serializes only canonical HTTP query params under the hood
unsafe_code, the first implemented slice is FFI-ready rather than a raw exported C ABI. The Rust runtime now exposes flat wire types and a blocking opaque-handle bridge in safe Rust, and cratestack-client-flutter now wraps that bridge for Dart or Flutter callers without introducing raw pointer code in this workspace.
Bridge Payloads
The bridge boundary is intentionally narrower than the transport boundary. What crosses the Dart or Flutter bridge today:- method, path, canonical query, and headers
- body bytes
- response status, headers, and body bytes
- generated Dart converts typed models into generic value graphs through
toWire() - Dart serializes those value graphs into bridge JSON bytes
- Rust decodes the bridge JSON bytes into a generic value graph
- Rust re-encodes that value graph into the configured HTTP transport codec
- Rust applies the configured framing for the HTTP request body
- Rust executes the HTTP request
- Rust decodes the HTTP response bytes from the response transport selected by the server
- Rust re-encodes that value graph into bridge JSON bytes for Dart or Flutter
- generated Dart maps the decoded value graph back into typed models through
fromWire()
- it removes
CrateStackWireCodecfrom the generated Dart seam - it keeps the bridge byte-only and FFI-friendly
- it keeps HTTP transport codec ownership in Rust
- it avoids making Dart or Flutter aware of CBOR, JSON transport fallback, or future COSE envelopes
./transport-architecture.md./http-transport-contract.md
- the bridge still uses generic value graphs
- the bridge still pays a JSON transcode cost internally
- a later typed Rust bridge can remove that extra bridge-format hop without changing the generated Dart API shape again
Runtime Transport Config
Transport configuration is runtime-wide today. The long-term direction still allows finer-grained request or route overrides where the public contract justifies them. Current config surface:codecenvelope
- request transport or request codec selection
- response transport preference ordering
- framing selection where sequence-capable routes are supported
- envelope selection
cborjson
none
cose_sign1
cose_sign1 is selected today, runtime construction fails early.
The transport ordering remains:
- typed value
- transport codec bytes
- framing
- optional envelope
- HTTP body
First spike
The first spike is intentionally narrow. Implemented in this repo:- a new
cratestack-client-rustcrate - CBOR-first request and response handling through
cratestack-codec-cbor - request journaling through a
ClientStateStoretrait - an in-memory store plus a JSON-file store for bootstrap and tests
- dedicated
cratestack-client-store-sqliteandcratestack-client-store-rediscrates for durable request-journal and state-version persistence - an FFI-ready runtime bridge with flat request, response, header, config, and error wire types
- a new
cratestack-client-fluttercrate that wraps the runtime bridge with safe Rust APIs for Dart or Flutter consumers - one successful procedure call against generated Axum-compatible CBOR routes
- one CRUD error-path call against generated Axum-compatible CBOR routes
- a generated Dart runtime that now targets a byte-oriented bridge instead of owning Dio directly
- canonical typed Dart query helpers for
fields,include, relation-specificincludeFields[path],sort,limit,offset, groupedwhere=, legacyor=, and resource-specific filters, plus per-model field/include constants for safer selection assembly - generated Dart selection builders plus projection wrappers for
getView/listView - request-authorizer hooks in
cratestack-client-rustbuilt around canonical request strings plus encoded request body bytes so host integrations can attach signed-request headers without changing generated clients - runtime-wide transport config for
cborandjson, with a reserved future envelope seam - documented target-state transport layering across codec, framing, and envelope, including a future
application/cbor-seqpath - removal of
CrateStackWireCodecfrom the generated Dart seam
- a raw exported ABI wrapper for Dart or Flutter FFI consumers
- COSE implementation
- COSE envelope implementation beyond the current request-authorizer trust hook
- full generated Rust typed delegates over this runtime beyond the current additive selected
get/listhelpers - moving the generated Dart model conversion layer fully off generic value graphs
- replacing bridge JSON bytes with a more direct typed bridge when that cost becomes worth paying
- exposing persisted state through the Flutter-facing wrapper and generated Dart-facing runtime integrations
- wiring the SQLite store into the public runtime config surface
Current implementation note
The currentcratestack-client-dart crate should be treated as an experimental runtime-oriented and bridge-facing slice. It no longer owns Dio directly, renders through repo-managed templates that callers can override, and still uses generic value graphs for typed model conversion while the Rust-owned bridge and codec story continues to mature. The generated Dart APIs now expose canonical projection query options plus selection builders and projection wrappers for projected reads. On the Rust side, cratestack-client-rust now exposes additive request-authorizer hooks and a generated schema-native client facade over the same runtime. include_schema! emits that facade alongside server/database code; include_client_macro! emits only client-facing Rust types, inputs, selection builders, procedure payloads, and the reqwest-backed facade for callers that only need to talk to another CrateStack HTTP service. Selection-aware response typing is still intentionally incomplete overall, so callers should treat these projections as a contract-aligned safety improvement rather than assuming every narrowed selection is perfectly type-level exact. Runtime state persistence is provided through the base in-memory and JSON-file stores, plus opt-in SQLite and Redis store crates.
Examples
Rust Runtime Config
CBOR-first transport with no envelope:Rust Client-Only Schema
Backend-to-backend callers should preferinclude_client_macro! when they consume another service’s .cstack schema but do not own its database, routes, policies, procedure registry, custom-field resolvers, or event subscriptions. CBOR should be the default backend-to-backend codec; JSON is for debugging, tests, and compatibility paths unless a service explicitly documents otherwise.
/$procs/{procedureName}, and projection helpers lower selected reads into fields, include, and includeFields[path] query params. OAuth2 protocol endpoints are intentionally outside .cstack and should remain handwritten protocol integrations rather than generated CrateStack clients.
JSON transport fallback with no envelope:
Rust Redis State Store
Server-side Rust clients can opt into Redis-backed request journaling without adding Redis to the base runtime crate:{prefix}:meta for schema_version, state_version, and updated_at, plus {prefix}:request_journal as an append-only Redis list of JSON-encoded RequestJournalEntry values. append_request_journal uses an atomic Redis pipeline to push the journal entry and increment state_version.
Flutter Wrapper Config
vaam-mobile Integration Path
For frontends/vaam-mobile, treat the generated package and the runtime bridge as separate concerns.
What works today
- generate the Flutter-shaped package into
frontends/vaam-mobile/packages/<client_name> - add it as a path dependency in the mobile app
- provide a
CrateStackRuntimeBridgeimplementation from the app side - override the generated Riverpod bridge provider
cratestack/, generation looks like:
Intended future path
The generated Dart package should remain stable while the bridge implementation moves closer to the final Rust-owned runtime path. That future path keeps these concerns in Rust:- transport codec selection such as
cbororjson - future COSE envelope behavior
- future signing and canonicalization
- runtime persistence choices
Current recommendation for vaam-mobile
Use the generated package API today, but treat the bridge as the integration seam that will later swap from app-owned wiring to a fuller Rust-backed runtime bridge.
Generated Dart Usage
- Dart converts
CreatePostInputinto a generic value graph throughtoWire() - Dart serializes that value graph into bridge JSON bytes
- Rust converts those bytes into the configured HTTP transport codec
- Rust performs the HTTP request and decodes the response
- Rust returns bridge JSON bytes
- Dart reconstructs typed models through
fromWire()
cratestack-cli generate-dart for the target package after changing the .cstack schema or the generator/templates.
Example:
- generated Rust and Dart clients understand schema enums
- read-policy literal lowering still only accepts required
Boolean,Int, andStringfields for literal comparisons at macro expansion time - to support enum fields in expressions like
field == "active"orauth().role == "admin", extend macro lowering to recognize required enum fields and lower those literals as string-backed policy literals
- use enums freely for generated client-facing data shapes
- be more careful when the same field is compared against string literals inside schema policies
Use Cases
1. Default Mobile Client
Use when:- the backend speaks CBOR by default
- the app should not know about CBOR or COSE details
- the app needs typed generated Dart APIs and Riverpod wiring
- codec:
cbor - envelope:
none - state store: in-memory during bootstrap, SQLite once durable mobile storage is required
2. Compatibility or Gateway Mode
Use when:- an integration point only accepts JSON transport
- the same generated client surface should remain usable
- codec:
json - envelope:
none
Current Gaps
The most important client-side features that still are not implemented end-to-end are:- a raw exported ABI wrapper for direct Dart FFI consumers
- COSE envelope support
- request signing and canonicalization on the runtime path
- generated typed Rust client output comparable to the generated Dart package output
- a public Flutter or Dart-facing persisted-state API
- runtime-configurable SQLite state-store selection from the Flutter-facing wrapper
- a first-class typed remote-error surface in the generated Dart APIs
- fully selection-aware response typing when
fields,include, andincludeFields[path]narrow or reshape payloads
3. Future Signed or Enveloped Transport
Use when:- requests or responses must be wrapped in COSE
- the app still should not implement envelope behavior in Dart or Flutter
- codec:
cbororjson - envelope:
cose_sign1