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’s DEFAULT 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() and clock_timestamp() need registering too. uuid() and now() are the portable spellings.
What check refuses:
  • An unregistered function. This includes the Prisma spellings and cs_cuid(): use cuid().
  • 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 an Int.
  • The bare word on a field of the matching type, such as @default(now) on a DateTime.
  • auth() inside a database expression, such as @default(lower(auth().email)).
Adding a column with an expression default on SQLite. SQLite refuses 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:
What check refuses:
  • A value of another type, such as @default(abc) on an Int.
  • 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.
A double-quoted string used to reach the migration as 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:
  1. A procedure output never carries the field. A procedure whose output is, or contains, a model with a @computed field used to send that model’s @server_only fields, over REST and RPC (/rpc/batch included), 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 a type that embeds the model. Model get and list responses, ?fields=, ?include=, MCP resources and @@subscribe events never sent it.
  2. 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_only value (?secret=HUNTER2), rebuild it one character at a time (?secret__startsWith=H), or order rows by it (?sort=secret), through every operator, the where= and or= grammars, relation paths (?owner.secret=, ?pets.some.token=, ?sort=owner.secret), RPC model.<Model>.list, and a FindMany<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, a FindMany where key that is ignored, and a FindMany sort field that does not decode. includeFields[<relation>] naming a @server_only field is refused too, as ?fields= already was.
The generated <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.
It accepts one action per declaration, from the same vocabulary @@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 NotFound a genuinely unknown op id gets, including per-frame inside POST /rpc/batch (a suppressed op in one frame does not poison sibling frames).
The canonical case this unblocks is a model whose policy is fail-closed and correct but whose route could only ever 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 / @@deny rules still compile and still gate in-process callers, so a custom procedure calling db.create() directly is still policy-checked exactly as before.
  • Client input types follow. Create<Model>Input / Update<Model>Input are 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-wiremock omits mappings for suppressed actions, so a mock never advertises a contract the real server doesn’t honour.
  • Breaking, opt-in per action. Adding @@internal to an action a generated client already calls removes that client method — a compile error at the call site on regeneration rather than a runtime 403 discovered later. cratestack diff classifies that as Breaking. A model with no @@internal attribute 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

Field-level @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:
Note the predicate is written in SQL, against column names, not schema field names — it is passed through untouched. 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:
The macro applies them in this evaluation order:
  1. exclusion from inputs (@id, @readonly, @server_only, @version, @default(...))
  2. validation on whatever survives (@length, @range, @regex, @email, @uri, @iso4217)
  3. policy evaluation (model-level @@allow / @@deny)
  4. SQL execution
  5. response projection (server_only stripped here)
  6. audit snapshot (pii / sensitive redacted here)

Spelling and placement

Since 0.14.1. The rules in this section shipped in 0.14.1 (framework CHANGELOG, ## 0.14.1: “Security: policy attributes the generator skipped are refused (GHSA-69g4-xvcm-vm2j)” and “@server_only is refused where it has no effect, and attributes in spellings no generator reads”). Both are breaking, and 0.14.1 is a patch release, so cargo update from 0.14.0 brings them in: run cratestack check on your schemas before you take it. Through 0.14.0 most spellings refused below pass cratestack check with schema OK and have no effect.
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:
  1. An attribute named inside a comment no longer applies. Through 0.14.0, secret String // was @server_only kept secret off 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), @id and every other field attribute. Write the attribute outside the comment before upgrading if the field relied on it.
  2. 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 @@allow grants 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
Any other @@ 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.

Invisible characters

An invisible character is refused anywhere in attribute text, strings and SQL bodies included: zero-width spaces and joiners, the byte-order mark, bidirectional text controls, non-whitespace control characters and similar code points. See the auth support matrix for why, and for the one place variation selectors are allowed.