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
dbgenerated() is spelled exactly that way, takes no argument and must be the whole default: cratestack check refuses
@default(dbgenerated("x")) and @default(dbgenerated() || 'x'), because the migration
emitter writes no DEFAULT clause for the marker and anything combined with it would be
dropped. (The trailing-text refusal is new in 0.16.0,
cratestack#1167.)
@default(autoincrement()) is refused (since 0.16.0,
cratestack#1128), in any letter case and as
the bare word @default(autoincrement). Neither Postgres nor SQLite
has an autoincrement() function, so the DEFAULT autoincrement() clause it used to produce
failed when the migration was applied. For a sequence or identity column, write
@default(dbgenerated()) and create the sequence or identity yourself in hand-authored migration
SQL: cratestack emits neither, and without one an insert that omits the column fails with a
NOT NULL violation.
Default functions
(Since 0.17.0, cratestack#1209.) A function-shaped default ends up in the migration’sDEFAULT clause, so cratestack check only accepts functions it knows the database can run.
cratestack’s own. The runtime generates these values in Rust when the create input omits the field, on the server and the embedded runtime alike. Migrations still carry the database’s equivalent, for writes that bypass cratestack:
cuid() writes from outside cratestack. cuid() has no database equivalent, so a writer that bypasses cratestack must supply the value.
Your database’s functions. Register the function on the datasource:
- Registering is an assertion, like
dbgenerated(). cratestack writes the call verbatim, and Postgres refuses the migration if the function does not exist. Create it in an earlier migration. - SQLite: the call is written in parentheses, as SQLite requires.
- Database built-ins:
gen_random_uuid()andclock_timestamp()need registering too.uuid()andnow()are the portable spellings.
check refuses:
- An unregistered function. This includes the Prisma spellings and
cs_cuid(): usecuid(). - A builtin spelled differently, or with an argument:
UUID(),uuid(4),now() + 1. - A builtin on a field its value does not fit, such as
uuid()on anInt. - The bare word on a field of the matching type, such as
@default(now)on aDateTime. auth()inside a database expression, such as@default(lower(auth().email)).
ADD COLUMN with an expression default once the table has rows. The SQLite migration flags each such column; rebuild the table there.
Literal defaults
(Since 0.17.0, cratestack#1234.)cratestack check reads a literal for its field’s type and refuses one that does not fit:
check refuses:
- A value of another type, such as
@default(abc)on anInt. - An unquoted word on a text field, such as
@default(draft). Write@default("draft"). - An SQL expression, such as
@default(CURRENT_TIMESTAMP). For the current time, write@default(now()). null. An optional field is already NULL when the create input omits it.- Any literal on
Json,Bytes, a list or a composite type.
DEFAULT "draft", which Postgres reads as a column name, so the migration failed. It is now written DEFAULT 'draft', and a snapshot from an older cratestack reads back the same way, so upgrading produces no spurious migration.
Defaults on the embedded runtime
(Since 0.17.0, cratestack#1234.)include_embedded_schema! tables come from create_table_sql, not from migrations, and CREATE TABLE IF NOT EXISTS never changes a table an older app version created. So the embedded runtime handles each default itself:
A required
dbgenerated() or registered-function column that comes back NULL is refused with RusqliteError::UnfilledDefault, and the insert is rolled back. In a batch, the item fails with VALIDATION_ERROR.
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 field-level @id per model. A second one is refused
(“model … declares more than one field-level @id”), so an accidental
composite key can’t slip through; use @@id([...]) when
you mean one. It used to be accepted and silently joined into one multi-column
PRIMARY KEY (issue #536).
@id is matched exactly (since 0.14.0,
cratestack#1074). Only
a bare @id makes a field the primary key. @identity, @idx and @id_foo
are not keys: a model keyed only by one of them reports a missing @id, and
@idx gets a “did you mean @id?” error. @id(...) is refused (“@id takes
no arguments — write @id”). Before 0.14.0 any attribute starting with @id
counted as the key everywhere except cratestack-migrate, so the two could
disagree.
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). @server_only
applies only to a stored scalar column of a model; see
where it is refused (since 0.14.1). Use @pii or
@sensitive to control audit redaction without changing input/output
surfaces.
A procedure argument can name a model directly (procedure p(account: Account))
or through a type that embeds one. A @server_only field in that argument is
never read from the request: the implementation always sees the field’s default,
whatever the client sent, on both transports. Before 0.13.0 (cratestack#1051)
the field was only skipped on output, so a client could set it this way. If a
procedure took a @server_only value from its argument, treat that value as
client-controlled and derive it on the server instead.
Since 0.13.0 (GHSA-ch54-jqw2-vpp5),
two more paths hold:
- A procedure output never carries the field. A procedure whose output is, or contains, a
model with a
@computedfield used to send that model’s@server_onlyfields, over REST and RPC (/rpc/batchincluded), from v0.8.11 through v0.12.0. The output was composed field by field, so the serde skip never ran. This covered the model itself,T?,T[],Page<T>, and atypethat embeds the model. Model get and list responses,?fields=,?include=, MCP resources and@@subscribeevents never sent it. - A request cannot filter or sort by the field. From v0.2.0 through v0.12.0 any caller who
could list a model could test a
@server_onlyvalue (?secret=HUNTER2), rebuild it one character at a time (?secret__startsWith=H), or order rows by it (?sort=secret), through every operator, thewhere=andor=grammars, relation paths (?owner.secret=,?pets.some.token=,?sort=owner.secret), RPCmodel.<Model>.list, and aFindMany<Model>procedure argument. Such a key is now refused exactly like a field the model does not declare, so the refusal does not reveal that the field exists:400 unsupported query filter,422 unsupported sort field, aFindManywherekey that is ignored, and aFindManysort field that does not decode.includeFields[<relation>]naming a@server_onlyfield is refused too, as?fields=already was.
<Model>Where and <Model>SortField have no member for a @server_only field, in
Rust (server and client role) and in the Dart client; the TypeScript client never had one. Server
code that needs to filter or sort by the field, such as a lookup by a hashed token, keeps the typed
builders, which read no request: <model>::<field>().eq(..) and
.order_by(<model>::<field>().asc()).
If you ran an affected version, treat as disclosed every @server_only value reachable through
either path, and rotate credentials, tokens and hashes stored in such fields. The generated Dart
model class declares and decodes @server_only fields, so a value an affected server sent may also
sit in client-side state or logs. A response
IdempotencyLayer stored before the upgrade is replayed as stored until its record expires; clear
the idempotency store or wait out its TTL. The advisory has the full guidance.
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)
Spelling and placement
Generators recognise most attributes by their exact text, so an attribute written any other way used to be skipped silently. A schema is now refused, with an error that names the attribute, when it relies on such a spelling.Trailing comments
A trailing// comment on a field line or a @@ line is a comment, and is dropped before
anything reads the attribute. // inside a string literal ("http://…", SQL in "…" or
"""…""") is not a comment. Two consequences, neither of which raises an error:
- An attribute named inside a comment no longer applies. Through 0.14.0,
secret String // was @server_onlykeptsecretoff the wire; from this change it is an ordinary field, returned to clients. The same holds for@readonly(the field becomes writable),@pii/@sensitive(no longer redacted in the audit log),@idand every other field attribute. Write the attribute outside the comment before upgrading if the field relied on it. - An attribute followed by a comment now takes effect.
@@audit // …starts writing audit rows,@@soft_delete // …makes deletes soft, and@@allow(…) // …grants what it says, where through 0.14.0 each was skipped. Confirm each such@@allowgrants what you intend.
Attributes that take no arguments
@server_only, @readonly, @version, @pii, @sensitive, @db_enforce, @email, @uri,
@iso4217 and @unique are written bare (@id(...) is refused since 0.14.0). An argument list or stray punctuation
(@readonly(), @server_only(true), @version(), @readonly,) is refused: through 0.14.0,
@readonly() left the field settable through the generated create input and @version() left
the model without a version field. Two attributes need a space between them:
@server_only@unique is refused. An @ inside a string argument (@default("a@b.c")) is not
affected. A longer name such as @unique_per_tenant is a different attribute.
Where server-only is refused
@server_only keeps a stored scalar column of a model out of every generated input and output.
It is a schema error in the five positions where it did nothing:
Block attributes are a closed list
Only models and views take@@ attributes, each from its own list, spelled exactly, with an
argument list exactly when the name takes one and nothing after the closing ):
- model:
@@allow,@@deny,@@emit,@@paged,@@audit,@@soft_delete,@@retain,@@subscribe,@@id,@@unique,@@index,@@internal,@@rename(and@@mcp) - view: see Views
@@ name is refused, with a suggestion when one is close: a typo, a Prisma habit such
as @@map(…), @@check(…) (never implemented), or a view-only attribute on a model. So are
whitespace before (, an empty argument list, and two block attributes on one line
(@@audit @@soft_delete). The @@allow / @@deny spelling rules are in the
auth support matrix.
Rename markers
@@rename on a model takes exactly @@rename(from = "<old_table>"), and a field’s @rename
exactly @rename(from = "<old_column>"): the only forms cratestack migrate reads. Through
0.14.0 any other form (@@rename(from: "documents"), @rename(from: "name")) passed cratestack check
and was read as no marker, so the next migration dropped the old table or column and created
a new one instead of renaming it. Other forms are now refused, and so are a second marker on the
same model or field and a @rename on a field of a view, type or auth block or on a
relation field, where the migrator never read it. If you ran migrate diff with a refused
marker, check the generated migrations for a DROP TABLE or DROP COLUMN of the old name. See
Migrations.