| title | State machine — record lifecycle spec |
|---|---|
| navTitle | 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] }.
{/* os:check */}
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const PurchaseRequest = ObjectSchema.create({
name: 'purchase_request',
sharingModel: 'private',
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 INSERT is unchecked by this rule and ANY declared option is
// a legal starting value. (An option's `default: true` is an authoring
// hint — only a field-level `defaultValue` fills an omitted field.)
events: ['update'],
message: 'Invalid purchase status transition.',
transitions: {
draft: ['pending'],
pending: ['approved', 'rejected'],
// Terminal states need an EXPLICIT empty array to be enforced: a
// state with no key at all is one the checker cannot reason about,
// so it does not block moves out of it (see "Enforcement semantics").
approved: [],
rejected: [],
},
},
],
});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. An explicit empty array is an enforced dead-end — every move to another state is rejected. A state with no key at all is different: the checker has nothing to reason about there and lets the write through (see Enforcement semantics), so declare [] when you mean "terminal".
transitions: {
draft: ['pending'],
pending: ['approved', 'rejected'],
approved: [], // terminal — enforced
rejected: [], // terminal — enforced
}- 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-levelselectoption-membership check (invalid_option), so any declared option is a legal start. - On update, if the state field changed and the new value is not in
transitions[oldValue], the write is rejected. Clearing the field (writingnull) is exempt. - The check is lenient where it cannot reason: if the prior state has no key in
transitions(legacy or externally-written data, or a state you simply forgot to declare), it does not block. Only an explicit[]makes a state a hard dead-end. - Only a rule with
severity: 'error'(the default) blocks the write.warning/infoare advisory: logged per write on an ordinary write, folded into one summary line per rule on a seed/boot load, and not evaluated at all on anupdatewhose payload touches only platform-injected system columns (#13889). - 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 "historical" data import is exempt too (#3479). Migrating established facts — a batch of already-
closedtickets,closed_wondeals — is the same "snapshot, not a lifecycle event" situation. SettreatAsHistorical: trueon the import request (default off) and the runner putsskipStateMachineon the write context, soinitialStatesdoesn't reject those mid-lifecycle rows. A normal import leaves it off and still walks the FSM — the strict behavior is the default, so the exemption is always an explicit opt-in. Only the system principal may send it: the REST import doors (POST /api/v1/data/:object/importand/import/jobs) refusetreatAsHistorical: truefrom every other caller, every signed-in user and administrator included, with403 PERMISSION_DENIEDbefore a row is read, because it takes the object'sstate_machinerule and its read-only and audit protection off the write. An in-process import run as the system principal (a seed or migration job) keeps it. treatAsHistoricalalso preserves the original audit timeline (#3493) — on the rows an import UPDATES (#6640). Skipping the FSM is only half of migrating established facts; the other half is keeping when they happened and who did them. Under the same flag the write context also carriespreserveAudit, which (1) makesupdated_at/updated_byclient-preferred — a supplied historical last-modified survives instead of being stamped with the import instant — and (2) admits a whitelist through the static-readonlywrite strip: the audit/timestamp family plus author-declared businessreadonlyfields (closed_at,resolved_by, …) — but never the record's own primary key (id), which is the address of the write rather than a fact being restored (#8215). Platform-managedsystemcolumns outside that family (organization_idand other tenancy/generated columns) stay stripped — a historical import reinstates facts, it does not forge tenancy. Like the FSM exemption this is opt-in: a normal write still auto-stampsupdated_at/updated_byand stripsreadonlyexactly as before, and permissions / RLS / field-level security are unchanged.- …but a historical
upsertstill drops those columns from the rows it CREATES (#6640).preserveAuditis an UPDATE-path exemption and nothing else reads it: both write paths run the same in-engine strip (stripReadonlyFields, #14147), but only the UPDATE call site passespreserveAudit— the create side is given no such flag and keepscontext.isSystemas its only exemption. So a singletreatAsHistoricalupsert keepsclosed_aton the rows it matches and strips it — together with a suppliedcreated_at/updated_at, which the injected audit columns also declarereadonly— from the rows it inserts. The asymmetry is deliberate, not an oversight:treatAsHistoricalarrives on an ordinary (non-system) import request, so honouring it on create would let any caller seed the approval/status columns that create-side strip exists to protect. The ignored request is at least no longer silent — the server logs aWARNnaming the object, the stripped fields and this UPDATE-only rule — but the strip still applies. To replay archival read-only facts on the rows an import creates, write from a system context (isSystem). Over HTTP this case no longer arises: the import doors admittreatAsHistorical: truefrom the system principal alone, whose creates the strip exempts; it still holds for an in-process, non-system caller of the import runner. Full rule and rationale: Security & Access Control. - Undoing a historical import is symmetric (#3549 / #3556). The import undo (
POST /api/v1/data/import/jobs/:jobId/undo) logically rolls back a finished job — deleting the rows it created and restoring the captured pre-import snapshot on the rows it updated. That restore write now carriespreserveAudittoo, but only when the job was flaggedtreatAsHistorical, so the snapshottedupdated_at/updated_byand businessreadonlyfields (closed_at, …) are reinstated verbatim instead of being re-stamped to the undo instant. The undo is unaffected by the create-side carve-out above: it only ever deletes the rows the import created and updates the rows it touched, so every write it makes is on the path where the exemption is real. Without it the undo would silently overwrite the very timeline the historical import preserved; a normal (non-historical) import's undo keeps the default stamp/strip.
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,[]when the state has no outgoing edges (an explicit[]or a state the table never mentions — introspection does not distinguish the two, enforcement does), ornullwhen nostate_machinerule governs the field. - Over HTTP,
GET /api/v1/meta/object/:name/state/:field?from=:statereturns{ object, field, from, next }, wherenextis the legal-next list, ornullfor either of two reasons: nostate_machinerule governs the field, or?from=was omitted (nofrom⇒ no transition table to answer with). Anullfrom a call that passed nofromis therefore not evidence that the field has no state machine — re-ask with?from=.
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.
{/* os:check */}
// 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'],
},
},
],
});