Use-case DSL¶
The use-case DSL lets you declare inputs, validations, derived values, and
pre-conditions in a declarative, composable style. The engine resolves everything
before execute() runs — keeping the body focused on the business outcome.
Anatomy of a use case¶
from loom.core.use_case import Compute, Exists, F, Input, LoadById, OnMissing, Rule
from loom.core.use_case.use_case import UseCase
from app.product.model import Product
from app.product.schemas import CreateProduct
class CreateProductUseCase(UseCase[Product, Product]):
# ↑ model ↑ return type
computes = [CREATE_NORMALIZE_NAME, CREATE_NORMALIZE_PRICE] # run first
rules = [CREATE_NAME_RULE, CREATE_PRICE_RULE] # run after computes
async def execute(
self,
cmd: CreateProduct = Input(), # body ← JSON-decoded command
) -> Product:
return await self.main_repo.create(cmd)
Execution order for every request:
1. Computes — derive / normalise command fields in declaration order
2. Rules — validate; any failure raises 422 before execute() is called
3. execute() — your business logic, guaranteed clean inputs
self.main_repo is the repository for the model declared in UseCase[Model, ...] — no
injection boilerplate needed.
Commands¶
A Command is a typed, immutable input object. Declare it with msgspec.Struct
conventions — the framework decodes the HTTP body into it automatically:
from loom.core.command import Command, Patch
class CreateProduct(Command, frozen=True):
name: str
price: float
sku: str
class UpdateProduct(Command, frozen=True):
# Patch[T] marks a field as optional in partial updates.
# None means "not provided" — not "set to null".
name: Patch[str] = None
price: Patch[float] = None
sku: Patch[str] = None
Patch[T]is a union type alias forT | Nonewith framework-level tracking of which fields were actually sent. Rules can gate on whether aPatchfield is present using.when_present(...).
F — typed field reference¶
F(CommandClass).field creates a typed reference to a command field. It is used
everywhere the DSL needs to identify a specific field — in Compute, Rule, and
predicate conditions:
from loom.core.use_case import F
# reference to the 'name' field of CreateProduct
F(CreateProduct).name
# reference to the 'price' field of UpdateProduct
F(UpdateProduct).price
Field references are type-safe. Accessing a field that does not exist on the command raises an error at bootstrap time, not at runtime.
Input markers¶
Input() in the execute() signature tells the engine to inject the decoded
command body as that parameter:
async def execute(self, cmd: CreateProduct = Input()) -> Product: ...
LoadById fetches an entity by ID from a path or command parameter before
execute() runs — the entity is available in rules and the body:
from loom.core.use_case import LoadById
async def execute(
self,
product_id: int, # path parameter
cmd: UpdateProduct = Input(),
product: Product = LoadById(Product, by="product_id"), # loaded before execute
) -> Product | None:
# product is guaranteed to exist here if OnMissing.RAISE (default)
return await self.main_repo.update(product_id, cmd)
LoadById parameters:
Parameter |
Description |
|---|---|
|
Entity class to load |
|
Name of the path/command param that holds the ID |
|
|
Exists checks a DB condition and injects a bool — without loading the entity:
from loom.core.use_case import Exists, OnMissing
async def execute(
self,
user_id: int,
cmd: CreateAddress = Input(),
_user_exists: bool = Exists(
User,
from_param="user_id", # check User.id == user_id
against="id",
on_missing=OnMissing.RAISE, # auto-404 if False
),
) -> Address:
return await self.main_repo.create(...)
Exists parameters:
Parameter |
Description |
|---|---|
|
Entity class to check |
|
Path parameter whose value is compared |
|
Command field whose value is compared |
|
Entity field to compare against |
|
|
Agent marker — reaching a named agent¶
Agent(name) declares a handle to one compiled agent, bound to the caller
already verified for this execution — the same identity
Caller() fills. The executor resolves the marker before
execute() runs; nothing but a reviewed use case’s own code chooses which
agent runs or which caller it runs as.
The example below is complete and runnable, uses Caller() alongside
Agent(), and is the exact use case exercised — with no network, no model
and no database — by
tests/integration/ai/test_agent_marker_test_double.py:
editing one without the other is a gap the next review will catch. An
example that binds identity through a plain argument instead of Caller()
would teach a forgeable pattern — a parameter with no marker is filled from
whatever the caller supplies, and it ends up in the schema the model sees.
import msgspec
from loom.ai.abc import AgentHandle
from loom.core.identity import Identity
from loom.core.use_case import Agent, Caller, UseCase
class SeverityAssessment(msgspec.Struct, frozen=True):
"""The artefact's own declared output shape."""
severity: int
class IncidentReport(msgspec.Struct, frozen=True):
incident_id: str
caller: str
runbook_title: str
severity: int
escalated: bool
class TriageIncidentUseCase(UseCase[object, IncidentReport]):
async def execute(
self,
incident_id: str,
caller: Identity = Caller(),
triage: AgentHandle[SeverityAssessment] = Agent("incident-triage"),
) -> IncidentReport:
obs = triage.sql("observability_readonly") # the artefact's own granted view
deploys = await obs.query(
"select service, status from deploys where incident = :id",
parameters={"id": incident_id},
)
runbooks = triage.mcp("runbooks") # the artefact's own filtered view
runbook = await runbooks.call(
"search_incident",
{"incident_id": incident_id, "recent_deploy": deploys[0]["service"]},
expect=dict,
)
assessment = await triage.run(f"Assess {incident_id}.") # the artefact's declared shape
return IncidentReport(
incident_id=incident_id,
caller=caller.require_subject(),
runbook_title=str(runbook["title"]),
severity=assessment.output.severity,
escalated=assessment.output.severity >= 4,
)
The type argument on AgentHandle[SeverityAssessment] — not a parameter to
Agent() — is what the compiler checks at start-up against the named
agent’s own declared output. Agent() itself carries only the name, exactly
like Caller() carries no configuration.
What the handle offers¶
Member |
Returns |
Notes |
|---|---|---|
|
|
Decodes into the artefact’s own declared output — the handle’s type argument. |
|
|
This run only; the artefact’s own output check does not run for it. |
|
|
Open prose — no declared or overridden shape, so nothing can fail to decode. |
|
|
The artefact’s own filtered view of one |
|
|
The artefact’s own bounded view of one |
|
|
Every grant name reachable through |
Three run modes¶
assessment = await triage.run(prompt) # the artefact's declared shape
plan = await triage.run(prompt, expect=RollbackPlan) # this run only
summary = await triage.run_text(f"Write it up: {facts}") # open prose, decodes nothing
Two grant views, reached through the agent — never re-declared¶
triage.mcp("runbooks") and triage.sql("observability_readonly") return
the very same composed view — over the same shared session or connection,
filtered or bounded by the same predicate — that the model’s own toolset
runs over. The grant reached this way is never re-declared: it is declared
once, on the artefact’s mcp / sql capability, and the use case reaches
it as-is.
That does not mean no wider view can exist. A use case may declare its own,
independent view of an MCP server with Mcp(),
whose filter belongs to the use case, not the agent — written in the
signature and checked at start-up against the server’s real tool list, the
same way an agent’s own mcp capability is checked.
Testing it¶
loom.testing.runner.AgentHandleDouble stands in for the handle: no
network, model or database call is ever made, and a call the test never
scripted fails closed with an AssertionError instead of silently
succeeding with the wrong data.
from loom.testing.runner import AgentHandleDouble, UseCaseTest
double = AgentHandleDouble("incident-triage").on_run(SeverityAssessment(severity=5))
double.sql("observability_readonly").on_query([{"service": "checkout", "status": "unhealthy"}])
double.mcp("runbooks").on_call("search_incident", {"title": "checkout rollback runbook"})
result = await (
UseCaseTest(TriageIncidentUseCase())
.with_caller(identity)
.with_agent("incident-triage", double)
.with_params(incident_id="INC-100")
.run()
)
A use case declaring Agent(name) with no matching .with_agent(name, ...)
fails closed with a RuntimeError naming the use case and the parameter —
the same fail-closed default Caller() already has when no .with_caller()
is registered.
What the Agent() marker refuses¶
Every refusal below raises loom.ai.errors.AgentRunError, whose .code is
one of loom.ai.errors.AgentRunErrorCode, checked before any model or
network call where the table says so. Each row is pinned by a test — follow
the file to see the exact assertion.
Refusal |
Code |
Message (from the code) |
What to do |
|---|---|---|---|
Anonymous caller |
|
|
A marker-filled handle never accepts an anonymous identity, with no configuration to relax it — the caller must already be authenticated at the use case’s own route. In a test, register one with |
A cycle: the same agent already in the chain (agent names |
|
|
Break the cycle: an |
A depth past |
|
|
Raise |
A shape override ( |
|
|
Call |
A tool outside the artefact’s own |
|
|
Call one of the tools |
A tool that publishes no output schema, called through |
|
|
Call |
Mcp marker — reaching an MCP server directly¶
Mcp(server, *, include) declares a handle to one configured MCP server,
bound to the caller already verified for this execution — the same identity
Agent() binds. Unlike triage.mcp(server), reached through an agent’s
own grant, Mcp() needs no agent in the middle: the use case is the
capability’s only owner, and its include is the only filter checked for
it.
include is mandatory, for the same reason it is required on every
include/exclude filter this framework has: an empty sequence would mean
“every tool this server publishes”, not “the handful this signature names”.
Passing one glob is not enough of a decision to make by omission, so Mcp()
raises ValueError rather than widening a grant no one asked for.
The example below is complete and runnable, uses Caller() alongside
Mcp(), and is the exact use case exercised — with no network and no MCP
server — by
tests/integration/ai/test_use_case_mcp_marker_test_double.py:
editing one without the other is a gap the next review will catch. As with
Agent(), an example that skipped Caller() would teach a forgeable
pattern.
import msgspec
from loom.ai.abc import McpHandle
from loom.core.identity import Identity
from loom.core.use_case import Caller, Mcp, UseCase
class RunbookLookup(msgspec.Struct, frozen=True):
incident_id: str
requested_by: str
runbook_title: str
class LookUpRunbookUseCase(UseCase[object, RunbookLookup]):
async def execute(
self,
incident_id: str,
caller: Identity = Caller(),
runbooks: McpHandle = Mcp("runbooks", include=("search_incident",)),
) -> RunbookLookup:
result = await runbooks.call_untyped(
"search_incident", {"incident_id": incident_id}
)
return RunbookLookup(
incident_id=incident_id,
requested_by=caller.require_subject(),
runbook_title=str(result["title"]),
)
server is validated at start-up against ai.mcp_servers, naming the
declaring use case and the parameter when it is not configured. include
is checked at start-up too, against the server’s real tool list — not a
promise that no wider view exists (an include with broad globs admits
everything, and nothing caps how broad it may be), but a guarantee that
whatever it admits is written down in the signature and verified before the
first request ever reaches it.
What the handle offers¶
Member |
Returns |
Notes |
|---|---|---|
|
|
Tool names already narrowed by this marker’s own |
|
|
Decodes the tool’s structured result into |
|
|
The server’s own structured content, undecoded — for a tool that publishes no schema. |
Testing it¶
loom.testing.runner.McpHandleDouble stands in for the handle, exactly as
AgentHandleDouble does for Agent(): no network or server is ever
reached, and a call the test never scripted fails closed. Unlike the
published double, the resolver wraps it in a private filter carrying this
marker’s own include, so a test that calls a tool outside it gets the same
refusal production would give — see What the Mcp() marker
refuses below.
from loom.testing.runner import McpHandleDouble, UseCaseTest
double = McpHandleDouble("runbooks").with_tools("search_incident")
double.on_call_untyped("search_incident", {"title": "checkout rollback runbook"})
result = await (
UseCaseTest(LookUpRunbookUseCase())
.with_caller(identity)
.with_mcp("runbooks", double)
.with_params(incident_id="INC-100")
.run()
)
A use case declaring Mcp(server, ...) with no matching .with_mcp(server, ...) fails closed with a RuntimeError naming the use case and the server —
the same fail-closed default Agent() already has.
What the Mcp() marker refuses¶
Every refusal below raises either loom.ai.errors.AgentRunError (a call, at
run time) or loom.ai.errors.AgentCompilationError (start-up). Each row is
pinned by a test — follow the file to see the exact assertion.
Refusal |
Code |
When |
What to do |
Test |
|---|---|---|---|---|
A tool outside the marker’s own |
|
Call time |
Call one of |
|
A tolerated-unreachable server ( |
|
Call time |
Treat it like any other dependency outage: the server, not the filter, is the problem. |
|
|
|
Start-up |
Fix the typo, or add the server to |
|
|
|
Start-up |
Fix the glob, or confirm the server still publishes the tool you expect. |
|
A tool that publishes no output schema, called through call(..., expect=X),
is refused the same way Agent()’s own grant refuses it (TOOL_UNTYPED) —
but production is the only side that can know: a tool’s published schema is
not something the test double tracks, so McpHandleDouble cannot reproduce
this particular refusal.
Under ai.remote_clients: optional, a server tolerated as down never has its
include checked at all — a tolerated outage means the filter goes
unverified, not that it verified clean.
Compute — derive and normalise fields¶
Compute derives or normalises a command field before rules run. This keeps
normalisation logic out of both the command and the execute body:
from loom.core.use_case import Compute, F
def _normalize_name(value: str) -> str:
return value.strip()
def _normalize_price(value: float) -> float:
return round(value, 2)
# Simple normalisation — takes one field, returns the normalised value
CREATE_NORMALIZE_NAME = Compute.set(F(CreateProduct).name).from_command(
F(CreateProduct).name, via=_normalize_name
)
CREATE_NORMALIZE_PRICE = Compute.set(F(CreateProduct).price).from_command(
F(CreateProduct).price, via=_normalize_price
)
Computes can read multiple fields:
def _compute_subtotal(unit_price: float, quantity: int) -> float:
return unit_price * quantity
# Derives subtotal from unit_price × quantity
CREATE_SUBTOTAL = Compute.set(F(PricingCommand).subtotal).from_command(
F(PricingCommand).unit_price,
F(PricingCommand).quantity,
via=_compute_subtotal,
)
Computes can also read path parameters via .from_params(...):
# Normalise name — but only if product_id != "1" (system product)
UPDATE_NORMALIZE_NAME = (
Compute.set(F(UpdateProduct).name)
.from_command(F(UpdateProduct).name, via=_normalize_name_with_context)
.from_params("product_id")
.when_present(F(UpdateProduct).name) # skip if name was not sent
)
Computes run in declaration order — a later compute can reference a field already set by an earlier one (e.g. subtotal → tax_amount).
Apply computes conditionally¶
.when_present(field) skips the compute when the Patch field was not provided.
Essential for partial-update commands:
UPDATE_NORMALIZE_PRICE = (
Compute.set(F(UpdateProduct).price)
.from_command(F(UpdateProduct).price, via=_normalize_price)
.when_present(F(UpdateProduct).price) # skip when price is absent from PATCH body
)
Rule — validate before execute¶
Rules run after all computes. A failing rule raises a structured 422 response
before execute() is called.
Rule.check — field validation¶
Rule.check validates a single field. The via= function returns an error
string when the value is invalid, or None when it is valid:
from loom.core.use_case import Rule, F
def _name_must_not_be_blank(name: str) -> str | None:
return None if name.strip() else "name must not be blank"
def _price_must_be_positive(price: float) -> str | None:
return None if price > 0 else "price must be positive"
CREATE_NAME_RULE = Rule.check(F(CreateProduct).name, via=_name_must_not_be_blank)
CREATE_PRICE_RULE = Rule.check(F(CreateProduct).price, via=_price_must_be_positive)
Rule.forbid — invariant enforcement¶
Rule.forbid declares a condition that must not be true. The predicate
receives the arguments you attach; it returns True when the forbidden condition
holds (triggering the error):
def _patch_payload_is_empty(_cmd: UpdateProduct, fields: frozenset[str]) -> bool:
"""Forbid empty PATCH bodies."""
return len(fields) == 0
# from_command() with no arguments injects (cmd, fields_set_frozenset)
UPDATE_NOT_EMPTY_RULE = Rule.forbid(
_patch_payload_is_empty,
message="at least one field must be provided",
).from_command()
Conditional rules with .when_present¶
.when_present(field) skips the rule entirely when a Patch field was not
provided. Use this to avoid running patch validations when the field was not sent:
UPDATE_NAME_RULE = Rule.check(
F(UpdateProduct).name,
via=_name_must_not_be_blank,
).when_present(F(UpdateProduct).name)
UPDATE_PRICE_RULE = Rule.check(
F(UpdateProduct).price,
via=_price_must_be_positive,
).when_present(F(UpdateProduct).price)
Rules with path parameters¶
Use .from_params(...) when validation needs a value from the request path
(e.g. to check a system-protected entity by ID):
def _is_system_product_name_update_forbidden(
_cmd: UpdateProduct,
_fields_set: frozenset[str],
product_id: str,
) -> bool:
"""Prevent renaming the system product (id=1)."""
return str(product_id) == "1"
UPDATE_SYSTEM_NAME_IMMUTABLE_RULE = (
Rule.forbid(
_is_system_product_name_update_forbidden,
message="system product name cannot be changed",
)
.from_command(F(UpdateProduct).name)
.from_params("product_id")
.when_present(F(UpdateProduct).name)
)
Rules with multiple fields¶
.from_command(field1, field2, ...) passes multiple values to the predicate:
def _name_cannot_match_price(name: str | None, price: float | None) -> bool:
if name is None or price is None:
return False
return name.strip() == str(price)
UPDATE_NAME_PRICE_MISMATCH_RULE = (
Rule.forbid(
_name_cannot_match_price,
message="name cannot be equal to price",
)
.from_command(F(UpdateProduct).name, F(UpdateProduct).price)
.when_present(F(UpdateProduct).name & F(UpdateProduct).price)
)
Predicate composition¶
.when_present(...) accepts composed predicates using & (AND) and | (OR):
# Run only when BOTH name AND price are present in the request
.when_present(F(UpdateProduct).name & F(UpdateProduct).price)
# Run when EITHER field is present
.when_present(F(UpdateProduct).name | F(UpdateProduct).price)
Full use case example — update with all DSL features¶
class UpdateProductUseCase(UseCase[Product, Product | None]):
computes = [
UPDATE_NORMALIZE_NAME, # strip whitespace — skipped if name absent
UPDATE_NORMALIZE_PRICE, # round to 2 dp — skipped if price absent
]
rules = [
UPDATE_NOT_EMPTY_RULE, # reject empty PATCH body
UPDATE_NAME_RULE, # name not blank (if present)
UPDATE_PRICE_RULE, # price positive (if present)
UPDATE_SYSTEM_NAME_IMMUTABLE_RULE, # block rename of id=1 (if name present)
UPDATE_NAME_PRICE_MISMATCH_RULE, # name ≠ str(price) (if both present)
]
async def execute(
self,
product_id: str,
cmd: UpdateProduct = Input(),
) -> Product | None:
return await self.main_repo.update(int(product_id), cmd)
Declaring use cases on a REST interface¶
Attach use cases to routes via RestRoute:
from loom.rest.model import RestInterface, RestRoute
class ProductInterface(RestInterface[Product]):
prefix = "/products"
tags = ("Products",)
routes = (
RestRoute(use_case=CreateProductUseCase, method="POST", path="/", status_code=201),
RestRoute(use_case=GetProductUseCase, method="GET", path="/{product_id}"),
RestRoute(use_case=ListProductsUseCase, method="GET", path="/"),
RestRoute(use_case=UpdateProductUseCase, method="PATCH", path="/{product_id}"),
RestRoute(use_case=DeleteProductUseCase, method="DELETE", path="/{product_id}"),
)
Or let the framework generate all five routes automatically:
class ProductInterface(RestInterface[Product]):
prefix = "/products"
tags = ("Products",)
auto = True # generates GET, POST, PATCH, DELETE, list automatically
See Auto-CRUD guide for the full options reference.
Declaring routes in configuration (app.rest.interfaces)¶
Every key above has a YAML twin under app.rest.interfaces.<name>, named and
defaulted identically — the same vocabulary, so nothing here is new to learn.
Three keys are required per route: use_case, method, path. Everything
else inherits the same default the Python route does.
app:
rest:
interfaces:
products:
prefix: /products
tags: [Products]
routes:
- use_case: myapp.application.products:CreateProductUseCase
method: POST
path: /
status_code: 201
- use_case: myapp.application.products:GetProductUseCase
method: GET
path: /{product_id}
use_case is a module:Symbol reference, resolved with the same offline
reader the AI artifact format already uses in its own references — a bad
reference fails the same way, at startup, naming the interface.
Auto-CRUD from configuration¶
auto: true works exactly like the Python auto = True attribute, with one
addition: YAML cannot express RestInterface[Model]’s generic parameter, so
a model reference stands in for it. model is required only when auto
is set and the interface declares no explicit routes:
app:
rest:
interfaces:
gadgets:
prefix: /gadgets
auto: true
model: myapp.domain.gadget:Gadget
The generated routes come from the same auto-CRUD generator a Python
auto = True interface calls — same routes, same status codes, same
defaults.
Coexistence with Python interfaces¶
A YAML interface is converted into the same RestRoute and RestInterface
objects a Python subclass produces, and handed to the same compiler — there
is no second compilation path. Python interfaces compile first, YAML ones
after, deterministically; the order decides nothing about behaviour, only
which origin an error names first.
A (method, path) declared twice aborts startup, naming both declarations.
This is not a new rule: the compiler already refuses a route duplicated
within one Python interface; a same- or cross-origin collision is that same
refusal, extended. Nothing takes precedence — to change a Python-declared
route per environment, disable it (below) and redeclare it in YAML. Two
app.rest.interfaces entries sharing a route have no such override: remove
the duplicate declaration instead.
Behaviour change: before
app.rest.interfacesexisted, two PythonRestInterfacesubclasses declaring the same(method, path)mounted two handlers, and FastAPI silently served only the first one — the duplicate was never rejected. An application that relied on that (almost certainly by accident) will now fail to start, naming both interfaces, until the duplicate is removed. This applies whether or not any YAML interface is in use.
Breaking change:
create_fastapi_app’s second parameter used to beinterfaces: Sequence[type[RestInterface]]; it is nowroutes: RouteSources. Code that called it as documented —create_fastapi_app(result, interfaces=[...]), keyword form, every published example — still works: a deprecatedinterfaces=keyword wraps the list inRouteSources(python=interfaces)and emits aDeprecationWarningnamingroutesas the replacement (the keyword will be removed in 2.0). A call that passed the list positionally now binds it toroutes, which requires aRouteSourcesinstance:create_fastapi_appraisesTypeErrorimmediately, namingRouteSources(python=[...])as the replacement — it does not wait for compilation to fail on a missing attribute. Passing bothroutesandinterfaces, or neither, also raisesTypeError. Migrate by replacinginterfaces=[...]withroutes=RouteSources(python=[...]).
Disabling a route per environment (app.rest.disable_routes)¶
app.rest.disable_routes is a subtractive-only list, and it only targets
routes declared by a Python RestInterface — it can remove one, never
add one, and it has no effect on a YAML-declared route or on a route mounted
outside interface compilation (such as the health check). A route declared
in app.rest.interfaces needs no such mechanism: remove it from the YAML
instead.
app:
rest:
disable_routes:
- method: DELETE
path: /products/{product_id}
Each entry names the route exactly as it is published — the full path,
prefix included. Disablement is applied to the Python-declared routes
before they are merged with app.rest.interfaces entries and checked
for collisions. That ordering is what makes the override flow work: disable
the Python route, declare the same (method, path) in YAML, and there is no
collision because the Python route is no longer in the set by the time the
check runs.
An entry matching no Python-declared route aborts startup instead of doing nothing: a silent no-op would leave an operator believing a route is gone when it is still being served.
Security note: the disable-and-redeclare override flow lets a
app.rest.interfacesentry drop arequires_rolesthe Python route declared, since the redeclaration is a fresh route with its own policy, not a patch of the old one. That is consistent with treating configuration as trusted — the same trust an operator already has to remove or rewrite any other route — but it means “per environment” includes the route’s authorization, not just its shape.
A runnable version of every example above lives in
tests/integration/rest/test_yaml_interfaces.py,
exercised end to end against a real FastAPI app on every test run.
Cross-use-case calls with ApplicationInvoker¶
Use ApplicationInvoker to call another use case by type without tight coupling.
The engine resolves it from the DI container:
from loom.core.use_case.invoker import ApplicationInvoker
class RestockWorkflowUseCase(UseCase[Product, RestockWorkflowResponse]):
def __init__(self, app: ApplicationInvoker, job_service: JobService) -> None:
self._app = app
self._jobs = job_service
async def execute(
self,
product_id: str,
cmd: DispatchRestockEmailCommand = Input(),
) -> RestockWorkflowResponse:
# Call another use case by type — no import of its instance
summary = await self._app.invoke(
BuildProductSummaryUseCase,
params={"product_id": int(product_id)},
)
handle = self._jobs.dispatch(
SendRestockEmailJob,
params={"product_id": int(product_id)},
payload={"product_id": int(product_id), "recipient_email": cmd.recipient_email},
)
return RestockWorkflowResponse(
summary=summary.summary,
restock_job_id=handle.job_id,
queue=handle.queue,
)
app.entity(Model) gives a CRUD-focused facade when you know the entity:
# Equivalent to invoke_name("product:get", params={"id": product_id})
product = await self._app.entity(Product).get(params={"id": product_id})
await self._app.entity(Product).update(params={"id": product_id}, payload={"stock": 0})
Execution lifecycle¶
RuntimeExecutor.execute owns one execution end to end — from opening the
unit of work to running the post-commit actions it queued. REST use cases
and Celery jobs run through the same lifecycle; the Celery worker drives it
through the async bridge, not through a lifecycle of its own.
EXEC_START
→ unit of work __aenter__ (skipped when read_only or no uow_factory)
→ pipeline (binds, loads, computes, rules, execute())
→ unit of work __aexit__ (commit on success, rollback on failure/cancellation, always closes)
→ one terminal event: EXEC_DONE or EXEC_ERROR
→ post-commit actions drained (job dispatch, on_transaction_committed hooks)
The executor drives the unit of work through its context-manager protocol
only (__aenter__ / __aexit__); begin() / commit() / rollback() stay
on the UnitOfWork protocol for adapters and hand-driven use — the executor
never calls them directly.
What runs after commit¶
Post-commit actions — job dispatches queued through JobService and
on_transaction_committed hooks enqueued by the @transactional decorator,
and the cache invalidations a cached repository publishes — are enqueued on a
PostCommitChannel bound to the execution and drained only after the unit
of work has closed, outside its transaction, in enqueue order:
With a unit of work: actions run after
commit, never inside the transaction — a broker failure cannot roll back a write that already committed.Without a unit of work, or
read_only=True: the same actions run at the end of the execution — there is nothing to commit, but they still run once, not per repository call.On failure or cancellation: the owner discards its queue instead of running it — nothing partially dispatched leaks into a later execution sharing the same async context.
A failure during drain() does not replace the terminal event already
emitted: it surfaces as PostCommitError(failures=(...)) raised by
execute() after EXEC_DONE. Every action still runs — failures are
collected, not short-circuited.
PostCommitError.committed says whether the owner that drained had
committed a unit of work of its own:
committed=True— the execution owned a unit of work and it committed. A retry would repeat a write that already stands.committed=False— the execution owned no unit of work (read_only=True, nouow_factory), or the drain came from a hand-drivenflush_pending_dispatches()outside any execution. Nothing was written, so the whole operation is safe to retry.
Nesting¶
An execution owns the post-commit channel iff it owns the unit of work, or no channel is bound yet:
A nested call that joins the outer unit of work (or finds none active) enqueues on the outer channel, drained once at the outer execution’s end.
A nested call that opens its own unit of work — typically a read-only outer execution calling a writing inner one — owns and drains its own channel right after its own close. An outer failure afterward cannot discard the actions of an inner transaction that already committed.
An execution started from a post-commit action (inside
drain()) finds no channel bound and opens its own lifecycle.
Cancellation¶
Cancelling an execution mid-flight is a terminal outcome, not a special
case: the unit of work still closes exactly once. Adapters shield their own
driver I/O (session.rollback(), session close, end_session) with
asyncio.shield, so a cancellation arriving while closing lets the close
finish instead of cutting it off, and reset their ContextVar tokens in the
caller’s context — a shielded coroutine runs in a copied context, where
ContextVar.reset raises. The executor itself
never shields; only the adapters do, around their own I/O. EXEC_ERROR
carries error_kind="cancelled" for a cancelled execution.
Events¶
Event |
When |
Carries |
|---|---|---|
|
Before the unit of work opens (or before the pipeline, when none is configured) |
|
|
The only success terminal event — after commit, or after the pipeline when there is no unit of work |
|
|
The only failure terminal event |
|
error_kind names the step that failed: begin for __aenter__,
business for the pipeline, commit for the unit-of-work exit, cancelled
for a cancellation, and post_commit when the propagating exception is a
PostCommitError — an inner execution that owned its own unit of work
committed and then failed to drain, so the outer failure is not its own
business logic.
duration_ms measures start → terminal event; pipeline_ms measures the
pipeline stage alone; commit_ms measures the unit-of-work exit (None
when no unit of work was opened). A post-commit failure raises no new event
kind — it surfaces only as PostCommitError, after EXEC_DONE.
The transactional declaration¶
Every unit-of-work adapter declares transactional: ClassVar[bool]: True
when commit / rollback are real (SQLAlchemy, Mongo with
transactions: true), False for adapters whose writes autocommit
(DynamoDB, Mongo with transactions: false). The protocol is
runtime-checkable, so an adapter that omits the declaration no longer
satisfies isinstance(x, UnitOfWork). Nothing reads the flag yet: the
bootstrap check that refuses a use case requiring a transaction on a
non-transactional backend, and the cache rules keyed on it, arrive in a
later release; see Persistence backends.
PostCommitError in REST and Celery¶
PostCommitError carries committed, and both transports read it:
REST:
HttpErrorMappermaps it to500and copies the flag into the response body’sdetailascommitted—truemeans the write stands even though the request failed,falsemeans nothing was written.Celery: the worker does not retry a
committed=Truefailure (a retry would repeat the write) and retries acommitted=Falseone like any other failure — see Post-commit failures.
Hand-driven units of work¶
Constructing a unit of work directly (e.g. SQLAlchemyUnitOfWork) and
calling begin() / commit() / rollback() by hand keeps working — those
methods stay on the protocol. Closing is the context manager’s job
(__aexit__), not commit()’s: a hand-driven caller that never enters the
async with block leaves the session open.
Upgrade notes¶
Metrics timing changed:
EXEC_DONEnow fires after commit, not before — a consumer computing latency fromEXEC_DONE.duration_msalone now sees the commit included;pipeline_msandcommit_mssplit it back out.PostCommitErrorwhere a rollback used to be reported: a broker or cache-bump failure after a successful commit used to roll back an already-committed transaction; it now raisesPostCommitError(committed=True)instead — the write stands, the failure is reported separately. The same failure in an execution that owned no unit of work raisesPostCommitError(committed=False), which REST reports ascommitted: falseand the Celery worker retries.flush_pending_dispatches()now raisesPostCommitErrorinstead of letting an individual dispatch’s exception propagate directly, and raisesRuntimeErrorwhen called inside an execution: the executor owns the post-commit channel and drains it after the unit of work closes. Call it only outside an execution, asInlineJobServicedocuments.clear_pending_dispatches()discards the executor’s channel when one is bound, so a hand-driven caller cancelling dispatches inside an execution now really cancels them.UnitOfWorkis a runtime-checkable protocol with a data member:isinstance(x, UnitOfWork)isFalsefor an adapter that does not declaretransactional, andissubclass(X, UnitOfWork)raisesTypeError— Python cannot check a data member on a class. Third-party adapters must declaretransactional: ClassVar[bool]; code that usedissubclassmust switch toisinstanceon an instance.error_kind="commit"means the commit itself failed: the outcome is undetermined — the driver may have committed before the failure surfaced. Treat it as “unknown”, not as “rolled back”; the adapter still rolled back and closed on its side (SQLAlchemy) or ended the session (Mongo). A failure while closing after a successful commit is logged asUoWCloseFailedand does not fail the execution on either adapter: the write landed, so the terminal event isEXEC_DONEand the post-commit actions run.error_kind="post_commit"is new: an outer execution whose inner one committed and then failed to drain no longer reportsbusiness.Hand-driven
begin()/commit()callers must close through the context manager (async with uow:or an explicit__aexit__call) — callingcommit()alone no longer closes the session.
DSL quick-reference¶
Primitive |
Purpose |
|---|---|
|
Typed field reference |
|
Inject decoded request body as command |
|
Fetch entity by path/command param; 404 on missing |
|
Check existence; 404 on missing if |
|
Derive/normalise a command field |
|
Include path params in derive computation |
|
Validate a field; |
|
Enforce invariant; predicate returns |
|
Pass command fields to rule predicate |
|
Pass path parameter to rule predicate |
|
Skip rule/compute when |
|
AND predicate — both must be present |
|
OR predicate — either must be present |
|
Call another use case by type |
|
CRUD facade for a model entity |
|
Handle to a named agent, bound to the verified caller |
|
The three run modes — declared shape, per-run shape, open text |
|
The artefact’s own granted views, never re-declared |
|
Handle to a configured MCP server, reached directly, bound to the verified caller |