Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
objectstack-ai avatar

Objectstack Data

  • 126 installs
  • 18 repo stars
  • Updated August 5, 2026
  • objectstack-ai/framework

Helps with ai & agent building tasks.

About

objectstack-data is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.

  • objectstack-data
  • AI & Agent Building
  • AI-coding skill

Objectstack Data by the numbers

  • 126 all-time installs (skills.sh)
  • +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
  • Ranked #3,699 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
  • Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/objectstack-ai/framework --skill objectstack-data

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs126
repo stars18
Last updatedAugust 5, 2026
Repositoryobjectstack-ai/framework

What it does

Helps with ai & agent building tasks.

Files

SKILL.mdMarkdownGitHub ↗

Data Modeling — ObjectStack Data Protocol

Expert instructions for designing business data schemas using the ObjectStack specification. This skill covers Object definitions, Field type selection, relationship modelling, validation rules, index strategy, and lifecycle hooks.

---

Skill Boundaries

NeedUse instead
Query, filter, or aggregate recordsobjectstack-query
Define REST API endpoints or authobjectstack-api
Build views, dashboards, or appsobjectstack-ui
Create a plugin or register servicesobjectstack-platform

---

When to Use This Skill

  • You are creating a new business object (e.g., account, project_task)
  • You need to choose the right field type from the 48 supported types
  • You are configuring lookup / master-detail relationships between objects
  • You need to add validation rules (uniqueness, cross-field, state machine, etc.)
  • You are optimising query performance with indexes
  • You are extending an existing object with new fields or capabilities
  • You need to implement data lifecycle hooks for business logic

---

Core Concepts

Object Definition

An Object is the fundamental data entity in ObjectStack. It maps to a database table and exposes automatic CRUD APIs.

Required properties:

PropertyTypeConventionDescription
namestringsnake_caseImmutable machine identifier (/^[a-z_][a-z0-9_]*$/)
fieldsmapkeys in snake_caseField definitions

Important optional properties:

PropertyDefaultDescription
labelAuto from nameHuman-readable singular label
pluralLabelPlural form (e.g., "Accounts")
namespaceDeprecated — ignored by the runtime. Embed prefix directly in name instead (e.g. name: 'crm_account')
datasource'default'Target datasource ID for virtualized data
displayNameField'name'Field used as record display name
enableCapability flags (trackHistory, searchable, apiEnabled, etc.)
fieldGroupsOrdered list of logical field groups for forms/detail pages (see Field Groups)

Object Capabilities (enable)

Toggle system behaviours per object:

FlagDefaultPurpose
trackHistoryfalseField-level audit trail
searchabletrueIndex records for global search
apiEnabledtrueExpose via automatic REST / GraphQL APIs
apiMethodsallWhitelist specific operations (get, list, create, …)
filesfalseAttachments & document management
feedsfalseSocial feed, comments, mentions
activitiesfalseTasks & events tracking
trashtrueSoft-delete with restore
mrutrueMost Recently Used tracking
clonetrueRecord deep cloning

---

Field Groups (MVP)

Organize fields into logical groups (e.g., "Contact Information", "Billing", "System") for forms, detail pages, and editors.

  • Declare groups on ObjectSchema.fieldGroupsarray order is the display order.
  • Assign each field to a group via Field.group, which references an

ObjectFieldGroup.key. In-group display order equals the traversal order of fields.

  • Group keys must be snake_case; group labels are human-readable.
import { ObjectSchema } from '@objectstack/spec';

export default ObjectSchema.create({
  name: 'account',
  label: 'Account',

  fieldGroups: [
    { key: 'contact_info', label: 'Contact Information', icon: 'user' },
    { key: 'billing',      label: 'Billing', defaultExpanded: false },
    { key: 'system',       label: 'System',  visibleOn: P`os.user.isAdmin == true` },
  ],

  fields: {
    name:       { type: 'text',  required: true, group: 'contact_info' },
    email:      { type: 'email',                  group: 'contact_info' },
    phone:      { type: 'phone',                  group: 'contact_info' },
    vat_id:     { type: 'text',                   group: 'billing' },
    billing_address: { type: 'address',           group: 'billing' },
    created_at: { type: 'datetime', readonly: true, group: 'system' },
    created_by: { type: 'lookup', reference: 'user', readonly: true, group: 'system' },
  },
});

Supported migrations at this layer: add / rename / delete / reorder groups (edit the fieldGroups array), assign a field to a group (edit Field.group). Explicit per-field in-group ordering is deferred to a future iteration.

---

Conditional Field Rules

Put conditional UI/data-entry rules on the field definition when the rule belongs to the data model and should apply everywhere the field is edited: default forms, Studio-authored forms, inline master-detail grids, public forms, and API-backed writes.

import { P } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Invoice = ObjectSchema.create({
  name: 'invoice',
  fields: {
    status: Field.select({
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Sent', value: 'sent' },
        { label: 'Paid', value: 'paid' },
        { label: 'Void', value: 'void' },
      ],
    }),
    paid_at: Field.datetime({
      visibleWhen: P`record.status == 'paid'`,
      requiredWhen: P`record.status == 'paid'`,
    }),
    locked_total: Field.currency({
      readonlyWhen: P`record.status == 'paid'`,
    }),
  },
});
  • Use visibleWhen to hide irrelevant fields in ObjectUI forms.
  • Use readonlyWhen for state-locked fields; the ObjectQL write path ignores

incoming changes when the predicate is TRUE.

  • Use requiredWhen for conditional requiredness; the ObjectQL validator

enforces it on submit. conditionalRequired is a deprecated compatibility alias, not the preferred authoring field.

  • For inline master_detail grids, predicates are evaluated row-by-row against

the child row's record, so line-item rules should live on child fields.

  • For complex predicates, load objectstack-formula and emit CEL via

P\...\`; do not use Salesforce-style AND, IN (...), or {field}` syntax.

---

Quick Reference — Detailed Rules

For comprehensive documentation with incorrect/correct examples:

  • [Naming Conventions](./rules/naming.md) — snake_case rules, option values, config properties
  • [Field Types](./rules/field-types.md) — All 48 field types with decision tree and configs
  • [Relationships](./rules/relationships.md) — lookup vs master_detail, junction patterns, delete behaviors
  • [Validation Rules](./rules/validation.md) — All validation types, script inversion, severity levels
  • [Index Strategy](./rules/indexing.md) — btree/gin/gist/fulltext, composite indexes, partial indexes
  • [Lifecycle Hooks](./rules/hooks.md) — Hook quick reference (→ see references/data-hooks.md for the full 14-event guide)
  • [Datasources & Federation](./rules/datasources.md)defineDatasource, external/federated objects (remoteName/columnMap), auto-connect gating, credentials; ❌ no field.columnName on external objects

---

Quick-Start Template

import { ObjectSchema } from '@objectstack/spec';

export default ObjectSchema.create({
  name: 'support_case',
  label: 'Support Case',
  enable: {
    trackHistory: true,
    feeds: true,
    activities: true,
    trash: true,
  },
  fields: {
    subject:     { type: 'text', required: true, maxLength: 255 },
    description: { type: 'richtext' },
    status:      { type: 'select', required: true, options: [
      { label: 'New',       value: 'new', default: true },
      { label: 'Open',      value: 'open' },
      { label: 'Escalated', value: 'escalated', color: '#e74c3c' },
      { label: 'Resolved',  value: 'resolved',  color: '#2ecc71' },
      { label: 'Closed',    value: 'closed' },
    ]},
    priority:    { type: 'select', options: [
      { label: 'Low',    value: 'low' },
      { label: 'Medium', value: 'medium', default: true },
      { label: 'High',   value: 'high',   color: '#e67e22' },
      { label: 'Urgent', value: 'urgent',  color: '#e74c3c' },
    ]},
    account:     { type: 'lookup', reference: 'account', required: true },
    contact:     { type: 'lookup', reference: 'contact' },
    assigned_to: { type: 'lookup', reference: 'user' },
    due_date:    { type: 'datetime' },
  },
  validations: [
    {
      name: 'status_flow',
      type: 'state_machine',
      field: 'status',
      transitions: {
        new:       ['open'],
        open:      ['escalated', 'resolved'],
        escalated: ['open', 'resolved'],
        resolved:  ['open', 'closed'],
        closed:    [],
      },
      message: 'Invalid status transition.',
    },
  ],
  indexes: [
    { fields: ['status', 'priority'] },
    { fields: ['account'] },
  ],
});

---

Schema evolution on an existing database

The metadata→DB sync is additive-only: new tables/columns are created on boot, but existing columns are never altered or dropped. A non-additive change to an object that already has data silently diverges from the physical schema, and the database column wins at write time (#2186):

ChangeExisting DB on restart
add object / field / index✅ applied automatically (additive)
required: true → false (relax NOT NULL)dev auto-heals (autoMigrate:'safe'); otherwise os migrate apply
type / length change, drop field, renameos migrate apply (--allow-destructive for drops / tightenings)

Tell-tale: /meta reports a field optional but a write still 400s "<field> is required" — that is a stale NOT NULL column (physical drift), not a validator bug. os dev reconciles loosening automatically; otherwise os migrate plan to preview and os migrate apply to reconcile. CLI details: see objectstack-platform.

---

Common Patterns

Naming Rules Summary

ContextConventionExample
Object namesnake_caseproject_task
Field keyssnake_casefirst_name, due_date
Schema propertiescamelCasemaxLength, referenceFilters
Option valuelowercasein_progress

See rules/naming.md for incorrect/correct examples.

Field Type Selection

48 types available. Quick categories:

  • Text: text, textarea, email, url, phone, markdown, html, richtext
  • Numbers: number, currency, percent
  • Date/Time: date, datetime, time
  • Logic: boolean, toggle
  • Selection: select, multiselect, radio, checkboxes
  • Relational: lookup, master_detail, tree
  • Media: image, file, avatar, video, audio
  • Calculated: formula, summary, autonumberformula fields take a CEL expression in formula (use F\...\` from @objectstack/spec`); see objectstack-formula skill
  • Enhanced: location, address, code, json, color, rating, slider, signature, qrcode, progress, tags, vector

See rules/field-types.md for full reference.

Relationship Patterns

PatternImplementation
One-to-Many (independent)lookup field on child
One-to-Many (owned)master_detail field on child
Many-to-Many (simple)multi-value lookup (multiple: true) — an array column of ids
Many-to-Many (with attributes)Junction object with two lookup fields
Hierarchicaltree field (self-reference)

See rules/relationships.md for detailed examples.

`multiple: true` lookup ≠ junction object. A multi-value lookup
({ type: 'lookup', reference: 'x', multiple: true }) is stored and read as an
array of ids on the record — reference elements positionally
({record.tags.0} in flow values). It is NOT a junction table. Reach for a
junction object (two lookups) only when the relationship itself carries
attributes (role, added_at, …). (#1872)

Validation Patterns

⚠️ Script validation is inverted: Validation fails when expression is true.

On insert, an optional field omitted from the payload reads as null in a
validation predicate — so record.due_date == null matches an omitted field the
same as an explicit null (#1871). (On update, the prior record supplies it.)

Common validation types:

  • script — Formula expression (inverted logic)
  • unique — Composite uniqueness
  • state_machine — Legal state transitions
  • format — Regex or built-in format
  • cross_field — Compare values across fields

See rules/validation.md for all types and examples.

Index Patterns

Omit default values: type defaults to 'btree', unique defaults to false.

indexes: [
  { fields: ['status', 'created_at'] },              // btree (default)
  { fields: ['email'], unique: true },                // btree + unique
  { fields: ['description'], type: 'fulltext' },      // non-default type
]

See rules/indexing.md for composite/partial/gin/gist indexes.

Lifecycle Hooks

Implement business logic at data operation lifecycle points:

import { Hook, HookContext } from '@objectstack/spec/data';

const accountHook: Hook = {
  name: 'account_defaults',
  object: 'account',
  events: ['beforeInsert'],
  handler: async (ctx: HookContext) => {
    if (!ctx.input.industry) {
      ctx.input.industry = 'Other';
    }
    ctx.input.created_at = new Date().toISOString();
  },
};

export default accountHook;

See rules/hooks.md for the quick reference, or references/data-hooks.md for complete documentation of all 14 lifecycle events, registration modes, and patterns.

---

CRM Schema Blueprint (Production Pattern)

Mirror these CRM-style patterns when designing enterprise metadata objects:

PatternTypical LocationImplementation Cue
Object layout via field groupssrc/objects/*.object.tsUse fieldGroups[] + per-field group for deterministic form structure
Capability gatingsrc/objects/*.object.tsUse enable flags (trackHistory, apiMethods, files, feeds, activities) per object
Index + validation pairingsrc/objects/*.object.tsKeep indexes[] aligned to common filters and enforce invariants with validations[]
Relationship constraintssrc/objects/*.object.tsUse lookup + referenceFilters for constrained child selection
Lifecycle automationsrc/objects/*.hook.tsUse a lifecycle hook (defineHook()) or a top-level record_change flow for field updates triggered by record changes. There is no object-level workflows[] field — authoring one is a build error (#1535).
State transitionssrc/objects/*.state.tsPrefer explicit stateMachines for lifecycle-heavy objects

For metadata authoring, keep expressions in CEL (P\...\`, F\...\, cel\...\`) and avoid legacy formula-string syntax.

---

Object Extension Model

When extending an object you do not own:

{
  ownership: 'extend',
  extend: 'crm.account',      // target object FQN
  fields: { custom_score: { type: 'number' } },
  priority: 300,               // higher = applied later
}
  • priority controls merge order (default 200; range 0–999)
  • Extensions can add fields, validations, and indexes — but cannot remove them

---

Security & Access Control

Per-object access control is part of the schema, not a separate layer. Configure these alongside fields / validations / hooks:

Object-level permissions (RBAC)

Bind CRUD operations to roles:

permissions: {
  read:   ['authenticated'],
  create: ['sales', 'admin'],
  update: ['record_owner', 'sales_manager', 'admin'],
  delete: ['admin'],
}
  • Source: node_modules/@objectstack/spec/src/security/permission.zod.ts
  • Combine with enable.apiMethods to also restrict the HTTP surface.

Access depth (scope-depth) — the ERP "see my unit / my unit and below" axis

For owner-scoped (private) objects, a per-object grant on a permission set can carry `readScope` / `writeScope` that widens the owner-match declaratively — the ERP "my own / my reports / my unit / my unit and below / whole org" axis (ADR-0057 D1). It saves hand-writing one RLS policy per object.

// in a permission set's `objects` map
objects: {
  account: {
    allowRead: true, allowEdit: true,
    readScope: 'unit_and_below',  // see accounts owned by my BU + descendant BUs
    writeScope: 'own',            // but only edit my own
  },
}
ScopeWho you can see / write
ownowner == me (baseline; unset = this)
own_and_reportsme + everyone below me on the sys_user.manager_id chain
unitowners in my business unit (sys_business_unit)
unit_and_belowmy BU + all descendant BUs (BFS)
orgthe whole tenant (≈ viewAllRecords / modifyAllRecords)

Resolves at request time into an owner_id IN (…) set and AND-injects like RLS (no compiler change; ADR-0055). Sharing rules still widen on top.

⚠️ Open-core boundary (ADR-0016). own and org work in open-source. The
hierarchy-relative scopes — own_and_reports / unit / unit_and_below
need the paid @objectstack/security-enterprise plugin (BU-subtree +
manager-chain resolver). Without it they fail closed to `own` (never
fail-open), and defineStack errors if a grant uses one without
requires: ['hierarchy-security']. In an open-source app, author own / org
+ explicit sharing rules; reach for unit* only when the enterprise plugin is
present.

Row-Level Security (RLS)

The enforced RLS surface is a list of rowLevelSecurity policies on a permission set / profile (PermissionSetSchema.rowLevelSecurity), not a CEL predicate on the object. Each policy carries a using (read filter) and/or check (write filter) string predicate. The compiler ANDs using into every read for users carrying that set; check gates writes. (@objectstack/plugin-security re-reads the target row through the write filter before single-id update/delete.)

// in a *.profile.ts / permission-set
rowLevelSecurity: [
  {
    name: 'own_records',
    operations: ['select', 'update', 'delete'],
    using: 'owner_id == current_user.id',   // read scope
    check: 'owner_id == current_user.id',   // write scope
  },
  {
    name: 'org_isolation',
    operations: ['all'],
    using: 'organization_id == current_user.organization_id',
  },
]

Predicates are canonical CEL (ADR-0058): field == current_user.<prop>, field == 'literal', field in current_user.<array>, comparisons (>/</>=/<=), &&/||/!, and == null checks all lower to a pushdown filter. No cross-object traversal or subqueries — those are a compile error (ADR-0055), never silently dropped. A legacy SQL-style = / IN (...) predicate still compiles via a deprecated bridge (emits a warning) but should be authored in CEL. The compiler resolves these current_user.* placeholders:

PlaceholderResolves to
current_user.idthe caller's user id (ownership)
current_user.emailthe caller's email (ADR-0056 #2054)
current_user.organization_idthe caller's tenant
current_user.org_user_idsids of users in the same org (for IN)
current_user.rolesthe caller's roles (for IN)
  • Source: node_modules/@objectstack/spec/src/security/permission.zod.ts (policy shape),

node_modules/@objectstack/spec/src/security/rls.zod.ts (predicate grammar).

  • Owner-scoping shortcut: the built-in member_default set already owner-scopes

writes via owner_only_writes / owner_only_deletes, and an object's sharingModel (private / public_read / controlled_by_parent, ADR-0056 D1) is the declarative way to set the org-wide default — prefer those over hand-written policies for the common cases.

Experimental: a separate object-level rls config with a free-form CEL
predicate exists in rls.zod.ts but is marked experimental (ADR-0056 D8) and
is not the path the runtime compiles/enforces. Author RLS as
rowLevelSecurity policies as shown above.

Field-level encryption

Encrypt sensitive columns at rest. Decryption is automatic for callers with permission; raw bytes are stored otherwise.

fields: {
  ssn: {
    type: 'text',
    encryptionConfig: { algorithm: 'aes-256-gcm', keyRef: 'pii_key_v1' },
  },
}
  • Source: node_modules/@objectstack/spec/src/system/encryption.zod.ts
  • Key rotation: bump keyRef and let the migration re-encrypt.

PII masking

Show partial values (****-****-1234) to roles that can read but should not see the full value. Applied after RLS, before serialization.

fields: {
  credit_card: {
    type: 'text',
    maskingRule: {
      pattern: 'last4',          // built-in: last4 | first2 | email | custom
      visibleToRoles: ['billing_admin'],
    },
  },
}
  • Source: node_modules/@objectstack/spec/src/system/masking.zod.ts

Multi-tenancy

For SaaS, set tenancy on the object schema. Combined with RLS, this enforces per-tenant data isolation:

ModeStorageWhen to use
sharedSingle table, tenant_id column + RLSDefault — most cost-efficient
isolatedSeparate database per tenantRegulatory isolation / large tenants
hybridShared schema, tenant-specific shardingHigh-volume multi-tenant

Cross-skill notes

  • API auth providers (OIDC, JWT, API key) live in objectstack-api.
  • Kernel-level RBAC services (role inheritance, custom policy engines)

live in objectstack-platform.

  • CEL predicate syntax (P\...\``, operators, functions) lives in

objectstack-formula.

---

Metadata Protection (protection)

Package authors can lock shipped metadata against Studio edits / overlays / deletes. See ADR-0010 for the full model.

The protection block is declared on the source schema (*.object.ts, *.app.ts, *.view.ts, …) and stripped at load time — it never appears in the runtime envelope. The runtime instead populates _lock, _lockReason, _lockDocsUrl, _lockSource, and _packageId, which REST returns to Studio and the lock banner reads.

Schema

protection?: {
  /** Lock level — controls what Studio can do to this item. */
  lock: 'none' | 'no-overlay' | 'no-delete' | 'full';
  /** Human-readable reason shown in the Studio lock banner. */
  reason?: string;
  /** Optional doc URL — renders as "查看文档 →" link in the banner. */
  docsUrl?: string;
}
lockEdit (overlay)DeleteTypical use
none (default)Normal authored metadata
no-overlaySchema is platform-defined but tenant can drop it (e.g. sys_role)
no-deleteTenant may customize fields but the object itself must exist
fullCore admin UI / platform identity (e.g. sys_user, app/setup)

Example — fully locked platform object

// packages/platform-objects/src/identity/sys-user.object.ts
import { defineObject } from '@objectstack/spec';

export const SysUserObject = defineObject({
  name: 'sys_user',
  label: 'User',
  protection: {
    lock: 'full',
    reason: 'Core identity object — see ADR-0010.',
    docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
  },
  fields: [ /* ... */ ],
});

Example — schema-locked but deletable

// packages/platform-objects/src/security/sys-role.object.ts
export const SysRoleObject = defineObject({
  name: 'sys_role',
  label: 'Role',
  protection: {
    lock: 'no-overlay',
    reason: 'RBAC schema is platform-defined — see ADR-0010.',
    docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
  },
  fields: [ /* ... */ ],
});

Example — locking a shipped app

The same block works on non-object metadata (apps, views, dashboards, flows, agents, tools, skills, reports, email-templates):

// packages/plugin-auth/src/apps/setup.app.ts
import { defineApp } from '@objectstack/spec';

export const SetupApp = defineApp({
  name: 'setup',
  label: 'Setup',
  protection: {
    lock: 'full',
    reason: 'Core admin UI shipped by @objectstack/platform-objects — see ADR-0010.',
    docsUrl: 'https://docs.objectstack.ai/adr/0010-metadata-protection',
  },
  // ...
});

Enforcement

  • REST: PUT /api/v1/meta/:type/:name and DELETE return 403 item_locked

for any operation the lock forbids. Layered-read endpoints (GET ?layers=true) include lock, lockReason, lockDocsUrl, lockSource, and packageId so Studio can render the banner.

  • Studio: ResourceEditPage renders a banner with the lock reason and the

"查看文档 →" link; edit + delete buttons are hidden according to the lock.

  • Package vs Artifact source: _lockSource: 'package' when the lock comes

from a code-shipped schema, 'artifact' when set by a workspace artifact. Artifact locks override package locks (workspace wins).

Authoring guidance

  • Default to no `protection` block for tenant-authored metadata.
  • Use full for anything Studio editing would break at runtime (core identity,

platform admin UIs, system flows).

  • Use no-overlay for schemas that platform owns but a tenant may legitimately

not need (then they can delete it).

  • Always include reason — it is the only thing the end-user sees first.
  • Prefer pointing docsUrl to an ADR or onboarding doc, not a marketing page.

---

Advanced Features Checklist

FeatureWhen to Consider
tenancyMulti-tenant SaaS — choose shared, isolated, or hybrid
softDeleteRegulatory requirement for data retention
versioningAudit / compliance — snapshot, delta, or event-sourcing
partitioningTables > 100M rows — range, hash, or list
cdcReal-time sync to Kafka, webhooks, or data lakes
encryptionConfigGDPR / HIPAA / PCI-DSS field-level encryption
maskingRulePII masking for non-privileged users

---

Seed Data & Fixtures (defineDataset())

Object definition and its seed data live together — writing a *.object.ts almost always goes with a *.seed.ts (test fixtures, reference rows, bootstrap data). defineDataset() is type-safe: pass the object definition and TypeScript checks every record's field keys at compile time.

Quick start

// src/data/index.ts
import { defineDataset } from '@objectstack/spec/data';
import { Status } from '../objects/status.object';
import { Category } from '../objects/category.object';

// Reference data — every environment
export const statusSeed = defineDataset(Status, {
  externalId: 'code',
  mode: 'upsert',
  records: [
    { code: 'active',   label: 'Active',   color: '#2ecc71' },
    { code: 'inactive', label: 'Inactive', color: '#95a5a6' },
  ],
});

// Demo data — dev/test only
export const categorySeed = defineDataset(Category, {
  externalId: 'slug',
  mode: 'upsert',
  env: ['dev', 'test'],
  records: [
    { slug: 'electronics', name: 'Electronics' },
  ],
});

export const SeedData = [statusSeed, categorySeed];   // parents first

Dataset fields

FieldDefaultPurpose
objectderivedAuto-set from objectDef.name — never write manually
externalId'name'Stable business key used for upsert / update lookup
mode'upsert'Import strategy (see below)
env['prod','dev','test']Environments where the dataset loads
recordsPartial<Record<keyof object.fields, unknown>>[]

Full Zod shape: node_modules/@objectstack/spec/src/data/dataset.zod.ts.

Import modes

ModeBehaviorUse for
upsert (default)Update by externalId, insert if missing. Idempotent.Reference data, bootstrap rows
insertInsert all; fail on duplicate externalId.Append-only / audit tables
updateUpdate only existing rows; never create.Patching existing config
ignoreInsert; silently skip duplicates.Additive bootstrap
replace ⚠️Delete everything, then insert. Data loss.Cache / lookup tables only — never user data

externalId selection

Pick a stable natural business key. Never use `id` — UUIDs differ across environments.

ScenarioKey
Named entities (country, currency)'code' / 'slug'
Users / contacts'email'
Externally sourced'external_id'
Generic'name' (default)

Relationship references

For lookup fields, supply the natural key of the target record (not its UUID). The seed runner resolves at load time. Order datasets so parents appear before children in the exported array:

If a lookup value matches no natural key, the loader now falls back to
resolving it as the target's id (#1814) — so a reference to a real existing
record by internal id resolves instead of dangling to null. Natural keys
remain the portable default; rely on the id fallback only for records you
didn't seed (e.g. a system user).
const contacts = defineDataset(Contact, {
  externalId: 'email',
  records: [{
    email: 'john@acme.example.com',
    first_name: 'John',
    account: 'Acme Corporation',   // natural key of an Account record
  }],
});

Dynamic values (CEL)

Any field value may be a CEL expression evaluated at install time against a single per-load pinned now. This is the only correct way to author time-based or identity-derived seed values — new Date() ships the package author's clock to every customer and breaks build determinism.

import { defineDataset, cel } from '@objectstack/spec';

defineDataset(Opportunity, {
  records: [{
    name:            'Acme Q3 Renewal',
    close_date:      cel`daysFromNow(45)`,
    created_at:      cel`now()`,
    owner_id:        cel`os.user.id`,   // installer
    organization_id: cel`os.org.id`,
  }],
});

Stdlib in seed context: now(), today(), daysFromNow(n), daysAgo(n), isBlank(v), coalesce(v, fallback). Scope: os.user, os.org, os.env. See objectstack-formula for the full contract.

Determinism gate: two consecutive os build runs with no source changes must produce byte-identical dist/objectstack.json. CEL + pinned now is what guarantees that — using Date.now() will fail CI.

Seed best practices

PracticeWhy
Always use defineDataset(), never DatasetSchema.parse()Lose compile-time field checking otherwise
Prefer natural keys (code / email / slug)Portable across environments
Default to upsertIdempotent re-runs
Scope demo data with env: ['dev','test']Keep noise out of prod
Order datasets parent → child in the exported arrayReferences resolve at load time
Use replace only on cache/lookup tables, with commentsData-loss footgun
One {object}.seed.ts file per objectReadability at scale

---

Linting & Generation Quality

objectstack lint checks the data model against the conventions in this skill — not just naming/labels but the relationship/master-detail/roll-up patterns. Run it after authoring or generating metadata. Severities: error (structural, fails the command), warning (likely-wrong choice), suggestion (nudge).

Data-model rules (in addition to naming/label/i18n):

RuleSeverityCatches
relationship/missing-referenceerrorlookup/master_detail without a reference target
relationship/master-detail-requiredwarninga master_detail that isn't required (a detail can't exist without its master)
relationship/delete-behaviorsuggestionmaster_detail without an explicit deleteBehavior
relationship/line-items-inline-editsuggestiona *_line/*_item master_detail child without inlineEdit
relationship/line-item-should-be-master-detailsuggestiona line-item-shaped child using lookup instead of master_detail
relationship/association-inline-editwarningan association (comment/audit/activity) marked inlineEdit (clutters the parent form — use a detail-page related list)
rollup/missing-summarysuggestiona parent of numeric master_detail children with no roll-up summary
field/select-missing-optionswarninga select/multiselect/radio with no options (or options source)
object/missing-name-fieldsuggestionan object with no name/title field or primaryField

These same rules are the rubric for AI-generated metadata — a generation is "good" exactly when it is schema-valid and lint-clean:

  • objectstack lint --score — print a 0–100 metadata-quality score (+ letter

grade and severity breakdown) for the current project. Schema errors and lint errors weigh most; suggestions barely move it.

  • objectstack lint --eval — run the generation eval over a bundled golden

corpus (invoice+lines, project+tasks, blog+comments, expense+lines, account+contacts) offline; each case must clear the pass bar (--eval-min, default 75). Deterministic, no API key.

  • objectstack lint --eval --generator ./gen.mjslive eval: the module

default-exports (prompt, id) => stack; wire it to your agent / AIService.generateObject<SolutionBlueprint> (+ blueprint→metadata expansion) to benchmark a real model against the same rubric.

When generating object metadata, target a lint-clean model: master_detail (with required + deleteBehavior + inlineEdit for line items), roll-up summaries on parents, select options, and a name/title field per object.

---

Verify your work

After authoring or editing any *.object.ts / *.seed.ts, run the author-time gate before reporting done:

os validate     # Zod schema + CEL predicates (record.<field> existence) + bindings
# or: os build  # the same gates, plus emits dist/

It catches what otherwise fails silently at runtime: a bare field ref in a requiredWhen / readonlyWhen / visibleWhen, a validation rule, a formula, or a row-level-security/sharing predicate (done instead of record.done) that evaluates to null and never fires (#2183/#2185). os lint is a separate pass that additionally checks the data model against the conventions in this skill (relationships, master-detail, roll-ups) — run it too, but it does not replace os validate. (Reminder: two consecutive os build runs with no source change must be byte-identical — see the determinism gate above.) In a scaffolded project the gate is npm run validate.

---

References

See references/_index.md for the full list of Zod schemas (with one-line descriptions) — pointers into node_modules/@objectstack/spec/src/. Always Read the source for exact field shapes; do not rely on memory of property names.

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.