Lesson 6 · Financial state machines
From cognokratos/tauros-revenue · docs/course/06-state-machines.md · pinned revision facbbc927eb4
Part II: Financial intent · Course map · Next: Lesson 7
Goal
Make the lifecycle a closed set of named moves that no actor can skip or forge, and that holds under concurrency.
Concept
draft → pending_approval → approved | rejected, request_changes back to
draft, withdraw and cancel to cancelled. Nobody sets state; changing
state is running a transition action. Tauros checks each transition against
the locked current row, not the copy the caller loaded.
Deep dive: concepts/financial-state-machines.md.
Code to inspect
lib/tauros/revenue/invoice.ex: thestate_machineblock, the only definition of the graphlib/tauros/revenue/changes/transition.ex:SELECT … FOR UPDATE, then ask AshStateMachinewithdrawvscancelininvoice.ex: similar moves, different authority, so different actions
Run it
mix test test/tauros/revenue/invoice_lifecycle_test.exs
In the app: an invoice page shows the lifecycle strip and whose move it is.
Break it
Lab: Exercise 2 · Skip the state machine:
approve a draft, then try to pass state: :approved to submit_for_approval.
Why it fails
approve is declared only from pending_approval: NoMatchingTransition. And
no action accepts state as input, so the second attempt is invalid before the
state machine is even asked. The test "the check uses the current row, not the
caller's stale copy" shows why the lock matters.
What to remember
- The graph is data; transitions are named actions;
stateis never input. - Who may run a transition is a policy, not part of the graph.
- Check transitions against the locked row, or concurrent requests both win.