State Machine
State Machine protocol schemas
@module automation/state-machine
XState-inspired State Machine Protocol — hierarchical states, guarded transitions, entry/exit actions. Used to declare strict business-logic constraints and lifecycle management, so an AI author cannot "hallucinate" a transition the machine never declared.
Where this is authored — the question #4001 had to answer first
The ledger carried these shapes as authorable (p) — provisional, because
nobody had checked. Checking matters here more than usual, because
ADR-0020
retired this shape as a record-lifecycle declaration: the top-level
workflow metadata type and object.stateMachines are both gone, and a
record's legal transitions are declared as a state_machine validation
rule (data/validation.zod.ts, a flat { from: [to] } table — closed
since #4001 batch 3b). A schema whose only doors were those two would be
dead surface, and the campaign's own rule is that dead surface gets its
ledger class corrected, not tightened.
One door survives, and it is an authoring door: ai/agent.zod.ts's
lifecycle is StateMachineSchema, and agent is a registered metadata
type — so defineStack({ agents }), POST /api/v1/meta/types/agent and the
Studio agent form all reach this file through AgentSchema.parse(). Verified
by parse, not by reading: before this change,
AgentSchema.parse({ …, lifecycle: {
id: 'probe_machine', initial: 'draft', stats: { runs: 3 },
states: { draft: { onn: { APPROVE: 'done' }, meta: { labell: 'Draft', owner: 'ops' } },
done: { type: 'final' } },
} })succeeded, returning
{ id, initial, states: { draft: { type: 'atomic', meta: {} }, done: … } } —
stats gone, meta's two keys gone, and onn (one keystroke from on)
gone with every transition the author declared. A state machine whose whole
purpose is to deny undeclared transitions had silently become one with no
transitions at all, and reported success.
So: authorable, and every shape below is strictObject.
meta is closed, deliberately
XState treats meta as an open bag, so leaving it open was the plausible
call and it was checked rather than assumed (the #4909 precedent: a slot
whose openness is real should say .passthrough(), not strip). Three facts
say closed here: the hand-written StateNodeConfig type beside this
schema declares exactly four meta keys, so passthrough would open the
Zod while tsc stayed shut — a new declared-≠-enforced split; nothing in
this repo reads any meta key (aiInstructions has no consumer outside
this file's own test); and the current behaviour is not openness but
strip — the probe above shows an author's meta arriving as {}. There
is no openness here to preserve, only a silence to end.
Source: packages/spec/src/automation/state-machine.zod.ts
TypeScript Usage
import { ActionRefSchema, GuardRefSchema, StateMachineSchema, StateNodeSchema, TransitionSchema } from '@objectstack/spec/automation';
import type { ActionRef, GuardRef, StateNode, Transition } from '@objectstack/spec/automation';
// Validate data
const result = ActionRefSchema.parse(data);ActionRef
Union Options
This schema accepts one of the following structures:
Option 1
Action Name
Type: string
Option 2
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| type | string | ✅ | |
| params | Record<string, any> | optional |
GuardRef
Union Options
This schema accepts one of the following structures:
Option 1
Guard Name (e.g., "isManager", "amountGT1000")
Type: string
Option 2
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| type | string | ✅ | |
| params | Record<string, any> | optional |
StateMachine
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| id | string | ✅ | Unique Machine ID |
| description | string | optional | |
| contextSchema | Record<string, any> | optional | Zod Schema for the machine context/memory |
| initial | string | ✅ | Initial State ID |
| states | Record<string, { type: Enum<'atomic' | 'compound' | 'parallel' | 'final' | 'history'>; entry?: (string | object)[]; exit?: (string | object)[]; on?: Record<string, string | object | object[]>; … }> | ✅ | State Nodes |
| on | Record<string, string | { target?: string; cond?: string | object; actions?: (string | object)[]; description?: string } | { target?: string; cond?: string | object; actions?: (string | object)[]; description?: string }[]> | optional |
StateNode
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| type | Enum<'atomic' | 'compound' | 'parallel' | 'final' | 'history'> | ✅ | |
| entry | (string | { type: string; params?: Record<string, any> })[] | optional | Actions to run when entering this state |
| exit | (string | { type: string; params?: Record<string, any> })[] | optional | Actions to run when leaving this state |
| on | Record<string, string | { target?: string; cond?: string | object; actions?: (string | object)[]; description?: string } | { target?: string; cond?: string | object; actions?: (string | object)[]; description?: string }[]> | optional | Map of Event Type -> Transition Definition |
| always | { target?: string; cond?: string | object; actions?: (string | object)[]; description?: string }[] | optional | |
| initial | string | optional | Initial child state (if compound) |
| states | Record<string, [StateNode](#statenode)> | optional | |
| meta | { label?: string; description?: string; color?: string; aiInstructions?: string } | optional |
Transition
Properties
| Property | Type | Required | Description |
|---|---|---|---|
| target | string | optional | Target State ID |
| cond | string | { type: string; params?: Record<string, any> } | optional | Condition (Guard) required to take this path |
| actions | (string | { type: string; params?: Record<string, any> })[] | optional | Actions to execute during transition |
| description | string | optional | Human readable description of this rule |