Attributes
The attribute primitives available in standards and their chainable modifiers.
Each attribute is a typed field on an object. You build it from a primitive function — text, relation, status, and so on — then chain modifiers before passing it to .attribute().
// @noverify
import { object, text, number, select, relation } from "@stndrds/schema";
const DEAL = object({ name: "deals", label: "Deal" })
.labelExpression("{{ name }}")
.attribute(text({ name: "name", label: "Name" }).required())
.attribute(number({ name: "amount", label: "Amount" }))
.attribute(
select({ name: "currency", label: "Currency" })
.options([
{ label: "EUR", value: "EUR", color: "blue" },
{ label: "USD", value: "USD", color: "green" },
])
)
.attribute(relation({ name: "company", label: "Company" }).to("companies").many());Breaking in vNEXT: builders now default to system: true. The .system() modifier has been removed — anything declared in code is automatically system. Call .runtime() only inside tests, seeds, and fixtures to opt out.
Breaking in vNEXT: the attribute model has been consolidated around a canonical capability layer. Several legacy types and modifiers were removed without aliases — migrate as follows:
textarea()→text({ ... }).multiline()rating()→number({ ... }).renderAs("rating").max(5)user.allowedRoles(roles)→user({ ... }).types(["user", "agent"])(default is both)relation.minItems(n)removed — only.maxItems(n)is supporteddocument.maxDocuments(n),.autoProcess(), and.attribute(...)removed — use.slots([...])and.qualifyWith(...)for propertieslocation.enableAutocomplete(),.enableMap(),.displayFormat(...)removed — these are UI/view concerns now.disabled()removed — set read-only on the view or form field, not on the schema attribute.featureGate(...)removed entirely- Option objects no longer accept
idoricon— use{ value, label, color?, description?, group?, inverse? }
Primitives
| Primitive | Use case | Example |
|---|---|---|
text | Single-line or multi-line strings | text({ name: "notes", label: "Notes" }).multiline() |
richtext | Rich text editor (blocks) | richtext({ name: "body", label: "Body" }) |
number | Numeric values, including rating UI | number({ name: "score", label: "Score" }).renderAs("rating").max(5) |
checkbox | Booleans | checkbox({ name: "active", label: "Active" }) |
date | Dates and timestamps | date({ name: "closedAt", label: "Closed" }) |
phone | E.164 phone numbers | phone({ name: "phone", label: "Phone" }) |
currency | Monetary amounts | currency({ name: "price", label: "Price" }) |
status | Workflow status with groups | status({ name: "stage", label: "Stage" }).options([...]) |
select | One value from a fixed set | select({ name: "kind", label: "Kind" }).options([...]) |
multiselect | Many values from a fixed set | multiselect({ name: "tags", label: "Tags" }).options([...]) |
location | Address / coordinates | location({ name: "address", label: "Address" }) |
file | File attachments | file({ name: "avatar", label: "Avatar" }) |
user | Reference to an actor | user({ name: "ownedBy", label: "Owner" }) |
relation | Foreign-key edge to another object | relation({ name: "company", label: "Company" }).to("companies").many() |
document | Document reference | document({ name: "contract", label: "Contract" }) |
formula | Computed read-only value | formula({ name: "fullName", label: "Full name" }) |
rollup | Aggregate across a relation | rollup({ name: "totalAmount", label: "Total" }) |
formula and rollup are now backed by the v1 computed-fields engine. The runtime compiles each expression into a ComputedPlan, persists evaluation state in computed_attribute_states (status: pending | fresh | stale | failed), and recomputes affected records via a background job when dependencies change. The recomputed value reaches the client through the realtime channel as a live patch — the client no longer polls for pending state or renders a per-cell pending/stale indicator.
Formula expressions are now validated semantically when you create or update an attribute, not just at boot. A reference to an attribute that doesn't exist (yet) comes back as a non-blocking warnings entry on the save response — the save still proceeds. A return type that doesn't match the formula's inferred type is rejected outright.
Common chainable modifiers
| Modifier | Effect |
|---|---|
.required() | Marks the attribute as required; validates presence on write |
.optional() | Explicitly marks the attribute as nullable (default for most primitives) |
.runtime() | Opt out of the default system: true (advanced — tests, seeds, fixtures only) |
.icon(name) | Attaches a Lucide icon to the attribute in the UI |
.description(s) | Admin tooltip shown in the attribute editor |
.autofill(config) | Enable AI autofill for this field (see callout below) |
.required() is a schema-level rule enforced by the runtime validator. Your database adapter must separately configure NOT NULL constraints to guarantee hard enforcement at the storage layer.
AI autofill
.autofill(config) lets the runtime populate a field from attached-document OCR content and other attributes on the same record. The from array is mandatory: an empty array makes the field document-only (filled exclusively from attached document content), while a non-empty array names sibling attributes whose changes also trigger a contextual fill. An optional instructions string is injected verbatim into the generation prompt.
// @noverify
import { object, text, select } from "@stndrds/schema";
const INVOICE = object({ name: "invoices", label: "Invoice" })
.attribute(
select({ name: "docType", label: "Type" })
.options([{ value: "invoice", label: "Invoice" }])
// document-only: filled from the attached file's OCR content
.autofill({ from: [] })
)
.attribute(
text({ name: "region", label: "Region" })
// contextual: re-filled when city or country changes
.autofill({ from: ["city", "country"], instructions: "deduce from the address" })
);Autofill is eligible only for text, number, checkbox, date, phone, currency, status, select, and multiselect attributes — eligibility is validated at schema sync, so an .autofill() on an unsupported type fails fast at boot. Fills run automatically through the generation pipeline (idempotent and usage-tracked) when a document is attached or a source attribute changes; the field is never overwritten if it already holds a user-entered value. To fill pre-existing records, the backend exposes POST /autofill/backfill (dry-run estimate by default, or enqueue a backfill job with confirm).