| title | Object schema — Data Protocol reference |
|---|---|
| navTitle | Object |
| description | Object schemas of the ObjectStack Data Protocol: ApiMethod, ApiOperation, Index and 13 more — each property with its type, default and a TypeScript example. |
{/*
import { ApiMethod, ApiOperationSchema, IndexSchema, LifecycleSchema, LifecycleClassSchema, ObjectSchema, ObjectAccessConfigSchema, ObjectCapabilities, ObjectExtensionSchema, ObjectExternalBindingSchema, ObjectFieldGroupSchema, ObjectOwnershipEnum, ObjectRequiredPermissionsSchema, PerOperationRequiredPermissionsSchema, RowCrudActionOverrideSchema, TenancyConfigSchema } from '@objectstack/spec/data';
import type { ApiMethod, ApiOperation, Lifecycle, LifecycleClass, ObjectAccessConfig, ObjectCapabilities, ObjectExtension, ObjectExternalBinding, ObjectFieldGroup, ObjectRequiredPermissions, PerOperationRequiredPermissions, RowCrudActionOverride, TenancyConfig } from '@objectstack/spec/data';
// Validate data
const result = ApiMethod.parse(data);getlistcreateupdatedeletebulk
getlistcreateupdatedeleteupsertbulkaggregatehistorysearchrestorepurgeimportexport
| Property | Type | Required | Description |
|---|---|---|---|
| name | string |
optional | Index name (auto-generated if not provided) |
| fields | string[] |
✅ | Fields included in the index |
| unique | false | 'global' | 'organization' |
optional (default: false) |
Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly fields, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, 'global')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' |
| Property | Type | Required | Description |
|---|---|---|---|
| class | Enum<'record' | 'audit' | 'telemetry' | 'transient' | 'event'> |
✅ | Persistence contract: record (business truth, permanent) | audit (compliance ledger) | telemetry (high-freq log) | transient (ephemeral state) | event (bus messages). |
| retention | { maxAge: string; onlyWhen?: Record<string, string | number | boolean | object | object> } |
optional | Age-based retention window enforced by the LifecycleService Reaper. |
| ttl | { field: string; expireAfter: string; onlyWhen?: Record<string, string | number | boolean | object | object> } |
optional | Per-row TTL auto-expiry (transient/event classes). |
| storage | { strategy: 'rotation'; shards: integer; unit: Enum<'day' | 'week' | 'month'> } |
optional | Physical storage strategy for high-frequency telemetry (LifecycleService Rotator). |
| archive | { after: string; to: string; keep?: string } |
optional | Cold-store archival (LifecycleService Archiver) — audit-class hot→cold hand-off. |
| reclaim | boolean |
optional | Run driver space reclamation (SQLite incremental_vacuum) after sweeping this object. Default true for non-record classes. |
| Property | Type | Required | Description |
|---|---|---|---|
| maxAge | string |
✅ | Rows older than this (by created_at) are deleted by the Reaper — or archived first when archive is set. |
| onlyWhen | Record<string, string | number | boolean | { $in: (string | number)[] } | { $null: boolean }> |
optional | Row filter the retention applies to — per-field equality, {$in: [...]} or the null predicate {$null: true|false} (e.g. { status: { $in: ["completed", "failed"] } }). Rows OUTSIDE the filter are retained regardless of age: for tables that interleave live workflow state with terminal history (sys_automation_run). Incompatible with rotation storage and archive, which act on whole shards / age alone. |
| Property | Type | Required | Description |
|---|---|---|---|
| field | string |
✅ | Timestamp field the TTL is measured from (e.g. created_at, expires_at). |
| expireAfter | string |
✅ | Rows expire this long after field and are deleted by the Reaper. |
| onlyWhen | Record<string, string | number | boolean | { $in: (string | number)[] } | { $null: boolean }> |
optional | Row filter the TTL reap applies to — per-field equality, {$in: [...]} or the null predicate {$null: true|false} (e.g. { revoked_at: { $null: true } }). Rows OUTSIDE the filter are retained regardless of expiry: for tables that interleave live rows with terminal history a TTL keyed on the same timestamp would otherwise destroy (a sys_session audit tombstone backdates expires_at, so a naive TTL reaps tombstones first). Incompatible with rotation storage, which DROPs whole shards, and with archive, which selects rows by the ttl cutoff alone and does not apply this filter. |
| Property | Type | Required | Description |
|---|---|---|---|
| strategy | 'rotation' |
✅ | Time-shard the table. The retained window (shards × unit) is the same on every dialect; the reclamation is not — SQLite DROPs the oldest shard whole (O(1) reclaim), other dialects reap that same window by age from created_at. |
| shards | integer |
✅ | Number of shards retained; total window = shards × unit. |
| unit | Enum<'day' | 'week' | 'month'> |
✅ | Time width of one shard. |
| Property | Type | Required | Description |
|---|---|---|---|
| after | string |
✅ | Rows older than this are copied to the archive datasource before hot deletion. |
| to | string |
✅ | Target datasource name for cold storage. When it is not registered, the Archiver skips (audit rows are then retained, never dropped unarchived). |
| keep | string |
optional | How long archived rows are kept in cold storage (undefined = forever). |
recordaudittelemetrytransientevent
| Property | Type | Required | Description |
|---|---|---|---|
| name | string |
✅ | Machine unique key (snake_case). Immutable. |
| label | string |
optional | Human readable singular label (e.g. "Account") |
| pluralLabel | string |
optional | Human readable plural label (e.g. "Accounts") |
| description | string |
optional | Developer documentation / description |
| icon | string |
optional | Icon name (Lucide/Material) for UI representation |
| isSystem | boolean |
optional (default: false) |
Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing) |
| managedBy | Enum<'platform' | 'config' | 'system-data' | 'engine-owned' | 'append-only' | 'better-auth'> |
optional | Lifecycle bucket — platform (user CRUD) | config (admin authored) | system-data (platform-defined schema, admin/user-writable data) | engine-owned (engine owns the lifecycle, no user writes) | append-only (audit) | better-auth (identity). UI clients honour the resolved affordance matrix. |
| ownership | Enum<'user' | 'business_unit' | 'org' | 'none'> |
optional | Record-ownership model: user (default — injects reassignable owner_id plus owning_business_unit_id) | business_unit (unit-owned: owning_business_unit_id only, no owner_id) | org | none (no per-record owner, neither anchor). Distinct from the package own/extend contribution kind. |
| userActions | { create?: boolean | object; import?: boolean | object; edit?: boolean | object; delete?: boolean | object; … } |
optional | Per-object override of the resolved CRUD affordance matrix. |
| systemFields | false | { tenant?: boolean; audit?: boolean } |
optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
| datasource | string |
optional (default: "default") |
Target Datasource ID. "default" is the primary DB. |
| external | { remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … } |
optional | Remote table binding for federated (external) objects. |
| fields | Record<string, { name?: string; label?: string; type: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; description?: string; … }> |
✅ | Field definitions map. Keys must be snake_case identifiers; "proto", "constructor" and "prototype" are refused. |
| attachedOnRead | Record<string, Record<string, Enum<'number' | 'text' | 'boolean' | 'date'>>> |
optional | Blocks a service attaches to each row it serves, computed per caller on read and never stored: block name → { leaf key → value type (number | text | boolean | date) }. NOT a field — no column, form, list view, export, write path or translation bundle reads it, and a block name may not repeat a declared field name. Its reader is the shared build validator (@objectstack/lint over @objectstack/formula, as os build / os validate run it), and only where record is a row the service served: in an action's visible and disabled predicates record.<block> resolves, and record.<block>.<leaf> resolves only to a leaf the block declares. Every other expression site binds the stored row, which never carries a block — a flow condition, a validation rule, a field rule or formula, an option visibleWhen, a sharing rule, a hook — and refuses record.<block> as an unknown field. |
| indexes | { name?: string; fields: string[]; unique?: false | 'global' | 'organization' }[] |
optional | Database performance indexes |
| fieldGroups | { key: string; label: string; icon?: string; description?: string; … }[] |
optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. |
| tenancy | { enabled: boolean; tenantField?: string } |
optional | Multi-tenancy configuration for SaaS applications |
| access | { default?: Enum<'public' | 'private'> } |
optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). |
| requiredPermissions | string[] | { read?: string[]; create?: string[]; update?: string[]; delete?: string[] } |
optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — string[] gates all CRUD, or a {read,create,update,delete} map gates per operation. |
| lifecycle | { class: Enum<'record' | 'audit' | 'telemetry' | 'transient' | 'event'>; retention?: object; ttl?: object; storage?: object; … } |
optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. |
| fileAccessDelegate | string |
optional | Kernel service that authorizes downloads of files owned by this object's media fields, and decides whether a reader who may not read sys_file sees their name, size and type in a record read of this object (asked once per owning record on each such read), instead of testing whether the caller can read the owning row. For objects whose access is mediated by a service (e.g. sys_approval_action → approvals). Fails closed. |
| validations | any[] |
optional | Object-level validation rules |
| activityMilestones | { field: string; value: string; summary: string; type?: string }[] |
optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). |
| nameField | string |
optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
| displayNameField | string |
optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
| titleFormat | string | { dialect: 'template'; source?: string; ast?: any; meta?: object } |
optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time. |
| imageField | string |
optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is image or avatar; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). |
| highlightFields | string[] |
optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
| relatedLists | string[] |
optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. |
| stageField | string | false |
optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value select / radio with inline options, each declaring its kind ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse. |
| editMode | Enum<'modal' | 'page'> |
optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |
| listViews | Record<string, { name?: string; label?: string | Record<string, string>; type?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>; data?: object | object | object; … }> |
optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
| searchableFields | string[] |
optional | Fields the $search query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual formula field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. |
| enable | { trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' | 'list' | 'create' | 'update' | 'delete' | 'bulk'>[]; … } |
optional | Enabled system features modules |
| sharingModel | Enum<'private' | 'public_read' | 'public_read_write' | 'controlled_by_parent'> |
optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) | public_read (everyone reads, owner writes) | public_read_write (everyone reads+writes) | controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
| sharing | { controlledByParent?: object } |
optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). |
| defaultRowScope | { name: string; label?: string; description?: string; operation: Enum<'select' | 'insert' | 'update' | 'delete' | 'all'>; … } |
optional | The object's default row scope: one row-level security policy, written like a permission set's rowLevelSecurity entry without object (the declaring object is implied; writing object is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object ('*') counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its operation, positions and enabled decide where it applies, and a using the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. os validate, os build and os lint judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. |
| membershipKeys | Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }> |
optional | Membership keys this object defines, by name: each makes current_user.KEY the set of valueField values on this object's rows whose userField is the signed-in user and on which the optional filter holds, for a row-level security policy to read with in (e.g. supplier in current_user.portal_supplier_ids, declared on a supplier-contact object as portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (id, organization_id, positions, org_user_ids, accessible_org_ids, email, queues) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a filter that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. |
| externalSharingModel | never |
optional | [REMOVED] object.externalSharingModel was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. sharingModel is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (publicSharing). Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| publicSharing | { enabled?: boolean; allowedAudiences?: Enum<'public' | 'link_only' | 'signed_in' | 'email'>[]; allowedPermissions?: Enum<'view' | 'comment' | 'edit'>[]; maxExpiryDays?: integer; … } |
optional | Public share-link policy (Notion/Figma-style link sharing) |
| actions | { name: string; label: string | Record<string, string>; description?: string | Record<string, string>; objectName?: string; … }[] |
optional | Actions associated with this object (auto-populated from top-level actions via objectName) |
| protection | { lock: Enum<'none' | 'no-overlay' | 'no-delete' | 'full'>; reason: string; docsUrl?: string } |
optional | Package author protection block — lock policy for this object. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> |
optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string |
optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> |
optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> |
optional | Origin of the item (package | org | env-forced). |
| _packageId | string |
optional | Owning package machine id. |
| _packageVersion | string |
optional | Owning package version. |
| _lockDocsUrl | string |
optional | Optional documentation link surfaced next to _lockReason. |
| Property | Type | Required | Description |
|---|---|---|---|
| create | boolean | { enabled?: boolean; visibleWhen?: string | object; disabledWhen?: string | object } |
optional | Show generic "New" button. Boolean, or an object adding visibleWhen/disabledWhen CEL predicates evaluated once per toolbar against the record in scope (the host record on a related list). |
| import | boolean | { enabled?: boolean; visibleWhen?: string | object; disabledWhen?: string | object } |
optional | Show CSV import wizard entry. Boolean, or an object adding visibleWhen/disabledWhen CEL predicates evaluated once per toolbar against the record in scope (the host record on a related list). |
| edit | boolean | { enabled?: boolean; visibleWhen?: string | object; disabledWhen?: string | object } |
optional | Allow inline / form edit of existing rows. Boolean, or an object adding per-record visibleWhen/disabledWhen CEL predicates. |
| delete | boolean | { enabled?: boolean; visibleWhen?: string | object; disabledWhen?: string | object } |
optional | Show row-level delete + bulk delete. Boolean, or an object adding per-record visibleWhen/disabledWhen CEL predicates. |
| exportCsv | boolean |
optional | Show CSV export entry. |
| Property | Type | Required | Description |
|---|---|---|---|
| tenant | boolean |
optional | Inject the organization_id column. Default true (the column is always provisioned; the multi-tenant flag governs only its index). |
| audit | boolean |
optional | Inject the audit columns (created_at/created_by/updated_at/updated_by). Default true. |
| Property | Type | Required | Description |
|---|---|---|---|
| remoteName | string |
optional | Remote table/view name. Defaults to object.name. |
| remoteSchema | string |
optional | Remote schema/database qualifier. |
| writable | boolean |
optional (default: false) |
Per-object write opt-in (also requires datasource.external.allowWrites). |
| columnMap | Record<string, string> |
optional | Remote column name → local field name. |
| introspectedAt | string |
optional | Set by os datasource introspect; informational. |
| ignoreColumns | string[] |
optional | Remote columns to skip during validation (dev convenience). |
| Property | Type | Required | Description |
|---|---|---|---|
| name | string |
optional | Machine name (snake_case) |
| label | string |
optional | Human readable label |
| type | Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …> |
✅ | Field Data Type |
| description | string |
optional | Tooltip/Help text |
| format | string |
optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an autonumber field it is the record-number PATTERN — the shorthand that predates autonumberFormat, which wins when both are present: format: 'INV-{0000}' mints INV-0001 on both the query engine and the SQL driver, while a value carrying no {...} token is emitted as literal text with the bare counter appended (format: 'email' mints email1). Prefer autonumberFormat on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the date and datetime cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field type (the write-time record validator's built-in email, url and phone checks key on type, never on this key) or a format validation rule, whose own format key is the closed set email | url | phone | json. |
| required | boolean |
optional (default: false) |
Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (multiple: true) required means NON-EMPTY array — an emptied required set fails validation loudly; [] does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (storage.notNull), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
| storage | { notNull?: boolean } |
optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
| searchable | boolean |
optional (default: false) |
Is searchable |
| multiple | boolean |
optional (default: false) |
Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); multiple: true on any other type, radio included, is REFUSED at parse. An emptied multi-value lookup reads back as [], never null — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. |
| unique | boolean | 'global' | 'organization' |
optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an autonumber field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit unique: true produces). To opt an autonumber field out, write unique: false explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (os migrate duplicates) still treats every autonumber field as an identifier. |
| defaultValue | any |
optional | Default applied on INSERT when the field is omitted or null ('' is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope { dialect: 'cel', source: 'today()' } (accepted structurally; result type is a runtime concern); a runtime TOKEN — NOW() on datetime/date/time only, current_user on user or lookup with reference: 'sys_user' only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 valueSchemaFor). Anything else is refused at parse time with a prescriptive message. |
| maxLength | integer |
optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
| minLength | integer |
optional | Min character length (positive integer; minLength: 0 is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the min/max transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. |
| valueDomain | Enum<'iana_time_zone' | 'iso_4217_currency' | 'iso_3166_alpha2'> |
optional | Standard value domain the WRITTEN value must be a member of: iana_time_zone (an IANA/tzdb zone identifier such as UTC, Asia/Kolkata, Europe/Kyiv — membership is the Intl.DateTimeFormat probe, never the Intl.supportedValuesOf enumeration, which omits UTC), iso_4217_currency (an ISO 4217 alphabetic currency code, uppercase, e.g. CHF) or iso_3166_alpha2 (an ISO 3166-1 alpha-2 country code, uppercase, e.g. CH). The same closed vocabulary and the same membership predicate as a settings specifier's valueDomain. Only authorable on text — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the min/max/maxLength transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code value_domain. Reach for it precisely where a pattern cannot help: ^[A-Z]{2}$ admits ZZ, and Mars/Olympus is a shape-valid zone that does not exist. |
| rows | integer |
optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea rows attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. |
| precision | integer |
optional | Total digits (non-negative integer) — the p of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so precision: 5, scale: 2 holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of number, currency, percent, rating and slider fields: a value that needs more digits is refused with field code max_precision, never rounded. Counted on the STORED value: at the declared scale when one applies, else at the value's own decimal places (leading zeros never count) — so on a currency field, where scale is refused, an amount's written decimals count toward the total; a fraction-stored percent is counted two places further right (scale + 2, or 2 with no scale), which makes the count that of the percentage-point value as displayed. Not decimal places (that is scale; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. |
| scale | integer |
optional | Decimal places (integer 0-100). Applies to number, percent, rating and slider fields, where it is enforced on writes, and to a formula field, whose computed result is rounded to it. REFUSED on a currency field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a percent field ⇒ 0 decimal places, so a stored 0.25 reads 25% on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED scale: 0 (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling resolveFieldScale from @objectstack/spec/data, the single source for an absent scale: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read 25% on the read-only cell and 25.00% in the edit widget. On a percent field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — scale: 2 means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no max, or a max at or below 1) stores 12.34% as 0.1234 and is allowed scale + 2 decimal places at the write seam, while a whole-percent field (max above 1) stores the displayed number itself and is allowed exactly scale. number, rating and slider are allowed exactly scale. The upper bound is the platform's, not a policy: renderers turn scale into fraction digits through toFixed and Intl.NumberFormat's maximumFractionDigits, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. |
| min | number |
optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[] script rule instead. |
| max | number |
optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as min: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a validations[] script rule instead. |
| useGrouping | boolean |
optional | Digit-grouping presentation hint for number fields — maps to Intl.NumberFormat's useGrouping. Absent = renderer decides (interim heuristic today, locale default eventually); false = author opts out of grouping (e.g. a year or other ordinal/identifier integer); true = author pins grouping on. |
| percentDisplay | Enum<'bar' | 'plain'> |
optional | How a percent field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; bar = pin the progress bar; plain = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (scale) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. |
| accept | string[] |
optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
| maxSize | integer |
optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
| options | { label: string; value: string; description?: string; color?: string; … }[] |
optional | Static options for the option types. A select / radio field needs a non-empty list here or a picklist — with neither (or options: []) it is refused at parse. |
| picklist | string |
optional | Name of a shared picklist whose options this field offers — instead of options, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty options; with neither it is refused at parse. |
| reference | string |
optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (sys_metadata, sys_metadata_history): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and os validate, os build and the object save door refuse it. On a tree field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. |
| referenceVia | string |
optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. record_id with referenceVia: 'object_name'. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with reference (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
| referenceDeletedWhen | { field: string; value: string } |
optional | Marks the record a referenceVia pointer names as PLATFORM-DELETED on rows where a sibling select field holds one declared option — e.g. record_id with { field: 'cancel_reason', value: 'record_deleted' }. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside referenceVia; field must be a single-value select on the same object declaring inline options, and value one of its option values — any other shape is refused at parse. |
| mailAssociation | boolean |
optional | Inbound mail association (email fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. |
| deleteBehavior | Enum<'set_null' | 'cascade' | 'restrict'> |
optional (default: "set_null") |
What happens if referenced record is deleted |
| inlineEdit | boolean | Enum<'grid' | 'form'> |
optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. |
| inlineTitle | string |
optional | Title for the inline master-detail grid |
| inlineColumns | { name: string; label?: string; type?: Enum<'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file'>; width?: number; … }[] |
optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column ({ name, label?, type?, … } — objectui GridColumn); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired field spelling are refused at parse. |
| inlineAmountField | string |
optional | Numeric child field summed for the inline grid total |
| relatedList | boolean | 'primary' |
optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record's related column (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=<field> / sort=-<field>, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. |
| relatedListTitle | string |
optional | Title for the detail-page related list |
| relatedListColumns | string[] |
optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. |
| relatedListFilter | any |
optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition { [referenceField]: parentId } — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query where), e.g. { status: { $ne: 'deleted' } } to hide soft-deleted children. |
| displayField | string |
optional | Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title). |
| descriptionField | string |
optional | Secondary field shown under the label in the quick-select popover. |
| lookupColumns | (string | { field: string; label?: string; width?: string; type?: string })[] |
optional | Explicit columns for the record-picker table; auto-derived from the referenced object when omitted. |
| lookupPageSize | integer |
optional | Rows per page in the record-picker dialog (default 10). |
| lookupFilters | { field: string; operator: Enum<'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'in' | 'notIn'>; value: any }[] |
optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. |
| dependsOn | (string | { field: string; param?: string })[] |
optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For lookup/master_detail it scopes the candidate query (string = same local/remote key; {field,param} when the remote filter key differs — the {field,param} form is lookup-only). For select/multiselect/radio the actual per-option rule lives in each option's visibleWhen; list the referenced fields here (string form) so the option list gates and refreshes with the parent. |
| allowCreate | boolean |
optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. |
| expression | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | CEL expression the field's value is computed from, e.g. Frecord.amount * 0.1. On a formula field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a summary can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
| returnType | Enum<'number' | 'text' | 'boolean' | 'date' | 'currency'> |
optional | Inferred value type of a formula field (number/text/boolean/date/currency). currency: an amount of money, in the currency the formula's own currencyConfig declares, as a currency field declares it (no currencyConfig: dynamic, the tenant default currency). |
| summaryOperations | { object: string; field?: string; function: Enum<'count' | 'sum' | 'min' | 'max' | 'avg'>; relationshipField?: string; … } |
optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
| language | string |
optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
| step | number |
optional | Step increment for slider (default: 1) |
| currencyConfig | { currencyMode?: Enum<'dynamic' | 'fixed' | 'record'>; defaultCurrency?: string; currencyField?: string } |
optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
| dimensions | integer |
optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) |
| trackHistory | boolean |
optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
| group | string |
optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. Precord.type == 'invoice' |
| readonlyWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Predicate (CEL) — field is read-only when TRUE. e.g. Precord.status == 'paid'. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (record.account.tier) faults on every row and refuses every update that writes the field — objectstack validate refuses it. Put such a check in a validations[] script rule, whose condition is read one hop through a reference. |
| requiredWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[] script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (record.account.tier) faults on every row and refuses every write that reaches it — objectstack validate refuses it; put such a check in a validations[] script rule, whose condition is read one hop through a reference. The only slot; the conditionalRequired alias was removed in protocol 17. |
| dueLike | boolean |
optional | Deadline semantic (date / datetime only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with settledWhen to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. |
| settledWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. Precord.status == 'done'. Evaluated per record like visibleWhen, reading the record's own columns as record. Requires dueLike: true on a date / datetime field; refused otherwise. Display only: nothing on the write path reads it. |
| conditionalFormatting | { condition: string | object; style: Record<string, string> }[] |
optional | Cell formatting rules for this field — [{ condition, style }], the same rule a list view's conditionalFormatting declares. In order, the first rule whose CEL condition holds applies its CSS style map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads value (this field's value on the record) and record (the row) and no other root, e.g. Pvalue < 0 with style: { color: '#b91c1c' }; objectstack validate refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with dueLike / settledWhen instead. Display only: nothing on the write path reads it. |
| widget | string |
optional | Form widget override — names a registered field component (resolved as field:<widget>) to render this field instead of the type default. Degrades to the type renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". |
| hidden | boolean |
optional (default: false) |
Hidden from default UI |
| internal | boolean |
optional | Never return this field's value on the generic data path — the engine OMITS the key from find/findOne results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in ?select=. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on text columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a required column. |
| readonly | boolean |
optional (default: false) |
Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. approval_status: "approved"), symmetric with readonlyWhen. A stripped INSERT field still falls back to its defaultValue. Exempt from the strip on BOTH paths: isSystem writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (preserveAudit) — which admits a whitelist (the audit/timestamp family plus author-declared business readonly fields). On INSERT the exemption does NOT apply: a non-system create that requests preserveAudit still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. |
| requiredPermissions | string[] |
optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). |
| maskingRule | Enum<'phone' | 'id_card' | 'bank_account' | 'email' | 'name'> | { keepHead: integer; keepTail: integer } |
optional | Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138*5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j@example.com, 'name' keep first char) or { keepHead, keepTail }. Masked for every non-system caller unless the field's requiredPermissions are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. |
| ackPlaintextMasking | boolean |
optional | [ADR-0100] Affirm a generic password field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields. |
| system | boolean |
optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. |
| sortable | boolean |
optional (default: true) |
Whether field is sortable in list views |
| inlineHelpText | string |
optional | Help text displayed below the field in forms |
| placeholder | string |
optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from inlineHelpText (always-visible help rendered beside/under the input) and description (tooltip/developer documentation). |
| autonumberFormat | string |
optional (default: "{0000}") |
Auto-number format: literal text + {0000} counter, {YYYY}/{MM}/{DD}/{YYYYMMDD} date tokens (business tz), and {field_name} interpolation. Counter resets per rendered prefix (e.g. AD{YYYYMMDD}``{0000} resets daily). Omitted on an autonumber field ⇒ the contract default {0000}, which every driver and the engine fallback read, so one field numbers alike on every backend. |
| externalId | boolean |
optional (default: false) |
Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own externalId; an import names it in the mapping's upsertKey. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> |
optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string |
optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> |
optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> |
optional | Origin of the item (package | org | env-forced). |
| _packageId | string |
optional | Owning package machine id. |
| _packageVersion | string |
optional | Owning package version. |
| _lockDocsUrl | string |
optional | Optional documentation link surfaced next to _lockReason. |
| Property | Type | Required | Description |
|---|---|---|---|
| name | string |
optional | Index name (auto-generated if not provided) |
| fields | string[] |
✅ | Fields included in the index |
| unique | false | 'global' | 'organization' |
optional (default: false) |
Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly fields, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, 'global')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' |
| Property | Type | Required | Description |
|---|---|---|---|
| key | string |
✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's group. |
| label | string |
✅ | Group display label |
| icon | string |
optional | Icon name (Lucide/Material) for the group header |
| description | string |
optional | Optional description shown under the group header |
| relatedList | string |
optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. It names a body table only (a FK declaring relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList): a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused where the child's fields are known (os validate / os build and the metadata write door), naming the FK and the remedy (set relatedList: 'primary' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList). |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. Precord.type == 'invoice' |
| collapse | Enum<'none' | 'expanded' | 'collapsed'> |
optional (default: "none") |
[ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). |
| defaultExpanded | boolean |
optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
| collapsible | boolean |
optional | [DEPRECATED → collapse] Boolean pair with collapsed; use the collapse enum. |
| collapsed | boolean |
optional | [DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no collapsible and outranks collapsible: false; false → 'none', or 'expanded' beside collapsible: true. Use the collapse enum. |
| Property | Type | Required | Description |
|---|---|---|---|
| enabled | boolean |
✅ | Enable multi-tenancy for this object |
| tenantField | string |
optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to organization_id, the kernel-injected column the RLS predicates and tenantPolicy() also assume. A declared name is honoured only when the object really has that field — otherwise the same organization_id fallback applies. No default is materialized here on purpose. |
| Property | Type | Required | Description |
|---|---|---|---|
| default | Enum<'public' | 'private'> |
optional (default: "public") |
Default exposure posture: public (covered by wildcard grants) | private (needs explicit grant; exempt from wildcard RLS). |
| Property | Type | Required | Description |
|---|---|---|---|
| read | string[] |
optional | Capabilities required to read (find/findOne/count/aggregate). |
| create | string[] |
optional | Capabilities required to create (insert). |
| update | string[] |
optional | Capabilities required to update (update/transfer/restore). |
| delete | string[] |
optional | Capabilities required to delete (delete/purge). |
| Property | Type | Required | Description |
|---|---|---|---|
| class | Enum<'record' | 'audit' | 'telemetry' | 'transient' | 'event'> |
✅ | Persistence contract: record (business truth, permanent) | audit (compliance ledger) | telemetry (high-freq log) | transient (ephemeral state) | event (bus messages). |
| retention | { maxAge: string; onlyWhen?: Record<string, string | number | boolean | object | object> } |
optional | Age-based retention window enforced by the LifecycleService Reaper. |
| ttl | { field: string; expireAfter: string; onlyWhen?: Record<string, string | number | boolean | object | object> } |
optional | Per-row TTL auto-expiry (transient/event classes). |
| storage | { strategy: 'rotation'; shards: integer; unit: Enum<'day' | 'week' | 'month'> } |
optional | Physical storage strategy for high-frequency telemetry (LifecycleService Rotator). |
| archive | { after: string; to: string; keep?: string } |
optional | Cold-store archival (LifecycleService Archiver) — audit-class hot→cold hand-off. |
| reclaim | boolean |
optional | Run driver space reclamation (SQLite incremental_vacuum) after sweeping this object. Default true for non-record classes. |
| Property | Type | Required | Description |
|---|---|---|---|
| field | string |
✅ | Field to watch (typically a status/stage select). |
| value | string |
✅ | The value the field must transition INTO to fire the milestone. |
| summary | string |
✅ | Activity summary template; {field} tokens interpolate the record value. e.g. "Deal won: {name}". |
| type | string |
optional | Activity type for the emitted row — left unset, it keeps the update row's kind, "updated" (a milestone only fires on an update). |
| Property | Type | Required | Description |
|---|---|---|---|
| name | string |
optional | Internal view name (lowercase snake_case) |
| label | string | Record<string, string> |
optional | Display label — the default-language string, or an inline locale map ({ en, "zh-CN" }) resolved at render time |
| type | Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'> |
optional (default: "grid") |
|
| data | { provider: 'object'; object: string } | { provider: 'value'; items: any[] } | { provider: 'schema'; schemaId: string; schema?: Record<string, any> } |
optional | Data source configuration (defaults to "object" provider) |
| columns | string[] | { field: string; label?: string | Record<string, string>; width?: number; align?: Enum<'left' | 'center' | 'right'>; … }[] |
✅ | Fields to display as columns — the PROJECTION of the per-view field composition columns x hiddenFields x fieldOrder: this list is the candidate set AND the baseline order; hiddenFields subtracts from it and fieldOrder orders what survives. hiddenFields and fieldOrder cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's ListView grid derives the object's default columns). |
| filter | { field: string; operator: Enum<'equals' | 'not_equals' | 'contains' | 'not_contains' | 'icontains' | …>; value?: string | number | boolean | null | (string | number)[] }[] |
optional | Filter criteria (JSON Rules) |
| sort | { field: string; order: Enum<'asc' | 'desc'> }[] |
optional | |
| searchableFields | string[] |
optional | Fields enabled for search |
| filterableFields | never |
optional | [REMOVED] view.list.filterableFields was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for userFilters.fields (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare userFilters: { element: 'dropdown', fields: [{ field: '<field_name>' }] }. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks hidden: true is not offered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| resizable | boolean |
optional | Enable column resizing |
| compactToolbar | boolean |
optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover |
| selection | { type?: Enum<'none' | 'single' | 'multiple'> } |
optional | Row selection configuration |
| navigation | { mode?: Enum<'drawer' | 'page' | 'new_window' | 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>; … } |
optional | Configuration for item click navigation (drawer by default, page, new_window or none) |
| pagination | { pageSize?: integer; pageSizeOptions?: integer[] } |
optional | Pagination configuration |
| kanban | { groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … } |
optional | Kanban-board configuration — applies when the view renders as a kanban layout |
| calendar | { startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … } |
optional | Calendar configuration — applies when the view renders as a calendar layout |
| gantt | { startDateField: string; endDateField: string; titleField: string; progressField?: string; … } |
optional | Gantt-timeline configuration — applies when the view renders as a gantt layout |
| gallery | { coverField?: string; coverFit?: Enum<'cover' | 'contain'>; cardSize?: Enum<'small' | 'medium' | 'large'>; titleField?: string; … } |
optional | Gallery/card view configuration |
| timeline | { startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … } |
optional | Timeline view configuration |
| chart | { chartType?: Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'>; dataset: string; dimensions?: string[]; values: string[] } |
optional | Chart binding — applies when the view renders as a chart. A type: 'chart' view must bind one: it names the ADR-0021 dataset and the measures (values) the chart plots, and there is no default binding |
| map | { latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … } |
optional | Map configuration — applies when the view renders as a map layout |
| tree | { parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } |
optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| pageName | never |
optional | [REMOVED] view.pageName was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a type: 'page' view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — { type: 'page', pageName: '<page_name>' } under the app's navigation — which is a different key on a different surface and is the page mount that has always rendered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| description | string | Record<string, string> |
optional | View description for documentation/tooltips |
| sharing | { type?: Enum<'personal' | 'collaborative'>; lockedBy?: string } |
optional | View sharing and access configuration |
| requiredPermissions | string[] |
optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through systemPermissions, the same list as requiredPermissions on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. |
| rowHeight | Enum<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'> |
optional | Row height / density setting |
| grouping | { fields: object[] } |
optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) |
| rowColor | { field: string; colors?: Record<string, string> } |
optional | Color rows based on field value |
| hiddenFields | string[] |
optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition columns x hiddenFields x fieldOrder: each name listed here is removed from the columns projection before fieldOrder orders the remainder. A name columns never projected subtracts nothing. |
| fieldOrder | string[] |
optional | Explicit field display order for this view — the ORDERING of the per-view field composition columns x hiddenFields x fieldOrder: it sorts what survives columns minus hiddenFields and never adds a field. A surviving column absent from fieldOrder sorts LAST, after every listed one, keeping its columns-relative order; a name listed here that did not survive orders nothing. |
| rowActions | string[] |
optional | Actions available for individual row items |
| bulkActions | string[] |
optional | Actions available when multiple rows are selected |
| bulkActionDefs | { name: string; label?: string; icon?: string; variant?: Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'>; … }[] |
optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a patch / 'delete') that no action expresses, or for an operation: 'custom' + execution: 'aggregate' entry that dispatches the action it NAMES once for the whole selection — the renderer injects params._selectedIds: string[] (read that on the server, not recordId) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. batchSize does not apply (the call is never chunked); set maxRecords on defs whose server work is expensive. For the PER-RECORD dispatch use bulkActions: ['<name>'] instead — the bare-string form, promoted with the action's own label, params and visible; a 'custom' def without execution: 'aggregate' has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via ${ctx.selection.ids} / ${ctx.selection.count}. |
| conditionalFormatting | { condition: string | object; style: Record<string, string> }[] |
optional | Conditional formatting rules for list rows |
| inlineEdit | boolean |
optional | Allow inline editing of records directly in the list view |
| exportOptions | Enum<'csv' | 'xlsx' | 'json'>[] | { formats?: Enum<'csv' | 'xlsx' | 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … } |
optional | Export configuration for the list toolbar export menu: { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }. A bare format array is the legacy spelling and lifts to { formats: [...] } at parse. |
| userActions | { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } |
optional | User action toggles for the view toolbar |
| appearance | { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } |
optional | Appearance and visualization configuration |
| tabs | never |
optional | [REMOVED] view.list.tabs was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's listViews instead: the tab's name becomes the entry's key, its label the entry's label, and its filter rules join the view's own filter on that entry (copy the view's columns too); a tab whose view already named a list view needs nothing more. Every listViews entry renders as a tab in the switcher. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| addRecord | { enabled: boolean; position?: Enum<'top' | 'bottom' | 'both'> } |
optional | Add record entry point configuration |
| showRecordCount | boolean |
optional | Show record count at the bottom of the list |
| allowPrinting | boolean |
optional | Allow users to print the view |
| emptyState | { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } |
optional | Empty state configuration when no records found |
| aria | { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } |
optional | ARIA accessibility attributes for the list view |
| userFilters | { element?: Enum<'dropdown' | 'toggle'>; fields?: object[] } |
optional |
| Property | Type | Required | Description |
|---|---|---|---|
| trackHistory | boolean |
optional (default: false) |
Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. On an authored page, a record:history or record:activity block shows the record's field changes from its own activity, and neither reads this flag. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance |
| searchable | boolean |
optional (default: true) |
Index records for global search |
| apiEnabled | boolean |
optional (default: true) |
Expose object via automatic APIs |
| apiMethods | Enum<'get' | 'list' | 'create' | 'update' | 'delete' | 'bulk'>[] |
optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) |
| files | boolean |
optional (default: false) |
Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike. Field.file/Field.image are independent |
| feeds | boolean |
optional (default: true) |
Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike |
| activities | boolean |
optional (default: true) |
Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
| clone | boolean |
optional (default: true) |
Allow record deep cloning |
| approvalsVisibleToReaders | boolean |
optional (default: false) |
Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it |
| Property | Type | Required | Description |
|---|---|---|---|
| controlledByParent | { ownerMayEdit?: boolean } |
optional | Opt-ins read only under sharingModel: 'controlled_by_parent' (ADR-0055 §3): ownerMayEdit. |
| Property | Type | Required | Description |
|---|---|---|---|
| name | string |
✅ | Policy unique identifier (snake_case) |
| label | string |
optional | Human-readable policy label |
| description | string |
optional | Policy description and business justification |
| operation | Enum<'select' | 'insert' | 'update' | 'delete' | 'all'> |
✅ | Database operation this policy applies to |
| using | string |
optional | Row predicate, authored in canonical CEL (ADR-0058 D1): the rows a select policy lets a caller read, and the existing rows an update or delete policy lets a caller change or remove. On an insert or an update, when no applicable policy for that operation declares check, each applicable policy's using also stands in as its check on every row written (see check); on an insert policy that is its only effect. It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a current_user.* context value using ==, !=, <, <=, > or >=; in against a current_user.* array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with && / ||; or the bare allow-all true. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites = to == and IN to in (deprecated under ADR-0058 D1); SQL AND / OR / NOT IN / IS NULL / LIKE are NOT bridged and fail closed. Needed on a select or delete policy (a check there is refused); optional on an insert, update or all policy that declares check. |
| check | string |
optional | Validation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its beforeInsert hooks leave it, and each row an update changes, by id or multi: true, as the prior row merged with the final payload after its beforeUpdate hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. The default to using is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares check, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares check does each applicable policy's using stand in as its check (OR-combined). Applicable = not enabled: false, object matches or is '*', operation matches or is 'all', and the caller holds one of its positions when it lists any. Refused on a policy whose operation is select or delete, which write no new row to check: limit the rows such a policy admits with using, and declare the check on an insert, update or all policy. |
| positions | string[] |
optional | Positions this policy applies to (omit for all) |
| enabled | boolean |
optional (default: true) |
Whether this policy is active |
| Property | Type | Required | Description |
|---|---|---|---|
| userField | string |
✅ | The column of this object that holds the user a row belongs to: a single-valued user reference (user, or a lookup / master_detail to sys_user). A row counts for the signed-in user when this column holds their user id. |
| valueField | string |
✅ | The column of this object whose values form the set: a reference (lookup, master_detail or user), multi-valued allowed. A policy compares the set with a column that references the same object, or with id on that object. Never the same column as userField. |
| filter | string |
optional | Optional condition a roster row must also meet, in canonical CEL over this object's own columns and within the row-level security subset a policy's using lowers (e.g. "active == true"). It may not read any current_user.* value. |
| label | string |
optional | Human-readable name of the set |
| description | string |
optional | What the set holds and who it is for |
| Property | Type | Required | Description |
|---|---|---|---|
| enabled | boolean |
optional (default: false) |
Allow records of this object to be published via share link |
| allowedAudiences | Enum<'public' | 'link_only' | 'signed_in' | 'email'>[] |
optional | Audiences callers may select when creating a link |
| allowedPermissions | Enum<'view' | 'comment' | 'edit'>[] |
optional | Permission levels selectable on the share dialog |
| maxExpiryDays | integer |
optional | Reject links with expiry beyond this many days |
| redactFields | string[] |
optional | Field names removed from records served via a share token |
| eligibility | string |
optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |
| Property | Type | Required | Description |
|---|---|---|---|
| name | string |
✅ | Machine name (lowercase snake_case) |
| label | string | Record<string, string> |
✅ | Display label |
| description | string | Record<string, string> |
optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing ai.description. |
| objectName | string |
optional | Target object this action belongs to. When set, the action is auto-merged into the object's actions array by defineStack(). |
| icon | string |
optional | Icon name |
| locations | Enum<'list_toolbar' | 'list_item' | 'record_header' | 'record_more' | …>[] |
optional | Locations where this action is visible |
| component | Enum<'action:button' | 'action:icon' | 'action:menu' | 'action:group'> |
optional | Visual component override |
| type | Enum<'script' | 'url' | 'modal' | 'flow' | 'api' | 'form'> |
optional (default: "script") |
Action functionality type — the dispatch route. The declarative single-record field write is not a type: it is operation: 'update' + patch on the default script route. |
| target | string |
optional | URL, Script Name, Flow ID, or API Endpoint. Supports ${param.X} and ${ctx.X} interpolation. For type:'url': an http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (a console route; a record is /apps/APP/OBJECT/record/ID, APP being the app's route segment — see onSuccess.navigate) — every other scheme and a protocol-relative //host are refused. |
| openIn | Enum<'self' | 'new-tab'> |
optional | For type:'url' — where to open target. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of params (which is user-input-collection only). |
| body | { language: 'expression'; source: string } | { language: 'js'; source: string; capabilities?: Enum<'api.read' | 'api.write' | 'api.transaction' | 'crypto.uuid' | 'log'>[]; timeoutMs?: integer; … } |
optional | Action body — expression (L1) or sandboxed JS (L2). Only used when type is script. |
| operation | Enum<'update'> |
optional | The declarative single-record field write, mirroring a list view's bulkActionDefs: 'update' applies patch (merged under the collected params) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. type stays at its default 'script' (the platform action route the write is performed on); target/body/method/bodyExtra are refused beside it. 'delete' and 'custom' have no row-level form. |
| patch | Record<string, any> |
optional | For operation: 'update' — static field values written to the current record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without operation: 'update' (it would be silently dropped). |
| execution | Enum<'perRecord' | 'aggregate'> |
optional | The bulk dispatch contract this action's BODY is written for, in bulkActionDefs' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's recordId (the view's bulkActions: ['<name>'] bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in params._selectedIds (a bulkActionDefs entry with execution: 'aggregate'). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by @objectstack/lint (action-dispatch-contract-mismatch). |
| params | { name?: string; field?: string; objectOverride?: string; label?: string | Record<string, string>; … }[] |
optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in bodyExtra). |
| variant | Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'link'> |
optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) |
| order | number |
optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without order keep their registration order. |
| confirmText | string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string> |
optional | Confirmation message before execution — a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over record, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's params is an object of static values — params ({{ params.tier }}); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete {{ record.name }}?'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty params is refused — that opens a second dialog for one decision; put the question on description instead. Correct on a param-LESS action, where the confirm is the only dialog there is. |
| successMessage | string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string> |
optional | Success message shown after the action succeeds — a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over result, the payload the action's handler answered ({{ result.email }}; for an api or script action the server response), and record, the record the action ran against. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to {{ result.email }}'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. On a registered action that declares outcomeMessages, the entry named by the response's outcome is shown instead, and this message is the fallback. |
| outcomeMessages | Record<string, string | Record<string, string>> |
optional | Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case outcome values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate ${result.*}, the scope onSuccess.navigate declares. An outcome with no entry here falls back to successMessage, then to the default text. Not allowed beside resultDialog (which suppresses the success toast) or operation: 'update' (no handler, so no outcome). |
| errorMessage | string | Record<string, string> |
optional | Error message to show when the action fails (overrides the raw error). |
| refreshAfter | boolean |
optional (default: false) |
Refresh view after execution |
| undoable | boolean |
optional | Offer an Undo affordance after this single-record update action succeeds. operation: 'update' is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, patch UNDER the collected params, not patch alone. An action with no operation declares no write set here, but type: 'api' is fulfilled by the console, which builds the undo envelope from undoable alone. Those two shapes — operation: 'update' and type: 'api' — are the whole fulfillable set; on a registered action undoable beside any other shape is refused, because nothing would anchor the capture. |
| resultDialog | { title?: string | Record<string, string>; description?: string | Record<string, string>; acknowledge?: string | Record<string, string>; format?: Enum<'qrcode' | 'code-list' | 'secret' | 'text' | 'json'>; … } |
optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). |
| visible | boolean | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Visibility predicate — true/false literal, CEL string, or {dialect, source} envelope. The action is offered when it evaluates TRUE. Omit = always visible. |
| requiresFeature | Enum<'twoFactor' | 'organization' | 'multiOrgEnabled' | 'degradedTenancy' | …> |
optional | Public auth feature flag gating this action; lowered into visible at parse time. |
| requiresMembershipReach | Enum<'invite_member' | 'cancel_invitation' | 'update_member_role' | …> |
optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into visible over current_user.positions at parse time. |
| disabled | boolean | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Disabled predicate — true/false literal, CEL string, or {dialect, source} envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. |
| disabledReason | string | Record<string, string> |
optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while disabled evaluates TRUE — the default-language string, or an inline locale map; translated at objects.OBJECT._actions.ACTION.disabledReason (globalActions.ACTION.disabledReason for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, requiredPermissions), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. |
| pressed | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Pressed-state predicate (CEL string or {dialect, source} envelope) over the same scope as visible: the action surface draws the button as already pressed for this reader (a toggle face, aria-pressed) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under attachedOnRead, as has(record.viewer) && record.viewer.vote == 'helpful'. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. |
| requiredPermissions | string[] |
optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (POST /actions/:object/:action and MCP run_action) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a type: api action whose declared target is a same-origin platform path (exactly one leading /, followed by neither / nor a backslash, with a path after it) under that door; its target endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A type: api action with an off-origin target is browser-only and not gated server-side. |
| ai | { exposed?: boolean; description?: string; category?: Enum<'data' | 'action' | 'flow' | 'integration' | 'vector_search' | 'analytics' | 'utility'>; paramHints?: Record<string, object>; … } |
optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. |
| recordIdParam | string |
optional | Body key to inject the row id into when running from a list_item context. |
| recordIdField | string |
optional | Row field whose value seeds recordIdParam. Defaults to "id". |
| bodyShape | 'flat' | { wrap: string } |
optional | Body wrapping: flat (default) or { wrap: key } to nest user-collected params under a key. |
| method | Enum<'POST' | 'PATCH' | 'PUT' | 'DELETE'> |
optional | HTTP method for type:"api" actions. Defaults to POST. |
| bodyExtra | Record<string, any> |
optional | Static request-body fields for a type:"api" action, merged last (overrides user params). {{page.<var>}} tokens are resolved by the runtime. This — not params — is where a payload goes. |
| mode | Enum<'create' | 'edit' | 'delete' | 'custom'> |
optional | Semantic mode of the action. |
| opensInNewTab | boolean |
optional | Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. |
| newTabUrl | string |
optional | New-tab path template on this backend, starting with / ({recordId} placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'. |
| onSuccess | { navigate: string; openIn?: Enum<'self' | 'newTab'> } |
optional | Post-success navigation for type:'api', type:'script' and type:'flow' actions. navigate is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response; for a flow, the completed run's answer); openIn defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior. |
| aria | never |
optional | [REMOVED] action.aria was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's label and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required label — the visible button or menu-item text, and the aria-label of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author ariaLabel / ariaDescribedBy / role in the aria block of the placing node: page.components[].aria (the component that renders the actions) or the list view aria. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> |
optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string |
optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> |
optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> |
optional | Origin of the item (package | org | env-forced). |
| _packageId | string |
optional | Owning package machine id. |
| _packageVersion | string |
optional | Owning package version. |
| _lockDocsUrl | string |
optional | Optional documentation link surfaced next to _lockReason. |
| Property | Type | Required | Description |
|---|---|---|---|
| lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> |
✅ | Lock policy — none | no-overlay | no-delete | full. |
| reason | string |
✅ | User-visible reason shown when the lock blocks an action. |
| docsUrl | string |
optional | Optional URL the Studio banner links to for more context. |
| Property | Type | Required | Description |
|---|---|---|---|
| default | Enum<'public' | 'private'> |
optional (default: "public") |
Default exposure posture: public (covered by wildcard grants) | private (needs explicit grant; exempt from wildcard RLS). |
| Property | Type | Required | Description |
|---|---|---|---|
| trackHistory | boolean |
optional (default: false) |
Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. On an authored page, a record:history or record:activity block shows the record's field changes from its own activity, and neither reads this flag. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance |
| searchable | boolean |
optional (default: true) |
Index records for global search |
| apiEnabled | boolean |
optional (default: true) |
Expose object via automatic APIs |
| apiMethods | Enum<'get' | 'list' | 'create' | 'update' | 'delete' | 'bulk'>[] |
optional | Whitelist of allowed API operations (six primitives; undefined = all, [] = none) |
| files | boolean |
optional (default: false) |
Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike. Field.file/Field.image are independent |
| feeds | boolean |
optional (default: true) |
Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike |
| activities | boolean |
optional (default: true) |
Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline |
| clone | boolean |
optional (default: true) |
Allow record deep cloning |
| approvalsVisibleToReaders | boolean |
optional (default: false) |
Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it |
| Property | Type | Required | Description |
|---|---|---|---|
| extend | string |
✅ | Target object name (FQN) to extend |
| fields | Record<string, { name?: string; label?: string; type: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; description?: string; … }> |
optional | Fields to add/override |
| label | string |
optional | Override label for the extended object |
| pluralLabel | string |
optional | Override plural label for the extended object |
| description | string |
optional | Override description for the extended object |
| validations | any[] |
optional | Additional validation rules to merge into the target object |
| indexes | { name?: string; fields: string[]; unique?: false | 'global' | 'organization' }[] |
optional | Additional indexes to merge into the target object |
| priority | integer |
optional (default: 200) |
Merge priority (higher = applied later) |
| Property | Type | Required | Description |
|---|---|---|---|
| name | string |
optional | Machine name (snake_case) |
| label | string |
optional | Human readable label |
| type | Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …> |
✅ | Field Data Type |
| description | string |
optional | Tooltip/Help text |
| format | string |
optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an autonumber field it is the record-number PATTERN — the shorthand that predates autonumberFormat, which wins when both are present: format: 'INV-{0000}' mints INV-0001 on both the query engine and the SQL driver, while a value carrying no {...} token is emitted as literal text with the bare counter appended (format: 'email' mints email1). Prefer autonumberFormat on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the date and datetime cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field type (the write-time record validator's built-in email, url and phone checks key on type, never on this key) or a format validation rule, whose own format key is the closed set email | url | phone | json. |
| required | boolean |
optional (default: false) |
Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (multiple: true) required means NON-EMPTY array — an emptied required set fails validation loudly; [] does not satisfy it. NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (storage.notNull), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. |
| storage | { notNull?: boolean } |
optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. |
| searchable | boolean |
optional (default: false) |
Is searchable |
| multiple | boolean |
optional (default: false) |
Allow multiple values (Stores as Array/JSON). Declarable ONLY on the multi-capable types — select, lookup, user, file, image — and redundantly on the inherently-multi option types (multiselect, checkboxes, tags); multiple: true on any other type, radio included, is REFUSED at parse. An emptied multi-value lookup reads back as [], never null — the rule binds every writer (cascade repair, form clears, API writes), not just cascade repair. |
| unique | boolean | 'global' | 'organization' |
optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'. Omitted ⇒ false, EXCEPT on an autonumber field, where omitted ⇒ 'organization' (an auto-number is a business identifier, so the platform makes it unique per organization by default — the same tenant-composite shape an explicit unique: true produces). To opt an autonumber field out, write unique: false explicitly — legitimate only for a display-only sequence that is not used to identify the record; the platform's duplicate scan (os migrate duplicates) still treats every autonumber field as an identifier. |
| defaultValue | any |
optional | Default applied on INSERT when the field is omitted or null ('' is a real value, not absence). Three legal shapes, discriminated in the engine's own order: a CEL Expression envelope { dialect: 'cel', source: 'today()' } (accepted structurally; result type is a runtime concern); a runtime TOKEN — NOW() on datetime/date/time only, current_user on user or lookup with reference: 'sys_user' only, neither on a multi-value field; or a LITERAL, which must satisfy this field's own stored value contract (ADR-0104 D1 valueSchemaFor). Anything else is refused at parse time with a prescriptive message. |
| maxLength | integer |
optional | Max character length (positive integer). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the min/max transition-gate class): a stored value longer than a bound declared later is never re-read and survives unrelated edits — only a write carrying an over-long value is refused. |
| minLength | integer |
optional | Min character length (positive integer; minLength: 0 is refused — express "no minimum" by omitting the key). Only authorable on types that store a bounded string: text, textarea, email, url, phone, password, markdown, html, richtext, code, signature, qrcode. Checked on the WRITTEN value only (the min/max transition-gate class): a stored value shorter than a bound declared later is never re-read and survives unrelated edits — only a write carrying a too-short value is refused. |
| valueDomain | Enum<'iana_time_zone' | 'iso_4217_currency' | 'iso_3166_alpha2'> |
optional | Standard value domain the WRITTEN value must be a member of: iana_time_zone (an IANA/tzdb zone identifier such as UTC, Asia/Kolkata, Europe/Kyiv — membership is the Intl.DateTimeFormat probe, never the Intl.supportedValuesOf enumeration, which omits UTC), iso_4217_currency (an ISO 4217 alphabetic currency code, uppercase, e.g. CHF) or iso_3166_alpha2 (an ISO 3166-1 alpha-2 country code, uppercase, e.g. CH). The same closed vocabulary and the same membership predicate as a settings specifier's valueDomain. Only authorable on text — the one type whose stored value is a single plain string naming the member. Checked on the WRITTEN value only (the min/max/maxLength transition-gate class): a stored value outside a domain declared later is never re-read and survives unrelated edits — only a write carrying a non-member is refused, with the field error code value_domain. Reach for it precisely where a pattern cannot help: ^[A-Z]{2}$ admits ZZ, and Mars/Olympus is a shape-valid zone that does not exist. |
| rows | integer |
optional | Height of the INLINE multiline editor, in text rows (positive integer — the HTML textarea rows attribute; fullscreen/dialog editor surfaces size themselves and ignore it). Only authorable on multiline editor types: textarea, markdown, html, richtext. Omit it for the widget default height. |
| precision | integer |
optional | Total digits (non-negative integer) — the p of a DECIMAL(p, s): the digits of the value, integer and fraction together, counted at the field's decimal places, so precision: 5, scale: 2 holds up to 999.99 and refuses 1234.5 (1234.50 is 6 digits). Enforced on writes of number, currency, percent, rating and slider fields: a value that needs more digits is refused with field code max_precision, never rounded. Counted on the STORED value: at the declared scale when one applies, else at the value's own decimal places (leading zeros never count) — so on a currency field, where scale is refused, an amount's written decimals count toward the total; a fraction-stored percent is counted two places further right (scale + 2, or 2 with no scale), which makes the count that of the percentage-point value as displayed. Not decimal places (that is scale; a currency's are its ISO 4217 minor unit) and not a column size: every numeric column keeps the platform's fixed exact decimal whatever this declares. Not read on any other field type. |
| scale | integer |
optional | Decimal places (integer 0-100). Applies to number, percent, rating and slider fields, where it is enforced on writes, and to a formula field, whose computed result is rounded to it. REFUSED on a currency field — delete it there: a currency amount's decimal places are its currency's, so the currency's ISO 4217 minor unit decides how the amount displays, and a currency write's decimal places stay unconstrained. OMITTED on a percent field ⇒ 0 decimal places, so a stored 0.25 reads 25% on every face; omitted on any OTHER numeric type declares NO fixed width — the value keeps its natural precision, and a DECLARED scale: 0 (a year, a fiscal period, an ordinal) stays distinguishable from having declared nothing, so nothing is defaulted there. Consumers resolve the effective width by calling resolveFieldScale from @objectstack/spec/data, the single source for an absent scale: a renderer that spells its own fallback is a width no other face can see, and that is how one stored 0.25 came to read 25% on the read-only cell and 25.00% in the edit widget. On a percent field this is the number of decimal places of the PERCENTAGE-POINT value as displayed and entered — scale: 2 means 12.34% — and the STORED precision derives from the field's storage scale rather than being declared again: a fraction-stored percent (no max, or a max at or below 1) stores 12.34% as 0.1234 and is allowed scale + 2 decimal places at the write seam, while a whole-percent field (max above 1) stores the displayed number itself and is allowed exactly scale. number, rating and slider are allowed exactly scale. The upper bound is the platform's, not a policy: renderers turn scale into fraction digits through toFixed and Intl.NumberFormat's maximumFractionDigits, both of which throw a RangeError above 100 — so a larger declaration is unrenderable by any conforming consumer. |
| min | number |
optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as requiredWhen: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a validations[] script rule instead. |
| max | number |
optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as min: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a validations[] script rule instead. |
| useGrouping | boolean |
optional | Digit-grouping presentation hint for number fields — maps to Intl.NumberFormat's useGrouping. Absent = renderer decides (interim heuristic today, locale default eventually); false = author opts out of grouping (e.g. a year or other ordinal/identifier integer); true = author pins grouping on. |
| percentDisplay | Enum<'bar' | 'plain'> |
optional | How a percent field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; bar = pin the progress bar; plain = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (scale) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. |
| accept | string[] |
optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. |
| maxSize | integer |
optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. |
| options | { label: string; value: string; description?: string; color?: string; … }[] |
optional | Static options for the option types. A select / radio field needs a non-empty list here or a picklist — with neither (or options: []) it is refused at parse. |
| picklist | string |
optional | Name of a shared picklist whose options this field offers — instead of options, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty options; with neither it is refused at parse. |
| reference | string |
optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (sys_metadata, sys_metadata_history): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and os validate, os build and the object save door refuse it. On a tree field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. |
| referenceVia | string |
optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. record_id with referenceVia: 'object_name'. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with reference (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. |
| referenceDeletedWhen | { field: string; value: string } |
optional | Marks the record a referenceVia pointer names as PLATFORM-DELETED on rows where a sibling select field holds one declared option — e.g. record_id with { field: 'cancel_reason', value: 'record_deleted' }. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside referenceVia; field must be a single-value select on the same object declaring inline options, and value one of its option values — any other shape is refused at parse. |
| mailAssociation | boolean |
optional | Inbound mail association (email fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. |
| deleteBehavior | Enum<'set_null' | 'cascade' | 'restrict'> |
optional (default: "set_null") |
What happens if referenced record is deleted |
| inlineEdit | boolean | Enum<'grid' | 'form'> |
optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. |
| inlineTitle | string |
optional | Title for the inline master-detail grid |
| inlineColumns | { name: string; label?: string; type?: Enum<'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file'>; width?: number; … }[] |
optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column ({ name, label?, type?, … } — objectui GridColumn); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired field spelling are refused at parse. |
| inlineAmountField | string |
optional | Numeric child field summed for the inline grid total |
| relatedList | boolean | 'primary' |
optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record's related column (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=<field> / sort=-<field>, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order. |
| relatedListTitle | string |
optional | Title for the detail-page related list |
| relatedListColumns | string[] |
optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. |
| relatedListFilter | any |
optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition { [referenceField]: parentId } — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query where), e.g. { status: { $ne: 'deleted' } } to hide soft-deleted children. |
| displayField | string |
optional | Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title). |
| descriptionField | string |
optional | Secondary field shown under the label in the quick-select popover. |
| lookupColumns | (string | { field: string; label?: string; width?: string; type?: string })[] |
optional | Explicit columns for the record-picker table; auto-derived from the referenced object when omitted. |
| lookupPageSize | integer |
optional | Rows per page in the record-picker dialog (default 10). |
| lookupFilters | { field: string; operator: Enum<'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'in' | 'notIn'>; value: any }[] |
optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. |
| dependsOn | (string | { field: string; param?: string })[] |
optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For lookup/master_detail it scopes the candidate query (string = same local/remote key; {field,param} when the remote filter key differs — the {field,param} form is lookup-only). For select/multiselect/radio the actual per-option rule lives in each option's visibleWhen; list the referenced fields here (string form) so the option list gates and refreshes with the parent. |
| allowCreate | boolean |
optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. |
| expression | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | CEL expression the field's value is computed from, e.g. Frecord.amount * 0.1. On a formula field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a summary can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). |
| returnType | Enum<'number' | 'text' | 'boolean' | 'date' | 'currency'> |
optional | Inferred value type of a formula field (number/text/boolean/date/currency). currency: an amount of money, in the currency the formula's own currencyConfig declares, as a currency field declares it (no currencyConfig: dynamic, the tenant default currency). |
| summaryOperations | { object: string; field?: string; function: Enum<'count' | 'sum' | 'min' | 'max' | 'avg'>; relationshipField?: string; … } |
optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. |
| language | string |
optional | Programming language for syntax highlighting (e.g., javascript, python, sql) |
| step | number |
optional | Step increment for slider (default: 1) |
| currencyConfig | { currencyMode?: Enum<'dynamic' | 'fixed' | 'record'>; defaultCurrency?: string; currencyField?: string } |
optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. |
| dimensions | integer |
optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) |
| trackHistory | boolean |
optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. |
| group | string |
optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. Precord.type == 'invoice' |
| readonlyWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Predicate (CEL) — field is read-only when TRUE. e.g. Precord.status == 'paid'. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (record.account.tier) faults on every row and refuses every update that writes the field — objectstack validate refuses it. Put such a check in a validations[] script rule, whose condition is read one hop through a reference. |
| requiredWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[] script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (record.account.tier) faults on every row and refuses every write that reaches it — objectstack validate refuses it; put such a check in a validations[] script rule, whose condition is read one hop through a reference. The only slot; the conditionalRequired alias was removed in protocol 17. |
| dueLike | boolean |
optional | Deadline semantic (date / datetime only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with settledWhen to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it. |
| settledWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. Precord.status == 'done'. Evaluated per record like visibleWhen, reading the record's own columns as record. Requires dueLike: true on a date / datetime field; refused otherwise. Display only: nothing on the write path reads it. |
| conditionalFormatting | { condition: string | object; style: Record<string, string> }[] |
optional | Cell formatting rules for this field — [{ condition, style }], the same rule a list view's conditionalFormatting declares. In order, the first rule whose CEL condition holds applies its CSS style map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads value (this field's value on the record) and record (the row) and no other root, e.g. Pvalue < 0 with style: { color: '#b91c1c' }; objectstack validate refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with dueLike / settledWhen instead. Display only: nothing on the write path reads it. |
| widget | string |
optional | Form widget override — names a registered field component (resolved as field:<widget>) to render this field instead of the type default. Degrades to the type renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker". |
| hidden | boolean |
optional (default: false) |
Hidden from default UI |
| internal | boolean |
optional | Never return this field's value on the generic data path — the engine OMITS the key from find/findOne results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in ?select=. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on text columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a required column. |
| readonly | boolean |
optional (default: false) |
Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. approval_status: "approved"), symmetric with readonlyWhen. A stripped INSERT field still falls back to its defaultValue. Exempt from the strip on BOTH paths: isSystem writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (preserveAudit) — which admits a whitelist (the audit/timestamp family plus author-declared business readonly fields). On INSERT the exemption does NOT apply: a non-system create that requests preserveAudit still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips. |
| requiredPermissions | string[] |
optional | [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate). |
| maskingRule | Enum<'phone' | 'id_card' | 'bank_account' | 'email' | 'name'> | { keepHead: integer; keepTail: integer } |
optional | Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138*5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j@example.com, 'name' keep first char) or { keepHead, keepTail }. Masked for every non-system caller unless the field's requiredPermissions are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field. |
| ackPlaintextMasking | boolean |
optional | [ADR-0100] Affirm a generic password field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields. |
| system | boolean |
optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. |
| sortable | boolean |
optional (default: true) |
Whether field is sortable in list views |
| inlineHelpText | string |
optional | Help text displayed below the field in forms |
| placeholder | string |
optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from inlineHelpText (always-visible help rendered beside/under the input) and description (tooltip/developer documentation). |
| autonumberFormat | string |
optional (default: "{0000}") |
Auto-number format: literal text + {0000} counter, {YYYY}/{MM}/{DD}/{YYYYMMDD} date tokens (business tz), and {field_name} interpolation. Counter resets per rendered prefix (e.g. AD{YYYYMMDD}``{0000} resets daily). Omitted on an autonumber field ⇒ the contract default {0000}, which every driver and the engine fallback read, so one field numbers alike on every backend. |
| externalId | boolean |
optional (default: false) |
Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own externalId; an import names it in the mapping's upsertKey. |
| _lock | Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> |
optional | Item-level lock — controls overlay & delete (ADR-0010). |
| _lockReason | string |
optional | Human-readable reason shown when a write is refused by _lock. |
| _lockSource | Enum<'artifact' | 'package' | 'env-forced'> |
optional | Layer that set _lock (artifact | package | env-forced). |
| _provenance | Enum<'package' | 'org' | 'env-forced'> |
optional | Origin of the item (package | org | env-forced). |
| _packageId | string |
optional | Owning package machine id. |
| _packageVersion | string |
optional | Owning package version. |
| _lockDocsUrl | string |
optional | Optional documentation link surfaced next to _lockReason. |
| Property | Type | Required | Description |
|---|---|---|---|
| name | string |
optional | Index name (auto-generated if not provided) |
| fields | string[] |
✅ | Fields included in the index |
| unique | false | 'global' | 'organization' |
optional (default: false) |
Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly fields, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, 'global')) at registration — one holder per organization; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' |
External datasource binding (ADR-0015)
| Property | Type | Required | Description |
|---|---|---|---|
| remoteName | string |
optional | Remote table/view name. Defaults to object.name. |
| remoteSchema | string |
optional | Remote schema/database qualifier. |
| writable | boolean |
optional (default: false) |
Per-object write opt-in (also requires datasource.external.allowWrites). |
| columnMap | Record<string, string> |
optional | Remote column name → local field name. |
| introspectedAt | string |
optional | Set by os datasource introspect; informational. |
| ignoreColumns | string[] |
optional | Remote columns to skip during validation (dev convenience). |
| Property | Type | Required | Description |
|---|---|---|---|
| key | string |
✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's group. |
| label | string |
✅ | Group display label |
| icon | string |
optional | Icon name (Lucide/Material) for the group header |
| description | string |
optional | Optional description shown under the group header |
| relatedList | string |
optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. It names a body table only (a FK declaring relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList): a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused where the child's fields are known (os validate / os build and the metadata write door), naming the FK and the remedy (set relatedList: 'primary' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList). |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. Precord.type == 'invoice' |
| collapse | Enum<'none' | 'expanded' | 'collapsed'> |
optional (default: "none") |
[ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). |
| defaultExpanded | boolean |
optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. |
| collapsible | boolean |
optional | [DEPRECATED → collapse] Boolean pair with collapsed; use the collapse enum. |
| collapsed | boolean |
optional | [DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no collapsible and outranks collapsible: false; false → 'none', or 'expanded' beside collapsible: true. Use the collapse enum. |
ownextendoverlay
This schema accepts one of the following structures:
Type: string[]
| Property | Type | Required | Description |
|---|---|---|---|
| read | string[] |
optional | Capabilities required to read (find/findOne/count/aggregate). |
| create | string[] |
optional | Capabilities required to create (insert). |
| update | string[] |
optional | Capabilities required to update (update/transfer/restore). |
| delete | string[] |
optional | Capabilities required to delete (delete/purge). |
| Property | Type | Required | Description |
|---|---|---|---|
| read | string[] |
optional | Capabilities required to read (find/findOne/count/aggregate). |
| create | string[] |
optional | Capabilities required to create (insert). |
| update | string[] |
optional | Capabilities required to update (update/transfer/restore). |
| delete | string[] |
optional | Capabilities required to delete (delete/purge). |
Boolean-or-predicates override for a built-in CRUD affordance.
| Property | Type | Required | Description |
|---|---|---|---|
| enabled | boolean |
optional | Object-level on/off for the generic affordance; same meaning as the bare boolean form. Omitted → managedBy bucket default. |
| visibleWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | CEL predicate over the record in scope (row record for edit/delete, host record for a related-list create/import toolbar); false → hide the button. Fail-closed. |
| disabledWhen | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } |
optional | CEL predicate over the record in scope (row record for edit/delete, host record for a related-list create/import toolbar); true → render the button disabled. Fail-soft. |
| Property | Type | Required | Description |
|---|---|---|---|
| enabled | boolean |
✅ | Enable multi-tenancy for this object |
| tenantField | string |
optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to organization_id, the kernel-injected column the RLS predicates and tenantPolicy() also assume. A declared name is honoured only when the object really has that field — otherwise the same organization_id fallback applies. No default is materialized here on purpose. |