Workflows
Model a record's lifecycle as a state machine — the states it can be in, the moves allowed between them, and nothing a flow can do better.
A workflow models a record's lifecycle as a finite state machine: the states the record can be in and the moves allowed between them. You declare it as a state_machine validation rule on the object, and every write to the state field is checked against it. Use one when the core requirement is "this object can only move through these states."
There is no standalone Salesforce-style Workflow Rule authoring type. The old "workflow" concept splits cleanly in two:
- A
state_machinevalidation rule for strict lifecycle transitions — this page. - Flows for event-triggered or scheduled automation, including approval pauses.
Workflows vs flows
| Workflow (state machine) | Flow | |
|---|---|---|
| Models | State — where a record is in its lifecycle | Steps — what happens when something occurs |
| Answers | "Is this transition allowed right now?" | "What do we do about it?" |
| Shape | A state field and a table of allowed moves | Nodes and edges: triggers, actions, branches |
| Side effects | None — it only constrains | All of them — email, updates, HTTP, waits |
They compose: the state machine constrains the transitions; flows perform the side effects around them when you need notifications, record updates, or external calls.
Define a state machine
A support case that must move new → assigned → resolved, with an escalation
path. The state machine is one of the object's validations:
import { P } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const SupportCase = ObjectSchema.create({
name: 'support_case',
label: 'Support Case',
fields: {
status: Field.select({
label: 'Status',
required: true,
options: [
{ label: 'New', value: 'new', default: true },
{ label: 'Assigned', value: 'assigned' },
{ label: 'Escalated', value: 'escalated' },
{ label: 'Resolved', value: 'resolved' },
],
}),
resolution: Field.textarea({
label: 'Resolution',
requiredWhen: P`record.status == 'resolved'`, // no resolved case without one
}),
},
validations: [
{
type: 'state_machine',
name: 'case_lifecycle',
field: 'status',
message: 'This status change is not allowed.',
initialStates: ['new'],
transitions: {
new: ['assigned'],
assigned: ['resolved', 'escalated'],
escalated: ['resolved'],
resolved: [],
},
},
],
});Read it as a contract: a case is created as new, and a new case can only be
assigned. An assigned case can be resolved or escalated, and an escalated one
can be resolved. resolved lists nothing, so nothing moves it again. Because
resolution is required when the status is resolved, the write that resolves
a case without one is refused.
Anatomy
| Key | What it declares |
|---|---|
type: 'state_machine' | This validation rule is a state machine |
name | The rule's identifier (snake_case) |
field | The state field the rule governs |
transitions | Map of each state → the states it may move to; checked when the record is updated |
initialStates | The states a record may be created in; checked when it is inserted |
message | The error a refused write returns |
The rule also takes the keys every validation rule shares: severity,
events, active and priority.
Final states
A state whose list is empty is final: every move out of it is refused. A state
with no key at all is not final. The check has nothing to reason about there
and lets the write through, so declare [] when you mean "final".
Conditions on a transition
A state_machine rule has no per-transition guard. To make a move conditional,
put the condition where the platform enforces it:
- A value a state needs —
requiredWhenon that field, asresolutiondoes above. It refuses the write that enters the state without the value, and leaves rows that predate the rule alone. - Anything else — a sibling
scriptorconditionalvalidation rule (a CEL predicate) in the samevalidationsarray.
Writes, not events
The machine is checked on writes to the state field. An update that changes
status to a value not listed under its current value is refused, and so is
an insert outside initialStates. There are no named events to declare: the
table of legal moves is the whole machine.
Tip: Keep the machine minimal — states and the moves between them. The moment you want "and then send an email", you've left workflow territory: put the side effect in a flow that reacts to the transition.
Side effects belong in flows
State machines describe valid transitions — nothing else. When a transition should do something (notify sales, stamp a closed date, call an external system), pair the machine with a flow triggered by the record change:
export const dealClosedWon = defineFlow({
name: 'deal_closed_won',
label: 'Deal closed won',
type: 'record_change',
status: 'active',
nodes: [
{
id: 'start',
type: 'start',
label: 'Stage moved to closed won',
config: {
triggerType: 'record-after-update',
objectName: 'opportunity',
condition: "record.stage == 'closed_won' && previous.stage != 'closed_won'",
},
},
{
id: 'set_closed_date',
type: 'update_record',
label: 'Set Closed Date',
config: {
objectName: 'opportunity',
filter: { id: '{record.id}' },
fields: { closed_date: '{TODAY()}' },
},
},
{
id: 'notify_sales',
type: 'notify',
label: 'Notify Sales',
config: { recipients: '{record.owner}', title: 'Deal won: {record.name}' },
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'set_closed_date' },
{ id: 'e2', source: 'set_closed_date', target: 'notify_sales' },
{ id: 'e3', source: 'notify_sales', target: 'end' },
],
});The machine guarantees the deal reached closed_won legally; the flow
handles what happens next. Conditions like the one above are CEL expressions —
see CEL.
Migrating from workflow rules
If you're coming from a platform with Workflow Rules, here's the mapping:
| Old concept | Current equivalent |
|---|---|
| Workflow Rule | Flow |
| Time trigger ("N days before/after a date") | Time-relative trigger — config.timeRelative on a flow's start node (16.0, see Flows) |
| Time trigger (fixed clock) | Scheduled flow |
| Field update action | update_record node |
| Email alert | notify node |
| HTTP call | http node |
| Approval Process | Flow with one or more approval nodes |
The test: if the old rule was "when X happens, do Y", model it as a small
flow. If it was "N days before/after a date field, do Y", declare a
time-relative trigger — never a date-equality condition on a record-change
flow, which only evaluates when the record happens to change. If it was
"this record must move through controlled states", declare a state_machine
validation rule and use flows for the side effects.
Where to go next
| Page | Why |
|---|---|
| Flows | Side effects around transitions — triggers, steps, error handling |
| Approvals | Human sign-off as a pause inside a flow |
| Validation rules | The rule family a state machine belongs to |
| CEL expressions | The language behind requiredWhen, validation rules and flow conditions |
| Data modeling | The objects whose lifecycles you're constraining |
| Automation overview | Chooser: flow vs workflow vs approval |