Status: Accepted — Implemented (2026-05-31)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0019 (collapse approval into Flow — "one engine, fold the parasitic concept into its host"; this ADR applies the same principle to the state-machine concept), ADR-0009 (execution-pinned metadata), ADR-0010 + ADR-0011 (AI authoring is the design center)
Revises: the #1398 outcome that "reclaimed workflow for state machines" — that reclaim left a name (workflow) and three declaration shapes, but no runtime enforcement. This ADR finishes the job.
Consumers: @objectstack/spec (automation/state-machine.zod.ts, data/validation.zod.ts, data/object.zod.ts, kernel/metadata-*), @objectstack/objectql (validation/record-validator.ts), examples/app-crm (src/workflows/*.workflow.ts)
The platform has a "state machine" concept whose stated purpose is to lock a record's legal status transitions so that automation — increasingly AI-generated — cannot drive a record into an illegal state. Today that purpose is not met: the concept exists as three overlapping declaration shapes and zero runtime enforcement.
- A top-level
workflowmetadata type backed by an XState-styleStateMachineSchema. - A
stateMachinesmap embedded on the object (object.stateMachines). - A
state_machinevalidation rule ({ fromState: [allowedToStates] }).
No code executes any of them: IWorkflowService has no implementation, no XState interpreter exists, the record validator only checks field data types (it does not dispatch on validation-rule type), and nothing reads object.stateMachines. A declarative guardrail with no enforcement is decoration — and three ways to declare it is a hallucination trap for an AI author, which will pick one of the three and get silent no-op behaviour.
This ADR makes three decisions: (D1) converge to one declaration shape — the state_machine validation rule, retiring both other shapes, (D2) name it state_machine and retire the top-level workflow metadata type, and (D3) enforce it on the write path so illegal transitions are rejected. The shape stays conventional (textbook FSM) so an AI author's strong priors help rather than mislead.
The surviving shape is already adopted in examples/app-showcase (the state_machine rule on Task, Project, and Account) and passes typecheck — so this ADR mostly removes the other two shapes and wires enforcement for the one that's already in use, rather than inventing anything new.
The design intent is a runtime guardrail: declare which status transitions are legal, and have the engine reject any write that violates them. This is exactly the class of error an AI author makes — e.g. generating a Flow that sets status from draft straight to closed, skipping required intermediate states. A declared-and-enforced transition table catches that at write time.
Three shapes, one concept:
| # | Where | Schema | Reference |
|---|---|---|---|
| 1 | Top-level workflow metadata type |
StateMachineSchema (XState-style: hierarchical/parallel states, entry/exit actions, guards, context) |
metadata-type-schemas.ts:85, metadata-plugin.zod.ts:90, metadata-plugin.zod.ts:612 |
| 2 | Object-embedded | object.stateMachines: Record<string, StateMachineSchema> ("parallel lifecycles: status, payment, approval") |
object.zod.ts:534 |
| 3 | Validation rule | state_machine rule: transitions: { fromState: [toStates] } |
validation.zod.ts:105 |
Zero enforcement — verified across packages/{runtime,objectql,services,core,metadata*,plugins} and the whole repo:
IWorkflowService(workflow-service.ts:58) has no concrete implementation.- There is no XState interpreter anywhere (no
createMachine/interpret/ transition engine). - The write-path validator
validateRecordreads onlyobjectSchema.fieldsand validates field data types (string/number/date/…). It never readsobjectSchema.validationsat all — so not one of the nine validation-rule types (state_machine,cross_field,script,unique,format,json_schema,async,custom,conditional) is enforced by it. - Nothing reads
object.stateMachines.
So the guardrail goal is currently unmet at runtime. The only artefacts that exist are declarations — e.g. examples/app-crm/src/workflows/stale-opportunity.workflow.ts:19 (StateMachineConfig), which additionally mixes orchestration into the machine (it carries email_alert / task_creation actions that no engine executes — that orchestration belongs to a record-triggered Flow per ADR-0019).
A transition check needs both the prior and the new state. But the write path can't supply the prior state today: on update, engine.ts:1850 calls validateRecord(schema, hookContext.input.data, 'update') — passing only the PATCH payload, not the prior record. On PATCH { status: 'done' } there is no way to know the from-state without a read. So enforcing state_machine is not just "add a dispatch branch"; it requires plumbing the prior (or merged) record into the rule-evaluation step. This is a shared need: cross_field and script rules are equally crippled by receiving only the patch — so the fix should land once for the whole validations union, not as a state_machine-only patch (see D3).
Future automation is AI-generated, human-previewed (ADR-0010 / ADR-0011). That changes how we should name this concept:
- The audience for the name is the model, not a non-technical admin. The right heuristic is "meet the model where its priors are": use the term that is densest in training data for this concept.
- "state machine" is that term — Rails
state_machine, AWS Step Functions "State Machine", XState, Spring Statemachine. An AI given a field namedstate_machinewith a{ from: [to] }transition table hits its priors and produces correct code. A coined term (e.g.lifecycle) forces the model off its priors onto local docs alone. state_machinealso reads as maximally distinct fromflow— eliminating theflow/workflownear-synonym ambiguity that makes an AI pick the wrong type.lifecycleis additionally already overloaded in this codebase (managed-by buckets and toolbar "lifecycle actions" inobject.zod.tsat L354/L371/L410/L765), so reusing it would create a new ambiguity.
Corollary (a trap to avoid): if we name it state_machine, the shape must also match the well-known shape. A conventional name on a bespoke structure is the worst case — the model's priors fire on the name and mislead on the structure. Keep the shape textbook FSM.
Binding "legal transitions" to the data model is a well-trodden pattern, in two camps:
- First-class FSM on the model (a structured transition table): Rails AASM /
state_machinegem (transitions from: :a, to: :bon the model), Djangodjango-fsm(@transition(source, target)), MS Dataverse/Dynamics ("status reason transitions" configured on the table), Jira (issue-type workflow: statuses + transitions + validators), ServiceNow (State Model). Terms: state machine / FSM / transition (source→target) / state model. - Generic predicate emulating a transition: Salesforce Validation Rules — a boolean formula using
ISCHANGED(Status)+PRIORVALUE(Status)+ISPICKVAL(...); TRUE blocks the save.
Our design is a deliberate hybrid: it lives in the validation bucket (write-time, object-bound — like Salesforce) but its payload is a structured transition table (like AASM/Django/Dataverse). That confirms the naming decision: state_machine matches the first camp's vocabulary (priors), while nesting it under validations matches the second camp's enforcement model — an AI author hits both priors at once.
How this differs from a Salesforce validation rule — same placement and trigger (object-bound, on save), different representation:
| Salesforce Validation Rule | This state_machine rule |
|
|---|---|---|
| Form | generic boolean formula, TRUE = block | structured transition table { from: [to] } |
| Expressing a transition | hand-coded ISCHANGED + PRIORVALUE + ISPICKVAL |
list the edges |
| One rule covers | one forbidden condition (graph scattered across many rules) | the whole legal graph (one place) |
| Introspectable? | ❌ opaque formula text | ✅ machine-readable — UI greys out illegal buttons, an Agent can ask "from here, what's legal next?" |
The introspectability is the upgrade that serves the two design centers: UI reads transitions[current] to render only legal actions, and an AI/Agent reads the legal-next set instead of parsing a formula — which is the original "prevent AI mistakes" goal.
state_machine is one variant of the ValidationRuleSchema discriminated union (validation.zod.ts:362), alongside script, unique, format, cross_field, json_schema, async, custom, and conditional. It shares BaseValidationSchema (name/label/message/severity) and the same write-time enforcement semantics as its siblings. This is why it stays in validations (D1) rather than becoming a standalone metadata type or file: it is, precisely, a write-time validation whose payload happens to be a transition graph.
Collapse the three shapes to one: the state_machine validation rule (#3) — a field plus a { fromState: [allowedToStates] } map, inline in the object's validations. It is minimal, textbook, already the enforcement-path concept, and already in use in app-showcase. Both other shapes are retired:
- Retire the top-level
workflowmetadata type (#1). The XState-styleStateMachineSchema(hierarchical/parallel states, context, entry/exit actions) is orchestration machinery — and orchestration was assigned to Flow by ADR-0019. As a guardrail it is over-built and, today, dead code. - Retire
object.stateMachines(#2) as well. It is the same XStateStateMachineSchemain a second location, read by nothing. Keeping it "as an alternative host" would re-create the multi-shape hazard this ADR removes — the parallel-lifecycle need it cites (status + payment + approval) is met by multiplestate_machinerules, one per field, in the samevalidationsarray. One authoring surface, not two.
Multiple independent lifecycles on one object are therefore N flat state_machine rules (one per field), not XState parallel regions — the showcase Account/Project/Task rules demonstrate the shape and varied topologies (forward-only with reopen, terminal states, re-entrant).
The surviving guardrail is named state_machine (rule type, already so named). The top-level metadata type workflow is removed from the type registry and schema map. This is greenfield (no production data; per ADR-0019 §Greenfield) — a code refactor, not a data migration.
Wire the validations union into the write path — today nothing evaluates it (see §prior-state plumbing gap). Concretely:
- Plumb the prior/merged record in. Extend the rule-evaluation entry point (today
validateRecord(schema, data, mode)) to receive the prior record on update — e.g.validateRecord(schema, data, mode, previous?), or run the rule pass from abeforeUpdatestep that already holds both old and new. This unblocksstate_machineand the currently-crippledcross_field/scriptrules in one move; do it union-wide, notstate_machine-only. - Transition check. On update: if
old[field] !== new[field]andnew[field] ∉ transitions[old[field]], reject with the rule'smessage. On insert: validatenew[field]is the declared initial state — derived from theField.selectoption markeddefault: true(no separateinitialkey needed; showcase relies on this). - Introspection endpoint (follow-on). Expose
legalNext(object, field, currentState)so UI/Agents read the legal set instead of re-deriving it.
This is the highest-leverage change in the ADR: it turns the guardrail (and the rest of validations) from declaration into enforcement.
The transition declaration stays a flat, recognizable FSM (field + { from: [to] }, optional CEL guard per transition). No hierarchical/parallel/context machinery in the guardrail. Anything that needs "do something when the state changes" is a record-triggered Flow (ADR-0019), not part of the machine.
Positive
- The guardrail actually works: illegal transitions are rejected at write time — the AI-mistake protection the concept was created for.
- One shape, conventional name → an AI author has exactly one obvious, prior-aligned way to declare it; no silent no-op forms.
flow/workflowambiguity disappears.- Dead code removed (
StateMachineSchemaXState surface,object.stateMachines, the unimplementedIWorkflowService). - Bonus: plumbing the prior/merged record into rule evaluation (D3) also makes
cross_fieldandscriptrules work on PATCH updates — they are silently broken today for the same reason.
Negative / costs
- Breaking schema change:
workflowmetadata type andStateMachineConfigauthoring go away;examples/app-crm/src/workflows/*.workflow.tsmust be rewritten (transition guard →state_machinerule; theemail_alert/task_creationactions → a record-triggered Flow). - Loses the theoretical expressiveness of hierarchical/parallel statecharts. Accepted: that was never enforced, and orchestration is Flow's job.
-
spec: removeworkflowfrom the metadata type enum + registry (metadata-plugin.zod.ts) and the schema map (metadata-type-schemas.ts). -
spec: canonical home — thestate_machinevalidation rule (validation.zod.ts). Removedobject.stateMachines(object.zod.ts) and thestack.workflowsarray (stack.zod.ts). (Deviation: kept theStateMachineSchemafile — see Implementation notes.) - [~]
spec:IWorkflowService— kept as a documented follow-up, not removed (see Implementation notes). -
objectql: wire thevalidationsunion into the write path — newrule-validator.ts(evaluateValidationRules/needsPriorRecord/legalNextStates), with the prior record plumbed intoengine.tson single-row update. Enforcesstate_machine,cross_field, andscripttogether. -
metadata-collection.zod.ts: dropped theworkflowscollection key +workflows: 'workflow'plural mapping (metadata-collection.zod.ts). -
examples/app-crm: rewrotesrc/workflows/*.workflow.ts— transition tables already live as theopp_stage_transitionsstate_machinerule on the opportunity object; side-effect actions became record-triggered / scheduled Flows (high-value-deal.flow.ts,stale-opportunity.flow.ts); removed theworkflowsregistration fromobjectstack.config.ts. -
examples/app-showcase: carries the surviving shape —state_machinerules onTask,Project,Account. Predicate conditions corrected to therecord.<field>CEL scope form so enforcement actually fires. - Tests:
rule-validator.test.ts(16 cases — allow/reject/no-op transitions, execution-control, predicate fail-open, introspection). Updatedobject.test.ts,metadata-plugin.test.ts,metadata-collection.test.ts,overlay-precedence.test.tsfor the retired shapes.
The proposal's intent is fully delivered — converge to the state_machine rule, retire the workflow metadata type, enforce on the write path — with three bounded deviations, all to avoid scope creep into unrelated subsystems:
-
StateMachineSchema(the XState-style schema) is kept, not deleted. It is still imported by the agent conversation lifecycle (ai/agent.zod.ts) and the discovery protocol (api/protocol.zod.ts). Only its role as theworkflowmetadata-type backing schema and theobject.stateMachines/stack.workflowshomes were removed. Deleting the schema outright would have churned the agent/protocol surfaces, which is a separate concern. -
The
workflowRPC service surface is kept as a follow-up.CoreServiceName.workflow, the/api/v1/workflowroute catalog (DEFAULT_WORKFLOW_ROUTES), andIWorkflowServiceremain — they describe an unimplemented optional service, not the retired metadata type, and removing them touches the service-discovery contract and its tests. Tracked as a follow-up; the metadata-type retirement (the AI-hallucination hazard this ADR targets) is complete. -
No per-transition CEL
guardwas added toStateMachineValidationSchema(it was "optional" in D3/D4). The flatfield+transitionstable is enforced as-is; a conditional transition can be expressed today as a siblingscript/conditionalrule. Guards can be added later without a breaking change.
Enforcement scope: rules run on single-row insert/update through the merged {...previous, ...patch} record. Multi-row (updateMany) updates log a warning and skip rule evaluation rather than silently enforcing on incomplete data — surfaced, not hidden.
- Name it
lifecycle. Rejected: off the model's priors vs.state_machine, and already overloaded inobject.zod.ts. - Keep the full XState
StateMachineSchemaand build an interpreter. Rejected: orchestration is Flow's job (ADR-0019); a statechart engine beside Flow re-creates the two-engine problem ADR-0019 just removed. The guardrail need is a flat transition table. - Keep
object.stateMachinesas an object-embedded host. Rejected: it is the same XState schema in a second unread location; the parallel-lifecycle need it cites is met by N per-fieldstate_machinerules. Two homes is the multi-shape hazard, not a convenience. - Make
state_machinea standalone metadata type /.state.tsfile. Rejected: it is intrinsically a per-field constraint on one object (not reusable, not independently versioned), sharesBaseValidationSchemawith eight sibling rule types, and a standalone file just re-creates the top-level type this ADR retires. If a state table grows large, split the TypeScript (export const fooValidations = [...]), not the metadata model. - Encode transitions as a Salesforce-style
scriptformula instead of a structuredstate_machinerule. Rejected: a free-form predicate is not introspectable — UI can't grey out illegal actions and an Agent can't read the legal-next set, which is the whole point. The structured table is the upgrade over the Salesforce approach. - Leave all three shapes, just add enforcement to one. Rejected: three declaration shapes for one concept is itself the AI-hallucination hazard this ADR exists to remove.
This ADR enforced state_machine / cross_field / script and left the other six rule types declarative "until a later phase." #1475 finished that phase, applying the same "no advertised-but-unenforced capability" principle to the rest of the union. The "nine types" figure quoted in the problem statement above is therefore now six:
- Enforced (added by #1475):
format(regex / named email·url·phone·json),json_schema(ajv-compiled),conditional(recursivewhen→then/otherwise). These join the three this ADR shipped — all are deterministic, synchronous, side-effect-free predicates over one record, the contract that makes them safe on the write path. - Removed from the schema:
unique,async,custom. Each needs I/O or a handler model a write-path rule must not carry, so it was trimmed rather than left a silent no-op, and redirected to the layer that already does it correctly: uniqueness → a unique index (ObjectSchema.indexes,partialfor scope) or field-levelunique: true; async/remote → the client form layer; custom code → abeforeInsert/beforeUpdatelifecycle hook.
Deviation #3 above ("a conditional transition can be expressed as a sibling script/conditional rule") now rests on a genuinely enforced conditional. See validation.zod.ts for the trimmed taxonomy and rule-validator.ts for the evaluator; examples/app-showcase demonstrates and verifies each enforced type (test/validation.test.ts).