Concept 2
Legal transitions
Naming the states is half of it. The other half is saying which moves between them exist.
In plain language
A record does not simply "change". It moves from one named state to another by a named action, and only along a route the model declares. Restoring a voided entry is a route. Reviving a superseded one is not — not because someone remembered to add a check, but because there is no route to take.
Precisely
A legal transition is a change the model explicitly permits, from a specific state, under a specific action, subject to the guards that action requires. A transition function is the only thing that constructs a new state value. Anything that writes state without going through one is a second, unpoliced route to the same place.
The full path of a requested action
- 1Current stateThe named state the entity is actually in — not a combination of flags a reader has to infer.
- 2Requested actionA named action, not an arbitrary field write.
- 3Capability checkIs this action one the current state permits at all? If not, the request is refused here.
- 4Evidence checkIs the basis for the transition present and valid?
- 5Policy checkDo the business rules covering this action hold for these particular values?
- 6Legal transitionOnly now does state change, and only to a state the model declares reachable from the current one.
- 7New state, obligations, effectsThe resulting state, what must still happen for the transition to be complete, and the external effects requested as data rather than performed in place.
Each gate refuses with a named reason. In the measured build, there are 24Distinguishable rejection states — so a refusal tells the caller which rule refused and why, rather than returning a boolean or throwing.
In real code
The measured time-entry build declares 4Distinct legal transitions: create into Recorded, void, restore, and supersede — reached by either splitting or merging, two distinct triggers onto one target state.
Superseded is terminal. What matters is how it is terminal. Every guard returns false for it, and two acceptance criteria assert the refusal directly. The illegal transition is explicitly represented and tested, not merely absent from the code — the difference being that absence is indistinguishable from an oversight, and a failing test is not.
What an agent should do differently
- Never write a state field directly. Find the transition function for the action, or establish that the action is not legal from the current state and report that instead.
- When a requested change has no transition, do not add a field to work around it. Either the transition should exist — a semantic change, decided at the authority — or the request should be refused.
- When adding a transition, ask what becomes reachable that was not before, and check the invariants that assumed it was not.
How this is most often got wrong
A declared outcome nothing can produce. The sharpest instance in this evidence base: a reconciliation function whose result type declared four outcomes, one of which no code path could construct. The type checker was satisfied. Ninety tests were satisfied. The contract was incomplete, and it took a fresh reviewer with no knowledge of the construction to notice — one of five findings, of which 0 of 5Independent-verification findings the builder's own checks had already caught were already covered by the existing checks.
The bypass route. One transition function, plus one repository method that writes the same field for a "special case". There are now two models and only one of them is documented.