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

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.