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.
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)