State rules with preconditions¶
Some rules aren't about the shape of a payload — they're about whether the operation is allowed right now, given the row and the actor. A budget that's been locked. An order already shipped. A reviewer who already has a pending request on this object.
preconditions is a declared slot for those, on the spec, so the rule travels
with the operation to every transport instead of living in one view method.
from rest_framework_services import ServiceConflict, ServiceSpec
class BudgetLocked(ServiceConflict):
"""A state collision, which over HTTP is a 409."""
def budget_not_locked(*, instance):
if instance.is_locked:
raise BudgetLocked("This budget is locked and cannot be edited.")
spec = ServiceSpec(
service=update_budget,
input_serializer=BudgetInput,
preconditions=[budget_not_locked],
)
Two rules you have to know¶
Raise to abort. The return value is ignored.
This is the one that bites. A predicate written the natural way —
— is silently a no-op. Returning False does not block the call; only raising
does. If you are porting existing -> bool predicates, every one of them needs
a raise.
Raise a ServiceError member, not a DRF exception.
The ServiceError family is the framework-agnostic error every transport already
maps: to a response over HTTP, to a failed tool result over MCP, to a retryable
error under an agent toolset. A DRF APIException subclass works over HTTP and
escapes the mapping everywhere else — the same rule lands as a clean 409 for a
browser and an unhandled internal error for an agent.
Pick the member that says what kind of failure it is, and every transport gets the distinction:
| Raise | HTTP | For |
|---|---|---|
ServiceConflict |
409 |
the state collides — a slot taken, a row moved first |
ServiceNotFound |
404 |
the target is absent, or not this caller's to see |
ServiceValidationError |
400 |
it should read as an input error |
ServiceError |
422 |
understood, and still not allowed |
An HTTP status is not a field on the exception. Setting status_code = 409 on
a ServiceError subclass does nothing — nothing reads it — and it could not work
off HTTP anyway, where there is no status to carry. The member is the portable
form of that intent, which is why the recipe above raises one.
Where they fire¶
That position is what lets one field cover both kinds of rule. By the time a precondition runs, all of these are in the pool:
| Key | When |
|---|---|
instance |
the resolved row (update / destroy / retrieve) |
collection |
the resolved set (collection mutations, LIST selectors) |
data, serializer |
whenever the spec has an input_serializer |
user, request |
always |
So a rule about the row, a rule about the payload, and a rule that needs both are all the same kind of thing. Declare the keys you want and you get exactly those:
def price_increase_needs_approval(*, instance, data, user):
if data["price"] > instance.price * 2 and not user.is_staff:
raise NeedsApproval("Doubling a price requires an approver.")
Business logic never sees an unvalidated payload — validation has already run.
Preconditions vs. serializer validation¶
Keep the boundary. A serializer validates shape: types, required fields, field-level formats, cross-field coherence within the payload itself. A precondition validates state: what's in the database, who's asking, what already happened.
The practical test is whether the rule can be answered from the payload alone.
end_date > start_date can — that's Serializer.validate(). "This budget is
locked" can't; it needs a row. Putting state rules in the serializer means
fetching the object twice and reimplementing the lookup the spec already did.
Selectors¶
SelectorSpec takes the field too, with one position: after the target
resolves. RETRIEVE seeds instance, LIST seeds collection.
SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=budget_by_pk,
preconditions=[budget_is_visible_during_freeze],
)
Pool binding does the discrimination — a precondition declaring instance
cannot be attached to a LIST spec, and you'll hear about it at as_view()
rather than at request time.
Bulk¶
A many=True bulk spec runs its preconditions once, with no target, before
the service — matching where the target guard fires. Only preconditions
declaring user / request / data bind. Per-item rules belong in the
service's own loop, where you can report which item failed.
What's checked at startup¶
as_view() rejects, with a message naming the problem:
- a bare callable instead of a sequence (
preconditions=check— wrap it in a list) - a non-callable element, named by index
- a parameter nothing seeds. The pool is keyed on framework seed names, not on
your model names —
def is_editable(order)will not receive yourOrder; writedef is_editable(*, instance). Without this check that's aTypeErrordeep in dispatch, which surfaces as a 500. preconditionson a nested spec (instance_selector_spec,collection_selector_spec,output_selector_spec). Those never dispatch, so the field would never run; it's refused rather than ignored.
Telling an agent about them¶
Preconditions are deliberately not reflected into tool schemas. There's
nothing sound to derive the text from — a function's __name__ and __doc__
are the only machine-readable strings on it, and generating agent-facing schema
from either makes a docstring edit a wire-format change.
If you want an agent to know an operation can fail this way, say so in
spec.metadata, in the words you want it to read, and surface that from your
own adapter. The retryable-vs-fatal distinction rides on the exception: a 409
tells an agent the call could succeed later, a 403 tells it not to bother.