Skip to content

Architecture the kernel and plugins

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

Radd's server is a kernel plus a set of plugins. This page covers the split between them, the plugin contract, and the path one request takes through both.

The kernel is mechanism, a plugin is policy

The kernel provides generic machinery every feature builds on. A plugin provides one concrete feature and decides how that machinery behaves for it.

Concretely: the kernel turns a plugin's declarations into a table, a router, and event types. It never decides who the caller is, whether the caller may act, which rows the caller may see, or where an event should go. Those are policy questions, and only an installed plugin answers them.

The kernel must not import any radd.modules.* package — not even from inside a function body. kernel/specs.py states the rule directly:

"""Contribution specs — the typed declarations a plugin puts on its manifest.

Each spec is a frozen dataclass the loader aggregates into a kernel registry
(`radd.kernel.registry`). The kernel iterates the registries blind — no consumer
names a plugin. These are the vocabulary of the plugin platform (docs/plugin-platform.md §4).

Kept deliberately pure: this module imports nothing from `radd.modules.*`, so the
kernel never depends on a plugin. Specs carry data + light callables only.
"""

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

A test enforces this for every file under kernel/, not only specs.py. It walks the syntax tree of each file, including imports deferred inside a function body, and fails if any of them import a module:

def test_the_kernel_imports_no_plugin():
    """The kernel is mechanism; a plugin is policy. Nothing under `kernel/` may
    import `radd.modules.*` — including from inside a handler, which is how
    `kernel/entities.py` reached auth/projects/events for years while its own
    docstring claimed purity (RADD-892). Policies arrive through
    `kernel.hosts.EntityHost` and the contribution registries."""
    found = _kernel_module_imports()
    assert not found, "the kernel imports plugins:\n  " + "\n  ".join(found)

— server/tests/test_module_contracts.py

The comment names a real defect. Before RADD-892, kernel/entities.py reached into auth, projects, and events through deferred imports, while its own docstring claimed the kernel was pure. The fix moved four decisions — identity, the permission gate, row visibility, and where an event goes — behind one protocol, kernel.hosts.EntityHost, resolved once per request. The last section on this page traces a request through that protocol.

Every module is a plugin

53 directories exist under server/src/radd/modules/. Each one defines a module-level plugin: RaddPlugin. 52 of them load by default, listed in Settings.modules (the RADD_MODULES boot set); one, milestones, is an installable plugin a person turns on through the plugin manager instead.

    # Ordered module assembly — the plugin system's root configuration.
    modules: tuple[str, ...] = (
        "radd.modules.events",
        "radd.modules.projects",
        "radd.modules.auth",
        "radd.modules.capabilities",
        "radd.modules.pluginmgr",
        "radd.modules.settings",
        ...
        "radd.modules.jiraimport",
        "radd.modules.monitoring",
        "radd.modules.leave",
    )
    # Non-core plugins are INSTALLED + runtime-ENABLED via the plugin manager
    # (docs/plugin-platform.md §10), not the always-on bootstrap set above. These
    # ship in the repo and are discoverable by the manager; `create_app` also loads
    # whichever are in state ENABLED in the `installed_plugins` table.
    installable_plugins: tuple[str, ...] = ("radd.modules.milestones",)

— server/src/radd/config.py

Every one of the 53 builds a RaddPlugin directly — there is no separate RaddModule type any more. The minimal constructor still works:

plugin = RaddPlugin(
    on_startup=(_startup,),
    on_shutdown=(_shutdown,),
    name="events",
    depends_on=(),
    weak_depends=("auth",),
    description="Transactional outbox: append-only event log, the spine every consumer reads.",
    routers=(router,),
)

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

RaddPlugin(name=..., description=..., routers=...) alone still constructs a valid plugin: core defaults to True, id defaults to name, and every contribution field defaults to an empty tuple.

The RaddPlugin contract

RaddPlugin (kernel/plugin.py) is a frozen dataclass. Its fields split into identity/lifecycle and contributions.

Identity and lifecycle

Field What it does
name The stable enable key. depends_on names other plugins by this.
id Defaults to name. A namespaced id (radd.milestones) for an external plugin.
version The plugin's own semver.
api_version The kernel SDK version this plugin targets ("1.0" today). The loader refuses a major-version mismatch.
core True = built in, cannot be disabled. False = the plugin manager can install, enable, and disable it.
depends_on Other plugin names that must load first. The loader enforces the order.
weak_depends Modules this plugin imports but must NOT be ordered by — a deferred reverse reach or a feature-detected optional seam. Declares the import without ordering by it.
python_deps / js_deps The Python distributions and npm packages this plugin needs.

Contributions. Each field below feeds exactly one kernel registry. Declaring one is the whole cost of the feature — no other module needs an edit.

Field Contributing it gets you
routers Endpoints, mounted under the API prefix.
entities A table, a CRUD router, RBAC atoms, and created/updated/deleted event types — see EntitySpec auto-wiring, below.
event_types A row in the event-type catalog. trigger=True (the default) makes it an automation trigger with no other catalog edit.
entity_refs How this plugin's entity types are described inside another event's payload (see Events and consumers).
consumers A name and a description for an offset-tracked stream consumer. Currently descriptive only — see the note below.
automation_actions / automation_conditions Declared on the manifest, but not wired into a registry. Contribute an automation action through automation_nodes instead — see the milestones example.
tasks A periodic tick or an enqueue-only job, run by the active TaskBackend.
settings_keys / settings_sections Scalar cascade settings this plugin owns, and the settings page section they appear on.
permissions RBAC atoms — appear in the roles matrix and GET /permissions.
crud_resources The create/update/delete atom family plus its umbrella verb, for a resource that is not a declared EntitySpec.
relations / relation_domains What a qualifier like @own or @team means for this resource's rows.
access_resources A grantable resource on the access-grant framework — gets the generic /grants API and the shared grants editor.
nav_facts One area-visibility answer for GET /auth/me — is this area worth offering this user.
grant_scopes A kind of thing a role grant can be bound to.
project_relations Why an actor can see a project without a grant on it.
project_purges Tables that must be deleted when a project is deleted, in dependency order.
capabilities A status descriptor for GET /capabilities.
slq_fields A custom SLQ query field.
view_types / widget_types A saved-view type or dashboard widget type, rendered by this plugin's own frontend slot.
mcp_tools An MCP tool — filtered by caller permission and enforced by the dispatcher (see the milestones example).
automation_nodes A node the automation graph can hold: a filter, a gate, or an action.
page_extensions A fenced block a wiki page can embed, written as ```radd:<name>.
cascades Cleanup for rows that must die with a parent the database cannot cascade from — a polymorphic parent with no foreign key.
integrations An implementation of a named socket another plugin consumes: a storage backend, a notifier, a task backend, and so on.
ui Nav items, and the URL of this plugin's federated frontend bundle.
on_startup / on_shutdown Hooks the app lifespan runs at boot and at shutdown, in plugin load order (reverse order on shutdown).
exception_handlers / openapi_augmentors A FastAPI exception handler, or a function that edits the generated OpenAPI schema.

Every contribution field is a tuple. The dataclass refuses a bare value in its place:

def _reject_unwrapped_contributions(self) -> None:
    """Refuse a single contribution passed where a tuple is declared.

    Every contribution field on this manifest is a `tuple[Spec, ...]`, and
    `on_startup=_startup` instead of `on_startup=(_startup,)` is a defect
    Python will not catch: the dataclass stores whatever it is handed, and
    the failure surfaces much later, wherever the field is finally iterated.
    RADD-745's cascade refactor shipped exactly that and produced an image
    that could not complete `lifespan` — a one-character typo that reached a
    published container with 1391 green tests behind it.

    Rejecting rather than NORMALISING is the deliberate choice. Quietly
    wrapping a bare value into a 1-tuple would make two shapes valid for one
    field, and the second one is how the next module learns the wrong
    convention. This raises at import — before an image is built, let alone
    deployed.
    """

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

Write on_startup=(_startup,), with the trailing comma. A missing comma raises TypeError at import time, before an image builds.

The contribution registries

KernelRegistries (kernel/registry.py) holds one dictionary per contribution kind — entities, event_types, permissions, crud_resources, capabilities, nav_facts, slq_fields, mcp_tools, automation_nodes, and the rest of the table above. The loader calls register_plugin once per plugin and fans its manifest fields into these dictionaries.

Exactly one generic consumer reads each registry, and that consumer never names a plugin:

"""The kernel contribution registries — one dict per contribution kind, populated
by the loader and read by exactly one generic consumer. No consumer names a plugin
(docs/plugin-platform.md §4). This is the `access.registry` pattern, generalized.
"""

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

For example, triggers() filters event_types down to the ones an automation may trigger on. The automations catalog derives its whole trigger list from this call, not from a hardcoded import of every producing module:

    def triggers(self) -> dict[str, EventTypeSpec]:
        """Event types that are automation triggers — chokepoint-1 inversion."""
        return {k: v for k, v in self.event_types.items() if v.trigger}

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

This design is deliberate, not incidental. A consumer that named plugins (if plugin_id == "milestones": ...) would need an edit every time a new plugin arrived — the exact backwards dependency the kernel exists to invert. A registry the consumer reads blind means a plugin's contribution is live the moment the plugin loads, whether it ships with Radd or arrives as an external install.

Two fields on the manifest run ahead of a consumer today. The loader aggregates consumers into registries.consumers, but no built-in plugin populates it and nothing reads the dictionary back — it records a name and a description with no behaviour behind it. A real consumer today is a plugin's own on_startup hook starting a hand-written loop; see Events and consumers. automation_actions and automation_conditions are declared on RaddPlugin but register_plugin does not iterate them at all — no registry receives them. Contribute an automation action through automation_nodes, the mechanism the milestones plugin uses.

The loader and lifecycle

load_plugins(paths) (kernel/loader.py) imports each configured plugin path in order, in one pass:

  1. Import the package and read its plugin: RaddPlugin export.
  2. Check api_version against the kernel's own. A major-version mismatch refuses to load:
def _api_compatible(plugin_api: str, kernel_api: str) -> bool:
    """Semver major-compat gate (§9): a plugin loads iff its api_version's major
    matches the kernel's. Minors/patches are backward-compatible by contract."""
    try:
        return plugin_api.split(".")[0] == kernel_api.split(".")[0]
    except (AttributeError, IndexError):
        return False

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

  1. Check every name in depends_on has already loaded, or raise:
        missing = [dep for dep in plugin.depends_on if dep not in seen]
        if missing:
            raise PluginLoadError(
                f"plugin {plugin.id!r} depends on {missing} — order/enable them first"
            )

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

  1. Register the plugin's contributions into registries, and auto-wire any declared entities.

weak_depends plays no part in this ordering. It exists only so a deferred, function-body import is a declared dependency instead of a silent one — tests/test_module_contracts.py::test_every_import_is_declared requires every radd.modules.X import, deferred or not, to appear in depends_on or weak_depends.

After every plugin has loaded, one more pass checks that every event subject, and every action node's subject, names a real registered entity. This is what the milestones plugin's auto-wired subjects=("milestone",) resolves against, checked at boot rather than discovered later:

def _check_subjects(plugins: list[RaddPlugin]) -> None:
    """Every declared event subject must have a registered `EntityRefSpec`
    (RADD-923), and every contributed action node's subject too.

    Checked after the whole set has loaded, not per plugin: the ref may be
    contributed by a plugin listed later, and ordering is `depends_on`'s job, not
    this check's.

    Boot is the cheapest place to find this. An event promising a subject nothing
    can resolve produces an automation that saves cleanly, enables cleanly and
    then does nothing at 3am — the failure mode this whole seam exists to remove,
    so it must not be reintroduced by the seam itself.
    """

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

import_models(paths) runs separately, before load_plugins, and imports each plugin's <path>.models submodule so every table lands in Base.metadata for Alembic. A plugin with no models.py is skipped, not an error.

Startup and shutdown. create_app's lifespan hook runs every plugin's on_startup hooks in load order, then starts the kernel-registered TaskSpecs — deliberately after every plugin's own startup, because a task's run function may touch a table a plugin's startup just created:

        for plugin in plugins:
            for hook in plugin.on_startup:
                await hook()
        # RADD-872: kernel-registered TaskSpecs start after every plugin's own
        # startup (their run functions may touch tables plugins just ensured).
        task_backend = socket_provider(Socket.TASK_BACKEND, settings.task_backend)
        task_loops = _schedule_registered_tasks(task_backend) if task_backend else []
        for loop in task_loops:
            await loop.start()
        yield
        for loop in reversed(task_loops):
            await loop.stop()
        for plugin in reversed(plugins):
            for hook in plugin.on_shutdown:
                await hook()

— server/src/radd/app.py

Shutdown reverses the order. Task loops stop first, then each plugin's on_shutdown hooks run, last-loaded plugin first.

Hot mount and unmount. The plugin manager (modules/pluginmgr/) can enable or disable a core=False plugin in the running process, with no restart. Mounting adds the plugin's routers ahead of the frontend catch-all route, which must stay last or it shadows them:

def mount_plugin(app: FastAPI, plugin: RaddPlugin, path: str) -> None:
    registries.register_plugin(plugin)
    # Record the plugin's UI bundle dir (`<plugin dir>/ui/dist`) for /plugins/<name>/* serving.
    registries.register_plugin_ui_dir(plugin, importlib.import_module(path))
    routers = _routers_for(plugin, path)
    catchall = [r for r in app.router.routes if getattr(r, "path", "") == _CATCHALL]
    for r in catchall:
        app.router.routes.remove(r)
    for router in routers:
        app.include_router(router, prefix=settings.api_prefix)
    for r in catchall:  # keep the SPA fallback last
        app.router.routes.append(r)
    app.openapi_schema = None

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

Unmounting matches routes by identity for the plugin's own routers and by prefix for its generated entity-CRUD routers, guards against a FastAPI internal that broke this once, then drops the plugin's registry contributions so /capabilities, the roles matrix, and the nav stop advertising it in the same step:

    # FastAPI's include_router no longer flattens into per-endpoint APIRoutes —
    # it appends a lazy `_IncludedRouter` wrapper whose `path` is None. The old
    # path-prefix filter therefore both MISSED every mounted router and crashed
    # on `None.startswith` (swallowed upstream), which is how a "disabled" AI
    # plugin kept serving every endpoint. Match the wrapper's `original_router`
    # instead: by identity for the plugin's own routers (module singletons at
    # boot and hot-mount alike), by prefix for entity CRUD routers (created
    # fresh per mount, but always prefixed `/{plural}`). Plain path-bearing
    # routes keep the prefix match as a fallback.

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

EntitySpec auto-wiring: the milestones example

milestones is the plugin platform's north star: the smallest complete feature, built from one EntitySpec. Its whole schema is five fields:

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

Declaring it, and listing it on the plugin's entities=(SPEC,), is the entire feature. kernel.entities.register_entity, run by the loader, gives it — with no other plugin code:

  • A table. build_model maps the field list onto a SQLAlchemy Table and an imperatively mapped class, added to Base.metadata.
  • CRUD. crud_router(spec) returns a full create/read/update/delete router at /milestones, permission-guarded by generated atoms.
  • RBAC atoms. milestone.create, milestone.update, milestone.delete, and the umbrella milestone.manage — derived from the entity's own project_scoped flag.
  • Events. milestone.created, milestone.updated, milestone.deleted, each declaring subjects=("milestone",) — so an automation trigger and a webhook event exist the moment the entity does.
  • A payload ref. A canonical {id, entity_type, title, project} shape, built from whichever of the entity's own fields is named title, name, or label.
  • Project-delete cleanup. register_entity also registers the milestones table as a project purge, so a project cannot be deleted while its milestones still exist.

The plugin's own file adds a nav item and the second half of the north star — an automation action that reacts to the very events the entity auto-wired:

plugin = RaddPlugin(
    name="milestones",
    id="radd.milestones",
    version="1.0.0",
    core=False,
    description="Project milestones — the plugin-platform north-star (entity + nav, all auto-wired).",
    depends_on=("projects", "auth", "events"),
    entities=(SPEC,),
    mcp_tools=(LIST_MILESTONES,),
    # RADD-923: the plugin contributes the ACTION that responds to its own
    # auto-wired events — the other half of the north-star. No edits to
    # `automations`, the kernel or the SPA.
    automation_nodes=(SET_STATUS_NODE,),  # RADD-640: agents see milestones too, kernel-filtered
    ui=PluginUiManifest(
        nav=(
            NavItemSpec(
                key="milestones",
                label="Milestones",
                path="/milestones",
                icon="flag",
                section="main",
                requires=("item.read",),
                order=55,
            ),
        ),
        # The Milestones CRUD page ships as this plugin's federated remote (web/remotes/milestones),
        # mounted by the host at /milestones via the route.page slot (spec 94).
        remote="/plugins/milestones/remoteEntry.js",
        ui_api_version="1.0.0",
    ),
)

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

SET_STATUS_NODE declares subject="milestone", so the executor hands its plan/apply pair milestone ids, resolved through the same ref the entity auto-wired. No line in automations, the kernel, or the frontend shell names milestones. The plugin acts on its own entity, and nothing else has to know it exists.

What the tests enforce

Two rules in tests/test_module_contracts.py are worth restating here. CLAUDE.md calls them the spine-table exception, and both run on every test pass, not only at review time.

A module's models.py is private, except for six spine modules. Any other module may import auth (User), projects (Project), items (WorkItem), workflow (State), teams (Team), and fields (FieldDefinition) directly, for reads — joins and foreign keys. Writes still go through the owning module's service.py. Every other module's models stay off-limits:

SPINE = {"auth", "projects", "items", "workflow", "teams", "fields"}

#: (importing module, imported module) pairs the audit found reaching a
#: non-spine models.py — frozen so the class cannot GROW. Shrunk by the
#: RADD-887/888 seam work and EMPTIED by RADD-892, which inverted auth's reach
#: into `pages.models` into a registered GrantScopeSpec. An addition needs a
#: reason reviewed here; the burn-down is finished.
MODEL_IMPORT_ALLOWLIST: set[tuple[str, str]] = set()

— server/tests/test_module_contracts.py

MODEL_IMPORT_ALLOWLIST is a burn-down list of an audit's remaining findings, not a place to add a new exception. It is currently empty, and a second test fails if an entry in it stops matching a real import — the list may only shrink.

Every cross-module import must be declared. A module that imports radd.modules.X anywhere, deferred or not, must name X in its own depends_on or weak_depends. No allowlist covers this rule:

def test_every_import_is_declared():
    ...
    missing = []
    for module, deps in declared.items():
        imported = {target for target, _, _ in _module_edges(module) if target != module}
        for target in sorted(imported - deps):
            missing.append(f"{module} imports {target} but declares no dependency on it")
    assert not missing, "undeclared module dependencies:\n  " + "\n  ".join(missing)

— server/tests/test_module_contracts.py

The request path

A request that mutates data through an auto-wired entity crosses five steps: request, auth resolution, authz, router, service, event. Trace them through the generated POST /milestones handler.

Auth resolution. FastAPI resolves the _acting_user dependency, which calls the installed host rather than importing auth directly:

async def _acting_user(
    request: Request, session: Annotated[AsyncSession, Depends(get_session)]
):
    """The caller, resolved through the host at REQUEST time.

    A kernel-owned dependency with a fixed signature is what lets the generated
    routers be built while plugins are still loading: FastAPI needs a callable at
    decoration time, and `auth.deps.CurrentUser` would have to be imported then —
    the very dependency this file is here to shed.
    """
    return await entity_host().current_user(request, session)

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

The auth plugin installs the host at import time. Its current_user reads the session cookie or a Bearer radd_pat_… header and raises UnauthorizedError — a 401 — when neither resolves to a user:

class AuthEntityHost:
    async def current_user(self, request: Any, session: AsyncSession) -> Any:
        user = await optional_user(request, session)
        if user is None:
            raise UnauthorizedError()
        return user

— server/src/radd/modules/auth/entityhost.py

Authz. The handler calls _require, which resolves the target project when the entity is project-scoped and asks the host to enforce the atom:

    async def require(
        self, session: AsyncSession, user: Any, atom: str, *, project_id: Any | None
    ) -> None:
        if project_id is None:
            await authz.require(session, user, atom)
            return
        from radd.modules.projects import service as projects_service

        project = await projects_service.get_project(session, project_id)
        await authz.require(session, user, atom, project=project)

— server/src/radd/modules/auth/entityhost.py

authz.require raises ForbiddenError — a 403 — when the user does not hold the atom on that project.

Router and service. For a generated entity router, router and service are the same function. The handler builds the model instance and flushes it directly — there is no plugin-authored service layer to call through:

    @router.post("", response_model=Read, status_code=201)
    async def create(data: Create, session: Session, user: User):  # type: ignore[valid-type]
        # exclude_unset so omitted fields fall to the column default (e.g. status).
        payload = data.model_dump(exclude_unset=True)
        await _require(session, user, payload.get("project_id"), f"{key}.create")
        obj = model(**payload)
        session.add(obj)
        await session.flush()
        await _emit(session, "created", obj, user.id)
        return Read.model_validate(obj)

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

A plugin with its own router calls its own service.py at this point instead; the shape of the step is the same either way.

Event. The handler emits through the host, which calls the real events.service.emit:

    async def emit(
        self,
        session: AsyncSession,
        *,
        event_type: str,
        entity_type: str,
        entity_id: Any,
        actor_id: Any,
        payload: dict[str, Any] | None = None,
        subjects: dict[str, Any] | None = None,
    ) -> None:
        from radd.modules.events import service as events

        await events.emit(
            session,
            event_type=event_type,
            entity_type=entity_type,
            entity_id=entity_id,
            actor_id=actor_id,
            payload=payload,
            subjects=subjects,
        )

— server/src/radd/modules/auth/entityhost.py

The handler adds the event row to the same session as the row it describes. get_session commits both together when the handler returns without raising:

async def get_session() -> AsyncIterator[AsyncSession]:
    """Request-scoped session: commits on success, rolls back on error."""
    async with SessionLocal() as session:
        try:
            yield session
            await session.commit()
        except BaseException:
            await session.rollback()
            raise

— server/src/radd/db.py

One more step sits outside this list. CommitBeforeSendMiddleware holds the response until that commit finishes. Without it, a client that reads its response and immediately issues a follow-up request could race the write:

"""ASGI middleware that holds a response until the request's transaction has committed.

FastAPI runs dependency-with-yield teardown (where `get_session` commits) AFTER the
response bytes are sent. A client that reads its response and immediately issues a
dependent request can therefore miss the write (found in the wild by the Jira importer).
Buffering the send until the inner app — including that teardown — finishes closes the
race. Non-http scopes (websockets, lifespan) pass through untouched.
"""

— server/src/radd/middleware.py

See Events and consumers for what happens to that event row next.


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