A State Machine for a Service Ticket: Modeling In-Progress, Blocked, and Cannot-Cancel States for a Tire Shop Workflow
Read this framing first. What follows is a design exercise. KMJ Tire does not run the ticketing system described here, has not built it, has not deployed it, and has no incident history behind it. There is no rollout, no migration, no adoption curve, and no measured defect rate to report. Every identifier, timestamp, and reason code below is invented to make an argument legible, not lifted from a running service. What is real is the operating environment: a small Calgary tire and oil-change business running several bays at once, taking work in through an online queue and a walk-in counter simultaneously, and living with the fact that once rubber comes off a wheel, some decisions cannot be taken back. The code is meant to be argued with, not pasted.
The Ticket That Was Four Booleans and a Prayer
Most service-ticket systems start the same way, and the start is reasonable. A ticket needs to know if it's been quoted, so somebody adds is_quoted. Then it needs to know if the customer said yes, so is_approved shows up. Then work needs a start and an end, so is_started and is_complete follow. Then billing needs its own signal, is_invoiced. Then somebody cancels a ticket for the first time and there's nowhere to record it, so is_cancelled gets bolted on at the end of a sprint, six months after the first four flags.
None of these additions is wrong in isolation. Each one answers a real question the business asked that week. The problem only shows up once you count the space they jointly describe: six independent booleans produce sixty-four combinations, and a tire-and-oil-change shop's actual workflow uses something like ten of them. The other fifty-four are not hypothetical. They are rows that exist in the table right now, today, because nothing in the schema prevents them.
Here is one, taken from the shape these bugs always have rather than from any real database: a ticket with is_complete = true and is_approved = false. Read literally, that row claims a technician finished mounting a set of tires on a customer's vehicle who never agreed to have the work done. Nobody typed that combination on purpose. It happened because two different code paths touch two different flags, and a retried request, a partial failure, or a background job that only updates one column at a time left the row in a shape nobody designed for and nobody would defend if you pointed at it directly.
Here's a second one: is_cancelled = true and is_invoiced = true on the same row. A cancelled ticket that also has an invoice attached to it is either a billing mistake waiting to be disputed or a modeling mistake that already happened. The database has no opinion. It stored both bits because both bits are, individually, legal values for a boolean column.
The deeper issue isn't that any single flag is badly named. It's that a pile of booleans has no concept of the current state of the ticket as a single fact. It has a set of independent yes/no answers to different questions, asked at different times, by different parts of the codebase, with no shared referee checking whether the combination makes sense. A finite state machine is that referee, made explicit instead of implicit, and this piece is about what changes when you build one on purpose instead of discovering its absence one impossible row at a time.
What a Boolean Flag Cannot Say
A boolean answers "has this thing happened." It cannot answer "what is true right now, to the exclusion of everything else," because nothing stops two flags from both being true when the business rule says only one condition should hold. It cannot express ordering — nothing in a set of independent columns says is_approved must become true before is_started can — so ordering has to be re-derived and re-enforced in application code every single place a ticket gets touched, and it only takes one omission for the rule to have a hole.
A finite state machine fixes this by making four things explicit that boolean flags leave implicit:
- States — a closed, enumerated set of values a ticket can be in, exactly one at a time.
- Events — the things that happen to move a ticket from one state toward another: a technician starts work, a customer approves a quote, a part arrives.
-
Transitions — the specific
(from_state, event) → to_statemappings that are legal. Everything not listed is, by construction, illegal. - Guards — predicates attached to a transition that must hold before it's allowed to fire, encoding the business rule that makes the transition legitimate rather than merely possible.
None of this is exotic theory. It's the same idea behind a traffic light or a vending machine, applied to a domain where the "impossible" states are the ones that show up in a support ticket three weeks later with a customer asking why they were billed for work they never approved.
States Are Not Fields, They Are a Vocabulary
The first concrete change is structural: a ticket has one state column, not six. It's an enum, not a collection of flags, and the enum's membership is a decision the team makes together and changes deliberately, not something any individual code path can silently extend by adding a new column.
create type ticket_state as enum (
'received',
'diagnosed',
'quoted',
'approved',
'in_progress',
'blocked',
'complete',
'invoiced',
'closed',
'cancelled',
'void'
);
Two of those values need a word up front because they aren't part of the main line. cancelled is a true dead end — reachable only from the early states, before any physical work has started, which is the whole subject of a section further down. void covers the rarer case of a quote that simply expires unactioned; nobody said no, nobody said yes, and time made the decision. Everything else is the path a normal ticket walks, in order, sometimes detouring through blocked and back.
Naming the states honestly matters more than it sounds like it should. in_progress is not working, because "working" describes an activity and in_progress describes a fact about the ticket that other parts of the system can check without knowing anything about bays or technicians. The state name is the interface. Everything downstream — billing, reporting, the counter display, a customer-facing status page — reads that one column and nothing else.
The Lifecycle of One Ticket, Named Honestly
Here's what each state actually represents for a shop that only does tire work and oil changes — no alignments, no brake jobs, no diagnostics beyond "what does this tire or this oil need":
received — A vehicle is checked in, or a request came through an online booking queue and turned into a ticket automatically. Nothing has been inspected yet. This is the only state a ticket can be created into.
diagnosed — A technician has looked at the tires (tread depth, sidewall condition, a puncture location, a load index mismatch against the vehicle) or the oil condition, and logged findings. No price has been given yet; this state is purely "here is what I found."
quoted — Line items with prices attached have been presented to the customer. Nothing has been agreed to. A quote can sit in this state for a while — a customer says they'll think about it — or it can expire, which is the void exit.
approved — The customer has signed off, in whatever form the shop accepts as a record (a signature on a tablet, a confirmation reply, a verbal yes logged by the counter with a timestamp and the staff member's name attached). This is the gate that matters most in the whole machine, and it gets its own section below.
in_progress — A technician has started the physical work in a bay. This is the point past which a plain cancellation stops being an option, for reasons that are physical, not procedural.
blocked — Work has started but cannot continue right now — most often because a size isn't on the shelf and needs to come in, sometimes because a customer needs to be reached for a decision mid-job. blocked is not one flavor, and the next section is entirely about why.
complete — Physical work is finished and verified by the technician who did it, or a second set of eyes depending on the shop's own checklist discipline.
invoiced — A billing document has been generated against the completed work. This cannot happen earlier — a ticket cannot be invoiced for work that hasn't been finished, which sounds obvious stated as a sentence and is exactly the rule a boolean-flag system fails to enforce the day two flags get out of sync.
closed — Payment has settled and the ticket is archived. Nothing changes about a closed ticket short of a formal reopen procedure, which is itself a separate, logged event rather than an edit.
A seasonal changeover week is where this lifecycle gets stress-tested hardest — dozens of tickets moving through received in the same two-hour window, several bays running in_progress simultaneously, and the volume high enough that any looseness in the model turns into a visible problem within days rather than months.
A Machine Needs Events, Not Just States
A state is a noun. What moves a ticket from one state to the next is a verb — an event, with a payload describing who did it, when, and often why. Conflating the two is a common early mistake: treating "set state to approved" as the whole operation, with no record of the fact that produced it.
CHECK_IN received created, no prior state
INSPECT received -> diagnosed
QUOTE diagnosed -> quoted
APPROVE quoted -> approved
QUOTE_EXPIRE quoted -> void
START_WORK approved -> in_progress
PARTS_UNAVAILABLE in_progress -> blocked
CUSTOMER_HOLD in_progress -> blocked
RESUME blocked -> in_progress
FINISH in_progress -> complete
INVOICE complete -> invoiced
SETTLE invoiced -> closed
CANCEL received | diagnosed | quoted | approved -> cancelled
VOID_AFTER_START in_progress | blocked -> complete (special-cased, covered below)
Every event, not just the ticket, carries an actor and a timestamp, because "the ticket moved to approved" and "Priya approved it over the phone at 2:41 PM after reading back the line items" are very different amounts of information, and only the second one is useful when a customer calls back in three weeks disputing what they agreed to.
The Transition Table Is the Specification
Once states and events are named, the whole rulebook for the ticket's lifecycle collapses into one table. This is the single most valuable artifact in the entire design, because it turns "what can this ticket legally do next" from a question you answer by reading scattered if statements into a question you answer by reading one table.
| From state | Event | To state | Guard |
|---|---|---|---|
received |
INSPECT |
diagnosed |
technician assigned |
diagnosed |
QUOTE |
quoted |
at least one priced line item |
quoted |
APPROVE |
approved |
approval record present (signature, reply, or logged verbal) |
quoted |
QUOTE_EXPIRE |
void |
quote older than expiry window, no approval recorded |
approved |
START_WORK |
in_progress |
bay assigned, technician assigned |
in_progress |
PARTS_UNAVAILABLE |
blocked |
reason code parts set |
in_progress |
CUSTOMER_HOLD |
blocked |
reason code customer_response set |
blocked (parts) |
RESUME |
in_progress |
required part received and logged |
blocked (customer_response) |
RESUME |
in_progress |
customer response logged |
in_progress |
FINISH |
complete |
all line items marked done |
complete |
INVOICE |
invoiced |
invoice not already issued for this ticket |
invoiced |
SETTLE |
closed |
payment reference present |
received \ |
diagnosed \ |
quoted \ |
approved |
Read that table once and notice what it does not contain. There is no row for complete plus CANCEL. There is no row for in_progress plus CANCEL. There is no row for invoiced plus INSPECT. Those aren't rules the application remembers to check — they are combinations that simply do not exist in the data the transition function consults, which is a structurally different guarantee than a conditional the next engineer might not notice.
The same table, as data rather than prose, is what the code actually checks:
create table valid_transition (
from_state ticket_state not null,
event text not null,
to_state ticket_state not null,
primary key (from_state, event)
);
insert into valid_transition (from_state, event, to_state) values
('received', 'INSPECT', 'diagnosed'),
('diagnosed', 'QUOTE', 'quoted'),
('quoted', 'APPROVE', 'approved'),
('quoted', 'QUOTE_EXPIRE', 'void'),
('approved', 'START_WORK', 'in_progress'),
('in_progress','PARTS_UNAVAILABLE', 'blocked'),
('in_progress','CUSTOMER_HOLD', 'blocked'),
('blocked', 'RESUME', 'in_progress'),
('in_progress','FINISH', 'complete'),
('complete', 'INVOICE', 'invoiced'),
('invoiced', 'SETTLE', 'closed'),
('received', 'CANCEL', 'cancelled'),
('diagnosed', 'CANCEL', 'cancelled'),
('quoted', 'CANCEL', 'cancelled'),
('approved', 'CANCEL', 'cancelled');
A transition request that doesn't find a matching row is rejected before any guard even runs, which is a cheap, deterministic first line of defense that costs one index lookup.
Guards Are Where the Business Rules Actually Live
The transition table answers "is this move on the map at all." Guards answer "is this specific ticket, right now, actually allowed to make this move." The distinction matters because some rules are about the shape of the workflow (you cannot go from received straight to in_progress) and some are about the content of a specific ticket (this particular approval record is missing a signature).
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class Ticket:
ticket_id: str
state: str
line_items: list
approval_ref: str | None
bay_id: str | None
technician_id: str | None
invoice_ref: str | None
block_reason: str | None
def guard_quote_has_items(t: Ticket) -> bool:
return len(t.line_items) > 0
def guard_approval_present(t: Ticket) -> bool:
return t.approval_ref is not None
def guard_ready_to_start(t: Ticket) -> bool:
return t.bay_id is not None and t.technician_id is not None
def guard_all_items_done(t: Ticket) -> bool:
return all(item.done for item in t.line_items)
def guard_not_already_invoiced(t: Ticket) -> bool:
return t.invoice_ref is None
GUARDS: dict[tuple[str, str], Callable[[Ticket], bool]] = {
("diagnosed", "QUOTE"): guard_quote_has_items,
("quoted", "APPROVE"): guard_approval_present,
("approved", "START_WORK"): guard_ready_to_start,
("in_progress", "FINISH"): guard_all_items_done,
("complete", "INVOICE"): guard_not_already_invoiced,
}
Two rules from the brief that make good guard examples because they sound like UI validation and are actually structural facts: a ticket cannot be invoiced before it's complete, and a job cannot skip approved no matter how obviously the customer wants the work done, because the transition table has no edge from quoted or diagnosed directly to in_progress. The second one is worth sitting with. A counter staffer under pressure during a busy seasonal changeover rush might reasonably think "the customer is standing right here and clearly wants this, just start the work" — but the signed-off quote is the record that protects both the shop and the customer from a dispute about what was agreed to, and the machine enforcing that ordering is what makes the rule real instead of aspirational.
Cannot Cancel Once Work Has Started — Making an Invariant Structural
Here is the invariant from the brief, stated as plainly as possible: once a technician has started mounting, balancing, or draining oil on a customer's vehicle, "cancel" stops being a coherent action, because some of what happened is physical and cannot be un-happened. A used oil filter is off the vehicle. A tire is off the wheel. Undoing that isn't a database operation.
The naive way to enforce this is a UI checkbox — grey out the "Cancel" button once the ticket's status looks like it's in progress. This fails for a specific, boring reason: the UI is not the only caller. A retried API request, an internal admin tool, a script somebody wrote for a one-off cleanup, a future integration nobody has built yet — every one of these can reach the cancel endpoint directly, and a disabled button in one client protects nothing against any of them.
The transition table is the actual enforcement, because it is consulted by every caller, not just the polite ones:
class IllegalTransition(Exception):
pass
def apply_event(ticket: Ticket, event: str, valid: dict[tuple[str, str], str]) -> str:
key = (ticket.state, event)
if key not in valid:
raise IllegalTransition(
f"{event} is not a legal move from {ticket.state} for ticket {ticket.ticket_id}"
)
to_state = valid[key]
guard = GUARDS.get(key)
if guard and not guard(ticket):
raise IllegalTransition(
f"{event} from {ticket.state} failed its guard for ticket {ticket.ticket_id}"
)
return to_state
("in_progress", "CANCEL") and ("blocked", "CANCEL") and ("complete", "CANCEL") simply are not keys in the valid dictionary. There is no branch to forget, no flag to remember to check, no code review comment that has to catch the omission — the operation cannot succeed because the mapping it needs does not exist. That's a materially stronger guarantee than "the button is disabled," and it costs nothing extra at runtime beyond the lookup every other transition already pays for.
This is also where the model has to be honest rather than just strict. Refusing CANCEL unconditionally doesn't make the customer's actual request disappear — sometimes a customer genuinely does want to stop after a technician has already pulled a tire off the wheel, usually because a hidden problem turned up mid-job that changes the economics for them. The correct response is not to force a cancellation the machine forbids and not to pretend nothing changed either. It's a distinct event, honestly named for what it is:
in_progress | blocked -- VOID_AFTER_START --> complete
VOID_AFTER_START doesn't erase the work that already happened; it routes the ticket to complete with the line items marked to reflect exactly what was and wasn't finished, so the eventual invoice bills for consumed parts and labour already performed rather than a full job that never happened. The state machine still says "no such thing as cancelling in-progress work," which is true, while giving the business a real, distinctly-named path for the real situation, rather than smuggling an exception into the CANCEL guard where it would quietly weaken the invariant for every other caller too.
Blocked Is Not One State, It Is a Reason
Treating blocked as a single undifferentiated state loses the information that actually matters operationally. "Blocked because a size isn't on the shelf" and "blocked because we're waiting on the customer to decide about an upsell" have almost nothing in common — different owners, different expected durations, different escalation paths — and collapsing them into one bucket makes it impossible to answer a question like "how many tickets are stuck on parts right now" without re-deriving it from free-text notes.
The fix, in the same spirit as separating physical stock from claims in an inventory ledger, is to keep blocked as the state and carry the why as a structured attribute alongside it, not folded into a proliferation of near-duplicate states like blocked_on_parts, blocked_on_customer, blocked_on_bay:
create type block_reason as enum ('parts', 'customer_response', 'bay_availability');
alter table ticket
add column block_reason block_reason,
add constraint block_reason_iff_blocked check (
(state = 'blocked' and block_reason is not null)
or (state <> 'blocked' and block_reason is null)
);
The check constraint does real work here: it's structurally impossible for a ticket to sit in blocked with no recorded reason, and impossible for a non-blocked ticket to be carrying a stale reason from the last time it was blocked. RESUME is a single event in the transition table, but its guard differs by reason — resuming from parts requires a logged receipt of the part, resuming from customer_response requires a logged customer decision — which the guard function can dispatch on without needing three separate states and three separate transition rows for what is, structurally, the same move.
A shop running several bays at once benefits from this distinction directly: a dashboard filtering on state = 'blocked' and block_reason = 'parts' answers "what's waiting on the supplier" in one query, and block_reason = 'bay_availability' answers a completely different operational question — one about scheduling and throughput rather than procurement — using the same column.
Multi-Bay Reality: Many Machines Running at Once
A single-bay shop has, at most, one in_progress ticket at a time, and a lot of state-machine design questions never come up because concurrency never comes up. A shop with several bays running simultaneously is a different problem: many independent instances of the same machine, each ticket its own, advancing on its own schedule, sharing a pool of technicians and a finite set of physical bays as external resources rather than as part of any individual ticket's state.
The important discipline here is keeping the resource allocation outside the ticket state machine, referenced by it rather than folded into it. bay_id and technician_id are attributes a ticket carries, checked by the guard_ready_to_start guard, but the state machine itself has exactly as many states whether the shop has one bay or ten. What changes with more bays is the guard logic and a separate allocation system — deciding which bay, which technician — not the ticket lifecycle itself. Conflating the two is a common design trap: trying to encode "waiting for a bay to free up" as its own ticket state produces a combinatorial mess the moment the shop adds a second bay, because now you need to know not just that a ticket is waiting, but for which resource, which is exactly what the block_reason pattern from the previous section already solves without a new state.
Where concurrency does bite the ticket machine directly is when two independent actors can legally attempt to move the same ticket at the same moment — a technician's tablet sending START_WORK at the same instant a manager's dashboard sends CANCEL because the customer just called to back out. Exactly one of those requests should win, the other should fail cleanly, and neither should corrupt the ticket into some blend of both outcomes. That's a concurrency-control problem, and it's the subject of the next two sections.
Idempotency: The Webhook Will Fire Twice
Retries are not a hypothetical for a shop with several integration points touching the same tickets. A customer approves a quote through a web form, and a flaky mobile connection means the confirmation request times out client-side and fires again. A mobile service technician working a driveway job in one of the areas the van covers has spotty signal, and a FINISH event queued locally gets sent twice once connectivity returns. A third-party billing integration retries an INVOICE webhook because it never received a 200 response in time, even though the first attempt actually succeeded.
None of these are edge cases in the sense of being rare. They're the default behavior of any system built on a network, and a transition function that isn't built to expect them will eventually either double-apply an event or throw a scary-looking error at a legitimate retry.
The naive apply_event from earlier gets this partly right and partly wrong. It's right that a second START_WORK against a ticket already in_progress correctly fails, because ("in_progress", "START_WORK") isn't in the transition table. It's wrong that it fails loudly as an error, because from the caller's point of view — the retry logic in the technician's tablet app, or the webhook sender — this isn't a new problem to report, it's confirmation that the first attempt already succeeded. Treating it as an exception forces every caller to build their own "is this actually a problem" logic on top of the state machine, which defeats the purpose of centralizing the rules in the first place.
The fix has two parts. First, distinguish "this event is illegal because it violates the workflow" from "this event is redundant because it already happened":
def apply_event_idempotent(ticket: Ticket, event: str, valid: dict[tuple[str, str], str]) -> tuple[str, bool]:
"""Returns (resulting_state, was_applied). was_applied is False for a
harmless replay — the caller should treat that as success, not error."""
key = (ticket.state, event)
if key in valid:
to_state = valid[key]
guard = GUARDS.get(key)
if guard and not guard(ticket):
raise IllegalTransition(f"{event} failed its guard from {ticket.state}")
return to_state, True
# Not a legal move from here — but is it because we already made this
# exact move? A ticket already in_progress receiving another
# START_WORK is a replay, not a new violation, if in_progress is
# itself the natural target of START_WORK from some earlier state.
if _is_terminal_replay(ticket.state, event, valid):
return ticket.state, False
raise IllegalTransition(f"{event} is not a legal move from {ticket.state}")
def _is_terminal_replay(state: str, event: str, valid: dict[tuple[str, str], str]) -> bool:
return any(to_state == state and evt == event for (_, evt), to_state in valid.items())
That covers same-effect replays for events whose target state is stable and recognizable. It does not cover the more general and more common case — a caller that doesn't know or care what state the ticket ended up in, only that this specific request should never be double-applied even if its effects aren't as easy to recognize after the fact (an INVOICE call that's supposed to generate exactly one invoice document, for instance, where "already invoiced" is easy to check but "generated a duplicate invoice document as a side effect before failing the second check" is exactly the bug idempotency is meant to prevent). That case needs an explicit idempotency key, covered next alongside the concurrency-safe write.
The Compare-and-Swap Transition, Written Out
The actual database write needs two properties at once: it must not lose a legitimate transition to a race (the tablet's START_WORK and the dashboard's CANCEL arriving within the same millisecond), and it must not double-apply a retried request carrying the same idempotency key.
create table ticket (
ticket_id uuid primary key default gen_random_uuid(),
state ticket_state not null default 'received',
block_reason block_reason,
bay_id text,
technician_id text,
approval_ref text,
invoice_ref text,
version bigint not null default 0,
constraint block_reason_iff_blocked check (
(state = 'blocked' and block_reason is not null)
or (state <> 'blocked' and block_reason is null)
)
);
version is the concurrency anchor. Every transition both checks and increments it in one statement, which is what makes the write safe under plain read-committed isolation without a held lock spanning any application logic:
update ticket
set state = $new_state,
version = version + 1
where ticket_id = $ticket_id
and state = $expected_from_state
and version = $expected_version
returning version;
If the row's state or version has already moved by the time this statement runs — because the other concurrent request won the race — the where clause matches zero rows, and the affected-row count tells the caller exactly that: this transition did not happen, because something else happened first. The caller re-reads the ticket, sees the new state, and decides from there whether its own request is now illegal (the CANCEL that arrived a moment too late, after START_WORK already landed) or redundant (a retry of the transition that just succeeded).
Idempotency keys ride alongside this rather than replacing it, because "was this specific request already fully processed" and "is this transition currently legal for this ticket" are different questions:
create table ticket_event_request (
idempotency_key text primary key,
ticket_id uuid not null,
event text not null,
resulting_state ticket_state not null,
processed_at timestamptz not null default now()
);
A transition handler checks this table first. A hit means the request was already fully processed — return the stored result, touch nothing else, and the caller (webhook sender, retrying tablet, whatever) gets the same successful response it would have gotten the first time, with zero risk of a second write. A miss means proceed with the compare-and-swap update and the guard checks, then insert the idempotency record in the same transaction as the state change, so the two either both happen or neither does. A unique-violation on the insert — two concurrent attempts with the same key — is handled the same way the reservation system in a companion piece on inventory handles it: catch it, look up what actually got written, and return that instead of erroring, because the violation itself is proof the work already happened exactly once.
Every Transition Is a Fact: The Audit Log
The ticket.state column answers "what is true right now." It cannot answer "what happened, in what order, at whose hand" — and that second question is exactly what gets asked the moment a customer disputes a charge, a technician disputes a schedule, or an owner wants to know why a particular ticket sat blocked for four days. The answer needs to live somewhere append-only, because a column that gets overwritten on every transition has, by definition, thrown away everything except the most recent fact.
create table ticket_transition_event (
event_id bigserial primary key,
ticket_id uuid not null references ticket (ticket_id),
event text not null,
from_state ticket_state not null,
to_state ticket_state not null,
actor text not null,
reason text,
ref_type text,
ref_id text,
idempotency_key text,
occurred_at timestamptz not null default now()
);
create index transition_by_ticket
on ticket_transition_event (ticket_id, event_id);
revoke update, delete on ticket_transition_event from application_role;
grant insert, select on ticket_transition_event to application_role;
The revoked privileges are doing more work than they look like they're doing. A convention that says "nobody edits transition history" is a policy that lives in a wiki page and survives exactly as long as everyone remembers it. A revoked update/delete grant is enforced by the database on every connection, including the one belonging to whoever is debugging a production issue at midnight and reaches for the fastest fix available. The audit trail's entire value proposition — that it is a record of what actually happened, not what somebody later wished had happened — depends on it being physically impossible to rewrite, not merely discouraged.
actor is never "system". When a nightly job auto-expires stale quotes via QUOTE_EXPIRE, the actor is the job's own identity, named specifically, so that forty expired-quote rows from one overnight run are all attributable to one traceable process rather than to an anonymous void. ref_type and ref_id tether the transition to whatever document justified it — an approval form, a parts receipt, an invoice number — so that a transition never floats free of the evidence that made it legitimate.
Reconstructing State From the Log vs. Storing It
With the log in place, ticket.state stops being the source of truth and becomes a cache of one — the most recent row in ticket_transition_event for that ticket, materialized into a column for fast reads:
select to_state
from ticket_transition_event
where ticket_id = $1
order by event_id desc
limit 1;
That query is authoritative. The ticket.state column exists purely so that every other query in the system doesn't have to run this fold every single time it needs to know a ticket's status — the same tradeoff a stock-position column makes against a full inventory ledger fold. What makes the cached column trustworthy rather than a second, competing source of truth is a scheduled comparison: recompute the fold, diff it against the cached state, and treat any disagreement as a page-worthy event, not a ticket to triage next sprint. A write path that mutates ticket.state without also appending to the log is exactly the kind of bug this comparison exists to catch, and it's a bug that a boolean-flag system has no equivalent mechanism to detect at all, because there's no independent record to compare the flags against.
Disputes Are Queries, If You Built the Log Right
This is where the design earns its cost back in a way that's visible to non-engineers. A customer calls saying they were never told about a parts delay. Instead of a staff member trying to reconstruct events from memory or scattered notes, the answer is one query:
select event, actor, reason, occurred_at
from ticket_transition_event
where ticket_id = $1
and event in ('PARTS_UNAVAILABLE', 'RESUME')
order by event_id;
That returns exactly when the ticket went into blocked with reason parts, who logged it, and — if a reason field was populated with something like "customer notified via call at 3:15" — whether contact was actually made and recorded, not just assumed. A customer disputing whether they approved a job at all gets an equally direct answer:
select actor, ref_type, ref_id, occurred_at
from ticket_transition_event
where ticket_id = $1
and event = 'APPROVE';
Either that returns a row with a signature or logged-call reference attached, or it returns nothing — and "it returns nothing" is itself the answer, immediately, rather than a multi-day investigation across whichever systems happen to still have the relevant data. This is the practical payoff of treating every transition as a fact instead of an overwrite: disputes stop being investigations and start being lookups.
A Worked Trace Through One Ticket
All identifiers and numbers here are invented for illustration.
Monday, 9:02 AM. A customer books online for a tire inspection and possible replacement. CHECK_IN creates TICKET-EXAMPLE-4471 in received, actor online-booking/system, ref_type='booking', ref_id='BK-EXAMPLE-771'.
Monday, 9:40 AM. A technician inspects the vehicle and finds two tires with sidewall damage. INSPECT fires: received → diagnosed, actor tech/d.osei, reason "sidewall damage, front pair, logged during walk-around."
Monday, 9:52 AM. A quote for two replacement tires plus mounting and balancing is generated. QUOTE fires: diagnosed → quoted, ref_type='quote', ref_id='QT-EXAMPLE-2290'.
Monday, 10:15 AM. The customer approves by phone. The counter staff logs it: APPROVE fires, quoted → approved, actor counter/r.singh, ref_type='approval', ref_id='CALL-EXAMPLE-9910'.
Monday, 10:16 AM — the retry. The mobile app used to log the phone approval times out on the confirmation screen and the staff member taps submit again. The second APPROVE request arrives carrying the same idempotency key as the first. The handler finds the key already in ticket_event_request, returns the stored result, and writes nothing new. One APPROVE row exists in the log, not two.
Monday, 11:00 AM. Work starts. START_WORK fires: approved → in_progress, guard checks bay_id and technician_id are both set, actor tech/d.osei.
Monday, 11:20 AM. One of the two replacement tires isn't in the size needed and has to come from a supplier. PARTS_UNAVAILABLE fires: in_progress → blocked, block_reason='parts', actor tech/d.osei, reason "second unit not on shelf, ordered."
Wednesday, 8:10 AM. The part arrives and is logged against the ticket. RESUME fires: blocked → in_progress, guard confirms a parts receipt is attached, actor counter/r.singh.
Wednesday, 9:30 AM. Work finishes: both tires mounted and balanced. FINISH fires: in_progress → complete, guard confirms all line items marked done, actor tech/d.osei.
Wednesday, 9:45 AM. Billing generates the invoice. INVOICE fires: complete → invoiced, guard confirms invoice_ref was previously null, ref_id='INV-EXAMPLE-5581'.
Wednesday, 2:00 PM. Customer pays at pickup. SETTLE fires: invoiced → closed, ref_type='payment', ref_id='PAY-EXAMPLE-3302'.
Nine transitions, one harmless replay that produced zero duplicate rows, one blocked-and-resumed detour with a supplier delay fully documented, and a complete, queryable narrative for a ticket that took two and a half days from check-in to close. Nothing here required reverse-engineering from a handful of overwritten boolean columns — it's nine inserted rows, in order, each one a fact nobody can quietly rewrite later.
Testing a State Machine Exhaustively
The transition table's biggest practical benefit shows up in the test suite, because a table is enumerable in a way that scattered conditionals never are. Instead of writing a test per feature and hoping the combinations get covered, the test walks the entire states × events matrix:
import itertools
import pytest
ALL_STATES = ["received", "diagnosed", "quoted", "approved", "in_progress",
"blocked", "complete", "invoiced", "closed", "cancelled", "void"]
ALL_EVENTS = ["INSPECT", "QUOTE", "APPROVE", "QUOTE_EXPIRE", "START_WORK",
"PARTS_UNAVAILABLE", "CUSTOMER_HOLD", "RESUME", "FINISH",
"INVOICE", "SETTLE", "CANCEL"]
@pytest.mark.parametrize("state,event", itertools.product(ALL_STATES, ALL_EVENTS))
def test_transition_matrix(state, event, make_ticket, valid_transitions):
ticket = make_ticket(state=state)
key = (state, event)
if key not in valid_transitions:
with pytest.raises(IllegalTransition):
apply_event(ticket, event, valid_transitions)
return
# legal move: guard satisfied should succeed and append exactly one row
ticket = make_ticket(state=state, satisfies_guard=True)
result_state = apply_event(ticket, event, valid_transitions)
assert result_state == valid_transitions[key]
assert count_transition_events(ticket.ticket_id) == 1
# legal move, guard unsatisfied should be rejected and append nothing
ticket = make_ticket(state=state, satisfies_guard=False)
with pytest.raises(IllegalTransition):
apply_event(ticket, event, valid_transitions)
assert count_transition_events(ticket.ticket_id) == 0
Eleven states times twelve events is 132 cases, most of them assertions that an illegal move correctly raises — which sounds like a lot of tests to write by hand and is instead a handful of fixtures and one parametrized function, because the matrix is data the test can iterate rather than a list a person has to remember to write out. Every time a new event is added to the vocabulary, this test suite automatically covers its interaction with every existing state without anyone updating the test file, which is the exact opposite of how boolean-flag validation logic tends to age: each new flag needs its own hand-written interaction tests against every existing flag, and coverage decays as the flag count grows because nobody goes back to add the cross-product by hand.
Hierarchical States and the Sub-Ticket Temptation
A real ticket for a set of four tires plus an oil change has multiple line items, and each one arguably has its own micro-lifecycle: this tire is mounted, that one still needs balancing, the oil hasn't been drained yet. The tempting move is to give every line item the full ticket-level state machine — its own received/in_progress/complete — and it's tempting because it looks consistent.
Resist it, for a specific reason: a line item cannot be independently approved, invoiced, or cancelled the way a ticket can. A customer doesn't approve one tire and decline the other three; approval happens at the ticket level, against the whole quote. Giving every line item a full copy of the ticket's lifecycle produces states that can never legally occur (a line item sitting in approved while its parent ticket is still quoted is nonsense) and duplicates guard logic that only makes sense once, at the ticket level.
The workable pattern is a smaller, genuinely independent sub-state that only line items need — pending, in_progress, done — combined with an aggregate guard at the ticket level:
def guard_all_items_done(t: Ticket) -> bool:
return all(item.sub_state == "done" for item in t.line_items)
FINISH at the ticket level is gated on every line item's small, three-value sub-state, not on a parallel copy of the ticket's eleven-state machine per item. This keeps the ticket-level machine — the one with approval, invoicing, and the cancel invariant — singular and unambiguous, while still letting a dashboard show "3 of 4 tires done" without inventing a state combination that could never legitimately exist.
What Happens When a Customer Wants to Cancel After All
It's worth returning to this because it's the invariant the brief singles out, and because the honest answer has more nuance than "no" once work has started. The state machine's job is not to make every customer request impossible to satisfy — it's to make sure every request is handled through a path that's honest about what already happened.
Before in_progress: CANCEL is a legal, guard-free transition from received, diagnosed, quoted, or approved. Nothing physical has happened yet, so there's genuinely nothing to reconcile. This is the overwhelming majority of real cancellations — a customer who got a quote and decided to go elsewhere, or one whose approved job simply hasn't started yet because the shop is running behind.
During in_progress or blocked: CANCEL does not exist as an edge in the table, for the physical reasons already covered. What exists instead is VOID_AFTER_START, which routes to complete with line items reflecting exactly what was and wasn't finished, so that whatever gets invoiced afterward is invoiced for work actually performed — not a full job that never happened, and not a silent write-off that leaves no record of why a technician's afternoon went to a ticket with no matching charge.
After complete or invoiced: this isn't a cancellation question anymore, it's a billing dispute or a refund question, and routing it through the ticket state machine at all would be a category error — those are financial-system concerns that reference the ticket's history, not events that should be able to change what state the ticket claims to be in after the fact.
The pattern generalizes past this one invariant: when a business rule says "X cannot happen once Y has occurred," the state machine enforces it correctly by never providing an edge for X from any state where Y has already occurred — not by adding a guard that tries to detect and block it after the fact. An edge that doesn't exist can't have its guard logic accidentally weakened by a well-meaning bug fix six months later. A guard on an edge that does exist always can.
Where a Formal State Machine Is More Than You Need
Everything above is real engineering effort — an enum, a transition table, a guard registry, an append-only log, an idempotency table, a compare-and-swap write path, and an exhaustive test matrix. For a shop running one bay, one technician, and low enough volume that a ticket rarely overlaps with another in time, most of that machinery buys very little. A status text column with a couple of if checks in one function, reviewed by a human who understands the whole workflow, is a completely reasonable choice when there's no concurrency to speak of and no integration retrying anything.
The trigger for building the fuller version, stated as concretely as I can: the first time two independent processes — a technician's device and a customer-facing status page, say, or two staff members working different counters — can legally attempt to change the same ticket without knowing about each other, or the first time a dispute arrives that the current schema genuinely cannot answer because the relevant fact was overwritten rather than recorded. Before either of those, this is scaffolding for a problem that hasn't shown up yet. After either of them, patching a boolean-flag system to retrofit these guarantees is considerably more expensive than building the state machine would have been from the start, because now there's production data sitting in combinations the new model has to somehow account for.
What I Would Build in an Afternoon
Stripped to the parts that carry real weight, in build order:
- The enum and the transition table, as data, not as scattered conditionals. This alone eliminates the impossible-combination class of bug, because illegal moves have nowhere to land.
-
A guard registry — a plain dictionary from
(state, event)to a predicate function — kept separate from the transition table so "is this move on the map" and "is this specific ticket allowed to make it" stay two distinct, individually testable questions. -
The append-only
ticket_transition_eventlog, withupdate/deleterevoked at the role level, not just avoided by convention. This is the piece that turns disputes into queries. - The compare-and-swap write with a version column, so concurrent legitimate requests fail safely instead of corrupting a row, plus an idempotency-key table so retried requests return the original result instead of erroring or double-firing.
- The exhaustive parametrized test over the full states-by-events matrix. It's cheap to write once the table exists, and it stays cheap every time a new event gets added.
Not in the afternoon build: per-line-item full state machines, a generalized workflow engine, or configurable transition tables editable by non-engineers at runtime. Those solve problems a small multi-bay operation mostly doesn't have yet, and the fixed, code-reviewed transition table this piece describes is a feature, not a limitation — it means every legal move in the system was deliberately decided by someone who understood the consequences, which is exactly the property a pile of accumulated booleans never had in the first place.
Questions That Come Up Every Time
Why not just add a cancelled_at timestamp column and check if cancelled_at is None everywhere?
Because that's a boolean with better observability, not a state machine. It still doesn't stop cancelled_at from being set on a ticket that's also invoiced, and every caller still has to remember to check it in combination with everything else. The transition table removes the combination from existing at all rather than asking every reader to keep checking for it.
Doesn't an idempotency-key table grow forever?
Yes, unbounded, unless it's pruned. A retention window covering the longest plausible retry delay — a day or two is generous for most client retry logic — is enough; anything older than that is safe to archive or delete, because a "retry" that shows up a week later is no longer a retry, it's a new request that should be evaluated on its own merits.
What if a guard itself needs to change — say, the approval requirement gets stricter?
Guards are ordinary functions, so they version the same way any business logic does: change the function, ship it, and every future transition uses the new rule. What doesn't change retroactively is the audit log — a ticket approved under the old guard keeps its APPROVE row exactly as it was recorded, because the log describes what was true and legal at the time, not what would be legal under today's rules applied backward.
Is this overkill for a workflow with only eleven states?
Eleven states is not what makes this worth building — sixty-four reachable combinations from six independent booleans, most of them nonsense, is. The state count is small on purpose; the discipline is in refusing to let it grow into an unenumerable pile of flags the way it does by default.
Can two customers dispute the same ticket and get different answers depending on who asks?
Not if the audit log is the only thing anyone consults for a dispute. The entire point of routing disputes through select ... from ticket_transition_event rather than through whoever happens to remember the ticket is that the answer stops depending on who's asked and starts depending only on what was actually recorded, which is the same guarantee a frequently asked questions page gives a customer before a ticket even exists: one consistent answer, available to anyone who looks, rather than a different story depending on who's telling it.
None of this is specific to tires. It applies to any operation where physical work happens in stages, where more than one system or person can legally touch the same record, and where a customer might reasonably ask "prove it" about something that happened days ago. A tire and oil-change shop running several bays, taking commercial account work alongside walk-ins, coordinating a fleet program with tickets arriving in batches, sometimes sending a mobile service unit out to one of the areas it covers, and occasionally fielding an emergency service request that has to jump the queue without jumping the rules, just happens to make every one of these pressures concrete and visible in the same afternoon. Understanding what buying tires actually involves at a shop that also handles puncture repairs, balancing, and oil changes — reading a sidewall carefully, sometimes waiting on a distributor for a size that isn't on the shelf, sometimes financing the total through a financing option — is exactly the operating detail that makes the difference between a state machine that looks tidy on a whiteboard and one that survives the first genuinely busy Monday.












