| title | State Machine (Lifecycle) |
|---|---|
| description | Define strict business logic constraints prevents AI hallucinations by enforcing valid transitions. |
The State Machine (state_machine validation rule) lets you define the "Constitution" of a record's lifecycle: the legal status transitions a record may take. It is a flat, textbook finite-state-machine transition table that the write path enforces.
In the era of AI Agents, field-level validation is not enough. Large Language Models (LLMs) can "hallucinate" and attempt illogical data updates (e.g., moving a contract from draft directly to paid without going through approval).
The transition table provides a hard constraint layer:
- Deterministic: On update, if the state field changed and the new value is not listed under the current value, the write is rejected.
- Self-Documenting: The whole legal graph lives in one place, as data.
- Introspectable: UIs can grey out illegal buttons and an Agent can ask "from here, what's legal next?" instead of parsing a formula.
Add a state_machine rule to the object's validations array. The rule names the state field and declares a transitions map of { currentValue: [allowedNextValues] }.
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const PurchaseRequest = ObjectSchema.create({
name: 'purchase_request',
label: 'Purchase Request',
fields: {
status: Field.select({
label: 'Status',
required: true,
options: [
{ label: 'Draft', value: 'draft', default: true },
{ label: 'Pending', value: 'pending' },
{ label: 'Approved', value: 'approved' },
{ label: 'Rejected', value: 'rejected' },
],
}),
amount: Field.number(),
},
validations: [
{
type: 'state_machine',
name: 'purchase_status_flow',
label: 'Purchase Status Flow',
field: 'status',
// This rule governs UPDATE transitions only (`events: ['update']`
// below). `initialStates` — the optional FSM entry point that locks
// which states a record may be CREATED in (#3165) — is checked on
// INSERT, so to use it here you'd also add 'insert' to `events`.
// Otherwise the field's `default: true` option is the starting value.
events: ['update'],
message: 'Invalid purchase status transition.',
transitions: {
draft: ['pending'],
pending: ['approved', 'rejected'],
// `approved` and `rejected` are dead-end states (no outgoing edges).
},
},
],
});A state_machine rule shares the common validation-rule fields (name, label, message, severity, events, priority, active) with its siblings, plus its own:
| Field | Type | Meaning |
|---|---|---|
field |
string |
The state field this rule governs (e.g. status). |
transitions |
Record<string, string[]> |
Map of { currentValue: [allowedNextValues] } — the legal edges (enforced on update). |
initialStates |
string[] (optional) |
States a record may be created in. When set, an insert whose field value falls outside this list is rejected (invalid_initial_state) — the FSM entry point (#3165). Omit to keep the legacy no-check-on-insert behavior. |
transitions is the whole legal graph. The keys are current state values; each value is the list of states the record may move to. A state with no key (or an empty array) is a dead-end with no outgoing edges.
transitions: {
draft: ['pending'],
pending: ['approved', 'rejected'],
}- On insert, the
transitionstable is not consulted — there is no prior state to transition from. If the rule declaresinitialStates, the created value must be one of them, or the write is rejected withinvalid_initial_state(the FSM entry point). WithoutinitialStates, insert is a no-op and the starting value is constrained only by the field-levelselectcheck — typically the option markeddefault: true. - On update, if the state field changed and the new value is not in
transitions[oldValue], the write is rejected. - The check is lenient where it cannot reason: if the prior state is not described by the table (e.g. legacy or externally-written data), it does not block.
- Only a rule with
severity: 'error'(the default) blocks the write;warning/infoare logged. - Seed writes are exempt (#3433). Curated seed data — package bootstrap fixtures, marketplace templates, per-org replay, all loaded by
SeedLoaderService— is a snapshot of established facts, not a record walking its lifecycle, so it bypasses thestate_machinerule entirely: a seed may be born mid-lifecycle (acompletedproject, aclosed_wonopportunity) and neitherinitialStates(insert) nortransitions(update) is enforced. Every other validation still runs, so a seed must still satisfy field shape,format,script, and the rest.os lintwarns when a seeded value is not a state the machine declares, so a typo is still caught before boot.
A state_machine rule has no per-transition guard. To gate a transition on a predicate, add a sibling script or conditional validation rule (CEL) in the same validations array.
Because the transition table is data, both UIs and Agents can ask "from this state, what's legal next?" instead of parsing a formula.
- In code,
legalNextStates(objectSchema, field, currentState)from@objectstack/objectqlreturns the declared next states,[]for a known dead-end, ornullwhen nostate_machinerule governs the field. - Over HTTP,
GET /api/v1/meta/objects/:name/state/:field?from=:statereturns{ object, field, from, next }, wherenextis the legal-next list (ornull).
For complex business objects (like Lead, Opportunity, or Order), a transition table can grow large. To keep your object definition readable, extract the rule(s) into a plain TypeScript constant — there is no special *.state.ts format or StateMachineConfig type for object lifecycles; it is just an array of validation rules.
// src/objects/lead.validations.ts
export const leadValidations = [
{
type: 'state_machine' as const,
name: 'lead_status_flow',
field: 'status',
message: 'Invalid lead status transition.',
events: ['update'] as const,
transitions: {
new: ['qualified', 'unqualified'],
qualified: ['converted', 'unqualified'],
},
},
];// src/objects/lead.object.ts
import { ObjectSchema } from '@objectstack/spec/data';
import { leadValidations } from './lead.validations';
export const Lead = ObjectSchema.create({
name: 'lead',
// ... fields ...
validations: leadValidations,
});In real enterprise systems, a single object often has multiple independent state lines. For example, an Order has:
- lifecycle —
draft → submitted → confirmed → shipped → delivered - payment —
unpaid → partial → paid → refunded - approval —
pending → approved → rejected
These are N flat state_machine rules, one per state field, in the same validations array — not hierarchical or parallel statechart regions.
// src/objects/order.object.ts
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Order = ObjectSchema.create({
name: 'order',
fields: {
status: Field.select({ options: ['draft', 'submitted', 'confirmed', 'shipped', 'delivered'] }),
payment_status: Field.select({ options: ['unpaid', 'partial', 'paid', 'refunded'] }),
approval_status: Field.select({ options: ['pending', 'approved', 'rejected'] }),
},
validations: [
{
type: 'state_machine',
name: 'order_lifecycle',
field: 'status',
message: 'Invalid order status transition.',
transitions: {
draft: ['submitted'],
submitted: ['confirmed'],
confirmed: ['shipped'],
shipped: ['delivered'],
},
},
{
type: 'state_machine',
name: 'order_payment',
field: 'payment_status',
message: 'Invalid payment status transition.',
transitions: {
unpaid: ['partial', 'paid'],
partial: ['paid', 'refunded'],
paid: ['refunded'],
},
},
{
type: 'state_machine',
name: 'order_approval',
field: 'approval_status',
message: 'Invalid approval status transition.',
transitions: {
pending: ['approved', 'rejected'],
},
},
],
});