Source profileQuality 94/100

objectstack-ai/objectstack/skills/objectstack-data/SKILL.md

objectstack-data

Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. Use when the user is creating or modifying `*.object.ts` / `*.seed.ts` files, picking field types, modelling relationships, writing `beforeInsert`/`afterUpdate` hooks, configuring per-object access control, or authoring bootstrap / demo data. Use for

Source repository stars
39
Declared platforms
0
Static risk flags
1
Last source update
2026-08-25
Source checked
2026-08-25

Decision brief

What it does: where it fits

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.

Best for

  • You are creating a new business object (e.g., account, projecttask)
  • You need to choose the right field type from the 49 supported types
  • You are configuring lookup / master-detail relationships between objects

Not for

  • Tasks that require unconfirmed production actions or broad system permissions.
  • Environments where the pinned source and install steps cannot be inspected.

Compatibility matrix

Platform support, with evidence labels

PlatformStatusEvidenceWhat to check
CodexNot declaredNo explicit evidencePortability before use
Claude CodeNot declaredNo explicit evidencePortability before use
CursorNot declaredNo explicit evidencePortability before use
Gemini CLINot declaredNo explicit evidencePortability before use
Open the compatibility checker

Installation

Inspect first. Install second.

The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.

Source-detected install commandSource
npx skills add https://github.com/objectstack-ai/objectstack --skill "skills/objectstack-data"
Safe inspection promptEditorial

Inspect the Agent Skill "objectstack-data" from https://github.com/objectstack-ai/objectstack/blob/2cc71222459e91964e883419611a820c28302429/skills/objectstack-data/SKILL.md at commit 2cc71222459e91964e883419611a820c28302429. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.

Workflow

What the source asks the agent to do

  1. 01

    Quick start

    Review the “Quick start” section in the pinned source before continuing.

    Review and apply the “Quick start” source section.
  2. 02

    Skill Boundaries

    Review the “Skill Boundaries” section in the pinned source before continuing.

    Review and apply the “Skill Boundaries” source section.
  3. 03

    When to Use This Skill

    You are creating a new business object (e.g., account, projecttask)

    You are creating a new business object (e.g., account, projecttask)You need to choose the right field type from the 49 supported typesYou are configuring lookup / master-detail relationships between objects
  4. 04

    Core Concepts

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

    An Object is the fundamental data entity in ObjectStack. It maps to a database table and exposes automatic CRUD APIs.Important optional properties:Toggle system behaviours per object:
  5. 05

    Object Definition

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

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

Permission review

Static risk signals and limitations

Network access

medium · line 874

The documentation includes network, browsing, or remote request actions.

docsUrl: 'https://objectstack.ai/docs/references/shared/protection',

Network access

medium · line 892

The documentation includes network, browsing, or remote request actions.

docsUrl: 'https://objectstack.ai/docs/references/shared/protection',

Evidence record

Why each signal appears

EvidenceSourceComputedTestedEditorial
SignalValueEvidence typeMeaning
Quality score94/100ComputedDocumentation, specificity, maintenance, and trust rules
Repository stars39SourceRepository attention, not individual Skill quality
Compatibility0 platformsSourceDeclared in the catalog source record
Usage guideautomated source guideEditorialGenerated or reviewed according to the visible evidence level

Pinned source

Provenance and original SKILL.md

Repository
objectstack-ai/objectstack
Skill path
skills/objectstack-data/SKILL.md
Commit
2cc71222459e91964e883419611a820c28302429
License
Apache-2.0
Collected
2026-08-25
Default branch
main
View the original SKILL.md

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 49 supported types
  • You are configuring lookup / master-detail relationships between objects
  • You need to add validation rules (cross-field, state machine, format, 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")
namespaceNot a schema keyObjectSchema.create() rejects unknown keys, so authoring it is a build error. Embed the prefix directly in name instead (e.g. name: 'crm_account')
datasource'default'Target datasource ID for virtualized data
nameFieldderived (e.g. 'name'/'title')Canonical record-title field — the stored field used as the record's display name. Use a single text/email field, or a formula field (returnType: 'text') for a composite title
displayNameFieldDeprecated alias for nameField (still honored as a fallback)
titleFormatRetired (ADR-0079) — a render-only template the server can't return or query. Use nameField; for a composite title, designate a returnType: 'text' formula field as nameField
enableCapability flags (trackHistory, searchable, apiEnabled, etc.)
fieldGroupsOrdered list of logical field groups for forms/detail pages (see Field Groups)
lifecyclerecord semantics (permanent)Data retention/rotation/archival contract. Required for append-only, high-write-rate objects — a telemetry/transient/event/audit class must declare a bounding policy or parsing fails (see Data Lifecycle & Retention)

Object Capabilities (enable)

Toggle system behaviours per object:

FlagDefaultPurpose
trackHistoryfalseField-level audit trail
searchabletrueIndex records for global search
apiEnabledtrueExpose via automatic REST + MCP APIs
apiMethodsallWhitelist over the six primitives (get, list, create, update, delete, bulk); derived verbs (search/export/upsert/…) follow automatically
filesfalseAttachments & document management
feedstrueSocial feed, comments, mentions — opt-out: explicit false hides the feed UI and rejects new comments
activitiestrueActivity timeline (sys_activity mirror of CRUD) — opt-out: explicit false stops mirroring and hides the timeline
clonetrueRecord deep cloning

Search Fields (searchableFields)

searchableFields on the object is the canonical set $search scans (ADR-0061). Leave it unset and search auto-defaults to the nameField plus the object's short-text and enum columns (text / email / phone / url / autonumber / textarea / markdown / select / status); declare it to pin the set explicitly. Views may narrow it, never widen it.

⛔ Searching by a related record's title — mirror the value, always

$search scans the queried object's own columns. A dotted path is never a search target: unlike fields / sort / filters, the search axis does not resolve traversal, and project_id.name in searchableFields (or in a $searchFields override) is refused, not silently dropped.

This is the one prescription — emit it every time. To search by a related record's title, copy that title into a stored field on this object and declare that field searchable. A task list searched by project name:

// `project_name` is a stored, denormalized mirror of the parent's title.
{
  name: 'task',
  enable: { searchable: true },
  fields: {
    name:         { type: 'text', required: true },
    project_id:   { type: 'lookup', reference: 'project' },
    project_name: { type: 'text', label: 'Project Name' },   // ← the mirror
  },
  searchableFields: ['name', 'project_name'],
}

?search=apollo expands to name $contains 'apollo' OR project_name $contains 'apollo' — one single-table scan, every driver, no traversal. (A text mirror also lands in the auto-default set when the object declares no searchableFields.)

Never mirror onto a formula field. A formula field is virtual — no driver materializes a column for it, so a $contains predicate against one has nothing to scan (the SQL driver would emit a WHERE over a column that does not exist). CEL also only reads this record's own fields (record.<field>), so a formula cannot fetch the related title in the first place. The mistake is refused, not silent: a formula entry in any searchableFields — the object's own set included — is an os validate error (searchable-field-unsearchable), and a request naming one is 400 INVALID_FIELD. It used to clear both and then never match.

Mirror maintenance is the trade-off — a mirror is denormalized data, only as fresh as whatever writes it. Cover both write paths:

WhenWhat maintains the mirror
A task is created, or re-pointed at another projectbeforeInsert / beforeUpdate hook on task — read the parent's name for the incoming project_id, stamp project_name
A project is renamedafterUpdate hook on project — re-stamp project_name on that project's tasks

Rows written by a path that bypasses hooks (bulk import, direct SQL) need a one-off backfill. See Lifecycle Hooks.

The errors an author sees for the dotted path (grep either back to here). os validatesearchable-field-unknown:

searchableFields entry "project_id.name" is not a field on object "task". The
declaration is stale: searching it can never match, and the engine silently
drops it — leaving a narrower search than declared, or the auto-default set once
every entry is dropped.

hint: 'search' scans this object's own columns, so a related record's column
cannot be a search target — expand the relation and search the related object,
or copy the value onto a stored text field here. Clients echo this declaration
verbatim as the '$searchFields' override, so a stale entry becomes a 400
INVALID_FIELD on list search, not just a quietly narrowed one.

A request carrying the dotted path is 400 INVALID_FIELD:

Unknown field 'project_id.name' on object 'task'. '$searchFields' narrows which
columns 'search' scans, so a name the object does not declare cannot narrow
anything — and the engine used to drop it and scan the default columns instead,
answering a NARROWER search with a WIDER one. 'search' scans this object's own
columns; a related record's column cannot be a search target.

Cross-object search paths are rejected by design, not pending. Do not invent a per-project convention for this — the mirror field is the answer.


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.
  • Optional per-group: icon, description, and collapse ('none' always open · 'expanded' collapsible, starts open · 'collapsed' collapsible, starts closed — replaces the deprecated defaultExpanded flag, ADR-0085). Groups render identically on forms, modals, and detail pages; for a bespoke single-page layout assign a custom Page instead.
import { ObjectSchema } from '@objectstack/spec/data';

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

  fieldGroups: [
    { key: 'contact_info', label: 'Contact Information', icon: 'user' },
    { key: 'billing',      label: 'Billing', collapse: 'collapsed' },
    { key: 'system',       label: 'System' },
  ],

  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',
  sharingModel: 'private',
  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.
  • readonly: true governs the end-user surface, not trusted system writers. A non-system write (REST/UI, and any runAs:'user' flow — the default) has the field silently stripped from an UPDATE payload; the write reports success but the value never lands. System-context writes — runAs:'system' flows, system hooks, seeds, imports, migrations — are exempt and DO write it. So the pattern "users can't edit this, but automation maintains it" is expressed by declaring the field readonly and running the maintaining flow runAs:'system' (see objectstack-automation), not by removing readonly. Writing a readonly field from a runAs:'user' update_record node is a build-time error (os validate / os build).
  • Use requiredWhen for conditional requiredness; the ObjectQL validator enforces it on submit. The conditionalRequired alias was REMOVED in protocol 17 — emitting it is a parse error.
  • 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 — snake_case rules, option values, config properties
  • Field Types — All 49 field types with decision tree and configs
  • Relationships — lookup vs master_detail, junction patterns, delete behaviors
  • Validation Rules — All validation types, script inversion, severity levels
  • Index Strategy — btree/gin/gist/fulltext, composite indexes, partial indexes
  • Data Lifecycle & Retentionlifecycle classes (record/audit/telemetry/transient/event), retention/TTL/rotation/archive policies; ❗ append-only objects must declare one (distinct from lifecycle hooks below)
  • Lifecycle Hooks — Hook quick reference (→ see references/data-hooks.md for the full 8-event guide + the sandboxed body ctx/capability contract)
  • Datasources & FederationdefineDatasource, external/federated objects (remoteName/columnMap), auto-connect gating, credentials; ❌ no field.columnName on external objects

Quick-Start Template

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

export default ObjectSchema.create({
  name: 'support_case',
  label: 'Support Case',
  sharingModel: 'private',
  enable: {
    trackHistory: true,
    feeds: true,
    activities: 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:

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
unique re-scoped global → per-tenantdev auto-heals; otherwise os migrate apply (replace_unique_index)
type / length change, drop field, renameos migrate apply (--allow-destructive for drops / tightenings)
declared index removed, or its columns changedos migrate apply (--allow-destructive when it drops, or rebuilds as UNIQUE)

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, lookupFilters
Option valuelowercasein_progress

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

Field Type Selection

49 types available. Quick categories:

  • Text: text, textarea, email, url, phone, password, markdown, html, richtext — ⚠️ password on a generic object is plaintext at rest (masked on read, never hashed); prefer secret for credentials
  • Secret: secret — reversible, encrypted-at-rest credential (DB password, API key, token) via the registered ICryptoProvider; masked on read, fail-closed (ADR-0100). The recommended type for credentials
  • Numbers: number, currency, percent
  • Date/Time: date, datetime, time
  • Logic: boolean, toggle
  • Selection: select, multiselect, radio, checkboxes
  • Relational: lookup, master_detail, tree, useruser is a person picker (a lookup specialized to sys_user; stored identically to lookup)
  • Media: image, file, avatar, video, audio
  • Calculated: formula, summary, autonumberformula fields take a CEL expression in expression (use F\...`from@objectstack/spec`); see objectstack-formula skill
  • Embedded: composite, repeater, record — embedded JSON sub-objects stored on the parent row (no separate table / FK)
  • 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, …).

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. (On update, the prior record supplies it.)

The complete set of validation types (ValidationRuleSchema discriminators):

  • script — Formula expression (inverted logic)
  • state_machine — Legal state transitions
  • format — Regex or built-in format
  • cross_field — Compare values across fields
  • json_schema — Validate a JSON field against a JSON Schema
  • conditional — Apply a nested rule only when a predicate holds

There is NO unique validation type (removed from the spec). Enforce uniqueness — including composite — with a unique index, and state its scope (ADR-0120): indexes: [{ fields: ['department', 'email'], unique: 'organization' }].

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

Index Patterns

The whole declaration surface is fields / unique / name. unique defaults to false; omit it when that is what you mean.

indexes: [
  { fields: ['status', 'created_at'] },                // composite
  { fields: ['email'], unique: 'organization' },       // unique per organization
  { fields: ['hostname'], unique: 'global' },          // unique platform-wide
  { name: 'idx_acct_status', fields: ['status'] },     // custom name
]

type and partial were retired at protocol 17: no driver ever read either, so an authored type chose no access method and an authored partial produced a full index with the predicate discarded. Both are now a tsc error and a parse error; os migrate meta --from 16 strips them. Access methods and partial predicates are database-layer migrations.

A unique index must state its scope'organization' (one holder per organization, NULL-safe) or 'global' (one holder across the installation). On a declared index bare unique: true is the deprecated spelling of 'global': it reads like "per organization" and does the opposite, so os lint warns and protocol 18 rejects it. On a FIELD, unique: true means 'organization' and stays valid.

See rules/indexing.md for composite indexes, unique scope, and how to build partial / gin / gist indexes at the database layer.

Lifecycle Hooks

Implement business logic at data operation lifecycle points:

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

export default defineHook({
  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();
  },
});

The handler above is the inline (in-process) form. The preferred, metadata-native form is a sandboxed body{ language: 'js', source, capabilities } run in an isolated VM, the shape that AI/Studio-authored hooks and every build artifact carry. See rules/hooks.md for the quick reference, or references/data-hooks.md for complete documentation of all 8 lifecycle events, both registration forms, the sandboxed body ctx + capability contract, 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 + lookupFilters ([{ field, operator, value }]) for constrained child selection
Lifecycle automationsrc/objects/*.hook.tsUse a lifecycle hook (authored with defineHook(), registered via defineStack({ hooks }) or the *.hook.ts convention scan) 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.
State transitionssrc/objects/*.object.tsPrefer explicit state_machine validation rules (one per state field) — there is no separate stateMachines map

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, author an extension with defineObjectExtension() and register it on the stack's objectExtensions array:

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

export const accountExtension = defineObjectExtension({
  extend: 'account',           // target object name
  fields: { custom_score: { type: 'number' } },
  priority: 300,               // higher = applied later
});

// objectstack.config.ts
// defineStack({ objectExtensions: [accountExtension], ... })
  • priority controls merge order (default 200; range 0–999)
  • Extensions can add fields, validations, and indexes — but cannot remove them
  • Do not author ownership: 'extend' on an object schema — the object-level ownership property is the record-ownership enum ('user' | 'business_unit' | 'org' | 'none'), unrelated to extensions

Security & Access Control

Per-object access control is authored in permission sets, not on the object schema. There is no object-level permissions key (and no hooks key either) — ObjectSchema.create() rejects both as unknown keys.

Object-level permissions (RBAC)

Grant CRUD access per object with boolean bits on a permission set:

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

export const salesUser = definePermissionSet({
  name: 'sales_user',
  objects: {
    account: { allowRead: true, allowCreate: true, allowEdit: true },
    contact: { allowRead: true },
  },
});

// Register it on the stack root under `permissions` — NOT `permissionSets`:
// defineStack({ permissions: [salesUser], ... })
  • Stack key: permissions. The collection is named for the metadata kind, not for the factory, so definePermissionSet() output goes into defineStack({ permissions: [...] }). permissionSets: is refused at load — the top level is strict, so the stack fails with an Unrecognized key(s) on this stack definition error naming the key, never a silent drop. ObjectStackDefinitionSchema (node_modules/@objectstack/spec/src/stack.zod.ts) is the enumeration of record; objectstack-platform lists every top-level key.
  • Bits: allowCreate / allowRead / allowEdit / allowDelete, plus allowTransfer (ownership change), viewAllRecords / modifyAllRecords (super-user, bypass sharing).
  • Source: node_modules/@objectstack/spec/src/security/permission.zod.ts
  • Combine with enable.apiMethods to also restrict the HTTP surface.

Assigning a permission set to a user

Declaring a set grants nobody anything — an assignment is data: one row in the join object sys_user_permission_set (@objectstack/plugin-security), carrying user_id, permission_set_id, and an optional organization_id (null = every org context). Optional valid_from / valid_until bound a half-open window checked at resolution time; granted_by is stamped by the gate on insert — never author it.

⚠️ permission_set_id takes the sys_permission_set RECORD ID, not the set's name. Grants resolve by loading sys_permission_set by id, so a name in that field matches nothing, raises no error, and silently grants nothing. Declared sets are upserted by name with a generated id on kernel:ready (ADR-0086 D5) — that id differs per environment, so resolve it first.

Assignment is therefore two calls, both POST /api/v1/data/{object} (…/query with a QueryAST body for the read): look up the set's id in sys_permission_set by name, then insert { user_id, permission_set_id, organization_id } into sys_user_permission_set. Only a tenant admin — or a delegated adminScope carrying manageAssignments for that set and user (ADR-0090 D12) — may write it; plain CRUD bits on the table are not enough.

Grant looks inert? Check in order: a name in permission_set_id; the set is active: false; the validity window has passed; organization_id mismatch. GET /api/v1/security/explain?object=&operation=&userId= answers from the enforcing code path (explaining another user needs manage_users).

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 permission set (definePermissionSet)
rowLevelSecurity: [
  {
    name: 'own_records',
    object: 'account',                       // REQUIRED per policy
    operation: 'all',                        // singular: select|insert|update|delete|all
    using: 'owner_id == current_user.id',    // read scope
    check: 'owner_id == current_user.id',    // write scope
  },
  {
    name: 'org_isolation',
    object: 'contact',
    operation: 'select',
    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)
current_user.organization_idthe caller's tenant
current_user.org_user_idsids of users in the same org (for IN)
current_user.positionsthe caller's positions (for IN; ADR-0090 D3)
  • 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 / public_read_write / 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.

Removed: a former object-level rls config (RLSConfigSchema, a free-form CEL predicate on the object) was removed from the spec (ADR-0056 D8, "design+enforce or remove"). Permission-set rowLevelSecurity policies are the only RLS surface — author them as shown above.

Sensitive fields — secret type + requiredPermissions

The former encryptionConfig and maskingRule field keys were pruned from FieldSchema — they had no runtime consumer (dead surface; setting them protected nothing). The real channels are:

Encrypted-at-rest values — type: 'secret' (ADR-0100). For reversible machine credentials (DB passwords, API keys, tokens): the engine encrypts the value on write via the registered ICryptoProvider, stores the ciphertext handle in sys_secret, persists only an opaque ref on the row, and masks the value on read. Fail-closed: with no crypto provider registered, writes throw rather than persist cleartext.

fields: {
  api_key: { type: 'secret', label: 'API Key' },
}

Per-field access gating — requiredPermissions (ADR-0066 D3). Capabilities required to READ/EDIT the field. A field declaring requiredPermissions is masked on read and denied on write unless the caller holds ALL listed capabilities — an AND-gate that is strictest-wins over permission-set field grants. Enforced by plugin-security's FieldMasker.

fields: {
  ssn: {
    type: 'text',
    requiredPermissions: ['view_pii'],  // mask on read / deny on write without it
  },
}
  • Source: node_modules/@objectstack/spec/src/data/field.zod.ts (secret field type, requiredPermissions)

Multi-tenancy

For SaaS, set tenancy on the object schema for row-level tenant isolation (the tenant field is injected on write and enforced on read). The block is strict — exactly two keys:

tenancy: {
  enabled: true,             // enable row-level tenant isolation
  tenantField: 'tenant_id',  // default: 'tenant_id'
}
  • The former shared / isolated / hybrid mode key (tenancy.strategy) was retired — an unknown tenancy key is now a loud parse error with upgrade guidance, never silently stripped.
  • Database-per-tenant isolation is not object metadata — it is an environment/deployment choice (each environment carries its own database URL).
  • Platform/env-global objects declare tenancy: { enabled: false } to opt out of org row-scoping (see the visibility-posture recipe below).

Platform-global / admin-only objects (visibility posture)

Some system/config objects are env-global (not partitioned per org) and should be visible to a platform admin env-wide but hidden from members — e.g. identity tables a plugin writes via its own adapter (sys_sso_provider, OAuth clients). These hit a non-obvious interaction:

  • Reads of a tenant object pass the Layer 0 tenant wall (ADR-0095 D1): an organization_id == <the caller's organization> filter AND-composed ahead of every business RLS policy. Any row whose organization_id is null or absent (common for adapter-written rows that never get the tenant stamp) is denied — the list renders empty. Single-tenant deployments never hit this; the wall is inert there.
  • The viewAllRecords superuser bit is posture-gated and wall-blind: it short-circuits business RLS only, and only on objects whose posture allows it (access.default: 'private', tenancy: { enabled: false }, or a better-auth-managed identity table). It never crosses the Layer 0 wall — crossing takes a true platform admin (the superuser bit and a platform-exclusive capability: manage_metadata, manage_platform_settings, studio.access, manage_users) on one of those same postures. So an org admin holding the superuser bit stays org-scoped, and on an ordinary tenant object nobody crosses — the admin sees 0 rows too.

Recipe — env-global, admin-only object that admins can fully see:

tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes nothing
requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403

⚠️ Both keys are load-bearing — neither works alone. tenancy: { enabled: false } by itself switches the wall off for every caller, and any permission set carrying a wildcard ('*') read grant then reads every row env-wide — the shipped viewer_readonly still carries one, as may an app-declared default profile or a customer-authored set. (The member_default baseline is not one of them: it is explicit-allow and grants only the objects it names.) requiredPermissions by itself leaves the object a tenant object, so the wall keeps denying the untagged rows and even a platform admin sees nothing. The pair is the correct combo (admin sees all, non-admins 403), and requiredPermissions is the half that holds however permissive the caller's grants are — it is an AND-gate checked before the CRUD grant. Posture model: ADR-0066; tenant wall: ADR-0095 D1.

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';
  /** REQUIRED — reason shown in the Studio lock banner (1–500 chars). */
  reason: string;
  /** Optional doc URL — renders as a "View docs" link in the banner. */
  docsUrl?: string;
}

The block is .strict(): reason is required (min 1 / max 500 chars) and unknown keys are rejected.

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

// src/objects/sys-user.object.ts
import { ObjectSchema } from '@objectstack/spec/data';

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

Example — schema-locked but deletable

// src/objects/sys-role.object.ts
import { ObjectSchema } from '@objectstack/spec/data';

export const SysRoleObject = ObjectSchema.create({
  name: 'sys_role',
  label: 'Role',
  protection: {
    lock: 'no-overlay',
    reason: 'RBAC schema is platform-defined — see ADR-0010.',
    docsUrl: 'https://objectstack.ai/docs/references/shared/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):

// 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://objectstack.ai/docs/references/shared/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 "View docs" link (from docsUrl); 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 — { enabled: true, tenantField: 'tenant_id' } row-level isolation (DB-per-tenant is an environment/deployment choice, not object metadata)
lifecycleAppend-only / high-write-rate objects — retention / rotation / archival contract; see rules/lifecycle.md
per-field trackHistoryRender a field's value changes as human-readable activity-timeline entries (pair with enable.trackHistory, ADR-0052 §5b)

The former softDelete / versioning object keys were removed from the spec (ADR-0049 enforce-or-remove) — authoring them is now a build error with upgrade guidance. partitioning / cdc were never schema keys, and the encryptionConfig / maskingRule field keys were pruned (see Sensitive fields).


Seed Data & Fixtures (defineSeed())

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). defineSeed() is type-safe: pass the object definition and TypeScript checks every record's field keys at compile time.

The factory is named defineSeednot defineDataset. The dataset name is reserved for the unrelated ADR-0021 analytics semantic layer (defineDataset from @objectstack/spec/ui), which is not a seed factory.

Quick start

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

// Reference data — every environment
export const statusSeed = defineSeed(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 = defineSeed(Category, {
  externalId: 'slug',
  mode: 'upsert',
  env: ['dev', 'test'],
  records: [
    { slug: 'electronics', name: 'Electronics' },
  ],
});

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

Seed 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 seed loads
recordsPartial<Record<keyof object.fields, unknown>>[]

Full Zod shape: node_modules/@objectstack/spec/src/data/seed.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 seeds 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 — 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 = defineSeed(Contact, {
  externalId: 'email',
  records: [{
    email: '[email protected]',
    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 { defineSeed } from '@objectstack/spec/data';
import { cel } from '@objectstack/spec';

defineSeed(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 defineSeed(), never SeedSchema.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 seeds 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 nameField (ADR-0079's canonical title pointer) and no name-like field (name/title/subject/label/full_name/display_name/code)

code counts for R9, but is NOT a title-derivation key. R9's name-like list above is the looser of two "name-like" sets, and the difference is deliberate. R9 asks "will records be anonymous?" — is there any readable face at all — and a code clears that bar. ADR-0079's title derivation (resolveDisplayField) asks the narrower "what IS the title?", and its name-ish set is name/title/subject/label/full_name/display_name without code — an identifier is not a title. So an object whose only name-ish field is code is R9-clean, yet its title is derived by the lower-priority "first title-eligible field by declaration order" tier rather than by name. Nothing user-visible turns on this (R9 is suggestion, and the Record #<id> floor guarantees a title regardless), but do not read the R9 list as the derivation contract — set nameField explicitly when the title matters.

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

Frequently asked questions

What to verify before installation and use

What does the objectstack-data source document cover?

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.

How do I install objectstack-data?

The source record exposes this install command: npx skills add https://github.com/objectstack-ai/objectstack --skill "skills/objectstack-data". Inspect the command and pinned source before running it.

Which permission-related actions were detected?

Static rules flagged network in the source; the page lists the matching lines and excerpts.

Alternatives

Compare before choosing

Computed 10045,511

coreyhaines31/marketingskills

ab-testing

When the user wants to plan, design, or implement an A/B test or experiment, or build a growth experimentation program. Also use when the user mentions "A/B test," "split test," "experiment," "test this change," "variant copy," "multivariate test," "hypothesis," "should I test this," "which version is better," "test two versions," "statistical significance," "how long should I run this test," "growth experiments," "experiment velocity," "experiment backlog," "ICE score," "experimentation program

Computed 10045,511

coreyhaines31/marketingskills

churn-prevention

When the user wants to reduce churn, build cancellation flows, set up save offers, recover failed payments, or implement retention strategies. Also use when the user mentions 'churn,' 'cancel flow,' 'offboarding,' 'save offer,' 'dunning,' 'failed payment recovery,' 'win-back,' 'retention,' 'exit survey,' 'pause subscription,' 'involuntary churn,' 'people keep canceling,' 'churn rate is too high,' 'how do I keep users,' or 'customers are leaving.' Use this whenever someone is losing subscribers o

Computed 10014,671

prowler-cloud/prowler

postgresql-indexing

PostgreSQL indexing best practices for Prowler: index design, partial indexes, partitioned table indexing, EXPLAIN ANALYZE validation, concurrent operations, monitoring, and maintenance. Trigger: When creating or modifying PostgreSQL indexes, analyzing query performance with EXPLAIN, debugging slow queries, reviewing index usage statistics, reindexing, dropping indexes, or working with partitioned table indexes. Also trigger when discussing index strategies, partial indexes, or index maintenance

Computed 100147

oaustegard/claude-skills

featuring

Generate hierarchical _FEATURES.md files that describe what a codebase DOES from a user/consumer perspective, anchored to source symbols via tree-sitting. Supports large complex codebases through feature-driven decomposition into sub-feature files. Uses a multi-pass synthesis: orientation → detail → overview rewrite. Use when someone says "what does this do", "document features", "feature inventory", "_FEATURES.md", or needs to understand a codebase's purpose before modifying it. Complements tre