ObjectStackObjectStack

Migration schema — System Protocol reference

Migration protocol — the two kinds of migration, kept apart on purpose.

Migration protocol — the two kinds of migration, kept apart on purpose.

A schema migration (ChangeSet + its atomic operations) reshapes the physical database to match metadata: add a field, change a type, create an object, run SQL. It is derivable from the metadata and applied by os migrate plan / os migrate apply.

A data migration rewrites the rows themselves, and whether it is done is a property of ONE DEPLOYMENT's database rather than of the installed code version — so it cannot be expressed as a ChangeSet, and its completion cannot be inferred from a release. DataMigrationFlag is the per-deployment record that one ran here and its self-check passed; consumers that would act irreversibly on migrated data gate on the flag instead of the version.

Source: packages/spec/src/system/migration.zod.ts

TypeScript Usage

import { AddFieldOperation, ChangeSetSchema, CreateObjectOperation, DataMigrationFlagSchema, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependencySchema, MigrationJournalEventSchema, MigrationOperationSchema, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';
import type { ChangeSet, DataMigrationFlag, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependency, MigrationJournalEvent, MigrationOperation, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system';

// Validate data
const result = AddFieldOperation.parse(data);

AddFieldOperation

Add a new field to an existing object

Properties

PropertyTypeRequiredDescription
type'add_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to add
field{ name?: string; label?: string; type: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; description?: string; … }✅Full field definition to add

Nested Shape: AddFieldOperation.field

PropertyTypeRequiredDescription
namestringoptionalMachine name (snake_case)
labelstringoptionalHuman readable label
typeEnum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>✅Field Data Type
descriptionstringoptionalTooltip/Help text
formatstringoptionalFree-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.
requiredbooleanoptional (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 }optionalPhysical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested.
searchablebooleanoptional (default: false)Is searchable
multiplebooleanoptional (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.
uniqueboolean | 'global' | 'organization'optionalUnique 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.
defaultValueanyoptionalDefault 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.
maxLengthintegeroptionalMax 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.
minLengthintegeroptionalMin 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.
valueDomainEnum<'iana_time_zone' | 'iso_4217_currency' | 'iso_3166_alpha2'>optionalStandard 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.
rowsintegeroptionalHeight 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.
precisionintegeroptionalTotal 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.
scaleintegeroptionalDecimal 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.
minnumberoptionalMinimum 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.
maxnumberoptionalMaximum 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.
useGroupingbooleanoptionalDigit-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.
acceptstring[]optionalPermitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write.
maxSizeintegeroptionalMaximum 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; … }[]optionalStatic 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.
pickliststringoptionalName 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.
referencestringoptionalTarget object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a tree field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out.
referenceViastringoptionalDeclares 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.
mailAssociationbooleanoptionalInbound 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.
deleteBehaviorEnum<'set_null' | 'cascade' | 'restrict'>optional (default: "set_null")What happens if referenced record is deleted
inlineEditboolean | Enum<'grid' | 'form'>optionalEdit 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.
inlineTitlestringoptionalTitle for the inline master-detail grid
inlineColumns{ name: string; label?: string; type?: Enum<'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file'>; width?: number; … }[]optionalExplicit 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.
inlineAmountFieldstringoptionalNumeric child field summed for the inline grid total
relatedListboolean | 'primary'optionalShow this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=<field> / sort=-<field>, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order.
relatedListTitlestringoptionalTitle for the detail-page related list
relatedListColumnsstring[]optionalExplicit 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.
relatedListFilteranyoptionalDeclarative 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.
displayFieldstringoptionalField shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title).
descriptionFieldstringoptionalSecondary field shown under the label in the quick-select popover.
lookupColumns(string | { field: string; label?: string; width?: string; type?: string })[]optionalExplicit columns for the record-picker table; auto-derived from the referenced object when omitted.
lookupPageSizeintegeroptionalRows 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 }[]optionalBase filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter.
dependsOn(string | { field: string; param?: string })[]optionalDeclares 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.
allowCreatebooleanoptionalAllow 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.
expressionstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalFormula expression (CEL). e.g. Frecord.amount * 0.1
returnTypeEnum<'number' | 'text' | 'boolean' | 'date' | 'currency'>optionalInferred 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; … }optionalRoll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted.
languagestringoptionalProgramming language for syntax highlighting (e.g., javascript, python, sql)
stepnumberoptionalStep increment for slider (default: 1)
currencyConfig{ currencyMode?: Enum<'dynamic' | 'fixed'>; defaultCurrency?: string }optionalWhich currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode.
dimensionsintegeroptionalVector dimensionality (e.g., 1536 for OpenAI embeddings)
trackHistorybooleanoptionalRender this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field.
groupstringoptionalField group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system")
visibleWhenstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (CEL) — field is shown only when TRUE (else hidden). e.g. Precord.type == 'invoice'
readonlyWhenstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (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.
requiredWhenstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (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.
conditionalRequiredneveroptional[REMOVED] conditionalRequired was removed in @objectstack/spec 17 — use requiredWhen. Rename the key; the value (a CEL predicate) is unchanged. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
dueLikebooleanoptionalDeadline 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.
settledWhenstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (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> }[]optionalCell 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.
widgetstringoptionalForm 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".
hiddenbooleanoptional (default: false)Hidden from default UI
internalbooleanoptionalNever 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.
readonlybooleanoptional (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.
requiredPermissionsstring[]optional[ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate).
maskingRuleEnum<'phone' | 'id_card' | 'bank_account' | 'email' | 'name'> | { keepHead: integer; keepTail: integer }optionalPartial 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.
ackPlaintextMaskingbooleanoptional[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.
systembooleanoptionalAuto-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.
sortablebooleanoptional (default: true)Whether field is sortable in list views
inlineHelpTextstringoptionalHelp text displayed below the field in forms
placeholderstringoptionalPlaceholder 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).
autonumberFormatstringoptional (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.
externalIdbooleanoptional (default: false)Is external ID for upsert operations
_lockEnum<'none' | 'no-overlay' | 'no-delete' | 'full'>optionalItem-level lock — controls overlay & delete (ADR-0010).
_lockReasonstringoptionalHuman-readable reason shown when a write is refused by _lock.
_lockSourceEnum<'artifact' | 'package' | 'env-forced'>optionalLayer that set _lock (artifact | package | env-forced).
_provenanceEnum<'package' | 'org' | 'env-forced'>optionalOrigin of the item (package | org | env-forced).
_packageIdstringoptionalOwning package machine id.
_packageVersionstringoptionalOwning package version.
_lockDocsUrlstringoptionalOptional documentation link surfaced next to _lockReason.

ChangeSet

A versioned set of atomic schema migration operations

Properties

PropertyTypeRequiredDescription
idstring✅Unique identifier for this change set
namestring✅Human readable name for the migration
descriptionstringoptionalDetailed description of what this migration does
authorstringoptionalAuthor who created this migration
createdAtstringoptionalISO 8601 timestamp when the migration was created
dependencies{ migrationId: string; package?: string }[]optionalMigrations that must run before this one
operations({ type: 'add_field'; objectName: string; fieldName: string; field: object } | { type: 'modify_field'; objectName: string; fieldName: string; changes: Record<string, any> } | { type: 'remove_field'; objectName: string; fieldName: string } | { type: 'create_object'; object: object } | … +3 more)[]✅Ordered list of atomic migration operations
rollback({ type: 'add_field'; objectName: string; fieldName: string; field: object } | { type: 'modify_field'; objectName: string; fieldName: string; changes: Record<string, any> } | { type: 'remove_field'; objectName: string; fieldName: string } | { type: 'create_object'; object: object } | … +3 more)[]optionalOperations to reverse this migration

Nested Shape: ChangeSet.dependencies[number]

Dependency reference to another migration that must run first

PropertyTypeRequiredDescription
migrationIdstring✅ID of the migration this depends on
packagestringoptionalPackage that owns the dependency migration

Nested Shape: ChangeSet.operations[number][type='add_field']

Add a new field to an existing object

PropertyTypeRequiredDescription
type'add_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to add
field{ name?: string; label?: string; type: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; description?: string; … }✅Full field definition to add

Nested Shape: ChangeSet.operations[number][type='modify_field']

Modify properties of an existing field

PropertyTypeRequiredDescription
type'modify_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to modify
changesRecord<string, any>✅Partial field definition updates

Nested Shape: ChangeSet.operations[number][type='remove_field']

Remove a field from an existing object

PropertyTypeRequiredDescription
type'remove_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to remove

Nested Shape: ChangeSet.operations[number][type='create_object']

Create a new object

PropertyTypeRequiredDescription
type'create_object'✅
object{ name: string; label?: string; pluralLabel?: string; description?: string; … }✅Full object definition to create

Nested Shape: ChangeSet.operations[number][type='rename_object']

Rename an existing object

PropertyTypeRequiredDescription
type'rename_object'✅
oldNamestring✅Current object name
newNamestring✅New object name

Nested Shape: ChangeSet.operations[number][type='delete_object']

Delete an existing object

PropertyTypeRequiredDescription
type'delete_object'✅
objectNamestring✅Name of the object to delete

Nested Shape: ChangeSet.operations[number][type='execute_sql']

Execute a raw SQL statement

PropertyTypeRequiredDescription
type'execute_sql'✅
sqlstring✅Raw SQL statement to execute
descriptionstringoptionalHuman-readable description of the SQL

Nested Shape: ChangeSet.rollback[number][type='add_field']

Add a new field to an existing object

PropertyTypeRequiredDescription
type'add_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to add
field{ name?: string; label?: string; type: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; description?: string; … }✅Full field definition to add

Nested Shape: ChangeSet.rollback[number][type='modify_field']

Modify properties of an existing field

PropertyTypeRequiredDescription
type'modify_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to modify
changesRecord<string, any>✅Partial field definition updates

Nested Shape: ChangeSet.rollback[number][type='remove_field']

Remove a field from an existing object

PropertyTypeRequiredDescription
type'remove_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to remove

Nested Shape: ChangeSet.rollback[number][type='create_object']

Create a new object

PropertyTypeRequiredDescription
type'create_object'✅
object{ name: string; label?: string; pluralLabel?: string; description?: string; … }✅Full object definition to create

Nested Shape: ChangeSet.rollback[number][type='rename_object']

Rename an existing object

PropertyTypeRequiredDescription
type'rename_object'✅
oldNamestring✅Current object name
newNamestring✅New object name

Nested Shape: ChangeSet.rollback[number][type='delete_object']

Delete an existing object

PropertyTypeRequiredDescription
type'delete_object'✅
objectNamestring✅Name of the object to delete

Nested Shape: ChangeSet.rollback[number][type='execute_sql']

Execute a raw SQL statement

PropertyTypeRequiredDescription
type'execute_sql'✅
sqlstring✅Raw SQL statement to execute
descriptionstringoptionalHuman-readable description of the SQL

CreateObjectOperation

Create a new object

Properties

PropertyTypeRequiredDescription
type'create_object'✅
object{ name: string; label?: string; pluralLabel?: string; description?: string; … }✅Full object definition to create

Nested Shape: CreateObjectOperation.object

PropertyTypeRequiredDescription
namestring✅Machine unique key (snake_case). Immutable.
labelstringoptionalHuman readable singular label (e.g. "Account")
pluralLabelstringoptionalHuman readable plural label (e.g. "Accounts")
descriptionstringoptionalDeveloper documentation / description
iconstringoptionalIcon name (Lucide/Material) for UI representation
isSystembooleanoptional (default: false)Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing)
managedByEnum<'platform' | 'config' | 'system-data' | 'engine-owned' | 'append-only' | 'better-auth'>optionalLifecycle 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.
ownershipEnum<'user' | 'business_unit' | 'org' | 'none'>optionalRecord-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; … }optionalPer-object override of the resolved CRUD affordance matrix.
systemFieldsfalse | { tenant?: boolean; audit?: boolean }optionalOpt out of, or selectively disable, registry-level system-field auto-injection.
datasourcestringoptional (default: "default")Target Datasource ID. "default" is the primary DB.
external{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }optionalRemote table binding for federated (external) objects.
fieldsRecord<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.
attachedOnReadRecord<string, Record<string, Enum<'number' | 'text' | 'boolean' | 'date'>>>optionalBlocks 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' }[]optionalDatabase performance indexes
fieldGroups{ key: string; label: string; icon?: string; description?: string; … }[]optionalOrdered list of field groups (array order = display order). See ObjectFieldGroupSchema.
tenancy{ enabled: boolean; tenantField?: string }optionalMulti-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).
requiredPermissionsstring[] | { 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; … }optionalData lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService.
fileAccessDelegatestringoptionalKernel 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.
validationsany[]optionalObject-level validation rules
activityMilestones{ field: string; value: string; summary: string; type?: string }[]optionalDeclarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2).
nameFieldstringoptional[ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title").
displayNameFieldstringoptional[DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField.
titleFormatstring | { dialect: 'template'; source?: string; ast?: any; meta?: object }optional[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time.
imageFieldstringoptionalThe 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).
highlightFieldsstring[]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.
stageFieldstring | falseoptional[ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed.
editModeEnum<'modal' | 'page'>optionalEdit-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).
listViewsRecord<string, { name?: string; label?: string | Record<string, string>; type?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>; data?: object | object | object; … }>optionalBuilt-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047)
searchableFieldsstring[]optionalFields 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'>[]; … }optionalEnabled system features modules
sharingModelEnum<'private' | 'public_read' | 'public_read_write' | 'controlled_by_parent'>optionalOrg-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).
externalSharingModelneveroptional[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; … }optionalPublic share-link policy (Notion/Figma-style link sharing)
actions{ name: string; label: string | Record<string, string>; description?: string | Record<string, string>; objectName?: string; … }[]optionalActions 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 }optionalPackage author protection block — lock policy for this object.
_lockEnum<'none' | 'no-overlay' | 'no-delete' | 'full'>optionalItem-level lock — controls overlay & delete (ADR-0010).
_lockReasonstringoptionalHuman-readable reason shown when a write is refused by _lock.
_lockSourceEnum<'artifact' | 'package' | 'env-forced'>optionalLayer that set _lock (artifact | package | env-forced).
_provenanceEnum<'package' | 'org' | 'env-forced'>optionalOrigin of the item (package | org | env-forced).
_packageIdstringoptionalOwning package machine id.
_packageVersionstringoptionalOwning package version.
_lockDocsUrlstringoptionalOptional documentation link surfaced next to _lockReason.

DataMigrationFlag

Deployment-level record that a data migration ran here and its self-check passed — the evidence gate consumers read instead of the platform version

Properties

PropertyTypeRequiredDescription
idstring✅Migration id (e.g. adr-0104-file-references) — one row per data migration
last_run_atstring✅When this migration last completed a gated (apply-mode) run on this deployment
verified_atstring | nulloptionalWhen the self-check last PASSED. Null/absent until it does — and cleared again by a later failing run, so a regression closes the gate
applied_atstring | nulloptionalWhen the backfill last ran in apply mode (writes enabled)
blockinginteger✅Blocking discrepancies reported by the last self-check. The gate requires 0
advisoryintegeroptionalAdvisory findings from the last run (external URLs, stale owners, …) — cost storage or need a modelling decision, never block the gate
detailsstringoptionalJSON-encoded counts from the last run, for diagnostics
deviation_observed_atstring | nulloptionalWhen this deployment last ADMITTED a value the verified contract rejects, via an OS_ALLOW_LAX_* escape hatch. Does not clear verified_at — it withdraws the irreversible half of what the certificate authorises
deviation_detailstring | nulloptionalJSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), for diagnostics
columns_moved_atstring | nulloptionalWhen this deployment last completed the COLUMN MOVE for this migration — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. Separate evidence from applied_at and verified_at, which attest the backfill and its self-check only: a deployment can carry both and still store the legacy encoding. Null/absent says exactly that, and is an expected steady state rather than an error — it is what a consumer that cannot read this field must assume

DeleteObjectOperation

Delete an existing object

Properties

PropertyTypeRequiredDescription
type'delete_object'✅
objectNamestring✅Name of the object to delete

ExecuteSqlOperation

Execute a raw SQL statement

Properties

PropertyTypeRequiredDescription
type'execute_sql'✅
sqlstring✅Raw SQL statement to execute
descriptionstringoptionalHuman-readable description of the SQL

MigrationDependency

Dependency reference to another migration that must run first

Properties

PropertyTypeRequiredDescription
migrationIdstring✅ID of the migration this depends on
packagestringoptionalPackage that owns the dependency migration

MigrationJournalEvent

One event in a migration run journal — the durable trace that lets a killed run be resumed forward or compensated back, with rows proving which

Properties

PropertyTypeRequiredDescription
run_idstring✅Identifies one run. Rows are keyed (run_id, seq)
seqinteger✅Monotonic per-run sequence. Ordering authority — wall-clock timestamps can tie or skew
kindEnum<'run_started' | 'chunk_started' | 'chunk_done' | 'compensated' | 'run_done' | 'run_failed'>✅Event kind
migration_idstringoptionalThe named migration this run belongs to, when it has one — joins to sys_migration.id
plan_hashstringoptionalOn run_started: hash of the plan shape. A resume whose plan hash differs REFUSES rather than resuming a changed plan against an old journal
chunk_indexintegeroptionalOn chunk_started / chunk_done / compensated: the run-global chunk index
attemptintegeroptionalWhich attempt produced this event. attempt > 1 means a prior outcome was unknown and the callback was asked to recheck by natural key
detailstringoptionalJSON-encoded payload — the chunk plan on run_started, the error on run_failed / a failed compensation
created_atstringoptionalWall-clock stamp, for humans. Never the ordering authority — that is seq

MigrationOperation

Union Options

This schema accepts one of the following structures:

Option 1

Add a new field to an existing object

Type: add_field

Properties

PropertyTypeRequiredDescription
type'add_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to add
field{ name?: string; label?: string; type: Enum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>; description?: string; … }✅Full field definition to add

Nested Shape: MigrationOperation[type='add_field'].field

PropertyTypeRequiredDescription
namestringoptionalMachine name (snake_case)
labelstringoptionalHuman readable label
typeEnum<'text' | 'textarea' | 'email' | 'url' | 'phone' | 'password' | 'secret' | …>✅Field Data Type
descriptionstringoptionalTooltip/Help text
formatstringoptionalFree-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.
requiredbooleanoptional (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 }optionalPhysical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested.
searchablebooleanoptional (default: false)Is searchable
multiplebooleanoptional (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.
uniqueboolean | 'global' | 'organization'optionalUnique 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.
defaultValueanyoptionalDefault 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.
maxLengthintegeroptionalMax 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.
minLengthintegeroptionalMin 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.
valueDomainEnum<'iana_time_zone' | 'iso_4217_currency' | 'iso_3166_alpha2'>optionalStandard 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.
rowsintegeroptionalHeight 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.
precisionintegeroptionalTotal 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.
scaleintegeroptionalDecimal 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.
minnumberoptionalMinimum 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.
maxnumberoptionalMaximum 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.
useGroupingbooleanoptionalDigit-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.
acceptstring[]optionalPermitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write.
maxSizeintegeroptionalMaximum 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; … }[]optionalStatic 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.
pickliststringoptionalName 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.
referencestringoptionalTarget object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a tree field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out.
referenceViastringoptionalDeclares 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.
mailAssociationbooleanoptionalInbound 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.
deleteBehaviorEnum<'set_null' | 'cascade' | 'restrict'>optional (default: "set_null")What happens if referenced record is deleted
inlineEditboolean | Enum<'grid' | 'form'>optionalEdit 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.
inlineTitlestringoptionalTitle for the inline master-detail grid
inlineColumns{ name: string; label?: string; type?: Enum<'text' | 'number' | 'currency' | 'date' | 'datetime' | 'time' | 'select' | 'lookup' | 'file'>; width?: number; … }[]optionalExplicit 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.
inlineAmountFieldstringoptionalNumeric child field summed for the inline grid total
relatedListboolean | 'primary'optionalShow this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). A derived related list (relatedList: 'primary') inherits its row order from the child object's DEFAULT list view sort — the isDefault view, or the first declared list item when none is marked default; wire spelling is sort=<field> / sort=-<field>, never $orderby. A child object with no list-view sort emits no ordering parameter, and rows fall back to record-id order.
relatedListTitlestringoptionalTitle for the detail-page related list
relatedListColumnsstring[]optionalExplicit 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.
relatedListFilteranyoptionalDeclarative 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.
displayFieldstringoptionalField shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title).
descriptionFieldstringoptionalSecondary field shown under the label in the quick-select popover.
lookupColumns(string | { field: string; label?: string; width?: string; type?: string })[]optionalExplicit columns for the record-picker table; auto-derived from the referenced object when omitted.
lookupPageSizeintegeroptionalRows 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 }[]optionalBase filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter.
dependsOn(string | { field: string; param?: string })[]optionalDeclares 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.
allowCreatebooleanoptionalAllow 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.
expressionstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalFormula expression (CEL). e.g. Frecord.amount * 0.1
returnTypeEnum<'number' | 'text' | 'boolean' | 'date' | 'currency'>optionalInferred 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; … }optionalRoll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted.
languagestringoptionalProgramming language for syntax highlighting (e.g., javascript, python, sql)
stepnumberoptionalStep increment for slider (default: 1)
currencyConfig{ currencyMode?: Enum<'dynamic' | 'fixed'>; defaultCurrency?: string }optionalWhich currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode.
dimensionsintegeroptionalVector dimensionality (e.g., 1536 for OpenAI embeddings)
trackHistorybooleanoptionalRender this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field.
groupstringoptionalField group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system")
visibleWhenstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (CEL) — field is shown only when TRUE (else hidden). e.g. Precord.type == 'invoice'
readonlyWhenstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (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.
requiredWhenstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (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.
conditionalRequiredneveroptional[REMOVED] conditionalRequired was removed in @objectstack/spec 17 — use requiredWhen. Rename the key; the value (a CEL predicate) is unchanged. Run os migrate meta --from 16 to list the mechanical edits for existing sources; --write applies the ones it can prove, and you apply the rest by hand.
dueLikebooleanoptionalDeadline 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.
settledWhenstring | { dialect: Enum<'cel' | 'cron' | 'template'>; source: string; ast?: any; meta?: object }optionalPredicate (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> }[]optionalCell 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.
widgetstringoptionalForm 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".
hiddenbooleanoptional (default: false)Hidden from default UI
internalbooleanoptionalNever 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.
readonlybooleanoptional (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.
requiredPermissionsstring[]optional[ADR-0066 D3] Capabilities required to read/edit this field (mask on read, deny on write; AND-gate).
maskingRuleEnum<'phone' | 'id_card' | 'bank_account' | 'email' | 'name'> | { keepHead: integer; keepTail: integer }optionalPartial 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.
ackPlaintextMaskingbooleanoptional[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.
systembooleanoptionalAuto-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.
sortablebooleanoptional (default: true)Whether field is sortable in list views
inlineHelpTextstringoptionalHelp text displayed below the field in forms
placeholderstringoptionalPlaceholder 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).
autonumberFormatstringoptional (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.
externalIdbooleanoptional (default: false)Is external ID for upsert operations
_lockEnum<'none' | 'no-overlay' | 'no-delete' | 'full'>optionalItem-level lock — controls overlay & delete (ADR-0010).
_lockReasonstringoptionalHuman-readable reason shown when a write is refused by _lock.
_lockSourceEnum<'artifact' | 'package' | 'env-forced'>optionalLayer that set _lock (artifact | package | env-forced).
_provenanceEnum<'package' | 'org' | 'env-forced'>optionalOrigin of the item (package | org | env-forced).
_packageIdstringoptionalOwning package machine id.
_packageVersionstringoptionalOwning package version.
_lockDocsUrlstringoptionalOptional documentation link surfaced next to _lockReason.

Option 2

Modify properties of an existing field

Type: modify_field

Properties

PropertyTypeRequiredDescription
type'modify_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to modify
changesRecord<string, any>✅Partial field definition updates

Option 3

Remove a field from an existing object

Type: remove_field

Properties

PropertyTypeRequiredDescription
type'remove_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to remove

Option 4

Create a new object

Type: create_object

Properties

PropertyTypeRequiredDescription
type'create_object'✅
object{ name: string; label?: string; pluralLabel?: string; description?: string; … }✅Full object definition to create

Nested Shape: MigrationOperation[type='create_object'].object

PropertyTypeRequiredDescription
namestring✅Machine unique key (snake_case). Immutable.
labelstringoptionalHuman readable singular label (e.g. "Account")
pluralLabelstringoptionalHuman readable plural label (e.g. "Accounts")
descriptionstringoptionalDeveloper documentation / description
iconstringoptionalIcon name (Lucide/Material) for UI representation
isSystembooleanoptional (default: false)Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing)
managedByEnum<'platform' | 'config' | 'system-data' | 'engine-owned' | 'append-only' | 'better-auth'>optionalLifecycle 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.
ownershipEnum<'user' | 'business_unit' | 'org' | 'none'>optionalRecord-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; … }optionalPer-object override of the resolved CRUD affordance matrix.
systemFieldsfalse | { tenant?: boolean; audit?: boolean }optionalOpt out of, or selectively disable, registry-level system-field auto-injection.
datasourcestringoptional (default: "default")Target Datasource ID. "default" is the primary DB.
external{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record<string, string>; … }optionalRemote table binding for federated (external) objects.
fieldsRecord<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.
attachedOnReadRecord<string, Record<string, Enum<'number' | 'text' | 'boolean' | 'date'>>>optionalBlocks 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' }[]optionalDatabase performance indexes
fieldGroups{ key: string; label: string; icon?: string; description?: string; … }[]optionalOrdered list of field groups (array order = display order). See ObjectFieldGroupSchema.
tenancy{ enabled: boolean; tenantField?: string }optionalMulti-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).
requiredPermissionsstring[] | { 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; … }optionalData lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService.
fileAccessDelegatestringoptionalKernel 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.
validationsany[]optionalObject-level validation rules
activityMilestones{ field: string; value: string; summary: string; type?: string }[]optionalDeclarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2).
nameFieldstringoptional[ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title").
displayNameFieldstringoptional[DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField.
titleFormatstring | { dialect: 'template'; source?: string; ast?: any; meta?: object }optional[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time.
imageFieldstringoptionalThe 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).
highlightFieldsstring[]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.
stageFieldstring | falseoptional[ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed.
editModeEnum<'modal' | 'page'>optionalEdit-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).
listViewsRecord<string, { name?: string; label?: string | Record<string, string>; type?: Enum<'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map' | 'chart' | 'tree'>; data?: object | object | object; … }>optionalBuilt-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047)
searchableFieldsstring[]optionalFields 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'>[]; … }optionalEnabled system features modules
sharingModelEnum<'private' | 'public_read' | 'public_read_write' | 'controlled_by_parent'>optionalOrg-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).
externalSharingModelneveroptional[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; … }optionalPublic share-link policy (Notion/Figma-style link sharing)
actions{ name: string; label: string | Record<string, string>; description?: string | Record<string, string>; objectName?: string; … }[]optionalActions 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 }optionalPackage author protection block — lock policy for this object.
_lockEnum<'none' | 'no-overlay' | 'no-delete' | 'full'>optionalItem-level lock — controls overlay & delete (ADR-0010).
_lockReasonstringoptionalHuman-readable reason shown when a write is refused by _lock.
_lockSourceEnum<'artifact' | 'package' | 'env-forced'>optionalLayer that set _lock (artifact | package | env-forced).
_provenanceEnum<'package' | 'org' | 'env-forced'>optionalOrigin of the item (package | org | env-forced).
_packageIdstringoptionalOwning package machine id.
_packageVersionstringoptionalOwning package version.
_lockDocsUrlstringoptionalOptional documentation link surfaced next to _lockReason.

Option 5

Rename an existing object

Type: rename_object

Properties

PropertyTypeRequiredDescription
type'rename_object'✅
oldNamestring✅Current object name
newNamestring✅New object name

Option 6

Delete an existing object

Type: delete_object

Properties

PropertyTypeRequiredDescription
type'delete_object'✅
objectNamestring✅Name of the object to delete

Option 7

Execute a raw SQL statement

Type: execute_sql

Properties

PropertyTypeRequiredDescription
type'execute_sql'✅
sqlstring✅Raw SQL statement to execute
descriptionstringoptionalHuman-readable description of the SQL


ModifyFieldOperation

Modify properties of an existing field

Properties

PropertyTypeRequiredDescription
type'modify_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to modify
changesRecord<string, any>✅Partial field definition updates

RemoveFieldOperation

Remove a field from an existing object

Properties

PropertyTypeRequiredDescription
type'remove_field'✅
objectNamestring✅Target object name
fieldNamestring✅Name of the field to remove

RenameObjectOperation

Rename an existing object

Properties

PropertyTypeRequiredDescription
type'rename_object'✅
oldNamestring✅Current object name
newNamestring✅New object name

On this page

TypeScript UsageAddFieldOperationPropertiesNested Shape: AddFieldOperation.fieldChangeSetPropertiesNested Shape: ChangeSet.dependencies[number]Nested Shape: ChangeSet.operations[number][type='add_field']Nested Shape: ChangeSet.operations[number][type='modify_field']Nested Shape: ChangeSet.operations[number][type='remove_field']Nested Shape: ChangeSet.operations[number][type='create_object']Nested Shape: ChangeSet.operations[number][type='rename_object']Nested Shape: ChangeSet.operations[number][type='delete_object']Nested Shape: ChangeSet.operations[number][type='execute_sql']Nested Shape: ChangeSet.rollback[number][type='add_field']Nested Shape: ChangeSet.rollback[number][type='modify_field']Nested Shape: ChangeSet.rollback[number][type='remove_field']Nested Shape: ChangeSet.rollback[number][type='create_object']Nested Shape: ChangeSet.rollback[number][type='rename_object']Nested Shape: ChangeSet.rollback[number][type='delete_object']Nested Shape: ChangeSet.rollback[number][type='execute_sql']CreateObjectOperationPropertiesNested Shape: CreateObjectOperation.objectDataMigrationFlagPropertiesDeleteObjectOperationPropertiesExecuteSqlOperationPropertiesMigrationDependencyPropertiesMigrationJournalEventPropertiesMigrationOperationUnion OptionsOption 1PropertiesNested Shape: MigrationOperation[type='add_field'].fieldOption 2PropertiesOption 3PropertiesOption 4PropertiesNested Shape: MigrationOperation[type='create_object'].objectOption 5PropertiesOption 6PropertiesOption 7PropertiesModifyFieldOperationPropertiesRemoveFieldOperationPropertiesRenameObjectOperationProperties