runex Architecture
What this is
runex is a graph-shaped ontology engine: a typed graph store with declarative semantics for object lifecycles and reactive behavior. Object types, state machines, actions, and their triggers all live as data in the graph itself. New behavior is added by writing data — not Python — and takes effect immediately, without restart.
The N+M ingest pipeline (one adapter per source, one upsert routine for all sinks) is one consumer of this engine, not its purpose. Everything downstream of a write — link extraction, state transitions, derived structure — happens through reactive cascades on store events, not through call-site coupling.
Layers
L4 Interface CLI · Ontology facade ← contract lives here
L3 Ontology engine DSL eval · Loader · Engine · Kernels · ReactiveBus
L2 Store nodes / fields / links / identities · tx_log · on_commit
L1 SQLiteDependency is strictly one-way L4 → L3 → L2 → L1. L2 doesn’t know what a
machine or action is — it just emits mutation events through on_commit.
Removing src/runex/ontology/ and src/runex/dsl/ leaves L2 + the
pipeline functional.
The contract is an L4 concern — Result envelope, closed Event
taxonomy, manifest(), cursor-resumable event stream. L3 stays a pure
bool/raise executor; L4 wraps it. JSON is truth; Rich output is a view.
Core abstractions
Node — the only object type
nodes ── node_tags ── supertags
│
├── fields (typed EAV: text/longtext/number/date/bool/blob)
├── links (typed directed edges with props)
└── node_identities (natural_key → node_id projection)A node is the universal record. A Bookmark, a Person, a state-machine definition, and a kernel reference are all nodes — differentiated by which supertag they carry.
Supertag — type declaration
A supertag declares:
- Field schema (names, types,
required, multi-value) - Optional inheritance via
extends - Optional
natural_keyfield that uniquely identifies real-world objects
Three supertags are reserved for the engine’s own metadata:
MachineDefinition, ActionDefinition, KernelRef.
Supertag definitions are (supertag …) forms in .scm bundles, loaded via
o.load(). The bundled system, domain, and pack ontologies ship under
src/runex/skill/ontology/. The
engine applies Policy-A migration gating: additive changes (new field,
new supertag) always apply at runtime; destructive changes (drop a field,
change a field’s type, change the natural key) are refused if the supertag
already has tagged nodes — unless allow_destructive=True. Re-applying a
byte-identical definition is a true no-op (no event emitted, no churn on
repeated load).
Machine — lifecycle template (data)
(machine "Name"
(supertag "Name")
(initial "s1")
(states ("s1" "act-a")
("s2" "act-b")
("s3"))) ; terminalCurrent state is stored on the node as the reserved field
__state__<MachineName>. Only the engine’s (transition …) primitive may
write this field; manual set-field on it bypasses discipline.
Action — guarded transition with effect (data)
(action "act-a"
(machine "Name")
(from-states "s1")
(trigger TRIGGER-EXPR)
(guard GUARD-EXPR) ; pure boolean
(effect EFFECT-EXPR) ; mutations + (transition "s2")
(priority 100) ; optional, lower runs first
(conflict-policy "last-write-wins")) ; optionalfrom-states is a hard precondition checked before guard. Guards read but
never write. Effects compose store primitives + kernel calls.
Reactive actions are ordered by priority ASC, name ASC — enforced at
dispatch time, not at load time. If multiple matching actions write the same
literal field, the default last-write-wins policy makes the last writer’s
value final and the bus records the conflict in reactive_dispatch_plan. An
action marked error-on-conflict that participates in a same-field conflict
blocks the whole conflict group and emits reactive_conflict_blocked. It also
fails closed on opaque field targets: if the field name is not a literal (e.g.,
it comes from a kernel result or variable), the bus refuses rather than risk a
silent accidental winner.
Trigger — when an action fires
(manual) only via explicit Ontology.dispatch
(on EVENT-TYPE PRED …) fires when event + predicates match
(any-of TRIGGER …) disjunctionEvent types synthesized from store mutations:
| Event | Source |
|---|---|
node-created | store.create_node / identity upsert |
node-updated | store.update_node |
field-set | store.set_field on non-state fields |
field-unset | store.unset_field |
link-created | store.create_link |
link-deleted | store.delete_link |
state-transitioned | set_field on __state__<M>, with machine + to synthesized |
tagged | store.tag |
Predicates: (field N) (supertag T) (rel R) (machine M) (to S).
Design principle: trigger on the event that signals the data the action
reads is in place, not the event that signals the object exists. An action
consuming a body field should trigger on (on field-set (field "body") …),
not on (on tagged …) — the tag event fires before the body is written.
Read primitive — guard-safe Python helper
Read primitives are Python functions injected into the DSL env by the engine. They are available in guards and effects, must not mutate the store, and must not perform external I/O. Use this boundary for deterministic computations that guards need, such as timestamp arithmetic or business-day counting.
Current engine-owned read primitives beyond store reads include date-diff
and business-days-between.
Kernel — effect-only Python escape hatch
Kernels are developer-registered Python functions:
engine.register_kernel("yt-dlp-download",
lambda url: {"blob_key": "…", "metadata": "…"})(call-kernel "name" arg …) is the only way for an effect to reach
outside the store: HTTP, LLM, filesystem, subprocess. Agents can compose
kernels via DSL but cannot define new ones. This is the safety boundary.
Guards cannot call kernels.
Built-in kernels (pure, auto-installed): extract-wiki-links,
extract-wiki-link-pairs, extract-hashtags, regex-find,
regex-find-all, sum-field-on.
(writeback "sink-key") is the outbound sibling of call-kernel: same
effect-only escape-hatch category, same registration model
(engine.register_sink(key, sink), the Python/bootstrap author wires a
configured SinkAdapter; the DSL author only names it), same
guard-disallowed boundary. It projects $node to a typed CanonicalItem
and hands it to the sink — the node’s data does not cross the DSL.
Reactive bus — events → dispatch
Every successful transaction commit fires on_commit(events). The bus
classifies each event into a high-level signal and matches every loaded
action’s trigger_spec against it. Matching actions are sorted by
priority ASC, name ASC, then dispatched synchronously.
Before dispatching a matched group, the bus writes a reactive_dispatch_plan
audit event containing the source signal, ordered actions, literal field
writes, detected same-field conflicts, and the ordering rule. If
error-on-conflict blocks a conflict group, the bus also writes
reactive_conflict_blocked. No reactive outcome is silent — guard
rejections (reactive_guard_rejected), illegal transitions
(reactive_illegal_transition), and dispatch errors (reactive_dispatch_error)
all emit typed events under actor system:reactive. Imperative dispatch
returns ok:false with a structured error; it never raises at the caller.
Cascades nest; depth is bounded by max_depth=10.
Ontology — high-level facade (L4)
o = Ontology.open("data/data.db") # store + engine + bus + default kernels
o.load("path/to/ontology.scm") # → Result{ok, data:{machines,actions}, events}
o.dispatch("act-a", node_id) # → Result{ok, data:{ran}, events, cursor}
o.manifest() # → Result{ok, data:{supertags,machines,...}}
o.analyze_conflicts() # → Result{ok, data:{field_conflicts,...}}
o.events(since=cursor, kinds=[…]) # → Result{ok, data:{count}, events, cursor}
trace = o.trace("act-a", node_id) # debug only — OntologyTrace, not ResultEvery operation returns a Result envelope (ok, data, error, events, cursor). trace() is the single deliberate exception — a debug introspection
tool that returns the richer OntologyTrace. See agent-contract.md for the
full contract specification.
CLI mirror: runex ontology {list|load|describe|run|trace|manifest|events|check}.
Every subcommand accepts --json and emits the Result envelope verbatim;
exit code mirrors ok.
Datasource registry
Adapters self-describe via a module-level SPEC: DataSourceSpec in
src/runex/adapters/<name>.py. The registry (adapters/registry.py)
lazily imports them and exposes registry(), get(key), and all_specs().
manifest().data.datasources surfaces the registry as structured data —
agents and products consume it without reading source.
DataSourceSpec.build(ctx) validates required params and returns an adapter
instance. A missing required param raises a structured error naming exactly
what is missing.
A spec also declares requires: tuple[str, ...] — the capability keys the
adapter needs. build() constructs those via the capability broker
(adapters/capabilities.py) and injects them into the adapter; the adapter
performs zero direct I/O and imports no filesystem/db/http library. A
capability (e.g. FileCapability) is the only code that touches the OS on a
channel’s behalf — Invariant 4’s sanctioned external-I/O surface made
structural rather than conventional, the same broker-injection pattern
Engine._make_env uses for the DSL env, one layer out. manifest() exposes requires so the
contract stays fully introspectable. All six adapters are split; the shipped
capabilities are file, sqlite, command, http (mcp deferred until a
consumer exists).
The seam is enforced by an executable criterion, not convention.
tests/test_capability_seam.py greps each channel module for I/O verbs —
open(, .read_text, .read_bytes, subprocess, sqlite3, httpx,
requests., .glob(, .iterdir( — and any hit fails the suite (a leaked
seam), the way tests/test_lint_gate.py makes ruff real. The line is the
I/O boundary, not the typing boundary: path algebra (Path(...), .name,
.parts) is data-shaping and stays in the channel; path I/O verbs
(.read_text, .glob, open() move to a capability. Behaviour is preserved
iff the channel emits a byte-identical CanonicalItem stream before and after a
split. The capability set is derived, never designed — only capabilities an
existing adapter provably exercises ship; http shipped with its first
consumer (NocoDB v3), not before.
The registry is bidirectional. SinkSpec is the outbound mirror of
DataSourceSpec (same lazy registry, same param/capability injection, same
zero-direct-I/O seam; manifest().data.sinks exposes it). The inbound
boundary is external → CanonicalItem → upsert_node_from_item; the outbound
boundary is its exact mirror — project_node_to_item reads a node into a
typed CanonicalItem, a SinkAdapter writes it out. The same typed
boundary carries data both directions; it never degrades to JSON through
the DSL.
Hot-discovery and the trust boundary
At Ontology.open() the registry also scans
~/.runex/extensions/{kernels,sources,sinks}/*.py and registers any module
exposing a KERNELS dict (capabilities) or a module-level SPEC (channels).
Dropping a file in and restarting the one process adds a channel or kernel — no
core edit, no rebuild. This makes runtime extensibility literal for I/O: a
channel break (Notion renames a field, WeChat bumps its schema) is a contained,
hot-swappable failure in an extension, never a core regression. The core is the
stable plug socket; the plugs are extensions — the way an Emacs package is to
the C core, or a mod is to a game engine.
Hot-discovery means arbitrary Python executes at open(). This is sound only
under runex’s single-user-local model: the extension directory shares the .db
file’s trust principal (the Emacs init.el model). The moment a shared or
product deployment loads an extension directory it does not own, this is remote
code execution. A product on top must not widen this without an isolation story
(Invariant 12).
Ingest boundary — Connector, Recipe, Job, Preset
The repository currently exposes two parallel catalogs:
- runnable business
DataSourceSpecs such asobsidian-thoughtandwechat-session - transport-level
CoreDataSourceSpecmetadata such asfile-markdown,sqlite-query, andhttp-json
This is transitional. CoreDataSourceSpec is currently descriptive only; it
cannot enumerate records. DataSourceSpec is runnable, but combines transport,
semantic mapping, watch behavior, and target Supertag in one object.
The target ingest contract, following ADR-0008, is:
Capability → Connector → RawRecord → Recipe → CanonicalItem → Store
╲ ╱
IngestConnection- Capability owns the sanctioned I/O verb (
file,http,sqlite,command). - Connector owns external record enumeration and observation. It emits a
declared
RawRecordshape and knows no target Supertag. - Recipe owns semantic interpretation. It accepts one or more RawRecord
shapes, validates against
manifest(), and emits typedCanonicalItems. It performs no I/O. - IngestConnection binds a configured Connector, Recipe, run mode, cursor, and deletion policy.
- Preset is a named preconfigured Connection template. Existing
DataSourceSpecs remain compatibility presets during migration.
These are separate manifest collections. Transport keys and business preset keys are not peers and must not share one flat namespace.
A RawRecord is a change envelope, not an untyped payload:
source_uri · record_shape · op(upsert/delete) · observed_at · version
payload · metadata · checkpointThis lets file watches, incremental SQLite reads, command pulls, and future
webhooks enter the same Job runner. Recipes consume record_shape + payload;
they do not receive filesystem Paths, HTTP requests, webhook acknowledgments,
or connector cursors as I/O handles.
A saved IngestConnection is itself an ordinary ontology node. Connector and Recipe definitions are discoverable runtime specs; a user’s configured vault path, mode, cursor, and deletion policy are durable graph state, not a new configuration table.
For v0.2 the first complete implementation is intentionally Obsidian-centered:
obsidian-markdown Connector
→ RawRecord {
path, vault, frontmatter, body, wikilinks, embeds,
attachments, content_hash, mtime
}
→ ontology-frontmatter Recipe
type/supertag resolved from manifest
schema fields and ref declarations mapped generically
→ CanonicalItemThe Connector understands Obsidian-flavored Markdown syntax and file
lifecycle, but not Thought, Task, or Customer. The Recipe understands
ontology schema, identity, fields, and links, but not filesystem watching.
The existing obsidian-thought adapter becomes a legacy Preset:
obsidian-thought ~= obsidian-markdown + legacy-thought recipe + watch defaultsFuture Feishu support follows the same seam:
feishu-webhook Connector → RawRecord → business/ontology RecipeWebhook acknowledgment, signature verification, cursoring, and retry belong to the Connector/Job; ontology mapping remains in the Recipe. Feishu work is not part of Phase 1, but Phase 1 must not hard-code file polling into Recipe or ingest orchestration.
Phase 1 does not implement every speculative core datasource. Only the
Obsidian connector is promoted to executable status. file-json, file-csv,
sqlite-query, http-json, and command-json remain descriptive candidates
until a real Job dogfoods each interface.
Target extension: proactive behavior runtime
The next architecture increment turns the existing synchronous ontology runtime into a host for long-running proactive behavior without making the reactive bus asynchronous.
It does not add a fifth persistence model or a queue subsystem. The core layering remains unchanged:
Outside core Human shells · Worker host · Agent runtimes · Channels
│
L4 Interface CLI · Ontology facade · Result / manifest / events
│
L3 Semantics DSL eval · Loader · Engine · Kernels · ReactiveBus
│
L2 Graph nodes · supertags · fields · links · identities · tx_log
│
L1 Storage SQLiteThe worker host is an L4 consumer, like the CLI or a product shell. It is not inserted between L3 and L2 and does not receive direct ownership of SQLite.
The proactive flow is:
External facts / human edits
│
▼
Connector → Recipe → CanonicalItem → Store
│
▼
synchronous ReactiveBus
guard → signal → transition
│
creates/updates WorkItem
│
▼
durable cursor + worker host
claim → query fresh state
→ run Agent / tool / channel
│
▼
validated command/result writeback
│
▼
synchronous ReactiveBus reacts
│
Approval / Conflict / Task / SinkEverything durable remains a node
The ontology rule is literal: every durable object is a node.
The proactive framework introduces no tasks, jobs, approvals,
assertions, or conflicts tables. It models each concept through the
existing graph substrate:
| Concept | Representation |
|---|---|
| Business Task | ordinary node tagged Task |
| Slow effect request | ordinary node tagged WorkItem |
| Agent recommendation | ordinary node tagged Proposal |
| Human decision | ordinary node tagged Approval or fields/links on a Proposal |
| Knowledge claim | ordinary node tagged Assertion |
| Contradiction case | ordinary node tagged Conflict |
| Natural-language rule | ordinary node tagged BusinessRule |
| Lifecycle | MachineDefinition node + reserved __state__<Machine> field |
| Behavior | ActionDefinition node containing trigger/guard/effect DSL |
| Provenance / relationships | typed links and link properties |
This exactly follows the existing implementation: MachineDefinition,
ActionDefinition, and KernelRef are already regular nodes with internal
supertags, read back into runtime dataclasses by L3.
Task and WorkItem are intentionally different ontology types. A Task is
work a human or business process cares about. A WorkItem is a durable
request for a worker to perform an external effect. Either can link to the
other; neither gets a dedicated L1 table.
WorkItem — an ontology node for durable intent
A slow effect is represented as a typed WorkItem node, not an in-memory
callback and not a blocking kernel call. Its lifecycle is:
pending → claimed → running → succeeded
├──────→ retry_wait → pending
└──────→ failedThe signal action writes only the intent and stable references needed to
reconstruct the work. The worker claims the item with a lease, then queries
the referenced nodes again before acting. Event payloads carry references
(node_id, work_item_id, action_name, source_event_cursor), not copied
subgraphs.
This preserves the answer to “what is true now?”: the Store is queried at execution time rather than trusting a stale event snapshot.
The fields that make a WorkItem claimable (work_key, handler, subject,
attempts, lease owner, lease expiry, timestamps) are ordinary typed fields.
Its references to source event, subject, result, proposal, and artifacts are
ordinary links.
The only possible L2 addition: generic conditional mutation
The current Store writes each field in its own transaction. That is sufficient for deterministic single-threaded cascades, but two worker processes cannot safely implement “claim this pending node if it is still pending” as a read-then-write sequence.
If multi-worker claiming is included, L2 may gain one generic primitive:
conditional_mutate_node(node_id, expected_fields, field_writes)It atomically checks current field values and applies field writes plus tx_log events in the existing tables. It has no knowledge of WorkItem, lease, Agent, Task, or approval. No L1 table or business-specific column is added. L3/L4 may use this primitive for WorkItem claims, compare-and-set business rules, or other ontology objects.
For a single-worker MVP this primitive can be deferred; the ontology model is the same either way.
Worker host — asynchronous work at the L4 edge
The worker host is a long-lived process outside the core that consumes L4. It
reads the persisted event stream or queries claimable WorkItem nodes, but
all graph mutations go through the same Ontology/facade contract.
Responsibilities:
- persist and resume its event cursor
- atomically claim work with an owner and lease expiry
- dispatch a named handler (Agent, channel, or deterministic tool)
- validate structured results against the current manifest
- write success, failure, retry, and provenance back to the graph
- recover abandoned work after process restart
The worker host does not own business branching. Machines, guards, approval requirements, and follow-up reactions remain runtime-loaded ontology data.
Approval and conflict are states, not blocked threads
Human-in-the-loop behavior uses typed objects and state transitions:
Proposal → pending_review → approved / rejected / expired
Conflict → unresolved → accepted / superseded / dismissedNo process waits for a person. A product shell, Obsidian file, or external channel edits the review object; that write re-enters the ordinary reactive path. The human remains sovereign over destructive, externally visible, or ambiguous actions.
Obsidian is the human-sovereign mirror
The proactive loop is not human-usable if state only changes inside SQLite. For v0.2, Obsidian is a required bidirectional shell, not an optional future projection.
The responsibility split:
- SCM owns type definitions (fields, machines, actions) — no I/O concepts
- Guard actions (e.g. correct_task_status) protect machine-state fields from being overwritten by stale file values during re-ingest
- Ingest writes all fields from the file into the graph without filtering
- Sink writes all non-internal fields back to the file without filtering
- StoreWatcher dispatches project_
by naming convention
The mirror loop is:
Human/Agent edits Markdown
→ FileWatcher + Frontmatter ingest
→ CanonicalItem → Store → ReactiveBus
→ guard action detects stale fields (e.g. state disagreement)
→ corrects to machine-authoritative value
→ writeback to file
→ FileWatcher suppresses the matching machine echoBefore writeback, project_* actions compare the current file hash against
_sync_file_hash baseline via (call-kernel "file-hash" …). If the human
changed the file since the last baseline, writeback fails closed and creates a
Conflict node.
The primary approval interface follows the same loop: an Approval/Proposal node is projected as an Obsidian note; the human changes a Frontmatter value; ingest advances the machine. CLI approval is only an operator fallback.
Agent role
An Agent is an effect handler, not the top-level orchestrator. It receives a bounded task, fresh referenced context, an allowlisted tool set, and a machine-readable output contract. Its output is a proposal or validated command batch; ontology rules decide whether the result is applied directly, sent for approval, or rejected.
Invariants
These hold across all changes; violating any of them is a regression.
- L3 → L2 is one-way. Removing
src/runex/{ontology,dsl}/leaves L2 + adapters + pipeline functional (and__state__*fields become inert text). - No business names in framework code, extended to no channel names in
core.
Bookmark,Thought, etc. appear only inconfig/andtests/;WeChat,Notion, etc. live only in extension specs, never in the engine. Verifiable by grep. __state__*fields are write-protected by convention. Only(transition …)writes them. Direct writes bypass the discipline that makes state machines meaningful.- Read primitives and kernels are separate boundaries. Guards may use
read primitives only. Effects can mutate L2 through store primitives or
invoke a registered kernel. No
eval, no Python interop, no untrusted import. - Reactive order is deterministic.
priority ASC, name ASCis enforced at the point of dispatch. Same input event → same cascade. Reloading actions in a different order does not change outcome. error-on-conflictfails closed. If an action asserts a hard guarantee and the bus cannot prove it (same-field co-writer present, or field target is opaque), the whole conflict group is blocked. Ambiguity is refused loudly, never resolved by accident.- Slow work never runs on the reactive bus. Agent calls, network I/O, subprocesses, downloads, transcription, and other unbounded work execute only in a worker host after durable intent has been recorded.
- Work is reference-first and recoverable. A worker re-queries current state from stable IDs, claims work with a lease, and can resume after a crash without relying on an in-memory callback or copied subgraph.
- Human review is modeled as state. Approval never blocks a thread or transaction; it suspends progress in the ontology until a later write advances the review object.
- No business-specific persistence tables. New durable concepts are supertagged nodes with fields and links. L2 additions, if any, must be domain-neutral graph/store primitives reusable by arbitrary ontology types.
- Projected human edits fail closed. A machine write must not overwrite a file changed since its last ingested baseline. Checksum suppresses echoes; ownership/version checks detect conflicts.
- The extension directory shares the
.db’s trust principal. Hot-loaded capability kernels and channel specs are the sole sanctioned external-I/O surface, demarcated from the pure trusted core. This holds only under the single-user-local model; a shared or product deployment must not load an extension directory it does not own without an isolation story.
Extending the engine
| Goal | Action |
|---|---|
| Add an object type | (supertag …) in a .scm file, o.load() |
| Add a lifecycle / behavior | Write an .scm bundle, then runex ontology load <path> |
| Add an external I/O kernel | Python def, engine.register_kernel("name", fn) |
| Add an ingest interface | Implement a Connector over injected capabilities; emit a declared RawRecord shape |
| Add ontology mapping | Implement a no-I/O Recipe from RawRecord → CanonicalItem |
| Add a ready-to-run source | Define an IngestConnection/Preset binding Connector + Recipe + run policy; legacy DataSourceSpec remains compatible |
| Add an outbound writeback | Sink in src/runex/adapters/sinks/ with SPEC = SinkSpec(…); engine.register_sink(key, spec.build(ctx)); invoke via (writeback "key") in an effect |
| Add proactive slow work | Define a signal action + typed WorkItem; implement an allowlisted worker handler outside the bus |
| Require human approval | Route the proposal through an approval machine; react to approved/rejected transitions |
| Inspect / debug a cascade | runex ontology trace ACTION NODE |
The DSL covers everything except external I/O. A .scm file is sufficient
for new lifecycles; no Python edit, no restart.
Pipeline ingest
Adapters parse external sources (Obsidian vaults, chat JSONLs, …) into
CanonicalItem:
@dataclass
class CanonicalItem:
source_uri: str
source_type: str
supertag: str
name: str
fields: dict[str, tuple[type, value]]
links: list[CanonicalLink]
extra_tags: list[str]
description: str | None
attachments: list[Attachment]upsert_node_from_item(store, item) upserts the item via L2 primitives:
get-or-create by natural_key → tag → set fields → resolve RefByName targets
→ set links → store attachments. Each step emits events; the reactive bus
handles everything downstream.
The adapter doesn’t know about ontology. The pipeline doesn’t know about ontology. The cascade is the connection.
Where to look
| File | For |
|---|---|
src/runex/skill/references/dsl-reference.md | DSL grammar, special forms, primitives |
src/runex/skill/references/ontology-authoring.md | Modeling workflow, migration gate, conflict policy, debug recipes |
src/runex/skill/references/agent-contract.md | Result envelope, Event taxonomy, manifest(), event stream contract |
src/runex/skill/ontology/**/*.scm | Bundled system, domain, and pack ontologies |
src/runex/ontology/facade.py | Python API (Ontology class) |
src/runex/ontology/engine.py | Dispatch + env construction |
src/runex/ontology/reactive.py | Event classification + trigger match |
src/runex/adapters/registry.py | Datasource capability registry |
runex ontology --help | CLI |
Expected scale & constraints
User profile
Single-person heavy knowledge worker + 1–3 agent collaborators. Not SaaS, not multi-tenant, not team workspace. This assumption drives every bound below.
Data volume (per user)
| Operation | Daily | Monthly | Yearly |
|---|---|---|---|
| write tx | 30–150 | 1k–4.5k | 10k–50k |
| new nodes | 10–50 | 300–1k | 4k–12k |
| field writes | 50–200 | 1.5k–5k | 18k–60k |
| links | 10–60 | 300–2k | 4k–24k |
Active agent extraction (WorkItem → proposal → approval) adds roughly 1.5–2× to daily write volume, but even the peak stays under 500 tx/day.
Ontology size
| Category | Count |
|---|---|
| built-in supertags | ~30 |
| built-in machines | ~15 |
| built-in actions | ~50 |
| user-defined supertags | 5–20 |
| user-defined actions (incl. business rules) | 5–30 |
| total actions | ~100 |
Actions have simple trigger patterns (field-set, node-created, manual,
scheduled) — no cross-entity join conditions in trigger spec. Guard/effect
expressions are evaluated per-node, per-action, ~1ms each.
Bottleneck ranking (highest concern first)
- writeback file I/O — each reactive cascade tail writes back to .md files. Disk writes are 100–1000× slower than SQLite. This is the real latency wall, not rule matching.
- tx_log unbounded growth — ~200 appends/day, no retention policy.
After one year ~70k rows; full scans degrade without an index on
tsor periodic pruning. - reactive cascade depth — a single action triggering 5+ nested
cascades, each writing files and re-triggering ingest, risks oscillation.
Protected by
max_depth=10and echo suppression, but cascades beyond ~3 should be reviewed. - FTS5 beyond 100k nodes — trigram tokeniser is resilient, but at
this scale consider
search_longtextquery planning. - trigger matching — even at 200 actions, naive prefix matching on trigger specs stays well under 1ms. No optimisation needed.
Why not a Rete-based rule engine
Rete forward-chaining optimises cross-fact join matching (the classic “1000+ rules, many share subconditions” case). runex has ~100 total actions, all ECA (event-condition-action) with per-node guard evaluation — no multi-fact join patterns exist in the trigger or guard layer. Bottlenecks are on I/O and cascade depth, not rule matching. Rete would add complexity (compiled network, cache invalidation, no hot-replace of rules stored as data) for zero gain at this scale.
If the bottleneck profile ever changes (10× actions, cross-entity trigger patterns emerge), the right response is not Rete — it’s a read cache layer and/or CQRS decomposition of the busy write path.
When this engine is the wrong fit
- Sub-millisecond latency budgets (bus is synchronous, single-threaded)
- High-volume OLTP (single SQLite process)
- Strongly-typed RPC schemas (CanonicalItem is loose by design)
- General-purpose high-throughput workflow orchestration; the durable work protocol is deliberately scoped to Agent/tool effects around ontology state
When these constraints bite, reach for a proper workflow engine or stream processor instead.
Future direction
L1 + L2 + L3 are a generic graph-ontology substrate that doesn’t know about runex’s specific domain. The plan is to extract them as a separate pip package once a second consumer appears, or a multi-graph use case emerges. The invariants above are what keep that extraction cheap.
Distribution shape (2026-07-06). runex today ships as a Nuitka onefile binary inside
runex-macos-arm64.zip.src/runex/is the build-time source-of-truth; it is no longer apip install runexartifact. See the 2026-07-06 entry inchangelog.mdfor the release-channel shape and migration story.