|
| 1 | +# ADR-0020: State Machine — converge three declaration forms to one enforced `state_machine` |
| 2 | + |
| 3 | +**Status**: Proposed (2026-05-31) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0019](./0019-approval-as-flow-node.md) (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](./0009-execution-pinned-metadata.md) (execution-pinned metadata), [ADR-0010](./0010-nl-to-flow-authoring.md) + [ADR-0011](./0011-actions-as-ai-tools.md) (AI authoring is the design center) |
| 6 | +**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. |
| 7 | +**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`) |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## TL;DR |
| 12 | + |
| 13 | +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**. |
| 14 | + |
| 15 | +1. A top-level `workflow` metadata type backed by an XState-style `StateMachineSchema`. |
| 16 | +2. A `stateMachines` map embedded on the object (`object.stateMachines`). |
| 17 | +3. A `state_machine` **validation rule** (`{ fromState: [allowedToStates] }`). |
| 18 | + |
| 19 | +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. |
| 20 | + |
| 21 | +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. |
| 22 | + |
| 23 | +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. |
| 24 | + |
| 25 | +## Context |
| 26 | + |
| 27 | +### Why a state machine at all — the guardrail goal |
| 28 | + |
| 29 | +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. |
| 30 | + |
| 31 | +### Today: three declaration shapes, zero enforcement |
| 32 | + |
| 33 | +**Three shapes, one concept:** |
| 34 | + |
| 35 | +| # | Where | Schema | Reference | |
| 36 | +|---|-------|--------|-----------| |
| 37 | +| 1 | Top-level `workflow` metadata type | `StateMachineSchema` (XState-style: hierarchical/parallel states, entry/exit actions, guards, context) | [`metadata-type-schemas.ts:85`](../../packages/spec/src/kernel/metadata-type-schemas.ts#L85), [`metadata-plugin.zod.ts:90`](../../packages/spec/src/kernel/metadata-plugin.zod.ts#L90), [`metadata-plugin.zod.ts:612`](../../packages/spec/src/kernel/metadata-plugin.zod.ts#L612) | |
| 38 | +| 2 | Object-embedded | `object.stateMachines: Record<string, StateMachineSchema>` ("parallel lifecycles: status, payment, approval") | [`object.zod.ts:534`](../../packages/spec/src/data/object.zod.ts#L534) | |
| 39 | +| 3 | Validation rule | `state_machine` rule: `transitions: { fromState: [toStates] }` | [`validation.zod.ts:105`](../../packages/spec/src/data/validation.zod.ts#L105) | |
| 40 | + |
| 41 | +**Zero enforcement — verified across `packages/{runtime,objectql,services,core,metadata*,plugins}` and the whole repo:** |
| 42 | + |
| 43 | +- `IWorkflowService` ([`workflow-service.ts:58`](../../packages/spec/src/contracts/workflow-service.ts#L58)) has **no concrete implementation**. |
| 44 | +- There is **no XState interpreter** anywhere (no `createMachine` / `interpret` / transition engine). |
| 45 | +- The write-path validator [`validateRecord`](../../packages/objectql/src/validation/record-validator.ts#L198) reads only `objectSchema.fields` and validates **field data types** (string/number/date/…). It **never reads `objectSchema.validations`** at 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. |
| 46 | +- **Nothing reads `object.stateMachines`.** |
| 47 | + |
| 48 | +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`](../../examples/app-crm/src/workflows/stale-opportunity.workflow.ts#L19) (`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). |
| 49 | + |
| 50 | +#### The prior-state plumbing gap (the real implementation constraint) |
| 51 | + |
| 52 | +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`](../../packages/objectql/src/engine.ts#L1850) 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). |
| 53 | + |
| 54 | +### The design-center shift: AI is the author — optimise naming for the model's priors |
| 55 | + |
| 56 | +Future automation is **AI-generated, human-previewed** (ADR-0010 / ADR-0011). That changes how we should name this concept: |
| 57 | + |
| 58 | +- 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. |
| 59 | +- "**state machine**" is that term — Rails `state_machine`, AWS Step Functions "State Machine", XState, Spring Statemachine. An AI given a field named `state_machine` with 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. |
| 60 | +- `state_machine` also reads as **maximally distinct from `flow`** — eliminating the `flow` / `workflow` near-synonym ambiguity that makes an AI pick the wrong type. |
| 61 | +- `lifecycle` is additionally **already overloaded** in this codebase (managed-by buckets and toolbar "lifecycle actions" in [`object.zod.ts`](../../packages/spec/src/data/object.zod.ts) at L354/L371/L410/L765), so reusing it would create a *new* ambiguity. |
| 62 | + |
| 63 | +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. |
| 64 | + |
| 65 | +### Industry precedent — and why this is *not* a Salesforce validation rule |
| 66 | + |
| 67 | +Binding "legal transitions" to the data model is a well-trodden pattern, in two camps: |
| 68 | + |
| 69 | +- **First-class FSM on the model** (a structured transition table): Rails **AASM** / `state_machine` gem (`transitions from: :a, to: :b` on the model), **Django** `django-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*. |
| 70 | +- **Generic predicate emulating a transition**: **Salesforce** Validation Rules — a boolean formula using `ISCHANGED(Status)` + `PRIORVALUE(Status)` + `ISPICKVAL(...)`; TRUE blocks the save. |
| 71 | + |
| 72 | +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. |
| 73 | + |
| 74 | +How this differs from a Salesforce validation rule — same placement and trigger (object-bound, on save), different **representation**: |
| 75 | + |
| 76 | +| | Salesforce Validation Rule | This `state_machine` rule | |
| 77 | +|---|---|---| |
| 78 | +| Form | generic boolean **formula**, TRUE = block | structured **transition table** `{ from: [to] }` | |
| 79 | +| Expressing a transition | hand-coded `ISCHANGED` + `PRIORVALUE` + `ISPICKVAL` | list the edges | |
| 80 | +| One rule covers | one forbidden condition (graph scattered across many rules) | the whole legal graph (one place) | |
| 81 | +| Introspectable? | ❌ opaque formula text | ✅ machine-readable — UI greys out illegal buttons, an Agent can ask "from here, what's legal next?" | |
| 82 | + |
| 83 | +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. |
| 84 | + |
| 85 | +### Where it lives: one of nine validation-rule types |
| 86 | + |
| 87 | +`state_machine` is one variant of the `ValidationRuleSchema` discriminated union ([`validation.zod.ts:362`](../../packages/spec/src/data/validation.zod.ts#L362)), 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. |
| 88 | + |
| 89 | +## Decision |
| 90 | + |
| 91 | +### D1 — One declaration shape |
| 92 | + |
| 93 | +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: |
| 94 | + |
| 95 | +- **Retire the top-level `workflow` metadata type** (#1). The XState-style `StateMachineSchema` (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. |
| 96 | +- **Retire `object.stateMachines` (#2) as well.** It is the same XState `StateMachineSchema` in 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 **multiple `state_machine` rules, one per field**, in the same `validations` array. One authoring surface, not two. |
| 97 | + |
| 98 | +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). |
| 99 | + |
| 100 | +### D2 — Name it `state_machine`; retire `workflow` |
| 101 | + |
| 102 | +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. |
| 103 | + |
| 104 | +### D3 — Enforce it on the write path |
| 105 | + |
| 106 | +Wire the `validations` union into the write path — today nothing evaluates it (see §prior-state plumbing gap). Concretely: |
| 107 | + |
| 108 | +1. **Plumb the prior/merged record in.** Extend the rule-evaluation entry point (today [`validateRecord(schema, data, mode)`](../../packages/objectql/src/validation/record-validator.ts#L198)) to receive the prior record on update — e.g. `validateRecord(schema, data, mode, previous?)`, or run the rule pass from a `beforeUpdate` step that already holds both old and new. This unblocks `state_machine` **and** the currently-crippled `cross_field` / `script` rules in one move; do it union-wide, not `state_machine`-only. |
| 109 | +2. **Transition check.** On update: if `old[field] !== new[field]` and `new[field] ∉ transitions[old[field]]`, **reject** with the rule's `message`. On insert: validate `new[field]` is the declared initial state — derived from the `Field.select` option marked `default: true` (no separate `initial` key needed; showcase relies on this). |
| 110 | +3. **Introspection endpoint (follow-on).** Expose `legalNext(object, field, currentState)` so UI/Agents read the legal set instead of re-deriving it. |
| 111 | + |
| 112 | +This is the highest-leverage change in the ADR: it turns the guardrail (and the rest of `validations`) from declaration into enforcement. |
| 113 | + |
| 114 | +### D4 — Keep the shape conventional |
| 115 | + |
| 116 | +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. |
| 117 | + |
| 118 | +## Consequences |
| 119 | + |
| 120 | +**Positive** |
| 121 | +- The guardrail actually works: illegal transitions are rejected at write time — the AI-mistake protection the concept was created for. |
| 122 | +- One shape, conventional name → an AI author has exactly one obvious, prior-aligned way to declare it; no silent no-op forms. |
| 123 | +- `flow` / `workflow` ambiguity disappears. |
| 124 | +- Dead code removed (`StateMachineSchema` XState surface, `object.stateMachines`, the unimplemented `IWorkflowService`). |
| 125 | +- **Bonus:** plumbing the prior/merged record into rule evaluation (D3) also makes `cross_field` and `script` rules work on PATCH updates — they are silently broken today for the same reason. |
| 126 | + |
| 127 | +**Negative / costs** |
| 128 | +- Breaking schema change: `workflow` metadata type and `StateMachineConfig` authoring go away; `examples/app-crm/src/workflows/*.workflow.ts` must be rewritten (transition guard → `state_machine` rule; the `email_alert` / `task_creation` actions → a record-triggered Flow). |
| 129 | +- Loses the *theoretical* expressiveness of hierarchical/parallel statecharts. Accepted: that was never enforced, and orchestration is Flow's job. |
| 130 | + |
| 131 | +## Blast radius / implementation checklist |
| 132 | + |
| 133 | +- [ ] `spec`: remove `workflow` from the metadata type enum + registry ([`metadata-plugin.zod.ts:90`](../../packages/spec/src/kernel/metadata-plugin.zod.ts#L90), [`:612`](../../packages/spec/src/kernel/metadata-plugin.zod.ts#L612)) and the schema map ([`metadata-type-schemas.ts:85`](../../packages/spec/src/kernel/metadata-type-schemas.ts#L85)). |
| 134 | +- [ ] `spec`: **canonical home decided — the `state_machine` validation rule** ([`validation.zod.ts:105`](../../packages/spec/src/data/validation.zod.ts#L105)). Remove `object.stateMachines` ([`object.zod.ts:534`](../../packages/spec/src/data/object.zod.ts#L534)); delete the XState `StateMachineSchema` ([`state-machine.zod.ts`](../../packages/spec/src/automation/state-machine.zod.ts)); optionally add an optional per-transition CEL `guard` to `StateMachineValidationSchema`. |
| 135 | +- [ ] `spec`: remove/retire `IWorkflowService` ([`workflow-service.ts`](../../packages/spec/src/contracts/workflow-service.ts)) or repurpose it as the enforcement contract. |
| 136 | +- [ ] `objectql`: wire the `validations` union into the write path — **plumb the prior/merged record** into rule evaluation (extend [`validateRecord`](../../packages/objectql/src/validation/record-validator.ts#L198) signature or evaluate from a `beforeUpdate` step at [`engine.ts:1850`](../../packages/objectql/src/engine.ts#L1850)), then add `type` dispatch. Fixes `state_machine`, `cross_field`, and `script` together. |
| 137 | +- [ ] `metadata-collection.zod.ts`: drop the `workflows: 'workflow'` collection key ([`metadata-collection.zod.ts:117`](../../packages/spec/src/shared/metadata-collection.zod.ts#L117)). |
| 138 | +- [ ] `examples/app-crm`: rewrite `src/workflows/*.workflow.ts` — transition rules → `state_machine`; side-effect actions → record-triggered Flow; update `objectstack.config.ts` registration. |
| 139 | +- [ ] `examples/app-showcase`: **already carries the surviving shape** — `state_machine` rules on `Task`, `Project`, `Account` (added in this design pass, typecheck passing). Keep them as enforcement test fixtures; no new authoring needed. |
| 140 | +- [ ] Tests: add write-path tests asserting illegal transitions are rejected and legal ones pass (e.g. `Account` `active → prospect` rejected, `churned → active` allowed). |
| 141 | + |
| 142 | +## Alternatives considered |
| 143 | + |
| 144 | +- **Name it `lifecycle`.** Rejected: off the model's priors vs. `state_machine`, and already overloaded in `object.zod.ts`. |
| 145 | +- **Keep the full XState `StateMachineSchema` and 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. |
| 146 | +- **Keep `object.stateMachines` as 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-field `state_machine` rules. Two homes is the multi-shape hazard, not a convenience. |
| 147 | +- **Make `state_machine` a standalone metadata type / `.state.ts` file.** Rejected: it is intrinsically a per-field constraint on one object (not reusable, not independently versioned), shares `BaseValidationSchema` with 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. |
| 148 | +- **Encode transitions as a Salesforce-style `script` formula instead of a structured `state_machine` rule.** 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. |
| 149 | +- **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. |
0 commit comments