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, nolambda, no tail-call optimization. - No general recursion (
for-eachis 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
floatgives 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-equivalentNotes:
- 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
| Type | Literal | Notes |
|---|---|---|
| number | 42, -1, 3.14 | Python int or float |
| string | "..." | Unicode, escape-aware |
| bool | #t #f | True / False |
| nil | nil | Python None |
| symbol | field-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.
| Form | Semantics |
|---|---|
(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.
| Function | Semantics |
|---|---|
(= 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):
| Call | Returns |
|---|---|
(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):
| Call | Semantics |
|---|---|
(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
- Guards are pure. No mutation, no kernel calls, deterministic read primitives only.
- Effects are sequential. No parallelism.
beginandfor-eachexecute in order. - Kernel failures abort. Exceptions propagate out of the interpreter; no automatic partial-effect rollback in v0 (use the optional
rollbackfield to express manual abort steps). - Reactive action order is explicit. When one event matches multiple actions, the bus runs them by
priority ASC, name ASCand logs areactive_dispatch_planaudit event. - Same-field conflicts are policy-controlled. The default is
last-write-winsafter explicit ordering.error-on-conflictblocks the whole conflicting group and recordsreactive_conflict_blocked. It also fails closed on unprovable writes: anerror-on-conflictaction whose field target is not a literal (a kernel call, a variable — anything static analysis cannot see) is blocked too, listed underopaque_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. - 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()andontology checkrun a whole-ontology static pass that reports these aslatent_conflicts—blockingwhen anerror-on-conflictaction is co-written elsewhere,advisoryotherwise. Catch it at design time; the append-only tx_log cannot un-write a prior cascade hop. - Out-of-vocabulary symbols error. No silent ignore.
- 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: Xcannot call non-callable: Xif expects 2 or 3 args, got Nlet: 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
Aggregation across related nodes
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) → numberNODE-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.