Flow
How the status guard fits together
1One table
Lists which status can follow which; the end states list nothing.
2Same check everywhere
Every endpoint asks the table and names both states when it says no.
3New meanings
Accept now means acknowledged, which changes what partners get back.
4History and webhook
One row and one message per real change from a supplier endpoint; none for a retry or an internal change.
5Gaps left
Two requests at once, saves that are not joined, messages not retried.
One endpoint in the ordering system would set a received order back to draft. It checked that the new status was a known word. It never looked at the order's current status.
Back then there were only four states. Each place that wrote the status made up its own rule. The supplier reply endpoint turned a "needs revision" into submitted and only refused a received order.
Then this change added more states, like out for delivery and delivered. With the old rule, a late "needs revision" on an order already on a truck would send it back to submitted. I never saw that happen. I worked it out by reading the code.
No way back once it's on the truck
Now there's one table. For each state, it lists the states that may come next. Every writer asks it before writing. The core is tiny.
A move is fine if the order stays in the same state or the new state is on the old state's list.
A few rules in the table matter. Early states can step back to submitted, so a supplier can still ask for changes. Once an order is out for delivery, it only moves forward or gets cancelled. Received and cancelled are the end. Staying in the same state is fine, so a retry does no harm. A stored status the table doesn't know is refused, not guessed at.
The sequence every writer follows
Each endpoint loads the order, checks the stored status, works out the new one, and asks the table. If the answer is no, it sends back a conflict that names both states. A partner can see exactly which move was blocked.
Accepted stopped meaning confirmed
The new states changed old meanings. An accept now means acknowledged, not confirmed. Anyone matching on the old word gets something new back, and I didn't find any warning period in the change. If you do this to a public API, announce it.
Later that day, endpoints for marking delivered and received used the same table. One catch: an order can't be marked delivered without going through out for delivery first.
A trail, and a message only when something moved
On the supplier endpoints, each real change saves a history row with the old status, the new one, the reason and who did it. Then the supplier's webhook gets a message, but only when the status really changed. A change made through the internal endpoint gets checked, but it saves no row and sends no message.
Two requests at once, and saves that aren't joined
The code reads the status and then writes it as a separate step, so two requests at once can both get through. The update and the history row aren't saved together, so a crash can leave a gap. The webhook isn't retried, so a supplier who misses one has to check the history.
I'd also add one test that walks every state in the table and makes sure it can still reach received or cancelled.