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-NNNerror 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
| Code | Trigger (one-line) | Section |
|---|---|---|
FRG-201 | #[field(min_length = …)] value is not a non-negative integer literal | link |
FRG-202 | #[field(max_length = …)] value is not a non-negative integer literal | link |
FRG-203 | #[field(min = …)] value type does not match the field’s numeric type | link |
FRG-204 | #[field(max = …)] value type does not match the field’s numeric type | link |
FRG-205 | #[field(pattern = …)] value is not a non-empty string literal | link |
FRG-206 | The same #[field(...)] rule key appears twice on the same field | link |
FRG-207 | min_length / max_length rule applied to a non-string field | link |
FRG-208 | min / max rule applied to a non-numeric field | link |
FRG-209 | pattern / email / url rule applied to a non-string field | link |
FRG-210 | min_length exceeds max_length on the same field | link |
FRG-211 | min exceeds max on the same field | link |
FRG-212 | #[field(required)] applied to a non-Option<T> field | link |
FRG-213 | Unknown #[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-215 | Option<Uuid> (or Option<Id>) tagged #[sea_orm(primary_key)] | link |
FRG-301 | A projection’s read = [...] or write = [...] ident does not match any struct field | link |
FRG-302 | #[ferra(write_only)] and #[ferra(read_only)] on the same field | link |
FRG-303 | #[ferra(write_only)] and #[ferra(computed)] on the same field | link |
FRG-304 | #[ferra(write_only)] on a field that carries #[sea_orm(primary_key)] | link |
FRG-305 | A #[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 field | link |
FRG-307 | Legacy #[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-310 | Two projections produce the same URL prefix (duplicate path_prefix or two default = true) | link |
FRG-311 | A projection name does not normalise to a URL-safe segment, and no path_prefix override | link |
FRG-312 | A projection’s auto-derived URL prefix collides with an existing resource route (runtime) | link |
FRG-313 | intentionally unallocated — see § FRG-3NN | |
FRG-314 | An explicit write = [...] list omits a #[ferra(write_only)] field on the model | link |
FRG-315 | path_prefix declared on a sub-resource (runtime) | link |
FRG-316 | default = true without promotes_from + breaking_change_version attestations | link |
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.stderrsnapshot 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 rules
— min_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 inferra-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 trait | Wrapped language trait | Consumed by |
|---|---|---|
FerraComputedDefault | Default (blanket-impl’d for T: Default) | ferra-forge’s compute_emit::emit_computed_bounds, once per #[ferra(computed)] field. |
FerraSend | Send (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). |
FerraSync | Sync (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. |
FerraAuthConfigured | none (placeholder) at 0.6.5 | Forward-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:
- Recognise the framing. The
messageline names a Ferra attribute or generation context (required for fields marked \#[ferra(…)]`,this model needs explicit authorization configuration`, …) rather than a bare language trait. - Read the
note:line. It names the concrete fix — a missing#[derive(...)], a missing impl, or a missing attribute on the model. - Apply the fix at the named site. The diagnostic’s
--> path:LINE:COLanchor 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 literalpath_prefix = "...", so both route to the same URL. - Double
default = true. Two projections each carrydefault = true; both target the bare resource path (/{resource}/{id}) and therefore collide. The seconddefault = trueis 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