A support agent sent us a screenshot of an order with two buttons that did nothing.
Clicking either gave back an error saying the transition was not allowed. The buttons came from one list of legal moves, and the error came from the code that actually performs them. Two lists, same intent, drifting apart slowly.
So we rebuilt the listing in our open-source project order-lifecycle so it asks the runtime's question instead of its own. A row survives the listing when its source matches the current state and it allows the given role and context, and allowing is exactly two things: the role is permitted, and the conditions hold. That is the same pair the machine consults before it moves anything. One question, two callers.
Then the uglier half. An admin screen asked "what is possible from here" before any order was loaded, and a condition predicate went looking for a field on nothing. A stack trace, out of a read-only listing.
The rule we landed on: None means absent, never unfiltered.
A missing role fails any row that declares roles. A missing context fails a condition before the predicate ever runs, and the failure carries the explanation of every condition we checked, so we can report all of them instead of just the first. The conditional row simply drops out of the listing. A row guarded only by "always" still lists without a context. An unguarded row still lists without a role.
Refusal then follows the same order the filter does. Unknown transition first, and that error carries the allowed moves computed for the exact role and context the caller passed, so the suggestion cannot contradict the question. Then role. Then conditions.
The payoff is small and daily: a predicate can index straight into the order it is handed. No defensive None checks smeared through business rules.
You probably already know where your permission checks and your "what can we do here" screens get their answers from: one source, or two that agree most of the time.