A policy that consults a query¶
This tutorial builds a library hold queue, and its centerpiece is the payoff of DIZZY's reactivity loop: a policy that runs a query to decide which command to dispatch. Patrons place holds on a checked-out book; when the book is returned, a policy consults the queue and notifies whoever is next in line.
return_book ─▶ process_return ─▶ book_returned
book_returned ─▶ notify_next_on_return ──(query get_next_hold)──▶ notify_next_patron
notify_next_patron ─▶ send_notification ─▶ patron_notified
A policy emits commands only, never events — and a query informs which command it dispatches, and with what arguments. That conditional, read-informed dispatch is the whole point.
New to DIZZY?
If you haven't yet, do the Build a guestbook tutorial first — it covers the generation pipeline (definitions → static → libraries) and the file conventions this one moves through more quickly.
Validated tutorial
Every command and file here is executed and checked by just tutorials-check (via
byexample). $ lines are commands; >
lines continue the one above. Don't copy the $/> markers.
Before you start¶
DIZZY installed, and the tutorial's assets in a fresh directory:
$ dizzy --help | head -n 1
$ ls -1
demo.py
edits
library.feat.yaml
Step 1 — Describe the feature¶
The feature-file declares three commands (one of them, notify_next_patron, is
dispatched by the policy rather than an end user), the events, the procedures, a read
model, a query, and — the star — the policy:
description: >-
Library hold queue — patrons place holds on checked-out books; when a book is
returned, a policy consults the hold queue and notifies the next patron in line.
# The star of this example: a POLICY that runs a QUERY to decide which COMMAND to
# dispatch. A policy emits commands only — never events — and a query informs
# *which* command it dispatches (and with what arguments). Here, when a book is
# returned, `notify_next_on_return` queries `get_next_hold` to find who is next
# in the queue, then dispatches `notify_next_patron`.
# Write intents
commands:
place_hold: A patron wants to reserve a checked-out book
return_book: A patron returns a borrowed book
# Dispatched by the policy, not by an end user:
notify_next_patron: Tell the next patron in line that their book is ready
# Immutable facts
events:
hold_placed: A patron's hold was recorded
book_returned: A book was returned to the library
patron_notified: The next patron was told their book is ready
# Command handlers (command -> procedure -> event)
procedures:
record_hold:
description: Validate and record a patron's hold on a book
command: place_hold
emits:
- hold_placed
process_return:
description: Record that a book has come back
command: return_book
emits:
- book_returned
send_notification:
description: Record that a patron was notified their held book is ready
command: notify_next_patron
emits:
- patron_notified
# Read-optimized schema (classes authored in def/models/holds.yaml)
models:
holds:
description: The hold queue — who is waiting for which book, in order
adapters:
- sqla
# Build read state: each placed hold is folded into the holds model
projections:
hold_store:
description: Persist each placed hold into the holds model
event: hold_placed
model: holds
adapter: sqla
# Read state back out
queries:
get_next_hold:
description: The oldest active hold for a given book, or none if the queue is empty
model: holds
adapter: sqla
# The reactivity loop's payoff: a policy that QUERIES to decide which command to
# dispatch. Reacts to book_returned, asks get_next_hold who is next, and — only
# if someone is waiting — dispatches notify_next_patron.
policies:
notify_next_on_return:
description: >-
When a book is returned, consult the hold queue and, if a patron is
waiting, dispatch a notification command for the next one in line.
event: book_returned
queries:
- get_next_hold
emits:
- notify_next_patron
Note the policies: block: notify_next_on_return reacts to book_returned, declares
it queries: [get_next_hold], and emits: [notify_next_patron]. Declaring the query is
what lets the policy read state to make its decision.
Step 2 — Scaffold and fill the schemas¶
Scaffold the LinkML schemas and runtime config:
$ dizzy generate definitions library.feat.yaml .
Generated def/ stubs and libconfig.yaml. Next steps:
<...>
As in guestbook, the scaffolds leave attributes: {} for you. Give the commands and
events their fields:
--- a/def/commands.yaml
+++ b/def/commands.yaml
@@ -8,10 +8,15 @@ imports:
classes:
place_hold:
description: A patron wants to reserve a checked-out book
- attributes: {}
+ attributes:
+ book_id: { range: string, required: true }
+ patron: { range: string, required: true }
return_book:
description: A patron returns a borrowed book
- attributes: {}
+ attributes:
+ book_id: { range: string, required: true }
notify_next_patron:
description: Tell the next patron in line that their book is ready
- attributes: {}
+ attributes:
+ book_id: { range: string, required: true }
+ patron: { range: string, required: true }
--- a/def/events.yaml
+++ b/def/events.yaml
@@ -8,10 +8,16 @@ imports:
classes:
hold_placed:
description: A patron's hold was recorded
- attributes: {}
+ attributes:
+ book_id: { range: string, required: true }
+ patron: { range: string, required: true }
+ placed_at: { range: datetime, required: true }
book_returned:
description: A book was returned to the library
- attributes: {}
+ attributes:
+ book_id: { range: string, required: true }
patron_notified:
description: The next patron was told their book is ready
- attributes: {}
+ attributes:
+ book_id: { range: string, required: true }
+ patron: { range: string, required: true }
$ git apply edits/commands.yaml.diff edits/events.yaml.diff
The read model is the hold queue — who is waiting for which book, and when:
--- a/def/models/holds.yaml
+++ b/def/models/holds.yaml
@@ -6,4 +6,20 @@ prefixes:
default_range: string
imports:
- linkml:types
-classes: {}
+classes:
+ Hold:
+ description: A single active hold in the queue
+ attributes:
+ id:
+ range: string
+ required: true
+ identifier: true
+ book_id:
+ range: string
+ required: true
+ patron:
+ range: string
+ required: true
+ placed_at:
+ range: datetime
+ required: true
The query is what the policy will consult, so its shape matters: it takes a book_id
and returns the next patron — or nothing when the queue is empty (note required: false
on the output):
--- a/def/queries/get_next_hold.yaml
+++ b/def/queries/get_next_hold.yaml
@@ -8,8 +8,17 @@ imports:
- linkml:types
classes:
GetNextHoldInput:
- description: Input for get_next_hold
- attributes: {}
+ description: Input for get_next_hold — which book's queue to inspect
+ attributes:
+ book_id:
+ range: string
+ required: true
GetNextHoldOutput:
- description: Output for get_next_hold
- attributes: {}
+ description: >-
+ Output for get_next_hold — the next patron in line, or empty fields when
+ no one is waiting for this book
+ attributes:
+ patron:
+ range: string
+ required: false
+ description: The next patron waiting, or null if the queue is empty
$ git apply edits/holds.yaml.diff edits/get_next_hold.yaml.diff
Step 3 — Compile and package¶
Compile the type packages, then split each element into its own runtime package:
$ dizzy generate static library.feat.yaml .
<...>
$ dizzy generate libraries library.feat.yaml .
<...>
$ ls -1 lib/python-uv
gen_def
gen_int
policy
procedure
projection
pyproject.toml
query
There's a package per element now — three procedures, the projection, the query, and the
policy. Each src/<name>.py is a stub raising NotImplementedError.
Step 4 — Implement the elements¶
Most elements are the familiar shapes from guestbook. The procedures turn commands into events:
--- a/lib/python-uv/procedure/record_hold/src/record_hold.py
+++ b/lib/python-uv/procedure/record_hold/src/record_hold.py
@@ -1,11 +1,25 @@
-# Implementation stub — fill in your logic here
-from gen_int.python.procedure.record_hold_protocol import record_hold_protocol
+# Implementation — turn a PlaceHold command into a HoldPlaced fact.
+from datetime import datetime, timezone
+
from gen_int.python.procedure.record_hold_context import record_hold_context
from gen_def.pydantic.commands import PlaceHold
+from gen_def.pydantic.events import HoldPlaced
def record_hold(
context: record_hold_context,
command: PlaceHold,
) -> None:
- raise NotImplementedError
+ # Business rule: a hold needs both a book and a patron.
+ if not command.book_id.strip() or not command.patron.strip():
+ raise ValueError("a hold requires both book_id and patron")
+
+ # Stamp time at emit time — the event is the immutable record of the hold,
+ # and placed_at is what get_next_hold later orders the queue by.
+ context.emit.hold_placed(
+ HoldPlaced(
+ book_id=command.book_id.strip(),
+ patron=command.patron.strip(),
+ placed_at=datetime.now(timezone.utc),
+ )
+ )
--- a/lib/python-uv/procedure/process_return/src/process_return.py
+++ b/lib/python-uv/procedure/process_return/src/process_return.py
@@ -1,11 +1,18 @@
-# Implementation stub — fill in your logic here
-from gen_int.python.procedure.process_return_protocol import process_return_protocol
+# Implementation — turn a ReturnBook command into a BookReturned fact.
from gen_int.python.procedure.process_return_context import process_return_context
from gen_def.pydantic.commands import ReturnBook
+from gen_def.pydantic.events import BookReturned
def process_return(
context: process_return_context,
command: ReturnBook,
) -> None:
- raise NotImplementedError
+ if not command.book_id.strip():
+ raise ValueError("return_book requires a book_id")
+
+ # The procedure just records the fact. Deciding who to notify next is a
+ # *reaction* to this fact, and lives in the notify_next_on_return policy.
+ context.emit.book_returned(
+ BookReturned(book_id=command.book_id.strip())
+ )
--- a/lib/python-uv/procedure/send_notification/src/send_notification.py
+++ b/lib/python-uv/procedure/send_notification/src/send_notification.py
@@ -1,11 +1,17 @@
-# Implementation stub — fill in your logic here
-from gen_int.python.procedure.send_notification_protocol import send_notification_protocol
+# Implementation — turn a NotifyNextPatron command into a PatronNotified fact.
from gen_int.python.procedure.send_notification_context import send_notification_context
from gen_def.pydantic.commands import NotifyNextPatron
+from gen_def.pydantic.events import PatronNotified
def send_notification(
context: send_notification_context,
command: NotifyNextPatron,
) -> None:
- raise NotImplementedError
+ # A real system would send an email/SMS here; we just record that it happened.
+ context.emit.patron_notified(
+ PatronNotified(
+ book_id=command.book_id,
+ patron=command.patron,
+ )
+ )
The projection folds each placed hold into the read model, and the query reads the oldest active hold for a book back out:
--- a/lib/python-uv/projection/hold_store/src/hold_store.py
+++ b/lib/python-uv/projection/hold_store/src/hold_store.py
@@ -1,11 +1,24 @@
-# Implementation stub — fill in your logic here
-from gen_int.python.projection.hold_store_projection import hold_store_projection
+# Implementation — fold each HoldPlaced event into the holds read model.
+from uuid import uuid4
+
from gen_int.python.projection.hold_store_projection import hold_store_context
from gen_def.pydantic.events import HoldPlaced
+from gen_def.sqla.models.holds import Hold
def hold_store(
event: HoldPlaced,
context: hold_store_context,
) -> None:
- raise NotImplementedError
+ # context.adapter is a SqlaAdapter — it hands us a live SQLAlchemy session.
+ # Each placed hold becomes a row; get_next_hold reads these back, ordered by
+ # placed_at, to find who is first in line.
+ context.adapter.session.add(
+ Hold(
+ id=str(uuid4()),
+ book_id=event.book_id,
+ patron=event.patron,
+ placed_at=event.placed_at,
+ )
+ )
+ context.adapter.session.commit()
--- a/lib/python-uv/query/get_next_hold/src/get_next_hold.py
+++ b/lib/python-uv/query/get_next_hold/src/get_next_hold.py
@@ -1,7 +1,20 @@
-# Implementation stub — fill in your logic here
-from gen_int.python.query.get_next_hold import get_next_hold_query, get_next_hold_context
+# Implementation — read the holds model for the next patron waiting on a book.
+from gen_int.python.query.get_next_hold import get_next_hold_context
from gen_def.pydantic.query.get_next_hold import GetNextHoldInput, GetNextHoldOutput
+from gen_def.sqla.models.holds import Hold
-def get_next_hold(input: GetNextHoldInput, context: get_next_hold_context) -> GetNextHoldOutput:
- raise NotImplementedError
+def get_next_hold(
+ input: GetNextHoldInput,
+ context: get_next_hold_context,
+) -> GetNextHoldOutput:
+ # The oldest hold for this book is first in line (FIFO by placed_at).
+ row = (
+ context.adapter.session.query(Hold)
+ .filter(Hold.book_id == input.book_id)
+ .order_by(Hold.placed_at.asc())
+ .first()
+ )
+ # When the queue is empty, patron is None — the policy reads that as
+ # "no one waiting" and dispatches nothing.
+ return GetNextHoldOutput(patron=row.patron if row is not None else None)
And the policy — the centerpiece. It queries the hold queue and, only if someone is
waiting, dispatches the notify command. A policy emits commands, never events; the
downstream notify_next_patron → send_notification → patron_notified chain is what
records the fact:
--- a/lib/python-uv/policy/notify_next_on_return/src/notify_next_on_return.py
+++ b/lib/python-uv/policy/notify_next_on_return/src/notify_next_on_return.py
@@ -1,11 +1,31 @@
-# Implementation stub — fill in your logic here
-from gen_int.python.policy.notify_next_on_return_protocol import notify_next_on_return_protocol
+# Implementation — the centerpiece of this example: a policy that QUERIES to
+# decide which COMMAND to dispatch.
+#
+# A policy emits commands only (never events). The query informs *which* command
+# it dispatches and with what arguments — here, who to notify. The downstream
+# command -> procedure -> event chain (notify_next_patron -> send_notification ->
+# patron_notified) is what actually records the fact.
from gen_int.python.policy.notify_next_on_return_context import notify_next_on_return_context
from gen_def.pydantic.events import BookReturned
+from gen_def.pydantic.commands import NotifyNextPatron
+from gen_def.pydantic.query.get_next_hold import GetNextHoldInput
def notify_next_on_return(
event: BookReturned,
context: notify_next_on_return_context,
) -> None:
- raise NotImplementedError
+ # Ask the read model who, if anyone, is next in line for this book.
+ result = context.query.get_next_hold(
+ GetNextHoldInput(book_id=event.book_id),
+ # context for the query is wired by the host; see demo.py.
+ )
+
+ # No one waiting -> the policy dispatches nothing. This conditional dispatch
+ # is exactly why policies need queries: the decision lives in read state.
+ if result.patron is None:
+ return
+
+ context.emit.notify_next_patron(
+ NotifyNextPatron(book_id=event.book_id, patron=result.patron)
+ )
Apply all six:
$ git apply edits/record_hold.py.diff edits/process_return.py.diff edits/send_notification.py.diff edits/hold_store.py.diff edits/get_next_hold.py.diff edits/notify_next_on_return.py.diff
Step 5 — Wire it up and run¶
The host's demo.py owns persistence and the routing. The interesting part is how it
binds the query into the policy's context — a closure over the read adapter — so the
policy calls context.query.get_next_hold(...) exactly the way it calls an emitter:
"""Wire the generated library hold-queue feature together and run it end to end.
This is the glue a host application writes by hand: it owns the database, the
event/command routing, and the wiring. Dizzy generates the typed pieces; this
file connects them.
The point of this example is the POLICY that runs a QUERY to decide which COMMAND
to dispatch:
BookReturned ─▶ notify_next_on_return ──(query get_next_hold)──▶ NotifyNextPatron
A policy emits commands only — never events. The query informs *which* command it
dispatches (and with what arguments). The host below binds each query into a
closure over the read adapter, so the policy calls it with just the query input,
exactly the way it calls an emitter.
Run inside the generated workspace environment:
uv sync --project lib/python-uv
uv run --project lib/python-uv python demo.py
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from gen_def.pydantic.commands import PlaceHold, ReturnBook, NotifyNextPatron
from gen_def.pydantic.events import HoldPlaced, BookReturned, PatronNotified
from gen_def.pydantic.query.get_next_hold import GetNextHoldInput
from gen_def.sqla.models.holds import Base
from gen_int.python.adapters.sqla import SqlaAdapter
from gen_int.python.procedure.record_hold_context import (
record_hold_context,
record_hold_emitters,
)
from gen_int.python.procedure.process_return_context import (
process_return_context,
process_return_emitters,
)
from gen_int.python.procedure.send_notification_context import (
send_notification_context,
send_notification_emitters,
)
from gen_int.python.projection.hold_store_projection import hold_store_context
from gen_int.python.policy.notify_next_on_return_context import (
notify_next_on_return_context,
notify_next_on_return_emitters,
notify_next_on_return_queries,
)
from gen_int.python.query.get_next_hold import get_next_hold_context
# Each element is its own installed package, exposing a top-level module.
from record_hold import record_hold
from process_return import process_return
from send_notification import send_notification
from hold_store import hold_store
from get_next_hold import get_next_hold
from notify_next_on_return import notify_next_on_return
def main() -> None:
# The host owns persistence. Here: an in-memory SQLite database.
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
session = Session(engine)
adapter = SqlaAdapter(session=session)
# --- The query, bound to the read adapter ---
# The host owns the adapter, so it binds it here. Handlers receive a closure
# that takes only the query input — symmetric with how they receive emitters.
def query_get_next_hold(input: GetNextHoldInput):
return get_next_hold(input, get_next_hold_context(adapter=adapter))
# --- Event handlers (the host's reaction routing) ---
# patron_notified is the terminal fact in this demo; the host just reports it.
def on_patron_notified(event: PatronNotified) -> None:
print(f" -> notified {event.patron} that '{event.book_id}' is ready")
# The notify_next_patron command is dispatched by the policy. The host routes
# it to its procedure, which emits patron_notified.
def dispatch_notify_next_patron(command: NotifyNextPatron) -> None:
send_notification(
send_notification_context(
emit=send_notification_emitters(patron_notified=on_patron_notified)
),
command,
)
# book_returned triggers the policy. The policy queries the hold queue and,
# if someone is waiting, dispatches notify_next_patron (routed above).
def on_book_returned(event: BookReturned) -> None:
notify_next_on_return(
event,
notify_next_on_return_context(
emit=notify_next_on_return_emitters(
notify_next_patron=dispatch_notify_next_patron
),
query=notify_next_on_return_queries(get_next_hold=query_get_next_hold),
),
)
# hold_placed is folded into the read model by the projection.
def on_hold_placed(event: HoldPlaced) -> None:
hold_store(event, hold_store_context(adapter=adapter))
# --- Procedure contexts (command -> procedure -> event) ---
record_hold_ctx = record_hold_context(
emit=record_hold_emitters(hold_placed=on_hold_placed)
)
process_return_ctx = process_return_context(
emit=process_return_emitters(book_returned=on_book_returned)
)
# --- Run a scenario ---
# Two patrons place holds on the same book (Ada first, then Grace).
print("Holds placed:")
record_hold(record_hold_ctx, PlaceHold(book_id="dune", patron="Ada"))
print(" - Ada holds 'dune'")
record_hold(record_hold_ctx, PlaceHold(book_id="dune", patron="Grace"))
print(" - Grace holds 'dune'")
# The book comes back. The policy should consult the queue and notify Ada
# (the oldest hold) — not Grace.
print("\nReturning 'dune':")
process_return(process_return_ctx, ReturnBook(book_id="dune"))
# A book with no holds: the policy queries, finds nobody, dispatches nothing.
print("\nReturning 'gardens-of-the-moon' (no holds):")
process_return(process_return_ctx, ReturnBook(book_id="gardens-of-the-moon"))
print(" -> no one waiting; nothing dispatched")
if __name__ == "__main__":
main()
Sync the workspace and run it:
$ uv sync --project lib/python-uv
<...>
$ uv run --project lib/python-uv python demo.py
Holds placed:
- Ada holds 'dune'
- Grace holds 'dune'
<...>
Returning 'dune':
-> notified Ada that 'dune' is ready
<...>
Returning 'gardens-of-the-moon' (no holds):
-> no one waiting; nothing dispatched
Two patrons hold dune; when it's returned the policy queries the queue and notifies
Ada (the oldest hold), not Grace. When a book with no holds comes back, the policy
queries, finds nobody, and dispatches nothing — the conditional dispatch that makes
policies-with-queries the expressive heart of the reactivity loop.
For another worked feature — a multi-step, policy-driven cascade — see
examples/recipes.