7. The states
A loan is out, then returned, or lost. That is a state machine, and you do not write it by hand.
Declaring the lifecycle
models/workflow.py:
"""``library_loan`` lifecycle: out, then returned or lost."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kewos.views import register_status_colorsfrom kewos.workflow import StateMachine, has_state_machine, register_state_machine
if TYPE_CHECKING: from kewos.orm.registry import Registry
_STATE_COLORS = {"out": "info", "returned": "success", "lost": "destructive"}
def _build() -> StateMachine: machine = StateMachine(initial="out", states=("out", "returned", "lost")) machine.add_transition(trigger="check_in", source="out", dest="returned") machine.add_transition(trigger="declare_lost", source="out", dest="lost") return machine
def register_loan_workflow(_registry: Registry) -> None: """Bind the loan state machine and its status colours (idempotent).""" if not has_state_machine("library_loan"): register_state_machine("library_loan", _build()) register_status_colors("library_loan", "state", _STATE_COLORS)Then call it from models/__init__.py, after the models.
What it produces

Three things appeared, without a line of interface:
- The state track, with the current step to the fore and the next ones waiting.
- The transition buttons, “Check in” and “Declare lost”, which only appear when the
transition is possible from the current state. From
returned, there are none. - The semantic colours: an outstanding loan is neutral, a returned one green, a lost one red. They come from the theme’s tokens, never a hard-coded colour, and they show up in the list and the kanban too.
The mistake I made writing this
My first attempt showed workflow.check_in on the button: an unresolved translation key,
served as-is to the user. I had written the key under action.check_in.
The right shape is workflow.<trigger>.
{ "workflow.check_in": "Check in", "workflow.declare_lost": "Declare lost"}No error is raised in that case: a missing key is shown raw. That is exactly the kind of defect that passes a code review and shows up in a demo.
Guarding a transition
A transition can refuse to run.
machine.add_transition( trigger="check_in", source="out", dest="returned", guard=lambda instance: instance.taken_on is not None,)The guard returns false, the transition is refused, the interface shows the error. You cannot work around it by calling the API: the transition goes through the same path.
Asking for information at transition time
form= declares the fields the user must fill in before the transition goes.
machine.add_transition( trigger="declare_lost", source="out", dest="lost", form=("lost_note",),)The engine shows a dialog with those fields, and the effect can then rely on them. That is how you guarantee a loan declared lost always carries an explanation.
Acting on the way through
effect= receives the session and runs inside the transition’s transaction. Whatever is
written there is rolled back with it if something fails later.
def _mark_returned(session, _classes, instance) -> None: """Stamp the return date, atomically with the transition.""" instance.returned_on = date.today()
machine.add_transition( trigger="check_in", source="out", dest="returned", effect=_mark_returned)That is the place to trace, to emit an event, to update a counter. It is not the place to call an external service: that would hold the transaction open across a network call. Emit an event, and let a background job deal with it.
A final state
A model can declare that beyond a certain state, nothing changes any more:
registry.register_model( "library_loan", module="library", fields=[...], immutable_after_state=("returned", "lost"),)A correction is then a counter-write, not an edit. Same rule as an accounting entry, and for the same reason: the history has to stay defensible.