Skip to content

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 conflicts

Every 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 "..."))                        ; optional

Current 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 "..."))                         ; optional

from-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 …)                ; disjunction

Event 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

  1. Type it. What objects exist? Declare (supertag …) for each, with a natural_key if instances correspond to real-world identities (so ingest/upsert is idempotent).
  2. Decide stateful vs. reactive. Discrete lifecycle → (machine …). Graph-wide post-write bookkeeping → a reactive action, no machine.
  3. Pick the trigger. (manual) for imperative-only; (on …) for auto-reactive; (any-of …) to union. Trigger on the data, not the tag.
  4. Write a pure guard. Boolean. Always-pass is #t. Calling a write primitive or a kernel from a guard is an error.
  5. 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.
  6. Order and protect. Set priority when 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.
  7. Load and verify. Write to config/ontology/<name>.scm, o.load, then describetracecheck (next sections).

Conflict policy — what "error-on-conflict" really guarantees

When several actions write the same derived field:

  • last-write-wins (default): after priority ASC, name ASC ordering, the last writer wins. The bus records a reactive_dispatch_plan event 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 a reactive_conflict_blocked event 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 blocking

plus 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".)