Object schema — Data Protocol reference
Object schemas of the ObjectStack Data Protocol: ApiMethod, ApiOperation, Index and 13 more — each property with its type, default and a TypeScript example.
Source: packages/spec/src/data/object.zod.ts
TypeScript Usage
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);ApiMethod
Allowed Values
getlistcreateupdatedeletebulk
ApiOperation
Allowed Values
getlistcreateupdatedeleteupsertbulkaggregatehistorysearchrestorepurgeimportexport
Index
Properties
| 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' |
| type | never | optional | [REMOVED] indexes[].type was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. SqlDriver.syncDeclaredIndexes creates every declared index through knex's table.index() / table.unique(), which cannot express an access method, so the value changed no DDL; its .default('btree') merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; gin/gist/fulltext are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| partial | never | optional | [REMOVED] indexes[].partial was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the WHERE clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue CREATE [UNIQUE] INDEX … WHERE <predicate> from a runtime migration (this is what metadata-protocol's ensureOverlayIndex already does for sys_metadata). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Lifecycle
Properties
| 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. |
Nested Shape: Lifecycle.retention
| 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. |
Nested Shape: Lifecycle.ttl
| 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. |
Nested Shape: Lifecycle.storage
| 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. |
Nested Shape: Lifecycle.archive
| 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). |
LifecycleClass
Allowed Values
recordaudittelemetrytransientevent
Object
Properties
| 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 to a formula field designated as nameField. 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. |
| 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. |
| 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). |
| 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. |
Nested Shape: Object.userActions
| 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. |
Nested Shape: Object.systemFields
| 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. |
Nested Shape: Object.external
| 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). |
Nested Shape: Object.fields[string]
| 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. |
| 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. 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. |
| 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 (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (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 | Formula expression (CEL). e.g. Frecord.amount * 0.1 |
| 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'>; defaultCurrency?: 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. |
| conditionalRequired | never | optional | [REMOVED] conditionalRequired was removed in @objectstack/spec 17 — use requiredWhen. Rename the key; the value (a CEL predicate) is unchanged. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| 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) | Is external ID for upsert operations |
| _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. |
Nested Shape: Object.indexes[number]
| 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' |
| type | never | optional | [REMOVED] indexes[].type was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. SqlDriver.syncDeclaredIndexes creates every declared index through knex's table.index() / table.unique(), which cannot express an access method, so the value changed no DDL; its .default('btree') merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; gin/gist/fulltext are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| partial | never | optional | [REMOVED] indexes[].partial was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the WHERE clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue CREATE [UNIQUE] INDEX … WHERE <predicate> from a runtime migration (this is what metadata-protocol's ensureOverlayIndex already does for sys_metadata). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
Nested Shape: Object.fieldGroups[number]
| 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 |
| 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. |
Nested Shape: Object.tenancy
| 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. |
Nested Shape: Object.access
| 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). |
Nested Shape: Object.requiredPermissions
| 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). |
Nested Shape: Object.lifecycle
| 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. |
Nested Shape: Object.activityMilestones[number]
| 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). |
Nested Shape: Object.listViews[string]
| 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 | string[] | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters |
| 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<'page' | 'drawer' | 'modal' | 'split' | 'popover' | 'new_window' | 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>; … } | optional | Configuration for item click navigation (page, drawer, modal, etc.) |
| pagination | { pageSize?: integer; pageSizeOptions?: integer[] } | optional | Pagination configuration |
| kanban | { groupByField: string; summarizeField?: string; titleField?: string; columns: 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'>; mode?: Enum<'inline' | 'form' | 'modal'>; formView?: string } | 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 |
| responsive | never | optional | [REMOVED] view.responsive was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer ever read it; the grid is responsive by its own layout rules. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| performance | never | optional | [REMOVED] view.performance was removed in @objectstack/spec 17.0.0 (audit close-out) — no renderer or runtime read it; list-view performance tuning was never implemented. Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| striped | never | optional | [REMOVED] view.striped was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it, so authoring it was a parse-clean no-op. There is no authorable striped-rows switch; delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| bordered | never | optional | [REMOVED] view.bordered was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| virtualScroll | never | optional | [REMOVED] view.virtualScroll was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via pagination. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| userFilters | { element?: Enum<'dropdown' | 'toggle'>; fields?: object[] } | optional |
Nested Shape: Object.enable
| Property | Type | Required | Description |
|---|---|---|---|
| trackHistory | boolean | optional (default: false) | Show the record History tab (audit-trail UI). 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 |
Nested Shape: Object.publicSharing
| 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. |
Nested Shape: Object.actions[number]
| 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 ./ — 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). |
| execute | never | optional | [REMOVED] execute was removed in @objectstack/spec 17 — use target. Rename the key; the value (a handler / flow / URL ref) is unchanged. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| 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 | Record<string, string> | optional | Confirmation message before execution. 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 | Record<string, string> | optional | Success message shown after the action succeeds. On a type: 'api' or type: 'script' action it may interpolate ${result.*} — the server response payload, the same scope onSuccess.navigate declares (e.g. ${result.id}). 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. |
| requiredPermissions | string[] | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a type: api action pointed at a custom endpoint must re-check it there. |
| shortcut | never | optional | [REMOVED] action.shortcut was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| bulkEnabled | never | optional | [REMOVED] action.bulkEnabled was removed in @objectstack/spec 17.0.0 (audit close-out) — the multi-select toolbar is driven by the LIST VIEW's bulkActions / bulkActionDefs, never by this flag, so setting it changed nothing. Delete the key and declare the action in the view's bulkActions instead. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| 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 | Direct new-tab URL template ({recordId} placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab here immediately — no action POST. The endpoint must enforce auth itself. |
| onSuccess | { navigate: string; openIn?: Enum<'self' | 'newTab'> } | optional | Post-success navigation for type:'api' and type:'script' actions. navigate is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response); openIn defaults 'self'. 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. |
Nested Shape: Object.protection
| 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. |
ObjectAccessConfig
Properties
| 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). |
ObjectCapabilities
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| trackHistory | boolean | optional (default: false) | Show the record History tab (audit-trail UI). 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 |
ObjectExtension
Properties
| 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) |
Nested Shape: ObjectExtension.fields[string]
| 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. |
| 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. 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. |
| 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 (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (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 | Formula expression (CEL). e.g. Frecord.amount * 0.1 |
| 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'>; defaultCurrency?: 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. |
| conditionalRequired | never | optional | [REMOVED] conditionalRequired was removed in @objectstack/spec 17 — use requiredWhen. Rename the key; the value (a CEL predicate) is unchanged. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| 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) | Is external ID for upsert operations |
| _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. |
Nested Shape: ObjectExtension.indexes[number]
| 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' |
| type | never | optional | [REMOVED] indexes[].type was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. SqlDriver.syncDeclaredIndexes creates every declared index through knex's table.index() / table.unique(), which cannot express an access method, so the value changed no DDL; its .default('btree') merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; gin/gist/fulltext are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
| partial | never | optional | [REMOVED] indexes[].partial was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the WHERE clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue CREATE [UNIQUE] INDEX … WHERE <predicate> from a runtime migration (this is what metadata-protocol's ensureOverlayIndex already does for sys_metadata). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand. |
ObjectExternalBinding
External datasource binding (ADR-0015)
Properties
| 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). |
ObjectFieldGroup
Properties
| 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 |
| 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. |
ObjectOwnershipEnum
Allowed Values
ownextendoverlay
ObjectRequiredPermissions
Union Options
This schema accepts one of the following structures:
Option 1
Type: string[]
Option 2
Properties
| 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). |
PerOperationRequiredPermissions
Properties
| 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). |
RowCrudActionOverride
Boolean-or-predicates override for a built-in CRUD affordance.
Properties
| 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. |
TenancyConfig
Properties
| 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. |
Mapping
Mapping schemas of the ObjectStack Data Protocol: ImportFieldMapping, Mapping, TransformType — each property with its type, default and a TypeScript example.
Validation
This module defines the validation schema protocol for ObjectStack, providing a comprehensive type-safe validation system similar to Salesforce's validation…