Skip to content

Latest commit

 

History

History
215 lines (176 loc) · 12.3 KB

File metadata and controls

215 lines (176 loc) · 12.3 KB
title State Machine (Lifecycle)
description Define strict business logic constraints prevents AI hallucinations by enforcing valid transitions.

State Machine Protocol

The State Machine (state_machine validation rule) lets you define the "Constitution" of a record's lifecycle: the legal status transitions a record may take. It is a flat, textbook finite-state-machine transition table that the write path enforces.

Per [ADR-0020](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0020-state-machine-converge-and-enforce.md), a state machine is **one of the object's `validations`** — a rule with `type: 'state_machine'`. There is no top-level `stateMachine`/`stateMachines` property on an object, and the older XState-style shape (hierarchical states, `on`/`cond`/`actions`, `meta.aiInstructions`) was retired. A flat `{ from: [to] }` transition table is the only enforced shape.

Why State Machines?

In the era of AI Agents, field-level validation is not enough. Large Language Models (LLMs) can "hallucinate" and attempt illogical data updates (e.g., moving a contract from draft directly to paid without going through approval).

The transition table provides a hard constraint layer:

  • Deterministic: On update, if the state field changed and the new value is not listed under the current value, the write is rejected.
  • Self-Documenting: The whole legal graph lives in one place, as data.
  • Introspectable: UIs can grey out illegal buttons and an Agent can ask "from here, what's legal next?" instead of parsing a formula.

Definition

Add a state_machine rule to the object's validations array. The rule names the state field and declares a transitions map of { currentValue: [allowedNextValues] }.

import { ObjectSchema, Field } from '@objectstack/spec/data';

export const PurchaseRequest = ObjectSchema.create({
  name: 'purchase_request',
  label: 'Purchase Request',

  fields: {
    status: Field.select({
      label: 'Status',
      required: true,
      options: [
        { label: 'Draft', value: 'draft', default: true },
        { label: 'Pending', value: 'pending' },
        { label: 'Approved', value: 'approved' },
        { label: 'Rejected', value: 'rejected' },
      ],
    }),
    amount: Field.number(),
  },

  validations: [
    {
      type: 'state_machine',
      name: 'purchase_status_flow',
      label: 'Purchase Status Flow',
      field: 'status',
      // This rule governs UPDATE transitions only (`events: ['update']`
      // below). `initialStates` — the optional FSM entry point that locks
      // which states a record may be CREATED in (#3165) — is checked on
      // INSERT, so to use it here you'd also add 'insert' to `events`.
      // Otherwise the field's `default: true` option is the starting value.
      events: ['update'],
      message: 'Invalid purchase status transition.',
      transitions: {
        draft: ['pending'],
        pending: ['approved', 'rejected'],
        // `approved` and `rejected` are dead-end states (no outgoing edges).
      },
    },
  ],
});
`ObjectSchema.create()` rejects unknown top-level keys (ADR-0032). A typo'd `stateMachine:` or `stateMachines:` key throws a build error rather than being silently ignored.

Structure

A state_machine rule shares the common validation-rule fields (name, label, message, severity, events, priority, active) with its siblings, plus its own:

Field Type Meaning
field string The state field this rule governs (e.g. status).
transitions Record<string, string[]> Map of { currentValue: [allowedNextValues] } — the legal edges (enforced on update).
initialStates string[] (optional) States a record may be created in. When set, an insert whose field value falls outside this list is rejected (invalid_initial_state) — the FSM entry point (#3165). Omit to keep the legacy no-check-on-insert behavior.

Transitions

transitions is the whole legal graph. The keys are current state values; each value is the list of states the record may move to. A state with no key (or an empty array) is a dead-end with no outgoing edges.

transitions: {
  draft: ['pending'],
  pending: ['approved', 'rejected'],
}

Enforcement semantics

  • On insert, the transitions table is not consulted — there is no prior state to transition from. If the rule declares initialStates, the created value must be one of them, or the write is rejected with invalid_initial_state (the FSM entry point). Without initialStates, insert is a no-op and the starting value is constrained only by the field-level select check — typically the option marked default: true.
  • On update, if the state field changed and the new value is not in transitions[oldValue], the write is rejected.
  • The check is lenient where it cannot reason: if the prior state is not described by the table (e.g. legacy or externally-written data), it does not block.
  • Only a rule with severity: 'error' (the default) blocks the write; warning/info are logged.
  • Seed writes are exempt (#3433). Curated seed data — package bootstrap fixtures, marketplace templates, per-org replay, all loaded by SeedLoaderService — is a snapshot of established facts, not a record walking its lifecycle, so it bypasses the state_machine rule entirely: a seed may be born mid-lifecycle (a completed project, a closed_won opportunity) and neither initialStates (insert) nor transitions (update) is enforced. Every other validation still runs, so a seed must still satisfy field shape, format, script, and the rest. os lint warns when a seeded value is not a state the machine declares, so a typo is still caught before boot.
  • A "historical" data import is exempt too (#3479). Migrating established facts — a batch of already-closed tickets, closed_won deals — is the same "snapshot, not a lifecycle event" situation. Set treatAsHistorical: true on the import request (default off) and the runner puts skipStateMachine on the write context, so initialStates doesn't reject those mid-lifecycle rows. A normal import leaves it off and still walks the FSM — the strict behavior is the default, so the exemption is always an explicit opt-in.
  • treatAsHistorical also preserves the original audit timeline (#3493). Skipping the FSM is only half of migrating established facts; the other half is keeping when they happened and who did them. Under the same flag the write context also carries preserveAudit, which (1) makes updated_at / updated_by client-preferred — a supplied historical last-modified survives instead of being stamped with the import instant, symmetric with how created_at / created_by already behave on insert — and (2) admits a whitelist through the static-readonly write strip: the audit/timestamp family plus author-declared business readonly fields (closed_at, resolved_by, …), so an upsert refresh no longer silently drops them. Platform-managed system columns outside that family (organization_id and other tenancy/generated columns) stay stripped — a historical import reinstates facts, it does not forge tenancy. Like the FSM exemption this is opt-in: a normal write still auto-stamps updated_at/updated_by and strips readonly exactly as before, and permissions / RLS / field-level security are unchanged.
  • Undoing a historical import is symmetric (#3549 / #3556). The import undo (POST /data/import/jobs/:jobId/undo) logically rolls back a finished job — deleting the rows it created and restoring the captured pre-import snapshot on the rows it updated. That restore write now carries preserveAudit too, but only when the job was flagged treatAsHistorical, so the snapshotted updated_at / updated_by and business readonly fields (closed_at, …) are reinstated verbatim instead of being re-stamped to the undo instant. Without it the undo would silently overwrite the very timeline the historical import preserved; a normal (non-historical) import's undo keeps the default stamp/strip.

Conditional transitions

A state_machine rule has no per-transition guard. To gate a transition on a predicate, add a sibling script or conditional validation rule (CEL) in the same validations array.

Introspection

Because the transition table is data, both UIs and Agents can ask "from this state, what's legal next?" instead of parsing a formula.

  • In code, legalNextStates(objectSchema, field, currentState) from @objectstack/objectql returns the declared next states, [] for a known dead-end, or null when no state_machine rule governs the field.
  • Over HTTP, GET /api/v1/meta/objects/:name/state/:field?from=:state returns { object, field, from, next }, where next is the legal-next list (or null).
When an AI Agent or Flow tries to update `status` to `approved` while the record is in `draft`, the write fails with a `ValidationError` (field error code `invalid_transition`) — the AI-mistake protection the rule exists for. There is no automatic injection of per-state AI instructions into the prompt; the guardrail is the enforced transition table.

Best Practices: File Structure

For complex business objects (like Lead, Opportunity, or Order), a transition table can grow large. To keep your object definition readable, extract the rule(s) into a plain TypeScript constant — there is no special *.state.ts format or StateMachineConfig type for object lifecycles; it is just an array of validation rules.

// src/objects/lead.validations.ts
export const leadValidations = [
  {
    type: 'state_machine' as const,
    name: 'lead_status_flow',
    field: 'status',
    message: 'Invalid lead status transition.',
    events: ['update'] as const,
    transitions: {
      new: ['qualified', 'unqualified'],
      qualified: ['converted', 'unqualified'],
    },
  },
];
// src/objects/lead.object.ts
import { ObjectSchema } from '@objectstack/spec/data';
import { leadValidations } from './lead.validations';

export const Lead = ObjectSchema.create({
  name: 'lead',
  // ... fields ...
  validations: leadValidations,
});

Multiple Lifecycles (Parallel State Lines)

In real enterprise systems, a single object often has multiple independent state lines. For example, an Order has:

  • lifecycledraft → submitted → confirmed → shipped → delivered
  • paymentunpaid → partial → paid → refunded
  • approvalpending → approved → rejected

These are N flat state_machine rules, one per state field, in the same validations array — not hierarchical or parallel statechart regions.

// src/objects/order.object.ts
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Order = ObjectSchema.create({
  name: 'order',
  fields: {
    status: Field.select({ options: ['draft', 'submitted', 'confirmed', 'shipped', 'delivered'] }),
    payment_status: Field.select({ options: ['unpaid', 'partial', 'paid', 'refunded'] }),
    approval_status: Field.select({ options: ['pending', 'approved', 'rejected'] }),
  },
  validations: [
    {
      type: 'state_machine',
      name: 'order_lifecycle',
      field: 'status',
      message: 'Invalid order status transition.',
      transitions: {
        draft: ['submitted'],
        submitted: ['confirmed'],
        confirmed: ['shipped'],
        shipped: ['delivered'],
      },
    },
    {
      type: 'state_machine',
      name: 'order_payment',
      field: 'payment_status',
      message: 'Invalid payment status transition.',
      transitions: {
        unpaid: ['partial', 'paid'],
        partial: ['paid', 'refunded'],
        paid: ['refunded'],
      },
    },
    {
      type: 'state_machine',
      name: 'order_approval',
      field: 'approval_status',
      message: 'Invalid approval status transition.',
      transitions: {
        pending: ['approved', 'rejected'],
      },
    },
  ],
});
"Do something when the state changes" (emails, webhooks, downstream record updates) is **not** part of the transition table. Express side effects as a record-triggered [Flow](/docs/automation/flows) — the state machine only locks the legal edges.