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:

#![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:

  • 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.

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):

NamespaceOwnerUnknown-key policy
#[sea_orm(...)]Sea-ORMsilently ignored by ferra-forge
#[ferra(...)]Ferrahard 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

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).

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.

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.
computed0.5.0+Computed fields with a Default bound.
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>.

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 — 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.

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.