Skip to content

Latest commit

 

History

History
796 lines (650 loc) · 143 KB

object.mdx

File metadata and controls

796 lines (650 loc) · 143 KB
title Object schema — Data Protocol reference
navTitle Object
description Object schemas of the ObjectStack Data Protocol: ApiMethod, ApiOperation, Index and 13 more — each property with its type, default and a TypeScript example.

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

**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

  • get
  • list
  • create
  • update
  • delete
  • bulk

ApiOperation

Allowed Values

  • get
  • list
  • create
  • update
  • delete
  • upsert
  • bulk
  • aggregate
  • history
  • search
  • restore
  • purge
  • import
  • export

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'

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

  • record
  • audit
  • telemetry
  • transient
  • event

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 title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time.
imageField string optional The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is image or avatar; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder).
highlightFields string[] optional [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout.
relatedLists string[] optional Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards.
stageField string | false optional [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. When the named field carries options, it must be a single-value select / radio with inline options, each declaring its kind ('open' / 'won' / 'lost'); a picklist-backed or multi-value stage field is refused. A name that resolves to no declared field, or a field with no options, is not judged at parse.
editMode Enum<'modal' | 'page'> optional Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family).
listViews Record<string, { name?: string; label?: string | Record<string, string>; type?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>; data?: object | object | object; … }> optional Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047)
searchableFields string[] optional Fields the $search query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual formula field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that.
enable { trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' | 'list' | 'create' | 'update' | 'delete' | 'bulk'>[]; … } optional Enabled system features modules
sharingModel Enum<'private' | 'public_read' | 'public_read_write' | 'controlled_by_parent'> optional Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) | public_read (everyone reads, owner writes) | public_read_write (everyone reads+writes) | controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1).
sharing { controlledByParent?: object } optional Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment).
defaultRowScope { name: string; label?: string; description?: string; operation: Enum<'select' | 'insert' | 'update' | 'delete' | 'all'>; … } optional The object's default row scope: one row-level security policy, written like a permission set's rowLevelSecurity entry without object (the declaring object is implied; writing object is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object ('*') counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its operation, positions and enabled decide where it applies, and a using the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. os validate, os build and os lint judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's.
membershipKeys Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }> optional Membership keys this object defines, by name: each makes current_user.KEY the set of valueField values on this object's rows whose userField is the signed-in user and on which the optional filter holds, for a row-level security policy to read with in (e.g. supplier in current_user.portal_supplier_ids, declared on a supplier-contact object as portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (id, organization_id, positions, org_user_ids, accessible_org_ids, email, queues) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a filter that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key.
externalSharingModel never optional [REMOVED] object.externalSharingModel was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. sharingModel is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (publicSharing). Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
publicSharing { enabled?: boolean; allowedAudiences?: Enum<'public' | 'link_only' | 'signed_in' | 'email'>[]; allowedPermissions?: Enum<'view' | 'comment' | 'edit'>[]; maxExpiryDays?: integer; … } optional Public share-link policy (Notion/Figma-style link sharing)
actions { name: string; label: string | Record<string, string>; description?: string | Record<string, string>; objectName?: string; … }[] optional Actions associated with this object (auto-populated from top-level actions via objectName)
protection { lock: Enum<'none' | 'no-overlay' | 'no-delete' | 'full'>; reason: string; docsUrl?: string } optional Package author protection block — lock policy for this object.
_lock Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> optional Item-level lock — controls overlay & delete (ADR-0010).
_lockReason string optional Human-readable reason shown when a write is refused by _lock.
_lockSource Enum<'artifact' | 'package' | 'env-forced'> optional Layer that set _lock (artifact | package | env-forced).
_provenance Enum<'package' | 'org' | 'env-forced'> optional Origin of the item (package | org | env-forced).
_packageId string optional Owning package machine id.
_packageVersion string optional Owning package version.
_lockDocsUrl string optional Optional documentation link surfaced next to _lockReason.

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.
percentDisplay Enum<'bar' | 'plain'> optional How a percent field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; bar = pin the progress bar; plain = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (scale) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does.
accept string[] optional Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write.
maxSize integer optional Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser.
options { label: string; value: string; description?: string; color?: string; … }[] optional Static options for the option types. A select / radio field needs a non-empty list here or a picklist — with neither (or options: []) it is refused at parse.
picklist string optional Name of a shared picklist whose options this field offers — instead of options, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty options; with neither it is refused at parse.
reference string optional Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (sys_metadata, sys_metadata_history): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and os validate, os build and the object save door refuse it. On a tree field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out.
referenceVia string optional Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. record_id with referenceVia: 'object_name'. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with reference (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior.
referenceDeletedWhen { field: string; value: string } optional Marks the record a referenceVia pointer names as PLATFORM-DELETED on rows where a sibling select field holds one declared option — e.g. record_id with { field: 'cancel_reason', value: 'record_deleted' }. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside referenceVia; field must be a single-value select on the same object declaring inline options, and value one of its option values — any other shape is refused at parse.
mailAssociation boolean optional Inbound mail association (email fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed.
deleteBehavior Enum<'set_null' | 'cascade' | 'restrict'> optional (default: "set_null") What happens if referenced record is deleted
inlineEdit boolean | Enum<'grid' | 'form'> optional Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form.
inlineTitle string optional Title for the inline master-detail grid
inlineColumns { name: string; label?: string; type?: Enum<'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file'>; width?: number; … }[] optional Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column ({ name, label?, type?, … } — objectui GridColumn); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired field spelling are refused at parse.
inlineAmountField string optional Numeric child field summed for the inline grid total
relatedList boolean | 'primary' optional Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record's related column (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=<field> / sort=-<field>, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order.
relatedListTitle string optional Title for the detail-page related list
relatedListColumns string[] optional Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse.
relatedListFilter any optional Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition { [referenceField]: parentId } — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query where), e.g. { status: { $ne: 'deleted' } } to hide soft-deleted children.
displayField string optional Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title).
descriptionField string optional Secondary field shown under the label in the quick-select popover.
lookupColumns (string | { field: string; label?: string; width?: string; type?: string })[] optional Explicit columns for the record-picker table; auto-derived from the referenced object when omitted.
lookupPageSize integer optional Rows per page in the record-picker dialog (default 10).
lookupFilters { field: string; operator: Enum<'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'in' | 'notIn'>; value: any }[] optional Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter.
dependsOn (string | { field: string; param?: string })[] optional Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For lookup/master_detail it scopes the candidate query (string = same local/remote key; {field,param} when the remote filter key differs — the {field,param} form is lookup-only). For select/multiselect/radio the actual per-option rule lives in each option's visibleWhen; list the referenced fields here (string form) so the option list gates and refreshes with the parent.
allowCreate boolean optional Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field.
expression string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional CEL expression the field's value is computed from, e.g. Frecord.amount * 0.1. On a formula field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a summary can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused).
returnType Enum<'number' | 'text' | 'boolean' | 'date' | 'currency'> optional Inferred value type of a formula field (number/text/boolean/date/currency). currency: an amount of money, in the currency the formula's own currencyConfig declares, as a currency field declares it (no currencyConfig: dynamic, the tenant default currency).
summaryOperations { object: string; field?: string; function: Enum<'count' | 'sum' | 'min' | 'max' | 'avg'>; relationshipField?: string; … } optional Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted.
language string optional Programming language for syntax highlighting (e.g., javascript, python, sql)
step number optional Step increment for slider (default: 1)
currencyConfig { currencyMode?: Enum<'dynamic' | 'fixed' | 'record'>; defaultCurrency?: string; currencyField?: string } optional Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode.
dimensions integer optional Vector dimensionality (e.g., 1536 for OpenAI embeddings)
trackHistory boolean optional Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field.
group string optional Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system")
visibleWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. Precord.type == 'invoice'
readonlyWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Predicate (CEL) — field is read-only when TRUE. e.g. Precord.status == 'paid'. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (record.account.tier) faults on every row and refuses every update that writes the field — objectstack validate refuses it. Put such a check in a validations[] script rule, whose condition is read one hop through a reference.
requiredWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[] script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (record.account.tier) faults on every row and refuses every write that reaches it — objectstack validate refuses it; put such a check in a validations[] script rule, whose condition is read one hop through a reference. The only slot; the conditionalRequired alias was removed in protocol 17.
dueLike boolean optional Deadline semantic (date / datetime only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with settledWhen to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it.
settledWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. Precord.status == 'done'. Evaluated per record like visibleWhen, reading the record's own columns as record. Requires dueLike: true on a date / datetime field; refused otherwise. Display only: nothing on the write path reads it.
conditionalFormatting { condition: string | object; style: Record<string, string> }[] optional Cell formatting rules for this field — [{ condition, style }], the same rule a list view's conditionalFormatting declares. In order, the first rule whose CEL condition holds applies its CSS style map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads value (this field's value on the record) and record (the row) and no other root, e.g. Pvalue < 0 with style: { color: '#b91c1c' }; objectstack validate refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with dueLike / settledWhen instead. Display only: nothing on the write path reads it.
widget string optional Form widget override — names a registered field component (resolved as field:<widget>) to render this field instead of the type default. Degrades to the type renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker".
hidden boolean optional (default: false) Hidden from default UI
internal boolean optional Never return this field's value on the generic data path — the engine OMITS the key from find/findOne results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in ?select=. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on text columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a required column.
readonly boolean optional (default: false) Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. approval_status: "approved"), symmetric with readonlyWhen. A stripped INSERT field still falls back to its defaultValue. Exempt from the strip on BOTH paths: isSystem writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (preserveAudit) — which admits a whitelist (the audit/timestamp family plus author-declared business readonly fields). On INSERT the exemption does NOT apply: a non-system create that requests preserveAudit still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips.
requiredPermissions string[] optional [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate).
maskingRule Enum<'phone' | 'id_card' | 'bank_account' | 'email' | 'name'> | { keepHead: integer; keepTail: integer } optional Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138*5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j@example.com, 'name' keep first char) or { keepHead, keepTail }. Masked for every non-system caller unless the field's requiredPermissions are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field.
ackPlaintextMasking boolean optional [ADR-0100] Affirm a generic password field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields.
system boolean optional Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag.
sortable boolean optional (default: true) Whether field is sortable in list views
inlineHelpText string optional Help text displayed below the field in forms
placeholder string optional Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from inlineHelpText (always-visible help rendered beside/under the input) and description (tooltip/developer documentation).
autonumberFormat string optional (default: "{0000}") Auto-number format: literal text + {0000} counter, {YYYY}/{MM}/{DD}/{YYYYMMDD} date tokens (business tz), and {field_name} interpolation. Counter resets per rendered prefix (e.g. AD{YYYYMMDD}``{0000} resets daily). Omitted on an autonumber field ⇒ the contract default {0000}, which every driver and the engine fallback read, so one field numbers alike on every backend.
externalId boolean optional (default: false) Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own externalId; an import names it in the mapping's upsertKey.
_lock Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> optional Item-level lock — controls overlay & delete (ADR-0010).
_lockReason string optional Human-readable reason shown when a write is refused by _lock.
_lockSource Enum<'artifact' | 'package' | 'env-forced'> optional Layer that set _lock (artifact | package | env-forced).
_provenance Enum<'package' | 'org' | 'env-forced'> optional Origin of the item (package | org | env-forced).
_packageId string optional Owning package machine id.
_packageVersion string optional Owning package version.
_lockDocsUrl string optional Optional documentation link surfaced next to _lockReason.

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'

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
relatedList string optional Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. It names a body table only (a FK declaring relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList): a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused where the child's fields are known (os validate / os build and the metadata write door), naming the FK and the remedy (set relatedList: 'primary' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList).
visibleWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. Precord.type == 'invoice'
collapse Enum<'none' | 'expanded' | 'collapsed'> optional (default: "none") [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed).
defaultExpanded boolean optional [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'.
collapsible boolean optional [DEPRECATED → collapse] Boolean pair with collapsed; use the collapse enum.
collapsed boolean optional [DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no collapsible and outranks collapsible: false; false → 'none', or 'expanded' beside collapsible: true. Use the collapse enum.

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 never optional [REMOVED] view.list.filterableFields was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — the key was declared as a shorthand for userFilters.fields (the quick-filter bar), but the one renderer that read it used it as a whitelist of the fields the Filter panel offers instead, so one key carried two meanings and drew no quick filter. Delete the key; no per-view Filter-panel whitelist replaces it. For a curated end-user filter bar, declare userFilters: { element: 'dropdown', fields: [{ field: '<field_name>' }] }. The Filter panel offers every field the user may read: field read security decides, and a field the object definition marks hidden: true is not offered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
resizable boolean optional Enable column resizing
compactToolbar boolean optional Collapse Group/Color/Density/Hide-fields into a single View settings popover
selection { type?: Enum<'none' | 'single' | 'multiple'> } optional Row selection configuration
navigation { mode?: Enum<'drawer' | 'page' | 'new_window' | 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>; … } optional Configuration for item click navigation (drawer by default, page, new_window or none)
pagination { pageSize?: integer; pageSizeOptions?: integer[] } optional Pagination configuration
kanban { groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … } optional Kanban-board configuration — applies when the view renders as a kanban layout
calendar { startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … } optional Calendar configuration — applies when the view renders as a calendar layout
gantt { startDateField: string; endDateField: string; titleField: string; progressField?: string; … } optional Gantt-timeline configuration — applies when the view renders as a gantt layout
gallery { coverField?: string; coverFit?: Enum<'cover' | 'contain'>; cardSize?: Enum<'small' | 'medium' | 'large'>; titleField?: string; … } optional Gallery/card view configuration
timeline { startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … } optional Timeline view configuration
chart { chartType?: Enum<'bar' | 'line' | 'pie' | 'area' | 'scatter'>; dataset: string; dimensions?: string[]; values: string[] } optional Chart binding — applies when the view renders as a chart. A type: 'chart' view must bind one: it names the ADR-0021 dataset and the measures (values) the chart plots, and there is no default binding
map { latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … } optional Map configuration — applies when the view renders as a map layout
tree { parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } optional Tree/hierarchy configuration — applies when the view renders as a tree layout
pageName never optional [REMOVED] view.pageName was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a type: 'page' view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — { type: 'page', pageName: '<page_name>' } under the app's navigation — which is a different key on a different surface and is the page mount that has always rendered. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
description string | Record<string, string> optional View description for documentation/tooltips
sharing { type?: Enum<'personal' | 'collaborative'>; lockedBy?: string } optional View sharing and access configuration
requiredPermissions string[] optional [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through systemPermissions, the same list as requiredPermissions on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object.
rowHeight Enum<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'> optional Row height / density setting
grouping { fields: object[] } optional Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema)
rowColor { field: string; colors?: Record<string, string> } optional Color rows based on field value
hiddenFields string[] optional Fields to hide in this specific view — the SUBTRACTION of the per-view field composition columns x hiddenFields x fieldOrder: each name listed here is removed from the columns projection before fieldOrder orders the remainder. A name columns never projected subtracts nothing.
fieldOrder string[] optional Explicit field display order for this view — the ORDERING of the per-view field composition columns x hiddenFields x fieldOrder: it sorts what survives columns minus hiddenFields and never adds a field. A surviving column absent from fieldOrder sorts LAST, after every listed one, keeping its columns-relative order; a name listed here that did not survive orders nothing.
rowActions string[] optional Actions available for individual row items
bulkActions string[] optional Actions available when multiple rows are selected
bulkActionDefs { name: string; label?: string; icon?: string; variant?: Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'outline'>; … }[] optional Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a patch / 'delete') that no action expresses, or for an operation: 'custom' + execution: 'aggregate' entry that dispatches the action it NAMES once for the whole selection — the renderer injects params._selectedIds: string[] (read that on the server, not recordId) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. batchSize does not apply (the call is never chunked); set maxRecords on defs whose server work is expensive. For the PER-RECORD dispatch use bulkActions: ['<name>'] instead — the bare-string form, promoted with the action's own label, params and visible; a 'custom' def without execution: 'aggregate' has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via ${ctx.selection.ids} / ${ctx.selection.count}.
conditionalFormatting { condition: string | object; style: Record<string, string> }[] optional Conditional formatting rules for list rows
inlineEdit boolean optional Allow inline editing of records directly in the list view
exportOptions Enum<'csv' | 'xlsx' | 'json'>[] | { formats?: Enum<'csv' | 'xlsx' | 'json'>[]; maxRecords?: integer; includeHeaders?: boolean; fileNamePrefix?: string; … } optional Export configuration for the list toolbar export menu: { formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }. A bare format array is the legacy spelling and lifts to { formats: [...] } at parse.
userActions { sort?: boolean; search?: boolean; filter?: boolean; refresh?: boolean; … } optional User action toggles for the view toolbar
appearance { showDescription?: boolean; allowedVisualizations?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>[] } optional Appearance and visualization configuration
tabs never optional [REMOVED] view.list.tabs was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no renderer ever mounted a tab bar for it, so authoring it drew nothing: the tab strip above an object's records is the saved-view switcher (ViewTabBar), which renders one tab per named list view and never read this key. Delete the key, and move each tab you want to a named list view under the object's listViews instead: the tab's name becomes the entry's key, its label the entry's label, and its filter rules join the view's own filter on that entry (copy the view's columns too); a tab whose view already named a list view needs nothing more. Every listViews entry renders as a tab in the switcher. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
addRecord { enabled: boolean; position?: Enum<'top' | 'bottom' | 'both'> } optional Add record entry point configuration
showRecordCount boolean optional Show record count at the bottom of the list
allowPrinting boolean optional Allow users to print the view
emptyState { title?: string | Record<string, string>; message?: string | Record<string, string>; icon?: string; link?: object } optional Empty state configuration when no records found
aria { ariaLabel?: string | Record<string, string>; ariaDescribedBy?: string; role?: string } optional ARIA accessibility attributes for the list view
userFilters { element?: Enum<'dropdown' | 'toggle'>; fields?: object[] } optional

Nested Shape: Object.enable

Property Type Required Description
trackHistory boolean optional (default: false) Show the record's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. On an authored page, a record:history or record:activity block shows the record's field changes from its own activity, and neither reads this flag. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance
searchable boolean optional (default: true) Index records for global search
apiEnabled boolean optional (default: true) Expose object via automatic APIs
apiMethods Enum<'get' | 'list' | 'create' | 'update' | 'delete' | 'bulk'>[] optional Whitelist of allowed API operations (six primitives; undefined = all, [] = none)
files boolean optional (default: false) Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike. Field.file/Field.image are independent
feeds boolean optional (default: true) Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike
activities boolean optional (default: true) Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline
clone boolean optional (default: true) Allow record deep cloning
approvalsVisibleToReaders boolean optional (default: false) Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it

Nested Shape: Object.sharing

Property Type Required Description
controlledByParent { ownerMayEdit?: boolean } optional Opt-ins read only under sharingModel: 'controlled_by_parent' (ADR-0055 §3): ownerMayEdit.

Nested Shape: Object.defaultRowScope

Property Type Required Description
name string ✅ Policy unique identifier (snake_case)
label string optional Human-readable policy label
description string optional Policy description and business justification
operation Enum<'select' | 'insert' | 'update' | 'delete' | 'all'> ✅ Database operation this policy applies to
using string optional Row predicate, authored in canonical CEL (ADR-0058 D1): the rows a select policy lets a caller read, and the existing rows an update or delete policy lets a caller change or remove. On an insert or an update, when no applicable policy for that operation declares check, each applicable policy's using also stands in as its check on every row written (see check); on an insert policy that is its only effect. It enforces when the predicate lowers to an ObjectQL filter: a field compared against a literal or a current_user.* context value using ==, !=, <, <=, > or >=; in against a current_user.* array or an inline literal list (e.g. status in ['draft', 'pending']); these combined with && / ||; or the bare allow-all true. Anything that does not lower fails closed — the policy matches zero rows. The legacy SQL-ish spellings are still accepted through a transitional bridge that rewrites = to == and IN to in (deprecated under ADR-0058 D1); SQL AND / OR / NOT IN / IS NULL / LIKE are NOT bridged and fail closed. Needed on a select or delete policy (a check there is refused); optional on an insert, update or all policy that declares check.
check string optional Validation condition judged on every row an insert or an update writes (enforced at application level): each row of an insert, an array insert included, as its beforeInsert hooks leave it, and each row an update changes, by id or multi: true, as the prior row merged with the final payload after its beforeUpdate hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the prior row merged with the change set as sent. The default to using is decided per operation across the applicable policies, not per policy: when any applicable policy for that operation declares check, only the declared checks decide (OR-combined) and a USING-only sibling adds nothing; only when none declares check does each applicable policy's using stand in as its check (OR-combined). Applicable = not enabled: false, object matches or is '*', operation matches or is 'all', and the caller holds one of its positions when it lists any. Refused on a policy whose operation is select or delete, which write no new row to check: limit the rows such a policy admits with using, and declare the check on an insert, update or all policy.
positions string[] optional Positions this policy applies to (omit for all)
enabled boolean optional (default: true) Whether this policy is active

Nested Shape: Object.membershipKeys[string]

Property Type Required Description
userField string ✅ The column of this object that holds the user a row belongs to: a single-valued user reference (user, or a lookup / master_detail to sys_user). A row counts for the signed-in user when this column holds their user id.
valueField string ✅ The column of this object whose values form the set: a reference (lookup, master_detail or user), multi-valued allowed. A policy compares the set with a column that references the same object, or with id on that object. Never the same column as userField.
filter string optional Optional condition a roster row must also meet, in canonical CEL over this object's own columns and within the row-level security subset a policy's using lowers (e.g. "active == true"). It may not read any current_user.* value.
label string optional Human-readable name of the set
description string optional What the set holds and who it is for

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 ./ (a console route; a record is /apps/APP/OBJECT/record/ID, APP being the app's route segment — see onSuccess.navigate) — every other scheme and a protocol-relative //host are refused.
openIn Enum<'self' | 'new-tab'> optional For type:'url' — where to open target. 'new-tab' opens a new browser tab; 'self' navigates in place. When omitted, external/absolute URLs open in a new tab and relative URLs navigate in place. Static execution option — keep it OUT of params (which is user-input-collection only).
body { language: 'expression'; source: string } | { language: 'js'; source: string; capabilities?: Enum<'api.read' | 'api.write' | 'api.transaction' | 'crypto.uuid' | 'log'>[]; timeoutMs?: integer; … } optional Action body — expression (L1) or sandboxed JS (L2). Only used when type is script.
operation Enum<'update'> optional The declarative single-record field write, mirroring a list view's bulkActionDefs: 'update' applies patch (merged under the collected params) to the current record on the data plane AS THE CALLER — never system-elevated — so the caller's permissions, the object's hooks and its validations fire as for a user edit. type stays at its default 'script' (the platform action route the write is performed on); target/body/method/bodyExtra are refused beside it. 'delete' and 'custom' have no row-level form.
patch Record<string, any> optional For operation: 'update' — static field values written to the current record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without operation: 'update' (it would be silently dropped).
execution Enum<'perRecord' | 'aggregate'> optional The bulk dispatch contract this action's BODY is written for, in bulkActionDefs' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's recordId (the view's bulkActions: ['<name>'] bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in params._selectedIds (a bulkActionDefs entry with execution: 'aggregate'). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by @objectstack/lint (action-dispatch-contract-mismatch).
params { name?: string; field?: string; objectOverride?: string; label?: string | Record<string, string>; … }[] optional Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in bodyExtra).
variant Enum<'primary' | 'secondary' | 'danger' | 'ghost' | 'link'> optional Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent)
order number optional Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without order keep their registration order.
confirmText string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string> optional Confirmation message before execution — a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over record, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's params is an object of static values — params ({{ params.tier }}); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete {{ record.name }}?'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty params is refused — that opens a second dialog for one decision; put the question on description instead. Correct on a param-LESS action, where the confirm is the only dialog there is.
successMessage string | { dialect: 'template'; source?: string; ast?: any; meta?: object } | string | Record<string, string> optional Success message shown after the action succeeds — a template slot (ADR-0032): a bare string is shorthand for { dialect: 'template', source }, and text with no hole is shown as written. Holes are {{ path }} or {{ path | formatter[:arg] }} over result, the payload the action's handler answered ({{ result.email }}; for an api or script action the server response), and record, the record the action ran against. Formatters come from the template engine's whitelist (TEMPLATE_FORMATTERS, @objectstack/formula); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to {{ result.email }}'. A hole over any other root, a ${…} or single-brace {x} token (each named with its {{ }} spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at objectstack validate, compile and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map ({ en, "zh-CN" }) is accepted too, each value such a sentence. On a registered action that declares outcomeMessages, the entry named by the response's outcome is shown instead, and this message is the fallback.
outcomeMessages Record<string, string | Record<string, string>> optional Success copy per handler outcome, for type:'api' and type:'script' actions: keys are the snake_case outcome values the handler returns in its success payload (e.g. archived, already_archived), values the message shown for that outcome. Each message may interpolate ${result.*}, the scope onSuccess.navigate declares. An outcome with no entry here falls back to successMessage, then to the default text. Not allowed beside resultDialog (which suppresses the success toast) or operation: 'update' (no handler, so no outcome).
errorMessage string | Record<string, string> optional Error message to show when the action fails (overrides the raw error).
refreshAfter boolean optional (default: false) Refresh view after execution
undoable boolean optional Offer an Undo affordance after this single-record update action succeeds. operation: 'update' is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, patch UNDER the collected params, not patch alone. An action with no operation declares no write set here, but type: 'api' is fulfilled by the console, which builds the undo envelope from undoable alone. Those two shapes — operation: 'update' and type: 'api' — are the whole fulfillable set; on a registered action undoable beside any other shape is refused, because nothing would anchor the capture.
resultDialog { title?: string | Record<string, string>; description?: string | Record<string, string>; acknowledge?: string | Record<string, string>; format?: Enum<'qrcode' | 'code-list' | 'secret' | 'text' | 'json'>; … } optional Render API response in a one-shot reveal dialog (suppresses successMessage when set).
visible boolean | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Visibility predicate — true/false literal, CEL string, or {dialect, source} envelope. The action is offered when it evaluates TRUE. Omit = always visible.
requiresFeature Enum<'twoFactor' | 'organization' | 'multiOrgEnabled' | 'degradedTenancy' | …> optional Public auth feature flag gating this action; lowered into visible at parse time.
requiresMembershipReach Enum<'invite_member' | 'cancel_invitation' | 'update_member_role' | …> optional Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into visible over current_user.positions at parse time.
disabled boolean | string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Disabled predicate — true/false literal, CEL string, or {dialect, source} envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled.
disabledReason string | Record<string, string> optional Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while disabled evaluates TRUE — the default-language string, or an inline locale map; translated at objects.OBJECT._actions.ACTION.disabledReason (globalActions.ACTION.disabledReason for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, requiredPermissions), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused.
pressed string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Pressed-state predicate (CEL string or {dialect, source} envelope) over the same scope as visible: the action surface draws the button as already pressed for this reader (a toggle face, aria-pressed) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under attachedOnRead, as has(record.viewer) && record.viewer.vote == 'helpful'. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed.
requiredPermissions string[] optional [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (POST /actions/:object/:action and MCP run_action) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a type: api action whose declared target is a same-origin platform path (exactly one leading /, followed by neither / nor a backslash, with a path after it) under that door; its target endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A type: api action with an off-origin target is browser-only and not gated server-side.
ai { exposed?: boolean; description?: string; category?: Enum<'data' | 'action' | 'flow' | 'integration' | 'vector_search' | 'analytics' | 'utility'>; paramHints?: Record<string, object>; … } optional AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents.
recordIdParam string optional Body key to inject the row id into when running from a list_item context.
recordIdField string optional Row field whose value seeds recordIdParam. Defaults to "id".
bodyShape 'flat' | { wrap: string } optional Body wrapping: flat (default) or { wrap: key } to nest user-collected params under a key.
method Enum<'POST' | 'PATCH' | 'PUT' | 'DELETE'> optional HTTP method for type:"api" actions. Defaults to POST.
bodyExtra Record<string, any> optional Static request-body fields for a type:"api" action, merged last (overrides user params). {{page.<var>}} tokens are resolved by the runtime. This — not params — is where a payload goes.
mode Enum<'create' | 'edit' | 'delete' | 'custom'> optional Semantic mode of the action.
opensInNewTab boolean optional Open the action result in a new tab. The renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl.
newTabUrl string optional New-tab path template on this backend, starting with / ({recordId} placeholder). When set with opensInNewTab, the renderer navigates the pre-opened tab to the backend origin + this path immediately — no action POST. The endpoint must enforce auth itself. An absolute URL is refused; an external page is a type:'url' action with openIn: 'new-tab'.
onSuccess { navigate: string; openIn?: Enum<'self' | 'newTab'> } optional Post-success navigation for type:'api', type:'script' and type:'flow' actions. navigate is a route/URL template interpolating ${param.*}, ${ctx.*} and ${result.*} (the server response; for a flow, the completed run's answer); openIn defaults 'self'. A flow run that pauses at a screen or ends refused performs no navigation. The handler-return convention ({ redirectUrl } without openIn) keeps its 17.0.0 new-tab behavior.
aria never optional [REMOVED] action.aria was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — no action surface ever applied it: the button, icon, menu, group and bar renderers, the row and bulk action menus and the record quick-actions toolbar all take the accessible name from the action's label and never read this block, so ARIA attributes declared here parsed and then silently did not reach the DOM. Delete the key. The accessible name that IS applied is the action's required label — the visible button or menu-item text, and the aria-label of an icon-only action — so write the name you meant there. To name the region that PLACES the actions, author ariaLabel / ariaDescribedBy / role in the aria block of the placing node: page.components[].aria (the component that renders the actions) or the list view aria. Run os migrate meta --from 17 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
_lock Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> optional Item-level lock — controls overlay & delete (ADR-0010).
_lockReason string optional Human-readable reason shown when a write is refused by _lock.
_lockSource Enum<'artifact' | 'package' | 'env-forced'> optional Layer that set _lock (artifact | package | env-forced).
_provenance Enum<'package' | 'org' | 'env-forced'> optional Origin of the item (package | org | env-forced).
_packageId string optional Owning package machine id.
_packageVersion string optional Owning package version.
_lockDocsUrl string optional Optional documentation link surfaced next to _lockReason.

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's field changes (audit-trail UI): on the default record page they join the record's one timeline stream, under its Changes filter. On an authored page, a record:history or record:activity block shows the record's field changes from its own activity, and neither reads this flag. Pair with per-field trackHistory to pick which field diffs are summarized; audit capture itself is always on for compliance
searchable boolean optional (default: true) Index records for global search
apiEnabled boolean optional (default: true) Expose object via automatic APIs
apiMethods Enum<'get' | 'list' | 'create' | 'update' | 'delete' | 'bulk'>[] optional Whitelist of allowed API operations (six primitives; undefined = all, [] = none)
files boolean optional (default: false) Generic record Attachments panel (sys_attachment). Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike. Field.file/Field.image are independent
feeds boolean optional (default: true) Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike
activities boolean optional (default: true) Record activity timeline (sys_activity mirror of CRUD). Default on; explicit false stops mirroring and hides the timeline
clone boolean optional (default: true) Allow record deep cloning
approvalsVisibleToReaders boolean optional (default: false) Opt-in: true lets a caller who can read a record of this object see that record's approval requests and full action history (comments and attachments included), read-only, on the approvals API and the generic data API alike, on a read that names the record. No approval action is offered. Default false: only the request's participants (submitter, approvers, past actors) and administrators see it

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.
percentDisplay Enum<'bar' | 'plain'> optional How a percent field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; bar = pin the progress bar; plain = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (scale) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does.
accept string[] optional Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write.
maxSize integer optional Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser.
options { label: string; value: string; description?: string; color?: string; … }[] optional Static options for the option types. A select / radio field needs a non-empty list here or a picklist — with neither (or options: []) it is refused at parse.
picklist string optional Name of a shared picklist whose options this field offers — instead of options, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty options; with neither it is refused at parse.
reference string optional Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (sys_metadata, sys_metadata_history): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and os validate, os build and the object save door refuse it. On a tree field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out.
referenceVia string optional Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. record_id with referenceVia: 'object_name'. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with reference (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior.
referenceDeletedWhen { field: string; value: string } optional Marks the record a referenceVia pointer names as PLATFORM-DELETED on rows where a sibling select field holds one declared option — e.g. record_id with { field: 'cancel_reason', value: 'record_deleted' }. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside referenceVia; field must be a single-value select on the same object declaring inline options, and value one of its option values — any other shape is refused at parse.
mailAssociation boolean optional Inbound mail association (email fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed.
deleteBehavior Enum<'set_null' | 'cascade' | 'restrict'> optional (default: "set_null") What happens if referenced record is deleted
inlineEdit boolean | Enum<'grid' | 'form'> optional Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form.
inlineTitle string optional Title for the inline master-detail grid
inlineColumns { name: string; label?: string; type?: Enum<'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file'>; width?: number; … }[] optional Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column ({ name, label?, type?, … } — objectui GridColumn); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired field spelling are refused at parse.
inlineAmountField string optional Numeric child field summed for the inline grid total
relatedList boolean | 'primary' optional Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown as a supporting list, a side card in the record's related column (the shared "Related" tab on the tabbed page); 'primary' = part of the record's main content, drawn in the record's body (the details column, after its first field section) as a full-width table (inside the Details tab on the tabbed page, with no tab of its own). Absent on a master_detail field that sets inlineEdit, the list is 'primary' by default; write true to keep it a side card. 'primary' places a list and does not order it: the parent object's relatedLists orders the body tables and the side cards (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=<field> / sort=-<field>, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order.
relatedListTitle string optional Title for the detail-page related list
relatedListColumns string[] optional Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse.
relatedListFilter any optional Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition { [referenceField]: parentId } — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query where), e.g. { status: { $ne: 'deleted' } } to hide soft-deleted children.
displayField string optional Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title).
descriptionField string optional Secondary field shown under the label in the quick-select popover.
lookupColumns (string | { field: string; label?: string; width?: string; type?: string })[] optional Explicit columns for the record-picker table; auto-derived from the referenced object when omitted.
lookupPageSize integer optional Rows per page in the record-picker dialog (default 10).
lookupFilters { field: string; operator: Enum<'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'in' | 'notIn'>; value: any }[] optional Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter.
dependsOn (string | { field: string; param?: string })[] optional Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For lookup/master_detail it scopes the candidate query (string = same local/remote key; {field,param} when the remote filter key differs — the {field,param} form is lookup-only). For select/multiselect/radio the actual per-option rule lives in each option's visibleWhen; list the referenced fields here (string form) so the option list gates and refreshes with the parent.
allowCreate boolean optional Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field.
expression string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional CEL expression the field's value is computed from, e.g. Frecord.amount * 0.1. On a formula field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a summary can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused).
returnType Enum<'number' | 'text' | 'boolean' | 'date' | 'currency'> optional Inferred value type of a formula field (number/text/boolean/date/currency). currency: an amount of money, in the currency the formula's own currencyConfig declares, as a currency field declares it (no currencyConfig: dynamic, the tenant default currency).
summaryOperations { object: string; field?: string; function: Enum<'count' | 'sum' | 'min' | 'max' | 'avg'>; relationshipField?: string; … } optional Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted.
language string optional Programming language for syntax highlighting (e.g., javascript, python, sql)
step number optional Step increment for slider (default: 1)
currencyConfig { currencyMode?: Enum<'dynamic' | 'fixed' | 'record'>; defaultCurrency?: string; currencyField?: string } optional Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode.
dimensions integer optional Vector dimensionality (e.g., 1536 for OpenAI embeddings)
trackHistory boolean optional Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field.
group string optional Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system")
visibleWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Predicate (CEL) — field is shown only when TRUE (else hidden). e.g. Precord.type == 'invoice'
readonlyWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Predicate (CEL) — field is read-only when TRUE. e.g. Precord.status == 'paid'. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (record.account.tier) faults on every row and refuses every update that writes the field — objectstack validate refuses it. Put such a check in a validations[] script rule, whose condition is read one hop through a reference.
requiredWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Predicate (CEL) — field is required when TRUE. A TRANSITION GATE, not an invariant: the write is refused only when the merged record violates the requirement AND the pre-write record complied — so the write that flips the predicate TRUE, an INSERT born inside the gate, and a write that clears the cell are all refused, while a row that was already missing the value keeps passing unrelated edits and state moves that stay inside the gate (ADR-0113 non-regression: adding the rule to a deployed object never bricks existing rows). Need an invariant every write must satisfy instead ('X may never exceed Y') — declare a validations[] script rule, which re-checks the merged record with no exemption. Enforced by evaluateValidationRules. Reads the bound record's OWN columns: the field level never reads a related record, so a read THROUGH a reference field (record.account.tier) faults on every row and refuses every write that reaches it — objectstack validate refuses it; put such a check in a validations[] script rule, whose condition is read one hop through a reference. The only slot; the conditionalRequired alias was removed in protocol 17.
dueLike boolean optional Deadline semantic (date / datetime only): TRUE declares this date a deadline, so a renderer may show relative overdue wording ("Overdue 3d") and an overdue colour once it has passed. Absent or FALSE: not a deadline — nothing is inferred from the field's name. Pair with settledWhen to say when the deadline is settled for a record. Refused on any other type. Display only: nothing on the write path reads it.
settledWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Predicate (CEL) — the deadline is SETTLED while TRUE: no overdue wording or colour applies to this record, e.g. Precord.status == 'done'. Evaluated per record like visibleWhen, reading the record's own columns as record. Requires dueLike: true on a date / datetime field; refused otherwise. Display only: nothing on the write path reads it.
conditionalFormatting { condition: string | object; style: Record<string, string> }[] optional Cell formatting rules for this field — [{ condition, style }], the same rule a list view's conditionalFormatting declares. In order, the first rule whose CEL condition holds applies its CSS style map to THIS FIELD'S CELL wherever the field renders (grid, kanban card, record page, related list, report cell); a list view's row rules style the row, and both may apply. The condition reads value (this field's value on the record) and record (the row) and no other root, e.g. Pvalue < 0 with style: { color: '#b91c1c' }; objectstack validate refuses a condition that does not parse or reads any other root. For presentation only — a semantic state such as a deadline's overdue is declared with dueLike / settledWhen instead. Display only: nothing on the write path reads it.
widget string optional Form widget override — names a registered field component (resolved as field:<widget>) to render this field instead of the type default. Degrades to the type renderer when unregistered. e.g. "object-ref", "filter-condition", "recipient-picker".
hidden boolean optional (default: false) Hidden from default UI
internal boolean optional Never return this field's value on the generic data path — the engine OMITS the key from find/findOne results, the 201 create body and the by-id update body, on the default projection AND when a client names the field in ?select=. Storage, filtering and indexing are untouched, so a server-side verifier can still match on the column and a purpose-built mint route can still return the value once at creation. The read protection for ADR-0100's third credential channel (auth-subsystem one-way hashes on text columns). Omission, not masking: a mask signals 'a value is set', which carries no information on a required column.
readonly boolean optional (default: false) Read-only — never editable in forms, AND server-enforced on BOTH write paths: a non-system write to this field is silently dropped from the payload on UPDATE and on INSERT (a create can no longer directly seed e.g. approval_status: "approved"), symmetric with readonlyWhen. A stripped INSERT field still falls back to its defaultValue. Exempt from the strip on BOTH paths: isSystem writes (seed replay, migration). Exempt on the UPDATE path ONLY: an opt-in "historical" import (preserveAudit) — which admits a whitelist (the audit/timestamp family plus author-declared business readonly fields). On INSERT the exemption does NOT apply: a non-system create that requests preserveAudit still has its readonly fields stripped, and is warned loudly that the exemption is UPDATE-only — replaying archival readonly facts on create requires a system context. A normal (non-system) import is NOT system-context and still strips.
requiredPermissions string[] optional [ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate).
maskingRule Enum<'phone' | 'id_card' | 'bank_account' | 'email' | 'name'> | { keepHead: integer; keepTail: integer } optional Partial masking rule enforced by the runtime FieldMasker (single channel — API, UI, export and AI context all see the same masked value). A named preset ('phone' 138*5678, 'id_card' keep 6+4, 'bank_account' keep last 4, 'email' j@example.com, 'name' keep first char) or { keepHead, keepTail }. Masked for every non-system caller unless the field's requiredPermissions are ALL held (that evaluation is the unmask gate); a permission set marking the field non-readable still deletes it entirely. Deterministic, length-preserving output; masked callers cannot filter/sort/group/aggregate on the field.
ackPlaintextMasking boolean optional [ADR-0100] Affirm a generic password field's plaintext-at-rest / masked-on-read contract is intended, silencing the author-time warning. No effect on non-password fields.
system boolean optional Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag.
sortable boolean optional (default: true) Whether field is sortable in list views
inlineHelpText string optional Help text displayed below the field in forms
placeholder string optional Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from inlineHelpText (always-visible help rendered beside/under the input) and description (tooltip/developer documentation).
autonumberFormat string optional (default: "{0000}") Auto-number format: literal text + {0000} counter, {YYYY}/{MM}/{DD}/{YYYYMMDD} date tokens (business tz), and {field_name} interpolation. Counter resets per rendered prefix (e.g. AD{YYYYMMDD}``{0000} resets daily). Omitted on an autonumber field ⇒ the contract default {0000}, which every driver and the engine fallback read, so one field numbers alike on every backend.
externalId boolean optional (default: false) Not read: no upsert, import or seed matches on this flag. A seed dataset names its match field in its own externalId; an import names it in the mapping's upsertKey.
_lock Enum<'none' | 'no-overlay' | 'no-delete' | 'full'> optional Item-level lock — controls overlay & delete (ADR-0010).
_lockReason string optional Human-readable reason shown when a write is refused by _lock.
_lockSource Enum<'artifact' | 'package' | 'env-forced'> optional Layer that set _lock (artifact | package | env-forced).
_provenance Enum<'package' | 'org' | 'env-forced'> optional Origin of the item (package | org | env-forced).
_packageId string optional Owning package machine id.
_packageVersion string optional Owning package version.
_lockDocsUrl string optional Optional documentation link surfaced next to _lockReason.

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'

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
relatedList string optional Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. It names a body table only (a FK declaring relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList): a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused where the child's fields are known (os validate / os build and the metadata write door), naming the FK and the remedy (set relatedList: 'primary' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList).
visibleWhen string | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object } optional Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. Precord.type == 'invoice'
collapse Enum<'none' | 'expanded' | 'collapsed'> optional (default: "none") [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed).
defaultExpanded boolean optional [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'.
collapsible boolean optional [DEPRECATED → collapse] Boolean pair with collapsed; use the collapse enum.
collapsed boolean optional [DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no collapsible and outranks collapsible: false; false → 'none', or 'expanded' beside collapsible: true. Use the collapse enum.

ObjectOwnershipEnum

Allowed Values

  • own
  • extend
  • overlay

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.