Skip to content

Write a backend plugin

Hussein Jarrar edited this page Sep 12, 2026 · 2 revisions

A complete tutorial: build a plugin, load it, and see it run. Every code block on this page is real code from the repository, not an invented sample.

What you will build

Radd calls a feature's directory a module — a package under server/src/radd/modules/<name>/. A module exports one object, plugin, an instance of RaddPlugin — Radd calls this object the plugin: a manifest of what the module contributes. This page uses "module" for the directory and "plugin" for the manifest, because the codebase does.

The north-star plugin, milestones, is the smallest complete example. One declaration wires a database table, a permission-guarded CRUD API, three event types, and four RBAC atoms — with no edits to any other module.

milestones/
├── __init__.py    # exports `plugin: RaddPlugin` — the manifest
├── spec.py        # the EntitySpec — the whole backend feature, declared
├── models.py      # builds the entity's SQLAlchemy model into Base.metadata
├── mcptool.py      # a contributed MCP tool
├── automation.py   # a contributed automation action node
└── ui/             # a federated frontend remote — a separate guide page

server/src/radd/modules/milestones/

This page builds a plugin shaped like milestones, walking its real files line by line. Where the declarative path is not enough, it shows a hand-written alternative too — a second real plugin, fields. fields predates EntitySpec and still writes its own router and service functions.

By the end you will have a plugin that:

  • builds a table, with no migration,
  • answers a permission-guarded REST API,
  • fires events an automation can react to,
  • appears in the roles matrix,
  • contributes an MCP tool, and
  • can be installed, enabled, disabled, and uninstalled with no restart.

The module directory

A module can contain any of these files. Only __init__.py is required.

File Holds Required when
__init__.py plugin = RaddPlugin(...) — the manifest the loader imports. Always
spec.py An EntitySpec declaration — the declarative path. Your entity fits the field DSL
models.py The SQLAlchemy model: entities.build_model(SPEC) for a declarative entity, or hand-written Base subclasses. Your plugin owns a table
schemas.py Pydantic request/response shapes for a hand-written router. You write your own router
service.py The plugin's public API — every function another module is allowed to call. Almost always
router.py FastAPI endpoints, when the generated CRUD router is not enough. You write your own router
types.py StrEnum vocabulary: event types, entity-type strings, wire constants. Most plugins
mcptool.py / mcptools.py McpToolSpec declarations. You expose an MCP tool
automation.py AutomationNodeSpec declarations. You contribute an automation node

milestones needs only spec.py and models.py. It skips schemas.py, service.py, router.py, and types.py entirely — the kernel generates what those files would otherwise hold. fields needs all of them: it validates values, and its access model is finer than "any project member" (see Permissions and access control).

The minimum plugin

RaddPlugin is a frozen dataclass with about thirty fields, and every contribution field defaults to an empty tuple:

@dataclass(frozen=True)
class RaddPlugin:
    name: str
    description: str = ""
    id: str = ""            # defaults to `name`
    version: str = "0.0.0"
    api_version: str = KERNEL_API_VERSION
    core: bool = True       # reclassified builtins are core; externals set False
    depends_on: tuple[str, ...] = ()
    ...

— server/src/radd/kernel/plugin.py

The name=/description= form alone builds a valid, loadable plugin — it contributes nothing. Here is milestones/__init__.py, stripped to that floor, to show it. (This is not a real file in the repository — the real one, below, also declares an entity, an MCP tool, an automation node, and nav.)

from radd.sdk import RaddPlugin

plugin = RaddPlugin(
    name="milestones",
    description="Project milestones — the plugin-platform north-star.",
)

— skeleton, built from server/src/radd/modules/milestones/init.py

A plugin only loads if its dotted import path is listed in server/src/radd/config.py. It goes in modules for an always-on plugin (core=True), or in installable_plugins for one an administrator installs and enables (core=False). milestones is core=False and lives in installable_plugins — installing and enabling it is covered in Install, enable, disable, uninstall below. Every code block on this page after this one is the real file.

Add an entity with EntitySpec

One EntitySpec declaration is the entire backend feature for a plugin whose data is a plain table: standard columns, one project foreign key, no composite validation. It gets you a table, a permission-guarded CRUD router, three event types, and the CRUD RBAC atoms — from one declaration.

@dataclass(frozen=True)
class EntitySpec:
    key: str              # stable entity type, e.g. "milestone"
    table: str
    label: str
    fields: tuple[EntityFieldSpec, ...] = ()
    model: type | None = None    # escape hatch: a hand-defined SQLAlchemy model
    project_scoped: bool = True
    searchable: bool = False
    mentionable: bool = False
    activity: bool = True
    plural: str = ""

— server/src/radd/kernel/specs.py

milestones/spec.py is the whole declaration:

from radd.sdk import EntityFieldSpec, EntitySpec

SPEC = EntitySpec(
    key="milestone",
    table="milestones",
    label="Milestone",
    plural="milestones",
    project_scoped=True,
    searchable=True,
    mentionable=True,
    fields=(
        EntityFieldSpec("project_id", "uuid", nullable=False, index=True, fk="projects.id"),
        EntityFieldSpec("title", "str", nullable=False),
        EntityFieldSpec("description", "text", nullable=True),
        EntityFieldSpec("due_on", "date", nullable=True),
        EntityFieldSpec("status", "str", nullable=False, default="open"),
    ),
)

— server/src/radd/modules/milestones/spec.py

Line by line: key is the entity type string — it becomes the event prefix (milestone.created), the CRUD atom prefix (milestone.create), and the table's model name. table is the literal database table name. plural picks the router prefix (/milestones) and the CRUD-resource label; leave it unset and the kernel appends s to key. project_scoped=True adds the project_id foreign key's row-visibility rule and registers a project-purge entry, so a deleted project takes its milestones with it. searchable and mentionable opt the entity into full-text search and #-mention resolution.

Each EntityFieldSpec becomes one column. The type string selects both the SQLAlchemy column type and the pydantic type for the generated request/ response models:

type Column Python
str String(255) str
text Text str
int Integer int
float Float float
bool Boolean bool
uuid Uuid uuid.UUID
datetime DateTime datetime
date Date date
json JSON Any
— server/src/radd/kernel/entities.py, _TYPES

status is nullable=False with a default. A field carrying either makes it optional in the generated Create schema. The create handler dumps the payload with exclude_unset=True, so an omitted status falls through to the column's own default at flush time, rather than failing validation.

models.py is one line, and it still has to exist:

from radd.sdk import entities

from .spec import SPEC

Milestone = entities.build_model(SPEC)

— server/src/radd/modules/milestones/models.py

import_models() imports every enabled plugin's <name>.models at boot, so Base.metadata is complete before Alembic or the entity table-creation step runs. Skip this file and your table is declared but never built.

The loader registers the entity automatically, the moment it sees entities=(SPEC,) on your manifest. That registration auto-wires four things, with no further code from you:

def register_entity(spec: EntitySpec) -> type:
    """Auto-wire an entity: build the model + register its CRUD-resource RBAC
    atoms, created/updated/deleted event types, and the payload ref those
    events carry. Idempotent."""
    model = build_model(spec)
    registries.crud_resources.setdefault(spec.key, _crud_resource(spec))
    registries.entity_refs.setdefault(
        spec.key, EntityRefSpec(spec.key, _ref_builder(spec, model), label=spec.label)
    )
    for et in _event_types(spec):
        registries.event_types.setdefault(et.event_type, et)
    if spec.project_scoped:
        purge = _project_purge(spec)
        registries.project_purges.setdefault(purge.name, purge)
    return model

— server/src/radd/kernel/entities.py

That is:

  • A CrudResourceSpec whose manage umbrella is milestone.manage, and whose actions are create/update/delete. So milestone.create/.update/.delete/.manage all appear in the roles matrix.
  • Three EventTypeSpecs: milestone.created/.updated/.deleted. Each declares subjects=("milestone",), so an automation trigger or a webhook receiver gets a real milestone ref, not a {id, project_id} stub.
  • An EntityRefSpec built from your own field names. It looks for a title/name/label field to use as the ref's display text.
  • For a project-scoped entity, a purge entry. DELETE /projects/{id} removes your rows too, even though your table has no ON DELETE CASCADE.

The generated CRUD router is permission-guarded on those same atoms. Reads are the exception — they ride the ordinary member floor, not a milestone.read atom of their own:

async def _require(session: AsyncSession, user, obj_or_pid, atom: str) -> None:
    ...
    await entity_host().require(session, user, atom, project_id=pid)

@router.post("", response_model=Read, status_code=201)
async def create(data: Create, session: Session, user: User):
    ...
    await _require(session, user, payload.get("project_id"), f"{key}.create")

@router.get("/{obj_id}", response_model=Read)
async def get_one(obj_id: uuid.UUID, session: Session, user: User):
    obj = await _get(session, obj_id)
    await _require(session, user, obj, authz_read_atom())   # -> "item.read", always
    return Read.model_validate(obj)

— server/src/radd/kernel/entities.py

authz_read_atom() returns the string "item.read" unconditionally. An entity declaration does not create a <key>.read atom. A read is open to any project member; value-level restriction is the access-grant/field system's job (see Permissions and access control), not the CRUD atom's. Only create/update/delete are your entity's own atoms.

You have written five fields in spec.py and three lines in models.py. In return, you now have POST/GET/PATCH/DELETE /api/v1/milestones (/api/v1 is settings.api_prefix), a table, three event types, and four RBAC atoms. server/tests/test_north_star.py is the acceptance test for exactly this promise — see Test your plugin.

Add a router and service by hand

EntitySpec covers a plain table. It does not cover per-value validation, an access model finer than "any project member", or endpoints that are not create/read/update/delete. When you need one of those, write service.py and router.py yourself.

fields is a real example. A custom field's value must be validated against its type before it is stored. Who may read or write one particular field is also a per-record access-grant question (see Permissions and access control), not a blanket CRUD atom. Its router resolves the scope, then delegates:

async def _require_manage_on_scope(
    session: AsyncSession, user, project_id: uuid.UUID | None, *,
    permission: authz.Permission = authz.Permission.FIELD_MANAGE,
) -> None:
    """`permission` on the field's scope: its project, or globally when unscoped."""
    if project_id is not None:
        project = await projects_service.get_project(session, project_id)
        await authz.require(session, user, permission, project=project)
    else:
        await authz.require(session, user, permission)

— server/src/radd/modules/fields/router.py

A service function is the module's public surface — the only thing another module is allowed to call:

async def create_field(
    session: AsyncSession, data: FieldDefinitionCreate, actor_id: uuid.UUID | None = None
) -> FieldDefinition:
    scope_ids = list(dict.fromkeys(data.project_ids))  # dedupe, preserving order
    for project_id in scope_ids:
        await projects_service.get_project(session, project_id)
    ...

— server/src/radd/modules/fields/service.py

Another module reaches your data only through your service.py functions — never your models.py. server/tests/test_module_contracts.py asserts this by walking the real import graph. Every radd.modules.X import in your module must be declared, either in depends_on (ordered) or weak_depends (a deferred reach — see Declare your dependencies). An import of another module's models.py at all is refused outright, unless that module is one of six spine models (auth, projects, items, workflow, teams, fields). Even those are READS only; a write still goes through the owning service.

Register your router on the manifest, beside whatever else you contribute:

routers=(router,),

— server/src/radd/modules/fields/init.py

Permissions, events, nav items, and settings

Permissions and access control covers the permission model in full — this section is the manifest wiring.

Permissions. A standalone atom (one that is not part of a CRUD create/update/delete/manage set) is a PermissionSpec:

permissions=(
    PermissionSpec(
        "field.manage", "project", "Manage custom-field definitions and field access rules.",
        implied_by=("project.manage",),
    ),
),

— server/src/radd/modules/fields/init.py

Events. An entity's created/updated/deleted events are auto-registered (above). Declare an EventTypeSpec by hand for anything else:

event_types=(
    EventTypeSpec(FieldEvent.CREATED, "Custom field created", "Admin"),
    EventTypeSpec(FieldEvent.UPDATED, "Custom field updated", "Admin"),
),

— server/src/radd/modules/fields/init.py

@dataclass(frozen=True)
class EventTypeSpec:
    event_type: str
    label: str
    group: str = "Other"
    item_scoped: bool = False    # a target item resolves -> SLQ + item actions apply
    has_changes: bool = False    # payload carries a field diff
    trigger: bool = True         # appears in the automation trigger catalog
    subjects: tuple[str, ...] = ()  # entity types this event is ABOUT

— server/src/radd/kernel/specs.py

Every entry in subjects must name an entity type with a registered EntityRefSpec. An EntitySpec registers its own automatically. If your event names something else, register a matching EntityRefSpec too — or the plugin refuses to load. See Declare your dependencies.

Nav items. milestones adds one sidebar link, gated on item.read:

ui=PluginUiManifest(
    nav=(
        NavItemSpec(
            key="milestones", label="Milestones", path="/milestones",
            icon="flag", section="main", requires=("item.read",), order=55,
        ),
    ),
    remote="/plugins/milestones/remoteEntry.js",
    ui_api_version="1.0.0",
),

— server/src/radd/modules/milestones/init.py

requires names the permission atoms that must hold before the link appears — hiding is presentation only, never enforcement (see Permissions and access control). This page does not cover building the frontend remote the remote line points at.

Settings. A scalar cascade setting (instance-level, or instance and project) is a SettingSpec:

SettingSpec(
    key="timesheet_day_min_hours",
    type="int",
    scopes=("instance",),
    label="Timesheet: minimum hours per workday",
    description=(
        "A working day (per the working week) with less than this logged is "
        "flagged as under-logged on the timesheet's per-person view. Leave and "
        "holiday days are never flagged."
    ),
    section="timelogging",
),

— server/src/radd/modules/timelogging/init.py

section places the setting on Settings → Time logging instead of the General page. Leave it "" to land on General.

Contribute an MCP tool

An McpToolSpec is both the tool's catalog entry and its own enforcement declaration — there is no second place to register it:

@dataclass(frozen=True)
class McpToolSpec:
    name: str
    description: str
    input_schema: dict[str, Any]
    handler: Callable[..., Awaitable[Any]]
    permission: str = ""          # "" = any authenticated principal
    project_scoped: bool = False  # visibility: show only where the atom holds
    project_param: str = ""       # input property naming the project
    kernel_enforced: bool = True  # dispatcher requires `permission` before the handler runs

— server/src/radd/kernel/specs.py

milestones/mcptool.py, in full:

async def _list(session: AsyncSession, actor: Any, args: Mapping[str, Any]) -> Any:
    from radd.modules.projects import service as projects_service

    key = str(args["project_key"]).upper()
    project = next(
        (p for p in await projects_service.list_projects(session) if p.key == key), None
    )
    if project is None:
        raise NotFoundError("project", key)
    rows = (
        (await session.execute(
            select(Milestone).where(Milestone.project_id == project.id)
            .order_by(Milestone.due_on.asc().nulls_last(), Milestone.title.asc())
        )).scalars().all()
    )
    return {"milestones": [...], "count": len(rows)}


LIST_MILESTONES = McpToolSpec(
    name="list_milestones",
    description="Milestones in a project, ordered by due date.",
    input_schema={
        "type": "object",
        "properties": {"project_key": {"type": "string", "description": "Project key, e.g. TD."}},
        "required": ["project_key"],
        "additionalProperties": False,
    },
    handler=_list,
    permission="item.read",  # entity reads are member reads
    project_scoped=True,
    project_param="project_key",
)

— server/src/radd/modules/milestones/mcptool.py

_list contains no permission check. That absence is the feature: the dispatcher requires the declared atom before the handler runs at all —

async def _call_registry_tool(
    session: AsyncSession, actor: User, spec: Any, arguments: Mapping[str, Any]
) -> Any:
    if spec.kernel_enforced:
        project = None
        if spec.project_param and arguments.get(spec.project_param):
            project = await projects_service.get_by_key(session, str(arguments[spec.project_param]))
        if spec.permission:
            await authz.require(session, actor, cast(Permission, spec.permission), project=project)
    return await spec.handler(session, actor, arguments)

— server/src/radd/modules/mcp/tools.py

— resolving project_param's value to the named project first, so a caller scoped to one project cannot list another's milestones by naming it in project_key. Leave kernel_enforced=True (the default) for a new tool. False is reserved for the handful of migrated builtins whose handlers already enforce at their own service seam.

The catalog also hides what a caller cannot run at all. It narrows project_param's schema to an enum of the projects the caller may act in — above mcp_project_enum_max it degrades to a plain string instead. That is the same spec-114 filtering every tool gets by registering, with no code of your own.

Register it:

mcp_tools=(LIST_MILESTONES,),

— server/src/radd/modules/milestones/init.py

Contribute an SLQ field

An item-rooted The query language for developers query can be extended with your module's own data without items ever importing your module. SlqFieldSpec is the seam:

@dataclass(frozen=True)
class SlqFieldSpec:
    name: str    # the SLQ field keyword, e.g. "note"
    label: str
    # (contains, value, ctx) -> Select[work_item_id]
    item_ids: Callable[[bool, str, SlqFieldContext], Any]

— server/src/radd/kernel/specs.py

timelogging contributes logged_by, answering "which issues has this person logged time on" — a question about worklogs whose answer is a set of items:

def logged_by_item_ids(contains: bool, value: str, ctx: SlqFieldContext) -> Select:
    """Work-item ids with at least one worklog whose author matches `value`."""
    stmt = select(Worklog.item_id).where(Worklog.item_id.is_not(None))
    if ctx.is_me:
        return stmt.where(Worklog.author_id == ctx.current_user_id)

    stmt = stmt.join(User, User.id == Worklog.author_id)
    if contains:
        needle = f"%{value}%"
        return stmt.where(or_(User.email.ilike(needle), User.name.ilike(needle)))
    return stmt.where(or_(User.email.ilike(value), User.name.ilike(value)))

— server/src/radd/modules/timelogging/slq/item_fields.py

contains is True for ~ (substring) and False for =. ctx.is_me is set when the operand was the bare me literal — a grammar sentinel, not the string "me". A person actually named "me" cannot be confused with it. The function returns a Select of matching work-item ids; the items query engine wraps it as work_item.id IN (…) and applies negation for !=. Your module joins its own tables plus auth.User; it never touches an items table.

Register it:

slq_fields=(
    SlqFieldSpec(name="logged_by", label="Logged by", item_ids=logged_by_item_ids),
),

— server/src/radd/modules/timelogging/init.py

logged_by = me now compiles on the item dialect. This direction — a module reaching INTO items — is one-way by design. An item-dialect field reaching OUT to another module's rows goes through that module's own compile_query instead — a different module's page to document.

Declare your dependencies

depends_on orders the loader: every name in it must finish loading before yours does. milestones needs auth loaded first. Entity registration needs the EntityHost — identity, the permission gate, row visibility — that auth installs:

plugin = RaddPlugin(
    name="milestones",
    ...
    depends_on=("projects", "auth", "events"),
    ...
)

— server/src/radd/modules/milestones/init.py

weak_depends declares an import without ordering by it. Use it for a deferred reverse reach, or a feature-detected optional seam, made the moment you need it rather than at plugin load. fields needs items for one narrow reach — rewriting stored custom-field values when an option is removed. But items itself depends on fields, so an ordered edge would be a cycle:

# RADD-949: removing a select option rewrites the values items already store,
# and `work_items.custom_fields` is items'. A DEFERRED reverse reach — items
# depends on fields, so a hard edge would be a cycle — through the public
# `items.service` seam, never the table.
weak_depends=("items",),

— server/src/radd/modules/fields/init.py

server/tests/test_module_contracts.py asserts every radd.modules.X import in your module's files appears in depends_on or weak_depends. This includes one inside a function body, guarded by a # deferred: X loads after Y comment. There is no allowlist for this rule. Add the name the moment you write the import, not after the test fails.

Migrations

Which migration step you need depends on how your model was built.

A declarative EntitySpec needs no migration at all. The kernel builds your table into Base.metadata, and a startup step creates it idempotently — once when the app boots:

async def create_app() -> FastAPI:
    ...
    async def lifespan(app: FastAPI):
        # Create any declaratively-registered plugin entity tables (idempotent) —
        # the "install" step for entities without a dedicated migration.
        await kentities.ensure_tables()

— server/src/radd/app.py

— and again whenever a plugin with entities is hot-enabled at runtime (see Install, enable, disable, uninstall):

if plugin.entities:
    await kentities.ensure_tables()

— server/src/radd/modules/pluginmgr/router.py

A hand-written models.py — the code escape hatch fields, views, and access all use — needs the real Alembic workflow:

cd server
uv run alembic revision --autogenerate -m ""   # review the generated file
uv run alembic upgrade head

Migration files carry a short mnemonic revision id, not a random hash — d113svcacct_api_token_scopes.py is spec 113's. Review the generated file and rename it to match, before committing. Parallel agents may create sibling heads; merge them with alembic merge.

Install, enable, disable, uninstall

Two ways for a plugin to be loadable at all, set in server/src/radd/config.py:

modules: tuple[str, ...] = (
    "radd.modules.events", "radd.modules.projects", "radd.modules.auth", ...
)
installable_plugins: tuple[str, ...] = ("radd.modules.milestones",)

— server/src/radd/config.py

A plugin listed in modules with core=True is always on — nobody can disable it. One listed in modules with core=False is on by default and disableable. One listed in installable_plugins — where milestones lives, core=False — starts DISCOVERED instead. An administrator must install it, then enable it, before it mounts:

DISCOVERED → INSTALLED → ENABLED ⇄ DISABLED

— server/src/radd/modules/pluginmgr/service.py

Install creates the installed_plugins row (state INSTALLED). For an in-repo plugin like milestones this is a bookkeeping step only. There is no separate migration branch to run — the entity table-creation step above already covers it.

Enable flips the row to ENABLED, then hot-mounts the plugin into the running app, with no restart:

runtime.mount_plugin(request.app, plugin, path)
# An entity plugin with no migration needs its tables created on runtime enable.
if plugin.entities:
    await kentities.ensure_tables()
for hook in plugin.on_startup:
    await hook()

— server/src/radd/modules/pluginmgr/router.py

The mount step registers the plugin's contributions into the live kernel registries — nav, permissions, event types, MCP tools, everything this page covered. It appends the plugin's routers ahead of the SPA catch-all route, which must always stay last.

Disable reverses it. The plugin's own routers are matched by identity. Its generated CRUD router is matched by its /milestones prefix instead — FastAPI wraps a mounted router in an object whose own path is None. A naive path filter both misses that router and crashes on it. registries.unregister_plugin() drops the plugin's nav, atoms, and capabilities from every live registry, in the same call. A plugin cannot be disabled while another plugin's depends_on still names it — disable that dependent plugin first.

Uninstall sweeps the plugin's atoms out of every stored role and API token scope, and deletes its access-grant rows. A role or a scoped key never keeps vocabulary the catalog no longer knows. It then deletes the installed_plugins row — its tables are not dropped.

milestones sets core=False specifically to prove the manager can disable it — server/tests/test_north_star.py asserts exactly that.

Settings → Plugins is the panel that drives all of this:

The Plugins settings page: each row shows a plugin's name, version, enabled state, and a Disable button, with a short description of what it contributes.

Test your plugin

Radd does not ask for a unit test per endpoint. It asks for tests on invariants many modules depend on, and otherwise trusts the running app (see CLAUDE.md, "Development rules"). For the auto-wiring this page walks through, that invariant test already exists — and doubles as your checklist:

def test_milestone_is_an_automation_trigger():
    from radd.modules.automations import catalog
    assert "milestone.created" in catalog.TRIGGERS
    assert catalog.TRIGGERS["milestone.updated"].has_changes is True

def test_milestone_has_rbac_atoms():
    atoms = all_permission_keys()
    assert {"milestone.create", "milestone.update", "milestone.delete", "milestone.manage"} <= atoms

def test_milestone_has_a_nav_item():
    nav = [n for n in registries.nav if n.key == "milestones"]
    assert nav and nav[0].path == "/milestones" and nav[0].requires == ("item.read",)

def test_milestone_entity_and_crud_router_registered():
    assert "milestone" in registries.entities
    ...

def test_milestone_plugin_is_disableable():
    plugin = registries.plugins["radd.milestones"]
    assert plugin.core is False

async def test_milestone_model_roundtrips(db):
    project = await projects_service.create_project(db, ProjectCreate(key="MS1", name="Milestone Test"))
    m = Milestone(project_id=project.id, title="Ship v1", status="open")
    db.add(m)
    await db.flush()
    ...

— server/tests/test_north_star.py (trimmed)

Run it:

cd server
uv run pytest tests/test_north_star.py -v

The test's own fixture shows the two ways a plugin gets into the running registries. At boot, load_plugins(settings.modules) reads the config tuples (see Install, enable, disable, uninstall). In a test, registries.register_plugin(...) does the same thing directly, with no database row. That is why a test exercises a plugin's wiring without going through install/enable at all:

@pytest.fixture(autouse=True)
def _load_milestones(_kernel_registries_loaded):
    from radd.modules.milestones import plugin as milestones_plugin
    registries.register_plugin(milestones_plugin)
    for spec in milestones_plugin.entities:
        kentities.register_entity(spec)
        registries.entity_routers.append(kentities.crud_router(spec))
    yield

— server/tests/test_north_star.py

Two more suites fail your plugin at the door before it fails at runtime. tests/test_module_contracts.py refuses an undeclared cross-module import (see Declare your dependencies). tests/test_permission_ownership.py refuses an atom your manifest declares that belongs to a resource you do not own. It also refuses one whose scope disagrees with where you actually check it. At boot itself, before any test runs, the loader refuses an event or an automation-node subject naming an entity type nothing describes:

if problems:
    raise PluginLoadError(
        "unresolvable event subjects — register an EntityRefSpec for each: " + "; ".join(problems)
    )

— server/src/radd/kernel/loader.py

An event promising a subject nothing can resolve would otherwise save cleanly and enable cleanly — then do nothing at 3am. This check exists to catch that at the cheapest possible moment.

For anything your plugin does beyond the entity/CRUD pattern — a hand-written service function, a validation rule — there is no per-function test requirement. Run the app (podman compose -f compose.dev.yaml up) and use the feature through the API docs at /docs or the running UI.


Mirrored from project.radd-hq.com on 2026-09-12. Documentation is written there; this copy is regenerated by scripts/publish_wiki.py and hand edits do not survive it.

Clone this wiki locally