Field Attributes
This reference covers every supported field-level attribute. Model-level (@@) attributes live in their dedicated guides — see
audit log for @@audit,
soft delete for @@soft_delete,
pagination for @@paged,
auth support matrix for @@allow / @@deny, and
composite keys for @@id([...]) / @@unique([...]).
Two model-level attributes are documented here because they have no
dedicated guide: @@internal(...) (below) and @@unique([...])’s
emitted DDL.
Identity & Defaults
Auth-defaulted columns are limited to
String/Cuid, Int, and
Boolean and act as fallbacks: they fill the field only when the
create input omits it. They are not enforcement.
“Exactly one @id” is not actually enforced for field-level @id.
The parser only checks that a model has at least one — nothing rejects
two (or more) fields each carrying a bare @id. That’s a different gap
from @@id([...]), which the parser and cratestack-macros do reject
outright (see composite keys): two field-level @id
attributes silently bypass that guard, because it only looks for the
@@id( model-level attribute, not a duplicate field-level one.
cratestack-migrate then marks every @id-tagged column
primary_key = true and joins all of them into one multi-column
PRIMARY KEY constraint — an accidental composite key with none of the
authoring safeguards @@id([...]) gets. Stick to exactly one @id
field per model; don’t rely on the parser to catch a second one for you.
Tracked as issue #536.
Relations
<Action> is one of Cascade, Restrict, SetNull, SetDefault, NoAction — bareword identifiers, not string literals.
onDelete/onUpdate can only be declared on the relation’s owning
side (the field typed as a single model, not Model[]) — the has-many
(List-typed) side has no physical column to attach a constraint to, and
cratestack check rejects the attempt. SetNull additionally requires
the local field to be optional (tenantId String?); SetDefault
requires it to declare @default(...).
See ADR 0004 for the generated DDL and
the SQLite limitation, and Migrations
for the generated DDL and naming convention.
Exposure controls
Use
@readonly for columns the server writes but clients may read (audit
timestamps, computed totals). Use @server_only for columns clients
should never see (internal risk scores, raw token blobs). Use @pii or
@sensitive to control audit redaction without changing input/output
surfaces.
Route suppression
@@internal("action") is a model-level declaration that an action must
never be reachable from the wire: no REST route, no RPC dispatch arm,
and no client stub in any generated SDK, on either transport.
@@allow / @@deny use — so there is no second action vocabulary to
learn:
Exactly one action per declaration.
@@internal("create", "update")
is a compile error. Suppressing more than one action means writing more
than one @@internal("action") line — the same repeated-declaration
shape @@allow / @@deny already use. An action name outside the table
above is also a compile error, naming the model and the bad action.
What suppression actually does
Suppression is implemented as emitting nothing, so the observable behaviour is whatever axum does with a route that was never registered:- A suppressed verb on a path that still has surviving verbs gets axum’s
own
405 Method Not Allowed. - A model that suppresses every verb on a path never registers that path
at all — axum’s own
404. - A suppressed RPC op id falls into the pre-existing unknown-op-id arm
and returns the same
NotFounda genuinely unknown op id gets, including per-frame insidePOST /rpc/batch(a suppressed op in one frame does not poison sibling frames).
403 — @@allow("create", auth().isSystem()) still generated a POST route and a .create()
client method. @@internal("create") removes both.
Scope and limits
- Generation-time only. Policy evaluation is untouched: a suppressed
action’s
@@allow/@@denyrules still compile and still gate in-process callers, so a custom procedure callingdb.create()directly is still policy-checked exactly as before. - Client input types follow.
Create<Model>Input/Update<Model>Inputare omitted from generated client SDKs when the corresponding verb is suppressed. The server’s own ORM-facing input types are unaffected. - Mock stubs follow.
generate-wiremockomits mappings for suppressed actions, so a mock never advertises a contract the real server doesn’t honour. - Breaking, opt-in per action. Adding
@@internalto an action a generated client already calls removes that client method — a compile error at the call site on regeneration rather than a runtime403discovered later.cratestack diffclassifies that as Breaking. A model with no@@internalattribute generates byte-identical output to before the feature existed.
Optimistic locking
See optimistic locking for the full
contract.
The macro excludes
@version from both Create and Update inputs. The
runtime seeds it to 0 on create and bumps it in the same statement as
every update or soft-delete.
Model-level uniqueness and indexes
@unique (a single-column shorthand) is unaffected by this. See
Migrations
for the emitted DDL, and Upsert for why a matching
unique index is required for ON CONFLICT targets.
Keyword arguments
Both attributes accept keyword arguments after the field list. Every one of them is verbatim passthrough — the value is never parsed or validated by CrateStack, only carried through to the emitted DDL and left for the database to accept or reject.
Passing an unsupported key is a compile error, as is declaring the same
key twice.
Partial indexes
where: constrains the index to the rows matching a predicate:
where: is the one case where a single-field @@unique is legal.
Without it, @@unique([x]) is rejected with “use a field-level @unique
instead”, because the shorthand exists and is simpler. With where: that
alternative disappears — a field-level @unique has nowhere to put a
keyword argument — so the floor drops from two fields to one. It never
drops to zero: @@unique([], where: "...") is still rejected, matching
@@index’s unconditional at-least-one-field rule.
The example above is the motivating shape: a genuinely optional column
that must be unique only when present, with the predicate keeping the
index off the rows where the column is NULL.
SQLite supports the same WHERE syntax (partial indexes since 3.8.0). The
divergence between backends is what a predicate may legally reference,
not the syntax.
Partial indexes round-trip through
cratestack migrate without churn.
Postgres normalizes a stored predicate — idempotency_key IS NOT NULL
reads back as (idempotency_key IS NOT NULL), and literal comparisons
gain an explicit cast (status = 'active'::text) — so the diff engine
compares predicates through a type-aware normalization rather than by raw
string equality. Writing the predicate in a different but equivalent
spelling than Postgres would store may still produce one drop-and-recreate;
ambiguous cases deliberately fail toward recreating the index rather than
toward silently leaving a stale one in place.Validators
See validators for the full surface, including
the PII-safe error message contract.
Type modifiers
Lists are supported only for a subset of scalars in the current slice;
banks running JSON columns prefer
@db.JsonB on a String for richer
payloads.
Composition
Multiple attributes on one field are space-separated and additive:- exclusion from inputs (
@id,@readonly,@server_only,@version,@default(...)) - validation on whatever survives (
@length,@range,@regex,@email,@uri,@iso4217) - policy evaluation (model-level
@@allow/@@deny) - SQL execution
- response projection (server_only stripped here)
- audit snapshot (pii / sensitive redacted here)