State Machine Basics: An AI Chat Example
This is the technical companion to Why Use State Machines?, written for developers. It walks through one small state machine map from my framework, explains what happens when things go wrong, and lists all the building blocks (primitives) and how they are stored.
The Example Map: AI Chat Initiation
Initial State | ⚡ A-RESET_CONTEXT-A
with Entry and | ◯ S-IDLE-S
Exit Actions | ⚡ A-LOG_IDLE_EXIT-A
| |
| +-------------------------+
| | |
2 Transitions | 🛑 G-CONFIG_READY-G 🛑 G-CONFIG_MISSING-G
with Guards and | 🔔 E-START_CHAT_REQ-E 🔔 E-START_CHAT_REQ-E
Actions | ⚡ A-INIT_CONNECTION-A ⚡ A-OPEN_SETUP_DIALOG-A
| | |
| V V
2 States | ◯ S-CHAT_ACTIVE-S ◯ S-AWAITING_SETUP-S
| |
| V
1 Transition | 🛑 G-INPUT_VALID-G
with Guards and | 🔔 E-SETUP_COMPLETE-E
Actions | ⚡ A-INIT_CONNECTION-A
| |
| V
1 State | ◯ S-CHAT_ACTIVE-S
Each name carries its type in a prefix and suffix: S-…-S for states, E-…-E for events, G-…-G for guards, and A-…-A for actions.
The Four Main Primitives
States (◯ S-NAME-S)
These represent the different modes or conditions of the application. The machine is always in exactly one state.
Guards (🛑 G-NAME-G)
Logical conditions evaluated before a transition is taken. They act as a “Lookahead” gate; if the guard returns False, the transition is blocked. [Only one guard per transition]
Events (🔔 E-NAME-E)
The triggers that tell the machine it is time to transition to a new state (e.g., a user clicking a button).
Actions (⚡ A-NAME-A)
The functional “Muscles” that perform work. In this system, they are categorized by their placement in the sequence:
Entry Actions: Located above their state in the map above (the left “ear” in the graphical map editor); they fire automatically upon entering the state.
Exit Actions: Located below their state in the map above (the right “ear” in the graphical map editor); they fire automatically before leaving the state.
Transition Actions: Located on the transition line (below the guard/event stack); they fire only when moving between states.
Walking Through the Example
This follows the life cycle of the AI Chat Initiation state machine, describing how the application moves from resting to active communication.
1. The Resting Phase
The machine begins in ◯ S-IDLE-S. Because of its entry action (⚡ A-RESET_CONTEXT-A), the machine automatically wipes its internal memory every time it returns here, ensuring no stale IP addresses or provider settings linger from previous sessions.
2. The Trigger
The silence is broken when the user clicks “Start Chat,” which rings the 🔔 E-START_CHAT_REQ-E event. As the machine prepares to leave the idle state, it executes its exit action (⚡ A-LOG_IDLE_EXIT-A), recording the timestamp of the request for the system logs.
3. The Logical Fork
The machine now stands at a crossroads. It evaluates the guards of both transitions, in priority order, to decide its path:
- The Happy Path: If the machine sees that an IP and Model are already saved in memory (🛑 G-CONFIG_READY-G), it immediately fires the connection “muscle” (⚡ A-INIT_CONNECTION-A) and transitions directly into the fully operational ◯ S-CHAT_ACTIVE-S state.
- The Setup Path: If the machine detects that it doesn’t know who to connect to (🛑 G-CONFIG_MISSING-G), it takes a detour. It fires a transition action (⚡ A-OPEN_SETUP_DIALOG-A) to pop up a window for the user and moves into a waiting mode: ◯ S-AWAITING_SETUP-S.
4. The Interaction Detour
While the machine is in ◯ S-AWAITING_SETUP-S, the system pauses and a dialog pops up. It remains here until the user provides the missing IP, Provider, and Model. Once the user clicks “Finish,” a new event occurs: 🔔 E-SETUP_COMPLETE-E.
5. Final Validation & Success
Before leaving the ◯ S-AWAITING_SETUP-S state and initiating the chat, the machine performs one last check. It evaluates the 🛑 G-INPUT_VALID-G guard to ensure the user didn’t leave any fields blank or enter an invalid IP. If the data passes this gate, the machine executes the connection muscle (⚡ A-INIT_CONNECTION-A) and joins the user in the ◯ S-CHAT_ACTIVE-S state.
Result: The user is now connected to the AI. Because of the FSM’s design, it was impossible to reach the “Active” state without a valid configuration: missing or invalid settings are stopped before a connection is even attempted. (A connection that fails for other reasons, such as a timeout, is covered below under Logic Failure.)
What If an Action Fails?
If an action like A-INIT_CONNECTION-A fails, is the transition still completed? In this architecture, the answer depends on how the action fails:
1. Hard Failure (Runtime Crash)
If A-INIT_CONNECTION-A throws a Python exception (e.g., a network library crashes or a variable is missing), the transition is aborted and rolled back.
- The Mechanism: The machine uses a transactional rollback() on the ContextBag (the key/value store that holds the machine’s context; it is checkpointed at the start of each step).
- The Result: Memory is restored to its pre-transition state, and the machine is immediately forced into the FAULTED state for safety.
2. Logic Failure (Business Logic)
If the action executes successfully but “fails” to connect (e.g., a timeout or wrong password), the transition is completed, but the action returns a specific feedback key.
- The Mechanism: The action returns FeedbackResult(key=“FAILURE”).
- The Result: The FSM completes the move to S-CHAT_ACTIVE-S (or wherever it was headed), but the “FAILURE” key triggers a new event (e.g., E-CONNECTION_FAILED-E, not shown) from the Feedback Matrix (the table that maps each action result to an event). This usually causes the machine to instantly transition back to a setup or error state in the next micro-step (the next pass of the engine’s run-to-completion loop, before any new outside event is handled).
3. Pre-condition Failure (Contract Violation), Not Shown on the Map
If the action has a contract (e.g., it requires global_ip_address to be set) and that data is missing:
- The Mechanism: The engine detects the violation before calling the muscle.
- The Result: The transition is blocked, a contract-violation error is raised, and the machine moves to the FAULTED state.
In short: If the action fails to start or crashes, the machine stays “safe” by rolling back. If it starts but the network fails, it completes the transition and then uses its internal logic to handle the error event.
Forks: One Guard, or Two Guards That Are Both True
In this FSM, there’s a logical fork based on guards. What if only one of those guards is set up, or both are true at the same time?
In this system, a fork is handled by the Determinism Law. Here is what happens in those two specific edge cases:
1. What If Only One Guard Is Set Up? (The “Lookahead” Barrier)
If you only have one transition for 🔔 E-START_CHAT_REQ-E and its 🛑 guard returns False:
- The Result: The machine is blocked.
- The Behavior: The engine evaluates the guard, sees it is false, and simply ignores the request. The machine remains resting in ◯ S-IDLE-S. No actions fire, and the UI doesn’t change.
- The Risk: Without a fallback transition (like a “Missing Config” path), the user might click the button and think the app is broken because “nothing happened.”
2. What If Both Guards Are True? (The Priority Tie-Breaker)
If you have two transitions for the same event and both their 🛑 guards return True at the same time, the machine has a “collision.” To resolve this, the engine uses priority numbers:
- The Mechanism: Every transition has a priority value (e.g., 100 vs. 50; the default is 100).
- The Winner: The engine checks the transitions from the highest number down and takes the first one whose guard passes.
- The Loser: The lower-priority transition is completely ignored, even though its guard was also true.
3. The Linter’s Role (Gate 2: Determinism)
Because two transitions that can both be taken at once are usually a logic error (why have a fork if both paths can be taken?), the system includes an Auditor (the linter and rigor gates that run when a map is exported) that checks for this:
- Logic Occlusion: If a high-priority transition is unconditional (has no guard), it will “shadow” or “hide” anything below it. The linter reports this as a warning, or as a FATAL error when the hidden transition can never be selected at all.
- Priority Collision: If two transitions for the same event have the same priority, Gate 2 halts the export, refusing to let you export the FSM map until you fix it.
Summary:
- One False Guard: Machine stays put (Blocked).
- Two True Guards: Machine takes the path with the highest priority number.
- No Guard: The transition is “Always True” (Unconditional).
Order of Operations When Leaving a State
When exiting a state, does the exit action fire before the guard for the next transition is checked?
In this architecture, the 🛑 guard is checked BEFORE the ⚡ exit action fires.
This is a fundamental law of the system: the engine must decide if the transition is even possible before it begins the destructive process of leaving the current state.
The Atomic Order of Operations
- 🔔 Event Rings: Stimulus arrives.
- 🛑 Guard Evaluation (The Gate): While still fully inside the source state, the engine checks the guard.
- If False: Nothing happens. No actions fire. The machine remains in the current state.
- If True: The transition is “authorized,” and the following sequence is locked in:
- ⚡ Exit Actions (the right ear in the map editor): The machine fires the exit actions of the state it is leaving.
- ⚡ Transition Actions (below the event): The machine fires the actions associated with the move itself.
- ◯ State Update: The machine formally changes its internal pointer to the target state.
- ⚡ Entry Actions (the left ear in the map editor): The machine fires the entry actions of the new state.
Why This Order Matters
Imagine a “Transfer Funds” state. If the ⚡ exit action fired before the 🛑 guard was checked, you might deduct a service fee from a user’s account only to have the guard then realize the user doesn’t have permission to complete the transfer.
By checking the 🛑 guard first, the machine guarantees that no side effects (exit, transition, or entry actions) occur unless the move has been authorized.
Summary
- The Guard is a “Lookahead” gate.
- The Exit Action is the first step of the “Execution” phase.
- The Check always happens before The Fire.
The Primitives
Here are the primitives of the system, in their three functional groups with their strict relational owners. Each one is a table in the map database.
Group 1: The Core Structure (The Map)
These primitives define the physical layout and movement of the state machine.
| Primitive | Description | Owner |
|---|---|---|
| Atlas | The root identity and container for a single machine. | System Root |
| State | A discrete mode or condition the machine can be in. | Atlas |
| Event | A signal or trigger that initiates a state change. | Atlas |
| Action | A physical Python implementation (“Muscle”) that does work. | Atlas |
| Transition | The relational path connecting two states via an event. | Source State |
Group 2: The Logic Context (The Brain)
These primitives define how the machine thinks, remembers, and maintains consistency.
| Primitive | Description | Owner |
|---|---|---|
| Variable | A memory register used to store primitive data. | Atlas |
| Guard | A logic gate evaluated before a transition is taken. | Atlas |
| Memory Link Action | Defines which Variables an Action is allowed to access. | Action |
| Memory Link Guard | Defines which Variables a Guard requires for its decision. | Guard |
| Feedback Link | Maps an Action’s result to a new internal Event. | Action |
| Global Invariant | A universal law that must be true at all times. Checked after every Action. | Atlas |
Group 3: The Governance & UI Layer (The Rules)
These primitives define the external interaction and the contracts that protect the code.
| Primitive | Description | Owner |
|---|---|---|
| Widget | A UI component that can emit events or display data. | Atlas |
| Property | A visual attribute (like “Visible”) that can be toggled. | Atlas |
| Manifest | A policy determining UI state based on the current machine mode. | State |
| Pre-condition | A requirement that must be true before an Action is called. | Action |
| Post-condition | A promise of a variable change after an Action succeeds. | Action |
Summary of Ownership
The Atlas owns the definitions (The “Vocabulary”).
The State owns the experience (The “Policy” and “Paths”).
The Action owns the execution (The “Requirements” and “Permissions”).
Can a Primitive Have More Than One Owner?
The FSM maps are modeled in a strictly normalized relational database. Every record has exactly one primary owner (a foreign key that dictates its parent). However, from a functional perspective, some primitives have “Co-Ownership” because they sit at the intersection of two concepts.
Here are the three primitives that exhibit Functional Co-Ownership:
1. The Transition (Owned by State AND Event)
- Database Owner: Source State. If the state is deleted, the transition is deleted.
- Functional Co-Owner: Event. The transition cannot exist without a trigger. On the canvas, the transition is “The Path,” but the Event is “The Key” that unlocks it.
2. The Manifest Rule (Owned by State AND Widget AND Property)
- Database Owner: State (the rule also carries a foreign key to its Atlas).
- Functional Co-Owners:
- The State says: “When I am active, apply this.”
- The Widget says: “This rule applies to me.”
- The Property says: “This rule changes my value.”
- If you delete any of these three, the Manifest rule becomes invalid and is deleted.
3. The Memory Link (Owned by Action AND Variable)
- Database Owner: Action. It defines the action’s permission profile.
- Functional Co-Owner: Variable. The link is the bridge between the implementation and the data. If you delete the Variable, the Action loses its permission to see that data.
Why This Matters for the Primitives
The system uses ON DELETE CASCADE (the database automatically deletes a record’s dependent records) to enforce these co-ownership rules.
If you delete an Atlas, everything is wiped.
If you delete an Action, its Pre-conditions, Post-conditions, Memory Links, and Feedback Links are automatically erased because they have no functional purpose without their “Executing Owner.”
In short: While the database assigns a “Parent” for record-keeping, the system logic treats these primitives as Contracts between two parties. If either party leaves the system, the contract is torn up.