Modeling a Business: Ontology Authoring
How you (an agent) model a user's business at runtime: declare types, lifecycles, and reactive behaviour as .scm data, load it, and verify it — no Python, no restart.
This is the task-oriented guide. For the exhaustive language reference (every primitive, grammar, value types) see dsl-reference.md. For the contract you consume the results through, see agent-contract.md.
TL;DR
python
from runex.ontology import Ontology
o = Ontology.open("data/data.db") # store + engine + bus + kernels
o.load("config/ontology/bookmark.scm") # → Result{ok, data:{machines,actions}}
o.dispatch("bm_cache", node_id, actor="agent") # → Result{ok, data:{ran}, events}
o.manifest() # everything that now exists
o.analyze_conflicts() # pre-flight: do any rules fight?bash
runex ontology load config/ontology/bookmark.scm
runex ontology describe bm_cache # echo the parsed form
runex ontology run bm_cache <node-id> # imperative dispatch
runex ontology trace bm_cache <node-id> # dispatch + full cascade
runex ontology check --strict # gate: no blocking conflictsEvery command takes --json and emits the Result envelope verbatim.
The three top-level forms
A .scm bundle contains three kinds of top-level form. All load through the same path — there is one modeling language, not a YAML side-channel for types.
(supertag …) — declare a type
scheme
(supertag "Person"
(base-type "thing")
(natural-key "规范名") ; identity field, optional
(description "A person")
(field "规范名" (type "text") (required #t))
(field "最近微信日期" (type "date")))Field types: text · longtext · number · date · bool · blob · ref. The core type system ships as config/ontology/core.scm.
Migration is gated (Policy-A). Re-loading a bundle re-applies supertags. Additive changes (a new field, a new supertag) always apply at runtime. Destructive changes — dropping a field, changing a field's type, changing the natural key — are refused if the supertag already has tagged nodes, unless an explicit migration opts in (allow_destructive=True). Re-applying a byte-identical definition is a true no-op (it does not even emit an event). So you can evolve a model generatively without silently corrupting stored data.
(machine …) — declare a lifecycle
scheme
(machine "Bookmark"
(supertag "Bookmark")
(initial "new")
(states ("new" "bm_cache")
("cached" "bm_refresh")
("dead")) ; terminal — no actions
(description "...")) ; optionalCurrent state lives on the node as the reserved field __state__<MachineName>. Only the (transition …) primitive may write it. Use a machine when an object has discrete lifecycle stages; skip it for stateless graph-wide bookkeeping (use a (manual)/reactive action with no machine binding).
(action …) — guarded transition with effect
scheme
(action "bm_cache"
(machine "Bookmark")
(from-states "new")
(trigger (on field-set (field "链接") (supertag "Bookmark")))
(guard (field-nonempty? "链接")) ; pure boolean
(effect (begin (call-kernel "fetch" (field "链接"))
(transition "cached")))
(priority 100) ; optional, lower runs first
(conflict-policy "last-write-wins") ; optional
(rollback <expr>) ; optional, not auto-run in v0
(description "...")) ; optionalfrom-states is a hard precondition checked before the guard. Guards read but never write or call kernels. Effects compose store primitives + kernels. trigger/guard/effect take exactly one expression — wrap sequences in (begin …).
Triggers — fire on the data, not the existence
scheme
(manual) ; never auto-matches; explicit dispatch only
(on EVENT-TYPE PREDICATE …) ; fires when event + all predicates hold
(any-of TRIGGER …) ; disjunctionEvent types are synthesized from store mutations: node-created, node-updated, field-set, field-unset, link-created, link-deleted, state-transitioned, tagged. Predicates: (field N)(supertag T) (rel R) (machine M) (to S).
Design rule: trigger on the event that signals the data the action reads is in place, not that the object exists. An action consuming a body field triggers on (on field-set (field "body") …), not (on tagged …) — the tag fires before the body is written.
How an agent models a new piece of business
- Type it. What objects exist? Declare
(supertag …)for each, with anatural_keyif instances correspond to real-world identities (so ingest/upsert is idempotent). - Decide stateful vs. reactive. Discrete lifecycle →
(machine …). Graph-wide post-write bookkeeping → a reactive action, no machine. - Pick the trigger.
(manual)for imperative-only;(on …)for auto-reactive;(any-of …)to union. Trigger on the data, not the tag. - Write a pure guard. Boolean. Always-pass is
#t. Calling a write primitive or a kernel from a guard is an error. - Compose the effect from write primitives. Outside-world I/O (download, LLM, HTTP) must be a kernel — the action stays declarative; you compose shipped kernels, you cannot define new ones.
- Order and protect. Set
prioritywhen multiple actions match one event (lower first; ties broken by name). Default resolution is last-write-wins. For a field where an accidental winner is unacceptable, set(conflict-policy "error-on-conflict")— see below. - Load and verify. Write to
config/ontology/<name>.scm,o.load, thendescribe→trace→check(next sections).
Conflict policy — what "error-on-conflict" really guarantees
When several actions write the same derived field:
last-write-wins(default): afterpriority ASC, name ASCordering, the last writer wins. The bus records areactive_dispatch_planevent listing the ordered actions and any same-field conflict.error-on-conflict: an accidental winner is unacceptable. If this action is in a same-event same-field conflict, the whole group is blocked and areactive_conflict_blockedevent is recorded. It also fails closed on unprovable writes: if the action's field target is dynamic (a kernel call or variable static analysis cannot see), it is blocked too (opaque_blocked) — the guarantee it asked for cannot be proven, so the bus refuses rather than risk a silent winner. Prefer a literal derived field name when you need the hard guarantee.
Cross-event conflicts (two actions writing the same field on different triggers) never share a dispatch plan, so the runtime detector cannot see them. They are surfaced statically instead — run o.analyze_conflicts() / runex ontology check; manifest() also carries latent_conflicts. blocking severity means an error-on-conflict field is co-written elsewhere. Catch it at design time: the append-only tx_log cannot un-write a prior cascade hop.
Kernels — the only way out of the store
python
o.register_kernel("yt-dlp-download",
lambda url: {"blob_key": "sha256:…", "metadata": "…"})(call-kernel "name" arg …) is the single path from an effect to HTTP / LLM / filesystem / subprocess. Kernels are effect-only — guards cannot call them. You compose kernels the developer shipped; you cannot define new ones — that is the safety boundary, and every external side effect has a named Python function attached, visible as a KernelRef and in manifest().kernels. Defaults auto-installed by Ontology.open: extract-wiki-links, extract-wiki-link-pairs, extract-hashtags, regex-find, regex-find-all, sum-field-on.
For guard-safe computation (deterministic, no I/O), use read primitives — pure Python helpers injected by the engine into the DSL env. Examples: date-diff, business-days-between. The full list is in dsl-reference.md under "Store-aware primitives".
Debug recipes
Did my reactive trigger fire?
bash
runex ontology trace some-action <node-id>Each commit batch is labelled with its actor. A reactive:<name> actor means that action's effect committed (the actor provenance invariant — see agent-contract.md §2).
Why didn't my guard pass / why no write? Reactive rejections are not swallowed. The bus emits a typed decision event under actor system:reactive: reactive_guard_rejected, reactive_illegal_transition, or reactive_dispatch_error, each carrying the action name and its description. Read it from the cascade (trace/dispatch events) or tail runex ontology events --kind guard_rejected --json. To see the raw imperative error, run the action with trace — the error lands in trace.error.
Two rules fighting over a field?
bash
runex ontology check --strict # cross-event, static, exit 1 if blockingplus reactive_dispatch_plan / reactive_conflict_blocked events for the same-event case.
Cascade infinite loop? The bus caps recursion at max_depth=10. If you hit it, your trigger matches an event your own effect emits — narrow the predicate (add (supertag …)) or guard against re-entry.
Action loaded but not dispatching?
bash
runex ontology list actions # registered?
runex ontology describe NAME # from-states + trigger correct?ref-typed fields are links, not fields — field-nonempty? will not see them. If a supertag declares a field with (type "ref"), the value is stored in the links table, not the fields table. (field-nonempty? "client") will always return false even when a client link exists. Use (links-on (self) "rel") instead:
scheme
;; ❌ wrong — ref fields are not in the fields table
(guard (field-nonempty? "client"))
;; ✅ correct
(guard (not (empty? (links-on (self) "client"))))This applies to any guard or effect that inspects a (type "ref") field. The guard_rejected event currently does not indicate which sub-expression failed, so this footgun produces a silent false return with no further explanation. (Tracked in docs/roadmap.md under "Guard expression introspection".)