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:
#![allow(unused)]
fn main() {
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:
DeriveEntityModelis the Sea-ORM half. It owns the#[sea_orm(...)]namespace, generates theEntity,Column,PrimaryKey,Relation, andActiveModeltypes, and is required on everyFerraModelstruct.FerraModelis the Ferra half. It reads#[sea_orm(primary_key)]and#[sea_orm(table_name)], plus the#[ferra(...)]namespace, and emits a staticModelMetathat every downstream Ferra crate reads.Serialize+Deserializeare required by theFerraModelsupertrait bound.ferra-forgedoes not auto-inject them — see the next section.- Visibility must be at least
pub(crate). Private structs trip class 8 of the Troubleshooting table.
Field types recognised in 0.2.0: String, i32, i64, f64,
bool, Uuid (or ferra_core::id::Id), and Option<T> for the
first five. Option<Uuid> and Option<Id> are not accepted —
see class 4.
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.
Two attribute namespaces, two policies
ferra-forge reads two attribute namespaces with deliberately
different handling (ADR-0006):
| Namespace | Owner | Unknown-key policy |
|---|---|---|
#[sea_orm(...)] | Sea-ORM | silently ignored by ferra-forge |
#[ferra(...)] | Ferra | hard compile_error! |
The asymmetry is load-bearing. Consider the worked example:
#![allow(unused)]
fn main() {
// 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
| Key | Value | Semantics |
|---|---|---|
resource | non-empty string literal | Override 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).
Field-level
| Flag | readable | writable | skip_api |
|---|---|---|---|
| (none) | true | true | false |
#[ferra(read_only)] | true | false | false |
#[ferra(write_only)] | false | true | false |
#[ferra(skip)] | false | false | true |
#[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.
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.
| Key | Planned phase | Intended use |
|---|---|---|
hypermedia | 0.3.0 Casting | Control HAL _links emission per field. |
projection / projections | 0.4.0 Refining | Typed projections (ServerFields<M>). |
exposure | 0.4.0 Refining | Exposure::Strict / Exposure::Loose. |
sortable / filterable / searchable | 0.4.0+ | Query DSL primitives. |
computed | 0.5.0+ | Computed fields with a Default bound. |
cascade | 0.6.0+ | Relation cascade rules. |
auth | 0.8.5 | Per-model auth scopes. |
rate_limit | 0.8.5+ | Tower rate-limit layer binding. |
required | — | Never recognised — nullability is deduced from Option<T>. |
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, ahelp:line, and anote:line — becauseferra-forgebuilds them as a singlesyn::Erroron stable Rust. Thehelpandnoteappear as indented continuation lines of the error body rather than as separate= help:/= note:sub-lines (that shape requiresproc_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 — Option<Uuid> rejection
error: Option<Uuid> is not supported in Ferra 0.2.0
help: remove the `Option<...>` wrapper — UUID identifiers are always required in this phase
note: see crates/ferra-core/src/meta.rs for the FieldType gate
Fix. Use Uuid (non-optional) or model the nullable identifier
at the database layer and project a non-null field into the Ferra
model.
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.
- :sea_orm::ModelTrait>()
bound assertion; a failure there surfaces aserror[E0277]: the trait bound<YourModel>: sea_orm::ModelTraitis not satisfied. All three paths reduce to the same fix: addDeriveEntityModelto 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.