CrateStack v0 PRD
Staleness notice: this PRD's "v0" framing, its Non-Goals list, and its crate layout (§9.1) predate several major additions to CrateStack — the facade-pattern crate split (
cratestack-pg/cratestack-api/cratestack-sqlite), RPC and gRPC transports, and the Studio admin product. This is a historical planning document, not a live spec; it is not being fully rewritten to track current source. For what's actually true today, see Current State.
0.3.0 macro-split update
This PRD was authored when CrateStack exposed a single include_schema! macro. As of 0.3.0, the developer surface is three role-specific macros, replacing the single one:
include_server_schema!("schema.cstack", db = Postgres)— server / sqlxinclude_embedded_schema!("schema.cstack")— embedded / rusqlite (native + browser via OPFS)include_client_schema!("schema.cstack")— HTTP client (renamed frominclude_client_macro!)
The PRD's intent (one schema, typed delegates, generated routes, policy enforcement) is unchanged. References to include_schema! below describe the developer surface in server contexts unless otherwise noted; they map to include_server_schema!("…", db = Postgres) in 0.3.0+.
1. Product Summary
CrateStack is a Rust-native, schema-first backend framework layer for building typed database-backed HTTP REST APIs, generated clients, declarative authorization policies, and custom business procedures.
Developers define their data model, authorization rules, field exposure directives, custom fields, and procedures in .cstack schema files, then include them in Rust using one of the role-specific macros — for example, in a server crate:
cratestack::include_server_schema!("schema.cstack", db = Postgres);The macro generates a typed ORM client, canonical REST CRUD routes, procedure interfaces, request/response types, generated client metadata, and policy enforcement code. CrateStack uses sqlx behind the scenes for database access and supports pluggable transport composition so applications can use JSON, CBOR, sequence-aware transports such as application/cbor-seq, and future COSE envelopes without sacrificing developer experience.
CrateStack v0 is intentionally focused:
- Rust-first
- PostgreSQL-first
- Axum-first
- SQLx-backed
- HTTP REST only
- Schema-defined ORM models
- Schema-defined authorization policies
- Schema-defined CRUD exposure controls
- Schema-defined field visibility and filterability controls
- Schema-defined custom fields
- Schema-defined procedures
- Framework-delegated authentication
- Pluggable codecs
- Pluggable framing
- Optional COSE envelope support
- Generated Rust clients as a first-class output
CrateStack v0 does not attempt to provide RPC, GraphQL, a full plugin marketplace, or built-in authentication.
Correction: the "no RPC" claim above no longer holds. RPC transport shipped in v0.3.3 — see internals/rpc-transport-adr.md and guides/rpc-transport.md for the current design. The rest of this sentence (no GraphQL, no plugin marketplace, no built-in auth) is still accurate.
2. Problem Statement
Rust backend developers often need to repeatedly build the same foundation across projects:
- database models
- typed query APIs
- REST CRUD endpoints
- request and response structs
- authorization checks
- business procedures
- stable internal client libraries
- pagination and filtering
- serialization format handling
- framework integration
Existing Rust ORMs and web frameworks are powerful, but they usually require significant manual wiring between schema, database, authorization, and HTTP transport. Many systems also assume JSON as the default wire format, which is unacceptable for projects that require CBOR and COSE.
CrateStack solves this by making the schema the source of truth and generating the surrounding Rust API, REST layer, and client contracts while keeping authentication, transport encoding, and cryptographic wrapping configurable.
2.1 Representative Use Cases
Internal catalog or CMS service Teams can define models, policies, and procedures once, then get generated CRUD routes and typed Rust delegates without hand-writing every handler and query parser.
CBOR-first service boundary Platforms that do not want to normalize around JSON can keep generated HTTP APIs and clients aligned with a CBOR-first contract from day one.
Policy-heavy content or workflow APIs Applications can encode data visibility rules such as
published || authorId == auth().idin schema policy declarations instead of duplicating checks across handlers and queries.Shared server and client contract generation Teams can use a
.cstackschema to drive generated routes, Rust delegate usage, and experimental generated client outputs from the same source of truth.
3. Goals
3.1 Product Goals
- Let developers define data models and policies in
.cstackfiles. - Generate a typed Rust ORM client from the schema.
- Generate canonical HTTP REST CRUD routes for schema models.
- Support schema-configurable CRUD exposure per model and per operation.
- Support separate schema directives for field visibility and filterability.
- Support custom schema-defined procedures for business logic.
- Enforce model-level and procedure-level permissions.
- Delegate authentication to the host framework or application.
- Avoid forcing JSON as the wire format.
- Support JSON and CBOR as first-class codecs.
- Support framing-aware transports, including
application/cbor-seq, where route semantics justify them. - Support COSE as a transport envelope layer.
- Use SQLx behind the scenes for database execution.
- Generate Rust clients for the canonical HTTP APIs.
- Provide a clean
include_schema!developer experience. - Support resolver-backed custom fields.
3.2 Developer Experience Goals
The ideal developer flow:
flowchart LR
Schema[Author .cstack schema] --> Macro[include_schema!]
Macro --> Delegates[Typed Rust delegates]
Macro --> Routes[Generated Axum routes]
Macro --> Policies[Generated policy checks]
Macro --> Clients[Generated client metadata]
Routes --> Runtime[Codec + framing + envelope + auth context + SQLx runtime]
cratestack::include_schema!("schema.cstack");
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let cool = cratestack_schema::Cratestack::builder(pool)
.codec(cratestack_codec_cbor::CborCodec::default())
.procedures(AppProcedures)
.build();
let app = axum::Router::new().nest(
"/api",
cratestack_schema::axum::router(
cool,
AppProcedures,
cratestack_codec_cbor::CborCodec,
resolve_context,
),
);
Ok(())
}The developer defines a .cstack file, implements generated procedure traits and custom-field resolver traits, provides authentication context through the framework, and gets REST endpoints plus a typed ORM and generated client contracts.
Canonical transport design and HTTP negotiation rules are documented in:
../architecture/transport-architecture.md../architecture/http-transport-contract.md
4. Non-Goals for v0
CrateStack v0 explicitly does not include:
- GraphQL.
- schema-first transport and client generation remain the core outputs.
- Production-stable non-Rust client generation in the initial slice.
- Built-in authentication.
- Session management.
- OAuth, JWT, password hashing, or login flows.
- Multi-database support beyond PostgreSQL.
- A complete migration engine.
- Polymorphic models.
- Deep nested writes.
- Advanced include trees.
- A broad plugin marketplace.
as_systemor superuser bypass APIs.- Automatic policy bypass inside procedures.
- Prisma compatibility as a formal goal.
- ZenStack compatibility as a formal goal.
Correction: the original list here also included "RPC transport" as a non-goal. This is no longer accurate — RPC transport (
transport rpc,POST /rpc/:op_id,POST /rpc/batch, RpcLink middleware chain, streaming, gRPC-style error codes) shipped in v0.3.3 and has been actively iterated on through v0.6.0+. (RPC transport shipped in v0.3.3 — see internals/rpc-transport-adr.md and guides/rpc-transport.md for the current design.)
5. Target Users
5.1 Primary Users
Rust backend developers who want:
- typed database access
- generated REST endpoints
- schema-driven authorization
- framework-level authentication integration
- CBOR/COSE-capable HTTP APIs
- low-boilerplate CRUD and business procedure support
5.2 Secondary Users
Teams building:
- internal services
- secure APIs
- embedded or constrained-environment backends
- public REST APIs requiring non-JSON formats
- policy-heavy data services
6. Core Concepts
6.1 .cstack Schema
The .cstack schema is the source of truth for:
- datasource configuration
- auth context shape
- database models
- fields
- relations
- constraints
- model-level permissions
- CRUD exposure controls
- field visibility rules
- field filterability rules
- model event emission directives
- custom-field resolution declarations
- custom input/output types
- procedures
- procedure-level permissions
6.2 ORM Client
CrateStack generates a typed Rust ORM client with model delegates:
let posts = cool
.post()
.find_many()
.where_(post::published().eq(true))
.limit(20)
.run(&ctx)
.await?;Builder-style relation DSL example:
let visible_posts = cool
.post()
.find_many()
.where_expr(
cratestack_schema::post::author()
.profile()
.nickname()
.eq("Zulu")
.and(cratestack_schema::post::published().is_true())
.not(),
)
.order_by(cratestack_schema::post::author().email().desc())
.run(&ctx)
.await?;6.3 REST CRUD
CrateStack generates conventional REST endpoints for models:
GET /api/posts
GET /api/posts/:id
POST /api/posts
PATCH /api/posts/:id
DELETE /api/posts/:idPer-model exposure is schema-configurable. A model may expose all CRUD operations, only a protected subset, public read-only routes, or no generated CRUD at all.
Raw HTTP examples for the current slice:
curl \
-H 'Accept: application/cbor' \
'http://127.0.0.1:3000/api/posts?fields=id,title&sort=-id&limit=20'
curl \
-H 'Accept: application/cbor' \
-H 'x-auth-id: 1' \
'http://127.0.0.1:3000/api/posts?include=author&where=author.email=owner@example.com'
curl \
-H 'Accept: application/cbor' \
-H 'x-auth-id: 1' \
'http://127.0.0.1:3000/api/posts?where=not(author.profile.nickname=Zulu,published=true)'6.4 Procedures
Procedures are schema-defined custom business operations implemented by the application.
Example:
mutation procedure publishPost(args: PublishPostInput): Post
@allow(auth().role == "admin")Generated Rust trait:
#[async_trait::async_trait]
pub trait CoolProcedures: Send + Sync + 'static {
async fn publish_post(
&self,
db: &CrateStack,
ctx: &cratestack::CoolContext,
args: PublishPostInput,
) -> Result<Post, cratestack::CoolError>;
}REST endpoint:
POST /api/$procs/publishPostAll non-CRUD business behavior should be represented as schema-declared procedures. Procedures are not an escape hatch around the schema; they are part of the schema contract.
6.5 Permissions
Permissions are schema-defined authorization rules.
Model example:
@@allow("read", published || authorId == auth().id)
@@allow("update", authorId == auth().id)
@@allow("delete", auth().role == "admin")Procedure example:
mutation procedure publishPost(args: PublishPostInput): Post
@allow(auth().role == "admin")Default rule:
No matching allow rule means deny.6.6 Authentication Delegation
CrateStack does not authenticate users.
The host application or framework is responsible for:
- login
- sessions
- JWT verification
- cookies
6.7 Custom Fields
Schemas may declare resolver-backed custom fields:
type Image {
storageKey String
thumbnailUrl String @custom
}The framework generates a resolver trait for custom fields so that applications can derive values such as thumbnailUrl from internal infrastructure like imgproxy without making that wiring part of every handler.
6.8 Generated Clients
Generated HTTP routes are the normal API surface of a CrateStack service.
The framework should generate:
- a Rust async client first
- a Dart client later, designed for Riverpod-based frontends
Generated Rust clients should expose both high-level typed operations and a lower-level request-builder escape hatch.
- OAuth
- user lookup
CrateStack only consumes a CoolContext that represents the already-authenticated request identity.
6.7 Codec
A codec converts typed Rust values to and from HTTP request/response bodies.
Examples:
- CBOR
- JSON, optional only
- MessagePack, possible later
6.8 Envelope
An envelope wraps already-encoded bytes for signing, verification, encryption, or MAC operations.
Examples:
- no envelope
- COSE_Sign1
- COSE_Encrypt0
- COSE_Mac0
CBOR and COSE are intentionally separate:
Rust type -> CBOR bytes -> COSE envelope -> HTTP response
HTTP request -> COSE open -> CBOR decode -> Rust type7. Example Schema
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
auth User {
id Int
role String
}
model User {
id Int @id @default(dbgenerated())
email String @unique
name String?
role String
@@allow("read", auth() != null)
}
model Post {
id Int @id @default(dbgenerated())
title String
published Boolean @default(false)
authorId Int
@@allow("read", published || authorId == auth().id)
@@allow("create", auth() != null)
@@allow("update", authorId == auth().id)
@@allow("delete", auth().role == "admin")
}
type SignUpInput {
email String
name String?
}
type PublishPostInput {
postId Int
}
procedure getFeed(limit: Int?): Post[]
@allow(auth() != null)
procedure getFeedPage(limit: Int?, offset: Int?): Page<Post>
@allow(auth() != null)
mutation procedure signUp(args: SignUpInput): User
@allow(true)
mutation procedure publishPost(args: PublishPostInput): Post
@allow(auth().role == "admin")8. Functional Requirements
8.1 Schema Parsing
CrateStack must parse .cstack files containing:
- datasource blocks
- auth blocks
- model blocks
- type blocks
- procedure declarations
- mutation procedure declarations
- scalar fields
- optional fields
- list fields
- model attributes
- field attributes
- permission expressions
Required scalar types for v0
String
Int
Float
Boolean
DateTime
Json
Bytes
UuidJson is a database/value type only. It must not imply JSON as the HTTP wire format.
Required field attributes for v0
@id
@default(...)
@unique
@relation(...)Required model attributes for v0
@@allow(action, expression)
@@paged
@@unique([...])
@@index([...])Current @@paged boundary:
@@pagedis a bare model-level opt-in only- it only changes the generated top-level list route for that model
- generated
GET /{models}responses becomePage<Model>instead ofModel[] - generated Rust and Dart model
list(...)and projection-drivenlistView(...)clients follow the same paged contract - paging is still offset-based through
limitandoffset - nested relation collections and detail routes are unaffected
Required procedure attributes for v0
@allow(expression)Built-in container return type currently supported in procedure returns:
Page<T>Current Page<T> boundary:
- only supported on procedure return types
Tmust be a declared model ortype- generated wire shape is
items,totalCount, andpageInfo
Explicit non-goals for the current slice:
- no user-defined generic schema types such as
type Foo<T> - no scalar page items such as
Page<Int> - no nested page containers such as
Page<Page<Post>> - no optional or list-wrapped page containers such as
Page<Post>?orPage<Post>[] - no
Page<T>support on model fields, type fields, auth fields, or procedure arguments - no alternative built-in paged container variations beyond the canonical
Page<T>envelope above
8.2 Semantic Analysis
CrateStack must validate:
- duplicate model names
- duplicate type names
- duplicate field names
- unknown field types
- invalid scalar types
- invalid relation references
- missing primary keys
- invalid policy actions
- invalid auth field references
- invalid model field references in policies
- invalid procedure input types
- invalid procedure return types
- duplicate procedure names
- unsupported database provider values
Semantic errors should produce compiler-style diagnostics that identify the file location when possible.
8.3 Macro Integration
The primary integration point is:
cratestack::include_schema!("schema.cstack");The macro must:
- read the schema file relative to
CARGO_MANIFEST_DIR - parse the schema
- analyze the schema
- generate Rust modules and types
- generate ORM delegates
- generate policy code
- generate procedure traits
- generate REST routes
- trigger recompilation when the schema changes
Generated module name for v0:
cratestack_schemaGenerated surface:
cratestack_schema::CrateStack
cratestack_schema::routes
cratestack_schema::models
cratestack_schema::post
cratestack_schema::user
cratestack_schema::CoolProcedures8.4 ORM Requirements
8.4.1 Generated Delegates
For each model, generate a delegate:
cool.user()
cool.post()Required methods:
find_many()
find_unique(id)
create(input)
update(id)
delete(id)8.4.2 Query Features for v0
Required:
- equality filters
- boolean filters
- simple string filters
- ordering
- limit
- offset
- primary-key lookup
Optional but desirable:
containsstarts_withends_withinnot_in
8.4.3 SQL Backend
CrateStack v0 must use SQLx behind the scenes.
Recommended implementation:
sqlx::PgPoolsqlx::QueryBuilder<Postgres>- generated
sqlx::FromRowstructs - dynamic SQL for filters and policies
Compile-time SQLx macros are not required for generated dynamic queries.
8.4.4 Policy Injection
Read, update, and delete policies must be injected into SQL where possible.
Example policy:
@@allow("update", authorId == auth().id)Should produce SQL behavior equivalent to:
UPDATE posts
SET ...
WHERE id = $1
AND author_id = $auth_user_id
RETURNING *Create policies may be checked before insert using the decoded input and auth context.
8.5 Permission Requirements
8.5.1 Supported Actions
Model permissions support:
read
create
update
deleteProcedure permissions support:
executeThe schema syntax may omit the explicit action for procedures:
@allow(auth() != null)8.5.2 Default Deny
If no applicable allow rule exists, the operation must be denied.
8.5.3 Multiple Allow Rules
Multiple allow rules for the same action are OR-combined.
Example:
@@allow("read", published)
@@allow("read", authorId == auth().id)Equivalent:
published OR authorId == auth().id8.5.4 Auth Context
Policies may reference:
auth()
auth().id
auth().roleauth() == null means unauthenticated.
auth() != null means authenticated.
8.5.5 Procedure Permissions
Procedure-level permissions are checked before calling the application-provided procedure implementation.
Example:
mutation procedure publishPost(args: PublishPostInput): Post
@allow(auth().role == "admin")Generated handler behavior:
1. decode request
2. extract context
3. check procedure permission
4. call handler implementation
5. encode response8.5.6 No System Bypass in v0
CrateStack v0 must not provide:
db.as_system()Procedures use the same policy-protected ORM methods as all other code.
8.6 Procedure Requirements
8.6.1 Schema Declaration
CrateStack supports:
procedure getFeed(limit: Int?): Post[]
mutation procedure signUp(args: SignUpInput): User8.6.2 Generated Types
CrateStack generates input and output types for procedure arguments and return values.
Input types derive:
Debug
Clone
serde::Serialize
serde::DeserializeModel return types derive:
Debug
Clone
serde::Serialize
serde::Deserialize
sqlx::FromRow8.6.3 Generated Procedure Trait
CrateStack generates a trait that the application must implement.
Example:
#[async_trait::async_trait]
pub trait CoolProcedures: Send + Sync + 'static {
async fn sign_up(
&self,
db: &CrateStack,
ctx: &cratestack::CoolContext,
args: SignUpInput,
) -> Result<User, cratestack::CoolError>;
}8.6.4 Procedure Registration
Applications register procedure implementations through the builder:
let cool = Cratestack::builder(pool)
.procedures(AppProcedures)
.build();8.6.5 Procedure ORM API
CrateStack may also expose procedures through a typed local API:
let user = cool
.procs()
.sign_up(input)
.run(&ctx)
.await?;8.6.6 Procedure REST API
Procedures are exposed as HTTP POST endpoints:
POST /api/$procs/getFeed
POST /api/$procs/signUp
POST /api/$procs/publishPostAll procedures use POST in v0, including read-like procedures.
Rationale:
- avoids complex query encoding
- works with CBOR bodies
- works with COSE envelopes
- avoids pretending arbitrary procedures are pure resources
- keeps transport simple
8.7 REST Requirements
8.7.1 Model CRUD Routes
For every model with applicable permissions, generate:
GET /{modelPlural}
GET /{modelPlural}/{id}
POST /{modelPlural}
PATCH /{modelPlural}/{id}
DELETE /{modelPlural}/{id}Example:
GET /posts
GET /posts/1
POST /posts
PATCH /posts/1
DELETE /posts/18.7.2 Query Parameters
Required v0 query parameters:
fields
include
sort
limit
offsetRequired simple filters:
GET /posts?published=true
GET /posts?authorId=1Current implemented extended filters:
GET /posts?authorId__in=1,2
GET /posts?title__contains=Draft
GET /posts?title__startsWith=Dr
GET /posts?subtitle__isNull=true
GET /posts?published__ne=true
GET /sessions?createdAt__gt=2026-01-01T12:00:00Z
GET /posts?author.email=owner@example.com
GET /posts?or=title__startsWith=Pub|author.email=owner@example.com
GET /posts?fields=id,title&sort=-id
GET /posts/1?fields=id,title&include=author
GET /posts?fields=id,title&include=author&includeFields[author]=email
GET /users?include=sessions&sort=email
GET /sessions?where=(label__startsWith=Primary|label__startsWith=Revoked),createdAt__gt=2026-01-01T12:00:00Z
GET /sessions?id=cprimarysession1
GET /sessions?externalId=11111111-1111-1111-1111-111111111111
GET /sessions?createdAt=2026-01-01T00:00:00ZRepresentative curl examples:
curl \
-H 'Accept: application/cbor' \
'http://127.0.0.1:3000/api/posts?fields=id,title&sort=-author.email,id&limit=10'
curl \
-H 'Accept: application/cbor' \
'http://127.0.0.1:3000/api/posts/1?fields=id,title&include=author'
curl \
-H 'Accept: application/cbor' \
'http://127.0.0.1:3000/api/posts?fields=id,title&include=author&includeFields[author]=email'
curl \
-H 'Accept: application/cbor' \
-H 'x-auth-id: 1' \
'http://127.0.0.1:3000/api/users?where=not(sessions.some.label__contains=Revoked)'Current relation filter shape is still intentionally narrow, but now distinguishes to-one and to-many traversal:
toOneRelation.targetField=value
toOneRelation.targetField__contains=value
toManyRelation.some.targetField=value
toManyRelation.every.targetField__contains=value
toManyRelation.none.targetField=value
toOneRelation.nestedToManyRelation.some.targetField=valueThe route grammar is contract-stable for the current slice as:
whereExpr := orExpr
orExpr := andExpr ("|" andExpr)*
andExpr := factor ("," factor)*
factor := predicate | "(" whereExpr ")" | "not(" whereExpr ")"flowchart TD
A[where=...] --> B[parse orExpr]
B --> C[parse andExpr]
C --> D[parse factor]
D -->|predicate| E[key=value]
D -->|(...)| F[nested group]
D -->|not(...)| G[negated group]
E --> H[QueryExpr tree]
F --> H
G --> H
H --> I[FilterExpr lowering]
I --> J[SQLx query builder]
Grouped where= expressions are canonical. not(...) binds tighter than ,, and , binds tighter than |; repeated , and repeated | chains are left-associative. Parentheses override the default grouping. For example, where=a=true,b=true|c=true parses as ((a=true,b=true)|c=true), and where=not(author.profile.nickname=Zulu,published=true) negates the full grouped subexpression.
Legacy or= remains supported only as a compatibility convenience for a top-level OR list such as or=title__startsWith=Pub|author.email=owner@example.com. New contract text and examples should prefer canonical grouped where= because it is the only route form that supports nesting, mixed AND/OR grouping, and not(...).
Current relation support requires explicit field metadata:
author User @relation(fields:[authorId],references:[id])
sessions Session[] @relation(fields:[id],references:[userId])Projection rules for the current route contract:
fieldsis validated against the generated scalar model fields for the requested resource.includeis validated as a declared relation path such asauthor.profile.includeFields[path]is validated against scalar fields on the already-included relation at that exact path.includeFields[path]is rejected unlessinclude=pathis also present.- nested include trees now materialize on list and fetch routes through declared relation paths only.
includeFields[path]narrows each included path payload independently.- ordering support remains separate from include materialization and still does not extend to to-many sort paths.
Projection examples for the current route contract:
GET /posts?fields=id,title
GET /posts?include=author
GET /posts?fields=id,title&include=author&includeFields[author]=email
GET /posts?include=author.profile&includeFields[author]=email&includeFields[author.profile]=nickname
GET /users?include=sessions&includeFields[sessions]=id,labelRecommended reading of the three projection knobs:
fieldsanswers: which scalar fields should the primary resource return?includeanswers: which declared relation paths should be embedded?includeFields[path]answers: which scalar fields should that embedded path return?
Representative use-cases:
- card/list views that only need
id,title, and one related label - admin grids that need compact rows plus a related owner email
- mobile detail previews where the embedded relation should stay small for bandwidth and cache efficiency
- to-many includes where each embedded row only needs identifiers and display text
Generated Rust helpers for the same nested projection contract now follow this shape:
let selection = cratestack_schema::post::select()
.id()
.include_author_selected(
cratestack_schema::user::include_selection()
.email()
.include_profile_selected(
cratestack_schema::profile::include_selection().nickname(),
),
);
let query = selection.to_query();Current generated Rust selection boundary:
- root
Selectionbuilders choose root scalar fields and nested include paths - nested
IncludeSelectionbuilders choose scalar fields and nested include paths under the already-included relation - projection wrappers decode projected payloads without changing the wire contract
- exact type-level projection remains intentionally looser than the projection wrapper surface
Recursive relation filters and first-pass relation-aware ordering use those explicit @relation(fields:[...],references:[...]) declarations rather than foreign-key inference.
To-many relations must use an explicit quantifier operator (some, every, or none) before the target field path. In this slice, relation-aware sort support follows nested to-one paths such as sort=author.email or sort=author.profile.nickname, missing related rows currently sort with explicit NULLS LAST behavior, and the model primary key is appended automatically as a deterministic tie-break when relation ordering is used without an explicit primary-key term. Any path that crosses a to-many relation remains unsupported for ordering. fields is validated against the generated scalar model fields for the requested resource, and include is validated as a declared relation path. Nested include paths now materialize on list and fetch routes, and includeFields[path] narrows the payload at each already-included relation path. Exact type-level projection and child-collection sort/pagination remain intentionally out of scope for this slice. Generated typed delegates expose the same supported relation filter and nested to-one ordering paths through both compatibility helpers such as cratestack_schema::post::author::email_desc() and cratestack_schema::post::author::profile::nickname_eq(...), and a builder-style DSL such as cratestack_schema::post::author().email().desc(), cratestack_schema::post::author().profile().nickname().eq(...), and cratestack_schema::user::sessions().some().label().contains(...) for where_expr(...) usage. Those builder expressions can now be combined directly with .and(...), .or(...), and .not()-equivalent grouping through FilterExpr, for example post::author().profile().nickname().eq("Zulu").and(post::published().is_true()).or(post::title().contains("Draft")). The route grammar remains narrower than the full generated delegate surface where true: the typed delegate DSL still exposes composition through where_expr(...) and typed builders rather than every delegate capability being representable as route query syntax. Legacy orderBy remains accepted as a compatibility alias for sort, but new contract text and generated clients should prefer sort.
Practical relation query examples:
Additional clarification for the current implemented boundary:
includeis now validated as a declared relation path rather than only a top-level relation name.- Nested include paths now materialize on list and fetch routes.
includeFields[path]narrows the payload at each already-included relation path.- What remains out of scope is exact type-level projection and child-collection sort/pagination, not nested include trees themselves.
Projection note:
- treat
fields,include, andincludeFields[path]as three separate controls rather than one overloaded selector includeFields[path]narrows the payload at each already-included relation path- exact type-level projection and child-collection sort/pagination remain out of scope for this slice
GET /posts?sort=-author.email,id
GET /posts?where=author.profile.nickname=Zulu
GET /users?where=sessions.some.label__contains=Revoked
GET /users?where=sessions.every.revokedAt__isNull=true
GET /users?where=not(sessions.none.label__startsWith=Primary)8.7.3 Request and Response Body
Bodies must be encoded using the configured CoolCodec.
JSON must not be assumed by the generated code.
8.7.4 Content Negotiation
For v0, CrateStack may enforce a single configured codec per router.
Example:
Content-Type: application/cbor
Accept: application/cborIf the request uses an unsupported content type, CrateStack should return:
415 Unsupported Media TypeIf the response cannot satisfy Accept, CrateStack should return:
406 Not AcceptableA relaxed v0 may skip full Accept negotiation and always return the configured codec content type.
8.7.5 Error Responses
Errors should also be encoded using the configured codec.
Canonical error shape:
pub struct CoolErrorResponse {
pub code: String,
pub message: String,
pub details: Option<cratestack::Value>,
}Example codes:
BAD_REQUEST
UNAUTHORIZED
FORBIDDEN
NOT_FOUND
CONFLICT
VALIDATION_ERROR
CODEC_ERROR
DATABASE_ERROR
INTERNAL_ERROR8.8 Codec Requirements
8.8.1 Codec Trait
CrateStack core exposes:
pub trait CoolCodec: Clone + Send + Sync + 'static {
const CONTENT_TYPE: &'static str;
fn encode<T: serde::Serialize>(&self, value: &T) -> Result<Vec<u8>, CoolError>;
fn decode<T: serde::de::DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, CoolError>;
}8.8.2 Required Codecs
v0 should provide:
cratestack-codec-cborJSON may exist as an optional crate:
cratestack-codec-jsonBut JSON must not be required by core.
8.8.3 CBOR Requirements
CBOR should support all generated model and procedure input/output types through Serde.
Recommended implementation crate:
minicbor-serde8.8.4 Generated Types
All generated transport types must derive:
serde::Serialize
serde::DeserializeThis allows codecs to work without custom generated serializers.
8.9 COSE Envelope Requirements
8.9.1 Envelope Trait
CrateStack core exposes:
pub trait CoolEnvelope: Clone + Send + Sync + 'static {
fn request_content_type(&self) -> &'static str;
fn response_content_type(&self) -> &'static str;
fn open_request(
&self,
bytes: &[u8],
ctx: &mut CoolContext,
) -> Result<Vec<u8>, CoolError>;
fn seal_response(
&self,
bytes: &[u8],
ctx: &CoolContext,
) -> Result<Vec<u8>, CoolError>;
}8.9.2 Required Envelopes
Core provides:
NoEnvelopeOptional crate provides:
cratestack-cosePotential envelope implementations:
CoseSign1Envelope
CoseEncrypt0Envelope
CoseMac0Envelopev0 may implement only CoseSign1Envelope initially.
8.9.3 Processing Order
Request processing:
HTTP request body
-> envelope.open_request
-> codec.decode
-> auth/policy/procedure/ORMResponse processing:
Rust result
-> codec.encode
-> envelope.seal_response
-> HTTP response body8.9.4 Auth Interaction
COSE verification may enrich CoolContext, but it must not replace framework-level authentication unless the host application explicitly chooses that behavior.
Example possible enrichment:
ctx.transport.subject
ctx.transport.key_id
ctx.transport.verified = true8.10 Authentication Context Requirements
CrateStack must expose a flexible context type:
pub struct CoolContext {
pub auth: Option<CoolAuthIdentity>,
pub extensions: Extensions,
}Auth identity should support dynamic field lookup:
pub struct CoolAuthIdentity {
pub fields: BTreeMap<String, cratestack::Value>,
}Convenience methods:
impl CoolContext {
pub fn anonymous() -> Self;
pub fn authenticated(fields: impl IntoAuthFields) -> Self;
pub fn is_authenticated(&self) -> bool;
pub fn auth_field(&self, name: &str) -> Option<&Value>;
}Framework integrations should extract or construct CoolContext from request extensions.
9. Architecture
9.1 Crate Layout
Stale: this layout predates the facade split (
cratestack-pg/cratestack-api/cratestack-sqlite), gRPC transport, and the Studio product, among other additions. Seeinternals/package-selection.mdfor the (also-stale-but-more-detailed) crate inventory, or Current State for what's current — this list is not duplicated here.
Recommended workspace:
cratestack/
crates/
cratestack/
// user-facing runtime crate and re-exports
cratestack-macros/
// include_schema! proc macro
cratestack-parser/
// .cstack parser
cratestack-core/
// AST, IR, validation, value model
cratestack-policy/
// policy expression handling and SQL compilation
cratestack-sqlx/
// SQLx backend
cratestack-axum/
// Axum REST integration
cratestack-codec-cbor/
// CBOR codec
cratestack-cli/
// human-facing CLI plus machine-readable diagnostics
cratestack-lsp/
// standalone language server for .cstack files
cratestack-codec-json/
// optional JSON codec
cratestack-cose/
// optional COSE envelope support
packages/
cratestack-vscode/
// thin VS Code wrapper that launches cratestack-lsp9.2 Generated Code Structure
include_schema! generates:
pub mod cratestack_schema {
pub struct CrateStack { ... }
pub mod models { ... }
pub mod fields { ... }
pub mod policy { ... }
pub mod delegates { ... }
pub mod procedures { ... }
pub mod rest { ... }
pub use models::*;
pub use procedures::*;
pub fn routes(cool: CrateStack) -> axum::Router { ... }
}9.3 Request Pipeline
Model CRUD request:
HTTP request
-> Axum route
-> read body or query/path params
-> envelope open
-> codec decode
-> extract CoolContext
-> validate input
-> compile ORM operation
-> inject policy
-> execute SQLx query
-> codec encode response
-> envelope seal
-> HTTP responseProcedure request:
HTTP request
-> Axum route
-> read body
-> envelope open
-> codec decode into procedure input
-> extract CoolContext
-> evaluate procedure @allow/@deny through canonical policy engine
-> run delegated DB-backed `@authorize(...)` checks if present
-> call app procedure implementation
-> app may use policy-protected ORM
-> codec encode result
-> envelope seal
-> HTTP response9.4 SQL Generation Strategy
Use generated metadata plus SQLx query builders.
Read example:
SELECT columns
FROM table
WHERE user_filter
AND policy_filter
ORDER BY ...
LIMIT ...
OFFSET ...Update example:
UPDATE table
SET ...
WHERE id = $id
AND policy_filter
RETURNING columnsDelete example:
DELETE FROM table
WHERE id = $id
AND policy_filter
RETURNING columnsCreate example:
check create policy
INSERT INTO table ...
RETURNING columns10. Public API Sketch
10.1 Application Setup
cratestack::include_schema!("schema.cstack");
pub struct AppProcedures;
#[async_trait::async_trait]
impl cratestack_schema::CoolProcedures for AppProcedures {
// generated required methods
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let cool = cratestack_schema::Cratestack::builder(pool)
.codec(cratestack_codec_cbor::CborCodec::default())
.envelope(cratestack::NoEnvelope::default())
.procedures(AppProcedures)
.build();
let app = axum::Router::new()
.nest("/api", cratestack_schema::routes(cool));
Ok(())
}10.2 COSE Setup
let cool = cratestack_schema::Cratestack::builder(pool)
.codec(cratestack_codec_cbor::CborCodec::default())
.envelope(cratestack_cose::CoseSign1Envelope::new(verifier, signer))
.procedures(AppProcedures)
.build();10.3 ORM Usage
let posts = cool
.post()
.find_many()
.where_(post::published().eq(true))
.limit(20)
.run(&ctx)
.await?;10.4 Procedure Usage
let post = cool
.procs()
.publish_post(PublishPostInput { post_id: 1 })
.run(&ctx)
.await?;11. Error Handling
CrateStack should use one primary error type:
pub enum CoolError {
BadRequest(String),
Unauthorized(String),
Forbidden(String),
NotFound(String),
Conflict(String),
Validation(String),
Codec(String),
Database(sqlx::Error),
Internal(String),
}Mapping to HTTP status:
BadRequest -> 400
Unauthorized -> 401
Forbidden -> 403
NotFound -> 404
Conflict -> 409
Validation -> 422
Codec -> 400 or 415
Database -> 500 or mapped conflict
Internal -> 500Errors must be encoded using the configured codec and sealed using the configured envelope if appropriate.
12. Security Requirements
- Default deny for all model and procedure operations.
- No generated route should bypass policy checks.
- Procedure implementations must use normal policy-protected ORM methods.
- No
as_systemAPI in v0. - Policy checks should be SQL-injected for read/update/delete where possible.
- Procedure-level permissions must be checked before calling implementation code.
- Auth context must come from host application or framework integration.
- Codec errors must not leak sensitive internals.
- Database errors should be sanitized before transport response.
- COSE verification failure must reject the request before decoding application data.
- COSE metadata should be available for audit or policy extensions later.
- Generated code must avoid SQL injection by using bind parameters.
13. CLI Requirements
Even though the primary integration is include_schema!, a CLI is useful for validation and future migration support.
Required v0 CLI commands:
cratestack check --schema schema.cstack
cratestack print-ir --schema schema.cstackOptional v0 commands:
cratestack format --schema schema.cstack
cratestack sql --schema schema.cstackDeferred commands:
cratestack migrate dev
cratestack db push
cratestack introspect14. Testing Requirements
14.1 Parser Tests
- valid schemas
- invalid schemas
- diagnostic locations
- procedures
- policies
- auth blocks
- type blocks
14.2 Semantic Tests
- duplicate names
- unknown types
- invalid auth references
- invalid model references
- missing primary keys
- invalid procedure return types
14.3 Policy Tests
- read policy SQL generation
- update policy SQL generation
- delete policy SQL generation
- create policy checks
- procedure policy checks
- delegated DB-backed procedure auth checks
- default deny behavior
14.4 ORM Tests
- create
- find unique
- find many
- update
- delete
- filtering
- pagination
- ordering
14.5 REST Tests
- CRUD routes
- procedure routes
- CBOR request decoding
- CBOR response encoding
- unsupported content type
- forbidden access
- unauthorized access
- not found
14.6 COSE Tests
- valid signed request
- invalid signature
- missing signature
- response signing
- key resolution failure
14.7 Integration Tests
Use temporary PostgreSQL database or test containers.
Test a full app flow:
schema.cstack
-> include_schema!
-> axum router
-> CBOR request
-> policy check
-> sqlx query
-> CBOR response15. Milestones
Implementation kickoff note:
The current repo slice completes Milestone 0 plus a narrow parser/macro/codec/CLI spike from Milestones 1, 3, and 7.
Current implementation note:
The current repo slice now also starts Milestone 4 with cratestack-sqlx, generated model structs, generated type structs, generated create/update input structs, a schema-specific CrateStack wrapper, and delegate scaffolding for create, find_many, find_unique, update, and delete.
Current limitations:
- Generated SQL currently assumes snake_case column names and naive pluralized snake_case table names.
- The current SQLx slice supports
where_with AND-combined scalar filters plusorder_bywith explicit ascending/descending clauses. - The current filter model is intentionally narrow and currently targets
eq,ne,in, stringcontains, stringstarts_with, boolean convenience helpers, andis_null/is_not_nullfor optional fields. - Generated create inputs currently exclude
@idand@default(...)fields. - Generated update inputs currently use optional patch fields and only include changed columns in SQL.
- Read policy SQL injection now exists for
@@allow(...)and@@deny(...)rules onfind_manyandfind_unique, with deny taking precedence over allow. - The supported model-policy subset is intentionally tiny:
auth() != null,auth() == null, boolean field checks,field == literal,field != literal,field == auth().field,field != auth().field,auth().field == literal,auth().field != literal, andallaction expansion, with&&/||support inside a single rule. - Policy parsing is still compile-time only inside the macro layer, and unsupported policy expressions fail code generation.
- Write-policy enforcement now exists for
create,update, anddelete, but only for the same tiny predicate subset used by the current read-policy implementation. - Create policies are evaluated against generated input values plus auth context before insertion, while update/delete policies are injected into SQL row scoping; create-time auth-derived defaults now support the narrow
@default(auth().field)form before create-policy evaluation. - DB-backed policy integration coverage now exists behind an env-gated test path using
CRATESTACK_TEST_DATABASE_URL. - The current executable auth/policy capability matrix now lives in
../reference/auth-support-matrix.md. - Procedure-level policy execution now exists through generated runtime wrappers under
cratestack_schema::procedures::*and reuses canonical policy types fromcratestack-policy. - The current procedure-policy subset now supports grouped
&&/||,auth() == null, nestedargs.<field>access, input-vs-auth comparisons, and input-vs-input comparisons, but it still does not expose a general DB-querying procedure policy language. - Generated procedure registry traits now exist and provide the first narrow execution contract for procedure implementations.
- Model-level
@@emit(...)directives now support the narrowcreated,updated, anddeletedevent set, generated typed subscription helpers undercratestack_schema::events, transactional outbox writes inside generated SQLx mutations, and explicitcratestack_schema::events().drain()style replay/drain hooks for in-process delivery. - Generated Axum procedure routes now exist for procedure invocation and enforce procedure policy checks plus delegated
@authorize(...)checks before handler execution. - Generated Axum model routes now exist for
GET /{modelPlural},GET /{modelPlural}/{id},POST /{modelPlural},PATCH /{modelPlural}/{id}, andDELETE /{modelPlural}/{id}and reuse the same codec/context/policy pattern. - The generated HTTP layer now enforces a single configured codec per router for
AcceptandContent-Type, returning406 Not Acceptableand415 Unsupported Media Typefor mismatches. - The current HTTP model slice is still intentionally narrow: list routes now expose canonical
fields, shallow declared-relationinclude, relation-specificincludeFields[relation], canonicalsort,limit,offset, scalar equality filters, a small operator suffix set (__ne,__lt,__lte,__gt,__gte,__in,__contains,__startsWith,__isNull) withcuid, UUID, and DateTime parsing, canonical groupedwhere=expressions withnot(...)>,(AND) >|(OR) precedence and left-associative chaining, and recursive relation filters backed by explicit@relation(fields:[...],references:[...])metadata, with to-many traversal requiring explicitsome/every/nonequantifiers and any to-manysortpath remaining unsupported. LegacyorderByis still accepted only as a compatibility alias. The route grammar remains narrower than the full generated delegate/runtime filter surface, while write routes forward directly into the generated delegate/runtime layer without a richer request DSL. cratestack/compose.ymlnow provides a local PostgreSQL 18 instance for driving DB-backed tests throughCRATESTACK_TEST_DATABASE_URL.- Arbitrary policy functions, explicit impersonation/acting-as semantics, and a general DB-querying procedure policy language remain future work.
Milestone 0: Project Skeleton
Deliverables:
- workspace setup
- core crates
- basic CI
- formatter/linter
- initial docs
Milestone 1: Schema Parser
Deliverables:
- parse datasource
- parse auth block
- parse models
- parse fields
- parse attributes
- parse type blocks
- parse procedures
- parse permissions
Milestone 2: Semantic Analyzer
Deliverables:
- schema IR
- validation errors
- type resolution
- policy expression validation
- procedure signature validation
Milestone 3: Macro Codegen
Deliverables:
include_schema!- generated model structs
- generated input structs
- generated procedure trait
- generated basic
CrateStacktype
Milestone 4: SQLx ORM
Deliverables:
- PostgreSQL-only execution
- create
- find unique
- find many
- update
- delete
- simple filters
- limit/offset
- ordering
Milestone 5: Policy Enforcement
Deliverables:
- model read policy
- model create policy
- model update policy
- model delete policy
- default deny
- procedure allow checks
Milestone 6: Axum REST
Deliverables:
- generated CRUD routes
- generated procedure routes
- path/query/body parsing
- error responses
- request context extraction
Milestone 7: Codec System
Deliverables:
CoolCodectrait- CBOR codec crate
- optional JSON codec crate
- generated handlers generic over codec
Milestone 8: Envelope System
Deliverables:
CoolEnvelopetraitNoEnvelope- initial COSE Sign1 envelope
- request verification
- response signing
Milestone 9: Hardening and Docs
Deliverables:
- security review
- integration examples
- tutorial
- API reference
- migration notes
- release candidate
16. v0 Acceptance Criteria
CrateStack v0 is successful when a developer can:
- Create a
.cstackschema with models, policies, types, and procedures. - Include it with
cratestack::include_schema!("schema.cstack"). - Instantiate the generated
CrateStacktype with a SQLx PostgreSQL pool. - Register procedure implementations.
- Use the generated ORM client in Rust.
- Mount generated Axum REST routes.
- Send CBOR requests to CRUD endpoints.
- Send CBOR requests to procedure endpoints.
- Receive CBOR responses.
- Enforce model permissions by auth context.
- Enforce procedure permissions by auth context.
- Confirm unauthorized operations are denied by default.
- Run without enabling JSON features.
- Optionally wrap requests/responses with a COSE envelope.
17. Open Questions
- Should procedure endpoints use
/api/$procs/name,/api/_procedures/name, or/api/actions/name? - Should read-like procedures ever support GET, or should v0 require POST for all procedures?
- Should the generated schema module always be named
cratestack_schema, or should the macro allow custom names? - Should codec selection be per-router, per-request, or compile-time feature based?
- Should COSE verification be allowed to populate
CoolContext.auth, or only transport metadata? - Should migrations be included in v0 or deferred?
- Should relation support be included in the first release or delayed until after scalar CRUD is stable?
- Should JSON be provided as an official optional crate from day one or delayed?
- Should policy expressions support custom functions in v0?
- Should generated handlers support pluralization customization?
18. Recommended Initial Decisions
For v0, make these decisions explicit:
- Use Axum only.
- Use PostgreSQL only.
- Use SQLx only.
- Use POST for all procedures.
- Use
/api/$procs/{name}for procedures initially. - Make CBOR the first official codec.
- Make JSON optional and not part of core.
- Make COSE an optional envelope crate.
- Keep authentication entirely external.
- Use default-deny permission semantics.
- Do not implement
as_system. - Generate a fixed
cratestack_schemamodule in v0. - Keep plugin architecture narrow: codec, envelope, auth/context extraction, and later hooks.
19. Risks
19.1 Macro Complexity
Large generated code from a procedural macro may produce difficult compiler errors.
Mitigation:
- strong schema diagnostics before codegen
- readable generated identifiers
- optional debug output later
- CLI
print-ir
19.2 SQL Policy Correctness
Incorrect policy injection could leak data.
Mitigation:
- default deny
- snapshot tests for generated SQL
- property-style authorization tests
- avoid policy bypass APIs in v0
19.3 Codec/Envelope Coupling
Mixing CBOR and COSE incorrectly could make the transport hard to reason about.
Mitigation:
- keep codec and envelope traits separate
- document strict processing order
- test request and response pipelines thoroughly
19.4 Scope Creep
ORMs, REST generation, policy systems, and procedure systems can expand endlessly.
Mitigation:
- PostgreSQL only
- Axum only
- no separate service-description format requirement
- no GraphQL
- no multi-db
- no full migration engine in v0
Correction: this mitigation list originally also said "no RPC." That is no longer accurate — RPC transport shipped in v0.3.3 (see internals/rpc-transport-adr.md and guides/rpc-transport.md for the current design) and has been actively iterated on since. The scope-creep risk this section describes is otherwise still real; RPC support was a deliberate, scoped addition rather than uncontrolled creep.
19.5 Rust Type Generation Ergonomics
Generated APIs may become verbose or unidiomatic.
Mitigation:
- start with simple delegates
- prioritize readable generated code
- provide examples early
- iterate with real app usage
20. Long-Term Roadmap
After v0:
- Relation loading.
- Nested includes.
- Nested writes.
- Migration engine.
- Additional databases.
- Actix integration.
- Poem integration.
- More codecs.
- More COSE envelope modes.
- Policy custom functions.
- Hooks.
- Soft delete plugin.
- Field-level encryption plugin.
- Generated admin UI metadata.
- Optional additional documentation/export surfaces.
- Optional client generation.
- More advanced procedure routing.
- Better diagnostics and editor tooling.
21. One-Sentence Product Definition
CrateStack is a Rust-native schema-first framework layer that turns .cstack files into a typed SQLx-backed ORM, policy-protected REST CRUD API, and custom REST procedure endpoints with pluggable CBOR/COSE-capable transport support.