Shipmind Labs

Rehydrating a state machine looks like deserialization, but the real work is name resolution, and the rebuilt machine has to replay nothing.

Two failure modes pushed us to treat it that way.

The first is a stored name that resolves to two different objects. In our open-source project order-lifecycle, the transition table is the only thing allowed to own names. When the table gets constructed it walks every row and declares the source state, the target state and the trigger. Declaring the same name a second time with a different value is a ValueError, not a last-write-wins merge. One stored name, one object, enforced at construction, before any record is ever read.

The second is the ticket that lands three weeks later saying "unknown state". Useless. So an unresolvable name raises an error carrying four things: what kind of name it was, the name itself, the set of names the table does declare, and the field it came from. Every caller passes that field label down. History is read position by position, so a bad cell comes back as history[1].target, not as a failure somewhere inside forty rows. A machine record resolves its current state under the label "state". Debugging stops being archaeology.

Then the part that is easy to get wrong.

A history entry serializes to names, nulls and an ISO timestamp: flat, readable, diffable. Reading it back resolves those names through the table and rebuilds the row. A missing timestamp is tolerated. A datetime stays, text is parsed, anything else is stamped with the current moment, because an entry without a clock is still a move that really happened.

But rebuilding the machine from a record appends no entry and fires no hook. The recorded moves already happened. If rehydration emitted events, every restart would re-send the notification, re-publish the status, re-trigger whatever listens downstream. Loading history exists so you can know the past without re-living it.

Our take: in any persisted workflow, the mapping from stored string to in-memory object is a contract, and contracts need a single owner and loud failures. The layers that quietly accept an unknown name, or helpfully re-run side effects on boot, are probably the ones behind the incidents nobody can reproduce.

So it is worth knowing where your workflow names actually live: in one table, in a database enum, or in three places that have already drifted.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com