State Machines

How HotCRM declares valid status transitions on leads and opportunities — what the tables do and do not do, and how to extend the rules.

State Machines

Some objects have a strict lifecycle — a lead progresses from New to Contacted to Qualified and on to Converted. A state machine declares that route; it does not enforce it. All five machines in this app carry warning severity, so a user can move a record outside the route and the save still goes through — see A declared route is advice, not a gate below.

Where state machines apply

HotCRM uses state machines on five objects:

  • LeadsNew → Contacted → Qualified → Converted, with Unqualified reachable from every open status and the only way back out of it being a re-open to New
  • Opportunities — through the 7 stages Prospecting → Qualification → Needs Analysis → Proposal → Negotiation → Closed Won / Closed Lost
  • CasesNew → In Progress → Resolved → Closed, with Waiting on Customer and Waiting on Support to park an open case, Escalated reachable from any unresolved status, and both Resolved and Closed able to reopen to In Progress
  • ContractsDraft → In Approval → Activated → Expired, with In Approval able to send the paperwork back to Draft, Terminated reachable from all three live statuses, and Expired and Terminated both final
  • QuotesDraft → In Review → Presented → Accepted, with In Review able to return to Draft, Rejected starting over at Draft, Expired reachable from Draft, In Review and Presented, and Accepted and Expired both final

All five are declared the same way — a named state_machine validation rule on the object, over that object's lifecycle field (status, or stage on opportunities). Cases, contracts and quotes are not a lighter mechanism than leads and opportunities; they are the same one. The top-level stateMachines key was removed in platform 7.7 and every machine moved onto its object.

A declared route is advice, not a gate. All five rules carry warning severity, so a move outside the transition table is written to the server log — "Invalid contract status transition", and so on — and the save still goes through. Nor is a record checked when it is created: the rules compare a new value against the previous one, so they only run on update, and a record imported straight into a late state passes untouched. What the table buys you is a declared, machine-readable route that automation, reports and the Copilot can rely on — not a lock on the field.

Campaigns have no state machine, and that is deliberate. A campaign's status is descriptive (Planning / In Progress / Completed / Aborted) rather than a controlled lifecycle, and the same is true of the status fields on campaign members, events, knowledge articles and tasks. A transition table on any of them would warn on ordinary edits and teach users to ignore the warning.

The Lead state machine

The main route, with the two side edges that leave it:

   New  ──Contact──▶  Contacted  ──Qualify──▶  Qualified  ──Convert──▶  Converted
    ▲                     │                        │                   (terminal)
    │                     │  Disqualify            │  Disqualify
    │                     └───────────┬────────────┘
    │                                 ▼
    │                          ┌─────────────┐
    └────────── Re-open ───────│ Unqualified │
                               └─────────────┘

Allowed transitions — this is the whole table, copied from the lead_status_progression rule in src/objects/lead.object.ts, and it carries a few shortcuts the picture above leaves out:

FromMay move toNotes
NewContacted, Qualified, Unqualified, ConvertedA lead can be qualified or converted without ever passing through Contacted
ContactedQualified, Unqualified, Converted
QualifiedConverted, Unqualified
UnqualifiedNewRe-open. This is the only edge out of Unqualified — it is not a terminal status
ConvertedTerminal

Two of those edges have machinery behind them. Moving to Unqualified needs a Disqualification Reason: a separate validation rule, disqualification_reason_required, fires when the status is unqualified and the reason is blank — and unlike the transition table it is error severity, so that one does reject the save. Moving to Converted is the Convert Lead button, which runs the lead_conversion flow — it creates the account, contact and opportunity and stamps status: 'converted' at the end.

Allowed on purpose: New directly to Converted. Convert Lead is visible on any lead that is neither converted nor unqualified — so New, Contacted and Qualified all show it — and the flow stamps the status itself, so that shortcut has to be in the table or every conversion from an unworked lead would log a spurious "invalid transition" warning. The comment above the table in lead.object.ts says exactly this.

Not in the table: anything out of Converted, which is terminal; stepping back from Qualified to Contacted or New; and re-qualifying an Unqualified lead in one move — the way back from there is a re-open to New.

The Opportunity state machine

Prospecting → Qualification → Needs Analysis → Proposal → Negotiation
                                                  │            │
                                                  └─────┬──────┘

                                                   Closed Won

Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ Closed Lost

There are two closed stages, not three: Closed Won and Closed Lost. There is no Closed Without Decision — a deal that goes quiet is a Closed Lost.

Allowed transitions, copied from the opportunity_stage_progression rule in src/objects/opportunity.object.ts:

FromMay move toNotes
ProspectingQualification, Proposal, Closed Lost
QualificationNeeds Analysis, Proposal, Closed Lost
Needs AnalysisProposal, Closed Lost40% default probability, Best Case forecast category
ProposalNegotiation, Closed Won, Closed Lost
NegotiationClosed Won, Closed Lost
Closed WonTerminal
Closed LostTerminal

Two of those edges exist for the CPQ path rather than for a rep clicking through stages: → Proposal is legal from every pre-proposal stage because the quote_generation flow fast-forwards the deal to Proposal the moment a quote is generated, which can happen at any open stage; and Proposal → Closed Won is legal because the quote_on_accepted hook wins the linked deal straight from an accepted quote, with no separate negotiation step.

Not in the table: moving backwards. No open stage declares an edge to an earlier one, so Negotiation back to Proposal — a deal slipping — is outside the route and logs a warning like any other departure (it still saves, per the note above). Neither closed stage declares an edge either, so there is no reopen transition and no admin path back to an open stage.

A closed opportunity is separately frozen, and by more than the stage field: the opportunity_lifecycle hook refuses any user edit to a Closed Won or Closed Lost record except to description, next_step and notes, and that refusal is a thrown error naming the fields attempted — not a warning. Approval verdicts and platform-managed columns are exempted so an in-flight approval can still land, and a write with no authenticated user (a seed or a backfill) is not guarded at all.

The system also enforces:

  • Close Date is required — and not from Proposal onwards, but always: close_date carries required: true with notNull storage, so an opportunity cannot be created without one.
  • Amount is required — unconditional in the same way. Neither field waits for a stage.
  • Won/Lost reason is required when entering Closed Lost or Closed WonrequiredWhen predicates on win_reason and loss_reason, keyed on the stage.
  • Approval starts at $100K. The opportunity_approval flow fires on any update that leaves an open opportunity at $100,000 or more with no approval on file, and routes it to a Sales Manager. $500K is the second tier, not the entry: above that a Sales Director signs off after the manager. So a $200K deal does go through approval.

The heading is fair for the first three, unlike the transition tables above them: required and requiredWhen are hard, and a save that violates one is refused. The fourth is not a save-time check at all — opportunity_approval is a record_change flow that runs after the update and then holds the record locked while a step is pending.

What a state machine buys you

Not prevention. Everything this section used to list as impossible still happens exactly as it did before the table was declared: a rep can mark a lead Converted by accident, an opportunity can go from Prospecting straight to Closed Won, and records can skip stages. Each of those writes one warning to the server log and is then saved. So a funnel report does not "reflect reality" by virtue of a table existing — it reports whatever stages the records actually passed through.

What the table does buy you is the route itself, written down in one machine-readable place:

  • One place the lifecycle lives. A reviewer, a report author or an agent writing metadata for this app reads the intended route off the object, instead of inferring it from every flow and hook that writes the field.
  • Illegal moves are reported rather than silent. The move is saved, but the engine names it in the log — "Invalid opportunity stage transition", with the rule's own name — so a bad import or a misbehaving integration can be found afterwards. A move inside the table logs nothing, which is what makes the warnings worth reading.
  • Something for automation and reporting to point at. A flow condition, a report or a skill can be written against the declared route instead of a list of statuses copied out by hand.

If you need a status change to be genuinely impossible, the state machine is the wrong tool: an error-severity validation rule is the one that blocks a save. Keeping these five advisory is deliberate — the reasoning is written into the rules themselves (src/objects/quote.object.ts): a support-driven correction must not require a data fix.

Customising state machines

Admins can:

  • Add new statuses (e.g., Lead status = Nurturing for marketing follow-up).
  • Remove transitions (e.g., disallow going backwards through opportunity stages).
  • Add entry conditions (e.g., Cannot enter Proposal without a Decision Maker contact role).
  • Add exit conditions (e.g., Cannot leave Negotiation without an updated Close Date).
  • Trigger automation on each transition (object hook, flow, notification).

All of that is a source edit, not a Setup screen: there is no Setup → Object → Status → State Machine page. Each table is a validations[] entry of type state_machine on its object (src/objects/*.object.ts), and that entry carries a transition map and nothing else — so the first two changes above are edits to the map, entry and exit conditions are separate validations[] rules or requiredWhen predicates on the fields concerned, and per-transition automation is a record_change flow or an object hook. Each of them is a code change plus a redeploy.

What users see

  • The status dropdown is not filtered. It offers every option the field declares, whatever the record's current status is. The platform can compute the legal next states — legalNextStates in @objectstack/objectql, reachable over GET /objects/{name}/state/{field}?from=… — but nothing in this app calls it, and no list, form or detail page narrows a picklist by it.
  • A disallowed transition raises no error and no dialog. The save goes through and the record keeps its new value; the only trace is a single server-side log line — WARN Validation rule 'lead_status_progression' (warning): Invalid lead status transition — which nobody working in the UI ever sees.
  • Required fields for the new state are enforced — the user sees them inline before save. That is a different mechanism: a requiredWhen predicate on the field (for example win_reason when the stage becomes Closed Won), which does reject the save.

State machines and the AI Copilot

Nothing in this app connects the two. The six skills under src/skills/ and every action under src/actions/ name no transition table, no state machine and no legal-next-state lookup, and no skill's instructions carry a list of which statuses may follow which. This page previously described the Copilot withholding a "Qualify this lead" or "Convert this lead" suggestion until the record's status allowed the move; HotCRM declares nothing that would produce that behaviour.

Whether the platform's own agent reads a state_machine validation when it loads an object's metadata, and narrows its suggestions accordingly, is a question about the runtime rather than about this app — so this page makes no claim about it in either direction. If you are relying on the Copilot to stay inside a lifecycle, verify it against your deployment rather than against this page.

What the tables do give an agent is what they give a report author: the route, declared in one place on the object, for a skill or a flow to be written against deliberately.

Tips for admins

  • Don't over-constrain — too many transition rules become a friction tax on the team.
  • ✅ When adding a new status, decide the entry and exit conditions first — the rest follows.
  • Don't hang automation off a transition — there is nothing to hang it on. The table is a validation rule, evaluated on the save that makes the move; it writes a log line and emits no event for automation to subscribe to. Automation that should run on one move is a record_change flow that fires on the save and narrows itself in its start condition, comparing record.stage against previous.stageopportunity_won_alert (src/flows/opportunity-won-alert.flow.ts) does exactly that, so the congratulations blast goes out on the move into Closed Won and not on every later edit of a won deal.
  • Test the wizard flows (convert lead, close opportunity) after every change.

Tips for users

  • A status dropdown never hides an option on the state machine's account — it does not filter the picklist at all. If an option you expect is missing, look at the field's own options or at your profile's field-level security, not at the transition table.
  • There is no "bypass state machine" permission to ask for, and nothing to bypass. Importing legacy data straight into a late status needs no grant: on create the table is not consulted at all, and on update a move outside it is logged and saved. What you should still do is tell an admin, so the warnings that land in the log are recognised as the import rather than chased as a bug.

On this page