Skip to content

Ontology DSL Reference

S-expression based DSL for declaring guards (boolean preconditions) and effects (state mutations + side effects) on data-driven actions in the runex ontology layer.

This is the language layer. For storage of action definitions, the loader, and the reactive bus, see docs/architecture.md. For the modeling workflow (how to author supertags, machines, and actions), see docs/ontology-authoring.md.

Goals

  • Trivial to parse and interpret: < 300 lines total for parser + evaluator.
  • Trivial for an LLM agent to emit: Scheme is in every model's training data; nested-paren syntax has no JSON-comma-hell or whitespace traps.
  • Pure-by-default: side effects are confined to a small fixed set of store primitives and call-kernel (the developer-registered escape hatch).
  • Sandboxable: no eval, no Python interop except through declared kernels. Out-of-vocabulary symbols are eval errors, not silent no-ops.

Non-goals (v0)

  • No macros, no define, no lambda, no tail-call optimization.
  • No general recursion (for-each is the only iteration primitive).
  • No exceptions or try/catch — kernel failures bubble up and abort the action.
  • No floating-point math beyond what Python's float gives us.

Syntax

expr      := atom | list
list      := '(' expr* ')'
atom      := number | string | bool | nil | symbol
number    := int | float           ; -123 / 3.14
string    := '"' (escape|char)* '"' ; \" \\ \n \t \r
bool      := '#t' | '#f'
nil       := 'nil'
symbol    := any char except whitespace ( ) "
comment   := ';' to end-of-line     ; whitespace-equivalent

Notes:

  • Symbols allow -, ?, !, :, digits, Unicode (e.g. field-nonempty?, 链接).
  • Strings carry the field/state/link names — symbols are reserved for identifiers in the DSL itself.

Value types

TypeLiteralNotes
number42, -1, 3.14Python int or float
string"..."Unicode, escape-aware
bool#t #fTrue / False
nilnilPython None
symbolfield-eq?Identifier; only meaningful in head
list(1 2 3)Eager-evaluated as a function call

There is no quoting: every list is treated as a call. Literal lists come from list-producing primitives (e.g. (list 1 2 3)).

Special forms

These are evaluated lazily by the interpreter; they are not functions.

FormSemantics
(if COND THEN) / (if COND T E)Lazy; nil else if 2-arg
(and EXPR …)Short-circuit; returns #t/#f
(or EXPR …)Short-circuit; returns #t/#f
(not EXPR)Boolean inverse
(let ((N1 E1) (N2 E2) …) BODY)Bind N_i to E_i (left-to-right), eval BODY
(begin EXPR …)Sequential; returns last
(for-each ITEMS BINDING BODY)Eval BODY with BINDING per item

for-each body is evaluated for side effects; its return value is the result of the last iteration (or nil if items is empty).

Built-in functions (pure, no I/O)

These ship in make_default_env() and are always available.

FunctionSemantics
(= a b) (< a b) (<= a b) (> a b) (>= a b)Numeric/lex comparison
(+ …) (- a …) (* …) (/ a …)Arithmetic
(count xs)Length of list, or 0 if nil
(empty? xs)True if nil or zero-length
(nil? x)True if nil
(list …)Construct a list
(str a b …)String concatenation
(get coll key)dict[key] or list[idx]; nil if missing

Store-aware primitives (injected by the engine)

These are not in the DSL kernel — they appear in env when the engine evaluates a guard or effect against a node. Listed here so action authors know what's available.

Read primitives (guard-safe — no side effects):

CallReturns
(field NAME)First value of field; nil if absent
(field-on NODE-ID NAME)First field value on another node
(field-all NAME)List of all values
(field-nonempty? NAME)True if field exists and non-empty
(field-eq? NAME VALUE)Compare first value
(field-older-than? NAME DURATION)First-value timestamp vs now
(has-link? REL TARGET?)Any link of REL (and optionally to TARGET)
(links REL)List of to_id for given rel
(links-on NODE-ID REL)Outgoing target ids for another node
(incoming-links REL)List of source node ids linking to current node
(state MACHINE)Current state of node in MACHINE
(state-on NODE-ID MACHINE)Current state of another node in MACHINE
(find-by-tag SUPERTAG)List of node ids tagged with SUPERTAG (ordered newest-first)
(find-by-field SUPERTAG FIELD VALUE)List of node ids matching a field
(find-by-name NAME)First node id with that name, or nil
(all-incoming-field-in? REL FIELD LIST)Whether all incoming linked nodes have allowed field values
(exists-recent-field-contains? SUPERTAG FIELD TEXT DATE-FIELD DURATION)Recent relevant node exists
(count LIST)Number of elements in a list (nil-safe, returns 0 for nil)
(first LIST)First element of a list, or nil
(most-recent SUPERTAG)Id of most recently created node with SUPERTAG, or nil
(most-recent-by-state SUPERTAG MACHINE STATE)Id of most recently created node in a given machine state
(node-name)Name of current node
(node-name-of NODE-ID)Name of any node by id
(node-created-at)ISO timestamp of current node
(now)ISO timestamp
(days N) (hours N) (minutes N)Duration value
(date-diff A B)Seconds between ISO timestamps (A - B)
(business-days-between A B)Mon-Fri days after A through B

Write primitives (effect-only — invalid in guards):

CallSemantics
(set-field NAME TYPE VALUE)TYPE in: text/longtext/number/date/bool/blob
(set-field-on NODE-ID NAME TYPE VALUE)Set a scalar field on another node
(unset-field NAME)Remove all values
(create-link REL TARGET)TARGET is node-id string
(create-link-on FROM-ID REL TARGET)Create link from another node
(unlink-on FROM-ID REL TARGET)Delete a specific link from another node
(delete-link REL TARGET)
(transition TO-STATE)Write __state__<machine>
(transition-on NODE-ID MACHINE TO-STATE)Write another node's machine state
(call-kernel NAME ARG …)Run developer-registered fn
(create-node SUPERTAG NAME)Create a fresh node, tag it, return its id. Use when synthesising new nodes (drafts, review snapshots, agenda items) rather than looking up existing ones. Pair with set-field-on / create-link to populate. Guard-disallowed.
(upsert-by-identity SUPERTAG KEY-NAME KEY-VALUE DISPLAY-NAME)Get-or-create by natural key; returns node id. Idempotent — use when the node has a stable identity (e.g. Deal id, Goal id).

Guards calling write primitives is an eval error (the engine enforces this).

Read primitives vs kernels

The boundary is deliberately split:

  • Read primitives are guard-safe Python helpers injected by the engine. They must be deterministic, read-only, and free of external I/O. Examples: date-diff, business-days-between.
  • Kernels are effect-only Python helpers invoked via call-kernel. They may touch developer-owned capabilities such as HTTP, LLMs, filesystem, subprocesses, or store-aware computations. Guards cannot call them.

Kernels

call-kernel is the only way for effects to reach outside-world I/O (download, transcribe, HTTP, etc.) or developer-owned Python helpers. Kernels are registered by developers in Python:

python
@register_kernel("yt-dlp-download")
def yt_dlp_download(url: str) -> dict:
    ...
    return {"blob_key": "sha256:...", "metadata": "..."}

The DSL agent cannot define new kernels — it can only compose the ones the developer has shipped. This is the safety boundary: any side effect that touches the outside world has a Python function name attached to it, visible in KernelRef nodes.

call-kernel returns whatever the Python function returns (usually a dict); access fields via (get $result "key").

Invariants

  1. Guards are pure. No mutation, no kernel calls, deterministic read primitives only.
  2. Effects are sequential. No parallelism. begin and for-each execute in order.
  3. Kernel failures abort. Exceptions propagate out of the interpreter; no automatic partial-effect rollback in v0 (use the optional rollback field to express manual abort steps).
  4. Reactive action order is explicit. When one event matches multiple actions, the bus runs them by priority ASC, name ASC and logs a reactive_dispatch_plan audit event.
  5. Same-field conflicts are policy-controlled. The default is last-write-wins after explicit ordering. error-on-conflict blocks the whole conflicting group and records reactive_conflict_blocked. It also fails closed on unprovable writes: an error-on-conflict action whose field target is not a literal (a kernel call, a variable — anything static analysis cannot see) is blocked too, listed under opaque_blocked. The guarantee it asked for cannot be given statically, so the bus refuses rather than risk a silent accidental winner. Default-policy actions with dynamic targets are unaffected.
  6. Cross-event conflicts are surfaced statically. The dispatch plan only sees actions matched by the same event. Two actions writing the same derived field on different triggers never share a plan, so the runtime detector is blind to them. manifest() and ontology check run a whole-ontology static pass that reports these as latent_conflictsblocking when an error-on-conflict action is co-written elsewhere, advisory otherwise. Catch it at design time; the append-only tx_log cannot un-write a prior cascade hop.
  7. Out-of-vocabulary symbols error. No silent ignore.
  8. Type errors error. No coercion magic; (< "foo" 1) raises.

Example: bookmark bm_cache

scheme
;; guard
(field-nonempty? "链接")

;; effect
(let ((result (call-kernel "yt-dlp-download"
                           (list (list "url" (field "链接"))))))
  (begin
    (set-field "视频缓存键" "blob"     (get result "blob_key"))
    (set-field "元数据JSON" "longtext" (get result "metadata"))
    (set-field "缓存时间"   "date"     (now))
    (transition "cached")))

Example: bookmark bm_evict (with fallback to created_at)

scheme
;; guard — true if last access is older than 90 days, or no last access
;; and created_at is older than 90 days
(or (field-older-than? "最后访问时间" (days 90))
    (and (not (field-nonempty? "最后访问时间"))
         (> (- (now) (node-created-at)) (days 90))))

(Note: this uses > on durations; the engine treats durations and timestamps interchangeably as numeric seconds-since-epoch.)

Errors

All raise EvalError with a short message:

  • unbound symbol: X
  • cannot call non-callable: X
  • if expects 2 or 3 args, got N
  • let: bindings must be a list
  • kernel name not registered, etc.

There is no error recovery within an action; the engine catches EvalError and surfaces it as a failed dispatch with the message logged via tx_log.

Patterns

The DSL has no first-class aggregation primitive. Use the built-in sum-field-on kernel (effect-only) to denormalize a numeric aggregate:

scheme
(call-kernel "sum-field-on" NODE-IDS FIELD) → number

NODE-IDS is a list of node id strings; FIELD is a field name. Returns the sum of value_num across those nodes (0.0 if absent or non-numeric). Because sum-field-on is a kernel, it cannot be used in guards — only in effects.

scheme
(action "recompute_pipeline_total"
  (machine     "Opportunity")
  (from-states "working")
  (trigger     (on field-set (field "expected_amount") (supertag "Opportunity")))
  (guard       #t)
  (effect
    (let ((acct-ids (links-on (self) "account_ref"))
          (acct     (get acct-ids 0))
          (opps     (find-by-field "Opportunity" "account_ref" acct))
          (total    (call-kernel "sum-field-on" opps "expected_amount")))
      (set-field-on acct "pipeline_total" "number" total))))

Business-day SLA enforcement

(business-days-between START END) is a read primitive (guard-safe). It counts Mon–Fri days after START through END (ISO strings); returns an int; returns 0 if START >= END or on parse error. Friday 08:00 → Monday 08:00 = 1 business day.

scheme
(action "assess_business_day_slip"
  (machine     "PurchaseOrder")
  (from-states "placed")
  (trigger     (on field-set (field "实际到货时间") (supertag "PurchaseOrder")))
  (guard       (> (business-days-between (field "承诺到货时间")
                                         (field "实际到货时间"))
                  (field "SLA宽限天数")))
  (effect
    (begin
      (set-field "延迟原因" "text" "business_day_sla_breach")
      (transition "delayed"))))

Use (days N) for calendar-day durations; business-days-between for Mon–Fri counting. Do not substitute one for the other at week boundaries.