Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Ferra is a Rust framework for building HTTP APIs from a single source of truth. You annotate a struct once — Ferra derives the routes, the SQL queries, the OpenAPI spec, the documentation UI, and the hypermedia automatically, at compile time. If the struct changes, everything downstream changes with it. If the generated code is inconsistent, the build fails.


The problem Ferra solves

Building a conventional REST API means describing the same data structure multiple times:

  • once in a migration file (the database schema),
  • once as a Rust struct (the model),
  • once in each handler (request body, response type, SQL query),
  • once in OpenAPI annotations (#[utoipa::path(...)], response schemas),
  • once in validation rules.

Every copy can drift from the others — and none of these mismatches are caught at compile time. A field renamed in the struct but not in the OpenAPI annotation is a silent documentation lie. A validation rule missing from one handler is a silent security gap.

Ferra collapses all five copies into one struct:

use ferra::*;

#[derive(FerraModel)]
pub struct Film {
    #[id]
    pub id:    Id,
    pub title: String,
    pub year:  i32,
}

What you get from twelve lines

#[tokio::main]
async fn main() {
    let conn = sea_orm::Database::connect(std::env::var("DATABASE_URL").unwrap()).await.unwrap();
    let app  = Foundry::new(conn).mount::<Film>().build();
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Running cargo run gives you all of the following — no additional code required.

Five CRUD routes

MethodPathDescription
GET/filmsPaginated collection — ?page=1&per_page=20
GET/films/:idSingle resource by ID
POST/filmsCreate — returns 201 + Location header
PUT/films/:idFull update — returns 200 or 404
DELETE/films/:idDelete — returns 204 or 404

Pagination is first-class, not a bolt-on. The collection endpoint always returns a page envelope with total, page, per_page, and the HAL _links for next and prev.

Every item response includes a _links block following the HAL standard. Clients and AI agents can navigate the API without prior knowledge of its URL structure:

{
  "id":    "01JVZ2RFJK3N7XQKE4M8P6SWTD",
  "title": "Amélie",
  "year":  2001,
  "_links": {
    "self":       { "href": "https://api.example.com/films/01JVZ2RFJK3N7XQKE4M8P6SWTD" },
    "collection": { "href": "https://api.example.com/films" }
  }
}

The _links.self href is always absolute and always correct — built from the Host header at request time, not hardcoded at build time.

OpenAPI 3.1 spec that cannot drift

The spec is served at /docs/openapi.json and the interactive Scalar UI at /docs. Both are derived from the same struct the routes are derived from — they cannot disagree with the running code. There are no annotations to forget, no #[utoipa::path] attributes to keep in sync.

Add a field to Film and it appears in the spec, the Scalar UI, the request validation, and the SQL insert — all at the next cargo build.

Typed SQL — injection-proof by construction

Every generated query is parameterized. There is no mechanism in the framework to concatenate user input into a SQL string. SQL injection is structurally excluded, not just discouraged — the framework has #![forbid(unsafe_code)] on every crate.

RFC 7807 errors — typed and machine-readable

Every error response follows RFC 7807 problem details. The type field is a URI from a closed set the framework maintains as a constant — not a free-form string. SDK generators and AI agents can produce a typed branch for every possible error because the set is expressed as a closed enum constraint in the OpenAPI spec.

{
  "type":   "https://ferra.rs/errors/not_found",
  "title":  "Resource Not Found",
  "status": 404,
  "detail": "films/01JVZ2RFJK3N7XQKE4M8P6SWTD not found"
}

The complete error URI table is documented in Error Handling.

Security defaults, not security options

  • Rate limiting on mutation routes (POST, PUT, DELETE) — out of the box, no middleware to remember to add.
  • Body size cap — requests over 1 MiB are rejected before the body is read.
  • CORS — restrictive by default. You opt in to origins, not out of open access.
  • #![forbid(unsafe_code)] — on every crate in the workspace, no exceptions.

Extending beyond the generated surface

The generated CRUD surface is a floor, not a ceiling. Ferra routes are standard Axum handlers — hand-written routes compose freely alongside them:

async fn top_rated(State(conn): State<DatabaseConnection>) -> Result<Json<Vec<Film>>, FerraError> {
    let films = Film::find()
        .filter(film::Column::Year.gt(2000))
        .order_by_desc(film::Column::Rating)
        .limit(10)
        .all(&conn)
        .await
        .map_err(|e| FerraError::Internal(e.to_string()))?;
    Ok(Json(films))
}

let app = Foundry::new(conn)
    .mount::<Film>()
    .route("/films/top-rated", get(top_rated))
    .build();

Custom handlers reuse the same FerraError enum and emit the same RFC 7807 bodies — clients handle one error shape across the entire API surface.


What’s coming — Cast Iron to Hardened Steel

Ferra is in its Cast Iron phase (v0.5–v0.14): each version adds one major capability on the way to a stable 1.0.

v0.5 — Rolling · OpenAPI 3.1 + Scalar UI

The OpenAPI 3.1 spec and the Scalar interactive UI land here. The spec is a deterministic compiler output — if a field type changes, the spec changes. If the spec would be wrong, the code does not compile.

Also ships: ferra::DateTime and ferra::Date newtypes over jiff for timestamp fields — serialized as RFC 3339, correctly handled in OpenAPI schemas.

v0.6 — Welding · Field validation

Declarative validation rules directly on struct fields, generated by the proc-macro into garde validators. Rules are applied automatically on POST and PUT before the query runs:

#[derive(FerraModel)]
pub struct Film {
    #[id]
    pub id:    Id,
    #[field(min_length = 1, max_length = 255)]
    pub title: String,
    #[field(min = 1888, max = 2100)]
    pub year:  i32,
    #[field(email)]
    pub contact: String,
    #[field(url)]
    pub poster:  Option<String>,
}

Violations return 422 Unprocessable Content with a per-field errors map. Every failing field is reported in a single response — clients never retry to discover further errors.

{
  "type":   "https://ferra.rs/errors/validation_failed",
  "title":  "Validation Failed",
  "status": 422,
  "detail": "validation failed",
  "errors": {
    "title":   ["must be at least 1 character"],
    "year":    ["must be at most 2100"],
    "contact": ["must be a valid email address"]
  }
}

v0.7 — Pre-tempering · Model behaviors

Common database patterns available as struct-level annotations:

#[derive(FerraModel)]
#[behavior(timestampable, soft_delete)]
pub struct Film {
    #[id]
    pub id:    Id,
    pub title: String,
}
  • timestampablecreated_at and updated_at are injected automatically. INSERT sets both; UPDATE refreshes updated_at. No field declarations needed in the struct.
  • soft_deleteDELETE /films/:id sets deleted_at instead of removing the row. All SELECT queries automatically filter WHERE deleted_at IS NULL. A soft-deleted item is invisible to the API — it returns 404, not a deleted record.

v0.8 — Forging · Authentication + authorization

Auth is deny-by-default. Forgetting to configure it is a startup panic, not a silent open endpoint.

You implement one trait to bridge your JWT claims to Ferra’s permission system:

impl FerraIdentity for MyClaims {
    fn id(&self) -> &str { &self.sub }
    fn has_role(&self, role: &str) -> bool { self.roles.contains(role) }
}

Then declare access rules on the model:

#[derive(FerraModel)]
#[authorize(
    read   = "IsAuthenticated",
    write  = "IsOwner | IsAdmin",
    delete = "IsAdmin"
)]
pub struct Film {
    #[id] pub id: Id,
    pub title:   String,
    #[field(owner)]
    pub owner_id: Id,
}

IsOwner is resolved automatically by comparing identity.id() to the #[field(owner)] field — no custom handler code needed.

v0.8.5 extends this with external JWKS providers (Auth0, Keycloak, Cognito, Google), API key authentication, and a FerraAuthProvider trait for bringing any custom auth mechanism.

v0.9 — Forging III · Role-based field projections

Different callers see different fields of the same model, declared statically:

#[derive(FerraModel)]
#[project(read = ["id", "title", "year"], admin_read = ["id", "title", "year", "internal_notes"])]
#[authorize(read_as = { IsAdmin => "admin_read", _ => "read" })]
pub struct Film { /* ... */ }

The OpenAPI spec emits a oneOf for the two response shapes — SDK generators produce typed variants, not a union of optional fields. The projection mismatch is caught at compile time.

v0.10 — Assembly · Relations

IRI-based relations between models, declared in the struct:

#[derive(FerraModel)]
pub struct Film {
    #[id] pub id: Id,
    pub title: String,
    #[field(belongs_to = Director)]
    pub director_id: Id,
    #[field(has_many = Review)]
    pub reviews: Vec<Id>,
}

Foreign keys are stored as IDs. In API responses, they are rendered as IRI references ("/directors/01HXYZ"). Write endpoints accept the same IRI format. The OpenAPI spec documents the relation type correctly.

v0.12 — Sheen · Test client + observability

FerraTestClient for integration testing without a running server:

let client = FerraTestClient::new(state);
let res    = client.create(Film { title: "Amélie".into(), year: 2001 }).await;
assert_eq!(res.status(), 201);

let res = client.with_auth(admin_claims).delete(id).await;
assert_eq!(res.status(), 204);

Also ships: built-in tracing spans per request and per DB query, and an optional OpenTelemetry export via --features otel.

v0.14 — Deployment · Serverless

ferra = { package = "ferra-rs", version = "0.14", features = ["lambda"] }
// The same app code runs on Axum or Lambda — the entry point is the same.
ferra::run(app).await.unwrap();

FerraState::lazy() defers the DB connection to the first request for Lambda cold-start optimization.

v1.0 — Hardened Steel · Stable API

Security audit, benchmarks, crates.io publication. The public API is Semver-stable from this point. The invariant holds from the first beta: a #[derive(FerraModel)] written at v1.0 compiles unchanged in v2, v3, v4, and v5.


What Ferra is not

Not an ORM. Database schema management (migrations, table creation) is handled by Sea-ORM CLI and lives outside the framework. Ferra generates the queries; you manage the schema.

Not a general-purpose web framework. No template engines, no session handling, no WebSockets yet. Ferra is narrowly focused on REST + hypermedia + typed errors. When you need to go further, Axum handlers compose directly alongside Ferra routes.

Not stable yet. The public API follows Semver once 1.0 ships. Until then, minor versions may introduce breaking changes — each release documents them.


How to read this guide

  • Getting Started — zero to running API in five minutes. Start here.
  • Architecture — Ferra’s cohabitation principle: what the framework wraps, what it re-exports verbatim, and where the upstream crate docs are the canonical reference.
  • Foundry — the router builder: configuration, middleware stack, mounting multiple models.
  • Ferra DBPgRepository<M>, query methods, and how Sea-ORM sits underneath.
  • Ferra HTTP — extractors, the FerraError enum, the built-in middleware stack.
  • Ferra OpenAPI — the generated spec, the Scalar UI, schema customization.
  • Ferra Forge — the #[derive(FerraModel)] proc-macro: field attributes, deferred keys, compile-time diagnostics.
  • Custom Handlers — Axum handlers alongside generated routes, sharing the same error taxonomy.
  • Error Handling — the closed RFC 7807 error URI set, per-variant wire shape, client branching patterns.

Getting started with Ferra

Ferra is a Rust framework for building HTTP APIs from a single #[model] source of truth. One model annotation gives you a CRUD router, an OpenAPI 3.1 spec, a Swagger / Scalar UI, hypermedia responses, and typed RFC 7807 errors — all derived at compile time.

This page walks you from cargo new to a running, documented API. If you only have five minutes, read Add Ferra to your project, The five-line CRUD, and Run it.


Important: the crate is published as ferra-rs

The ferra name on crates.io belongs to an unrelated project, so the framework’s facade crate is published as ferra-rs. To keep your Rust code idiomatic, use Cargo’s package alias — declare the dependency under the local name ferra while pulling ferra-rs from crates.io:

[dependencies]
ferra = { package = "ferra-rs", version = "0.5" }

The alias is invisible from this point forward. Every use ferra::... import, every example in this guide, every doc snippet on docs.rs uses the ferra name — Cargo handles the indirection.

ferra.rs (the domain) and ferra-rs (the crate) are two different identifiers. The framework’s official domain is ferra.rs — that’s where the documentation site lives and where the closed error-type namespace (https://ferra.rs/errors/<variant>) resolves. The crate name on crates.io is ferra-rs (with a hyphen), because the dotless ferra name was already taken. Both are correct and authoritative; do not substitute one for the other.

If you see a plain ferra = "..." line in older snippets (with no package = "ferra-rs" key), that’s pre-rename documentation. Replace it with the form above — the plain line resolves to a different, unrelated crate on crates.io.


Add Ferra to your project

A consumer crate built on Ferra needs one direct framework dependency plus the surrounding async / HTTP / serde stack:

[package]
name    = "my-api"
version = "0.1.0"
edition = "2024"

[dependencies]
ferra = { package = "ferra-rs", version = "0.5" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
axum  = "0.8"
serde = { version = "1", features = ["derive"] }

That is the entire framework dep surface. ferra-core, ferra-db, ferra-forge, ferra-http, ferra-openapi, and the surrounding sea-orm ORM are all reached through the ferra facade — they do not appear as direct dependencies. Adding them directly fights the facade’s cohabitation guarantees and is flagged by the framework’s example dep-shape gate.

The one-line opener

Open every Ferra source file with a single glob over the prelude:

use ferra::prelude::*;

That brings into scope, in one line:

  • The #[derive(FerraModel)] derive — the model annotation.
  • The typed time newtypes Date and DateTime.
  • The typed error type FerraError.
  • The runtime state and router primitives FerraState, ferra_router, and the Foundry builder.
  • The typed-extraction wrappers FerraJson, FerraPath.
  • The response envelopes ItemResponse, CollectionResponse and the PaginationParams extractor.
  • Sea-ORM’s entity-derive prelude (DeriveEntityModel, EntityTrait, ColumnTrait, Uuid, …) reached via the cohabitation re-export.
  • serde::{Deserialize, Serialize} so model structs declare their derive set with no extra imports.

Reaching Sea-ORM through the facade

When you need Sea-ORM types directly — typically the Database::connect(...) constructor, or the #[sea_orm(...)] attribute namespace on a model struct — write ferra::sea_orm::...:

let conn = ferra::sea_orm::Database::connect(&database_url).await?;

The prelude glob already brings the sea_orm module name into scope, so the standard sibling-derive pair #[derive(DeriveEntityModel, FerraModel)] works with no extra use sea_orm::entity::prelude::*; line.


The five-line CRUD

The smallest meaningful Ferra application — one resource, full CRUD, auto-generated OpenAPI spec, Scalar docs UI, and HAL hypermedia responses — fits in this src/main.rs:

use ferra::prelude::*;

mod film {
    use ferra::prelude::*;

    #[derive(Clone, Debug, PartialEq, Eq,
             DeriveEntityModel, FerraModel,
             Serialize, Deserialize)]
    #[sea_orm(table_name = "films")]
    pub struct Model {
        #[sea_orm(primary_key)]
        pub id: i32,
        #[sea_orm(unique)]
        pub title: String,
        pub director: String,
        pub year: Option<i32>,
    }

    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
    pub enum Relation {}

    impl ActiveModelBehavior for ActiveModel {}
}

pub use film::Model as Film;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let database_url = std::env::var("DATABASE_URL")?;
    let conn = ferra::sea_orm::Database::connect(&database_url).await?;
    let app = Foundry::new(conn).mount::<Film>().with_docs().build();
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

What you get on cargo run:

URLBehaviour
GET /filmsPaginated list of films with HAL _links.
GET /films/{id}Single film + self-link.
POST /filmsCreate — validates, returns 201 Created.
PUT /films/{id}Update.
DELETE /films/{id}Delete.
GET /docs/openapi.jsonGenerated OpenAPI 3.1 spec — schema names Film, CreateFilmInput, UpdateFilmInput, FilmCollection, ProblemDetails.
GET /docsInteractive Scalar UI rendered from the spec.

Errors come back as RFC 7807 application/problem+json with typed type URIs from a closed namespace (https://ferra.rs/errors/<variant>).

The example in examples/hello-ferra/ in the framework repository matches this layout one-to-one and is the canonical reference. If you’re reading this through docs.rs, the same example is published under that name on the Ferra repository.


Run it

You need PostgreSQL reachable at DATABASE_URL. The fastest local setup uses Podman or Docker:

podman run --rm -d --name pg \
  -e POSTGRES_USER=ferra -e POSTGRES_PASSWORD=ferra \
  -e POSTGRES_DB=my_api \
  -p 5432:5432 \
  postgres:16

export DATABASE_URL=postgres://ferra:ferra@localhost:5432/my_api

Apply your schema migrations (Sea-ORM CLI) and start the server:

cargo run

You should see listening on 0.0.0.0:3000 — open http://localhost:3000/docs in a browser.


MSRV and toolchain

Ferra’s MSRV is Rust 1.88 (edition 2024, resolver v3). let chains, native async fn in traits, and the rest of the modern Rust feature surface this guide assumes are stable on 1.88+.

You don’t need a pinned development toolchain in your own project — any stable Rust at or above 1.88 works. The framework itself pins a specific toolchain in its repository for reproducible CI; that pin does not propagate to consumers.


Next steps

  • Foundry — the router-assembly facade. Multi-resource chains, API versioning, the with_docs_protected(...) auth-gated docs variant.
  • Ferra Forge — the #[derive(FerraModel)] derive grammar; the full attribute set on model structs and fields.
  • Ferra OpenAPI — what the generated spec contains, schema-name derivation, and how SDK generators consume it.
  • Custom Handlers — drop down to your own Axum handler when Ferra’s defaults don’t fit a specific endpoint.
  • Error Handling — the closed https://ferra.rs/errors/<variant> namespace, the ProblemDetails shape, and consumer-side branching.

Ferra and the Rust ecosystem

Ferra is a thin layer over a small set of upstream Rust crates that already solve specific concerns well. This page explains what is Ferra-defined and what is re-exported, so that you know where to look for documentation and which surface to import when you build an application.

If you are looking for “five-minute zero-to-API,” see Getting started. This page is about the long-term shape of the dependency graph you inherit when you depend on ferra.


The principle: cohabitation, not encapsulation

Ferra does not wrap the upstream crates it integrates. Instead, it does one of two things:

  1. Direct re-export — when an upstream crate already offers a stable, namespaced surface, Ferra simply re-exports it under ferra::*. You import ferra::sea_orm::DatabaseConnection, ferra::biscuit_quote::check, and you call them with the API the upstream crate documents. There is no Ferra-renamed equivalent.
  2. Default-locking + opt-in feature — when an upstream crate is correct for some applications but not all, Ferra locks the upstream-default configuration explicitly behind a Cargo feature, so the dependency weight only lands when you opt in.

Ferra introduces a wrapper or a Ferra-defined trait only when one of three things is true:

  • There is concrete behaviour to inject at the call site (logging, validation, span propagation).
  • A typed Ferra newtype prevents misuse that the upstream type does not catch (Email, UserId).
  • A Ferra trait abstracts over multiple backends with build-time or runtime swap-out (FerraAuthProvider, RateLimitStore).

If none of those apply, the upstream surface is exposed verbatim and the upstream documentation is the canonical reference.


What lives under ferra::*

Ferra-defined surface

The ferra:: namespace defines a small, stable user-facing API:

NamespaceWhat it is
ferra::FoundryThe router builder — Foundry::new(conn).mount::<M>().build().
ferra::IdThe framework’s identifier type used by #[id] fields.
ferra::RouterThe composed Axum router returned by Foundry::build().
ferra::FerraErrorThe framework-wide error enum surfaced in HTTP responses.
ferra::FerraLayerThe Tower layer composition entry point.
#[derive(FerraModel)]The proc-macro that turns a struct into a Ferra resource.
#[ferra(...)]The attribute namespace for Ferra-specific concerns.
#[id], #[field(...)]Field-level attributes for primary keys and projection rules.
#[authorize(...)]Operation-level authorization rules.

The Ingot, ferra-forge, ferra-anvil thematic names belong to internal documentation. You will not see them in your application code.

Re-exported upstream surface

The rest of ferra::* is re-exports. The biggest namespaces:

NamespaceRe-exportsDocumentation
ferra::sea_ormAll of sea_ormsea_orm docs.rs
ferra::biscuit_quoteAll of biscuit-quotebiscuit-quote docs.rs
ferra::tracingAll of tracingtracing docs.rs

When you write:

use ferra::sea_orm::Database;

let conn = Database::connect("postgres://...").await?;

…you are calling sea_orm::Database::connect. The upstream documentation applies verbatim. There is no Ferra-side wrapping.

Crate graph: modules pub(crate), items glob-exported at root

Every framework implementation crate (ferra-core, ferra-db, ferra-forge, ferra-http, ferra-openapi) keeps its modules pub(crate) and re-exports its public items at its crate root. The facade then writes pub use ferra_<crate>::*; once per implementation crate. A consequence is that two implementation crates can share a module name (emit, routes, schema, …) without collisions: only the items at each crate’s root reach the facade, and the items themselves are uniquely named across the workspace.

This rule is enforced mechanically. The cargo clippy --workspace -- -D ambiguous_glob_reexports lint fires the moment two glob re-exports in the facade name the same item from different sources; CI gates the lint as a release-blocker (FR-024). A future regression that exposes a sub-module from any implementation crate as pub would be caught the next time the facade picks up a sibling-crate item with the same name.

If you are reading the source code: prefer the absence of pub mod on every implementation crate’s lib.rs. The single exception is ferra::prelude on the facade itself — the prelude is intended to be opened directly by consumers, so its module visibility is pub (FR-025).


Cargo feature surface

Ferra’s default dependency graph is intentionally lean. Heavy or audience-partial features are opt-in.

Feature flagWhat it enablesWhen you want it
(default)Core framework + tracing + structured stderr JSON loggingAlways.
otelOpenTelemetry SDK + OTLP/HTTP exporter (locked to upstream-default transport)You run an OTel collector and want OTLP exports.
otel-grpcotel + gRPC transport (instead of HTTP/protobuf)Your collector requires gRPC and your ingress preserves HTTP/2.
otel-fipsotel + runtime-installed FIPS-compliant rustls CryptoProviderFIPS-compliant deployments.

Activating a feature

[dependencies]
ferra = { package = "ferra-rs", version = "0.x", features = ["otel"] }

The package = "ferra-rs" alias is required because the ferra name on crates.io is owned by an unrelated project; the framework’s facade is published as ferra-rs. See Getting Started for full details.

Adding the feature pulls the locked OTel SDK pin into your build; without the feature, none of those crates compile. There is no runtime check — the choice is at compile time.


Where to look for documentation

QuestionSource
How do I declare a model?This user guide → ferra-forge.md
What does Foundry::new(...) do?This user guide → ferra-http.md
How do I run a database query?Upstream — sea_orm docs.rs (Ferra re-exports verbatim)
What are valid Datalog rules in #[authorize]?Upstream — biscuit documentation + the rule grammar reference
How do I configure tracing subscribers?Upstream — tracing-subscriber docs.rs (Ferra re-exports verbatim)

When you ask an AI coding assistant about a Ferra application, the assistant should reach for upstream documentation for re-exported surfaces and for this user guide for Ferra-defined surface. The distinction matters because upstream training corpora are large and Ferra-specific corpora are small — the cohabitation pattern is what makes a Ferra application legible to AI agents trained on the broader Rust ecosystem.


A worked example

A complete Ferra application demonstrating both halves of the principle:

// Cargo.toml:
//   ferra = { package = "ferra-rs", version = "0.x", features = ["otel"] }

// Direct re-export (Half A): upstream `sea_orm` reached without renaming.
use ferra::sea_orm::Database;

// Direct re-export (Half A): upstream `biscuit-quote::check` for compile-time
// Datalog. A typo here is a `cargo build` error, not a runtime panic.
use ferra::biscuit_quote::check;

// Ferra-defined surface: the model derive, the router builder.
use ferra::{Foundry, FerraModel, Id};

#[derive(FerraModel)]
struct Film {
    #[id]
    id: Id,
    title: String,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Upstream API (sea_orm): no Ferra-side wrapper.
    let conn = Database::connect("postgres://localhost/films").await?;

    // Ferra-defined: the router composition surface.
    let app = Foundry::new(conn).mount::<Film>().build();

    // Upstream API (axum / hyper): Ferra returns a standard Axum Router.
    axum::serve(tokio::net::TcpListener::bind("0.0.0.0:3000").await?, app).await?;
    Ok(())
}

The same application without the otel feature compiles to a smaller binary; the OTel SDK is not in the dependency graph at all.


When you need a Ferra wrapper that does not yet exist

If you find yourself wanting a Ferra-side wrapper around an upstream type — a Ferra newtype, a Ferra trait abstracting multiple backends, or a Ferra macro that does something the upstream macro does not — the wrapper is a feature request worth filing. The maintainers will ask which of the three justifications applies (concrete behaviour to inject, type-narrowing, swap-out insurance) and ship the wrapper at that point. Until then, the upstream surface is the documented path.

Foundry

Foundry is Ferra’s router-assembly facade. One chain — beginning with a Sea-ORM DatabaseConnection and ending with .build() — declares every resource on your API, mounts the OpenAPI 3.1 docs surface, applies version prefixes, and produces the final axum::Router.

use ferra::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let conn = sea_orm::Database::connect(&std::env::var("DATABASE_URL")?).await?;
    let app = Foundry::new(conn)
        .mount::<Film>()
        .mount::<Actor>()
        .with_docs()
        .build();
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

The chain replaces the per-resource conn.clone() + FerraState::new + ferra_router::<M> + Router::merge boilerplate. Two compile-time invariants are enforced by the type system: no duplicate .mount::<M>() calls, and no mixing of un-versioned with versioned mounts on a single chain.

Foundry::build() -> axum::Router is the sole method whose return type names an axum::* type. Every intermediate value is a Foundry-defined builder. Consumer-added .layer() calls on the returned router land outside the framework’s Tower stack.


The chain at a glance

MethodDefaultOverride
Foundry::new(conn)begins the chain
.mount::<M>()mounts the model’s CRUD on /{resource}.mount_with::<M>(state) (custom FerraState)
.with_docs()publishes /docs/openapi.json + /docs (Scalar UI).with_docs_at(path)
.with_docs_protected(verifier)gates docs behind the verifier (RFC 7807 401 on rejection).with_docs_protected_at(path, verifier)
.api_version("v1")?transitions to a versioned chain (paths under /v1/)
.deprecated(date)adds Sunset: header + deprecated: true + x-sunset to the spec
.build()finalizes the chain into axum::Router

api_version returns Result<VersionedFoundryBuilder, ApiVersionError> — prefixes that are empty, contain /, contain whitespace, or contain characters unsafe in a URL path segment are rejected at construction time.


The no-duplicate-mount rule

Mounting the same model twice is a compile error. The typestate-tracked Set: NotMounted<M> bound forecloses route shadowing at the type level (FR-012a, ADR-0025). The #[diagnostic::on_unimplemented] message names the duplicated type:

error[E0277]: the model `Film` is already mounted on this Foundry chain
   --> src/main.rs:5:33
    |
5 |         .mount::<Film>().mount::<Film>().build();
    |                          ^^^^^^^^^^^^^ duplicate `.mount::<Film>()` —
    |                                        remove this call or rename the model
    |
    = note: see https://ferra.rs/guide/foundry#duplicate-mount for the supported pattern

If you genuinely need two different shapes for the same underlying type, define two distinct Rust types — Ferra’s metadata travels with the type, not with the resource name.


The no-mixed-mode rule

.api_version(...) is callable only on an empty chain. Mounting an un-versioned model first and then calling .api_version("v1") is a compile error (FR-014a, ADR-0025):

error[E0277]: `.api_version(...)` cannot be called after un-versioned `.mount(...)` calls
   --> src/main.rs:6:14
    |
6 |         .api_version("v1")?
    |          ^^^^^^^^^^^^^^^^ this builder already has un-versioned mounts
    |
    = note: to ship mixed-mode (legacy + versioned) routes, build two
            assemblies and merge them via `axum::Router::merge`. See
            https://ferra.rs/guide/foundry#mixed-mode for a worked example.

Mixed-mode pattern: two assemblies merged

If you want a top-level /films and a /v1/films, build two Foundry chains and merge them with axum::Router::merge:

use ferra::*;

let conn = /* … */;

let legacy = Foundry::new(conn.clone())
    .mount::<Film>()
    .with_docs() // serves /docs/openapi.json
    .build();

let v1 = Foundry::new(conn)
    .api_version("v1")?
    .mount::<Film>()
    .deprecated(date!(2027-01-05))
    .with_docs() // serves /docs/v1/openapi.json
    .build();

let app: axum::Router = legacy.merge(v1);

Each Foundry::build() produces its own axum::Router; merging them is the supported way to expose multiple-version surfaces from a single binary. The OpenAPI documents stay separate — one per build.


Versioning + deprecation

.api_version("v1")? nests every mounted resource under /v1/, prefixes every operationId with v1., and serves the spec at /docs/v1/openapi.json.

.deprecated(date) activates two parallel signals:

  • a Sunset: YYYY-MM-DD HTTP header on every response under the version (RFC 8594);
  • deprecated: true + x-sunset: YYYY-MM-DD on every operation in the emitted spec, plus an info.x-sunset mirror at the document root (ADR-0027).
use ferra::*;

let v1 = Foundry::new(conn)
    .api_version("v1")?
    .mount::<Film>()
    .deprecated(date!(2027-01-05))
    .with_docs()
    .build();

The date! macro validates the calendar date at compile time; an invalid month or day fails cargo build rather than panicking at startup. See ferra-core.md §“Time vocabulary”.

Post-sunset warn-event

When today_utc > sunset (strict greater-than — equality does not trigger; the sunset day is the last day of the grace window), every request under the deprecated version causes the framework to emit a structured warning at tracing::Level::WARN. The event is layered into the same middleware that emits the Sunset: response header, so the two signals always travel together.

Verbatim target string (filterable via tracing-subscriber’s EnvFilter):

ferra_http::sunset

Field set on the event:

Field nameTypeSource
versionstringthe version prefix ("v1")
sunsetstringthe declared sunset date in YYYY-MM-DD form
days_overduei64whole days from sunset to today_utc
http.methodstringthe inbound request method, upper-cased
http.targetstringthe inbound path?query (includes the version prefix)

Message body (verbatim):

deprecated API version reached after declared sunset date

Once-per-process de-duplication: the framework fires the warning exactly once per (version, sunset) pair within a single running process. Two sequential requests on the same pair produce one warning; two distinct pairs (e.g., v1/2026-01-01 and v2/2026-06-01) each fire once independently. The dedup state is process-local — a process restart re-creates the registry and the first post-restart hit fires the warning again. This is the right default for operational paging: a single human-readable signal per incident, repeated only when a release rolls the world.

Operator filtering. An operator who wants only the post-sunset warnings (and nothing else from the framework) sets:

RUST_LOG=ferra_http::sunset=warn

An operator who wants every framework-emitted event at warn level:

RUST_LOG=ferra_http=warn

The convention follows the <snake-cased crate name>::<event segment> pattern; the rate-limit event lives at ferra_http::rate_limit and is configured the same way.

Metrics counter (gated by the observability Cargo feature). When the observability feature is enabled:

[dependencies]
ferra = { package = "ferra-rs", version = "0.7", features = ["observability"] }

the framework also advances a metrics::counter! named ferra.sunset.post_sunset_hits on every post-sunset request, labelled with version and sunset. Unlike the warn-event, this counter advances on every hit — its purpose is rate-of-arrival visibility, not signal-per-incident paging. With the feature off (default), the framework takes zero dependency on the metrics crate; the constitutional 0.4.0 secure defaults are byte-identical for non-opting-in consumers.

No-deny on post-sunset: the comparator is observability-only. The framework continues to serve every request past sunset; if you want denial after a date, add your own middleware. A first-class deprecated_with_enforcement(...) opt-in is on the roadmap but not shipped at 0.7.0.

Per-request budget: the comparator’s common path runs in well under 200 ns on the CI reference runner and is informational-tier under 100 ns. The framework’s first build-gating performance contract enforces this: a regression past 200 ns fails the build (scripts/bench/check_sunset_ceiling.sh against cargo bench --bench sunset_comparator). The two-tier discipline is documented in ADR-0039 as the pattern for future hot-path additions.


Public-default docs and the protected variant

.with_docs() exposes the documentation surface unauthenticated by default. This is a deliberate, documented departure from Ferra’s model-route default-deny posture (Q1 in the 0.5.0 specification, arbitrated in ADR-0024). The reasoning: the docs surface describes the API but does not serve resource data; gating it would block the constitutional zero-to-documented-API standard for every new consumer.

For any publicly-reachable network surface, switch to .with_docs_protected(verifier). The verifier closure receives the inbound request and returns bool; a false produces an RFC 7807 401 Unauthorized response with type: "https://ferra.rs/errors/unauthorized":

use ferra::*;
use axum::http::header::AUTHORIZATION;

let app = Foundry::new(conn)
    .mount::<Film>()
    .with_docs_protected(|req: &axum::extract::Request| {
        req.headers()
            .get(AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            == Some("Bearer secret")
    })
    .build();

The verifier runs synchronously and MUST NOT perform I/O — the underlying primitive does not yet integrate with the ferra-auth provider chain (that landing is scheduled for 0.8.5 beta). The current API accepts any Fn(&Request) -> bool + Send + Sync + 'static; the future Provider arm of DocsAuthVerifier will be additive.


Layer-ordering invariance

Every resource mounted through Foundry carries the per-resource Tower stack ferra_router::<M> builds: CORS (restrictive default), the 413-mapping middleware, the 1 MiB body limit, tracing with body sampling off, and per-route rate limiting on mutation endpoints. Foundry does not reorder, replace, or remove any of these layers.

Consumer-added .layer(...) calls on the value Foundry::build() returns land outside the framework’s stack — the standard Tower composition rule. If you need a layer to wrap framework layers, add it on the returned router; if you need it to sit beneath the framework layers, fork to mount_with::<M>(state) and pre-compose on the state’s ferra_router::<M> output.

Cross-reference: ferra-http.md §“Tower layer-ordering notation” documents the exact stack order.


Transport-layer overrides

Reader contract. This section stands on its own. A reader with only this page plus standard Rust knowledge produces a correct per-app or per-model override on the first attempt. Every knob, default, fallback, and validation rule is spelled out below — no ADR and no framework source is required.

The framework’s secure Tower stack (CORS deny-by-default, 1 MiB body cap, { per_second: 2, burst_size: 5 } mutation rate-limit, tracing with body sampling off) is the right default for the vast majority of APIs. Some shapes — file uploads, high-volume mutation endpoints, sub-path deployments behind a reverse proxy — need narrow overrides. Use the FerraLayer builder for those.

FerraLayer is a single value carrying overrides for five knobs:

KnobDefaultOverride surface
CORSCorsLayer::new() (deny by default).cors(cors_layer)
Body cap1 MiB.body_limit(bytes)
Rate-limit (mutations only)RateLimitRule::new(2, 5) (2/sec, burst 5).rate_limit(RateLimitRule::new(per_second, burst_size))
TracingTraceLayer::new_for_http() with body sampling off.trace_layer(layer)
Base path for _linksnone.base_path("/api")

A FerraLayer whose every knob is left at its None default (FerraLayer::new().build()) reproduces the framework stack byte-for-byte — the override surface is silent when unused.

RateLimitRule construction. Always use the RateLimitRule::new(per_second, burst_size) constructor. The struct carries #[non_exhaustive] to accommodate future additive knobs (e.g., per-key extractor selection, per-method overrides) without breaking downstream construction sites; direct struct-literal construction (RateLimitRule { per_second: 2, burst_size: 5 }) is rejected by the compiler from outside the crate.

Per-app vs per-model — REPLACE not merge

FerraLayer applies at two scopes:

  • Per-application defaultFoundry::new(conn).layer(global) — every model mounted afterwards inherits global’s settings.
  • Per-model overrideFoundry::new(conn).mount_with_layer::<M>(M_layer) — only M’s routes use M_layer.

The per-model layer REPLACES (does NOT merge with) the per-app layer for the affected model. Critically, a knob left at None on the per-model layer falls back to the framework default — NOT to the per-app setting. The intent is per-model auditability in isolation: every mount_with_layer::<M>(...) site is fully inspectable on its own, without chasing a per-app default the reader may not have on screen, and without the security surprise of inheriting a permissive per-app override the per-model author did not intend.

Security posture. The REPLACE-not-merge rule means that an unset knob on a per-model override cannot silently inherit a permissive per-app value. If the per-app layer raises the body cap to 5 MiB and the per-model layer for Document does NOT explicitly call .body_limit(...), Document routes use the 1 MiB framework default, not the 5 MiB per-app value. The rule is auditable by grep: every override is explicit at its declaration site.

let global = FerraLayer::new()
    .body_limit(5 * 1024 * 1024)         // 5 MiB
    .build();
let document = FerraLayer::new()
    .cors(permissive_cors)
    .build();

let app = Foundry::new(conn)
    .layer(global)                       // 5 MiB on every model …
    .mount_with_layer::<Document>(document) // … EXCEPT Document
    .mount::<Film>()
    .build();

Observable behaviour:

  • Film routes: body limit 5 MiB (from global), CORS deny-by-default (global did not set .cors(...)).
  • Document routes: body limit 1 MiB (the framework defaultdocument did not set .body_limit(...), so the per-model layer does NOT inherit global’s 5 MiB), permissive CORS (the per-model setting).

Body-limit override + verbatim 413 detail

When the configured cap is exceeded, the framework’s standard 413 application/problem+json response carries the verbatim configured limit in the detail field:

{
  "type": "https://ferra.rs/errors/payload_too_large",
  "title": "Payload Too Large",
  "status": 413,
  "detail": "request body exceeds 5242880 bytes"
}

Operators tracing 413s in logs can map the byte cap in the response back to the originating FerraLayer::body_limit(...) call site without consulting source code.

Sub-path mount — base_path

A common deployment shape: serve Ferra at /api/ behind a reverse proxy that strips the prefix before forwarding. Configure the framework’s hypermedia links to include the publicly visible prefix via .base_path("/api"):

Static prefix only. base_path is a static &'static str declared at framework-assembly time. Ferra deliberately does NOT consume the X-Forwarded-Prefix request header (the dynamic-prefix convention used by FastAPI’s root_path_in_servers + Traefik). Reasons: (a) request-time prefix discovery breaks _links URL stability — two requests through different proxies could yield different _links.self.href for the same row; (b) the closed, const-evaluable contract enables the validation grammar below to fire at compile time, not at proc-macro time. If your deployment needs multiple prefixes simultaneously, declare a separate FerraLayer per-deployment-target.

let app = Foundry::new(conn)
    .layer(FerraLayer::new().base_path("/api").build())
    .mount::<Film>()
    .build();

Every _links.*.href (self, collection, restore, named-operation links) now carries the /api prefix:

  • _links.self.hrefhttps://api.example.com/api/films/42
  • _links.collection.hrefhttps://api.example.com/api/films

.base_path is validated at proc-macro-equivalent time: the prefix MUST start with / and MUST NOT end with /. Malformed literals produce a compile error at the call site when the value is reachable in const context:

.base_path("api")        // compile error — no leading `/`
.base_path("/api/")      // compile error — trailing `/`
.base_path("")           // compile error — empty

The base_path declaration applies only to hypermedia link generation. The framework does not assume the prefix is present in the inbound request URI — if the reverse proxy strips /api before forwarding, no further configuration is needed. To mount under the prefix internally as well, compose with axum::Router::nest("/api", foundry.build()).

Rate-limit override

use ferra::{FerraLayer, RateLimitRule};

let layer = FerraLayer::new()
    .rate_limit(RateLimitRule::new(10, 20))   // 10/sec, burst 20
    .build();

The constructor signature is RateLimitRule::new(per_second: u32, burst_size: u32) -> Self. burst_size is the bucket depth (peak concurrent mutations); per_second is the steady-state refill rate. At RateLimitRule::new(10, 20) a client may issue 20 mutations in burst, then 10/second steady-state. The key extractor at 0.7.0 is fixed to peer socket IP; richer extractor selection (proxy-aware keying, custom keying) composes on top in a later release. The #[non_exhaustive] attribute on RateLimitRule ensures additive knobs ship without breaking downstream construction sites — always use the ::new(...) constructor rather than struct-literal construction.

Custom CORS + tracing layers

.cors(cors_layer) and .trace_layer(trace_layer) accept the underlying [tower_http] types verbatim — the framework does not wrap them. To configure CORS for a specific allowed origin:

use tower_http::cors::{AllowOrigin, CorsLayer};

let cors = CorsLayer::new()
    .allow_origin(AllowOrigin::list(["https://app.example.com".parse().unwrap()]))
    .allow_methods(tower_http::cors::AllowMethods::list([
        axum::http::Method::GET,
        axum::http::Method::POST,
    ]));
let layer = FerraLayer::new().cors(cors).build();

The accepted TraceLayer type at 0.7.0 is the default TraceLayer<HttpMakeClassifier> shape returned by TraceLayer::new_for_http(). Consumers who need a custom classifier or per-handler customisation that changes the layer’s generic parameters compose on top of Foundry::build() with a regular .layer(...) call.

Composition order — preserved

The constitutional Tower stack order is preserved regardless of which knobs are overridden:

CorsLayer                                       (outermost)
  body_limit_mapper   (413 → problem+json with verbatim detail)
    RequestBodyLimitLayer({bytes})
      TraceLayer
        DefaultBodyLimit::disable
          [on mutation sub-router only:]
            rate_limit_mapper   (429 → problem+json)
              GovernorLayer(per_second, burst_size)
                <handlers>
          [on read sub-router:]
            <handlers>

FerraLayer does not reorder, add, or remove layers — only swaps the parameters / values that flow into the existing slots.

Typestate guard — .layer(...) before .mount(...)

.layer(...) carries the same Set: IsEmpty typestate bound as .api_version(...). Calling it after a model has been mounted produces a compile error pointing at the merge-two-assemblies pattern (the same diagnostic shape documented in §“The no-mixed-mode rule” above).

.mount_with_layer::<M>(layer) follows the same no-duplicate-mount rule as .mount::<M>().


Escape hatches

  • mount_with::<M>(state) — supply a caller-built FerraState<M> rather than letting Foundry derive one from the connection. Useful for shared state across builders, instrumentation, or test fixtures where the repository needs to be substituted.
  • with_docs_at(path) / with_docs_protected_at(path, verifier) — override the /docs mount point. Useful when a reverse proxy serves Ferra under a sub-path.
  • Direct ferra-openapi use — for advanced docs UIs (Redoc, RapiDoc, Swagger UI), call ferra_openapi::build_openapi_for_models(...) directly and merge the resulting axum::Router into the value Foundry returns. See ferra-openapi.md §“Alternative UIs”.

When Foundry is the wrong tool

  • Apps where every resource needs a hand-tuned middleware stack (e.g. per-resource auth schemes, per-resource body-limit overrides). Use ferra_router::<M>(state) per resource and compose by hand.
  • Apps that mount the docs surface from a different process than the API itself. The ferra-openapi free functions stand alone.
  • Apps with a non-Sea-ORM persistence layer. Foundry takes a sea_orm::DatabaseConnection; alternative backends compose through mount_with::<M>(state) using a FerraState<M> bound to the alternative.

For the bare CRUD case — the framework’s zero-to-documented-API target — Foundry::new(conn).mount::<M>().with_docs().build() is the right shape.


Cross-references (for the curious reader)

  • ADR-0024 — ferra-openapi crate scope, public-default docs deviation, Foundry → ferra-openapi edge.
  • ADR-0025 — Foundry typestate strategy (set-membership-via-trait + #[diagnostic::on_unimplemented]).
  • ADR-0026 — closed ERROR_TYPES URI namespace; the https://ferra.rs/errors/unauthorized URI used by .with_docs_protected(...).
  • ADR-0027 — x-sunset vendor extension + deprecated: true mirror.
  • ADR-0002 — Axum 0.8 framework choice and the Foundry::build() -> axum::Router single-leak rule.

These are pointers for readers tracing decisions back to their arbitration; they are not required reading for correct use of the APIs above.

ferra-core

ferra-core is the zero-I/O contract layer that every other Ferra crate depends on. It exports the following items across five modules:

ModuleItems
metaFieldType, FieldMeta, ModelMeta
modelFerraModel
idId
errorIdParseError
timeDateTime, Date, plus the date! macro

Nothing in this crate allocates, performs I/O, or talks to a database. Its external runtime dependencies are serde, uuid, and jiff (activated in 0.5.0 Rolling for the typed time vocabulary). A fourth dep (utoipa) is feature-gated behind openapi and stays off in the default build — ferra-openapi flips the feature on transitively when it depends on this crate.


Model metadata (FieldMeta / FieldType / ModelMeta)

The three types in ferra_core::meta describe a resource precisely enough for downstream crates to derive HTTP routes, SQL, OpenAPI, and hypermedia links from a single source of truth.

FieldType

A tag identifying the Rust type of a field.

pub enum FieldType {
    String, I32, I64, F64, Bool, Uuid,
    OptionString, OptionI32, OptionI64, OptionF64, OptionBool,
    OptionUuid,
}

Option variants are spelled out so every field’s nullability is visible in the IR without indirection. OptionUuid joins the table in 0.6.0 Welding (User Story 4 — FR-014/FR-024); see the recognised field types table in ferra-forge.md for the end-user surface.

FieldType is #[non_exhaustive]. Pattern matches on it MUST include a _ => arm to stay forward-compatible:

use ferra_core::meta::FieldType;

fn is_optional(ty: FieldType) -> bool {
    matches!(
        ty,
        FieldType::OptionString
            | FieldType::OptionI32
            | FieldType::OptionI64
            | FieldType::OptionF64
            | FieldType::OptionBool
            | FieldType::OptionUuid,
    )
}

FieldMeta

Description of a single field on a model. Seven named pub fields — no boolean-collapse tuples — so every flag is individually grep-able and readable in IDE hover output.

pub struct FieldMeta {
    pub name:     &'static str,
    pub ty:       FieldType,
    pub required: bool,
    pub readable: bool,
    pub writable: bool,
    pub skip_api: bool,
    pub is_id:    bool,
}

FieldMeta is #[non_exhaustive]. Construction goes through the const fn new constructor:

use ferra_core::meta::{FieldMeta, FieldType};

const TITLE_FIELD: FieldMeta = FieldMeta::new(
    "title",
    FieldType::String,
    /*required*/ true,
    /*readable*/ true,
    /*writable*/ true,
    /*skip_api*/ false,
    /*is_id*/   false,
);

ModelMeta

Description of a single resource:

pub struct ModelMeta {
    pub resource_name:      &'static str,           // external name, e.g. "films"
    pub table_name:         &'static str,           // SQL table name
    pub primary_key_fields: &'static [&'static str], // PK column names, declaration order
    pub fields:             &'static [FieldMeta],
}

primary_key_fields carries the primary-key column names in declaration order. Single-PK models hold a one-element slice (e.g. &["id"]) — the single-PK fast path; downstream consumers read primary_key_fields[0] and the route shape stays /{resource}/{<pk>}. Composite-PK models hold a multi-element slice (e.g. &["tenant_id", "document_id"]) and the route shape becomes /{resource}/{<pk1>}/{<pk2>}/…. The composite-PK lift landed in 0.6.0 Welding (User Story 3).

Invariants (hand-authoring discipline in 0.1.0; enforced by compile_error! from 0.2.0 Smelting onward):

  • fields is non-empty.
  • primary_key_fields has length ≥ 1 (the N = 0 case is rejected by ferra-forge at proc-macro time).
  • Each entry of primary_key_fields matches some fields[i].name whose is_id = true.
  • All fields[i].name values are distinct.

Hand-authoring in 0.1.0

In 0.1.0 every ModelMeta is hand-written. Starting in 0.2.0, the #[model] proc-macro (shipping in ferra-forge) emits the same types automatically.

use ferra_core::meta::{FieldMeta, FieldType, ModelMeta};

const FILM_FIELDS: &[FieldMeta] = &[
    FieldMeta::new("id",    FieldType::Uuid,   true, true, false, false, true),
    FieldMeta::new("title", FieldType::String, true, true, true,  false, false),
];

// Single-PK fast path: a one-element slice of PK field names in
// declaration order. Composite-PK models pass a multi-element slice
// (e.g. `&["tenant_id", "document_id"]`).
const FILM_META: ModelMeta =
    ModelMeta::new("films", "films", &["id"], FILM_FIELDS);

The FerraModel trait

FerraModel binds a Rust struct to its static ModelMeta description. It is the single piece of user code required to make a Rust struct a Ferra resource.

pub trait FerraModel:
    serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static
{
    fn meta() -> &'static ModelMeta;
}

The supertrait set is part of the 0.1.0 contract. Every bound is load-bearing:

BoundWhy it’s required
SerializeLater phases render responses.
DeserializeOwnedLater phases deserialise request bodies (no borrowed 'de).
Send + SyncModels participate in async handlers on a multi-threaded Tokio runtime.
'staticFerra models are owned values — never borrowed from request state.

A hand-written implementation is four lines:

use ferra_core::{id::Id, meta::{FieldMeta, FieldType, ModelMeta}, model::FerraModel};
use serde::{Deserialize, Serialize};
const FILM_FIELDS: &[FieldMeta] = &[
    FieldMeta::new("id",    FieldType::Uuid,   true, true, false, false, true),
    FieldMeta::new("title", FieldType::String, true, true, true,  false, false),
];
const FILM_META: ModelMeta = ModelMeta::new("films", "films", &["id"], FILM_FIELDS);
#[derive(Serialize, Deserialize)]
struct Film { id: Id, title: String }

impl FerraModel for Film {
    fn meta() -> &'static ModelMeta { &FILM_META }
}

In 0.1.0 this trait has no downstream consumer yet — ferra-http (0.3.0 Casting) is its first real reader. The trait ships early because narrowing supertraits later would be a breaking change.


Id — the identifier primitive

Id is the identifier type every Ferra model uses. It is a 16-byte newtype wrapping uuid::Uuid.

Construction

use ferra_core::id::Id;

let a = Id::new();        // fresh v4 UUID (collision-resistant)
let b = Id::default();    // same as Id::new()
let n = Id::nil();        // all-zero sentinel — TEST FIXTURE ONLY

Id::nil() is explicitly a test fixture: it is distinguishable from any Id::new() output, which makes it useful as a sentinel in unit tests. Do not store or return Id::nil() from production code.

Wire format

Display emits the RFC 4122 hyphenated lowercase form (for example, "67e55044-10b1-426f-9247-bb680e5fe0c8"). FromStr accepts any of the four canonical UUID forms: hyphenated, simple (no hyphens), URN (urn:uuid:...), and braced ({...}). The serde impls delegate to Display / FromStr, so the wire representation is consistent across JSON, MessagePack, and every serde-backed format.

Full round-trip example

This example mirrors the doctest on Id in crates/ferra-core/src/id.rs, which is what cargo test actually runs; the markdown fence below is a readable transcription, not the compiled source. The doctest is the ground-truth contract for how Id behaves.

use std::str::FromStr;
use ferra_core::id::Id;

// Construction.
let id = Id::new();

// Text round-trip.
let text = id.to_string();
let parsed = Id::from_str(&text).expect("fresh ids always parse");
assert_eq!(id, parsed);

// JSON round-trip.
let json = serde_json::to_string(&id).unwrap();
let back: Id = serde_json::from_str(&json).unwrap();
assert_eq!(id, back);

// Equality and the nil sentinel.
let nil = Id::nil();
assert_eq!(nil, Id::nil());
assert_ne!(nil, id);

// Parsing failure is typed, never a panic.
let err = Id::from_str("not a uuid").unwrap_err();
assert_eq!(err.to_string(), "failed to parse Id as a UUID");

// Id is Copy — passing by value is free.
fn takes_id(_x: Id) {}
takes_id(id);
takes_id(id);       // still valid, the previous call didn't move it.

Why a newtype

Wrapping uuid::Uuid rather than exposing it directly lets Ferra swap the inner representation later (for example, to UUID v7 time-ordered identifiers) without churning every caller. The 16-byte payload is Copy; passing Id through the HTTP pipeline costs nothing at runtime.

Id is not PartialOrd / Ord — v4 UUIDs carry no meaningful ordering semantics, and adding those traits later is non-breaking.

IdParseError

<Id as FromStr>::Err is IdParseError, a hand-written unit struct with a Display impl and a std::error::Error impl. Its inner is pub(crate), so you can only obtain an IdParseError by a parse failure — never by direct construction.

In later phases, IdParseError may grow variants distinguishing “wrong length” from “invalid character”. Because external code only ever matches it as an opaque error, adding variants will be a non-breaking change.


Time vocabulary (DateTime / Date / date!)

Ferra exposes one typed time vocabulary across the framework. Two #[repr(transparent)] newtypes — ferra::DateTime (timestamp) and ferra::Date (calendar date) — wrap the underlying time library so the framework can swap that library in a future release without a consumer-facing signature change. Reach the types through ferra::DateTime / ferra::Date (or via ferra::prelude::*); never import the underlying time library directly.

DateTime — timestamp

DateTime represents a precise instant anchored to UTC. Wire format: RFC 3339 timestamp text ("2023-11-14T22:13:20Z").

ConstructorReturnsNotes
DateTime::from_second(seconds: i64)Result<DateTime, jiff::Error>seconds since the Unix epoch
DateTime::from_millisecond(milliseconds: i64)Result<DateTime, jiff::Error>milliseconds since the Unix epoch
DateTime::from_microsecond(microseconds: i64)Result<DateTime, jiff::Error>microseconds since the Unix epoch
DateTime::from_nanosecond(nanoseconds: i128)Result<DateTime, jiff::Error>nanoseconds since the Unix epoch
DateTime::from_duration(duration: jiff::SignedDuration)Result<DateTime, jiff::Error>epoch-relative duration
DateTime::now()DateTimereads the system clock; not const

The constructor names mirror the underlying time library verbatim; they are not invented. Names that do not exist on DateTime and will not be added: from_unix_timestamp, from_unix_nanos.

Each from_* constructor returns a Result and propagates an out-of-range argument as the underlying time library’s error type. The methods are not const fn — call sites use the standard ? operator, not const-context evaluation.

use ferra::DateTime;

fn epoch_seconds(seconds: i64) -> Result<DateTime, jiff::Error> {
    DateTime::from_second(seconds)
}

let now = DateTime::now();
let later = DateTime::from_second(1_700_000_000)?;

Date — calendar date

Date represents a time-zone-free calendar date. Wire format: RFC 3339 calendar date text ("YYYY-MM-DD").

ConstructorReturnsNotes
Date::new(year: i16, month: i8, day: i8)Dateconst fn; out-of-range triple is a const-eval error

Date::new is const fn — an out-of-range (year, month, day) triple is rejected at cargo build time, not at runtime:

use ferra::Date;

const RELEASED: Date = Date::new(2027, 1, 1);
// const INVALID: Date = Date::new(2027, 13, 1); // would not compile

date! macro — hyphen-literal calendar dates

ferra::date!(YYYY-MM-DD) re-tokenises a hyphen-literal into Date::new(year, month, day). Same compile-time validation as Date::new:

use ferra::{Date, date};

const SUNSET: Date = date!(2027-01-01);
// const INVALID: Date = date!(2027-13-01); // const-eval error

The macro is the recommended call site for fixed dates (release sunsets, scheduled migrations) because the literal form is most readable. Use Date::new(...) directly when the inputs are computed or come from configuration.

Use as model fields

Both newtypes are accepted as field types on #[derive(FerraModel)] structs and emit the right OpenAPI schema (format: date-time and format: date):

use ferra::{Date, DateTime, FerraModel};

#[derive(FerraModel)]
pub struct Film {
    #[id]
    id: ferra::Id,
    title: String,
    released: Date,
    indexed_at: DateTime,
}

Use as Sunset: header values

Foundry::deprecated(sunset) accepts a Date. The framework serialises the value as the Sunset: HTTP header on every response under the deprecated version (RFC 8594):

use ferra::{Foundry, date};

let app = Foundry::new(conn)
    .api_version("v1")?
    .mount::<Film>()
    .deprecated(date!(2027-01-01))
    .build();

Banned imports

The framework forbids direct use of legacy time libraries. Two gates enforce this:

  1. #[derive(FerraModel)] rejects field declarations whose type path starts with chrono:: or time::. The diagnostic names ferra::DateTime / ferra::Date as the replacement and is spanned to the offending field.
  2. cargo deny check bans the chrono and time crates from the dependency graph except as transitive deps of the Sea-ORM ecosystem (sea-orm, sea-query, sqlx-core, sqlx-postgres). A Ferra crate or downstream consumer that adds either crate directly fails CI.

Sample diagnostic for a chrono-typed field:

error: field type uses the legacy `chrono` time crate; Ferra exposes
       typed time values through `ferra::DateTime` for timestamps
       and `ferra::Date` for calendar dates
       help: replace with `ferra::DateTime` (or `ferra::Date` for
             calendar-only values); the wire format is RFC 3339 in
             both cases
       note: docs/user-guide/ferra-core.md §"Time vocabulary"

Why newtypes

The two newtypes are #[repr(transparent)] over the underlying library’s value types. The layout is identical to the inner type, so a future release that replaces the underlying library does so at the ferra-core::time module boundary alone — consumer-facing signatures stay stable. The inner field is pub(crate); consumers reach the value only through ferra::DateTime / ferra::Date.

The OpenAPI schema emission is encapsulated in the same module: <DateTime as utoipa::ToSchema> and <Date as utoipa::ToSchema> emit {type: "string", format: "date-time"} and {type: "string", format: "date"} respectively. Consumers and downstream crates never write the schema literal — the typed-newtype contract preserves the framework’s “single source of truth” rule.


Public surface

RequestContext

RequestContext (canonical path: ferra_core::request_context::RequestContext) is the request-scoped handle the framework threads through the computed-field async pipeline. At 0.6.5 Chasing the type is an empty marker: the framework constructs it via RequestContext::empty() and passes it as the ctx parameter to FerraComputed::compute_async and FerraComputed::compute_async_batch. The shape is deliberate — the parameter pins the trait-method signatures so that 0.7.x can widen RequestContext with the authentication-principal handle, the database-pool handle, and the HTTP-request metadata without touching any existing impl FerraComputed block. The #[non_exhaustive] posture on the struct makes that widening non-breaking by construction. For the consumer-facing usage of the surrounding pipeline, see ferra-forge.md § Computed fields.

ferra-db — the repository layer

The 0.3.0 Casting deliverable. Turns a #[derive(FerraModel, DeriveEntityModel)] entity into a CRUD-capable repository against a live PostgreSQL, with zero hand-written SQL.

This page is the authoritative user-guide for ferra-db. A reader with only this page plus standard Rust knowledge can produce a compiling CRUD round-trip on the first attempt — no ADR, no constitution, and no framework source is required.

At 0.4.0 Refining the ferra facade absorbs ferra-forge (the #[derive(FerraModel)] macro) and ferra-http (the CRUD handler layer). Consumers write use ferra::*; and no longer need a second direct dependency on ferra-forge — the 003 two-line pattern collapses to one line. See ferra-http.md for the HTTP surface that composes on top of this page’s repository layer.


Zero-to-CRUD in four lines

use ferra::{DatabaseConnection, FerraRepository, PgRepository};

let conn: DatabaseConnection = sea_orm::Database::connect(&database_url).await?;
let repo = PgRepository::<Film>::new(conn);
let film = repo.find_by_id(42).await?;

(Film is the canonical sibling-derive entity shape from ferra-forge — see the worked example below. database_url is read from the DATABASE_URL environment variable in the usual way.)


The FerraRepository<M> surface

FerraRepository<M> is an async trait with ten methods, split into two symmetric groups of five. The ambient-connection quintet runs against the pool supplied at construction time; the _in_tx quintet takes a caller-owned transaction handle.

Ambient-connection methods

MethodSignature summaryError outcomes
find_by_idasync fn find_by_id(&self, id: PkValue<M>) -> Result<M, FerraDbError>NotFound · Db
find_pageasync fn find_page(&self, page: u32, per_page: u32) -> Result<Page<M>, FerraDbError>Db (never NotFound)
insertasync fn insert(&self, data: M) -> Result<M, FerraDbError>Conflict · Db
updateasync fn update(&self, id: PkValue<M>, data: M) -> Result<M, FerraDbError>NotFound · Conflict · Db
deleteasync fn delete(&self, id: PkValue<M>) -> Result<(), FerraDbError>NotFound · Db
  • PkValue<M> is the Sea-ORM primary-key value type for M — for most entities this is a plain i32 / i64 / Uuid. The exact type alias is pub type PkValue<M> = <<<M as sea_orm::ModelTrait>::Entity as sea_orm::EntityTrait>::PrimaryKey as sea_orm::PrimaryKeyTrait>::ValueType; — consumers rarely spell it out; it propagates through impl blocks automatically.
  • For composite primary keys (two or more #[sea_orm(primary_key)] markers — see ferra-forge.md § Composite primary keys), Sea-ORM resolves PkValue<M> to a tuple matching the declaration order: (K1, K2) for two-key composites, (K1, K2, K3) for three-key, etc. The repository call site is the typed tuple lookup repo.find_by_id((tenant_id, document_id)). Single-PK consumers see no change.
  • find_page is 1-indexed (page = 1 is the first page). A request past the end returns Ok(Page { items: vec![], total, .. }) — never NotFound.
  • update accepts (id, data) where data: M carries the new values. At 0.3.0 the caller is responsible for ensuring data’s primary key matches id; the 0.4.0 DTO layer will enforce that invariant upstream.

Transaction-scoped methods

Each ambient method has a paired _in_tx variant with the same name + suffix, taking &DatabaseTransaction as its first argument after &self:

async fn find_by_id_in_tx(&self, tx: &DatabaseTransaction, id: PkValue<M>) -> Result<M, FerraDbError>;
async fn find_page_in_tx (&self, tx: &DatabaseTransaction, page: u32, per_page: u32) -> Result<Page<M>, FerraDbError>;
async fn insert_in_tx    (&self, tx: &DatabaseTransaction, data: M) -> Result<M, FerraDbError>;
async fn update_in_tx    (&self, tx: &DatabaseTransaction, id: PkValue<M>, data: M) -> Result<M, FerraDbError>;
async fn delete_in_tx    (&self, tx: &DatabaseTransaction, id: PkValue<M>) -> Result<(), FerraDbError>;

Semantics are identical to the ambient counterpart modulo the transaction handle. A bug fixed in one path is fixed in both — the two families share their inner implementation via a ConnectionTrait-generic helper.

Object-safety note

FerraRepository<M> is NOT object-safe — edition-2024 native async-in-trait does not support dyn FerraRepository<M>. Compose against it via generics: fn handler<R: FerraRepository<Film>>(repo: R, ...). A runtime-heterogeneous-backend story is deferred to the post-v1 ferra-db-* sibling crates.


The PgRepository<M> constructor

let repo: PgRepository<Film> = PgRepository::new(conn);
  • conn is a sea_orm::DatabaseConnection (re-exported from ferra-db so you do not need to add sea-orm as a direct dependency — ferra re-exports the name under ferra::DatabaseConnection).
  • The struct derives Clone unconditionally. Sea-ORM’s DatabaseConnection wraps its pool in an Arc, so repo.clone() is cheap — pass PgRepository<M> by value or by clone rather than wrapping in Arc<PgRepository<M>>.
  • The constructor performs no validation. Callers with a non-PostgreSQL DatabaseConnection compile but lose the SQLSTATE 23505Conflict mapping (those failures fall through to the Db(_) variant).

Bounds on M

PgRepository<M> works for any M that satisfies the 0.2.0 sibling-derive shape — i.e., the derive combination #[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]. Concretely, M must implement FerraModel (from ferra-core) plus the Sea-ORM trait set (ModelTrait, FromQueryResult, IntoActiveModel<ActiveModel>, with Entity: EntityTrait<Model = M> and the ActiveModel: ActiveModelTrait + ActiveModelBehavior + Send). The 0.2.0 canonical shape satisfies every bound automatically.

If you see error[E0277]: the trait bound <M>: sea_orm::ModelTrait is not satisfied at a PgRepository::<M>::new(conn) call site, you forgot the DeriveEntityModel derive on the same struct. Add it per the 0.2.0 canonical shape.


Error taxonomy

FerraDbError has exactly three variants. The set is locked from 0.3.0 through 0.6.0 Welding — adding a variant requires an ADR.

VariantWhenDisplay template0.4.0 HTTP mapping
NotFound { resource: &'static str, id: String }find_by_id / update / delete observed zero matching rows"{resource}/{id} not found"404 Not Found
Conflict(String)insert / update hit a Postgres SQLSTATE 23505 unique-constraint violation"unique constraint \"{constraint}\" violated on {target}"409 Conflict
Db(sea_orm::DbErr)Every other sea_orm::DbErrwhatever the wrapped DbErr renders as500 Internal Server Error (no internal fragment leaks into the response body)

Display guarantees

  • NotFound { resource: "films", id: "42".into() }.to_string()"films/42 not found".
  • Conflict("unique constraint \"films_title_key\" violated on column \"title\"".into()).to_string()"unique constraint \"films_title_key\" violated on column \"title\"".
  • Db(db_err).to_string() → whatever db_err.to_string() renders (e.g., "Record not found", "Execution Error: ...").

Every variant’s Display is stable through 0.6.0 Welding — the 0.4.0 RFC 7807 layer serializes these templates verbatim into the problem+json response. No Display output contains any internal Rust path fragment (no sea_orm::..., no sqlx::..., no rustc error codes).

source() + downcasting

FerraDbError::Db(_).source() returns Some(&dyn Error) pointing at the wrapped sea_orm::DbErr. You can downcast to inspect specific Sea-ORM failure modes:

use std::error::Error as _;

if let FerraDbError::Db(_) = &err {
    if let Some(db_err) = err.source().and_then(|s| s.downcast_ref::<sea_orm::DbErr>()) {
        // inspect db_err
    }
}

source() on NotFound and Conflict returns None — these variants carry their context in their fields.

Construction rules

External consumers cannot directly construct NotFound or Conflict — those variants are produced exclusively by the repository itself at the single from_sea_orm mapping site. A raw sea_orm::DbErr that propagates through ? becomes FerraDbError::Db(_) via the From<sea_orm::DbErr> derive; that is the only construction path available to user code.


The Page<M> shape

#[non_exhaustive]
#[derive(Debug)]
pub struct Page<M> {
    pub items: Vec<M>,
    pub total: u64,
    pub page: u32,
    pub per_page: u32,
}
  • 1-indexed page by framework convention. page = 0 is treated identically to page = 1 (the repository applies saturating_sub(1) internally to hand Sea-ORM its 0-indexed offset).
  • total: u64 fits any Postgres bigint in the non-negative half — the full-table row count observed by the count query.
  • items.len() <= per_page always. An over-scrolled request is Ok(Page { items: vec![], total, .. }), never NotFound.
  • Concurrent-insert drift. items.len() and total may disagree by ±1 under concurrent inserts — both queries run serially on the same connection (FR-015), not in parallel. Callers that need strict consistency use find_page_in_tx inside a SERIALIZABLE transaction.
  • Serialize, not Deserialize. Page<M>: serde::Serialize (inherited from M: FerraModel: Serialize). No Deserialize — pages are server-constructed, never parsed from client input.
  • #[non_exhaustive] — future phases may add fields (e.g., cursor: Option<Cursor> at 0.7.5 Enameling). Construct via Page::new(items, total, page, per_page), not a struct literal.

Transactions

ferra-db re-exports DatabaseConnection and DatabaseTransaction from Sea-ORM under their original names, so you can type both handles without a direct sea-orm dependency. Obtaining a transaction requires importing Sea-ORM’s TransactionTrait:

use ferra::{DatabaseConnection, DatabaseTransaction, FerraRepository, PgRepository};
use sea_orm::TransactionTrait; // import needed for .begin()

let tx: DatabaseTransaction = conn.begin().await?;
repo.insert_in_tx(&tx, film_1).await?;
repo.insert_in_tx(&tx, film_2).await?;
tx.commit().await?; // both rows persist atomically

Rollback on drop

Dropping a DatabaseTransaction without calling .commit() rolls it back. No cleanup call is required on the failure path:

let tx = conn.begin().await?;
repo.insert_in_tx(&tx, film_1).await?;
if let Err(e) = repo.insert_in_tx(&tx, film_2).await {
    // tx goes out of scope here → Sea-ORM rolls it back. film_1 is NOT persisted.
    return Err(e.into());
}
tx.commit().await?;

The TransactionTrait import

ferra-db deliberately does not re-export sea_orm::TransactionTrait under a Ferra-prefixed alias. Callers import it explicitly: use sea_orm::TransactionTrait;. This is a one-line acknowledgment that Sea-ORM owns the transaction mechanism; it is not a barrier to use, just intentional transparency.


No raw-SQL entry point

ferra-db publishes NO entry point that accepts a pre-formed SQL string. The following paths do NOT resolve and WILL NOT resolve in 0.3.0 — each is asserted by a trybuild compile-fail fixture (FR-029 is a release blocker):

ferra_db::Statement            // not reachable
ferra_db::execute_unprepared   // not reachable
ferra_db::raw_sql              // not reachable

The same three fragments are also blocked under ferra:: via the companion fixture at crates/ferra/tests/ui/no_raw_sql.rs.

The escape hatch

If you genuinely need raw SQL (a hand-written migration, a complex analytics query that defeats Sea-ORM’s builder), add sea-orm as a direct dependency of your consumer crate:

[dependencies]
ferra   = "0.3"
sea-orm = { version = "=2.0.0-rc.38", features = ["runtime-tokio-rustls", "sqlx-postgres", "macros"] }

Then use Sea-ORM’s own raw-SQL entry points (Statement::from_string, execute_unprepared, etc.) directly. This is a deliberate, transparent act — your code reads as “I am opting out of Ferra’s SQL-injection-by-construction contract for this specific query” rather than reaching through a convenience hole in the framework.


The ferra facade

The ferra crate is the single user-facing dependency. At 0.3.0 it re-exports the public surface of ferra-core and ferra-db:

use ferra::{
    FerraDbError, FerraRepository, PgRepository, Page,
    DatabaseConnection, DatabaseTransaction,
    Id, FerraModel, ModelMeta, FieldMeta, FieldType,
};

#[derive(FerraModel)] is NOT re-exported at 0.3.0. Consumers who want the derive at 0.3.0 add ferra-forge as a second direct dependency:

[dependencies]
ferra       = "0.3"
ferra-forge = "0.2"   # for #[derive(FerraModel)] — re-export arrives at 0.4.0

At 0.4.0 Refining, ferra will gain pub use ferra_forge::FerraModel; and the two-line pattern collapses to one. The 0.3.0 split is deliberate: re-exporting the derive at 0.3.0 would force every ferra-depending consumer to link the proc-macro toolchain even when only the repository layer is needed.


Worked example

A complete compiling example. Drop this into a downstream crate that depends on ferra, ferra-forge, sea-orm, serde, and the Sea-ORM migration crate. The DATABASE_URL environment variable must point at a reachable PostgreSQL 16.

1 — the migration

-- migrations/20260417000000_create_films.sql
CREATE TABLE films (
    id           SERIAL  PRIMARY KEY,
    title        TEXT    NOT NULL UNIQUE,
    release_year INTEGER,
    archived     BOOLEAN NOT NULL DEFAULT FALSE
);

2 — the entity (sibling-derive shape)

// src/film.rs
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[sea_orm(unique)]
    pub title: String,
    pub release_year: Option<i32>,
    pub archived: bool,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

pub use Model as Film;

3 — the CRUD round-trip

// src/main.rs
use ferra::{FerraDbError, FerraRepository, PgRepository};
use sea_orm::TransactionTrait;

mod film;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let db_url = std::env::var("DATABASE_URL")?;
    let conn = sea_orm::Database::connect(&db_url).await?;
    let repo = PgRepository::<film::Film>::new(conn.clone());

    // Insert
    let blade_runner = repo.insert(film::Model {
        id: 1,
        title: "Blade Runner".to_owned(),
        release_year: Some(1982),
        archived: false,
    }).await?;
    println!("inserted: {blade_runner:?}");

    // Read
    let one = repo.find_by_id(1).await?;
    assert_eq!(one.title, "Blade Runner");

    // Update
    let mut tweaked = one.clone();
    tweaked.archived = true;
    let updated = repo.update(1, tweaked).await?;
    assert!(updated.archived);

    // Paginate
    let page = repo.find_page(1, 10).await?;
    println!("page 1/10: {} items, {} total", page.items.len(), page.total);

    // Compound write: two inserts in a single transaction
    let tx = conn.begin().await?;
    repo.insert_in_tx(&tx, film::Model {
        id: 2, title: "Alien".into(), release_year: Some(1979), archived: false,
    }).await?;
    repo.insert_in_tx(&tx, film::Model {
        id: 3, title: "2001: A Space Odyssey".into(), release_year: Some(1968), archived: false,
    }).await?;
    tx.commit().await?;

    // Delete
    repo.delete(1).await?;
    assert!(matches!(repo.find_by_id(1).await, Err(FerraDbError::NotFound { .. })));

    Ok(())
}

Troubleshooting

"films/42 not found"

You observed FerraDbError::NotFound { resource: "films", id: "42" } — the find_by_id / update / delete call targeted a primary key that has no row. At the HTTP layer (0.4.0) this maps to 404. Check that the id value you passed corresponds to an extant row.

"unique constraint \"films_title_key\" violated on column \"title\""

You observed FerraDbError::Conflict(_) — Postgres returned SQLSTATE 23505. The inner message names the constraint and, when available, the offending column. At the HTTP layer (0.4.0) this maps to 409. Resolve by either changing the colliding value or deleting the existing row first.

A DbErr::Custom("...") in logs

You observed FerraDbError::Db(_) — any sea_orm::DbErr that is not a recognized NotFound / NotUpdated / unique-violation shape falls through here. The wrapped DbErr is reachable via err.source().and_then(|s| s.downcast_ref::<sea_orm::DbErr>()). At the HTTP layer (0.4.0) this maps to 500 without leaking the internal detail into the response body.

error[E0277]: the trait bound <M>: sea_orm::ModelTrait is not satisfied

You forgot the DeriveEntityModel derive on the struct. The 0.2.0 sibling-derive canonical shape requires BOTH FerraModel AND DeriveEntityModel on the same #[derive(...)] list. Add DeriveEntityModel back and recompile.

error[E0425]: cannot find value Statement in crate ferra_db

Expected — ferra-db publishes no raw-SQL entry point (FR-007). To reach Sea-ORM’s raw-SQL surface, add sea-orm as a direct dependency of your consumer crate and call through it explicitly.


Stability commitment

Item0.3.0 commitmentChange requires
FerraDbError variant listThree variants — NotFound, Conflict, DbADR (locked through 0.6.0 Welding)
FerraDbError::Display wordingStable templates per §Error taxonomyADR + user-guide update
FerraRepository<M> method setTen methods — five ambient + five _in_txADR to remove / rename; additions are additive
PgRepository::new(conn)(conn: DatabaseConnection) -> SelfADR
Page<M> field listFour named fields (items, total, page, per_page)#[non_exhaustive] allows additions; removals require ADR
DatabaseConnection / DatabaseTransaction re-exportsStable names, re-exported from Sea-ORMADR
Forbidden raw-SQL fragmentsStatement / execute_unprepared / raw_sql do not resolveADR

MSRV: rust-version = "1.88" — inherited from the workspace. Edition 2024 native async-in-trait requires it.

ferra-http — the Axum CRUD handler layer

The 0.4.0 Refining deliverable. Turns a #[derive(FerraModel, DeriveEntityModel)] entity into a fully-wired Axum router with RFC 7807 errors, HAL-lite hypermedia, pagination, and constitutional security defaults — zero hand-written routes, zero hand-written error mapping.

This page is the authoritative user-guide for ferra-http. A reader with only this page plus standard Rust knowledge produces a compiling ferra_router::<Film>(state) call site on the first attempt — no ADR, no constitution, and no framework source is required.


Zero-to-API in five lines

use ferra::*;

let conn = sea_orm::Database::connect(&database_url).await?;
let state = FerraState::<Film>::new(conn);
let app = ferra_router::<Film>(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;

Drop one #[derive(FerraModel, DeriveEntityModel, Serialize, Deserialize)] on a struct whose PK is i32 (or any Display + DeserializeOwned + Send + Sync + 'static) and you get the five CRUD endpoints (GET /films / GET /films/:id / POST /films / PUT /films/:id / DELETE /films/:id), a Location header on creation, a Warning: 214 header on per_page clamp, and an application/problem+json body on every error — with no further code.

See examples/hello-ferra/ for the full worked example with PostgreSQL container + Rust-coded migrations.


The FerraState<M> constructor

pub struct FerraState<M> { pub repo: Arc<PgRepository<M>> }
impl<M> FerraState<M> {
    pub fn new(pool: DatabaseConnection) -> Self;
}
  • #[non_exhaustive] — construct via FerraState::new(pool); struct-literal construction is not supported. Future phases add fields (tracing context, auth identity) without breaking this.
  • #[derive(Clone)] — cloning is a reference-count bump on Arc<PgRepository<M>>. Every request clones.
  • Implements axum::extract::FromRef<FerraState<M>> for Arc<PgRepository<M>>. Custom handlers that only need the repository write State(repo): State<Arc<PgRepository<M>>>; the Ferra handlers all take State(state): State<FerraState<M>>.

Instantiate one FerraState<M> per model per database; cheaply share the same DatabaseConnection across models by calling FerraState::new(conn.clone()) multiple times.


The five CRUD handlers

All five handlers are generic over M: FerraModel + <Sea-ORM bounds>. You rarely name them directly — ferra_router<M> mounts them for you. When you do, the HTTP contracts are:

HandlerMethodPathRequestSuccessError surfaces
get_item<M>GET/{resource}/:id200 OK + Json<ItemResponse<M>>400 on path-extractor rejection · 404 on missing row · 500 on DB error
get_collection<M>GET/{resource}?page={u32}&per_page={u32}200 OK + optional Warning: 214 … + Json<CollectionResponse<M>>500 on DB error
create_item<M>POST/{resource}Json<M> body201 Created + Location: {base}/{resource}/{id} + Json<ItemResponse<M>>400 on body parse · 409 on unique violation · 413 on oversized body · 429 on rate limit · 500
update_item<M>PUT/{resource}/:idJson<M> body200 OK + Json<ItemResponse<M>>400 · 404 · 409 · 413 · 429 · 500
delete_item<M>DELETE/{resource}/:id204 No Content (no body)400 · 404 · 429 · 500

Path id is authoritative on update and delete — any id field in the request body is ignored for the WHERE clause. {resource} is <M as FerraModel>::meta().resource_name (e.g. "films").

A model that carries one or more #[ferra(projection(...))] declarations also mounts a parallel set of routes under each named projection’s URL prefix (e.g. /detail/films/{id}). The wire shape on those routes is filtered to the projection’s read_fields / write_fields allowlist. See Projections and routing for the full URL contract, naming convention, and the path_prefix override.


RFC 7807 error taxonomy

Every error from every handler is rendered as application/problem+json per RFC 7807 §3. Six variants, locked through 0.6.0 Welding:

VariantHTTP statustype URItitle
FerraError::NotFound { resource, id }404https://ferra.rs/errors/not_foundResource Not Found
FerraError::Validation(ValidationErrors)400https://ferra.rs/errors/validationValidation Error
FerraError::Conflict(String)409https://ferra.rs/errors/conflictConflict
FerraError::Internal(String)500https://ferra.rs/errors/internalInternal Server Error
(middleware)413https://ferra.rs/errors/payload_too_largePayload Too Large 1
(middleware)429https://ferra.rs/errors/rate_limitedToo Many Requests 1

Worked body for a 404:

{
  "type":   "https://ferra.rs/errors/not_found",
  "title":  "Resource Not Found",
  "status": 404,
  "detail": "films/42 not found"
}

Worked body for a 400 from a malformed JSON request:

{
  "type":   "https://ferra.rs/errors/validation",
  "title":  "Validation Error",
  "status": 400,
  "detail": "validation failed",
  "errors": {
    "errors": [
      { "field": "body", "code": "invalid_json", "message": "expected `,` or `}` at line 1 column 14" }
    ]
  }
}

Internals never leak. The detail field on a 500 is always the literal string "internal server error"; the inner sea_orm::DbErr is dropped before reaching the HTTP body. The Axum "Failed to deserialize … into the target type: " prefix and any axum::extract::rejection:: substring are stripped at the extractor boundary; only human-readable messages reach errors[*].message.

Worked body for a 429 from the rate-limit mapper:

{
  "type":   "https://ferra.rs/errors/rate_limited",
  "title":  "Too Many Requests",
  "status": 429,
  "detail": "rate limit exceeded; retry after 3 seconds"
}

The response also carries the Retry-After header from the underlying rate-limit component verbatim (integer seconds). When the component does not emit a Retry-After header, detail degrades gracefully to the literal string "rate limit exceeded" and the header is absent from the response. The rate-limit mapper mounts only alongside the framework’s default rate-limit component — consumer-provided rate-limiters produce their own error shape (see ADR-0015 for rationale).


The HAL-lite envelope

Every successful body carries _links so an AI agent can navigate the API without prior URL knowledge.

ItemResponse<M> — single-item envelope

Model fields are flattened at the top level via #[serde(flatten)]. A Film { id: 1, title: "Rust en pratique", director: "Alice", year: Some(2025) } serializes as:

{
  "id": 1,
  "title": "Rust en pratique",
  "director": "Alice",
  "year": 2025,
  "_links": {
    "self":       { "href": "https://api.example.com/films/1" },
    "collection": { "href": "https://api.example.com/films" }
  }
}

CollectionResponse<M> — paginated envelope

{
  "items": [ /* array of ItemResponse<M> */ ],
  "total": 25,
  "page": 2,
  "per_page": 10,
  "_links": {
    "self":  { "href": "https://api.example.com/films?page=2&per_page=10" },
    "next":  { "href": "https://api.example.com/films?page=3&per_page=10" },
    "prev":  { "href": "https://api.example.com/films?page=1&per_page=10" },
    "first": { "href": "https://api.example.com/films?page=1&per_page=10" },
    "last":  { "href": "https://api.example.com/films?page=3&per_page=10" }
  }
}

On boundary pages, next or prev serialize as JSON null (never omitted). On total == 0, last_page == 1 and first == last == self.

pub fn build_item_links(meta: &ModelMeta, id: &str, base_url: &str) -> ItemLinks;
pub fn build_collection_links(
    meta: &ModelMeta,
    page: u32,
    per_page: u32,
    total: u64,
    base_url: &str,
) -> CollectionLinks;

Use these when writing a custom handler that returns an envelope-shaped body.


Pagination

pub struct PaginationParams { pub page: Option<u32>, pub per_page: Option<u32> }
impl PaginationParams {
    pub const fn with(page: Option<u32>, per_page: Option<u32>) -> Self;
    pub fn resolved_page(&self) -> u32;          // default 1; 0 → 1
    pub fn resolved_per_page(&self) -> u32;      // default 20; clamp to [1, 100]
    pub fn clamped_per_page(&self) -> Option<u32>;
}

PaginationParams is #[non_exhaustive] — external struct-literal construction is rejected. For code that needs to build a PaginationParams outside an Axum Query<_> extractor (integration tests, admin tools, batch jobs), use the stable with(page, per_page) constructor:

use ferra::PaginationParams;

let p = PaginationParams::with(Some(3), Some(50));
assert_eq!(p.resolved_page(), 3);
assert_eq!(p.resolved_per_page(), 50);

let defaulted = PaginationParams::with(None, None);
assert_eq!(defaulted.resolved_page(), 1);
assert_eq!(defaulted.resolved_per_page(), 20);

let clamped = PaginationParams::with(None, Some(500));
assert_eq!(clamped.resolved_per_page(), 100);
assert_eq!(clamped.clamped_per_page(), Some(100));

The constructor stores the raw Option<u32> inputs as-is and applies the clamp / default lazily through the resolved_* / clamped_per_page methods — values produced via with(...) are observationally identical to those that reach a handler through the HTTP extraction path.

  • Default: page=1, per_page=20.
  • Cap: per_page ≤ 100 (hard limit). Values below 1 or above 100 are silently clamped and the response carries Warning: 214 - "per_page clamped to {N} (max 100)" per RFC 7234 §5.5.4.
  • The response body’s per_page field reports the clamped value (so clients can detect the clamp without parsing the Warning header).

Worked curl:

$ curl -D - 'http://localhost:3000/films?per_page=500'
HTTP/1.1 200 OK
warning: 214 - "per_page clamped to 100 (max 100)"
content-type: application/json
...
{"items":[...],"total":523,"page":1,"per_page":100,"_links":{...}}

Base-URL extractor

pub fn base_url_from_parts(parts: &http::request::Parts) -> String;

Produces the base_url prefix used by build_item_links / build_collection_links. Behavior:

  • Scheme: first comma-separated token of X-Forwarded-Proto if it matches the {http, https} allowlist; otherwise "http".
  • Host: the Host header if present and non-empty; otherwise "localhost".
  • Output format: "{scheme}://{host}". No trailing slash.

Anti-spoofing: X-Forwarded-Proto: javascript (or any non-allowlisted scheme) falls back to http. A spoofed header cannot inject a javascript: URL into _links.*.href by construction.

Deploying behind a TLS-terminating load balancer? Ensure the LB sets X-Forwarded-Proto: https so the generated links match the client-visible scheme.


Custom extractors

See custom-handlers.md — the rationale for FerraJson<T> / FerraPath<T> (RFC 7807 error continuity), the FromRequest vs FromRequestParts ordering rule, the Send + Sync + 'static cheat-sheet, and a worked end-to-end POST /films/{id}/publish action route.


Security defaults

ferra_router::<M>(state) mounts a deny-by-default Tower layer stack — a consumer who writes exactly those five lines is already constitution-§I compliant:

LayerDefaultOverride path
tower_http::cors::CorsLayer::new()Deny all cross-originFerraLayer::cors(...) at 0.7.0 Pre-tempering
413 body-limit mapperRewrites stock text/plainapplication/problem+json with type: .../payload_too_large— (permanent)
tower_http::limit::RequestBodyLimitLayer1 MiBFerraLayer::body_limit(...) at 0.7.0
tower_http::trace::TraceLayerBody sampling OFF (never logs PII)Per-route opt-in at 0.12.0 Sheen
tower_governor::GovernorLayer on mutation endpointsper_second(2) + burst_size(5) + peer-socket-IP keyFull config at 0.9.0 Forging III
PaginationParams clamp (FR-017)Loud Warning: 214 header on every clamp— (constitutional)
X-Forwarded-Proto allowlist{http, https} only— (constitutional)

429 wire format — the framework’s default rate-limit component’s 429 responses are rewritten to application/problem+json with type: https://ferra.rs/errors/rate_limited, preserving the Retry-After header from tower_governor verbatim. See the worked body in §“RFC 7807 error taxonomy” above. Oversized bodies produce the same application/problem+json shape — a client parsing type never sees a mixed wire format.

Rate-limit observability event. Each time the rate-limit mapper rewrites a 429, it emits a single structured tracing::warn! event so an operator tailing logs can see rate-limit rejections without reading the framework source:

  • Target: ferra_http::rate_limit — namespaced so an operator can isolate it via EnvFilter (RUST_LOG=ferra_http::rate_limit=warn).
  • Message: rate limit exceeded.
  • Severity: WARN.
  • Fields (deliberately minimal):
    • http.method — the request HTTP method (e.g. "POST"). Always present.
    • http.target — the request path-and-query (e.g. "/films" or "/films?page=2"). Always present.
    • http.retry_after_seconds — integer seconds parsed from the inbound Retry-After header. Present only when the header parses as a non-negative integer; genuinely absent otherwise (not recorded as 0).

Request and response body content never appear in this event — only the three fields listed above. The schema is pinned minimal so the framework’s dedicated observability phase at 0.12.0 Sheen can absorb it into span-based telemetry (adding trace_id, span_id, and peer-IP once the trusted-proxy story lands) without breaking a consumer’s existing EnvFilter or custom tracing::Layer.

Wire up a subscriber in your host binary’s main to surface these events:

tracing_subscriber::fmt()
    .with_env_filter("ferra_http::rate_limit=warn")
    .init();

Layer placement (FR-006). The rate-limit mapper mounts as the outer layer of the mutation sub-router only, wrapping tower_governor::GovernorLayer from the outside (the last .layer() call on that sub-router). Consumer-added middleware appended to ferra_router::<M>(state) wraps the mapper from further outside — see §“Layer ordering — how .layer() composes” below for the full diagram.

Overriding the defaults. Every default in the table above is overridable via the FerraLayer builder applied to a Foundry chain — body cap, CORS, mutation rate-limit rule, tracing layer, and a base_path prefix on hypermedia links. The override surface is documented in foundry.md § “Transport-layer overrides” with the full worked example and the REPLACE-not-merge rule for per-model overrides.

Layer ordering — how .layer() composes

Tower’s .layer() wraps the current router as the inner service, so the last .layer() call becomes the outermost layer. A .layer(MyCustomLayer::new()) appended to ferra_router::<M>(state) therefore wraps every Ferra-provided layer from the outside.

Side-by-side: the conceptual stack (top = outermost, bottom = innermost) and the literal .layer() call sequence in ferra_router::<M>(state) (top = first call, bottom = last call). Arrows map the inversion:

CONCEPTUAL stack (request top→bottom):        LITERAL call sequence (first→last in source):

 outer → CorsLayer                              .layer(DefaultBodyLimit::disable())   ← first call (innermost)
         body_limit_mapper                      .layer(TraceLayer::new_for_http())
         RequestBodyLimitLayer(1 MiB)           .layer(RequestBodyLimitLayer::new(1 MiB))
         TraceLayer                             .layer(middleware::from_fn(body_limit_mapper))
         DefaultBodyLimit::disable              .layer(CorsLayer::new())              ← last call (outermost)
         (on mutation sub-router:)             
           rate_limit_mapper                    [on mutation sub-router:]
           GovernorLayer                          .layer(GovernorLayer { ... })       ← first call on mutations
 inner → handlers                                .layer(middleware::from_fn(rate_limit_mapper))  ← last (outer)

Reading it: a request enters at CorsLayer (outermost), traverses down to the handler, and the response retraces the stack back out. In the source, the call order is inverted — the first .layer() call is the innermost layer on the merged router, and the last .layer() call is the outermost.

Mutation sub-router subtlety. GovernorLayer (rate-limit) and rate_limit_mapper (the 429 RFC 7807 wrapper) mount on the mutation sub-router only — never on the merged router and never on read routes. rate_limit_mapper is the last .layer() call on that sub-router, so it wraps GovernorLayer from the outside and sees the 429s that layer produces. Placing a custom observability layer is a decision about which sub-router you wrap — consumers who want to observe only mutations attach to a sub-router; consumers who want to observe everything attach to the merged router returned by ferra_router::<M>(state).

Worked consumer example. A consumer appending two custom layers on top of the framework stack:

let app = ferra_router::<Film>(state)
    .layer(MyObservabilityLayer::new())   // outer: sees requests before the framework
    .layer(MyShedLoadLayer::new());       // appended AFTER → now the TRUE outermost

On incoming requests: MyShedLoadLayer runs first, then MyObservabilityLayer, then the framework stack (CORS → body_limit_mapperRequestBodyLimitLayerTraceLayerDefaultBodyLimit::disable → handlers; with the mutation-only branch adding rate_limit_mapperGovernorLayer before mutation handlers). On the way back out, responses traverse the stack in reverse.

See also §“RFC 7807 error taxonomy” for the 429 wire body the rate_limit_mapper produces.


Content negotiation — RFC 7240 Prefer: header

Every mutation endpoint (POST, PUT, DELETE) recognises the RFC 7240 Prefer: request header and branches the response shape accordingly. Consumers that do not send the header observe no change — the wire shape stays byte-identical to the framework’s pre-0.6.5 emission.

Recognised tokens

Prefer: valueEffectResulting statusResponse body
(header absent)default mutation shapePOST → 201 Created, PUT → 200 OK, DELETE → 204full resource body on POST/PUT; empty on DELETE
return=minimalomit the response bodyPOST → 204 No Content (Location preserved), PUT → 204, DELETE → 204 unchangedempty
return=representationexplicit form of the defaultPOST → 201, PUT → 200, DELETE → 204 unchangedfull resource body
respond-asyncparse-only at 0.6.5 — recognised so clients can probe the header without being broken, but has no effect on the response shape. Reserved for a future async-jobs cohort.unchangedunchanged

The parser is case-insensitive, whitespace-tolerant, and silently drops unrecognised tokens and ;-delimited parameters per RFC 7240 §2 (“recipients SHOULD ignore preferences they do not understand”):

Prefer: Return=Minimal, respond-async; wait=10

…parses as return=minimal + respond-async (the ; wait=10 parameter is dropped).

Preference-Applied: response header

Per RFC 7240 §3, the framework echoes honoured preferences via the Preference-Applied: response header. The header is absent when no preference was honoured — including the no-header case (table row 1) and the unrecognised-token case.

Request Prefer:Response Preference-Applied:
(absent)absent
return=minimalreturn=minimal
return=representationreturn=representation (echoed even though the body is unchanged — the standard binds the echo to “preferences that were honoured”, not to “preferences that changed the response”)
respond-asyncabsent (parse-only — no observable change)
return=minimal, respond-asyncreturn=minimal (only the honoured preference is echoed)
handling=lenient (unrecognised)absent

Interaction with the 422 validation path

When a mutation request fails declarative validation (the 0.6.0 Welding application/problem+json 422 path), the Prefer: header is recognised but does not change the response. The 422 body is mandated by the framework’s closed-namespace contract; Preference-Applied: is not emitted on the 422 path (no preference was honoured that changed observable behaviour).

Worked example

# Default — full body returned.
curl -i -X POST https://api.example.com/films \
  -H 'Content-Type: application/json' \
  -d '{"title": "Inception", "director": "Christopher Nolan", "year": 2010}'

# HTTP/1.1 201 Created
# Content-Type: application/json
# Location: /films/42
# { "id": 42, "title": "Inception", ... }

# `return=minimal` — body omitted, Location preserved.
curl -i -X POST https://api.example.com/films \
  -H 'Content-Type: application/json' \
  -H 'Prefer: return=minimal' \
  -d '{"title": "Inception", "director": "Christopher Nolan", "year": 2010}'

# HTTP/1.1 204 No Content
# Location: /films/43
# Preference-Applied: return=minimal

Batch-processing consumers gain bandwidth and JSON-parse savings on every mutation; consumers that do not opt in observe the same response shape they did before 0.6.5.


The ferra facade

[dependencies] ferra = { package = "ferra-rs", version = "0.5" } + use ferra::*; is the canonical entry point at 0.4.0. The package = "ferra-rs" alias is required because the ferra name on crates.io is owned by an unrelated project; the framework ships as ferra-rs and Cargo’s package alias keeps ferra::* working in your Rust code (see Getting Started). Every item on this page — FerraState, FerraError, FerraJson, FerraPath, PaginationParams, Link, ItemLinks, CollectionLinks, ItemResponse, CollectionResponse, build_item_links, build_collection_links, base_url_from_parts, the five handlers, and ferra_router — is reached from this single import, alongside the #[derive(FerraModel)] macro and the ferra-core / ferra-db public surface.

Consumers do not depend on ferra-core, ferra-db, ferra-forge, or ferra-http directly. The 0.4.0 compile-fail trybuild fixture also pins ferra::Statement, ferra::execute_unprepared, ferra::raw_sql, and ferra::sea_orm::Statement as unreachable — there is no raw-SQL entry point on the facade.


Worked end-to-end example

use ferra::*;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

mod film {
    use super::*;

    #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
    #[sea_orm(table_name = "films")]
    pub struct Model {
        #[sea_orm(primary_key)]
        pub id: i32,
        #[sea_orm(unique)]
        pub title: String,
        pub director: String,
        pub year: Option<i32>,
    }

    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
    pub enum Relation {}

    impl ActiveModelBehavior for ActiveModel {}
}

pub use film::Model as Film;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let database_url = std::env::var("DATABASE_URL")?;
    let conn = sea_orm::Database::connect(&database_url).await?;
    let app = ferra_router::<Film>(FerraState::new(conn));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

See examples/hello-ferra/ for the full three-command boot sequence (docker compose up -d postgres or podman compose up -d postgressea-orm-cli migrate upcargo run -p hello-ferra) and the smoke.sh script that exercises all seven wire-format contracts.


Troubleshooting

413 vs 400 on oversized bodies

A request whose body exceeds the 1 MiB default cap is rejected either with 413 Payload Too Large or with 400 Bad Request, depending on a single precondition: whether the request declares a Content-Length header.

RequestResponse codetype URIResponsible mapper
Body > 1 MiB with Content-Length declared413https://ferra.rs/errors/payload_too_largebody_limit_mapper (FR-033a)
Body > 1 MiB without Content-Length (streaming / chunked)400https://ferra.rs/errors/validation (single errors[0].code = "invalid_json")FerraJson<T>::from_request surfacing as FerraError::Validation

When Content-Length is present, RequestBodyLimitLayer short-circuits at the layer level before the stream is consumed — hence the early 413. When the header is absent, the stream is consumed up to the cap, the read fails, and the JSON extractor rejects the request as a parse error (still machine-readable, still RFC 7807, still parseable via the same generic type-dispatch path you use for the other validation failures).

Both outcomes are semantically correct — the oversized input is rejected either way. The divergence is intentional at 0.4.1. A future release may unify the two paths under a single 413 (an anvil-class cleanup candidate); the Story 3 regression test pins the current 400 fallback so any such rework must explicitly update or remove it. If you can set Content-Length on your client, do — your clients then see a single, predictable 413.

Other diagnostics

  • 413 with application/problem+json body — request body exceeds 1 MiB. Split the request or (at 0.7.0 Pre-tempering) raise the limit via FerraLayer::body_limit(...).
  • 429 with Retry-After — peer IP hit the mutation rate limit (per_second(2) / burst_size(5)). Back off for the number of seconds in Retry-After.
  • Warning: 214 - "per_page clamped ..." header — your per_page query parameter was out of [1, 100]. Use per_page ≤ 100 to silence it.
  • problem+json with type: ".../validation" and errors[0].code = "invalid_path_param" — the path segment after /{resource}/ is not a valid primary key for M (e.g. GET /films/abc when Film.id: i32). The built-in path extractor rejects before the handler runs.
  • 500 with detail: "internal server error" — the DB surfaced an error the framework does not map. Inspect logs / tracing for the Debug form of FerraError::Internal; the HTTP body intentionally says nothing more.
  • CORS preflight failsCorsLayer::new() denies all cross-origin by default. At 0.4.0, CORS is not configurable; at 0.7.0 Pre-tempering FerraLayer::cors(...) ships the override surface.

  1. emitted by a middleware mapper (the 413 body-limit mapper or the 429 rate-limit mapper), not by FerraError::IntoResponse. The wire shape is identical to the four handler-produced variants — a client that handles one type URI handles them all through the same generic RFC 7807 code path. ↩2

Projections and routing

The 0.6.5 Chasing deliverable. A #[derive(FerraModel)] struct can expose multiple distinct read/write shapes — projections — by adding a single attribute. Each named projection auto-mounts under its own URL prefix and surfaces as a distinct path in the OpenAPI document, a distinct call in the generated SDK, and a distinct HAL _links.self.href target.

This page is the authoritative user-guide for projection-driven routing. A reader with only this page plus standard Rust knowledge produces a working projection declaration on the first attempt — no ADR and no framework source is required.


Mental model

A projection is a content variant of a resource — not an access-control mechanism. The same underlying row can be served through several projections, each one a deliberate slice of the row’s fields plus a deliberate stance on which fields a client may submit.

The defining property is that every projection is a distinct callable entity at every layer of the stack: a distinct URL path, a distinct OpenAPI operationId, a distinct generated-SDK method, a distinct HAL self link. A consumer that has the URL has, by construction, also fixed the wire shape — there is no header, no query parameter, and no out-of-band negotiation required.

Authorisation is enforced separately — by middleware on the route or by per-operation guards. A detail projection is not an admin-only projection; it is a more detailed projection that a developer may choose to gate behind an authorisation layer if they wish.


The routing contract

SurfaceURLWire shape
Default read (always emitted)GET /{resource}/{id} and GET /{resource}Every readable field of M
Default write (always emitted)POST /{resource}, PUT /{resource}/{id}Every writable field of M
Named projection — auto-derived prefixGET /{name}/{resource}/{id} (etc.)Exactly the fields the projection’s read/write lists declare
Named projection — explicit path_prefixGET /{path_prefix}/{resource}/{id} (etc.)As above; the auto-derived /{name}/ is suspended
Default replacement (default = true)GET /{resource}/{id} (the bare path now serves the named projection’s shape)The named projection’s read/write lists

A model that declares zero named projections observes byte-identical 0.5.x / 0.6.0 routing — the bare resource path is unchanged. This is the SC-007 baseline: introducing a named projection on a model is strictly additive to its URL surface, never a silent breaking change.


A worked example

use ferra::*;
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(
    Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
#[ferra(projection(
    name = "detail",
    read = [id, title, director, cost_price],
    write = [title, director, cost_price],
))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[sea_orm(unique)]
    pub title: String,
    pub director: String,
    pub cost_price: Option<f64>,
}

This declaration auto-mounts two parallel route groups:

VerbBare pathDetail path
Read itemGET /films/{id}GET /detail/films/{id}
Read collectionGET /filmsGET /detail/films
CreatePOST /filmsPOST /detail/films
UpdatePUT /films/{id}PUT /detail/films/{id}
DeleteDELETE /films/{id}DELETE /detail/films/{id}

The bare-path responses carry every readable field of Model (the default-read set). The /detail/... responses are filtered to the projection’s read list — fields not in that list never appear in the response body, ever.

For the OpenAPI document, the paths map gains entries for /films/{id} and /detail/films/{id}, each operation referencing a distinct schema (Film vs FilmDetail). SDK generators driving off the document produce distinct method names per surface (client.films.get(id) vs client.films.getDetail(id)).


Overriding the URL prefix

By default the projection’s name is lower-cased and used as the URL prefix segment. To pin the URL surface to something different — for example, a versioned segment or a developer-friendly synonym — add a path_prefix:

#[ferra(projection(
    name = "detail",
    path_prefix = "/v2/detail",
    read = [id, title, director, cost_price],
    write = [title, director, cost_price],
))]

This mounts /v2/detail/films/{id} (and parallel verbs). The auto-derived /detail/... URL is NOT also registered — exactly one URL surface exists per projection.

The leading / is part of the literal; the framework appends the trailing / and the resource segment.


Naming convention — shape, not audience

Projection names MUST describe the shape of the variant — what fields it exposes — not the audience it serves.

Good (shape)Bad (audience)
"detail""admin"
"summary""staff"
"compact""backoffice"
"full""internal"

Naming a projection after a role visually couples the projection to access-control in the developer’s mental model and in URLs that appear in access logs — even though projections enforce no authorisation. A "detail" projection that exposes cost prices is still subject to whatever auth middleware sits on its route; calling it "admin" invites the next reader to assume the projection itself enforces a role check.

Use the role-neutral shape name and gate the route at the auth layer.


Default replacement and the silent-break problem

Setting default = true on a named projection makes that projection’s shape the one served at the bare resource path. The auto-default read/write surface is suppressed; consumers requesting /films/{id} get the named projection’s shape without any URL change to signal the difference. This is a silent breaking change to the public API contract.

To force the change to be visible, the framework requires two attestation attributes alongside default = true:

#[ferra(projection(
    name = "detail",
    default = true,
    promotes_from = "auto-derived",      // attestation of the previous default
    breaking_change_version = "2.0.0",   // app version in which the promotion lands
    read = [id, title, director, cost_price],
    write = [title, director, cost_price],
))]

Missing either attestation produces compile-time FRG-316. promotes_from is purely declarative — the macro does not validate it against repository history. Its purpose is to force the developer to name what is being replaced, which is the part the silent-flip case skips entirely. breaking_change_version surfaces in the OpenAPI document as the x-ferra-promoted-in vendor extension on every operation under the bare resource path, so SDK consumers and downstream tooling can detect the silent break.

A second defence (the .ferra/projection-defaults.lock file with schema-hash drift detection) is queued for the next release; at 0.6.5 the Layer-1 attestation is the sole defence.

Worked example — promotion replaces the bare path

The default-promoting projection serves at the bare resource path and no prefixed mirror is published. A model whose v2 projection is the default has exactly one URL surface for the projection’s shape: /films / /films/{id}. The would-be auto-derived /v2/... prefix is not registered, so GET /v2/films/{id} returns 404. This is what makes the promotion a REPLACE, not an ADD.

#[derive(FerraModel, /* ... */)]
#[sea_orm(table_name = "films")]
#[ferra(projection(
    name = "v2",
    default = true,
    promotes_from = "auto-derived",
    breaking_change_version = "2.0.0",
    read = [id, title],
    write = [title],
))]
pub struct Film {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
}

Wire-shape contract:

RequestResponse
POST /films body {"title": "X"}201 Created — accepted, named projection’s write_fields
POST /films body {"title": "X", "unknown": "y"}422 Unprocessable Entity — unknown field rejected
GET /films/{id}200 OK body {"id": ..., "title": "X", "_links": {...}}
GET /v2/films/{id}404 Not Found — the prefixed mirror is NOT published

Worked example — x-ferra-promoted-in in the OpenAPI document

The breaking_change_version attestation surfaces as the x-ferra-promoted-in vendor extension on every operation under the bare resource path. SDK generators and tools like ferra anvil read this extension to detect the silent breaking change.

For the Film model above, fetching GET /docs/openapi.json returns a document containing:

paths:
  /films:
    get:
      x-ferra-promoted-in: "2.0.0"
      # ... rest of operation ...
    post:
      x-ferra-promoted-in: "2.0.0"
      # ...
  /films/{id}:
    get:
      x-ferra-promoted-in: "2.0.0"
      # ...
    put:
      x-ferra-promoted-in: "2.0.0"
      # ...
    delete:
      x-ferra-promoted-in: "2.0.0"
      # ...

The extension is per-operation under the bare path. A non-default named projection (one with an auto-derived /admin/ prefix or an explicit path_prefix = "/...") does not emit the extension on its prefixed operations — the extension is the signal that the model’s bare-path wire shape has shifted, not a per-model badge.


Prefix collisions

Two collision classes are rejected by the framework — one at compile time, one at Foundry::build().

FRG-310 — same prefix on two projections

Two projections (on the same model or across two mounted models) that resolve to the same effective URL prefix produce ambiguous routing. The compile-time channel catches the intra-model case; the Foundry::build() channel catches the cross-model case.

// Both projections declare path_prefix = "/staff" → FRG-310 at compile time.
#[ferra(projection(name = "admin", path_prefix = "/staff", read = [id, title]))]
#[ferra(projection(name = "manager", path_prefix = "/staff", read = [id, title]))]

Resolution: rename one of the projections, or set a distinct path_prefix on one of them.

FRG-312 — projection prefix collides with another resource

A named projection’s auto-derived prefix that matches another mounted model’s resource_name is rejected at Foundry::build(). For example, model A with projection name = "admin" (auto-deriving /admin/) cannot be mounted on the same Foundry as model B with resource_name = "admin" (mounting at /admin).

Resolution: rename the projection to a shape descriptor that does not match any mounted resource name, or set an explicit path_prefix to disambiguate.


Nested resources (root-only URL surface)

When 0.6.5 ships, a single Ferra application mounts every model on a flat URL surface — there is no nested-resource mounting API. ADR-0030 §Nested resources reserves the routing semantics for a future release:

  • The URL prefix derives exclusively from the root resource’s active projection and propagates uniformly to every nested segment.
  • A nested model MAY declare a projection with the same name as the root’s; its read/write shapes are served for that segment. If it does not declare a matching projection, the default read/write shape of the nested model is served (additive fallback).
  • A nested (non-root) model MUST NOT declare its own path_prefix. Doing so produces compile-time FRG-315 when the nested-mounting API ships.

No URL of the form /detail/films/{id}/summary/reviews/{rid} is supported — a request reaches with one variant choice that applies coherently to the entire response graph. A developer who needs different per-segment shapes declares distinct root-level projections.


Versioning composition

Ferra takes no position on API versioning — that is an application-level concern. The framework provides two complementary mechanisms that compose with the projection surface:

  1. Application-level prefix (canonical). Mount the entire API under a version segment:

    let app = Foundry::new(conn).api_version("v1")?.mount::<Film>().build();

    When a breaking change requires bumping /v1/v2, the developer bumps the prefix in the application code. The framework provides the signal (the FRG-316 attestation requirement on default = true); the developer decides when to bump and how to communicate the bump (Deprecation: header, sunset date, parallel-run period).

  2. Per-projection versioning (escape hatch via path_prefix). A single projection can be versioned independently of the application by embedding the version in its path_prefix:

    #[ferra(projection(name = "detail", path_prefix = "/v2/detail", read = [...]))]

    This composes naturally with .api_version("v1") — the final URL becomes /v1/v2/detail/films/{id}.

The framework deliberately does NOT introduce a separate version = N attribute; path_prefix is sufficient for the escape-hatch case and avoids growing the projection surface for what is fundamentally an application concern.


Why path-prefix routing — and what it costs

Three alternative designs were considered and rejected. The honest trade-offs:

AlternativeProsWhy rejected
Accept: profile=... (RFC 6906)Standards-track REST answer; preserves single-URL semanticsNo mainstream SDK generator branches on Accept parameters; CDN caching requires Vary: Accept (fragments hit rates); HAL _links cannot encode the profile in the href; AI clients must reason about header serialisation at the call site
Prefer: profile=... (RFC 7240 extension)Reuses an existing header surfaceRFC 7240 §2 mandates that recipients MUST silently ignore unrecognised preferences — a misconfigured intermediary could drop the variant signal without error; conflates Prefer:’s response-shape semantic with variant selection
Query parameter (?projection=... / JSON:API sparse fieldsets / Google FieldMask / Stripe expand)Strongest industry precedent; single canonical URL per row; arbitrary runtime field selection composes naturallyCollapses the OpenAPI paths map into a single entry per resource; SDK generator produces one method per resource with a runtime opts.projection parameter; HAL _links.self.href either encodes the query string (less clean) or drops the variant signal; diagnostics for invalid projection names move from compile-time to runtime

The load-bearing axis is the typed-callable-per-projection invariant: every projection should be a distinct callable entity at every layer of the stack — handler, OpenAPI path, SDK method, HAL self URL, operationId. Query parameter routing collapses three of those four layers (OpenAPI path, SDK method, operationId) back into a single entity with a runtime discriminator, which weakens the typed-projection contract this feature exists to support.

Path-prefix routing is the more aggressive choice and the less-precedented one, but it is the one that propagates the type-level distinction through to the wire format. The trade-off accepted: REST-purist critique on URL-per-row, in exchange for typed-callable-per-projection through OpenAPI and SDKs.


When NOT to use projections

  • The variant exists for authorisation reasons (e.g., “only authenticated admins see cost prices”). Use auth middleware on a route, not a projection.
  • The variant changes the underlying row identity (e.g., joined data from another table). That is a separate resource, not a projection — declare a second #[derive(FerraModel)].
  • The variant is one-of-many runtime field selections (?fields=id,title,cost_price style). Projections are named-and-typed at compile time; arbitrary runtime field selection is a future feature on top of (or instead of) projections.
  • The wire shape varies by row content, not by URL. Projections are URL-keyed by design.

Reference

Behaviors

The 0.7.0 Pre-tempering deliverable. A #[derive(FerraModel)] struct can enrol cross-cutting behaviors that the framework injects in lockstep across the entire stack — fields, SQL, default projections, hypermedia links, and OpenAPI emission — from a single container-level attribute.

This page is the authoritative user-guide for the behaviors surface. A reader with only this page plus standard Rust knowledge produces a working #[behavior(...)] declaration on the first attempt — no ADR and no framework source is required.


Mental model

A behavior is a cohesive cohort of cross-cutting modifications the framework applies to a model: the fields it carries, the SQL the framework emits, the projections the framework derives, the hypermedia links the framework attaches, and the OpenAPI operations the framework documents — all at once, from one attribute line.

The two behaviors that ship at this release:

NameEffect
soft_deleteAdds a deleted_at column. Every framework-generated SELECT filters rows whose stamp is non-null. The standard DELETE rewrites to an UPDATE deleted_at = NOW(). A new POST /{resource}/{id}/restore action sets the stamp back to NULL.
timestampableAdds created_at and updated_at columns. Every framework-generated INSERT stamps both. Every framework-generated UPDATE advances updated_at. Submitted values for either field in a create/update body are silently ignored.

A model that declares zero #[behavior(...)] attributes observes byte-identical 0.6.5 behaviour. Adding a behavior is strictly additive to the model’s surface — the existing routes, the existing projections, and the existing OpenAPI emission all continue to exist; the behavior layers new structure on top.


The attribute

#[behavior(name1, name2, ...)]
  • Container-level — applied to the struct declaration, not to individual fields.
  • Closed vocabulary at this release: soft_delete, timestampable. An unknown name is a compile-time error.
  • Stackable — the attribute can appear multiple times on the same struct; the framework dedupes by set-union.
  • Order-irrelevant#[behavior(soft_delete, timestampable)] and #[behavior(timestampable, soft_delete)] produce byte-identical ModelMeta.
  • No arguments at this release — each name is a bare identifier.

#[behavior(soft_delete)]

Worked example

use ferra::*;
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(
    Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
#[behavior(soft_delete)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Uuid,
    pub title: String,
}

The framework’s ModelMeta for Film carries:

  • A behaviors slice containing BehaviorKind::SoftDelete.
  • An automatically injected deleted_at: Option<jiff::Timestamp> field at the end of the user’s field list, flagged read_only: true so the default-read projection includes it and the default-write projection excludes it.
  • An auto-emitted OperationMeta::new("restore", "POST", "restore") on operations.

The migration

The column shape is hand-written into your sea-orm-migration migration file at 0.7.0 (the ferra-anvil migration create scaffolder lands at 0.13.0 and emits the same shape automatically based on M::meta().behaviors):

ALTER TABLE films ADD COLUMN deleted_at TIMESTAMPTZ NULL;

That is the entire migration surface — the framework’s runtime SQL composes the predicate (WHERE deleted_at IS NULL) and the rewrites (DELETE → UPDATE deleted_at = NOW()) on top of this single column.

What changes on the wire

  • DELETE /films/{id} on a live row → 204 No Content, no physical delete; the row’s deleted_at is set to NOW().
  • DELETE /films/{id} on an already-soft-deleted row → 404 Not Found (the framework’s SELECT predicate filters the row out before the handler reaches it).
  • GET /films/{id} on a soft-deleted row → 404 Not Found.
  • GET /films → soft-deleted rows are absent from the collection.
  • The 1-to-1 read response shape is unchanged — your existing JSON consumers see deleted_at: null on every live row (the field is in the default-read projection by construction).

Restoring a row

POST /films/{id}/restore
  • On a soft-deleted row → 200 OK with the row’s HAL ItemResponse. The deleted_at is set back to NULL.
  • On a row that never existed → 404 Not Found.
  • On a row that is already live (deleted_at IS NULL) → 404 Not Found (idempotent — re-restoring a live row is a no-op as far as the client is concerned).
  • The route honours Prefer: return=minimal per the framework’s standard preference header rules: with the header → 204 No Content, Preference-Applied: return=minimal, empty body.

The restore action mounts under the standard Tower stack — CORS deny / body cap / mutation rate-limit / tracing — exactly like the standard CRUD mutation routes. It composes with per-projection URL prefixes by simple concatenation:

POST /admin/films/{id}/restore   ← when the model has an `admin` projection

_links.restore.href

Every live row’s HAL response carries:

{
  "data": { "id": "550e8400-...", "title": "Casablanca", "deleted_at": null },
  "_links": {
    "self":       { "href": "https://api.example/films/550e8400-..." },
    "collection": { "href": "https://api.example/films" },
    "restore":    { "href": "https://api.example/films/550e8400-.../restore" }
  }
}

The _links.restore.href is present only on live rows — soft-deleted rows are filtered before they ever reach the link-emission stage, so a client cannot observe a _links.restore.href on a row whose stamp is non-null. The URL composes through any configured base_path automatically (see Foundry → Transport-layer overrides → base_path).

The AllowsHardDelete opt-in marker

The framework’s standard DELETE /{r}/{id} handler rewrites to UPDATE deleted_at = NOW() on every soft-delete model. A developer who registers a custom processor that bypasses this and issues DELETE FROM films WHERE ... directly is bypassing the soft-delete safety contract.

The framework requires the bypass to be explicit:

use ferra::AllowsHardDelete;

pub struct PurgeProcessor;

impl FerraProcessor<Film> for PurgeProcessor {
    // ... developer's processor logic, including the physical DELETE ...
}

// The opt-in:
impl AllowsHardDelete for PurgeProcessor {}

Without the impl AllowsHardDelete line, Foundry::build() panics at boot with a verbatim message naming the model, the processor type, and the exact impl line the developer must add. The framework refuses to wire the application together until the bypass is acknowledged.

A custom processor that does NOT issue a physical DELETE — for example, one that mutates deleted_at directly via UPDATE films SET deleted_at = NOW() WHERE id = $1 — does NOT require the marker. The marker gates only the physical-delete path.

Tip: directly mutating deleted_at from a custom processor is permitted without the AllowsHardDelete marker, but it bypasses the framework’s restore action and the HAL link emission. The framework provides the restore operation as the supported way to flip the stamp back; direct mutation is your responsibility.

Edge case — identifier collision with a soft-deleted row

A POST /films that supplies an id matching an existing soft-deleted row returns the framework’s standard 409 conflict response:

{
  "type": "https://ferra.rs/errors/conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "..."
}

This is the standard duplicate-key shape unchanged — the soft-deleted row continues to occupy the UNIQUE (id) index, and an INSERT colliding on it is a primary-key collision regardless of stamp state. The framework’s behaviour matches the database’s natural semantics.

If your application needs identifier reuse across hard-deleted-and-restored cycles, apply a partial unique index in the same migration:

-- Allow re-use of soft-deleted identifiers:
CREATE UNIQUE INDEX films_id_live_only ON films (id) WHERE deleted_at IS NULL;
ALTER TABLE films DROP CONSTRAINT films_pkey;
ALTER TABLE films ADD CONSTRAINT films_pkey PRIMARY KEY (id) DEFERRABLE INITIALLY IMMEDIATE;

The partial-unique-index pattern is an opt-in schema customisation; the framework does not emit it automatically.


#[behavior(timestampable)]

Worked example

use ferra::*;
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(
    Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
#[behavior(timestampable)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Uuid,
    pub title: String,
}

The framework injects created_at: jiff::Timestamp and updated_at: jiff::Timestamp at the end of the field list, both flagged read_only: true (present in the default-read, absent from the default-write).

The migration

ALTER TABLE films ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE films ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();

NOT NULL DEFAULT NOW() ensures that pre-existing rows in the table receive a value at migration time. The framework’s runtime SQL stamps both columns on every INSERT and advances updated_at on every UPDATE, so the DEFAULT NOW() only matters for the migration path.

What changes on the wire

  • POST /films → the response carries created_at and updated_at populated to the server’s current time. The request body cannot set them; values submitted in either field are silently ignored.
  • PATCH /films/{id} → the response carries the advanced updated_at; created_at is unchanged.
  • GET /films/{id} and GET /films → both fields appear on every row.

The “silently ignored on submit” behaviour follows from the read_only: true flag: the default-write projection excludes both fields, and the deserialisation rejects unknown fields when strict-projection-write is on (the framework’s default). Submitting a body with an explicit created_at value either returns a 422 (strict mode) or silently drops it (lenient mode) — the persisted value is always the framework’s NOW() stamp.


Combined behaviors

#[behavior(soft_delete, timestampable)] enrols both. The framework injects all three fields and the restore action. The composition behaves as the union of the two individual behaviors:

#[derive(
    Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize,
)]
#[sea_orm(table_name = "films")]
#[behavior(soft_delete, timestampable)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Uuid,
    pub title: String,
}

Composition rules:

  • DELETE /films/{id} rewrites to UPDATE films SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL. Soft-delete refreshes updated_at because the soft-delete is logically a write.
  • POST /films/{id}/restore emits UPDATE films SET deleted_at = NULL, updated_at = NOW() WHERE id = $1 AND deleted_at IS NOT NULL. Restore refreshes updated_at; created_at stays at the original creation time.
  • The order of names is irrelevant — #[behavior(timestampable, soft_delete)] produces the same ModelMeta as #[behavior(soft_delete, timestampable)].

The combined migration:

ALTER TABLE films ADD COLUMN deleted_at TIMESTAMPTZ NULL;
ALTER TABLE films ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE films ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();

Common pitfalls

Manually declaring an injected column on the struct

#[behavior(soft_delete)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Uuid,
    pub title: String,
    pub deleted_at: Option<jiff::Timestamp>,   // ← compile error
}

The framework emits FRG-323 at proc-macro time:

error: field `deleted_at` conflicts with the soft_delete behavior's auto-injected field
help: remove the field declaration; the behavior injects it automatically

The same diagnostic applies to created_at and updated_at under #[behavior(timestampable)]. Behaviors own their injected column shape; if you need a column whose name happens to match one of the framework’s reserved names but holds different data, choose a different column name.

Forgetting the AllowsHardDelete marker on a custom processor

A FerraProcessor registered against a soft-delete model that issues a physical DELETE triggers a boot-time panic:

the model `Film` is enrolled in the soft-delete behavior AND a custom processor
`PurgeProcessor` is registered against it. The processor MUST opt in via
`impl AllowsHardDelete for PurgeProcessor {}` to acknowledge that a physical
delete bypasses the soft-delete safety contract. See
https://ferra.rs/guide/behaviors#the-allowshard-delete-opt-in-marker.

Add the marker (impl AllowsHardDelete for PurgeProcessor {}) or rewrite the processor to soft-delete instead. The check runs at Foundry::build() — it does not require a request to surface.

Submitting a timestamp value in a request body

A POST /films request whose body carries created_at: "2020-01-01T00:00:00Z" does NOT plant the past date — the framework silently ignores submitted values for behavior-injected timestamps. The persisted value is the server’s NOW() at the moment of the INSERT. This is by construction (the field is excluded from the default-write projection).

If your application needs to record a different “as-of” date (e.g., for retroactive imports), declare a separate column with a non-reserved name (recorded_at, effective_at, …) and write it normally — the framework’s behaviors own only the names listed in the closed vocabulary above.

Unknown behavior name

#[behavior(softdelete)]    // typo — missing underscore

The framework emits FRG-320 at proc-macro time:

error: unrecognised behavior name `softdelete`
help: did you mean `soft_delete`?
note: recognised behaviors: soft_delete, timestampable.
      See docs/user-guide/behaviors.md.

The recognised vocabulary is closed; future releases add new names additively (the #[non_exhaustive] BehaviorKind enum makes the additions non-breaking by construction).

Empty #[behavior()]

#[behavior()]    // empty

The framework emits FRG-321:

error: behavior attribute requires at least one name
help: remove the attribute or supply at least one of: soft_delete, timestampable

An empty attribute is rejected because it has no effect and most likely indicates an editor template that was not filled in.

Non-identifier syntax inside the attribute

#[behavior(soft_delete = true)]      // key/value — not the grammar
#[behavior("soft_delete")]            // string literal — not the grammar

Both fire FRG-322 at proc-macro time. The grammar is bare comma-separated identifiers; key/value pairs and string literals are reserved for future widening.


Forward compatibility

The behaviors namespace is #[non_exhaustive] at the IR level — future releases add behaviors additively without breaking existing call sites. Future releases will likely add behaviors such as audit_log, tenanted, versioned, and other model-level concerns — the namespace is designed for additive growth and no specific shape is contracted at this release. Argument-bearing behaviors (e.g., #[behavior(versioned(strategy = "snapshot"))]) are explicitly not part of the 0.7.0 surface; the grammar widens additively when the first argument-bearing behavior lands.

ferra-openapi

ferra-openapi is the framework’s documentation surface. It builds an OpenAPI 3.1 specification from a model’s ModelMeta description and serves the spec at /docs/openapi.json plus an interactive Scalar UI at /docs. Both surfaces are derived end-to-end from the same #[model] source of truth that drives the framework’s HTTP routes, SQL queries, and hypermedia links.

The crate ships in 0.5.0 Rolling. The recommended call site is Foundry::with_docs() (landing in 0.5.0 Rolling alongside this crate); the underlying free functions in this crate stand alone for direct use cases and for the per-resource integration test suite.


What you get for free

A Ferra #[model] mounted through Foundry::with_docs() produces:

  • GET /docs/openapi.json — full OpenAPI 3.1 document.
  • GET /docs — interactive Scalar UI rendering the spec.

Both routes are publicly reachable by default — the inventory is the feature. For deployments where the endpoint structure is itself sensitive, the auth-gated variant (Foundry::with_docs_protected(verifier)) ships in the same release and gates the surface behind authentication. See §“Public-by-default rationale” below for when to choose which.

For each mounted model M (PascalCase struct name), the spec contains:

  • Five paths (GET /{resource}, GET /{resource}/{id}, POST /{resource}, PUT /{resource}/{id}, DELETE /{resource}/{id}).
  • Four projection schemas ({Model}, Create{Model}Input, Update{Model}Input, {Model}Collection).
  • A shared ProblemDetails schema referenced by every error response.
  • Every operation carries an operationId and a tags array containing the resource name. Zero inline schemas in operation definitions; every body schema is a $ref into components/schemas.

Enabling the docs surface

The headline call site is Foundry::with_docs():

use ferra::Foundry;

let app = Foundry::new(conn)
    .mount::<Film>()
    .with_docs()
    .build();

That single chain produces a runnable HTTP service mounting Film’s five CRUD endpoints plus /docs/openapi.json and /docs with zero boilerplate.

Variants

MethodEffect
.with_docs()Public docs at /docs and /docs/openapi.json.
.with_docs_at("/api-docs")Public docs at the supplied prefix.
.with_docs_protected(verifier)Auth-gated docs at /docs; anonymous requests get 401 + RFC 7807 body.
.with_docs_protected_at("/api-docs", verifier)Auth-gated at the supplied prefix.

Public-by-default rationale

The framework’s default-deny posture on model routes does not extend to the docs surface — and the deviation is security-positive, not a convenience trade-off.

/docs/openapi.json is generated from the same ModelMeta IR that drives the routes. By construction, every endpoint a Ferra application exposes is documented, with no manual schema to keep in sync. This structurally closes OWASP API Security Top 10 API9:2023 (Improper Inventory Management) — the gap between the API a team thinks it exposes and the API actually deployed. Salt Security research cited in OWASP API9 measures that gap at 40% on average in production (one cited case: 54 undocumented endpoints, 12 exposing PII). The public default makes the complete inventory legible to any operator without an opt-in step; a deny-by-default /docs would force every team to opt into seeing what their own API exposes, which is the inverse of the API9 recommendation.

For deployments that must restrict who can read the inventory, .with_docs_protected(verifier) ships in the same release and gates the surface behind authentication — operators keep that floor without changing crates.

Rule of thumb: prefer .with_docs() for development, internal tools, public APIs intentionally discoverable, and AI-consumable surfaces (the inventory is the feature). Prefer .with_docs_protected(verifier) for any production deployment of an internal-only API whose endpoint structure is itself sensitive.

The deviation from default-deny is recorded in ADR-0024 for the curious reader; this guide stands alone.


Naming conventions

Schema names

For a model named Film with no named projections declared, the spec carries four entries in components/schemas:

ProjectionOpenAPI schema namePurpose
ReadFilmResponse shape for GET and the body of POST / PUT responses.
CreateCreateFilmInputRequest body of POST /films.
UpdateUpdateFilmInputRequest body of PUT /films/{id}.
CollectionFilmCollectionResponse shape for GET /films ({ items: [Film], page, per_page, total }).

The schema name derives from resource_name (the URL path slug) via PascalCase singularisation.

Per-projection schemas

A model that declares one or more #[ferra(projection(...))] blocks emits an additional triple of schemas per named projection. For Film with one named admin projection (#[ferra(projection(name = "admin", read = […], write = […]))]):

ProjectionOpenAPI schema nameOperations served at
ReadFilmAdminGET /admin/films/{id}, GET /admin/films
CreateCreateFilmAdminInputPOST /admin/films
UpdateUpdateFilmAdminInputPUT /admin/films/{id}
CollectionFilmAdminCollectionGET /admin/films (pagination envelope)

The general rule:

  • Read schema: {Model}{Projection-PascalCase}.
  • Create schema: Create{Model}{Projection-PascalCase}Input.
  • Update schema: Update{Model}{Projection-PascalCase}Input.

For the default projection the {Projection-PascalCase} segment is the empty string, so the rule produces Film, CreateFilmInput, UpdateFilmInput.

Each schema’s properties map carries exactly the field set the corresponding projection declares — no schema reuse via $ref from one projection to another. A reader scanning the document sees the per-projection shape without dereferencing chains.

Names are SDK-friendly: orval, openapi-generator, kiota, and their peers produce idiomatic, distinguishable types in the target language without post-processing. The framework’s internal Rust struct names (FilmReadProjection, FilmAdminReadProjection, …) NEVER leak into the published document — components/schemas keys, $ref values, operation IDs, and descriptions all carry only the public names from the tables above.

x-ferra-promoted-in vendor extension

When a named projection carries default = true with a breaking_change_version = "X.Y.Z" attestation (per ADR-0030 §Default-projection promotion safeguards), every operation served under the bare resource path gains an x-ferra-promoted-in: "X.Y.Z" vendor extension. SDK generators and downstream tooling (ferra anvil) surface the silent breaking change to consumers who might otherwise see only the URL stability.

operationId

Every operation carries a stable operationId:

MethodPathoperationId
GET/filmslistFilms
GET/films/{id}getFilm
POST/filmscreateFilm
PUT/films/{id}updateFilm
DELETE/films/{id}deleteFilm

Versioned chains (Foundry::api_version("v1")) prefix the operationId with the version segment: v1.listFilms, v1.getFilm, etc.

tags

Every operation carries a single tag — the resource name in snake_case plural (films, actors). Scalar / Redoc / Swagger UI group operations by tag for easier navigation.


Field type mapping

Field types map to OpenAPI 3.1 schemas as follows:

Rust typeOpenAPI
String{ "type": "string" }
i32{ "type": "integer", "format": "int32" }
i64{ "type": "integer", "format": "int64" }
f32 / f64{ "type": "number" }
bool{ "type": "boolean" }
ferra::Id / Uuid{ "type": "string", "format": "uuid" }
Option<T>nullable T ({ "type": ["string", "null"] }) and absent from required
ferra::DateTime{ "type": "string", "format": "date-time" }
ferra::Date{ "type": "string", "format": "date" }

Option<T> uses the OpenAPI 3.1 nullable form (a type array containing "null"), not the deprecated 3.0 nullable: true keyword.

The two typed time newtypes (ferra::DateTime and ferra::Date) are recognised by the framework and emit the right format. Direct use of legacy time crates (chrono::*, time::*) is rejected at the field-declaration site — see the §“Time vocabulary” section of ferra-core.md.


Field annotations

Two #[field(...)] attributes shape what appears in each projection.

#[field(skip)]

Excludes the field from every projection schema and from the wire contract. Useful for server-only state (audit timestamps, internal flags).

#[derive(FerraModel)]
pub struct Film {
    #[id]
    id: ferra::Id,
    title: String,
    #[field(skip)]
    internal_note: String,
}

internal_note does not appear in Film, CreateFilmInput, or UpdateFilmInput, and the wire shape rejects an inbound value for that field.

#[ferra(read_only)]

The field appears in every read schema (Film and any per-projection read schema where the field is included) marked readOnly: true, and is structurally absent from every create / update input schema. Useful for server-assigned values that the consumer can read but cannot write.

#[derive(FerraModel)]
pub struct Film {
    #[id]
    id: ferra::Id,
    title: String,
    #[ferra(read_only)]
    indexed_at: ferra::DateTime,
}

indexed_at appears in Film with readOnly: true; both CreateFilmInput and UpdateFilmInput omit the property entirely. If the model also declares a named admin projection that includes indexed_at in its read = [...] list, the field appears in FilmAdmin with readOnly: true and is omitted from CreateFilmAdminInput / UpdateFilmAdminInput.

#[ferra(write_only)]

The field appears in every write schema (CreateFilmInput / UpdateFilmInput / per-projection input schemas where the field is included), and is structurally absent from every read schema — not marked writeOnly: true on the read side, but simply missing. Useful for fields the consumer must supply on write but should never see on read (one-shot secrets, transient hints, write-side credentials).

#[derive(FerraModel)]
pub struct Film {
    #[id]
    id: ferra::Id,
    title: String,
    #[ferra(write_only)]
    new_password: String,
}

new_password appears in CreateFilmInput and UpdateFilmInput; the Film read schema omits the property entirely. The framework guarantees the field cannot leak via a read operation — its absence is enforced at compile time on the generated read-projection struct.

#[ferra(computed)]

The field is server-derived (e.g., a derived count, a denormalised projection of joined rows). It appears in every read schema with readOnly: true, and is structurally absent from every write input schema — the framework calls FerraComputed::compute() / compute_async_batch() on read and never accepts the field as inbound input.

#[derive(FerraModel)]
pub struct Film {
    #[id]
    id: ferra::Id,
    title: String,
    #[ferra(computed)]
    review_count: i64,
}

review_count appears in Film with readOnly: true; the create and update inputs omit the property entirely. See ferra-core.md § Computed fields for the FerraComputed trait that drives the compute pipeline.


The shared ProblemDetails schema

Every error response in the spec references the same schema:

{
  "$ref": "#/components/schemas/ProblemDetails"
}

The schema’s body:

{
  "type": "object",
  "required": ["type", "title", "status"],
  "properties": {
    "type":   {
      "type": "string",
      "format": "uri",
      "enum": [
        "https://ferra.rs/errors/not_found",
        "https://ferra.rs/errors/validation",
        "https://ferra.rs/errors/conflict",
        "https://ferra.rs/errors/internal",
        "https://ferra.rs/errors/payload_too_large",
        "https://ferra.rs/errors/rate_limited",
        "https://ferra.rs/errors/unauthorized"
      ]
    },
    "title":    { "type": "string" },
    "status":   { "type": "integer", "format": "int32" },
    "detail":   { "type": "string" },
    "instance": { "type": "string", "format": "uri-reference" }
  }
}

The closed enum on type is the load-bearing constraint: SDK generators produce a typed union over the URI set in the target language. An AI agent generating client code is foreclosed from fabricating new URIs — values outside the enum fail schema validation.

The full URI table (with HTTP status, title, and example body for every variant) lives in error handling.


Mounting an alternative UI

/docs/openapi.json is the framework’s stable spec endpoint; the Scalar UI at /docs is the bundled default but not the only choice. To replace Scalar with Redoc, RapiDoc, or Swagger UI, mount the alternative on the existing axum router via Router::merge:

use ferra::Foundry;
use utoipa_redoc::{Redoc, Servable};

let app = Foundry::new(conn)
    .mount::<Film>()
    .build()                                        // no .with_docs()
    .merge(ferra_openapi::docs_routes(             // hand-mount the JSON
        ferra_openapi::build_openapi(
            ferra_openapi::info("Hello", "0.5.0"),
            "Film",
            Film::meta(),
        ),
        "/docs",
    ))
    .merge(Redoc::with_url("/redoc", openapi));    // and Redoc on top

The same pattern works with utoipa-rapidoc and utoipa-swagger-ui.


Where the spec is built

build_openapi(info, model_name, meta) assembles the full document from a model’s static ModelMeta reference. The function runs once at process startup; the resulting OpenApi value is cached and served unchanged on every /docs/openapi.json request — no per-request rebuild.

For the multi-model assembly, build_openapi_for_models(info, &[ModelEntry, ...]) produces a single document covering every mounted model. Foundry::build() calls this internally; consumers who want to assemble a spec by hand can call it directly.


Linting your spec

The framework’s CI runs Spectral against every reference example’s generated /docs/openapi.json, extending the default spectral:oas ruleset with four Ferra-specific rules: every operation declares a tags array, every operation declares an operationId, every request body schema is a $ref into components/schemas, and every response schema is a $ref into components/schemas. New linter findings at the warn severity bar block the build.

Two takeaways for consumer-side CI:

  • Spectral is the recommended linter for a Ferra-served API. The framework’s .spectral.yaml (in the workspace root) is a reasonable starting ruleset. Copy it into a downstream repo and add deployment-specific rules (e.g., re-enable oas3-api-servers once the deployment carries a non-empty servers array).
  • The rule selection is not arbitrary. The full adopted / deferred / rejected matrix — including why Spectral wins over Redocly, why Vacuum is rejected, and why progenitor / openapi-generator / orval smoke-tests are deferred rather than adopted — lives in the contributor research memo at docs/research/dropshot-openapi-conformance.md. Curious readers consult that memo; correct use of the gate does not require it.

See also

  • Foundry — the assembly facade that mounts the docs surface alongside resource routes (lands alongside this crate in 0.5.0).
  • Error handling — the closed ERROR_TYPES URI namespace and the ProblemDetails consumer- side branching pattern.
  • ferra-core §Time vocabulary — the typed DateTime / Date newtypes that emit format: date-time and format: date in the spec.

For curious readers, the architectural decisions behind the docs surface are recorded in ADR-0024 (crate scope + public-default posture), ADR-0026 (closed ERROR_TYPES URI namespace), and ADR-0003 v2 (Scalar over Swagger UI as the default UI). These ADRs are not load-bearing for correct use of the feature; this guide stands alone.

ferra-forge — the #[derive(FerraModel)] derive

Status: 0.2.0 Smelting.

Reader contract. This page stands on its own. A reader with only this page plus standard Rust and Sea-ORM knowledge can produce a compiling entity on the first attempt. Cross-references to ADRs and the constitution are non-load-bearing — every correctness cue is spelled out below.

ferra-forge is the Ferra proc-macro crate. It exports one procedural derive, #[derive(FerraModel)], which binds a Rust struct to its static ferra_core::meta::ModelMeta description. The derive cohabits with Sea-ORM’s DeriveEntityModel on the same struct — neither replaces the other; both read the struct.

Canonical entity shape (sibling-derive)

The canonical 0.2.0 entity is a plain Rust struct with six derives and two container attributes:

use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
    pub release_year: Option<i32>,
    pub rating: f64,
}

// Sea-ORM boilerplate that `DeriveEntityModel` relies on.
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

Key facts:

  • DeriveEntityModel is the Sea-ORM half. It owns the #[sea_orm(...)] namespace, generates the Entity, Column, PrimaryKey, Relation, and ActiveModel types, and is required on every FerraModel struct.
  • FerraModel is the Ferra half. It reads #[sea_orm(primary_key)] and #[sea_orm(table_name)], plus the #[ferra(...)] namespace, and emits a static ModelMeta that every downstream Ferra crate reads.
  • Serialize + Deserialize are required by the FerraModel supertrait bound. ferra-forge does not auto-inject them — see the next section.
  • Visibility must be at least pub(crate). Private structs trip class 8 of the Troubleshooting table.

Recognised field types:

Rust typeFieldType variantNotes
StringStringUTF-8 string.
i32I3232-bit signed integer.
i64I6464-bit signed integer.
f64F6464-bit IEEE-754 float.
boolBoolBoolean.
Uuid (or ferra_core::id::Id)UuidUUID, always present.
Option<String>OptionStringNullable string.
Option<i32>OptionI32Nullable 32-bit integer.
Option<i64>OptionI64Nullable 64-bit integer.
Option<f64>OptionF64Nullable float.
Option<bool>OptionBoolNullable boolean.
Option<Uuid> (or Option<Id>)OptionUuidNullable UUID — added in 0.6.0 Welding (FR-014/FR-024). Surfaces in OpenAPI as "type": ["string","null"] with "format": "uuid" (the OpenAPI 3.1 nullable form). Cannot be combined with #[sea_orm(primary_key)] — that contradiction emits FRG-215.

Explicit serde derives are required

ferra-forge does not add #[derive(Serialize, Deserialize)] for you. If you omit them, rustc fires E0277 with the span pinned to your struct’s name:

error[E0277]: the trait bound `Model: serde::Serialize` is not satisfied
  --> src/my_module.rs:7:12
   |
 7 | pub struct Model {
   |            ^^^^^ unsatisfied trait bound
   |
   = note: for local types consider adding `#[derive(serde::Serialize)]` to your `Model` type
note: required by a bound in `_assert_ferra_model_bounds`

The fix is always the same: add #[derive(Serialize, Deserialize)] to the derive list.

Why not auto-inject? A user with use serde::Serialize as S; #[derive(S)] would trip a naive name-based detector into silent double-derive and hit E0119 conflicting-impls. Delegating to the trait-bound solver is correct by construction.

Three attribute namespaces, two policies

ferra-forge reads three attribute namespaces with deliberately different handling:

NamespaceOwnerPurposeUnknown-key policy
#[sea_orm(...)]Sea-ORMPersistence shape (table name, primary key, column type)silently ignored by ferra-forge
#[ferra(...)]FerraExposure flags (read_only, write_only, skip, resource)hard compile_error!
#[field(...)]FerraDeclarative validation rules (min_length, email, …) — see § Validation ruleshard compile_error! (FRG-213)

The asymmetry is load-bearing. Consider the worked example:

// Security-critical typo. The user MEANT `write_only`.
#[ferra(writeonly)]
pub password_hash: String,

Because #[ferra(writeonly)] is not recognised, the build fails:

error: `#[ferra(writeonly)]` is not a recognized attribute
  --> src/models/user.rs:12:13
   |
12 |     #[ferra(writeonly)]
   |             ^^^^^^^^^
   = help: did you mean `write_only`?
   = note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase

If Ferra had accepted the typo, password_hash would quietly serialise into API responses — a Broken Object Property Level Authorization (OWASP API Top 10 #3). The hard-fail rule makes this class of bug unshippable.

Conversely, an unknown-to-Ferra but Sea-ORM-permitted key such as #[sea_orm(rename_all = "camelCase")] passes through Ferra silently — it is Sea-ORM’s concern, not Ferra’s, and pre-validating it inside ferra-forge would saddle Ferra with every Sea-ORM minor-release vocabulary change.

Recognised #[ferra(...)] keys

Container-level

KeyValueSemantics
resourcenon-empty string literalOverride the default resource_name in the emitted ModelMeta.

The default resource_name is the #[sea_orm(table_name)] literal if present, otherwise snake_case(<StructIdent>) with a simple English plural suffix (Film → films, UserProfile → user_profiles, Category → categories, Box → boxes).

Declarative behaviors (#[behavior(soft_delete)], #[behavior(timestampable)], and the cross-cutting cohort surface they share) are documented separately in behaviors.md. Enrolling a model in a behavior is a container-level act — declaring the behavior on the struct opts every operation on that model into the cohort’s contract.

Field-level

Flagreadablewritableskip_api
(none)truetruefalse
#[ferra(read_only)]truefalsefalse
#[ferra(write_only)]falsetruefalse
#[ferra(skip)]falsefalsetrue

#[ferra(read_only)] and #[ferra(write_only)] on the same field contradict — see class 10. #[ferra(write_only)] on a primary-key field contradicts the id’s always-readable invariant — see class 12. Repeated identical flags are idempotent.

#[ferra(write_only)] is structurally excluded from every read

#[ferra(write_only)] is the framework’s load-bearing primitive for fields that must be inbound-accepting but never outbound-leaking — canonically a password hash, an API-key plaintext, or any other write-side credential.

#[derive(DeriveEntityModel, FerraModel, Serialize, Deserialize, Clone, Debug, PartialEq)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub email: String,
    #[ferra(write_only)]
    pub password_hash: String,
}

What the framework guarantees:

  • POST /users and PUT /users/{id} accept password_hash in the request body, validate it, and persist it through the standard insert / update pipeline.
  • Every read response — single-item GET /users/{id}, collection GET /users, and the create / update response envelopes — omits password_hash. There is no header, query parameter, or Accept: variant that flips the field on.
  • The same exclusion applies to every named projection: the auto-derived {Model}{Projection}ReadProjection struct emitted by ferra-forge does not have the field as a member, so the type system itself blocks any future handler from re-introducing it.

Structural-by-construction, not runtime-filter

The guarantee is enforced at the Rust type-system layer, not by a runtime serialiser flag. The ferra-forge proc-macro emits one pub struct {Model}ReadProjection per model whose field list does not contain any #[ferra(write_only)] field; mutation and read handlers serialise through that struct’s allowlist before the JSON leaves the framework. A future custom handler that hand-writes a response cannot inadvertently leak password_hash because the field does not exist on the type the response carries.

Prior to 0.6.5 the exclusion lived only in the OpenAPI emitter and a runtime serialiser filter; the 0.6.5 promotion to a structural guarantee closes the OWASP API #3 mitigation gap.

There is no “opt-in” — FRG-305 rejects the obvious workaround

Listing a #[ferra(write_only)] field in any projection’s read = [...] is rejected at compile time with diagnostic FRG-305:

error: FRG-305: projection `admin` lists field `password_hash` in `read`,
       but the field is declared `#[ferra(write_only)]`
  = help: remove the ident from `read = [...]`, or remove `#[ferra(write_only)]`
          from the field declaration

If you genuinely need the field to appear in some read surface, the resolution is to drop #[ferra(write_only)] from the field declaration entirely. The field then appears in every read by default; use #[ferra(skip)] or a per-projection read = [...] allowlist to scope the surfaces that include it. See projections-and-routing.md for the projection key set and the auto-derived URL prefix contract.

The full FRG-3NN diagnostic family for write-only-related mistakes (FRG-302 through FRG-307, FRG-314) is documented in ferra-forge-diagnostics.md.

Computed fields

A computed field is server-derived: the framework runs the developer’s compute function after every fetch and before serialisation, then carries the produced value out through the read response. The field is excluded from INSERT / UPDATE SQL and from every write projection’s struct, so submitted values in request bodies are silently dropped — the only path to set the field is the compute hook the developer writes.

The substrate ships at 0.6.5 Chasing per ADR-0031 and the FerraComputed trait surface contract.

The #[ferra(computed)] attribute

Tag the field with #[ferra(computed)] and write an impl FerraComputed for {Model} block that overrides the method matching your computation kind:

use ferra::*;
use ferra::compute::{FerraComputed, RequestContext};
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Invoice {
    #[sea_orm(primary_key)]
    pub id: Id,
    pub subtotal: f64,
    pub tax: f64,
    #[ferra(computed)]
    pub total: f64,
}

impl FerraComputed for Invoice {
    fn compute(&mut self) {
        self.total = self.subtotal + self.tax;
    }
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

#[ferra(computed)] implies writable = false and read_only = true. The framework injects a ferra_core::diagnostics::FerraComputedDefault bound on the field’s type — see the Compile-time rejections sub-section below for the consequence when that bound fails.

The three tiers

FerraComputed exposes three default-implemented methods covering the spectrum from pure derivations to N+1-eliminating batched I/O. An implementer overrides only the method matching the computation kind.

  • fn compute(&mut self) — synchronous, zero I/O. The right choice for total = subtotal + tax and other in-row derivations. Cost shape: one function call per row, no async overhead. Default: no-op.
  • fn compute_async(&mut self, ctx: &RequestContext) — per-row async with a request-context handle. The right choice for per-row I/O (currency-rate lookup, external HTTP enrichment). Cost shape: one I/O round-trip per row — invoke this tier only when the computation genuinely cannot be batched. At 0.6.5 RequestContext is an empty marker (see compute_async_batch below for the pool-threading limitation); it widens in 0.7.x. Default: returns Ok(()).
  • fn compute_async_batch(rows: &mut [Self], ctx: &RequestContext) — collection-level batch hook. Called once per read with the full slice. The default implementation iterates compute_async sequentially over every row; override this method to perform a single batched I/O call across the slice. This is the N+1 elimination path.

Read pipeline

For every read (item or collection) of a model with at least one #[ferra(computed)] field, the framework runs the pipeline in this order:

  1. The repository fetches the row(s) from the data source.
  2. The framework calls compute(&mut self) on each row in sequence.
  3. The framework calls Self::compute_async_batch(&mut rows, ctx).await? once with the full slice.
  4. The framework serialises each row through the projection’s read struct.

Uniform-invocation guarantee. Item reads (GET /invoices/{id}) pass a one-element slice to compute_async_batch. The developer writes one batch implementation; it covers both the item and collection surfaces without branching.

N+1 elimination pattern

The batch hook is where you fold a per-row I/O into a single round-trip. The canonical shape is a WHERE id = ANY($1) query keyed on the slice’s identifiers:

use ferra::*;
use ferra::compute::{FerraComputed, FerraComputeError, RequestContext};
use std::collections::HashMap;

impl FerraComputed for FilmWithCommentCount {
    fn compute(&mut self) {
        // No synchronous derivation; the batch hook supplies the value.
    }

    async fn compute_async_batch(
        rows: &mut [Self],
        _ctx: &RequestContext,
    ) -> Result<(), FerraComputeError> {
        // Collect every row's id, then fetch the matching counts in
        // a single SQL round-trip.
        let ids: Vec<Id> = rows.iter().map(|r| r.id).collect();

        // At 0.6.5 the pool is threaded through application state; at
        // 0.7.x it will live on `RequestContext`. Replace `app_pool()`
        // with the pool handle of your choice.
        let counts: HashMap<Id, i64> = sqlx::query_as::<_, (Id, i64)>(
            "SELECT film_id, COUNT(*) FROM comments \
             WHERE film_id = ANY($1) GROUP BY film_id",
        )
        .bind(&ids[..])
        .fetch_all(app_pool())
        .await
        .map_err(|e| FerraComputeError::new(e.to_string()))?
        .into_iter()
        .collect();

        for row in rows {
            row.comment_count = counts.get(&row.id).copied().unwrap_or(0);
        }
        Ok(())
    }
}

0.6.5 limitation — explicit. RequestContext is an empty marker at this release. Until 0.7.x widens it, thread the database pool through your application state (a tokio::sync::OnceCell<PgPool>, a Arc<AppState> in your handlers, or the pattern of your choice) and reach it from inside the batch hook. The contract for the trait method does NOT change when RequestContext widens — the parameter shape is stable; only the values reachable through it grow.

Forward-pointer to the post-v1.0 DataLoader pattern. The compute_async_batch contract is forward-compatible with a future request-scoped loader registry on RequestContext: a compute_async implementation that calls ctx.loader::<L>().load(key).await will coalesce all loads in the request into one batch_load(keys) call. Override-based batch hooks and loader-based per-row hooks compose without conflict.

What the framework does for you

Tagging a field #[ferra(computed)] is one declaration with effects on both ends of the wire:

SideEffect
InboundThe field is silently dropped from request bodies. POST /invoices and PUT /invoices/{id} accept subtotal + tax and ignore any client-supplied total.
SQLINSERT and UPDATE statements skip the column. The database does not have to carry it — and even if your schema persists the column, the framework will not write to it; reads ignore the persisted value and replace it with the compute output.
OutboundThe OpenAPI document marks the field readOnly: true on every read schema. SDK generators that consume the spec (orval, openapi-generator, kiota) surface the field as read-only on the client side.
Read responseThe response carries the value produced by the compute hook — compute() runs first, then compute_async_batch runs once across the slice, then serialisation walks the read projection.

Missing-impl behaviour (FR-015). A model that declares #[ferra(computed)] on a field but does not provide an impl FerraComputed for Model { ... } block — or that provides an empty impl FerraComputed for Model {} block adopting every default — is not a compile error. The framework substitutes the trait’s default methods (no-op compute, Ok(()) async hooks), and the field is serialised with its type’s Default::default() value. The framework never panics on this path.

Compile-time rejections

The 0.6.5 FRG-3NN diagnostic family covers the contradictions; the full catalogue lives in ferra-forge-diagnostics.md. The codes that matter for computed fields:

CodeTriggerFix
FRG-303#[ferra(write_only)] + #[ferra(computed)] on the same field. A computed value is server-produced — it cannot also be inbound-only.Drop one of the two flags. Keep #[ferra(computed)] if the value is derived; keep #[ferra(write_only)] if the value is client-supplied and write-only.
FRG-309#[ferra(computed)] + explicit #[ferra(read_only)] on the same field. computed already implies read_only; the second flag is redundant.Remove the redundant #[ferra(read_only)].

The FerraComputedDefault #[diagnostic::on_unimplemented] triple. The framework injects a FerraComputedDefault bound on every #[ferra(computed)] field’s type. When the chosen type does not implement Default, rustc fails the bound and surfaces the framework-authored diagnostic verbatim:

error[E0277]: `MyType` is used as the type of a `#[ferra(computed)]` field, but it does not implement `Default`
  --> src/models/invoice.rs:8:5
   |
 8 |     pub total: MyType,
   |     ^^^^^^^^^^^^^^^^^ this type needs `impl Default for MyType`
   |
   = note: Ferra calls `T::default()` when a computed field's compute trait is not implemented (FR-015 — missing implementations are a no-op, not a panic). The `Default` bound is required by the computed-field contract; see `docs/user-guide/ferra-forge.md#computed-fields`. Diagnostic emitted by `ferra_core::diagnostics::FerraComputedDefault`.

Every type in the recognised-field-types table above already implements Default (numbers default to zero, String to "", Option<T> to None, Uuid to nil, Id to a fresh v4 UUID). The diagnostic fires only on custom field types where you’ve forgotten the #[derive(Default)] line.

Deferred keys and when they land

The following keys are rejected in 0.2.0 (hard compile_error!) and will arrive in a later roadmap phase. If you reach for any of them, you get an unknown-key diagnostic — that is intentional, so you don’t silently compile code that looks correct but does nothing.

KeyPlanned phaseIntended use
hypermedia0.3.0 CastingControl HAL _links emission per field.
projection / projections0.4.0 RefiningTyped projections (ServerFields<M>).
exposure0.4.0 RefiningExposure::Strict / Exposure::Loose.
sortable / filterable / searchable0.4.0+Query DSL primitives.
cascade0.6.0+Relation cascade rules.
auth0.8.5Per-model auth scopes.
rate_limit0.8.5+Tower rate-limit layer binding.
requiredNever recognised — nullability is deduced from Option<T>.

Validation rules

Ferra’s third attribute namespace, #[field(...)], declares validation rules that fire at the API edge — before persistence, on every POST (create) and PUT (update) request. A payload that violates any rule is rejected with a 422 Problem+JSON body whose errors map names every offending field; a payload that satisfies every rule flows on to persistence unchanged. Validation is opt-in: a model carrying no #[field(...)] attributes behaves identically to one written before the namespace existed.

The full 422 wire shape and consumer-side branching pattern live in Error Handling § Validation failures (422); this section pins the model-side declaration grammar.

The eight rules at a glance

RuleCompatible field typesDefault English message on violation
min_length = NString, Option<String>"must be at least N character(s)" (singular when N == 1)
max_length = NString, Option<String>"must be at most N character(s)" (singular when N == 1)
min = Ni32, i64, f64, and their Option<…>"must be at least N"
max = Ni32, i64, f64, and their Option<…>"must be at most N"
pattern = "regex"String, Option<String>"does not match the required format"
emailString, Option<String>"is not a valid email address"
urlString, Option<String>"is not a valid URL"
requiredOption<T> only"is required"

Any other rule key is a compile-time error (FRG-213) carrying a “did you mean” hint when the typo is within Levenshtein distance 2 of a recognised name. Repeated rules of the same kind on the same field are FRG-206. Conflicts (min_length > max_length, min > max) are FRG-210 / FRG-211. A rule applied to an incompatible field type is FRG-207 / FRG-208 / FRG-209 / FRG-212 depending on the rule. A wrong-shape literal (a string where an integer is expected, an empty regex) is FRG-201 through FRG-205. Every code surfaces with a span anchored on the offending token, and multiple independent mistakes on the same struct surface in a single cargo check round-trip rather than the first-and-stop pattern.

Combined-rule example (the most common shape)

The headline call site is a single #[field(...)] carrying multiple rules separated by commas, on a struct that already derives FerraModel and Sea-ORM’s DeriveEntityModel:

use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,

    #[field(min_length = 1, max_length = 255)]
    pub display_name: String,

    #[field(min = 0, max = 120)]
    pub age: i32,

    #[field(email)]
    pub contact_email: String,

    #[field(pattern = r"^[a-z0-9-]+$")]
    pub slug: String,

    #[field(url)]
    pub homepage: String,

    #[field(required)]
    pub bio: Option<String>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

Two or more #[field(...)] attributes on the same field accumulate in source order — #[field(min_length = 1)] #[field(max_length = 255)] is identical to #[field(min_length = 1, max_length = 255)]. Two #[field(...)] attributes on two different fields are independent; each field’s rule list is evaluated in isolation against that field’s value, and every violation observed on the request is aggregated into a single 422 response (Ferra never short-circuits on the first failed rule).

Per-rule details

min_length = N and max_length = N

Fires on String and Option<String> fields. The literal N is a non-negative integer that fits in u32. On Option<String> the rule applies only when the value is Some(..) — a None is not a length violation (use required to forbid None outright).

use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 1, max_length = 255)]
    pub title: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

A POST /films with {"title": ""} returns 422 with errors.title = ["must be at least 1 character"] (singular — length rules pluralise only when N != 1). A 300-char title returns 422 with errors.title = ["must be at most 255 characters"].

Length rules count Unicode scalar values, not grapheme clusters or UTF-8 byte length — see § Unicode counting below.

min = N and max = N

Fires on i32, i64, f64, and their Option<…> forms. The literal type must match the field’s declared numeric type: min = 1.5 on an i32 field is FRG-203. Integer literals on f64 fields are accepted (and coerce to 0.0-style values).

use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ratings")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min = 0, max = 10)]
    pub score: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

A POST /ratings with {"score": 99} returns 422 with errors.score = ["must be at most 10"].

pattern = "regex"

Fires on String and Option<String> fields. The literal is a non-empty string carrying a regex-crate pattern (NOT PCRE). The regex crate has no lookaround and no backreferences — the syntactic features that drive catastrophic backtracking in PCRE-style engines are not even expressible. A raw-string literal (r"…") is the recommended shape so backslashes are not double-escaped.

use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(pattern = r"^[a-z0-9-]+$")]
    pub slug: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

A POST /posts with {"slug": "Bad Slug!"} returns 422 with errors.slug = ["does not match the required format"].

Bounded-time guarantee. Every pattern evaluation completes in time linear to the input length — O(m·n) worst-case, where m is the (compile-time-fixed) pattern size and n is the input length, per the regex crate’s upstream contract. Combined with Ferra’s default 1 MiB request-body cap, this forecloses ReDoS without a per-request timeout. You can write any regex-syntax pattern you like and submit it to user input; no pattern, regardless of shape, can cause unbounded matching time.

email

Bare flag on String and Option<String> fields — no value. Delegates to the upstream HTML5-compliant email validator. #[field(email = "x")] is FRG-214.

use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "contacts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(email)]
    pub email: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

A POST /contacts with {"email": "not-an-email"} returns 422 with errors.email = ["is not a valid email address"].

url

Bare flag on String and Option<String> fields — no value. Delegates to the upstream url-crate-backed validator. #[field(url = "x")] is FRG-214.

use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "links")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(url)]
    pub href: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

A POST /links with {"href": "not a url"} returns 422 with errors.href = ["is not a valid URL"].

required

Bare flag on Option<T> fields only — no value. #[field(required)] on a non-Option field is FRG-212 (non-Option fields are required by Rust’s type system; the flag is meaningless there). On a POST request, an absent field or an explicit null both fail with "is required". On a PUT request, an absent field is “no change” (Ferra’s existing partial-update semantics) and skips per-field rules, but an explicit null fails with "is required"null on the wire is a positive declaration of “set to none”, which the rule rejects.

use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "profiles")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(required)]
    pub bio: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

A POST /profiles with {"bio": null} (or {}) returns 422 with errors.bio = ["is required"].

Propagation to the OpenAPI document

Every #[field(...)] rule you declare propagates into the emitted OpenAPI 3.1 document under the canonical JSON Schema keyword for the rule. SDK generators that consume the spec (orval, openapi-generator, kiota, …) and UI tooling that drives form validation off the spec (react-jsonschema-form, JSON-Forms, …) receive the same rules the server enforces — no parallel maintenance, no spec/server drift.

#[field(...)] ruleOpenAPI keywordWhere it lands
min_length = NminLength: Nproperties.<field>
max_length = NmaxLength: Nproperties.<field>
min = Nminimum: Nproperties.<field>
max = Nmaximum: Nproperties.<field>
pattern = "<regex>"pattern: "<regex>"properties.<field>
emailformat: "email"properties.<field>
urlformat: "uri"properties.<field>
required(field name added to schema’s required: [...])<schema>.required

Per-projection coverage. Keywords appear on every projection in which the field appears: the read projection (Film), the create-input projection (CreateFilmInput), and the update-input projection (UpdateFilmInput). A field excluded from one projection (for example, a #[ferra(read_only)] field is excluded from the create-input projection because it is not writable) does NOT appear in that projection’s properties map — but its keywords continue to appear on the read projection because the rules describe the persisted state, not only the input shape.

Note on urlformat: uri. JSON Schema’s vocabulary uses uri, not url — the rule name on the Rust side stays as the friendlier url, but the emitted spec uses the JSON Schema canonical name. SDK generators look up format: uri.

Combined rules merge into one property schema. A field carrying #[field(min_length = 1, max_length = 255, pattern = "^[A-Z][a-z]+$")] produces:

"title": {
  "type": "string",
  "minLength": 1,
  "maxLength": 255,
  "pattern": "^[A-Z][a-z]+$"
}

The keyword order in the JSON output is not contractual; the keyword set is.

Required behaviour. The #[field(required)] rule on an Option<T> field adds the field name to the create-input and update-input projections’ required: [...] arrays — it does NOT inject a "required": true keyword on the per-property schema (JSON Schema does not have one; required-ness is a parent-schema concern). The same array also includes every non-Option field, matching Rust’s “non-Option means required” semantics on the wire.

No-rules opt-in. A model that declares zero #[field(...)] rules emits a spec byte-identical to its 0.5.x equivalent. Validation surfaces only when you opt in.

Default messages and localisation

Every rule emits a hard-coded English message as its violation text — the literal strings shown in the table above. Length rules pluralise “character” only when N != 1: min_length = 1 produces "must be at least 1 character" (singular) while min_length = 5 produces "must be at least 5 characters" (plural). Ferra 0.6.0 does not support per-field, per-rule message overrides or Accept-Language-driven locale negotiation; both are deferred to a later release.

If you need localised error text on the consumer side, branch on the field name in the response’s errors map and rewrite the message client-side. The default English text is a stable contract within the 0.6.0 line — string-matching against "must be at least", "is required", etc. is supported until the localisation work lands. SDK consumers that have to rewrite the body server-side can do so in a Tower middleware layer applied after the framework’s IntoResponse runs.

Unicode counting

Length rules count characters as the underlying validation crate counts them: chars().count() on the underlying &str, which counts Unicode scalar values. The framework does not interpose a custom counting policy in 0.6.0 — there is no NFC normalisation pass, no grapheme-cluster counting, no UTF-16 code-unit counting.

The practical consequence: a 10-emoji string may not have a length of 10 by your reader’s intuition. The grinning-face emoji 😀 is one scalar value (one chars() count), but some emoji sequences (skin-tone modifiers, ZWJ-joined family emoji) are several scalar values — a single visible glyph may count as 2, 4, or more. If your length rule needs to count “what the user sees”, reach for the typed-escape path below and write a hand-rolled rule that walks unicode-segmentation graphemes.

When the eight rules are not enough — typed escape via ferra::garde

The eight-rule namespace is a closed contract surface for 0.6.0. For rule shapes the surface does not cover — a custom function predicate, a locale-aware rule, a regex syntax beyond the regex crate, a grapheme-cluster-aware length count — Ferra re-exports the underlying validation crate at ferra::garde so you can hand-roll a garde::Validate impl on your model:

use ferra::garde::{self, Validate};

impl Validate for MyModel {
    type Context = ();

    fn validate_into(
        &self,
        _ctx: &Self::Context,
        _parent: &mut dyn FnMut() -> garde::Path,
        report: &mut garde::error::Report,
    ) {
        // your hand-rolled checks here, appending to `report`
    }
}

The escape is the documented fallback — neither encouraged nor forbidden, used when the closed set is insufficient. It is not part of the v1 #[field(...)] contract surface; the rule shape and the trait surface follow the upstream garde crate’s evolution, not Ferra’s. Use it sparingly: every model that hand-rolls validation is a model the #[field(...)] diagnostics no longer protect.

Hand-rolling a Validate impl on a model that ALSO carries #[field(...)] rules is a duplicate-impl error at compile time. The two paths are exclusive: either the framework generates the impl from your declarative rules, or you write it yourself.

Composite primary keys

A model can carry two or more #[sea_orm(primary_key)] markers. Ferra picks them up in declaration order and threads the resulting multi-segment shape through the route, the Location header, the _links.self.href URL, the OpenAPI per-item parameters list, and the find_by_id(...) lookup signature — without consumer-side glue.

A worked example with (tenant_id: Uuid, document_id: i64):

use ferra::*;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(
    Clone, Debug, PartialEq,
    DeriveEntityModel, FerraModel,
    Serialize, Deserialize,
)]
#[sea_orm(table_name = "documents")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub tenant_id: Uuid,
    #[sea_orm(primary_key, auto_increment = false)]
    pub document_id: i64,
    pub title: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

#[sea_orm(auto_increment = false)] is required on every PK field in a composite key — PostgreSQL SERIAL only synthesises a value for one column per table, so the application supplies an explicit value on every insert.

The framework derives:

SurfaceSingle-PK shapeComposite-PK shape (2 keys)
Per-item routeGET /films/{id}GET /documents/{tenant_id}/{document_id}
Location header on POST/films/{id}/documents/{tenant_id}/{document_id}
_links.self.href/films/{id}/documents/{tenant_id}/{document_id}
OpenAPI parameters count1 entry2 entries (in declaration order)
repo.find_by_id(...) argumentid (scalar)(tenant_id, document_id) (tuple)

The repository call site is a typed tuple lookup:

let row = repo.find_by_id((tenant_id, document_id)).await?;

The tuple element order matches the #[sea_orm(primary_key)] declaration order in the struct — Sea-ORM resolves PrimaryKeyTrait::ValueType to (Uuid, i64) for the Document entity above. Reordering the markers in the source struct is therefore a wire-breaking change for any client URL-templating against _links.self.href or against the OpenAPI path template.

The _links.self.href shape on a fetched row:

{
  "_links": {
    "self":       { "href": "https://api.example.com/documents/<tenant>/<doc>" },
    "collection": { "href": "https://api.example.com/documents" }
  }
}

Ferra supports any N >= 1. A three-key composite ((tenant_id, year, sequence)) emits GET /issued_documents/{tenant_id}/{year}/{sequence} with three OpenAPI parameters. The framework does not impose an upper bound; in practice 2-4 keys cover the working set (multi-tenant isolation, time-bucketed sequencing, partition keys). The N=0 case continues to fail at compile time with the existing “this model has no primary key” diagnostic — composite primary keys are a widening of the legacy single-PK rule, not a relaxation.

The FerraRepository<M> surface for composite-PK models is documented in ferra-db.md § The FerraRepository<M> surface.

Why DeriveEntityModel is required

Skipping the DeriveEntityModel sibling is a class-16 compile error:

error: FerraModel requires DeriveEntityModel on the same struct
         help: add DeriveEntityModel to the `#[derive(...)]` list
         note: see docs/user-guide/ferra-forge.md § Canonical entity shape — sibling-derive requirement
  --> src/models/film.rs:3:12
   |
 3 | pub struct Film {
   |            ^^^^

The requirement is not decorative. Phase 0.3.0 Casting wires the Entity, Column, PrimaryKey, and ActiveModel types that DeriveEntityModel generates into the router and SQL query builder. A FerraModel-only struct would compile locally but collapse the moment ferra-http or ferra-db touches it. The rule fails loud now so you cannot paint yourself into that corner.

Troubleshooting

Every error class 0.2.0 emits, with the exact message shape and the fix. Numbering matches data-model.md § 4 of the Smelting spec.

About the rendered shape. Every Ferra-authored diagnostic in this section carries three logical lines — an error: line, a help: line, and a note: line — because ferra-forge builds them as a single syn::Error on stable Rust. The help and note appear as indented continuation lines of the error body rather than as separate = help: / = note: sub-lines (that shape requires proc_macro::Diagnostic, which is nightly-only as of the 2026-04 toolchain). Text content is identical; grep-based CI checks and visual legibility both work.

Class 1 — no primary key

error: this model has no primary key
  help: annotate exactly one field `#[sea_orm(primary_key)]`
  note: see docs/user-guide/ferra-forge.md § Troubleshooting — missing primary key

Fix. Add #[sea_orm(primary_key)] to the id field. In 0.2.0 Ferra supports one-field primary keys only.

Class 2 — multiple primary keys

error: composite primary keys are not supported in Ferra 0.2.0
  help: keep `#[sea_orm(primary_key)]` on exactly one field

Fix. Consolidate into a single id column. Composite keys are a later-phase feature.

Class 3 — unsupported field type

error: type `<T>` is not supported by Ferra in 0.2.0
  help: use one of `String`, `i32`, `i64`, `f64`, `bool`, `Uuid`, or `Option<T>` for the first five

Fix. Model with one of the recognised types, or wait for the phase that adds your type (Decimal, Timestamp, relations are on the roadmap).

Class 4 — historical (Option<Uuid> rejection — retired in 0.6.0)

Option<Uuid> and Option<Id> are now first-class field types admitted as FieldType::OptionUuid (0.6.0 Welding User Story 4 — FR-014). The diagnostic that previously fired here (“Option<Uuid> is not supported in Ferra 0.2.0”) no longer exists. The class number is preserved for historical reference; the contradictory case Option<Uuid> tagged #[sea_orm(primary_key)] is now rejected with FRG-215 instead — shape and fix below.

error: FRG-215: primary key cannot be optional
   = help: primary key fields must always have a value; remove `Option<...>` from the field type or remove `#[sea_orm(primary_key)]` from the field tag
   = note: an inserted `None` would be indistinguishable from a row whose key was never assigned, so the round-trip equality semantics a PK guarantees cannot hold; see Spec §Edge Cases "Optional UUID field tagged as primary key"
  --> src/film.rs:6:9
   |
 6 |     pub id: Option<Uuid>,
   |         ^^

Fix. Either drop the Option<...> from the field type (so the PK is always present), or remove #[sea_orm(primary_key)] from the tag (so the field becomes a nullable non-key UUID).

The full FRG-2NN matrix and worked examples for every code introduced in 0.6.0 Welding live in Compile-time diagnostics. This page keeps the FRG-215 details inline because the Troubleshooting section needs to stand alone for consumers reading ferra-forge.md in isolation.

Class 5 — not a struct with named fields

error: FerraModel must be derived on a struct with named fields (not tuple structs, unit structs, enums, or unions)

Fix. Convert to pub struct Name { ... } with named fields.

Class 6 — generic struct

error: FerraModel does not currently support generic models
  help: supply a concrete type — remove the generic parameter

Class 7 — lifetime-parameterised struct

error: FerraModel does not currently support lifetime-parameterised models
  help: Ferra models are owned values — remove the lifetime parameter

Class 8 — visibility below pub(crate)

error: FerraModel requires at least `pub(crate)` visibility
  help: widen the struct's visibility — e.g., `pub struct ...` or `pub(crate) struct ...`

Class 9a — unknown #[ferra(...)] key (no nearest match)

error: `#[ferra(<key>)]` is not a recognized attribute
  note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase

Fix. Consult the Recognised keys table above, or the Deferred keys table if you were trying to use a future-phase key.

Class 9b — unknown #[ferra(...)] key with a near-miss hint (release-blocker family)

error: `#[ferra(writeonly)]` is not a recognized attribute
  help: did you mean `write_only`?
  note: see docs/user-guide/ferra-forge.md for the full list of recognized keys in this phase

Fix. The help line quotes the intended spelling. The canonical case is #[ferra(writeonly)] on password_hash — a write-only bypass that would expose sensitive data. The trybuild fixture for this diagnostic is marked a release blocker per SC-008.

Class 10 — read_only + write_only contradiction

error: `#[ferra(read_only)]` and `#[ferra(write_only)]` contradict
  help: keep at most one of the two flags

Class 11 — #[ferra(required)] on Option<T>

error: `#[ferra(required)]` contradicts `Option<T>`
  help: remove the attribute or change the field type

Fix. Nullability is deduced from the type, not from an attribute. Remove #[ferra(required)] or change the field to a non-Option<T> type.

Class 12 — #[ferra(write_only)] on the primary key

error: primary keys cannot be write-only
  help: remove `#[ferra(write_only)]` from the id field

Class 13 — empty #[ferra(resource = "")]

error: `#[ferra(resource)]` must be a non-empty string
  help: remove the attribute to fall back to the default

Class 14 — non-string-literal #[ferra(resource = ...)]

error: `#[ferra(resource)]` value must be a string literal
  help: e.g., `#[ferra(resource = "films")]`

Class 15 — missing serde derives

Delivered by rustc E0277 on the emitted _assert_ferra_model_bounds trait-bound assertion (see § Explicit serde derives are required above). Span is pinned to your struct’s identifier.

Class 16 — missing DeriveEntityModel sibling

error: FerraModel requires DeriveEntityModel on the same struct
  help: add DeriveEntityModel to the `#[derive(...)]` list
  note: see docs/user-guide/ferra-forge.md § Canonical entity shape — sibling-derive requirement

Fix. Add DeriveEntityModel to the #[derive(...)] list. See the Canonical entity shape example above.

Degraded diagnostic under one specific shape. The Ferra-authored class-16 message above fires when the struct has zero #[sea_orm(...)] attributes (neither at container level nor on fields). If you forget DeriveEntityModel but do keep a #[sea_orm(primary_key)] on a field, rustc errors earlier than ferra-forge runs with cannot find attribute sea_orm in this scope — the sea_orm helper is unregistered without its owning derive. As a final safety net for any remaining edge case, the emitted code contains a `_assert_derives_entity_model<T:
:sea_orm::ModelTrait>()bound assertion; a failure there surfaces aserror[E0277]: the trait bound <YourModel>: sea_orm::ModelTrait is not satisfied. All three paths reduce to the same fix: add DeriveEntityModelto the#[derive(…)]` list.

Class 17 (pass case) — unknown #[sea_orm(...)] key

An unknown-to-Ferra but Sea-ORM-permitted key such as #[sea_orm(column_name = "...")] compiles without intervention from Ferra. This is the asymmetry documented in § Two attribute namespaces, two policies. If Sea-ORM diagnoses the key itself, that diagnostic surfaces — Ferra does not layer its own.

Compile-time diagnostics — the FRG-NNN namespace

Status: 0.6.0 Welding (validation-attribute codes, FRG-201 through FRG-215) + 0.6.5 Chasing (projection / computed / write-only codes, FRG-301 through FRG-316, with FRG-313 intentionally unallocated).

Reader contract. This page stands on its own. A reader with only this page plus standard Rust and Sea-ORM knowledge can identify the trigger condition for any FRG-NNN error in the table below and fix the offending source on the first attempt. Cross-references to ADRs and the constitution are non-load-bearing — every correctness cue is spelled out in the per-code section.

#[derive(FerraModel)] and the #[field(...)] attribute parser emit typed FRG-NNN codes on every diagnostic. Each code is grep-friendly, span-anchored on the offending source token, and documented below with a worked-bad → worked-good example pair plus a one-sentence fix template.

The codes are emitted via proc-macro-error2’s emit_with_triple / abort_with_triple helpers — multiple independent mistakes on the same struct surface in one cargo check round-trip rather than the first-and-stop pattern. The emitted shape carries three logical lines (error: + help: + note:) anchored to the offending token via quote_spanned!; the note: line in every code below points back to the ferra-forge.md section that owns the rule’s call-site documentation.

Namespace structure

FRG-000 .. FRG-099   derive-shape errors        (existing — class 1..16)
FRG-100 .. FRG-199   #[authorize] errors        (post-0.8.0 — reserved)
FRG-200 .. FRG-299   validation-attribute       (0.6.0 Welding — FRG-201..FRG-215)
FRG-300 .. FRG-399   projection / computed /    (0.6.5 Chasing — FRG-301..FRG-316,
                     write-only attribute        FRG-313 intentionally unallocated)
FRG-400 ..           later phases               (reserved)

Code numbers are never recycled. A code retired because the class becomes unreachable remains documented as historical (see the FRG-215 note on Class 4 retirement); future codes append to the namespace.

Code matrix

CodeTrigger (one-line)Section
FRG-201#[field(min_length = …)] value is not a non-negative integer literallink
FRG-202#[field(max_length = …)] value is not a non-negative integer literallink
FRG-203#[field(min = …)] value type does not match the field’s numeric typelink
FRG-204#[field(max = …)] value type does not match the field’s numeric typelink
FRG-205#[field(pattern = …)] value is not a non-empty string literallink
FRG-206The same #[field(...)] rule key appears twice on the same fieldlink
FRG-207min_length / max_length rule applied to a non-string fieldlink
FRG-208min / max rule applied to a non-numeric fieldlink
FRG-209pattern / email / url rule applied to a non-string fieldlink
FRG-210min_length exceeds max_length on the same fieldlink
FRG-211min exceeds max on the same fieldlink
FRG-212#[field(required)] applied to a non-Option<T> fieldlink
FRG-213Unknown #[field(<key>)] key (with did you mean hint when distance ≤ 2)link
FRG-214#[field(<flag>)] bare flag given a value (e.g. email = "x")link
FRG-215Option<Uuid> (or Option<Id>) tagged #[sea_orm(primary_key)]link
FRG-301A projection’s read = [...] or write = [...] ident does not match any struct fieldlink
FRG-302#[ferra(write_only)] and #[ferra(read_only)] on the same fieldlink
FRG-303#[ferra(write_only)] and #[ferra(computed)] on the same fieldlink
FRG-304#[ferra(write_only)] on a field that carries #[sea_orm(primary_key)]link
FRG-305A #[ferra(write_only)] field is listed in a projection’s read = [...]link
FRG-306#[ferra(skip)] combined with a non-empty #[ferra(groups = […])] on the same fieldlink
FRG-307Legacy #[field(write_only)] form (predates the #[ferra(...)] namespace)link
FRG-308#[ferra(writeonly)] typo (missing underscore)link
FRG-309#[ferra(computed)] #[ferra(read_only)] on the same field (redundant)link
FRG-310Two projections produce the same URL prefix (duplicate path_prefix or two default = true)link
FRG-311A projection name does not normalise to a URL-safe segment, and no path_prefix overridelink
FRG-312A projection’s auto-derived URL prefix collides with an existing resource route (runtime)link
FRG-313intentionally unallocated — see § FRG-3NN
FRG-314An explicit write = [...] list omits a #[ferra(write_only)] field on the modellink
FRG-315path_prefix declared on a sub-resource (runtime)link
FRG-316default = true without promotes_from + breaking_change_version attestationslink

How to read each entry

Every section below carries:

  • Trigger. A one-sentence description of when the error fires plus the offending #[field(...)] attribute pattern.
  • Bad. A 5-15-line worked example showing the mistake. The block compiles in isolation against the canonical entity shape; just paste it into a sandbox crate to reproduce the diagnostic.
  • Good. A 5-15-line worked example showing the fix. Replaces the bad block one-for-one — same imports, same struct shape, only the offending attribute (or field type) changes.
  • Fix. A one- or two-sentence prescription naming what to change and where.
  • Fixture. A repo-relative link to the corresponding compile-fail fixture under crates/ferra-forge/tests/diagnostics/. The fixture is the byte-shape contract — a CI-checked .stderr snapshot proves the diagnostic shape stays stable across releases.

Every code uses the same import preamble. Show it once here so the per-code blocks stay short:

use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

The Sea-ORM boilerplate (Relation enum + ActiveModelBehavior impl) below the struct is identical in every example and is suppressed in the per-code blocks for brevity. Add it verbatim from the canonical entity shape when reproducing locally.


FRG-201 — min_length value is not a non-negative integer literal

Trigger. A #[field(min_length = …)] attribute whose value is anything other than a non-negative integer literal — a string, a negative integer, a float, an identifier. The min_length rule needs a u32-sized count.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = "abc")]
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 1)]
    pub title: String,
}

Fix. Replace the value with a non-negative integer literal (e.g. 1, 10, 255).

Fixture. crates/ferra-forge/tests/diagnostics/frg_201_min_length_invalid.rs


FRG-202 — max_length value is not a non-negative integer literal

Trigger. A #[field(max_length = …)] attribute whose value is anything other than a non-negative integer literal. Same shape as FRG-201, on the upper bound.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(max_length = "abc")]
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(max_length = 255)]
    pub title: String,
}

Fix. Replace the value with a non-negative integer literal (e.g. 1, 10, 255).

Fixture. crates/ferra-forge/tests/diagnostics/frg_202_max_length_invalid.rs


FRG-203 — min value type does not match the field’s numeric type

Trigger. A #[field(min = …)] attribute whose literal type does not match the field’s declared numeric type — a float on an i32 field, an integer where a f64 is expected when the literal is fractional, etc. The literal type must match: min = 1.5 on an i32 field is rejected; min = 1 on a f64 field is accepted (and coerces).

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min = 1.5)]
    pub rating: i32,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min = 1)]
    pub rating: i32,
}

Fix. Use a literal whose Rust type matches the field’s declared numeric type (i32, i64, or f64). Or change the field’s type to match the literal.

Fixture. crates/ferra-forge/tests/diagnostics/frg_203_min_type_mismatch.rs


FRG-204 — max value type does not match the field’s numeric type

Trigger. A #[field(max = …)] attribute whose literal type does not match the field’s declared numeric type. Same shape as FRG-203, on the upper bound.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(max = 2.5)]
    pub rating: i32,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(max = 10)]
    pub rating: i32,
}

Fix. Use a literal whose Rust type matches the field’s declared numeric type. Or change the field’s type to match the literal.

Fixture. crates/ferra-forge/tests/diagnostics/frg_204_max_type_mismatch.rs


FRG-205 — pattern value is not a non-empty string literal

Trigger. A #[field(pattern = …)] attribute whose value is not a non-empty string literal — pattern = "" (empty), pattern = 42 (non-string), pattern = some_const (non-literal). The literal must be a non-empty regex-crate-syntax pattern.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(pattern = "")]
    pub slug: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "posts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(pattern = r"^[a-z0-9-]+$")]
    pub slug: String,
}

Fix. Provide a non-empty regex literal. A raw-string literal (r"…") is recommended so backslashes are not double-escaped.

Fixture. crates/ferra-forge/tests/diagnostics/frg_205_empty_pattern.rs


FRG-206 — duplicate rule on the same field

Trigger. The same #[field(...)] rule kind appears twice on the same field — #[field(min_length = 5, min_length = 10)], or two #[field(...)] attributes each carrying a min_length. Repeated rules of the same kind are ambiguous; keep at most one per kind per field.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 5, min_length = 10)]
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 10)]
    pub title: String,
}

Fix. Remove the redundant entry. If you intended two different kinds of bound (a length AND a numeric range), the kinds must differ — #[field(min_length = 5, max_length = 10)] is fine; two min_length entries are not.

Fixture. crates/ferra-forge/tests/diagnostics/frg_206_duplicate_rule.rs


FRG-207 — length rule on a non-string field

Trigger. A #[field(min_length = …)] or #[field(max_length = …)] attribute on a field whose Rust type is not String or Option<String>. Length rules count Unicode scalar values on a string body — they are not defined on numeric or other field types.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 1)]
    pub rating: i32,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min = 1)]
    pub rating: i32,
}

Fix. Apply length rules to a String or Option<String> field. For numeric bounds on a numeric field, use #[field(min = …)] / #[field(max = …)] instead.

Fixture. crates/ferra-forge/tests/diagnostics/frg_207_length_on_non_string.rs


FRG-208 — numeric rule on a non-numeric field

Trigger. A #[field(min = …)] or #[field(max = …)] attribute on a field whose Rust type is not i32, i64, f64 (or their Option<…> form). Numeric range rules are not defined on string, boolean, or UUID field types.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min = 1)]
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 1)]
    pub title: String,
}

Fix. Apply numeric range rules to an i32, i64, or f64 field (or their Option<…> form). For string-length bounds on a string field, use #[field(min_length = …)] / #[field(max_length = …)] instead.

Fixture. crates/ferra-forge/tests/diagnostics/frg_208_range_on_non_numeric.rs


FRG-209 — string rule on a non-string field

Trigger. A #[field(pattern = …)], #[field(email)], or #[field(url)] attribute on a field whose Rust type is not String or Option<String>. These rules need a string body to evaluate against.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(email)]
    pub age: i32,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "contacts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(email)]
    pub email: String,
}

Fix. Apply pattern / email / url to a String or Option<String> field. If the field genuinely is non-string, the rule does not apply — drop it.

Fixture. crates/ferra-forge/tests/diagnostics/frg_209_string_rule_on_non_string.rs


FRG-210 — min_length exceeds max_length on the same field

Trigger. A field declares both #[field(min_length = N)] and #[field(max_length = M)] with N > M — no string length satisfies both bounds, so every wire value would fail validation.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 10, max_length = 1)]
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 1, max_length = 255)]
    pub title: String,
}

Fix. Ensure min_length <= max_length. The bounds are inclusive on both sides, so min_length = max_length (a single fixed length) is acceptable.

Fixture. crates/ferra-forge/tests/diagnostics/frg_210_min_exceeds_max_length.rs


FRG-211 — min exceeds max on the same field

Trigger. A field declares both #[field(min = N)] and #[field(max = M)] with N > M — no numeric value satisfies both bounds. Same shape as FRG-210, on the numeric range rules.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ratings")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min = 10, max = 1)]
    pub rating: i32,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "ratings")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min = 0, max = 10)]
    pub rating: i32,
}

Fix. Ensure min <= max. The bounds are inclusive, so a single fixed value (min = max) is acceptable.

Fixture. crates/ferra-forge/tests/diagnostics/frg_211_min_exceeds_max.rs


FRG-212 — required applied to a non-Option<T> field

Trigger. A #[field(required)] attribute on a field whose Rust type is not Option<T>. Non-Option fields are required by Rust’s type system — the wire value cannot be absent — so the required flag is meaningless on them. The flag exists for Option<T> fields, where it forbids null on the wire.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(required)]
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "profiles")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(required)]
    pub bio: Option<String>,
}

Fix. Either remove required (the field’s non-Option type already requires a value) or change the field type to Option<T> if the wire form should be nullable but rejected on null.

Fixture. crates/ferra-forge/tests/diagnostics/frg_212_required_on_non_option.rs


FRG-213 — unknown #[field(...)] key

Trigger. A #[field(<key>)] invocation whose <key> is not in the closed #[field(...)] vocabulary. The closed vocabulary is the eight rules listed in ferra-forge.md § Validation rulesmin_length, max_length, min, max, pattern, email, url, required. When the unknown key’s Levenshtein distance to a recognised key is ≤ 2, the diagnostic carries a did you mean <closest>? help line.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_lenght = 1)]
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 1)]
    pub title: String,
}

Fix. Apply the spelling the did you mean line suggests, or consult the closed #[field(...)] vocabulary above. If the key you wanted is on the deferred list (see ferra-forge.md § Deferred keys), that feature has not yet shipped.

Fixture. crates/ferra-forge/tests/diagnostics/frg_213_unknown_field_key.rs


FRG-214 — bare flag given a value

Trigger. A #[field(<flag>)] invocation written as #[field(<flag> = <value>)] for one of the bare-flag rules. The flags email, url, and required take no value — they fire purely on presence. Writing #[field(email = "x")] is a shape mismatch.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "contacts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(email = "x")]
    pub contact_email: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "contacts")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(email)]
    pub contact_email: String,
}

Fix. Drop the = … part — write the flag bare. The rule fires on presence; there is nothing to configure.

Fixture. crates/ferra-forge/tests/diagnostics/frg_214_value_shape_mismatch.rs


FRG-215 — primary key cannot be optional

Trigger. A field declared as #[sea_orm(primary_key)] pub id: Option<Uuid> (or Option<Id>). Option<Uuid> was admitted as a field type in 0.6.0 Welding (User Story 4), but a primary key cannot be optional — an inserted None would be indistinguishable from a row whose key was never assigned, so the round-trip equality semantics a PK guarantees cannot hold.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Option<Uuid>,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: Uuid,
}

Fix. Either drop the Option<...> from the field type (so the PK is always present) or remove #[sea_orm(primary_key)] from the tag (so the field becomes a nullable non-key UUID).

Note on Class 4. Option<Uuid> was previously rejected outright by #[derive(FerraModel)] (Class 4 in ferra-forge.md § Troubleshooting). 0.6.0 Welding lifts the rejection — the type is now first-class — and replaces it with this narrower contradiction check. If you came here looking for the old “Option<Uuid> is not supported” diagnostic, that class is retired; the per-rule rejection above is the current diagnostic.

Fixture. crates/ferra-forge/tests/diagnostics/frg_215_optional_primary_key.rs


Compile-time diagnostic wrappers

Some Ferra attributes impose a trait bound on the field’s type — for example, #[ferra(computed)] requires the field’s type to implement Default (the framework supplies a compute-time placeholder by calling T::default()). When the bound is violated, rustc’s default message would be the language-level error[E0277]: the trait bound \T: Default` is not satisfied`. That phrasing is correct but tells the user nothing about why Ferra needs the bound or how to satisfy it in the Ferra-specific context.

To close that gap, ferra-core ships a small set of wrapper traits in ferra_core::diagnostics that re-export the underlying language traits under a framework-authored name and carry a #[diagnostic::on_unimplemented] annotation. The compiler reads the annotation and renders the framework’s message / label / note triple instead of its default E0277 text. The user sees a Ferra-shaped diagnostic on the first line of compiler output.

The shipped wrappers

Wrapper traitWrapped language traitConsumed by
FerraComputedDefaultDefault (blanket-impl’d for T: Default)ferra-forge’s compute_emit::emit_computed_bounds, once per #[ferra(computed)] field.
FerraSendSend (blanket-impl’d for T: Send + ?Sized)ferra-forge’s emit_bounds_assertion (the FR-027 class 15 “forgot derives / non-Send field” detector emitted on every #[derive(FerraModel)] struct).
FerraSyncSync (blanket-impl’d for T: Sync + ?Sized)Sibling of FerraSend on the same assertion — carries the framework-authored message for the Sync half of the bound.
FerraAuthConfigurednone (placeholder) at 0.6.5Forward-declared at 0.6.5 with no consumer; Foundry::mount::<M>() will require this bound from 0.8.0 Forging I onward.

FerraComputedDefault, FerraSend, and FerraSync are the three wrappers that can fire on user code today. FerraAuthConfigured exists in 0.6.5 so the 0.8.0 release can emit the bound without a coupled ferra-core evolution — at 0.6.5 it has no blanket impl and no consumer.

The canonical diagnostic shape

The compiler renders a #[diagnostic::on_unimplemented] failure as three lines anchored on the offending type. The message is the top-line error: …; the label is the inline span annotation pointing at the offending position; the note is the bottom-line fix hint. For FerraComputedDefault:

error: `NotDefault` does not implement `Default`, but it is required for fields marked `#[ferra(computed)]`
  --> src/model.rs:LL:CC
   |
LL |     pub total: NotDefault,
   |                ^^^^^^^^^^ this type must implement `Default` for ferra-forge to supply its compute-time placeholder
   |
   = note: add `#[derive(Default)]` on `NotDefault`, or provide an explicit `impl Default for NotDefault`.

The framing — is required for fields marked \#[ferra(…)]`plus the explicitferra-forgereference in thelabel— is how a consumer (human or AI) recognises a Ferra wrapper diagnostic versus a generic rustc message. Whenever you see one of these triples, thenote:line names the fix verbatim; apply it on the offending type and re-runcargo check`.

Migration when an upgrade introduces a new wrapper-trait bound

Future Ferra releases land additional bounds (e.g. FerraAuthConfigured at 0.8.0). When an upgrade triggers a previously-passing compile to fail with a new wrapper-trait diagnostic:

  1. Recognise the framing. The message line names a Ferra attribute or generation context (required for fields marked \#[ferra(…)]`, this model needs explicit authorization configuration`, …) rather than a bare language trait.
  2. Read the note: line. It names the concrete fix — a missing #[derive(...)], a missing impl, or a missing attribute on the model.
  3. Apply the fix at the named site. The diagnostic’s --> path:LINE:COL anchor points at the exact source position; the wrapper-trait substrate guarantees the fix is local to the type or the model under the cursor.

The per-code FRG-3NN sections below cite individual diagnostics; the wrapper-trait substrate is shared by every emission that imposes a typed bound on user fields (the FerraComputedDefault consumer site is documented under FRG-3NN — computed/projection codes that share the wrapper substrate).


Computed-field default-value diagnostic

A #[ferra(computed)] field is computed by the framework at serialise time. The default emission strategy substitutes T::default() for the field’s value when reading the entity off the database — so the field’s type must implement Default. The proc-macro emits a bound assertion through the FerraComputedDefault wrapper trait (see Compile-time diagnostic wrappers) so the diagnostic surfaces the framework-authored triple instead of rustc’s generic E0277 text.

Bad — a user-named type that does not implement Default.

use ferra_core::compute::FerraComputed;
use ferra_forge::FerraModel;
use sea_orm::entity::prelude::*;
use sea_orm::DeriveValueType;
use serde::{Deserialize, Serialize};

/// A user-defined newtype that deliberately omits `#[derive(Default)]`.
/// Every other trait Ferra's substrate requires (`Clone`, `Debug`,
/// `PartialEq`, sea-orm's `ValueType` family via `DeriveValueType`,
/// serde derives) is in place — `Default` is the only missing piece.
#[derive(
    Clone, Debug, PartialEq, DeriveValueType, Serialize, Deserialize,
)]
pub struct NotDefault(pub i64);

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[ferra(computed)]
    pub custom: NotDefault,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
impl FerraComputed for Model {}

The diagnostic the developer sees — the framework-authored FerraComputedDefault #[diagnostic::on_unimplemented] triple, pinned verbatim by the trybuild golden crates/ferra-forge/tests/ui/computed_default_missing.{rs,stderr}:

error[E0277]: `NotDefault` is used as the type of a `#[ferra(computed)]` field, but it does not implement `Default`
  --> src/model.rs:LL:CC
   |
LL |     pub custom: NotDefault,
   |                 ^^^^^^^^^^ this type needs `impl Default for NotDefault`
   |
   = note: Ferra calls `T::default()` when a computed field's compute trait is not implemented (FR-015 — missing implementations are a no-op, not a panic). The `Default` bound is required by the computed-field contract; see `docs/user-guide/ferra-forge.md#computed-fields`. Diagnostic emitted by `ferra_core::diagnostics::FerraComputedDefault`.
   = note: required for `NotDefault` to implement `FerraComputedDefault`
help: consider annotating `NotDefault` with `#[derive(Default)]`

Fix. Add #[derive(Default)] (or an explicit impl Default) on the field’s underlying type:

#[derive(
    Default, Clone, Debug, PartialEq, DeriveValueType, Serialize, Deserialize,
)]
pub struct NotDefault(pub i64);

The build now succeeds and the wrapper-trait bound is satisfied via the blanket impl impl<T: Default> FerraComputedDefault for T {} on [ferra_core::diagnostics::FerraComputedDefault].

FR-043 — non-computed fields are unaffected. A user-named field type that does NOT carry #[ferra(computed)] is rejected by the classifier cascade (type … is not supported by Ferra). The classifier widening that admits NotDefault above lives behind the #[ferra(computed)] gate by design; the negative control fixture crates/ferra-forge/tests/ui/non_computed_user_named_rejected.{rs,stderr} pins this invariant.

Multi-segment paths (module::MyType) are not admitted at 0.7.0. Only single-ident user-named types pass the gate. A path-qualified type continues to be rejected by the classifier cascade even under the computed gate. Use a use module::MyType as MyType; at the declaration site to surface the bare ident if the wrapper-trait diagnostic is desired.


FRG-3NN — projection-attribute diagnostics (0.6.5 Chasing)

The 0.6.5 Chasing release opens the FRG-3NN block for projection / computed / write-only attribute rejections. Codes FRG-301..FRG-312 and FRG-314 ship from the proc-macro parser (#[derive(FerraModel)]); codes FRG-312 and FRG-315 are runtime-detected at Foundry::build() and ship their full integration-test fixture when the corresponding API surface (cross-model collision check, nested-mounting) wires in. FRG-313 is intentionally unallocated — the slot is reserved for namespace stability; the framework will not recycle the identifier.

See Projections and routing for the user-facing contract; this page documents the compile-time enforcement.

FRG-301 — projection references an unknown field

Trigger. A #[ferra(projection(... read = [...]))] or write = [...] list contains an ident that does not match any field on the host struct. The check also fires when an ident references a field but in the wrong half (e.g. a #[ferra(write_only)] field listed in a read = [...]; see FRG-305 for that refinement).

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin", read = [ghost_field]))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin", read = [id, title]))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
}

Fix. Replace the unknown ident with a field that exists on the struct, or add the field. Identifiers in read/write lists must match field names exactly (case-sensitive).

Fixture. crates/ferra-forge/tests/diagnostics/frg_301_projection_unknown_field.rs


FRG-302 — write_only and read_only on the same field

Trigger. A field carries both #[ferra(write_only)] and #[ferra(read_only)]. The two flags are mutually exclusive: a write-only field is inbound-only (e.g. a password on registration), a read-only field is outbound-only (e.g. a server-set timestamp); combining them produces a field that can be neither read nor written — a field nobody can interact with. Diagnostic span on the second-encountered flag.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[ferra(read_only)]
    #[ferra(write_only)]
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[ferra(read_only)]
    pub title: String,
}

Fix. Decide which direction the field travels and keep exactly one flag. If the field genuinely flows in one direction only, drop the other attribute.

Fixture. crates/ferra-forge/tests/diagnostics/frg_302_write_only_and_read_only.rs


FRG-303 — write_only and computed on the same field

Trigger. A field carries both #[ferra(write_only)] and #[ferra(computed)]. A computed field is server-produced — its value materialises at read time from other fields on the row — so it is strictly outbound. Tagging it write_only makes the field unreachable: the framework would never produce it on the read side and would reject inbound values on the write side. Diagnostic span on write_only.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub subtotal: f64,
    #[ferra(write_only)]
    #[ferra(computed)]
    pub total: f64,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub subtotal: f64,
    #[ferra(computed)]
    pub total: f64,
}

Fix. Drop #[ferra(write_only)]computed already implies “read shape only” (and also implies read_only; see FRG-309 for the redundancy-with-read_only check).

Fixture. crates/ferra-forge/tests/diagnostics/frg_303_write_only_and_computed.rs


FRG-304 — write_only on a primary-key field

Trigger. #[ferra(write_only)] on a field that also carries #[sea_orm(primary_key)]. Primary-key columns surface in _links.self.href and as /{resource}/{id} URL segments — they must round-trip on the read side. A write-only PK column would leave the inserted row structurally unaddressable. Composite-key models fire FRG-304 independently per PK column, so the developer resolves the full graph in one round-trip.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "documents_composite")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[ferra(write_only)]
    pub workspace_id: i32,
    #[sea_orm(primary_key, auto_increment = false)]
    #[ferra(write_only)]
    pub document_id: i32,
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "documents_composite")]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub workspace_id: i32,
    #[sea_orm(primary_key, auto_increment = false)]
    pub document_id: i32,
    pub title: String,
}

Fix. Remove #[ferra(write_only)] from every PK column. If the column is genuinely sensitive on read, model it as a non-PK column and keep a separate synthetic primary key.

Fixture. crates/ferra-forge/tests/diagnostics/frg_304_write_only_on_primary_key.rs


FRG-305 — write_only field appears in a read projection

Trigger. A field declared #[ferra(write_only)] is listed in some projection’s read = [...]. The structural exclusion of write-only fields from the read shape is the load-bearing security guarantee of US3 — there is no escape hatch via the projection attribute, even on an admin-scoped projection. Diagnostic span on the offending ident inside the read list.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "admin", read = [id, email, password_hash]))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub email: String,
    #[ferra(write_only)]
    pub password_hash: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "admin", read = [id, email]))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub email: String,
    #[ferra(write_only)]
    pub password_hash: String,
}

Fix. Remove the write-only field from the read = [...] list. If the field should genuinely appear on the read side, remove #[ferra(write_only)] from its declaration — but think hard first: the attribute exists to prevent precisely this leak.

Fixture. crates/ferra-forge/tests/diagnostics/frg_305_write_only_in_read_projection.rs


FRG-306 — skip and non-empty groups on the same field

Trigger. A field carries both #[ferra(skip)] and a non-empty #[ferra(groups = ["..."])]. skip removes the field from every projection unconditionally; groups = [...] opts it into specific projections. The two cannot simultaneously hold. The “both empty” case (groups = [] next to skip) is allowed — the empty group list is a no-op. Diagnostic span on the second-encountered attribute.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[ferra(skip)]
    #[ferra(groups = ["admin"])]
    pub internal_notes: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[ferra(groups = ["admin"])]
    pub internal_notes: String,
}

Fix. Decide whether the field is universally hidden (keep skip, drop the non-empty groups) or scoped to specific projections (drop skip, keep the groups list).

Fixture. crates/ferra-forge/tests/diagnostics/frg_306_skip_and_groups_contradict.rs


FRG-307 — legacy #[field(write_only)] form

Trigger. A field carries #[field(write_only)]. The #[field(...)] namespace is reserved for validation rules (FRG-201..FRG-215); write_only is a projection flag that lives under the #[ferra(...)] namespace per ADR-0006’s namespace harmonisation. The parser recognises the pre-ADR-0006 legacy spelling and emits FRG-307 with help pointing at the canonical #[ferra(write_only)] spelling.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(write_only)]
    pub password_hash: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[ferra(write_only)]
    pub password_hash: String,
}

Fix. Rename the attribute namespace from #[field(write_only)] to #[ferra(write_only)]. This is distinct from FRG-308 (typo within the #[ferra(...)] namespace): the mistake here is the namespace, not the spelling.

Fixture. crates/ferra-forge/tests/diagnostics/frg_307_legacy_field_write_only.rs (See also the sibling release-blocker tests/ui/fail_ferra_writeonly_password_hash.rs fixture cited under FRG-308 — that one carries forward from 0.6.0 and now also exercises the FRG-308 typo path.)


FRG-308 — #[ferra(writeonly)] typo (did you mean write_only?)

Trigger. A field carries #[ferra(writeonly)] — the underscore is missing. The parser computes the Levenshtein distance to every known flag in the #[ferra(...)] vocabulary and, when the distance is ≤ 2, emits FRG-308 with a did you mean hint pointing at the closest known flag. This is the release-blocker test carried forward from 0.6.0 Welding — the canonical typo on a password_hash field is gated on every release.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[ferra(writeonly)]
    pub password_hash: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[ferra(write_only)]
    pub password_hash: String,
}

Fix. Apply the spelling the did you mean line suggests — for the writeonly typo, the canonical form is write_only with the underscore.

Fixture. crates/ferra-forge/tests/ui/fail_ferra_writeonly_password_hash.rs


FRG-309 — computed and read_only redundant

Trigger. A field carries both #[ferra(computed)] and #[ferra(read_only)]. The first attribute implies the second by construction — a computed field is server-produced and therefore appears only on the read shape — so the explicit read_only is redundant. Diagnostic span on the second-encountered flag with help suggesting the redundant attribute be dropped.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub subtotal: f64,
    #[ferra(computed)]
    #[ferra(read_only)]
    pub total: f64,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "invoices")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub subtotal: f64,
    #[ferra(computed)]
    pub total: f64,
}

Fix. Drop #[ferra(read_only)]. computed already implies the read-only invariant.

Fixture. crates/ferra-forge/tests/diagnostics/frg_309_computed_and_read_only_redundant.rs


FRG-310 — projection URL prefix collision

Trigger. Two projections on the same model produce the same URL prefix. Two failure shapes share this code:

  • Duplicate path_prefix. Two projections each declare the same literal path_prefix = "...", so both route to the same URL.
  • Double default = true. Two projections each carry default = true; both target the bare resource path (/{resource}/{id}) and therefore collide. The second default = true is the offending span.

The proc-macro detects single-model collisions at compile time; the framework’s Foundry::build() startup pass covers cross-model collisions as defense-in-depth (see FRG-312 for the projection-vs-resource case).

Bad (duplicate path_prefix).

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin",   path_prefix = "/staff", read = [id, title]))]
#[ferra(projection(name = "manager", path_prefix = "/staff", read = [id, title]))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
}

Bad (double default = true).

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(
    name = "v2", default = true,
    promotes_from = "auto-derived", breaking_change_version = "2.0.0",
))]
#[ferra(projection(
    name = "v3", default = true,
    promotes_from = "named:v2", breaking_change_version = "3.0.0",
))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
}

Good.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin",   path_prefix = "/staff",   read = [id, title]))]
#[ferra(projection(name = "manager", path_prefix = "/manager", read = [id, title]))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
}

Fix. Give each projection a distinct path_prefix, or rename one of the projections so the auto-derived prefix differs. For the default = true form, keep at most one default projection per model — to migrate the default shape, retire the previous default = true while landing the replacement (and pair the new default with the promotes_from + breaking_change_version attestations from FRG-316).

Fixture. crates/ferra-forge/tests/diagnostics/frg_310_projection_prefix_collision.rs (duplicate path_prefix form); crates/ferra-forge/tests/diagnostics/frg_310_double_default_projection.rs (double default = true form).


FRG-311 — projection name is not a URL-safe segment

Trigger. A projection’s name = "..." cannot be normalised to a URL-safe segment (it contains characters outside [a-z0-9_-] after to_lowercase()) AND the projection does not supply an explicit path_prefix = "..." override. The framework needs a URL-safe segment to route the projection; when the name cannot produce one, the developer must either rename the projection or supply a path prefix verbatim.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin console"))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
}

Good (rename).

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin-console"))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
}

Good (explicit prefix).

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
#[ferra(projection(name = "admin console", path_prefix = "/admin-console"))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
}

Fix. Rename the projection to use only lowercase ASCII letters, digits, hyphens, and underscores; or supply an explicit path_prefix = "/some-segment" to bypass the auto-derive.

Fixture. crates/ferra-forge/tests/diagnostics/frg_311_projection_name_invalid_url_segment.rs


FRG-312 — projection prefix collides with a resource route

Trigger. A projection’s auto-derived URL prefix collides with an existing resource route mounted elsewhere on the same Foundry. For example: model A is mounted at /admin, and model B declares a projection named admin — the projection would shadow the top-level resource at the shared URL prefix.

Detection site. This collision spans two models, so the proc-macro cannot see it at compile time (it parses one struct at a time). Detection lives at Foundry::build(): the runtime walks every registered resource route plus every projection prefix and panics on overlap with a FRG-312 error. The proc-macro emits no hint for this code.

Status at 0.6.5. The runtime collision check ships with the projection-routing wiring. A dedicated tests/diagnostics/frg_312_*.rs proc-macro fixture is intentionally absent — the diagnostic surface is runtime (a panic with the FRG-312 prefix), so the test lives as an integration assertion alongside other Foundry::build() checks rather than as a trybuild fixture.

Bad (illustrative — runtime).

// Two models on the same Foundry:
//   - `Film` is mounted at the resource route `/admin`.
//   - `User` declares a projection named `admin`, auto-deriving to `/users/admin`.
//
// If `Film`'s mount path were `/admin/...` and `User`'s `admin`
// projection auto-derived to a route that overlaps with `Film`'s
// nested URL surface, `Foundry::build()` would panic with FRG-312.
#[derive(Clone, Debug, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "admin", read = [id, email]))]
pub struct Model { /* ... */ }

Fix. Rename one of the colliding sides — either pick a different projection name (auto-deriving a different prefix), or remount the conflicting resource under a different top-level path. The Foundry::build() panic message names both colliding routes so the rename is mechanical.

Fixture. Runtime-only at 0.6.5; no proc-macro tests/diagnostics/ fixture in this release. The Foundry::build() integration test in crates/ferra-http/tests/ (lands with the cross-model collision check) is the byte-shape contract.


FRG-314 — write_only field omitted from an explicit write list

Trigger. A named projection declares an explicit write = [...] list that omits a #[ferra(write_only)] field on the host model. The default-write projection (auto-derived when write = [...] is omitted) already includes every write-only field by construction; this rejection only applies to projections that opt into an explicit list. The help suggests adding the missing field or removing the #[ferra(write_only)] declaration. Diagnostic span on the projection’s write key.

Bad.

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "register", read = [id, email], write = [email]))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub email: String,
    #[ferra(write_only)]
    pub password_hash: String,
}

Good (include password_hash in write).

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "register", read = [id, email], write = [email, password_hash]))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub email: String,
    #[ferra(write_only)]
    pub password_hash: String,
}

Good (drop explicit write and rely on the auto-derived default).

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
#[ferra(projection(name = "register", read = [id, email]))]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub email: String,
    #[ferra(write_only)]
    pub password_hash: String,
}

Fix. Add <missing_field> to the projection’s write = [...] list, OR remove #[ferra(write_only)] from the field’s declaration if the field genuinely should not be writable through this projection. If the projection is meant to accept every writable field, drop the explicit write = [...] and rely on the default.

Fixture. crates/ferra-forge/tests/diagnostics/frg_314_write_only_omitted_from_explicit_write.rs


FRG-315 — path_prefix declared on a sub-resource

Trigger. A model mounted as a sub-resource of another model (Foundry’s nested-mounting pattern) carries path_prefix = "..." on one of its #[ferra(projection(...))] declarations. The URL surface of a nested resource is governed exclusively by the root resource’s projection prefix (ADR-0030 §Nested resources); a sub-resource overriding the URL would break the one-prefix-per-request invariant.

Detection site. Primary detection lives at Foundry::build() time — the proc-macro cannot fully detect sub-resource context (it sees one struct at a time). The proc-macro carries a hint emit at the path_prefix attribute as defense-in-depth.

Status at 0.6.5. The framework’s nested-mounting API ships in a future release. The FRG-315 namespace slot and the parser hint are in place; the runtime fixture lands when nested-mounting becomes wireable.

Bad.

// Imagined nested-mounting flow (future API):
// Foundry::new(conn).mount::<Film>().mount_nested::<Review, Film>().build()
#[derive(Clone, Debug, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "reviews")]
#[ferra(projection(
    name = "detail",
    path_prefix = "/v2",       // ← Review is mounted as a sub-resource of Film;
    read = [id, content],      //   its URL surface MUST come from Film's prefix.
))]
pub struct Model { /* ... */ }

Fix. Remove the path_prefix from the nested model’s projection. To version a sub-resource independently of its parent, version the root resource’s projection — the prefix propagates uniformly to every nested segment per ADR-0030 §Nested resources.

#[ferra(projection(
    name = "detail",
    // path_prefix removed — the prefix inherits from Film's `detail` projection.
    read = [id, content],
))]

FRG-316 — default = true requires promotes_from + breaking_change_version

Trigger. A projection declares default = true without both promotes_from = "..." and breaking_change_version = "..." attestations on the same declaration. Setting default = true replaces the bare-path response shape — a silent breaking change to the public API contract (consumers continue requesting /{resource}/{id} and receive a different schema). The two attestations force the change to be visible in the source.

Why both are required. promotes_from is purely declarative — the macro does not validate it against repository history. Its purpose is to force the developer to name what is being replaced ("auto-derived" for the framework’s default, or "named:<other>" for a previous named-default projection). breaking_change_version surfaces in the OpenAPI document as the x-ferra-promoted-in: "X.Y.Z" vendor extension on every operation under the bare resource path, so SDK consumers and downstream tooling can detect the silent break.

A future release adds a .ferra/projection-defaults.lock file with schema-hash drift detection (Layer 2); at 0.6.5 the Layer-1 attestation is the sole defence.

Bad.

#[ferra(projection(
    name = "v2",
    default = true,              // ← replaces the bare /films/{id} shape silently
    read = [id, title],
    write = [title],
))]
pub struct Model { /* ... */ }

Fix.

#[ferra(projection(
    name = "v2",
    default = true,
    promotes_from = "auto-derived",     // ← attestation of the previous default
    breaking_change_version = "2.0.0",  // ← app version in which the promotion lands
    read = [id, title],
    write = [title],
))]
pub struct Model { /* ... */ }

If the previous default was itself a named projection (rather than the auto-derived shape), use promotes_from = "named:<other-projection-name>".

Fixture. crates/ferra-forge/tests/diagnostics/frg_316_default_promotion_missing_attestation.rs

Custom handlers

Most Ferra apps need at least one route that is not pure CRUD — a publish action, a derived collection, an aggregation, a webhook receiver. This page is the authoritative guide to writing those handlers on top of a Ferra-served API. A reader with this page plus standard Rust knowledge produces a compiling action route on the first attempt — no framework source, no ADR, no constitution required.

The companion CRUD / envelope / Tower-stack / typed-extraction reference is ferra-http.md. The router-assembly facade is foundry.md.


When ferra_router::<M> is not enough

The five built-in handlers cover the resource-shaped endpoints — list, read, create, update, delete. They are all you need for ~80 % of the surface a typical app exposes.

You write a custom handler when the route is not one of those five operations. Common reasons:

  • Action routes. POST /films/{id}/publish, POST /invoices/{id}/cancel, POST /accounts/{id}/transfer — verbs on a resource that aren’t expressible as PUT.
  • Derived collections. GET /films/popular, GET /actors/{id}/films — collections whose membership is computed, not just SELECT * WHERE ....
  • Multi-resource projections. GET /dashboard returning a join of three resources at once.
  • Webhook receivers. POST /webhooks/stripe reading a non-Ferra payload shape.
  • Cross-cutting endpoints. GET /healthz, GET /metrics.

Custom handlers are plain Axum handlers. They accept Axum extractors, return Result<_, FerraError> for RFC 7807 error continuity, and compose with the framework router via axum::Router::merge or by appending .route(...) on top of Foundry::build() / ferra_router::<M>(state).


The worked example: POST /films/{id}/publish

The complete shape of an action route — model declaration, handler signature, composition with Foundry — in one file. Drop this into a fresh src/main.rs whose Cargo.toml declares the canonical four-dep set (ferra, tokio, axum, serde) and it compiles.

use ferra::prelude::*;
use axum::{
    Json, Router,
    http::request::Parts,
    routing::post,
};

// --- The model -----------------------------------------------------

mod film {
    use super::*;

    #[derive(
        Clone, Debug, PartialEq, Eq,
        DeriveEntityModel, FerraModel,
        Serialize, Deserialize,
    )]
    #[sea_orm(table_name = "films")]
    pub struct Model {
        #[sea_orm(primary_key)]
        pub id: i32,
        #[sea_orm(unique)]
        pub title: String,
        pub director: String,
        pub year: Option<i32>,
        pub release_date: Option<Date>,
        pub published: bool,
    }

    #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
    pub enum Relation {}

    impl ActiveModelBehavior for ActiveModel {}
}

pub use film::Model as Film;

// --- The action's request body -------------------------------------

// Plain serde struct — nothing Ferra-specific. The `FerraJson<T>`
// extractor on the handler signature accepts any `T: DeserializeOwned`.
#[derive(Deserialize)]
struct PublishRequest {
    release_date: Option<Date>,
}

// --- The handler ---------------------------------------------------

// `#[axum::debug_handler]` is optional but turns the dense
// `Handler` trait-bound error into a single, targeted message
// when one of the rules below is violated. Keep it on while
// iterating; it is a no-op at runtime.
#[axum::debug_handler]
async fn publish_film(
    // FromRequestParts — `State` reads only the request parts,
    //                   so it can be in any position.
    State(state): State<FerraState<Film>>,
    // FromRequestParts — `FerraPath` is the typed-extraction
    //                   wrapper around `axum::extract::Path<T>`.
    FerraPath(id): FerraPath<i32>,
    // FromRequestParts — `Parts` itself, used below for
    //                   building HAL `_links` from the request URL.
    parts: Parts,
    // FromRequest — `FerraJson` consumes the request body. Body-
    //              consuming extractors MUST be the LAST argument.
    FerraJson(body): FerraJson<PublishRequest>,
) -> Result<Json<ItemResponse<Film>>, FerraError> {
    // 1. Read the existing row.
    let mut film = state
        .repo
        .find_by_id(id)
        .await
        .map_err(FerraError::from)?;

    // 2. Apply the action's effect.
    film.published = true;
    if let Some(d) = body.release_date {
        film.release_date = Some(d);
    }

    // 3. Persist.
    let updated = state
        .repo
        .update(id, film)
        .await
        .map_err(FerraError::from)?;

    // 4. Build the response envelope with HAL `_links`. The two
    //    helpers live at the facade root (not the prelude) — reach
    //    them via the fully-qualified path or add an explicit `use`.
    let base_url = ferra::base_url_from_parts(&parts);
    let links = ferra::build_item_links(
        Film::meta(),
        &updated.id.to_string(),
        &base_url,
    );

    Ok(Json(ItemResponse { data: updated, links }))
}

// --- Composition with Foundry --------------------------------------

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let database_url = std::env::var("DATABASE_URL")?;
    let conn = ferra::sea_orm::Database::connect(&database_url).await?;

    // Foundry assembles the CRUD + `/docs` surface from `conn`.
    let api = Foundry::new(conn.clone())
        .mount::<Film>()
        .with_docs()
        .build();

    // The action route lives on a sibling sub-router with its own
    // `FerraState<Film>`. `DatabaseConnection: Clone` (Sea-ORM pool
    // refcount); `FerraState<M>: Clone` (Arc refcount) — both clones
    // are cheap.
    let custom = Router::new()
        .route("/films/{id}/publish", post(publish_film))
        .with_state(FerraState::<Film>::new(conn));

    let app = api.merge(custom);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

A successful POST /films/42/publish with body {"release_date": "2026-05-10"} returns 200 OK and the same envelope shape as GET /films/42:

{
  "id": 42,
  "title": "Rust en pratique",
  "director": "Alice",
  "year": 2025,
  "release_date": "2026-05-10",
  "published": true,
  "_links": {
    "self":       { "href": "https://api.example.com/films/42" },
    "collection": { "href": "https://api.example.com/films" }
  }
}

Errors flow through the same FerraError taxonomy the built-in handlers use — a malformed JSON body returns 400 with type: "https://ferra.rs/errors/validation"; a missing row returns 404 with type: "https://ferra.rs/errors/not_found". Clients that handle one Ferra error already handle this one.


Extractor ordering: FromRequest vs FromRequestParts

Axum has two extractor traits. The distinction looks academic until you write your first multi-extractor handler — then it becomes the load-bearing rule.

TraitReads fromExamplesPosition
FromRequestPartsrequest parts only (method, URI, headers, state)State<T>, Path<T>, Query<T>, Parts, HeaderMap, FerraPath<T>any position
FromRequestthe whole request, including the body (consumed)Json<T>, Bytes, String, Form<T>, FerraJson<T>last argument only

The rule: at most one body-consuming extractor (FromRequest) per handler, and it MUST be the last argument. Every other extractor is body-agnostic (FromRequestParts) and goes earlier.

This is a fundamental property of HTTP, not an Axum design quirk: a request body is a one-shot stream. Once you have read it, the bytes are gone.

The handler in the worked example follows the rule:

async fn publish_film(
    State(state): State<FerraState<Film>>,   // FromRequestParts
    FerraPath(id): FerraPath<i32>,            // FromRequestParts
    parts: Parts,                             // FromRequestParts
    FerraJson(body): FerraJson<PublishRequest>, // FromRequest — LAST
) -> Result<...> { ... }

Reorder FerraJson ahead of FerraPath and the build fails. Without #[axum::debug_handler] the compiler reports the violation through a deep trait-bound chain on Handler. With #[axum::debug_handler] the diagnostic points directly at the offending argument.

Multiple body extractors. Axum allows exactly one body-consuming extractor per handler. If you legitimately need to look at the raw bytes and a typed JSON deserialization, do both inside a single FromRequest-implementing wrapper, or read Bytes and parse it explicitly with serde_json::from_slice. There is no Json<A> + Json<B> shape.


Send + Sync + 'static: the bound-set cheat-sheet

Tokio runs handlers on a multi-threaded executor by default. Every value Axum carries through the handler must be sendable across threads and outlive the request. The compiler enforces this through Send + Sync + 'static-shaped bounds on the Handler trait — failures here look daunting at first read.

The cheat-sheet:

SymptomLikely causeFix
the trait Send is not implemented for ... inside an async fna !Send value is held across an .await (Rc<_>, RefCell<_>, MutexGuard from std::sync::Mutex)use Arc<_>, tokio::sync::Mutex, or restructure so the offending value is dropped before the .await
the trait Sync is not implemented for ... on State<T>the state type is not Sync (e.g., it owns a non-Sync cache)wrap the inner shared state in Arc<RwLock<...>> or restructure to a channel
argument requires that ... must outlive 'staticthe handler captures a non-'static reference (&str from outside the closure, a borrow from the request)own the data (String, Bytes), or extract it via an extractor
Handler<_, _> not satisfied with a long, hard-to-read chainusually one of the three above, hidden behind Handler’s blanket impladd #[axum::debug_handler] and read the first targeted error it produces

Notes on the framework’s own types:

  • FerraState<M> is Clone + Send + Sync + 'static by construction — it wraps Arc<PgRepository<M>>. Pass it through any number of handlers freely.
  • FerraJson<T> / FerraPath<T> add no bounds beyond the inner T: DeserializeOwned + Send + 'static that Axum already requires.
  • FerraError is Send + Sync + 'static and implements axum::response::IntoResponse — return Result<_, FerraError> from any handler signature.

If your handler async fn body holds a !Send value across an .await, the bound failure surfaces in the Handler impl, not in the body. Move the value into a synchronous block or drop it before the await.


Friendlier diagnostics with #[axum::debug_handler]

Axum’s Handler trait has a blanket impl that erases per-argument type information by the time the bound failure is reported. Without help, a single wrong extractor produces a multi-screen not satisfied chain.

#[axum::debug_handler]
async fn my_handler(/* … */) -> Result<…> { … }

The proc-macro re-checks the arguments one by one and emits a single targeted error per offending argument:

  • Json must be the last extractor
  • the trait FromRequestParts is not implemented for …
  • Send is not satisfied because …

It is purely a development aid — it adds no runtime cost and emits no code in release builds. Keep it on every custom handler while iterating; remove it only if a transitive dep needs the un-decorated handler signature for some other proc-macro to see.

It does not (and cannot) flag every misuse — extractor ordering and bound failures are visible to it; subtle data-flow bugs and panic-on-unwrap paths are not.


FerraJson<T> and FerraPath<T> — RFC 7807 error continuity

Axum’s stock Json<T> and Path<T> produce text/plain 400 bodies on rejection. A consumer that handles Ferra’s application/problem+json taxonomy elsewhere on the API gets a mixed wire format on the rejection path — two parsers, two error shapes, two test surfaces.

FerraJson<T> and FerraPath<T> are thin newtypes around the stock extractors that intercept the rejection and remap it to FerraError::Validation:

pub struct FerraJson<T>(pub T);   // wraps axum::Json<T>
pub struct FerraPath<T>(pub T);   // wraps axum::extract::Path<T>

The output on a malformed body looks like every other Ferra 400:

{
  "type":   "https://ferra.rs/errors/validation",
  "title":  "Validation Error",
  "status": 400,
  "detail": "validation failed",
  "errors": {
    "errors": [
      { "field": "body", "code": "invalid_json",
        "message": "expected `,` or `}` at line 1 column 14" }
    ]
  }
}

Use the wrappers in every custom handler that takes JSON or path parameters. The single-token swap from Json<T>FerraJson<T> and Path<T>FerraPath<T> is the only difference.

What if I genuinely want stock Axum extraction? You can — they coexist. A handler that takes axum::Json<T> produces stock 400 text/plain on rejection; one that takes FerraJson<T> produces RFC 7807. Pick per handler.

The errors[*].code vocabulary at this release is {"invalid_json", "invalid_path_param"}. It widens when field-level validation lands, which is additive — existing consumer branches keep matching.


Composing with Foundry, ferra_router::<M>, or both

Three composition patterns, in order of how often you reach for them:

1. Append routes on top of Foundry::build()

let api = Foundry::new(conn.clone())
    .mount::<Film>()
    .with_docs()
    .build();

let custom = Router::new()
    .route("/films/{id}/publish", post(publish_film))
    .with_state(FerraState::<Film>::new(conn));

let app: Router = api.merge(custom);

The framework’s Tower stack (CORS, body-limit, tracing, rate-limit on mutation routes) wraps every Ferra-mounted resource. Routes you append on the outer Router see the framework’s outer layers but not the per-resource inner layers — your action endpoint does NOT inherit the rate-limit layer that mounts on the CRUD mutation sub-router. Add your own where needed.

2. Append routes on top of a single-resource ferra_router::<M>

let state = FerraState::<Film>::new(conn);
let app: Router = ferra_router::<Film>(state.clone())
    .route("/films/{id}/publish", post(publish_film))
    .with_state(state);

Use this shape when you have exactly one resource and no Foundry chain — you skip docs but keep the per-resource Tower stack on the CRUD endpoints.

3. Hand-rolled Router with no Ferra CRUD at all

let app: Router = Router::new()
    .route("/healthz", get(|| async { "ok" }))
    .route("/films/{id}/publish", post(publish_film))
    .with_state(FerraState::<Film>::new(conn));

For apps that are only action routes, with no CRUD surface to derive. You retain FerraJson / FerraPath / FerraError and the response envelopes; you give up the auto-generated CRUD, the HAL _links builder pre-wired into the response, and the docs surface.

In all three patterns, consumer-added .layer(...) calls on the final Router wrap the framework stack from the outside. Place observability and authentication layers there; place per-route policy where the policy belongs (a sub-router scoped to that route).


Reaching the repository directly

FerraState<M> exposes pub repo: Arc<PgRepository<M>>. A custom handler that only needs the database adapter (no other state) can extract just that:

use std::sync::Arc;
use ferra::PgRepository;

async fn count_films(
    State(repo): State<Arc<PgRepository<Film>>>,
) -> Result<Json<u64>, FerraError> {
    let page = repo.find_page(1, 1).await.map_err(FerraError::from)?;
    Ok(Json(page.total))
}

FerraState<M>: FromRef<Arc<PgRepository<M>>> makes this swap zero-effort — Axum derives the inner extractor from the outer state. Use it when the handler genuinely does not need the rest of the state.


Cross-references (for the curious reader)

Pointers below trace decisions back to their arbitration. They are not required reading for correct use of the APIs above — every guarantee stated on this page stands on its own.

  • ferra-http.md — the typed-extraction reference, the full RFC 7807 taxonomy, and the Tower layer-ordering diagram. Cross-read it when wiring observability or auth around custom handlers.
  • foundry.md — the router-assembly facade. The composition patterns above attach action routes to a Foundry::build() output.
  • ferra-error-handling.md — the closed ERROR_TYPES URI namespace and the consumer-side error-branching pattern.
  • ferra-core.md §“Time vocabulary” — Date and DateTime (used in the worked example’s release_date field).

Error handling

Every Ferra-served HTTP response that is not a success carries an RFC 7807 problem-details body (Content-Type: application/problem+json). Every body’s type field is a URI from a closed set the framework maintains as a single source of truth.

This page is the reference for that closed set: the URI table, the HTTP status / title / example body for each variant, and the consumer-side branching pattern.


The closed ERROR_TYPES URI set

The framework can emit exactly eight type URIs on the wire. Every literal in framework source (and in any consumer crate) MUST appear in this set; a CI gate (error_types_closed.sh) fails the build on any drift.

URIHTTP statusTitleSource
https://ferra.rs/errors/not_found404 Not FoundResource Not FoundFerraError::NotFound
https://ferra.rs/errors/validation400 Bad RequestValidation ErrorFerraError::Validation (extractor failures: malformed JSON body, unparseable path parameter)
https://ferra.rs/errors/validation_failed422 Unprocessable ContentValidation FailedFerraError::Unprocessable (well-formed body whose values violate #[field(...)] rules — see § Validation failures (422))
https://ferra.rs/errors/conflict409 ConflictConflictFerraError::Conflict
https://ferra.rs/errors/internal500 Internal Server ErrorInternal Server ErrorFerraError::Internal
https://ferra.rs/errors/payload_too_large413 Payload Too LargePayload Too Largerequest-body-limit middleware
https://ferra.rs/errors/rate_limited429 Too Many RequestsToo Many Requestsrate-limit middleware
https://ferra.rs/errors/unauthorized401 UnauthorizedUnauthorizedFoundry::with_docs_protected(...)

Three of the eight URIs (payload_too_large, rate_limited, unauthorized) are emitted by middleware layers, not through FerraError. The framework’s full URI surface is the union of the two paths; both are governed by the same ERROR_TYPES slice.

The validation (400) and validation_failed (422) URIs are intentionally distinct. A malformed request body (JSON parse failure, type mismatch on a required field, an Id parameter that will not parse) returns 400 — the request was syntactically unprocessable. A well-formed request body whose values violate the model’s declarative #[field(...)] rules returns 422 — the request parsed cleanly but its semantics were rejected. The split follows RFC 9110 §15.5.1 (400) vs §15.5.21 (422).


Worked example bodies

not_found404

Returned by every read / update / delete that resolves to zero matching rows.

{
  "type": "https://ferra.rs/errors/not_found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "films/c2bb1f10-72b8-486a-9f2b-c92e4a2cdf41 not found"
}

validation400

Returned when an inbound JSON body or a path parameter fails the framework’s typed deserialisation, or when an Id parameter fails to parse. Declarative-rule violations on a well-formed body are the separate 422 path — see § Validation failures (422).

{
  "type": "https://ferra.rs/errors/validation",
  "title": "Validation Error",
  "status": 400,
  "detail": "validation failed",
  "errors": {
    "title": ["must not be empty"],
    "year":  ["must be between 1888 and the current year"]
  }
}

The errors object on the 400 path carries a flat list of error messages per field name. The 422 path uses a sibling shape — see the worked example linked above.

conflict409

Returned when a write would violate a unique constraint (duplicate slug, primary-key collision, etc.).

{
  "type": "https://ferra.rs/errors/conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "title 'Casablanca' is already in use"
}

internal500

Returned for any database / infrastructure failure not classifiable as one of the typed variants above. detail is always the constant literal "internal server error" — no internal path fragment, no crate name, no underlying-library substring leaks (constitution §I).

{
  "type": "https://ferra.rs/errors/internal",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "internal server error"
}

payload_too_large413

Emitted when an inbound request body exceeds the framework’s default 1 MiB cap (constitution §I — DoS protection).

{
  "type": "https://ferra.rs/errors/payload_too_large",
  "title": "Payload Too Large",
  "status": 413,
  "detail": "request body too large"
}

rate_limited429

Emitted by the framework’s default rate-limiter on mutation routes (POST / PUT / DELETE). The response carries a Retry-After header when the limiter can compute a sensible delay.

{
  "type": "https://ferra.rs/errors/rate_limited",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "rate limit exceeded"
}

unauthorized401

Emitted by Foundry::with_docs_protected(verifier) when an unauthenticated request reaches the docs surface. New in 0.5.0.

{
  "type": "https://ferra.rs/errors/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "authentication required"
}

validation_failed422

Returned when an inbound POST or PUT body parses cleanly but violates one or more #[field(...)] rules declared on the target model. The full worked example with multi-field aggregation lives in § Validation failures (422); the short form:

{
  "type": "https://ferra.rs/errors/validation_failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "validation failed",
  "errors": {
    "title": ["must be at least 1 character"]
  }
}

The errors object on this variant is shaped Map<String, Vec<String>> keyed by the wire-side field name — every violation observed during the validation pass is included (no short-circuiting), so a consumer never needs to retry to discover further violations.


Validation failures (422)

A 422 response fires when the request body is well-formed — it parses as JSON and deserialises into the model’s request shape — but one or more values violate the declarative #[field(...)] rules on the target model. Validation runs before persistence on every POST (create) and PUT (update) handler, so an invalid payload never reaches the database. The wire shape is identical across create and update.

This is the 422 path. The 400 validation path above continues to denote extractor failures — a body that does not parse as JSON at all, a path parameter that will not parse as the declared type. Both paths share the application/problem+json envelope; only the 422 path carries the per-field errors map.

The model-side declaration grammar — every supported rule, its default English message, and the compile-time FRG-2NN codes that protect typos — is documented in ferra-forge.md § Validation rules. This section pins the wire shape your consumer sees.

Worked example: multi-field violation

For a Film resource declared as:

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, FerraModel, Serialize, Deserialize)]
#[sea_orm(table_name = "films")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[field(min_length = 1, max_length = 255)]
    pub title: String,
    #[field(min = 0, max = 10)]
    pub rating: i32,
}

a request that violates two fields simultaneously:

POST /films HTTP/1.1
Content-Type: application/json

{ "title": "", "rating": 99 }

receives:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://ferra.rs/errors/validation_failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "validation failed",
  "errors": {
    "rating": ["must be at most 10"],
    "title":  ["must be at least 1 character"]
  }
}

Both fields appear in the same response. Ferra never short-circuits on the first failed rule, so a consumer that submits a payload with N invalid fields receives N entries in errors in a single round-trip. Within a single field, every rule that fired produces its own message — #[field(min_length = 5, email)] on a value of "ab" produces errors.<field> = ["must be at least 5 characters", "is not a valid email address"] (two entries).

Assertion contract for SDK consumers

A consumer driving validation off the wire response asserts the following invariants on a 422:

Wire fieldValueNotes
HTTP status422always equal to the body’s status integer
Content-Type headerapplication/problem+jsonidentical envelope to every other Ferra error
type"https://ferra.rs/errors/validation_failed"pinned URI; closed-namespace member (table above)
title"Validation Failed"distinct from "Validation Error" (the 400 path’s title)
status422 (integer)
detail"validation failed"constant literal — does NOT echo input back into the response (constitution §I — no reflected-XSS on the error path)
errorsMap<String, Vec<String>>keys are wire-side field names (the same names that appear in the request body’s JSON); values are non-empty arrays of human-readable English messages, one entry per rule that fired

The errors map’s keys preserve the request’s wire-side names — when a model uses serde’s #[serde(rename = "...")] to map a wire-side name to a different Rust-side field name, the errors map keys by the wire-side name (what the consumer submitted and what they will inspect).

_links is never present on a 422 body. Validation failure is terminal — there is no resource yet, no self-link to thread, no hypermedia to follow. A consumer that branches on the response’s _links.self.href should treat 422 as the absent-link case.

Update-vs-create symmetry

The validation step runs identically on POST /<resource> and PUT /<resource>/{id}. The 422 body is identical across both. A field omitted from a PUT body is “no change” (Ferra’s existing partial-update semantics) and bypasses that field’s per-field rules. A field present with explicit null on a PUT body for a #[field(required)] Option<T> field fails with ["is required"]null on the wire is a positive declaration of “set to none”, which the rule rejects.

Consumer-side branching pattern

Add one branch to the existing match on the wire type field:

match body["type"].as_str() {
    Some("https://ferra.rs/errors/validation_failed") => {
        // 422 path — read body["errors"] as Map<String, Vec<String>>
        // and surface each (field, msg) pair into your form's
        // per-field error UI.
    }
    Some("https://ferra.rs/errors/validation") => {
        // 400 path — extractor failure; the body shape differs
        // (flat `errors` list, not the per-field map).
    }
    // ... other arms ...
    _ => /* unreachable on a non-fabricated error */,
}

Server-side, when handling a FerraError value, the 422 path reads through the new variant:

use ferra::FerraError;

fn observe(err: &FerraError) {
    match err {
        FerraError::Unprocessable(_) => {
            // 422 — declarative-rule violation
        }
        FerraError::Validation(_) => {
            // 400 — extractor failure
        }
        // other arms unchanged
        _ => {}
    }
}

FerraError::Unprocessable is the typed home for the 422 path; FerraError::error_type returns "https://ferra.rs/errors/validation_failed" on this variant.


Consumer-side branching

The recommended consumer pattern is to compare the wire type field against the URI constants — never against the Display form of the error. The URI is the wire contract; the message text is not.

match body["type"].as_str() {
    Some("https://ferra.rs/errors/not_found")          => /* 404 path */,
    Some("https://ferra.rs/errors/validation")         => /* 400 path; extractor failure */,
    Some("https://ferra.rs/errors/validation_failed")  => /* 422 path; #[field(...)] rule violation */,
    Some("https://ferra.rs/errors/conflict")           => /* 409 path */,
    Some("https://ferra.rs/errors/payload_too_large")  => /* 413 path */,
    Some("https://ferra.rs/errors/rate_limited")       => /* 429 path; check Retry-After */,
    Some("https://ferra.rs/errors/unauthorized")       => /* 401 path */,
    Some("https://ferra.rs/errors/internal")           => /* 500 path; retry with backoff */,
    _ => /* unreachable on a non-fabricated error */,
}

Server-side, when handling a FerraError value (for example, when mapping to a custom log line or a metrics tag), use the typed FerraError::error_type method:

use ferra::FerraError;

fn log_one(err: &FerraError) {
    tracing::warn!(error_type = err.error_type(), "request failed");
}

FerraError::error_type returns one of the five URIs that map through the typed enum (not_found, validation, validation_failed, conflict, internal). The other three URIs reach the wire via middleware layers and are never observed as FerraError values.


OpenAPI schema constraint

The OpenAPI spec the framework emits at /docs/openapi.json constrains ProblemDetails.type to the closed ERROR_TYPES set as an enum. SDK generators (orval, openapi-generator, kiota, …) produce a typed union over the eight URIs rather than a free-form string — consumers in TypeScript, Go, Python, etc. branch exhaustively on the wire type value.

For an AI coding assistant generating client code: the type field is an enum, not a free-form string. Synthesising a value outside the eight URIs above fails OpenAPI schema validation against the spec.


Observability events

Two structured tracing events fire alongside the wire surface of the error namespace and the deprecation surface. Both follow the <snake-cased crate name>::<event segment> target convention so an operator can filter them independently via tracing-subscriber’s EnvFilter (RUST_LOG=ferra_http::sunset=warn, etc.).

TargetLevelTriggerDocumented at
ferra_http::rate_limitWARNevery 429 emission from the governor middlewarethis file § “rate_limited429
ferra_http::sunsetWARNonce per (version, sunset) per process when a request lands past the declared sunset datefoundry.md § “Post-sunset warn-event”

The post-sunset event carries five fields (version, sunset, days_overdue, http.method, http.target); the rate-limit event carries three (http.method, http.target, http.retry_after_seconds). Both event-schemas are contracts under ADR-0015 §“event schema is a contract” — additive widenings only, never renames.

When the observability Cargo feature is on, the framework ALSO advances a metrics::counter! named ferra.sunset.post_sunset_hits on every post-sunset hit (regardless of the once-per-process dedup that gates the warn-event). See foundry.md § “Post-sunset warn-event” for the full surface.


Adding a new URI

The framework’s URI namespace is closed by design: a new variant requires a coordinated change across three surfaces.

  1. Add the URI literal to ERROR_TYPES in crates/ferra-core/src/error.rs.
  2. Extend the OpenAPI emitter so ProblemDetails.type carries the new value in its enum constraint (the emitter reads ERROR_TYPES directly; this step is automatic once step 1 lands).
  3. Publish at least one HTML anchor on https://ferra.rs/errors/<variant> returning HTTP 200, so that AI agents and human readers reach a stable documentation page for the new variant.

The release-blocking CI gates verify (1) source-side closure (error_types_closed.sh) and (3) network-side resolution (the URI probe). New variants without all three gates green fail the build.

The closed-namespace contract is recorded in ADR-0026. The contract is not load-bearing for consumer correctness — the URI table above is self-contained.