Data Model
Objects, fields, relationships, validation, indexes — described to AI or written in TypeScript.
The data model is the single source of truth for your app. Once an object exists, ObjectOS gives you REST APIs, a generated view, RBAC checkpoints, audit log entries, and AI tool exposure — for free.
Most customers never write the schema by hand. They describe what they need in the AI Builder and the platform creates the objects, fields, indexes, and translations. This page describes the underlying shape — so you understand what the AI is generating and can edit it directly when you want to.
Authoring paths
| Path | Looks like |
|---|---|
| AI Builder (primary) | "Create a support_ticket object with subject, description, priority, status, assignee." |
| Click-build | Studio → Objects → New Object → forms |
TypeScript (*.object.ts) | The TS shown below — typically inside a forked template |
All three produce the same schema. The schema is canonical; everything else is derived.
Anatomy of an object
// src/objects/task.ts
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Task = ObjectSchema.create({
name: 'todo_task',
label: 'Task',
pluralLabel: 'Tasks',
icon: 'check-square',
description: 'A single unit of work.',
fields: {
subject: Field.text({ label: 'Subject', required: true, maxLength: 200 }),
description: Field.markdown({ label: 'Description' }),
status: Field.select({
label: 'Status',
options: [
{ label: 'To Do', value: 'todo', default: true },
{ label: 'In Progress', value: 'in_progress' },
{ label: 'Done', value: 'done' },
],
}),
due: Field.date({ label: 'Due' }),
assignee: Field.lookup('sys_user', { label: 'Assignee' }),
},
enable: {
trackHistory: true, // record field changes in audit log
apiEnabled: true, // expose REST endpoints (default true)
feeds: true, // chatter / comments / @mentions
},
});Register it in your stack:
// objectstack.config.ts
import { defineStack } from '@objectstack/spec';
import * as objects from './src/objects';
export default defineStack({
manifest: { id: 'my.app', namespace: 'myapp', version: '0.1.0', type: 'app', name: 'My App' },
objects: Object.values(objects),
});That's all you need. os dev recompiles, and /api/v1/data/todo_task,
the generated Task view, and its permission row all appear.
Field types
ObjectStack ships ~25 field types. The most-used ones:
Scalars
| Type | What it stores | Helper |
|---|---|---|
text | Short string | Field.text({ maxLength, required }) |
textarea | Long string | Field.textarea(...) |
markdown | Rich text with markdown | Field.markdown(...) |
number | Integer | Field.number({ min, max }) |
decimal | Exact decimal (money, etc.) | Field.decimal({ precision, scale }) |
boolean | True/false | Field.boolean({ defaultValue }) |
date | Calendar date | Field.date(...) |
datetime | Timestamp | Field.datetime(...) |
email | Validated email | Field.email(...) |
url | Validated URL | Field.url(...) |
phone | Validated phone | Field.phone(...) |
json | Arbitrary JSON | Field.json(...) |
Choices
| Type | Use for |
|---|---|
select | Single choice (enum) |
multiselect | Multiple choices |
Relationships
| Type | Cardinality | Helper |
|---|---|---|
lookup | One-to-many (FK) | Field.lookup({ reference: 'sys_user' }) |
masterDetail | One-to-many with cascade delete | Field.masterDetail({ reference: 'order' }) |
Files & media
| Type | What it stores |
|---|---|
file | One file via the storage service |
image | Image file with preview |
Computed / derived
| Type | Behavior |
|---|---|
formula | Computed at read time from a CEL expression |
summary | Aggregate of related records (sum/count/avg) |
autonumber | Sequence (INV-{000001}) |
created, lastModified | System-maintained timestamps |
createdBy, lastModifiedBy | System-maintained user refs |
Required / unique / default
Common modifiers on every scalar field:
Field.text({
label: 'Code',
required: true,
unique: true, // unique constraint enforced at DB level
defaultValue: '',
helpText: 'Internal short code',
})Validation
Inline:
Field.number({ label: 'Quantity', min: 1, max: 9999 })
Field.text({ label: 'SKU', pattern: '^[A-Z]{3}-[0-9]{4}$' })Object-level rules (cross-field):
ObjectSchema.create({
name: 'order',
fields: { /* ... */ },
validations: [
{
name: 'discount_lt_total',
message: 'Discount cannot exceed total',
condition: 'discount < total',
},
],
});Validation runs on every write — REST, the UI, ObjectQL — so there's no "back door."
Indexes & performance
ObjectSchema.create({
name: 'order',
fields: { /* ... */ },
indexes: [
{ fields: ['status', 'created_at'] },
{ fields: ['account', 'created_at'], unique: false },
],
});The driver creates real DB indexes on schema sync.
Field groups
For long forms, group fields:
ObjectSchema.create({
name: 'task',
fieldGroups: [
{ key: 'core', label: 'Task', icon: 'check-square' },
{ key: 'planning', label: 'Planning', icon: 'calendar' },
{ key: 'meta', label: 'Metadata', icon: 'info', defaultExpanded: false },
],
fields: {
subject: Field.text({ label: 'Subject', group: 'core' }),
due: Field.date({ label: 'Due', group: 'planning' }),
},
});Capability flags & ownership
ObjectSchema.create({
name: 'task',
ownership: 'own', // 'own' | 'shared' | 'system'
sharingModel: 'private', // OWD — custom objects default to private (v13)
enable: {
apiEnabled: true, // generated REST endpoints (default true)
searchable: true, // index records for global search (default true)
trackHistory: true, // History tab + field-level diffs (opt-in, default false)
feeds: true, // sys_comment / @mentions (default true)
activities: true, // mirror CRUD to sys_activity timeline (default true)
files: false, // Attachments panel (opt-in, default false)
clone: true, // record deep cloning (default true)
},
});enable is a closed vocabulary — the block is strict, so an
unrecognized flag (a typo like feedEnabled, or a key that used to
exist) is a parse error naming the key, not a silently dropped setting.
The seven flags above are the whole of it, plus one more key:
enable: {
apiMethods: ['get', 'list', 'create', 'update'], // no delete over the API
}apiMethods whitelists which API operations the object exposes. The
authorable vocabulary is exactly six primitives — get, list,
create, update, delete, bulk. Omit the key for unrestricted
access; [] means deny-all. The eight legacy values (upsert,
import, export, aggregate, search, history, restore,
purge) are derived effective operations, not things you declare:
a stored one still parses but is stripped with a warning naming its
replacement (upsert → create + update, export/aggregate/
search → list, history → get; restore and purge derive from
nothing and are simply deleted). Watch one cliff — a whitelist of
only legacy values strips to [], which closes the object's API
rather than widening it.
There is no soft delete —
enable.trashwas removed. Earlier docs pointed atenable: { trash: true }as the replacement for the retired object-levelsoftDeleteprop. That was wrong in a way worth stating plainly:trashnever had a runtime consumer. Every delete has always been a hard delete, so a default-true flag promising a recycle bin was a false affordance — authors wrotetrash: falsebelieving they were opting out of a soft delete that never ran. The key (and its neighbourenable.mru) is now rejected at parse time with guidance rather than ignored. Delete it from your source, or runos migrate meta --from 16to rewrite it for you.For recoverability, use per-field
trackHistory(an audit trail of what changed) or alifecyclepolicy. A real recycle bin is not a shipped capability today.
Also removed from the object surface:
softDelete,versioning,search(use top-levelsearchableFields),recordName(use an autonumber field asnameField),keyPrefix,tags,active, andabstract—ObjectSchema.createthrows a located error naming the replacement if you pass one.
Since ObjectStack 14 the enable.* flags are enforced, not
advisory: feeds: false rejects comment creation with 403
FEEDS_DISABLED, files must be opted in before sys_attachment rows
can be created (403 FILES_DISABLED otherwise), and activities /
trackHistory gate the timeline and History tab. The compliance
sys_audit_log row is always written regardless of flags.
Data lifecycle (retention)
High-volume objects can declare a lifecycle block so the platform
bounds their growth (ObjectStack 14.4, ADR-0057):
ObjectSchema.create({
name: 'my_event',
lifecycle: {
class: 'event', // 'record' | 'audit' | 'telemetry' | 'transient' | 'event'
retention: '14d', // reaper deletes rows past the window
storage: { rotation: 'weekly' }, // time-sharded tables, O(1) expiry
},
});The built-in LifecycleService (disable with OS_LIFECYCLE_DISABLED=1)
reaps expired rows, rotates time-sharded tables, and archives
audit-class objects. Platform objects such as sys_activity (14 days)
and sys_audit_log (90 days hot, then archive) ship with lifecycle
declarations you can tune per environment via the
lifecycle.retention_overrides setting.
System objects (free with every project)
You don't have to declare these — they're always there:
| Object | What |
|---|---|
sys_user | User accounts |
sys_org | Organizations / tenants |
sys_member | Org membership |
sys_position, sys_permission_set | RBAC primitives |
sys_audit_log | Audit trail (when audit capability loaded) |
sys_file, sys_attachment | File metadata (when storage loaded) |
sys_comment, sys_activity | Feed / chatter (when feed loaded) |
sys_session, sys_api_key | Auth artifacts |
sys_webhook, sys_webhook_delivery | Webhook subs (when enabled) |
Reference them in lookup fields by name — e.g. Field.lookup({ reference: 'sys_user' }).
Polymorphic platform features
When you enable feeds: true and trackHistory: true, your object
automatically participates in:
sys_comment(thread_id =<object>:<id>)sys_attachment(parent_object =<object>, parent_id =<id>)sys_activity(timeline)sys_audit_log(field-level diffs)
You don't wire these per object — they're polymorphic on the platform.
Where to go next
- Permissions — gate access to your objects
- Flows / Automation — react to record changes
- API Access — call your generated REST
os explain— print the rendered schema@objectstack/specsource — the schema is the contract; everything here is derived from it
IDE Skills (Claude Code / Cursor / Copilot)
Install ObjectOS skills into your coding agent so Claude Code, Cursor, Copilot, Codex and friends know how to author ObjectOS metadata correctly.
Relationships
Connect objects with lookups and master-detail — cascade rules, filtered pickers, hierarchies, junction objects, and roll-ups.