Dispatching¶
dispatch runs one spec for
one principal with one set of arguments, and returns a
DispatchResult.
It is the whole of what a transport calls: an HTTP view, an MCP tool, a
command and a task worker each build a principal and a mapping of arguments,
dispatch, and answer the result in their own terms.
The order¶
The same six steps for a read and a write, and for the async entry point:
- The shape check and the closed argument set, over
spec.parameters(): presence, JSON type, format, choices and nullability at every level of nesting, with no query. An argument no parameter declares is refused. - Class-level authorization: every permission check's
has_permission, or the grant a transport passed instead. - Target resolution: a read's own selector, or a write's instance or
collection selector, called with the arguments its
readsdeclare, then shaped. A retrieve that finds nothing iskind="not_found", unless the selector allowsNone. - Object-level authorization: every check's
has_object_permission, on a retrieved row. Never on a list, which has no single object to check, and never on aNonethatallow_nonelet through. - Validation: a write's Validator, on only the arguments it declares, with the resolved target in its context.
- The run: the service, inside
transaction.atomic()unless the spec saysatomic=False, then the output selector if one is declared.
A read stops after step four: its selector is its run.
The order is the design, and each place reads as if it could move:
- The shape check comes first because it costs nothing and reveals nothing the declaration does not already say, so a malformed call is refused before it costs a row lookup.
- Class-level authorization comes before resolution, so a principal it refuses learns nothing about which rows exist: a missing row and a present one are refused alike.
- Resolution comes before validation, so the Validator's context carries the row. An update's uniqueness check has to exclude the row being updated, and it cannot if the row is resolved afterwards.
The result¶
A DispatchResult has a kind and a value:
kind |
When | value |
|---|---|---|
"list" |
A LIST read, or a write whose output selector is a LIST |
The rows, a queryset when the selector returned one |
"instance" |
Everything else that ran | The row, the service's return, or what the output selector re-read |
"not_found" |
A required row does not exist | None |
A write's result also carries service_result (the service's own return,
before any output selector), instance (the resolved target) and data (the
validated values the service received).
Not-found is reported, not raised. A transport decides what a missing row means on its own wire - a 404, an exit code, a tool error - and most transports have no status codes for the kernel to choose from.
def list_notes(user: Any) -> list[dict[str, Any]]:
result = dispatch(list_notes_spec, principal=user, arguments={"ordering": "-title"})
# result.kind is "list", and result.value the shaped queryset, not yet read.
return present(list_notes_spec, result) # one dict per row, in order
note_spec = SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=lambda *, user, pk: Note.objects.filter(owner=user, pk=pk),
permissions=[IsSignedIn()],
reads=Parameters.of(Parameter("pk", "integer", required=True)),
presenter=DataclassPresenter(NoteRow),
)
def show_note(user: Any, pk: int) -> tuple[int, Any]:
result = dispatch(note_spec, principal=user, arguments={"pk": pk})
if result.kind == "not_found":
return 404, {"detail": "Not found."}
return 200, present(note_spec, result)
note_spec scopes its selector to the principal, so another user's note is not
found rather than refused: it does not exist for them.
One case is not reported as not-found: a retrieve output selector that finds
nothing. The service has already run and, under atomic, committed, so
reporting not-found would tell the caller the write did not happen. The value
is None under kind="instance" instead.
Presenting¶
Nothing is rendered by dispatch.
present renders a result
through the spec's presenter when the transport asks, so one that hands the
row to a template never pays for rendering it.
- A
"list"result is presented item by item, into a list. With no presenter the items come back as they are, still in a list: a queryset is evaluated here rather than handed back lazily. - An
"instance"result is presented through the presenter. A value ofNoneis returned asNone, never handed to a presenter to render as a row of blank fields. - A
"not_found"result raisesValueError. Presenting nothing as a success is the bug this refuses; answer the not-found first.
apresent does the same from
async code, in one executor hop, since a presenter may query.
Permissions¶
A PermissionCheck
takes a principal and a spec, and nothing else: no request and no view, because
most transports have neither. Checks are instances, so a check can carry
its own configuration - a codename, a group - and a spec reads as the list of
checks it applies. has_permission is the class-level check;
has_object_permission runs on a resolved row and allows unless overridden;
message is what NotPermitted
says when the check refuses. A check is sync-only: it may query.
A spec with no permission check is refused, with ImproperlyConfigured, at
dispatch and at registration. Off HTTP there is no view whose
policy an undeclared operation could inherit, and running it anyway is how an
off-HTTP runner skips authorization with nothing warning. An operation that is
genuinely open says so with permissions=[Unrestricted()], which is also one
search away when someone asks which operations are open.
Grants¶
Dispatch enforces the permission check by default. A transport that has
already authorized through its own machinery - an HTTP view that ran its
framework's permission classes - says so with a
Grant rather than by
skipping the check, and pays for one class-level evaluation instead of two:
def rename_from_a_view(user: Any, arguments: dict[str, Any]) -> DispatchResult:
# The view's own permission machinery has already run for this user and
# this operation, so it says so rather than paying for the check twice.
grant = Grant(spec=rename_note_spec, principal=user)
return dispatch(rename_note_spec, principal=user, arguments=arguments, grant=grant)
A grant is bound by identity to one spec object and one principal object.
Carried to another operation or another user it covers nothing, and dispatch
refuses it with NotPermitted. It covers the class-level check only, unless
it says target_checked=True, which a transport can claim once it has resolved
the row and run the object-level check itself; otherwise dispatch still runs
has_object_permission on the row it resolves. And a grant cannot be
serialized: pickling one raises TypeError, so it cannot ride a queue into a
worker. A task runs later, against state that may have moved, and
re-authorizes by construction.
authorize and
authorize_target
are the two checks dispatch runs, exported for a transport that runs them
itself; authorize returns the grant it honoured or a new one covering the
spec and principal.
Principals off HTTP¶
HTTP has a session to resolve a user from; a queue payload, a command argument
or an agent's notion of who is asking carries an identifier.
resolve_principal
is the one place that becomes a user row, and it refuses a missing, malformed
or deactivated one with
PrincipalUnavailable.
It never falls back to an anonymous user: an operation dispatched with no
resolvable principal has no principal, not an anonymous one. The stock
permission classes of the HTTP frameworks pass a deactivated user, which is why
the refusal lives at lookup.
Binding without dispatching¶
Some transports bind input themselves: an MCP server answering a malformed call
before it opens a transaction, a queue validating a task in the worker.
bind_arguments
runs the same two steps dispatch does - the shape check over
spec.parameters(), then the Validator on its own arguments - so a transport
that binds for itself cannot drift from the gate dispatch applies.
def bind_rename(user: Any, note: Note, arguments: dict[str, Any]) -> dict[str, Any]:
# A transport that binds input itself authorizes and resolves first, then
# runs the same shape check and Validator dispatch would have run.
return bind_arguments(rename_note_spec, arguments, principal=user, target=note)
It returns the Validator's values: {"title": "Final"} here, without the pk
the instance selector reads. A read has no Validator, and gets its checked
arguments back.
It does not resolve or authorize. Dispatch runs both between the two
steps. A caller of bind_arguments owns them: it authorizes before binding,
and passes the row it resolved as target, or None for a create.
The async entry point¶
adispatch takes the same
steps in the same order, from async code, and
apresent renders its result.
Only the run may be async def: a write's service, or a read's own
selector. Every other callable a spec carries is sync-only - a permission
check, a Validator, a Presenter, a selector nested in a write,
extend_queryset, a seed's resolver - because each may query, and because a
spec is written once for both entry points: a check written async def could
not be called from dispatch. The run is the one callable no step shares.
So adispatch runs the steps in one thread-sensitive executor hop, and the
run's shape decides only what joins it:
- A sync run joins the hop, so a sync spec costs exactly one.
- An atomic
async defservice joins it too.transaction.atomicis sync-only, so the transaction is opened on the executor thread and the coroutine is driven inside it, where its own ORM calls reach the connection holding the transaction. - A non-atomic
async defrun is awaited on the event loop after the hop. Holding the executor thread while it waits on the network would stall every thread-sensitive call queued behind it. Anything after it - an output selector, a read's shaping and object-level check - takes a second hop.
from typing import Any
from django_service_specs import ServiceSpec, adispatch, apresent
from docs.examples.quickstart import IsOwner, rename_note_spec
from tests.dispatch_app.models import Note
async def rename_note_async(*, instance: Note, title: str) -> Note:
instance.title = title
await instance.asave(update_fields=["title"])
return instance
# The same declaration as the sync quickstart, with an async run. The permission
# check, the selector and the Validator stay sync: adispatch runs them in one
# executor hop, and runs this service inside the transaction ``atomic`` opens.
arename_note_spec = ServiceSpec(
service=rename_note_async,
permissions=[IsOwner()],
validator=rename_note_spec.validator,
instance_selector_spec=rename_note_spec.instance_selector_spec,
presenter=rename_note_spec.presenter,
)
async def rename_from_a_worker(user_id: Any, arguments: dict[str, Any]) -> Any:
# A queue carries an identifier, not a user: adispatch resolves it, and a
# missing or deactivated user is refused with PrincipalUnavailable.
result = await adispatch(arename_note_spec, principal_id=user_id, arguments=arguments)
if result.kind == "not_found":
return None
return await apresent(arename_note_spec, result)
adispatch takes exactly one of principal and principal_id. An identifier
is resolved with resolve_principal inside the hop, since resolving it is a
query. A grant never covers a principal resolved there, because a grant is
bound to the principal object it was made for.
The sync dispatch accepts an async def run as well, and drives it to
completion, inside the transaction when the spec is atomic.
run_service and
arun_service are
that bridge, exported for a transport that calls a service directly.
Pool seeds¶
Off HTTP there is no request for ambient values to hang off: a tenant, a
correlation id, a locale, a clock. A
PoolSeeds registry is that
channel. Each registration is a name and a resolver, and the resolver's value
is in every pool dispatch builds - the service's, each selector's,
extend_queryset's - for any of them to declare:
seeds = DEFAULT_POOL_SEEDS.extend(signature=lambda *, user: user.get_username())
def sign_note(*, user: Any, title: str, signature: str) -> Note:
return Note.objects.create(owner=user, title=f"{title}, by {signature}")
sign_note_spec = ServiceSpec(
service=sign_note,
permissions=[IsSignedIn()],
validator=DataclassValidator(Rename),
)
def create_signed_note(user: Any, title: str) -> DispatchResult:
return dispatch(sign_note_spec, principal=user, arguments={"title": title}, pool_seeds=seeds)
A resolver declares what it wants from the pool like any other callable, and sees the principal, never an argument: a seed is ambient, and a caller must not steer one by naming an argument after something its resolver reads.
A registered name is reserved, and that is half of what registering does.
A spec that declares a parameter by that name is refused at dispatch with
ImproperlyConfigured; a caller that sends it is refused like any other
undeclared argument; and extend() refuses one of the dispatcher's own names,
or a name registered twice, with ValueError. Either half alone is a trap: a
value with no reservation would share the pool with an argument of the same
name, and a reservation with no value would fail the callable that declares
it.
PoolSeeds is immutable and extend() returns a new registry, so pass one per
dispatch rather than installing one process-wide: two mounts with different
ambient context need them to differ. An adapter that builds a pool of its own
builds it with base_pool
and binds a callable with
resolve_callable_kwargs,
the declare-to-receive rule every callable here is called through.