Dizzy Feature File Specification¶
Overview¶
A .feat.yaml file is the primary authoring surface in Dizzy. It describes a single feature's
domain model and processing logic at a high level of abstraction — without specifying databases,
frameworks, or infrastructure.
The generator pipeline reads a .feat.yaml and produces:
- LinkML schema files (def/) for each section
- Generated Python models and interfaces (gen_def/, gen_int/)
Top-Level Structure¶
description: <string> # Human-readable description of the feature (optional)
models: <map> # Domain value objects / data shapes
queries: <map> # Read interfaces (input + output)
commands: <map> # Write intents
events: <map> # Immutable facts (what happened)
procedures: <map> # Command handlers (do work, emit events)
policies: <map> # Event handlers (react, issue commands)
projections: <map> # Read-model builders (event → queryable state)
environment: <map> # Injected constants/variables (in place of os env)
telemetry: <map> # Injected observation sinks (callables)
All sections are optional. The generator skips sections not present.
Section Definitions¶
models¶
Named database schemas — each entry represents a logical grouping of related classes (tables)
for a single database. The feat file declares schema names and optional descriptions only.
The actual classes are defined in the corresponding def/models/<schema_name>.yaml LinkML file,
which is authored separately and may grow over time without touching the feat file.
The generator creates a stub def/models/<schema_name>.yaml if one does not already exist, then
generates one output file per schema per target backend. Use plural, lowercase names.
models:
recipes: Full recipe database — recipes, steps, and ingredients
def/models/recipes.yaml (hand-authored) then defines all classes in that schema:
classes:
Recipe:
attributes:
title: ...
Step:
attributes:
body: ...
Ingredient:
attributes:
name: ...
quantity: ...
Scaffold generates (stub, never overwritten):
- def/models/<schema_name>.yaml — stub LinkML schema
Gen generates (by running the LinkML toolchain on the authored stub):
- gen_def/pydantic/models/<schema_name>.py — Pydantic models (via linkml gen-pydantic)
- gen_def/sqla/models/<schema_name>.py — SQLAlchemy models (via linkml gen-sqla)
queries¶
Named read operations. Each query must declare the single model schema it reads from, but IO types are not specified in the feat file — those are defined in authored LinkML stubs and fleshed out when the implementation is written.
Each query decomposes into three composable elements:
QueryInput— a LinkML-defined data shape for the query's input parametersQueryOutput— a LinkML-defined data shape for the query's return valueQueryProcess— a Protocol for the callable that accepts aQueryInputand a context (holding a SQLAlchemy session for the referenced model schema) and returns aQueryOutput
queries:
get_recipe_text:
description: Retrieves raw recipe text given a source reference
model: recipes
get_recipe:
description: Retrieves a structured recipe by ID
model: recipes
Fields:
- description (required): what this query does
- model (required): the schema name from models that this query reads from
Scaffold generates (stub, never overwritten):
- def/queries/<query_name>.yaml — single LinkML stub containing both <QueryName>Input and <QueryName>Output class stubs
For example, def/queries/get_recipe_text.yaml:
id: https://example.org/queries/get_recipe_text
name: get_recipe_text
description: Retrieves raw recipe text given a source reference
prefixes:
linkml: https://w3id.org/linkml/
default_range: string
imports:
- linkml:types
classes:
GetRecipeTextInput:
description: Input for get_recipe_text
attributes: {}
GetRecipeTextOutput:
description: Output for get_recipe_text
attributes: {}
Gen generates (by running linkml gen-pydantic on the authored def stub, then deriving the Protocol from the feat file):
- gen_def/pydantic/query/<query_name>.py — Pydantic models for both <QueryName>Input and <QueryName>Output (via linkml)
- gen_int/python/query/<query_name>.py — QueryProcess Protocol + context dataclass:
# AUTO-GENERATED — do not edit
from dataclasses import dataclass
from typing import Protocol, Any
from gen_def.pydantic.query.get_recipe_text import GetRecipeTextInput, GetRecipeTextOutput
@dataclass
class get_recipe_text_context:
"""SQLAlchemy session for the schema read by this query."""
recipes: Any # SQLAlchemy session for the recipes schema
class get_recipe_text_query(Protocol):
"""Retrieves raw recipe text given a source reference"""
def __call__(
self, input: GetRecipeTextInput, context: get_recipe_text_context
) -> GetRecipeTextOutput:
...
Queries declared in a procedure's queries: list are injected into that procedure's
_queries context dataclass as typed fields:
@dataclass
class extract_and_transform_recipe_queries:
get_recipe_text: Callable[[GetRecipeTextInput], GetRecipeTextOutput]
Each query field is a host-bound callable — the host injects the read adapter and
supplies a closure that takes only the query input and returns its output (symmetric
with how emit fields are bound). The handler calls context.query.get_recipe_text(input)
without needing the query's own adapter context.
commands¶
Named write intents. Value is either a short description string, or a map with description and optional attributes.
commands:
ingest_recipe_text: Initiates ingestion of a recipe from a raw text source
upload_blob_using_manifest:
description: Uploads a blob using manifest information
attributes:
manifest_id:
type: string
required: true
Scaffold generates (stub, never overwritten):
- def/commands.yaml — LinkML stub listing all commands with attributes
Gen generates (by running linkml gen-pydantic on the authored stub):
- gen_def/pydantic/commands.py — Pydantic models for all commands
events¶
Immutable domain facts. Value is either a description string, or a map with description and optional attributes.
events:
recipe_ingested: A recipe was successfully ingested and validated
scan_item_found:
description: Found a file while scanning
attributes:
file_path:
type: string
required: true
file_hash:
type: string
Scaffold generates (stub, never overwritten):
- def/events.yaml — LinkML stub listing all events with attributes
Gen generates (by running linkml gen-pydantic on the authored stub):
- gen_def/pydantic/events.py — Pydantic models for all events
procedures¶
Command handlers. Each procedure is bound to one command, declares queries it uses, and events it may emit.
procedures:
extract_and_transform_recipe:
description: >
Queries raw recipe text via source_ref, then uses an LLM to extract a structured
recipe, validated against the recipe model schema.
command: ingest_recipe_text
queries:
- get_recipe_text
emits:
- recipe_ingested
Fields:
- command (required): the command this procedure handles
- queries (optional): list of query names this procedure needs access to
- emits (optional): list of event names this procedure may emit
- environment (optional): list of environment entry names injected as context.env.<name>
- telemetry (optional): list of telemetry sink names callable as context.telemetry.<name>(payload)
Gen generates:
- gen_int/python/procedure/<procedure_name>_context.py — context dataclass with _emitters and _queries nested dataclasses:
# AUTO-GENERATED — do not edit
from dataclasses import dataclass
from typing import Callable
from gen_def.pydantic.events import recipe_ingested
from gen_def.pydantic.query.get_recipe_text import GetRecipeTextInput, GetRecipeTextOutput
@dataclass
class extract_and_transform_recipe_emitters:
recipe_ingested: Callable[[recipe_ingested], None]
@dataclass
class extract_and_transform_recipe_queries:
get_recipe_text: Callable[[GetRecipeTextInput], GetRecipeTextOutput]
@dataclass
class extract_and_transform_recipe_context:
emit: extract_and_transform_recipe_emitters
query: extract_and_transform_recipe_queries
gen_int/python/procedure/<procedure_name>_protocol.py— Protocol stub:
# AUTO-GENERATED — do not edit
from typing import Protocol
from gen_def.pydantic.commands import ingest_recipe_text
from gen_int.python.procedure.extract_and_transform_recipe_context import (
extract_and_transform_recipe_context,
)
class extract_and_transform_recipe_protocol(Protocol):
"""Queries raw recipe text via source_ref, then uses an LLM to extract a structured recipe."""
def __call__(
self,
context: extract_and_transform_recipe_context,
command: ingest_recipe_text,
) -> None:
...
src/procedure/<procedure_name>.py— empty implementation stub (skipped if already exists)
policies¶
Event-driven reaction handlers. Each policy listens to one event, may declare queries it consults, and dispatches commands only (never events). A query informs which command a policy dispatches, and with what arguments — the decision lives in read state, not in the policy's hard-coded logic. To change state, a policy emits a command, which flows through the normal command → procedure → event chain.
policies:
trigger_priority_manifest:
description: Issues command to create image priority manifest when scan completes
event: scan_complete
queries:
- get_pending_scan_count
emits:
- create_image_priority_manifest
Fields:
- event (required): the event that triggers this policy
- queries (optional): list of query names this policy consults to decide what to dispatch
- emits (optional): list of command names this policy may dispatch
- environment (optional): list of environment entry names injected as context.env.<name>
- telemetry (optional): list of telemetry sink names callable as context.telemetry.<name>(payload)
Gen generates:
- gen_int/python/policy/<policy_name>_context.py — context dataclass with emitters and (when declared) queries nested dataclasses (mirrors procedure context):
# AUTO-GENERATED — do not edit
from dataclasses import dataclass
from typing import Callable
from gen_def.pydantic.commands import create_image_priority_manifest
from gen_def.pydantic.query.get_pending_scan_count import (
GetPendingScanCountInput,
GetPendingScanCountOutput,
)
@dataclass
class trigger_priority_manifest_emitters:
create_image_priority_manifest: Callable[[create_image_priority_manifest], None]
@dataclass
class trigger_priority_manifest_queries:
get_pending_scan_count: Callable[[GetPendingScanCountInput], GetPendingScanCountOutput]
@dataclass
class trigger_priority_manifest_context:
emit: trigger_priority_manifest_emitters
query: trigger_priority_manifest_queries
For policies with no emits, the emitters dataclass has pass. The query field and
its _queries dataclass appear only when the policy declares queries. As with
procedures, each query field is a host-bound Callable[[Input], Output] closure.
gen_int/python/policy/<policy_name>_protocol.py— Protocol stub:
# AUTO-GENERATED — do not edit
from typing import Protocol
from gen_def.pydantic.events import scan_complete
from gen_int.python.policy.trigger_priority_manifest_context import (
trigger_priority_manifest_context,
)
class trigger_priority_manifest_protocol(Protocol):
"""Issues command to create image priority manifest when scan completes"""
def __call__(
self, event: scan_complete, context: trigger_priority_manifest_context
) -> None:
...
src/policy/<policy_name>.py— implementation stub (skipped if already exists)
projections¶
Build queryable read models in response to a single event. Each projection listens to exactly one event and may update one or more model schemas.
A projection is structurally similar to a procedure: it receives an event and a context object, then uses SQLAlchemy to persist state into the referenced model schemas. One SQLAlchemy session is injected per declared model schema.
projections:
recipe_library:
description: Adds ingested recipe to the recipe library
event: recipe_ingested
models:
- recipes
Fields:
- description (required): what this projection does
- event (required): the single event that triggers this projection
- models (required): list of schema names from models that this projection writes into
- environment (optional): list of environment entry names injected as context.env.<name>
- telemetry (optional): list of telemetry sink names callable as context.telemetry.<name>(payload)
Gen generates: gen_int/python/projection/<projection_name>_projection.py — a context dataclass
and a Protocol stub, plus src/projection/<projection_name>.py (skipped if already exists):
# AUTO-GENERATED — do not edit
from dataclasses import dataclass
from typing import Protocol, Any
from gen_def.pydantic.events import recipe_ingested
@dataclass
class recipe_library_context:
"""SQLAlchemy sessions for schemas written by this projection."""
recipes: Any # SQLAlchemy session for the recipes schema
class recipe_library_projection(Protocol):
"""Adds ingested recipe to the recipe library"""
def __call__(self, event: recipe_ingested, context: recipe_library_context) -> None:
"""Apply the projection — mutate model state in response to the event."""
...
The Any session type is a placeholder; the implementor binds it to a concrete SQLAlchemy
Session when wiring up the projection.
environment¶
Named injected constants/variables, acquired from the host in place of reading os.environ.
The feat file declares names and descriptions only; each entry's shape is authored in
def/environment.yaml (one LinkML class per entry). A function references entries via its
environment: list, and the value surfaces as context.env.<name>.
environment:
model: The LLM model configuration injected in place of an os env var.
Gen generates: gen_def/pydantic/environment.py (compiled from def/environment.yaml),
and — for any function that lists the entry — a <name>_env dataclass plus an env field on
that function's context.
telemetry¶
Named host-injected observation sinks. Each entry is a callable the function invokes with a
typed payload — the emitters pattern, but for observation (streamed tokens, progress, metrics)
rather than durable facts. A telemetry call is a transport concern and is never recorded as
an event. The payload shape is authored in def/telemetry.yaml (one LinkML class per entry).
A function references entries via its telemetry: list, surfacing as
context.telemetry.<name>(payload).
telemetry:
stream_chunk: Sink for live LLM token chunks forwarded to the SSE transport.
Gen generates: gen_def/pydantic/telemetry.py (compiled from def/telemetry.yaml), and —
for any function that lists the entry — a <name>_telemetry dataclass of
Callable[[Payload], None] sinks plus a telemetry field on that function's context.
Full Example¶
description: Recipe App
models:
recipes: Full recipe database — recipes, steps, and ingredients
queries:
get_recipe_text:
description: Retrieves raw recipe text given a source reference
model: recipes
get_recipe:
description: Retrieves a structured recipe by ID
model: recipes
commands:
ingest_recipe_text: Initiates ingestion of a recipe from a raw text source
events:
recipe_ingested:
description: A recipe was successfully extracted and validated
attributes:
recipe_id:
type: string
required: true
source_ref:
type: string
required: true
procedures:
extract_and_transform_recipe:
description: >
Queries raw recipe text via source_ref, then uses an LLM to extract a structured
recipe (title, ingredients, steps, time, cost), validated against the recipe model.
command: ingest_recipe_text
queries:
- get_recipe_text
emits:
- recipe_ingested
policies:
index_recipe_on_ingest:
description: Adds recipe to the library projection when ingested
event: recipe_ingested
projections:
recipe_library:
description: Adds ingested recipe to the recipe library
event: recipe_ingested
models:
- recipes
Generator Output Layout¶
Given a feature at app/my_feature/my_feature.feat.yaml, the generator produces:
app/my_feature/
def/
models/
recipes.yaml
queries/
get_recipe_text.yaml
get_recipe.yaml
commands.yaml
events.yaml
gen_def/
pydantic/
models/
recipes.py
query/
get_recipe_text.py
get_recipe.py
commands.py
events.py
sqla/
models/
recipes.py
gen_int/
python/
query/
get_recipe_text.py
get_recipe.py
procedure/
extract_and_transform_recipe_context.py
extract_and_transform_recipe_protocol.py
policy/
index_recipe_on_ingest_protocol.py
projection/
recipe_library_projection.py
src/
query/
get_recipe_text.py
get_recipe.py
procedure/
extract_and_transform_recipe.py
policy/
index_recipe_on_ingest.py
projection/
recipe_library.py
def: definitions gen_def: generated definitions gen_int: generated interfaces
dizzy gen also emits an empty __init__.py in every generated directory so that the output
tree is a valid Python package and root-relative imports resolve correctly.
Sections with no content in the feat file produce no output.
Import Path Convention¶
All generated files use relative Python imports. The intent is that the entire output directory is portable — it can be copied or symlinked into any project structure without changing import paths.
Generated files assume the feature output directory is a Python package root (i.e. there is
an __init__.py at each level). Imports between generated layers use dot-notation relative
to that root:
| From | Importing | Import |
|---|---|---|
gen_int/python/query/ |
Query input/output models | from gen_def.pydantic.query.<name> import <Name>Input, <Name>Output |
gen_int/python/procedure/ |
Pydantic events | from gen_def.pydantic.events import ... |
gen_int/python/procedure/ |
Pydantic commands | from gen_def.pydantic.commands import ... |
gen_int/python/procedure/ |
Query input/output models | from gen_def.pydantic.query.<name> import <Name>Input, <Name>Output |
gen_int/python/policy/ |
Pydantic events | from gen_def.pydantic.events import ... |
gen_int/python/policy/ |
Pydantic commands | from gen_def.pydantic.commands import ... |
gen_int/python/policy/ |
Query input/output models | from gen_def.pydantic.query.<name> import <Name>Input, <Name>Output |
gen_int/python/projection/ |
Pydantic events | from gen_def.pydantic.events import ... |
src/query/ |
Query Protocol + context | from gen_int.python.query.<name> import ... |
src/procedure/ |
Procedure Protocol + context | from gen_int.python.procedure.<name>_protocol import ... |
src/policy/ |
Policy Protocol | from gen_int.python.policy.<name>_protocol import ... |
src/projection/ |
Projection Protocol + context | from gen_int.python.projection.<name>_projection import ... |
The feature output directory must be on sys.path (or be a package reachable from it) for
these imports to resolve at runtime.
CLI Workflow¶
Dizzy is a two-step generator. The split exists because def/ files require human authorship
between the two steps — they cannot be fully derived from the feat file alone.
Step 1 — Author the feat file¶
Write my_feature.feat.yaml by hand. Declare models, commands, events, procedures, policies,
and projections at the intent level. No types, no schemas, no implementation details yet.
Step 2 — Scaffold definition stubs¶
dizzy scaffold <feat_file> <output_dir>
Reads the feat file and generates empty def/ stub files for everything that requires
human schema authorship before code can be generated:
def/models/<schema_name>.yaml— stub LinkML schema per model (skipped if already exists)def/queries/<query_name>.yaml— stub LinkML schema with<QueryName>Inputand<QueryName>Outputclass stubs (skipped if already exists)def/commands.yaml— stub LinkML schema listing all commands (skipped if already exists)def/events.yaml— stub LinkML schema listing all events (skipped if already exists)
After running, Dizzy prints:
Scaffolded def/ stubs. Next steps:
1. Fill in class definitions in def/models/*.yaml
2. Add input/output shapes in def/queries/*.yaml
3. Add attributes to def/commands.yaml and def/events.yaml
4. Run: dizzy gen <feat_file> <output_dir>
Step 3 — Author the definition files¶
Edit the generated def/ stubs:
- Add classes, attributes, and relationships to each model schema
- Add typed attributes to commands and events as needed
These files are yours — Dizzy will never overwrite them.
Step 4 — Generate interfaces and source stubs¶
dizzy gen <feat_file> <output_dir>
Reads both the feat file and the authored def/ files, then generates:
gen_def/ — produced by running linkml gen-pydantic (and linkml gen-sqla for models)
on the authored def/ schemas
gen_int/ — Protocol stubs derived from the feat file structure (queries, procedures,
policies, projections), using the gen_def/ types as references in imports
src/ — implementation stubs, one per interface, for the developer to fill in. Each stub imports its Protocol and raises NotImplementedError:
src/
query/
<query_name>.py
procedure/
<procedure_name>.py
policy/
<policy_name>.py
projection/
<projection_name>.py
Source stubs are only created if the file does not already exist, so they are safe to re-run after editing.
After running, Dizzy prints a per-section summary and:
Generated interfaces and source stubs. Next steps:
Implement the src/ files to complete your feature.
Summary¶
| Step | Command | You do next |
|---|---|---|
| 1 | — | Write my_feature.feat.yaml |
| 2 | dizzy scaffold |
Edit def/ schema stubs |
| 3 | — | Author class definitions and attributes |
| 4 | dizzy gen |
Implement src/ stubs |
Testing Strategy¶
Architecture: split render from write¶
Every generator module exposes two layers:
-
render_*(feat, ...) -> str— pure function, takes parsed feat data and returns the file content as a string. No filesystem access. Fully unit-testable in isolation. -
write_*(feat, output_dir, ...)— thin wrapper that callsrender_*and writes the result to the correct path underoutput_dir. This layer is covered by integration tests.
This split means the majority of tests never touch the filesystem and run fast.
Unit tests — render functions¶
Each render_* function gets direct unit tests that assert on the returned string. Tests live
in dizzy/tests/generators/test_<section>.py. A representative feat fixture is defined once
in dizzy/tests/conftest.py and shared across all generator tests.
def test_render_procedure_context(recipe_feat):
result = render_procedure_context("extract_and_transform_recipe", recipe_feat)
assert "class extract_and_transform_recipe_context" in result
assert "get_recipe_text: get_recipe_text_query" in result
CLI tests — typer commands as plain functions¶
Typer commands are plain Python functions. Tests call them directly without subprocess or
CliRunner. The tmp_path pytest fixture provides the output directory.
from dizzy.cli import scaffold
def test_scaffold_creates_def_stubs(tmp_path: Path) -> None:
scaffold(feat_file=FIXTURES_DIR / "recipe.feat.yaml", output_dir=tmp_path)
assert (tmp_path / "def" / "commands.yaml").exists()
assert (tmp_path / "def" / "events.yaml").exists()
This keeps CLI tests fast (no subprocess overhead) and avoids install-time coupling. End-to-end smoke testing against the installed binary is handled manually in Phase 7.
Integration tests — snapshot tests (syrupy)¶
End-to-end tests call the full generator pipeline against a known feat fixture, write output
to a tmp_path directory, and compare every generated file against a saved snapshot.
Snapshots live in dizzy/tests/snapshots/ and are committed to version control. They serve
as living documentation of exactly what each generator produces.
def test_gen_full_example(tmp_path, snapshot):
feat = load_feat("tests/fixtures/recipe.feat.yaml")
gen(feat, tmp_path)
for path in sorted(tmp_path.rglob("*.py")):
assert path.read_text() == snapshot(name=str(path.relative_to(tmp_path)))
To update snapshots after an intentional template change:
pytest --snapshot-update
Dev dependency¶
Add syrupy to the dev dependency group in pyproject.toml:
[dependency-groups]
dev = [
"pytest>=8.4.2",
"syrupy>=4.0",
]
Test layout¶
dizzy/
tests/
conftest.py # shared feat fixtures
fixtures/
recipe.feat.yaml # full example feat used across tests
snapshots/ # syrupy snapshot files (committed)
generators/
test_models.py
test_commands.py
test_events.py
test_queries.py
test_procedures.py
test_policies.py
test_projections.py
test_cli.py # end-to-end scaffold + gen integration tests