Workflows
A status column that anything can set to anything is not a status. It is a text field with suggestions. A workflow turns one select field into a process the server enforces, declared in the same place the field is.
What a status field is without one
Generate an order with a status select and the admin shows a dropdown with every value on every record. Any client that can update an order can send this:
PATCH /api/v1/orders/8f2a{ "status": "delivered" }
On an unpaid order. Nothing stops it. A delivered order can go back to pending, an unpaid one can be marked shipped, and the only thing between your fulfilment process and nonsense is that people mostly click the right option. That is the default behaviour of a status dropdown in every CRUD admin, Grit included, until you declare the process.
Declaring it
A workflow is a block on the select field itself. That needs more structure than a command-line flag, so the resource goes in a YAML file:
- name: statustype: selectoptions:- { value: pending, label: Pending payment }- { value: paid, label: Paid }- { value: packed, label: Packed }- { value: shipped, label: Shipped }- { value: delivered, label: Delivered }- { value: cancelled, label: Cancelled }workflow:initial: pendingterminal: [delivered, cancelled]transitions:- action: mark_paidfrom: [pending]to: paid- action: packfrom: [paid]to: packedpermission: orders.fulfil- action: shipfrom: [packed]to: shippedpermission: orders.fulfil- action: deliverfrom: [shipped]to: delivered- action: cancelfrom: [pending, paid, packed]to: cancelledconfirm: true
grit g resource Order --from order.yaml --forcegrit migrate
The shape of it
Three decisions worth understanding
States come from the field's own options
They are not listed twice. If they were, the dropdown and the state machine would drift, and you would get a transition to a state the UI never offers, or an option nothing can reach.
It is a directed graph, not a ladder
This is the one people get wrong. paid back to pending is illegal only because you did not declare it. Add a transition and it becomes legal:
- action: reopenfrom: [paid]to: pending
Only three things are actually enforced:
- Nothing leaves a terminal state. Whatever you list under
terminalis an end. - An empty
frommeans from anywhere. Useful for something likearchivethat applies whatever the current state is. - Generation refuses a dead end. A non-terminal state with no way out is rejected when you generate, rather than discovered in production when an order lands in it and nobody can move it.
Permission sits on the transition, not the resource
pack and ship require orders.fulfil. deliver does not. Warehouse staff advance fulfilment without being able to refund, which is not expressible if permission is a property of the whole resource.
What it generates
Four things, none of which you write.
1. A guarded service method
The check runs in the service, so a warehouse account calling ship without the permission gets a 403 from the server. Not a hidden button: hiding a button is a UI preference, and the request still works if somebody sends it by hand.
2. Per-action endpoints, and no general status write
POST /api/v1/orders/:id/transitions/mark_paidPOST /api/v1/orders/:id/transitions/packPOST /api/v1/orders/:id/transitions/cancelGET /api/v1/orders/workflow the definition, for a client that draws it
This is the core of the whole feature. There is no endpoint that sets status to an arbitrary value, so an illegal jump is not rejected. It is unrepresentable.
An action that exists but is not legal from here returns a 422 saying what state the record is in and which actions are available from it, so a client can show the next step without a second request.
GET /workflow returns the definition itself, which is what lets a client draw only the legal transitions as buttons instead of a dropdown of everything.
3. A domain event per transition
Each transition emits <resource>.<action>, so orders.mark_paid, on the shared bus. Audit, webhooks and realtime are already subscribers, and so is anything you add:
events.On("orders.mark_paid", events.Async, "send-confirmation", sendOrderConfirmation)
The transition is its own event rather than a generic update, because a subscriber that cares about orders being paid should not have to diff two versions of a record to work out that is what happened. It is also how a confirmation email gets sent without a line of email code in the checkout handler.
4. confirm: true in the admin
Marks the action as needing a confirmation step, because cancel is not undoable and a misclick on a table row is easy.
The trap those events create
Read this before adding a back-edge. Say your orders.mark_paid subscriber sends the customer confirmation email, and you add a reopen transition from paid to pending because a payment sometimes needs redoing.
Now paid → pending → paid sends that email twice. The state machine is behaving exactly as declared. The mistake is reversing into a state whose entry has a side effect.
Use a compensating state instead. A refunded or payment_failed state, with its own transitions and its own event, says what actually happened rather than pretending the order went back in time.
What is not built yet
The admin does not render workflow state as a badge with the legal transitions as buttons. Today you get the generated dropdown plus the transition endpoints, and a custom cell is how to draw it properly. The definition endpoint exists so that component can be written without hardcoding the graph.
