What can be done to this object right now¶
Some operations exist and are permitted and still cannot happen: an order that shipped cannot be cancelled, a draft with no title cannot be published, refunds are closed while the books are. That is not a fact about the caller — a permission class answers that, with a 403 — but a fact about the world, and it refuses with a 409.
affordances declares those facts once, on the operation, so the same
declaration decides whether the call is refused and what the refusal says.
from django.db.models import Exists, OuterRef, Q
from rest_framework_services import Affordance, ServiceSpec
cancel_order = ServiceSpec(
service=cancel,
permission_classes=[IsOrderOwner],
affordances=[
Affordance(
code="order_shipped",
reason="A shipped order cannot be cancelled.",
when=~Q(status="shipped"),
),
Affordance(
code="refund_pending",
reason="Wait for the pending refund to settle.",
when=~Exists(Refund.objects.filter(order=OuterRef("pk"), settled=False)),
),
],
)
when is true when the action is available. A caller whose call fails a
condition gets
ActionUnavailable
— over HTTP a 409 whose body names the rule:
A code and a reason are two different things¶
code is stable and machine-readable: it names the rule, never the state that
tripped it. It is what a transport or a client branches on, and it does not
change when somebody rewords the sentence.
reason is the sentence people and models both read: it is the message of the
refusal a caller gets, and the sentence a model relays when it explains why an
action is not possible. Write it for them — say what cannot be done and, where
it helps, what would make it possible — and keep internal state out of it:
which reviewer, which batch job, which ledger row is nobody's business but the
code's. A sentence is for reading, not parsing, so branch on the code.
Codes must be unique within a spec, because the code is the only way a reader tells one refusal from another.
Two kinds of condition, and the type decides¶
An ORM boolean expression is a condition on the row. A Q, an Exists, a
lookup: it means exactly what Order.objects.filter(when) means, relations
included, and it is answered in SQL. However many are declared, the row they
apply to is checked with one query. A NULL is not true, so a condition over
a missing relation reads as unavailable.
A callable is a condition on nothing in particular — "refunds are closed
during month-end". It is resolved through the keyword pool like any other spec
callable, returns a bool, and sees the pool's seeds and nothing else: user,
request, progress, and any
registered pool seed such as a clock. A
client argument never reaches it, even under a binding that spreads arguments
into the pool — the caller does not get to decide whether the call is allowed.
def books_open(*, clock) -> bool:
return clock().day < 28
Affordance(code="books_closed", reason="Refunds reopen on the 1st.", when=books_open)
A callable result is read for truth, so one that forgets to return refuses rather than allows.
A callable over the row is refused at construction, and that is the point of
the design rather than a limitation of it. A Python predicate over the row has to
run once per row, and a list that reports availability for fifty orders becomes
fifty-one queries. Prefetching does not rescue it: a prefetched relation answers
some reads from its cache and silently re-queries for others. A rule the ORM
cannot express is not an affordance — keep it in
preconditions, where it is enforced and simply not
advertised.
For the same reason a callable may not name instance, collection, data or
serializer. Those exist only once a call is being made, and an affordance is
meant to be answerable without making one.
Where it runs¶
After access, always. A refusal describes the row's state, and telling a caller who may not see the row what state it is in is a disclosure. A caller denied the row gets the 403 and nothing of the affordance.
Before preconditions. An affordance is the declared, advertisable form of
the same kind of rule, so the one a client could have been told about is the one
that answers.
In declaration order, stopping at the first unmet condition.
A condition on the row needs one resolved row. It is refused on a spec with
many=True or a collection_selector_spec at construction, and at as_view()
on an action that targets no row (a create, a non-detail action). A callable condition works on every shape, bulk
included.
Asking for a whole list¶
A client deciding which buttons to render should not have to attempt each
action on each row. SelectorSpec.affordances asks the question inside the
list query itself:
orders = SelectorSpec(
kind=SelectorKind.LIST,
selector=orders_for_user,
affordances={"cancel": cancel_order, "refund": refund_order},
)
The mapping holds the mutation specs themselves, keyed by a name you
choose — not registry names, because a ServiceSpec does not know what it is
called and the read path should not need a registry to find out. Every
condition on those specs becomes one boolean annotation on each row, named
affordance__<name>__<code> (affordance__cancel__order_shipped), and all of
them join the same single .annotate() call as the spec's own
annotations. Fifty orders with five conditions is still one query.
Each annotation is the same correlated Exists the mutation evaluates when it
is called, so the list and the call cannot disagree about a row. It also means
the answer is read from the table:
- a condition over a multi-valued relation neither duplicates the list's rows
nor inflates a
Countyou annotated beside it; - a filtered
Prefetchon the same relation — which would hide rows from a Python predicate readingorder.lines.all()— does not change the answer.
A callable condition has no row to vary with, so it is answered once per list and carried as a constant on every row.
A selector that returns rows instead of a queryset¶
A selector does not have to return a QuerySet for its rows to be answered. A
list, a generator, or a RETRIEVE selector's bare instance gets the same answers
under the same affordance__<name>__<code> names, so everything that renders them
reads them the same way:
- Model instances are answered by one query per model class present —
Model._base_manager.filter(pk__in=...)annotated with the very sameExistsexpressions — and carry the answers as attributes. Fifty instances with five conditions is one extra query, not fifty. A row the table no longer holds (deleted between the selector returning it and the check) carriesNonefor each condition on the row rather thanFalse: a call against it would be refused as not found, but it fails none of the conditions, so it must not be reported as failing one. An instance with no primary key is refused when a condition on the row needs finding it. - Mappings come back as new mappings with the callable answers added; the
selector's own objects are left alone. A condition on the row is refused on a
mapping, which has no model and no primary key to evaluate it against — return
model instances or a
QuerySetinstead. - Anything else is refused rather than having attributes written onto it.
A generator is materialised once, and that list is what the dispatch returns.
A generated name that collides with a key of annotations, or with another
entry's, is refused at construction. affordances needs a selector, and is
refused on an instance_selector_spec or collection_selector_spec, whose rows
are acted on rather than returned. On an output_selector_spec it answers the
row a mutation hands back.
What a client reads¶
Every object rendered from a selector that declares affordances carries them
under an affordances key — through render_spec_output, through the list,
retrieve and @selector_action views, and in a mutation's response when its
output_selector_spec declares them:
{
"id": 42,
"status": "shipped",
"affordances": {
"cancel": {
"available": false,
"code": "order_shipped",
"reason": "A shipped order cannot be cancelled."
},
"refund": {"available": true}
}
}
available is always there. An unavailable answer names the first unmet
condition in declaration order — the same one a call would be refused with. The
one exception is a row a selector returned directly that was deleted before its
answers were asked: it reads {"available": false} with no code and no
reason, because it fails none of the conditions and no sentence about it would
be true. (A callable condition declared before the row conditions, and unmet,
still reports its own code and reason; that sentence is true either way.)
Reading the answers costs nothing: they are the annotations the list query
already computed, or the answers attached to rows a selector returned directly.
An agent reads the same answers a browser does.
render_for_audience
passes them through whole. A model speaks human language, so the reason is
what it relays when it explains why an action is not possible, and the code is
still there for the transport — or the model — to branch on. That is why the
reason is written for people and models alike and keeps internal state out.
The output schema declares the object too:
spec_to_json_schema
adds it, with code enumerated from the declaration so a client can switch on it
exhaustively, and
output_to_json_schema
takes the mapping as affordances= for a transport that builds its schema from
the serializer. With or without a projection the object it declares is the
same, reason included, because the payload is.
Rendering refuses, rather than quietly dropping the answers, when the spec has no
output_serializer, when the serializer already renders a field called
affordances, or when the row being rendered did not come through the selector
that computes them.
Offering an operation at all¶
A callable condition does not vary with the row, so it also answers a question about the operation itself: is it worth offering right now? A transport that lists operations, such as an MCP server's tool list or an agent's toolset for its next step, can leave out one that no argument could make succeed:
from rest_framework_services import base_pool, unmet_operation_affordance
pool = base_pool(user=user, request=request, seeds=seeds)
offered = {
name: spec
for name, spec in operations.items()
if unmet_operation_affordance(spec, pool, reserved=seeds.reserved) is None
}
unmet_operation_affordance
returns the first unmet callable condition in declaration order, or None. A
condition on the row is skipped without a query, because there is no row yet,
and is still answered for each object: on a selector's rows and at the call. A
SelectorSpec always answers None, because its affordances describes other
operations, not the selector.
Build the pool the way the call's would be built, with the same seeds, and pass
reserved=seeds.reserved. The list and the call then answer every condition from
the same names, through one definition.
The answer is advisory. The call still enforces every affordance. If a
condition flips just after the list is built, calling the stale entry gets the
same ActionUnavailable a direct call would, with the code the list would now
report. A condition driven by a clock has no event: it is answered when someone
asks, and nothing announces the moment it flips. A transport that pushes list
changes to its client needs another signal for when to ask again.
It is a check, not a lock¶
The answer is correct at the moment of the call. Between the check and the
service committing, another request can ship the order. If the service's
correctness depends on the state, the service still enforces it — inside its
transaction, with a select_for_update or a conditional update — and an
affordance is what lets every client find out before trying.
Declaring nothing costs nothing¶
A spec without affordances runs no query and, on the async path, takes no
executor hop for them. Neither does asking aunmet_operation_affordance about a
spec with no callable condition. A selector without affordances issues exactly the
query it issued before, its rows carry no extra attribute, and its views, its
rendered payloads and its schemas are what they were.
Full signatures: Affordance,
ActionUnavailable.