Skip to content

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:

library.feat.yaml
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:

demo.py
"""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.