Comprehensive guide for implementing validation rules in ObjectStack.
The complete set of type discriminators accepted by ValidationRuleSchema:
| Type | Purpose | When Validation Fails | Judged against |
|---|---|---|---|
script |
CEL predicate over the record | When predicate evaluates to true |
Merged record — prior row plus this write |
state_machine |
Legal state transitions | When transition not allowed | Written value vs the prior row's, only when the write sets the field (insert: initialStates) |
format |
Regex or built-in format | When format doesn't match | Write payload only — an omitted field is never judged |
cross_field |
CEL predicate comparing fields | When predicate evaluates to true |
Merged record — prior row plus this write |
json_schema |
Validate JSON field | When JSON doesn't match schema | Write payload only — an omitted field is never judged |
conditional |
Apply nested rule when a predicate holds | When nested rule fails | Its when on the merged record; the nested rule as its own type |
There is no other type. In particular:
- No
uniquetype (removed from the spec) — enforce uniqueness with a unique index (see below). - No
async/customtype — external checks and arbitrary validation code belong in abeforeInsert/beforeUpdatelifecycle hook (see references/data-hooks.md).
condition / when are CEL predicates (ADR-0032). Author them with the
P tag from @objectstack/spec; a plain string is also accepted and parsed
as CEL. Record fields are addressed as record.<field>; on update the prior
row is available as previous.<field>. CEL operator and null-handling rules:
see objectstack-formula.
script and cross_field, the predicate expresses
the failure condition — validation fails when it evaluates to true.
Tool choice — this is the invariant tool. script and cross_field
are judged against the merged record on every write, with no pre-state
exemption, so a row that already violates the rule is refused on any edit until
a repairing write lands (frozen, not bricked). A condition that must hold only
from a transition onward is not this tool: use requiredWhen or a field bound
(min / max / minLength / maxLength), which judge the write, not the
stored row (see Choose by intent — invariant or transition gate in
SKILL.md).
import { P } from '@objectstack/spec';
const validations = [
{
name: 'prevent_past_dates',
type: 'script',
condition: P`record.due_date < today()`, // ❌ Fails when this is TRUE
message: 'Due date cannot be in the past',
severity: 'error',
events: ['insert', 'update'],
},
];// Prevent negative values
condition: P`record.amount < 0`
// Require field when another field has value
condition: P`record.status == 'approved' && isBlank(record.approver_id)`
// Date range validation
condition: P`record.end_date < record.start_date`
// Conditional required field
condition: P`record.type == 'enterprise' && isBlank(record.account_manager)`On insert, an optional field omitted from the payload reads as
nullin the predicate —record.due_date == nullmatches an omitted field the same as an explicitnull. UseisBlank(v)to catchnulland empty strings together.
There is no unique validation type. Uniqueness — including composite
uniqueness — is declared as a unique index on the object:
indexes: [
{ fields: ['email'], unique: 'organization' }, // one per organization
{ fields: ['department', 'email'], unique: 'organization' }, // composite, per organization
{ fields: ['hostname'], unique: 'global' }, // one across the installation
]The database enforces the constraint; duplicate writes are rejected at the driver layer. See indexing.md for index options.
validations: [
{
name: 'status_flow',
type: 'state_machine',
field: 'status',
transitions: {
draft: ['submitted', 'cancelled'],
submitted: ['in_review', 'cancelled'],
in_review: ['approved', 'rejected'],
approved: ['published'],
rejected: ['draft'],
published: [], // Terminal state
cancelled: [], // Terminal state
},
message: 'Invalid status transition',
severity: 'error',
},
]validations: [
// Built-in formats
{
name: 'email_format',
type: 'format',
field: 'email',
format: 'email', // Built-in: email, url, phone, json
message: 'Invalid email format',
},
// Custom regex — the key is `regex`, not `pattern`
{
name: 'sku_format',
type: 'format',
field: 'sku',
regex: '^[A-Z]{3}-\\d{4}$', // e.g., ABC-1234
message: 'SKU must be format: XXX-0000',
},
]Same inverted semantics as script — the predicate is the failure
condition. fields lists the fields involved (used for error targeting).
validations: [
{
name: 'date_range',
type: 'cross_field',
condition: P`record.end_date <= record.start_date`, // ❌ TRUE = invalid
message: 'End date must be after start date',
fields: ['start_date', 'end_date'],
},
{
name: 'discount_limit',
type: 'cross_field',
condition: P`record.discount_amount > record.subtotal * 0.5`,
message: 'Discount cannot exceed 50% of subtotal',
fields: ['discount_amount', 'subtotal'],
},
]validations: [
{
name: 'config_schema',
type: 'json_schema',
field: 'config',
schema: {
type: 'object',
properties: {
timeout: { type: 'number', minimum: 0 },
retries: { type: 'integer', minimum: 1, maximum: 5 },
enabled: { type: 'boolean' },
},
required: ['timeout', 'enabled'],
additionalProperties: false,
},
message: 'Invalid configuration format',
},
]Shape is when / then / otherwise — when is a CEL predicate, then
is a single nested rule applied when it holds, otherwise (optional) a
single rule applied when it doesn't. There is no validations: [] array —
compose multiple checks as multiple top-level rules or nested conditionals.
validations: [
{
name: 'enterprise_requires_manager',
type: 'conditional',
when: P`record.type == 'enterprise'`,
message: 'Enterprise account validation',
then: {
name: 'manager_required',
type: 'script',
condition: P`isBlank(record.account_manager)`,
message: 'Enterprise accounts must have an account manager',
},
},
]With an otherwise branch:
{
name: 'payment_validation',
type: 'conditional',
when: P`record.order_total > 10000`,
message: 'Order validation',
then: {
name: 'manager_approval_required',
type: 'script',
condition: P`isBlank(record.manager_approval_id)`,
message: 'Orders over $10,000 require manager approval',
},
otherwise: {
name: 'payment_method_required',
type: 'script',
condition: P`isBlank(record.payment_method)`,
message: 'Payment method is required',
},
}Calling an external API, hitting another object, or running arbitrary code is
not a validation type. Implement it as a beforeInsert / beforeUpdate
lifecycle hook and throw on failure — the typed, supported extension point:
import { defineHook, HookContext } from '@objectstack/spec/data';
const taxIdCheck = defineHook({
name: 'tax_id_external_check',
object: 'account',
events: ['beforeInsert', 'beforeUpdate'],
handler: async (ctx: HookContext) => {
if (ctx.input.tax_id && !(await verifyTaxId(ctx.input.tax_id))) {
throw new Error('Invalid tax ID');
}
},
});severity: 'error' // Blocks save (default)
severity: 'warning' // Allows save, shows warning
severity: 'info' // Informational onlyBlocking rests on a human judgement. A machine-inferred signal — a score, a
duplicate guess, anything the system decided — may warning, never error.
Only a value a person wrote may block a save. Do not add an override flag to
soften a block either: an escape hatch around a rule is evidence the rule should
have been a warning.
events: ['insert'] // Only on create
events: ['update'] // Only on update
events: ['insert', 'update'] // On create and update (default)Validation rules run only on the insert/update write path — there is no
'delete'event (a delete carries no record payload to validate). To block or guard a deletion, use abeforeDeletelifecycle hook instead (seereferences/data-hooks.md).
priority: 0 // System validations (run first)
priority: 100 // Application validations (default)
priority: 1000 // User validations (run last)Lower numbers execute first.
{
type: 'script',
condition: P`record.amount > 0`, // ❌ Fails when amount > 0 (inverted!)
message: 'Amount must be positive',
}{
type: 'script',
condition: P`record.amount <= 0`, // ✅ Fails when amount <= 0
message: 'Amount must be positive',
}{
type: 'script',
condition: P`record.status == 'draft'`,
message: 'Record is still in draft',
// ❌ No events — runs on all operations
}{
type: 'script',
condition: P`record.status == 'draft'`,
message: 'Cannot publish draft records',
events: ['update'], // ✅ Only validate on update
}