Realm Authoring Guide: RealmDescriptor
Overview
Realms are packages that extend Yggdrasil with handlers and WatchSpecs.
Use the unified ygg.realm entry point with RealmDescriptor for registration.
This guide covers: - Creating a RealmDescriptor - Defining handlers with required attributes - Plan dependencies, failure policy and required outputs - Configuring WatchSpecs for event routing - Dev-mode gating patterns - Validation rules and common pitfalls
Quick Start
1. Define a Handler
The handler is the core of any realm. It subscribes to an event type, extracts a scope from the incoming document, and generates a plan:
# my_realm/handlers.py
from typing import Any, ClassVar
from yggdrasil.flow.base_handler import BaseHandler
from yggdrasil.flow.model import Plan
from yggdrasil.flow.planner import PlanDraft, PlanningContext
from yggdrasil.watchers import EventType
class MyProjectHandler(BaseHandler):
event_type: ClassVar[EventType] = EventType.COUCHDB_DOC_CHANGED
handler_id: ClassVar[str] = "project_handler" # Unique within realm
def derive_scope(self, doc: dict[str, Any]) -> dict[str, Any]:
return {"kind": "project", "id": doc.get("_id", "unknown")}
async def generate_plan_drafts(self, payload: dict[str, Any]) -> list[PlanDraft]:
doc = payload.get("doc", {})
ctx: PlanningContext = payload["planning_ctx"]
steps = [...]
plan = Plan(
plan_id=f"my_realm:{ctx.scope['id']}",
realm=self.realm_id or "my_realm",
scope=ctx.scope,
steps=steps,
)
return [PlanDraft(
plan=plan,
auto_run=True, # Or False for approval workflow
approvals_required=[],
notes="My plan notes",
)]
2. Register the Realm
Wire the handler into a RealmDescriptor and expose it via a ygg.realm entry point:
# my_realm/__init__.py
from yggdrasil.core.realm import RealmDescriptor
from my_realm.handlers import MyProjectHandler
def get_realm_descriptor() -> RealmDescriptor:
return RealmDescriptor(
realm_id="my_realm",
handler_classes=[MyProjectHandler],
watchspecs=[], # No watcher yet — realm is registered but not triggerable
)
At this point the realm is registered and Yggdrasil knows about it, but no events will reach it yet — there are no watchers configured to trigger it. Continue to Step 3 to wire up event-driven triggering.
Note: The entry point name (left side) is just a discovery key. Only
RealmDescriptor.realm_idis used for identity.
3. Add a WatchSpec (event-driven triggering)
To have a CouchDB change automatically trigger your handler, add a WatchSpec to the descriptor:
# my_realm/__init__.py
from typing import Any
from yggdrasil.core.realm import RealmDescriptor
from yggdrasil.watchers import EventType, WatchSpec
from my_realm.handlers import MyProjectHandler, MyDeliveryHandler
def _build_scope(raw_event: Any) -> dict[str, str]:
doc = getattr(raw_event, "doc", None) or {}
return {"kind": "project", "id": doc.get("_id", "unknown")}
def _build_payload(raw_event: Any) -> dict[str, Any]:
doc = getattr(raw_event, "doc", None) or {}
return {
"doc": doc,
"reason": f"doc_change:{doc.get('_id', 'unknown')}",
}
def _get_watchspecs() -> list[WatchSpec]:
return [
WatchSpec(
backend="couchdb",
connection="my_connection", # Logical name from config
event_type=EventType.COUCHDB_DOC_CHANGED,
filter_expr={"==": [{"var": "doc.type"}, "my_doc_type"]},
build_scope=_build_scope,
build_payload=_build_payload,
target_handlers=["project_handler"], # Optional routing
),
]
def get_realm_descriptor() -> RealmDescriptor:
return RealmDescriptor(
realm_id="my_realm",
handler_classes=[MyProjectHandler, MyDeliveryHandler],
watchspecs=_get_watchspecs, # Callable enables dev-mode gating
)
Passing
watchspecsa callable (rather than a list) enables dev-mode gating. See Dev-Mode Gating.
Dependencies and failure policy
A plan is a graph, not a sequence. Each step's deps decide when it may run and what a failure takes down with it:
- Every step in
depsis a success prerequisite. The step runs only once all of them have succeeded, or been reused, in the same attempt. - Nothing else creates a dependency. A step's position in the list, a shared scope or a common name prefix does not.
- Steps may be listed in any order. List order only decides which of several ready steps runs first, so a readable order still makes the plan easier to follow.
The plan's failure_policy decides what a failure does to the steps that do not depend on it:
from yggdrasil.flow.model import CONTINUE_INDEPENDENT_POLICY, Plan
plan = Plan(
plan_id=f"my_realm:{ctx.scope['id']}",
realm=self.realm_id or "my_realm",
scope=ctx.scope,
steps=steps,
failure_policy=CONTINUE_INDEPENDENT_POLICY, # omit for "fail_fast"
)
| Policy | Use it when | After a failure |
|---|---|---|
fail_fast (default) |
Any failure makes the rest of the plan pointless | The attempt stops. The request stays eligible and runs again the next time the plan changes |
continue_independent |
The plan holds independent branches, such as one per lane or sample, that should finish even if another fails | Steps depending on the failure are blocked, and the rest runs. The attempt ends failed, and its request is finished: running it again takes a new request (see Plan Execution) |
An unknown policy is rejected, never downgraded to fail_fast. A realm that sets failure_policy must require a Yggdrasil version that supports it. An older one would not run the plan as a continuation plan.
Choosing what is a prerequisite
Whether one step must succeed before another is the realm author's decision, expressed only through deps. The engine gives no step special treatment for its name. Take a plan with shared validation, a metadata update, and one branch per lane:
steps = [validate_runfolder, update_metadata] # update_metadata depends on validation
branch_prerequisites = ["validate_runfolder"]
if metadata_is_required:
branch_prerequisites.append("update_metadata")
for lane in lanes:
# The first step of each branch gets branch_prerequisites as its deps;
# every later step depends on the one before it in its branch.
steps.extend(lane_branch(lane, first_deps=branch_prerequisites))
Under continue_independent:
| Failing step | Metadata update independent | Metadata update a prerequisite |
|---|---|---|
| Shared validation | Everything depending on it, the metadata update and every branch, is blocked | Same |
| Metadata update | Every branch still runs; the attempt still ends failed | Every branch is blocked |
| Lane 2 processing | Lane 2's later steps are blocked; other lanes continue | Same |
docs/design/demux_realm_single_flowcell_plan_prd.md works through this graph for the demux realm. The test realm's branch_failure and branch_failure_metadata_required recipes build both variants, so both can be run in dev mode (see Test Realm).
Prefer simple step IDs using letters, digits, underscores and hyphens, such as lane_2__demux. Keep IDs stable across reruns: derive them from what the step works on, not from its position in a list, since a step's ID names its work directory and reuse on a rerun depends on finding that directory again. See Step IDs for the validation rules.
Declaring required outputs
A step whose artifacts later steps depend on should declare them in outputs, so that an earlier success is reused only while those artifacts exist:
StepSpec(
step_id="lane_2__demux",
name="Demultiplex lane 2",
fn_ref="my_realm.steps.demultiplex",
params={"lane": 2},
deps=["lane_2__samplesheet"],
# Relative paths are relative to this step's own work directory.
outputs={"demux_complete": "output/DEMUX_COMPLETE"},
)
The step fails if it returns without producing a declared output, and it runs again if a declared output has gone missing since it last succeeded. Only existence is checked, so declare a completion sentinel rather than a directory whose contents matter. Steps that produce no artifact, such as validations, need no outputs. See Declared outputs and reuse.
Required Handler Attributes
| Attribute | Type | Description |
|---|---|---|
event_type |
ClassVar[EventType] |
Which events this handler subscribes to |
handler_id |
ClassVar[str] |
Stable identifier within the realm |
Handler Methods
| Method | Required | Description |
|---|---|---|
derive_scope(doc) |
Yes | Extract {"kind": ..., "id": ...} from document |
generate_plan_drafts(payload) |
Yes | Async method returning list[PlanDraft] |
run_now(payload) |
Inherited | Blocking entrypoint for CLI mode |
Instance Attributes Set by Core
| Attribute | Type | Description |
|---|---|---|
realm_id |
str \| None |
Set during registration (from RealmDescriptor) |
Important:
realm_idis an instance variable, not a ClassVar. Do not declare it as a class attribute; it is set by YggdrasilCore.
WatchSpec Fields
| Field | Required | Type | Description |
|---|---|---|---|
backend |
Yes | str |
Backend type: "couchdb", "fs" (future) |
connection |
Yes | str |
Logical connection name (from config) |
event_type |
Yes | EventType |
EventType to emit when filter matches |
build_scope |
Yes | Callable |
RawWatchEvent → {"kind": ..., "id": ...} |
build_payload |
Yes | Callable |
RawWatchEvent → payload dict |
filter_expr |
No | dict \| None |
JSON Logic predicate (None = match all) |
target_handlers |
No | list[str] \| None |
List of handler_ids (None = all subscribers) |
Filter Expressions
Use JSON Logic syntax. The filter context is the RawWatchEvent dict:
| Variable | Description |
|---|---|
doc.* |
Document fields |
deleted |
True if deletion event |
meta.* |
Backend-specific metadata |
Examples
Match specific document type:
Match non-deleted documents with status:
filter_expr = {
"and": [
{"==": [{"var": "doc.status"}, "active"]},
{"==": [{"var": "deleted"}, False]},
]
}
Match documents where a field exists:
Dev-Mode Gating
Make watchspecs a callable that returns [] when disabled:
from yggdrasil.config import YggSession
def _get_watchspecs() -> list[WatchSpec]:
"""Callable for dev-mode gating."""
if not YggSession.is_dev():
return [] # No WatchSpecs = no watcher created
return [WatchSpec(...)]
RealmDescriptor(
...,
watchspecs=_get_watchspecs, # Callable, invoked at discovery
)
This pattern ensures: - Handler is always registered (for CLI/manual triggers) - Watcher only active in dev mode (no events received in prod)
Alternative: Return None from Descriptor
For realms that should be completely invisible when disabled, return None
from get_realm_descriptor():
def get_realm_descriptor() -> RealmDescriptor | None:
"""Return descriptor only when dev mode is enabled."""
if not YggSession.is_dev():
return None # Realm not discovered at all
return RealmDescriptor(...)
This is cleaner when you want no handlers and no watchers in production.
Validation Rules (Fatal Errors)
YggdrasilCore validates realm configuration at startup. These violations cause the daemon to fail immediately:
realm_idmust be unique across all realms- Every handler class must have
handler_idclass attribute (realm_id, handler_id)must be unique globally- If
target_handlersis set, all IDs must exist in that realm - If
target_handlers=None, at least one handler must subscribe to theevent_type
Example Validation Error
RuntimeError: WatchSpec from realm 'my_realm' references unknown
handler_id 'missing_handler'. Registered handlers: ['project_handler']
Handler-Only Realms
Realms with handlers but no WatchSpecs are valid — useful when events are injected programmatically or from other handlers/steps:
RealmDescriptor(
realm_id="cli_tools",
handler_classes=[ManualProcessHandler],
watchspecs=[], # No watching needed
)
Events can be triggered via:
- Direct handle_event() calls
- Other handlers/steps emitting events internally
Event Flow Summary
┌─────────────────────────────────────────────────────────────────────┐
│ Startup (setup_realms) │
├─────────────────────────────────────────────────────────────────────┤
│ 1. discover_realms() → finds ygg.realm entry points │
│ 2. Call get_realm_descriptor() for each realm │
│ 3. Validate realm_id uniqueness │
│ 4. Instantiate handlers from handler_classes │
│ 5. Call watchspecs() if callable, collect WatchSpecs │
│ 6. Validate WatchSpec → handler bindings │
│ 7. Wire WatchSpecs into WatcherManager │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Runtime (daemon mode) │
├─────────────────────────────────────────────────────────────────────┤
│ 1. WatcherManager starts backend watchers │
│ 2. Backend detects change → RawWatchEvent │
│ 3. filter_expr evaluated → match/skip │
│ 4. build_scope() + build_payload() → YggdrasilEvent │
│ 5. YggdrasilCore.handle_event() routes to subscribed handlers │
│ 6. Handler.generate_plan_drafts() → list[PlanDraft] │
│ 7. Plan persisted to yggdrasil_plans database │
│ 8. PlanWatcher detects eligible plan → Engine executes it │
│ 9. Result recorded on the plan document; snapshot from events │
└─────────────────────────────────────────────────────────────────────┘
Common Pitfalls
1. Missing handler_id
# ❌ Wrong - will fail validation
class MyHandler(BaseHandler):
event_type = EventType.COUCHDB_DOC_CHANGED
# Missing handler_id!
# ✅ Correct
class MyHandler(BaseHandler):
event_type: ClassVar[EventType] = EventType.COUCHDB_DOC_CHANGED
handler_id: ClassVar[str] = "my_handler"
2. Declaring realm_id as ClassVar
# ❌ Wrong - conflicts with instance variable set by core
class MyHandler(BaseHandler):
realm_id: ClassVar[str] = "my_realm" # Don't do this!
# ✅ Correct - let core set it
class MyHandler(BaseHandler):
# realm_id is set by YggdrasilCore during registration
pass
3. WatchSpec Without Receivers
# ❌ Wrong - WatchSpec emits COUCHDB_DOC_CHANGED but no handler subscribes
class MyHandler(BaseHandler):
event_type: ClassVar[EventType] = EventType.OTHER # different
handler_id: ClassVar[str] = "my_handler"
...
RealmDescriptor(
realm_id="orphan",
handler_classes=[MyHandler], # event_type mismatch — subscribes to a different EventType
watchspecs=[
WatchSpec(event_type=EventType.COUCHDB_DOC_CHANGED, ...) # emits different type
],
)
WatchSpec.event_type != handler.event_type. Handler subscribes to a different EventType (EventType.OTHER), so no handler in this realm will receive the WatchSpec’s emitted events (EventType.COUCHDB_DOC_CHANGED). This leads to fatal validation error.
4. Passing Handler Instances
# ❌ Wrong - should be classes, not instances
RealmDescriptor(
handler_classes=[MyHandler()], # Instance!
)
See Also
- Realm Authoring Cookbook — common patterns for handlers, steps, and recipes
- Flow API Overview —
@step, Engine, emitters,PlanDraftfields - Architecture Overview — how realms plug into the core