ObjectStackObjectStack

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

PropertyTypeRequiredDescription
typestring
paramsRecord<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

PropertyTypeRequiredDescription
typestring
paramsRecord<string, any>optional


StateMachine

Properties

PropertyTypeRequiredDescription
idstringUnique Machine ID
descriptionstringoptional
contextSchemaRecord<string, any>optionalZod Schema for the machine context/memory
initialstringInitial State ID
statesRecord<string, { type: Enum<'atomic' | 'compound' | 'parallel' | 'final' | 'history'>; entry?: (string | object)[]; exit?: (string | object)[]; on?: Record<string, string | object | object[]>; … }>State Nodes
onRecord<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

PropertyTypeRequiredDescription
typeEnum<'atomic' | 'compound' | 'parallel' | 'final' | 'history'>
entry(string | { type: string; params?: Record<string, any> })[]optionalActions to run when entering this state
exit(string | { type: string; params?: Record<string, any> })[]optionalActions to run when leaving this state
onRecord<string, string | { target?: string; cond?: string | object; actions?: (string | object)[]; description?: string } | { target?: string; cond?: string | object; actions?: (string | object)[]; description?: string }[]>optionalMap of Event Type -> Transition Definition
always{ target?: string; cond?: string | object; actions?: (string | object)[]; description?: string }[]optional
initialstringoptionalInitial child state (if compound)
statesRecord<string, [StateNode](#statenode)>optional
meta{ label?: string; description?: string; color?: string; aiInstructions?: string }optional

Transition

Properties

PropertyTypeRequiredDescription
targetstringoptionalTarget State ID
condstring | { type: string; params?: Record<string, any> }optionalCondition (Guard) required to take this path
actions(string | { type: string; params?: Record<string, any> })[]optionalActions to execute during transition
descriptionstringoptionalHuman readable description of this rule

On this page