Shipmind Labs

One table, one cancel: why each actor needs its own trigger

· 9 min read

A lifecycle table indexed by (state, trigger) answers "what happens now" with a dictionary lookup instead of a walk through handlers. What you pay for that index is a vocabulary problem: two actors who leave the same state for different reasons, with different follow-ups, cannot both name their move cancel, because the table refuses a second row for the same pair. We took that trade on purpose in order-lifecycle, and this is where the bill arrives.

The index is the whole point#

A lifecycle in this package is a tuple of frozen rows, and the index gets built once, at construction:

python
def __post_init__(self) -> None:
    rows = tuple(self.transitions)
    index: dict[tuple[State, Trigger], Transition] = {}
    for row in rows:
        key = (row.source, row.trigger)
        if key in index:
            raise ValueError(
                f"duplicate transition for state {row.source.name!r} "
                f"and trigger {row.trigger.name!r}"
            )
        index[key] = row
    object.__setattr__(self, "transitions", rows)
    object.__setattr__(self, "_index", index)

TransitionTable.find is a lookup on that key, and everything useful follows from it staying a lookup: available(PAID, role=WAREHOUSE) renders the buttons, for_role(SUPPORT) prints the permission matrix, roles enumerates every actor the lifecycle knows about. You cannot ask a pile of handlers any of those questions without reading all of them first.

The uniqueness check is not defensive bookkeeping. It is the invariant of the structure, since a dict key maps to one value. If two rows could share (PAID, CANCEL), find would turn into a scan plus a tie-break rule, and the first thing a tie-break rule does is make the outcome depend on something outside the table (order of definition, import order, whichever row a later refactor happens to move). Tables are usually module-level constants, so a contradictory one raises at import, not on the request that would have been ambiguous.

The second cancel is refused by name#

Cancellation is where the constraint stops being theoretical. A real order has several ways out of paid, and they are not variations of one move:

  • the customer changed their mind, or support found a duplicate order, so you refund and close;
  • the warehouse cannot fulfil it, because the shelf is empty or the goods are damaged, so you refund and restock.

Same source state, same target state, different actors, different reason vocabularies, different follow-ups. The obvious declaration is two cancel paths, and it does not survive construction:

python
CancellationPolicy(
    (
        CancellationPath(PAID, CANCELLED, CANCEL, roles={CUSTOMER, SUPPORT}),
        CancellationPath(PAID, CANCELLED, CANCEL, roles=WAREHOUSE),
    )
)
# ValueError: ... give each actor its own trigger

CancellationPolicy.__post_init__ applies the table's rule to its own paths, and the message tells you what to do about it. That is not a style preference inherited from the index, it is the index talking, because a CancellationPath is a transition row wearing another name:

python
@property
def transition(self) -> Transition:
    """Return this path as an ordinary row of a transition table."""
    return Transition(
        self.source,
        self.target,
        self.trigger,
        roles=self.roles,
        conditions=self.conditions,
        before=self.before,
        after=self.follow_up,
    )

CancellationPolicy.extend turns every path into one of those rows and appends it to a core table, so len(policy.extend(CORE)) == len(CORE) + len(policy) and the cancellation paths become indistinguishable from the happy path at lookup time. Which also means that a policy colliding with a row already in the core table (a cancel the core declared for new, say) raises when the extended table is built, not when somebody tries to cancel.

So the actor becomes the verb#

Once you accept the constraint, the lifecycle grows a vocabulary. In our test fixture the exits from the order are cancel (customer and support), reject (warehouse), fail (courier, which moves the order to returned rather than cancelled), and write off (warehouse, from returned):

python
CancellationPolicy(
    (
        CancellationPath(
            PAID,
            CANCELLED,
            CANCEL,
            roles={CUSTOMER, SUPPORT},
            reasons=(CHANGED_MIND, DUPLICATE),
            follow_up=refund,
        ),
        CancellationPath(
            PAID,
            CANCELLED,
            REJECT,
            roles=WAREHOUSE,
            reasons=(OUT_OF_STOCK, DAMAGED),
            follow_up=(refund, restock),
        ),
        CancellationPath(
            SHIPPED,
            RETURNED,
            FAIL,
            roles=COURIER,
            reasons=(UNDELIVERABLE, DAMAGED),
            follow_up=collect_parcel,
        ),
        CancellationPath(RETURNED, CANCELLED, WRITE_OFF, roles=WAREHOUSE),
    )
)

This reads better than the single cancel we were denied. Reason vocabularies are per path, so accept can demand one of out_of_stock or damaged from the warehouse and refuse changed_mind with a ReasonNotAccepted: a path that declares reasons takes only those, while a path that declares none records whatever sentence the caller wrote. The follow-ups differ because the business differs, since a rejection restocks, a customer cancellation does not, and a failed delivery collects the parcel instead of closing the order. And because source.terminal is checked in __post_init__, there is no path out of delivered at all: a delivered order gets a return flow instead of a cancellation, and the attempt to declare one fails at construction with the state named.

Callers do not need to know which verb applies. CancellationPolicy.resolve walks the paths in declaration order, skips the ones whose source is not the current state, skips the ones whose trigger does not match the trigger argument when one is given, and returns the first path whose roles permit the caller. If nothing permits them it raises CannotCancel, which names the actors who may cancel here instead of just saying no. So policy.cancel(machine, role=WAREHOUSE, reason="out_of_stock") fires reject without the warehouse endpoint knowing the word, and the same call with role=CUSTOMER fires cancel.

Two things about declaration order are worth saying plainly, because the first match wins. If two paths from one state both permit the same role, the earlier one is taken silently, and you get no warning, because that silent choice is itself the tie-break the table would otherwise have needed. An unguarded path (one that permits anyone) declared early shadows every guarded path from the same state that comes after it. When a caller genuinely has two exits available, the trigger argument is how they choose, and declaration order is not a feature to lean on.

The payoff is in the record. Every accepted move is appended to an append-only history, and the trigger goes in with it:

python
machine = policy.cancel(Machine(table, PAID), role=WAREHOUSE, reason="out_of_stock")

machine.state                    # CANCELLED
machine.history.last.trigger     # REJECT
machine.history.last.reason      # the sentence declared by OUT_OF_STOCK

Who ended this order is not a field someone remembered to set. It is the verb, and it was written by the lookup that permitted the move. We have written before about hooks running before the new state is returned, and the same discipline applies here: the entry is appended for a move that actually completed, not for one that was attempted.

The bill: a query per verb#

So this is what the vocabulary costs. History.for_trigger compares entry.trigger == trigger, exactly as find compares keys. Ask it for cancel and you get the customer and support cancellations, and nothing recorded under reject, fail or write off. The question "how did this order end?" no longer has one key.

It has to be asked of the vocabulary rather than of a single word, and the policy is the thing that knows the vocabulary:

python
def exits(policy, history):
    """Every recorded move that left the lifecycle, grouped by trigger."""
    return {
        path.trigger: entries
        for path in policy.paths
        if (entries := history.for_trigger(path.trigger))
    }

That is five lines, and it is the honest shape of the cost: any report, metric or admin filter that means "cancellations" has to iterate the policy instead of matching a string. Code that hard-codes for_trigger(CANCEL) is not wrong today and will be wrong the first time someone adds a fifth exit, which is probably the usual way this class of bug ships, because the hard-coded version passes every test written before the new path existed. Note also that grouping by trigger is grouping and not chronology: an order that went fail and then write off has two exit entries, and the mapping above tells you both happened without telling you which came first.

What it costs to run#

One sharp edge is worth knowing before you build on this. cancel resolves the path, accepts the reason, then calls machine.apply(path.trigger, ...). Nothing in it checks that the machine's table actually contains the path's row. Build a Machine on a core table you forgot to pass through policy.extend, and the policy will happily permit the role, validate the reason, and then hand an unknown trigger to Machine.resolve, which raises IllegalTransition. The error is accurate and the state is unchanged, but it points at the table rather than at the missing extend call. The fix is a one-line convention: the extended table is the only table the application ever sees, built once at import next to the policy that produced it.

The rest of the running cost is the vocabulary itself. Four exit verbs mean four rows, four reason lists and four follow-up tuples, all of it data, all of it readable in one file, and all of it enumerable by anything that needs to know how an order can end. We would rather pay that than own a cancel handler with a branch on user.role inside it, because the branch is invisible to the UI and to the history, and it gets duplicated the moment a second entry point needs the same decision.

Indexing by (state, trigger) is what makes the lifecycle answerable. The vocabulary is the receipt for that, and it stays cheap as long as every consumer reads the exits off the policy instead of guessing the word.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com