ADR-0018: Engine Keeps a Minimal Substrate; Every Domain Capability Is an Extension
- Status: Accepted
- Date: 2026-07-09
- Revised: 2026-07-09 (decouple from specific paths; soften migration)
Context
runex is positioning itself as a general-purpose ontology execution engine: the substrate on top of which users (and third parties) build their own agent-driven systems. It is not an Obsidian tool, not a personal knowledge base, and not a workflow orchestrator — those are use cases that happen to be built on runex.
This framing has been implicit for a while (CLAUDE.md §1 calls runex a “CLI ontology 引擎 + 6 个开箱即用的 skill 产品”), but the current code shape contradicts it:
- Engine bootstrap ships domain types. The ObsidianNote supertag
(and its synced/orphan lifecycle), a free-form agent text payload
wrapper targeted at one specific agent profile, and other
domain-shaped supertags all load at
inittime, as if Obsidian and openclaw were part of the engine’s identity. - Core skills ship domain onboarding. A single “god skill” knows about Obsidian + Claude + Codex + OpenCode simultaneously and is pre-installed for every user, regardless of whether they want any of those.
- Core skills ship a domain use case. Multi-step workflow orchestration is shipped as a top-level skill even though its only real home is one specific extension.
- Install scripts hardcode the assumption. The triple
["runex", "<onboarding>", "<workflow>"]is repeated across the install script, the build script, the doctor command, and the release bundle — and any addition requires touching all of them.
The result is a confusing message: runex markets itself as a platform but ships as a product. New users see domain-shaped onboarding and use-case-shaped defaults; new integrators see “core has its own Task type, why would I write an extension?”; and every refactor of the core threatens to break downstream consumers because core is the product.
We need a single, durable line that says what belongs to the engine and what belongs to an extension, expressed in a way that:
- makes “core vs extension” explainable in one breath
- is enforceable mechanically (lint/test) rather than by convention
- leaves the engine free to evolve without breaking extensions
- lets third parties author extensions without forking runex
Note on stability. This ADR is deliberately written in terms of principles and characteristics, not in terms of specific file paths, directory names, or schemata. The point of an ADR is to outlive the implementation details it was written against. A future refactor that renames the engine’s ontology directory, moves the skill manifest from a text file into Python constants, or changes the extension layout must not require reopening this ADR; only the criteria below matter. Specific implementation choices belong in code and tests; this document states the what and why, not the how.
Decision
The engine ships a minimal substrate. Every domain capability is delivered as an extension — a self-contained bundle that declares its dependencies, declares what it provides, and hooks into the engine via a documented contract.
Concretely:
A. What belongs to the engine
The engine owns only what the engine itself needs to run:
| Concern | Belongs to engine because… |
|---|---|
| Reactive kernel (dispatch / effect cascade / transactional rollback) | The “physical law” substrate. The engine IS this. |
| Engine contracts — types and lifecycles the engine itself reads or writes as part of its core operation | The engine’s own data model. Whether a type is an engine contract is a property of what reads/writes it, not of its name. |
| Ingest pipeline plumbing | The engine ships generic connectors/recipes as plug-in points; the pipeline itself is substrate. |
| Worker host | Generic claim loop + agent dispatch; agent providers are plug-in points. |
CLI primitives (runex init, runex doctor, runex ontology load/check/primitives, runex node, runex field) | Core interface. |
| Skill(s) that are engine-level — i.e. operate on the graph generically without assuming any domain | Zero domain assumptions. |
B. What belongs to an extension
Everything that answers “for what use case”:
| Concern | Belongs to extension because… |
|---|---|
| Domain supertags (any type whose primary consumer is a specific use case, not the engine itself) | The engine doesn’t read/write it as part of its own operation. |
| Onboarding skills for a specific data source | Tied to that source’s connector/recipe. |
| Domain use-case skills (workflow orchestration, knowledge management, session import, …) | Tied to a specific use case. |
| Domain-specific connectors / recipes | Plug-in points; the engine ships the plug, not the connector. |
A diagnostic for grey cases. When uncertain whether a piece is “engine contract” or “domain”:
Does the engine’s own code path — not user code, not extension code — read or write this supertag / invoke this skill / depend on this connector as part of its core operation?
- Yes → engine contract. It belongs in core.
- No → domain. It belongs in an extension.
- Both (engine reads it AND a specific extension writes it) → it’s still engine (because the engine depends on it), but the extension may extend it with additional fields / states / actions via the engine’s extend-supertag-style mechanism.
This diagnostic is mechanical enough to apply case-by-case and to write tests against, without binding the test to a specific file path.
C. The extension contract
An extension is a unit that:
- Declares what it provides (ontology files, skills, kernels, connectors, recipes), what it depends on (other extensions / engine versions), and how to install / uninstall itself.
- Hooks into the engine only via documented entry points: ontology load, skill registration, kernel registration, connector / recipe registration, lifecycle events. No engine-internal coupling.
- Is independently versioned. Engine refactors that respect the contract do not require extension authors to update immediately; pinned extension versions continue to work as long as the contract holds.
- Can be authored by third parties without forking runex. Drop the unit into a discoverable location; the engine honors the manifest.
The exact directory layout / file format / discovery mechanism is an implementation detail and out of scope for this ADR. It belongs in code, in tests, and in user-facing developer docs — not here.
D. Mechanical enforcement
This decision is not a convention; it is enforced by tests that inspect invariants rather than paths. Three classes of invariant:
- Engine-level supertag invariant. A CI check asserts that the
set of supertags the engine bootstraps on
initis a subset of “supertags the engine’s own code paths depend on”. The test queries the engine’s actual dependency surface (which internal modules / CLI commands / kernels read or write which supertags), not a static whitelist. Adding a domain supertag to the bootstrap list that nothing in the engine depends on fails the test. - Engine-skill invariant. The set of skills shipped by the engine is the set of skills that operate on the graph generically. This is enforced by inspecting the skills’ content (no domain triggers, no domain-specific knowledge) — not by listing paths.
- Single-source-of-truth invariant. Whatever the engine ships is declared in exactly one place (a manifest, a Python constant, a build configuration). The install script, the build script, the doctor’s bundled-skill check, and any other consumer all read from this one source. Adding a skill requires editing the manifest; tests assert all consumers agree.
E. Migration
No specific migration is mandated by this ADR. Whether a particular supertag / skill / connector should move from core to an extension is a per-case judgement call that applies the diagnostic in §B and weighs:
- Cost of moving: how many tests, default-bindings, and other internal callers depend on this artifact being in core?
- Cost of leaving: does leaving it in core actively mislead users about what the engine is, or is it a defensible engine contract?
- Reversibility: if we move it and the move turns out wrong, how expensive is the rollback?
Future PRs may audit individual artifacts and migrate them based on this diagnostic. Each migration is its own ADR-sized decision and is not planned in advance here. The point of this ADR is the principle, not a checklist.
F. Known grey zones (illustrative, not exhaustive)
Three categories of artifact routinely sit on the boundary and are worth naming so future audits can apply consistent reasoning:
- Engine contracts that look domain-shaped. Some supertags have names that sound domain-specific (e.g. a vault-mirror type, a workflow-orchestration primitive), but the engine reads or writes them as part of its own operation. They are engine contracts by §B’s diagnostic. Whether to rename them is a separate cosmetic decision; whether to move them out of core is out of scope here.
- Default-binding substrate. A fresh
initships a working surface, not a question. To honor that promise, the engine bootstraps whatever artifact the default binding depends on — even if that artifact is technically domain-shaped. Migrating it to an extension would require either dropping the default binding (worse UX) or making the extension install part ofinit(which would mean extensions ship pre-installed, contradicting the principle in §A). The right call today is to keep such substrate in core; revisit if/when the engine stops shipping a default binding. - Internal-but-typed types. Some types exist only so the engine itself can record its own bookkeeping (per-run message logs, reverse-channel messages, definition records). They have user-visible names but no user-visible use case. They are engine contracts by §B’s diagnostic — the engine reads/writes them.
Why
This decision aligns with three established principles:
- ADR-0006 (runtime-loadable ontology over Python-registered logic): business semantics should be data, not framework code. An extension is the unit of “loadable business data”.
- ADR-0007 (Python as host and extension interface): Python is the place for capability / kernel / channel-adapter code. An extension’s Python surface is a first-class extension point.
- CLAUDE.md §11 (architecture invariants): kernel-only-for-external-I/O, logic-in-.scm-not-Python. Extensions are the user-facing unit that composes primitives + adds domain ontology.
Concretely the consequences are:
- The engine is explainable in one breath. “It’s a reactive ontology execution engine with a Scheme DSL, a work-item host, and an ingest pipeline. Everything else is an extension.”
- A new user gets a clean start. Fresh
initships only what the engine needs to run + the engine-level skill(s). They are prompted to install an extension. The choice is theirs. - A third party can author an extension without forking runex. Drop a unit that declares what it provides; the engine honors the declaration. No engine-side code change needed.
- The engine evolves faster. Refactoring core (changing reactive semantics, swapping the ingest pipeline, etc.) only touches the substrate; extensions continue to work as long as the §C contract holds.
- The “core vs extension” boundary is mechanically enforceable. CI tests fail on violations; the docs aren’t the only line of defense.
Alternatives Considered
方案 A — Keep everything in core, just make the messaging nicer (rejected)
Add “RunEX is a platform” to the README while continuing to ship domain-shaped artifacts as defaults.
Rejected because:
- The marketing won’t match the code shape; new users will keep getting confused.
- The hardcoded manifest of “core skills” / “core types” will keep drifting out of sync as new use cases are added.
- Nothing forces an extension author to ship their own onboard or their own ontology when the core already has an opinion.
方案 B — Engine = substrate only, ALL ontology in extensions (rejected — too aggressive)
Move every supertag — including those the engine itself reads or writes — into extensions. The engine becomes a “blank slate” that loads its own contracts from an extension at startup.
Rejected because:
- The engine itself depends on certain supertags as part of its core operation. Making the engine load its own contracts at startup is a chicken-and-egg problem and forces an awkward “core extension” that ships with every install (defeating the purpose).
- An empty
initcan’t do anything the user actually wants — not even basic operations — until they’ve installed multiple extensions. UX gets dramatically worse. - ADR-0006’s spirit (“semantics as data”) is preserved without going this far: engine contracts stay in core; everything else is an extension.
We chose 方案 C: core keeps what the engine itself needs; the rest is an extension.
方案 D — Dynamic plugin system with Python entry-points (rejected)
A setuptools entry-points-based plugin loader where extensions
register themselves via pip-installable Python packages.
Rejected because:
- Forces extensions to be Python packages, which is heavier than a drop-in directory.
- Mixes runtime plugin discovery with packaging, which makes upgrades fragile (every extension upgrade = pip install).
- Loses the “drop a unit in” simplicity that makes authoring trivial.
- Doesn’t compose well with the engine’s already-runtime-loadable ontology: extensions should be data + skills, not Python imports.
The drop-in unit model (方案 C) keeps authoring trivial and lets the engine control when each extension loads.
Consequences
Positive
- Core is explainable in one breath.
- New users get a clean start with a deliberate choice of extension.
- Third-party extension authoring is forkless.
- Engine can evolve without breaking extensions (contract-pinned).
- The boundary is mechanically enforceable, not a convention.
Negative / Costs
- UX cost on first install. Users used to “open the box and have
a default ready” now see “the engine is empty; pick an extension”.
Mitigation:
initships a defensible default-binding substrate so the engine is usable out of the box; the user opts into more extensions as needed. See §F-2. - Extension discovery. Without a registry, users don’t know what extensions exist. Mitigation: a future registry may be added; out of scope for this ADR.
- The hardcoded “what is engine” list becomes a refactor target in its own right (single source of truth). This is mechanical and small.
Neutral
- Extension authoring requires some declaration format. The specifics are an implementation detail; this ADR only requires that such a format exist and be machine-readable.
- The install / uninstall story for extensions is owned by the extension itself; the engine provides the hook, the extension provides the script.
runex doctoroutput may grow one section to report “engine vs extensions” status — a small UI change.
Status
Accepted. The principle is in force from this ADR’s date. No specific migration is mandated; future audits apply §B’s diagnostic case-by-case, weighing cost of moving vs cost of leaving.