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

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