ADR-032 — Language-routed expression engines and the text-expression battery¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-25 |
| Owner | Ruslan Gabitov |
| Refines | ADR-002 v.2 §4.1/§4.2 (the extension pattern; ExpressionEngine is one of its named seams), ADR-031 v.1 (the multi-engine registry conception this transplants: enumerable claims, core routing, loud conflicts — proven for scriptFormat, applied here to the spec's language), ADR-011 v.7 (the data-flow layer expressions evaluate within), SAD-001 v.1 §11 |
Expressions run everywhere — conditional flows, gateways, timers,
Multi-Instance cardinality and completion, Standard Loop conditions,
correlation retrieval, conditional events, data transformations — yet the
expression seam is the engine's least finished: a single, replace-wholesale
engine whose default merely delegates to each expression's own Evaluate,
and no way to write an expression as text. This ADR completes the
Expression Layer epic's conception: multiple expression engines register
simultaneously, routed by the spec's own language attribute, a text
expression kind carries language + body for engines to interpret, and
a stdlib-only lightweight evaluator ships in core as the text battery.
1. Context & problem¶
What exists (all landed):
data.FormalExpression— the abstract contract (Language(),Evaluate(ctx, Source),ResultType(), …), with one implementation: the Go-functor kind (data/goexpr.GExpression, languagegobpm:goexpr) — a closure over the scope-backeddata.Source.- The seam:
expression.Engine(one method —Evaluate(ctx, expr, src)), the five-point wiring, and the in-core default (expression/goexpr.Engine) that delegates to the expression's ownEvaluate— a pass-through. - Scope integration: every runtime consumer (conditions, timers, MI,
loops, correlation, conditional events) already evaluates through
ExpressionEngine()against the instance's data surface.
What's missing — the epic's open half:
- Language routing. The seam is single-engine and replace-wholesale:
wiring a FEEL engine would silently take over
gobpm:goexprfunctor expressions too — a mixed-language model (Go conditions here, FEEL there) is impossible, although the metamodel gives everyFormalExpressionalanguageattribute (URI, 0..1) for exactly this. - Text expressions. There is no
FormalExpressionkind carrying a source body;total > 100cannot be written as a string, which blocks both low-ceremony modeling and any future XML interchange of expressions. - A text battery. With no text kind there is naturally no default evaluator for one; FEEL/JUEL are reserved as adapters (roadmap WS-E), but a dependency-light in-core option is the epic's explicit "batteries included" deliverable.
The multi-engine registry conception is already proven twice — formats
for scripts (ADR-031), decision names for rules (ADR-027) — and the
language attribute is its exact expression-layer analog.
2. Decision¶
2.1 The seam — multiple engines, language-routed by the core¶
expression.Engine grows from one method to the registry contract:
Type()— the##-kind for the startup config;Languages()— the enumerable claim list (language URIs, matched case-insensitively; enumerable for the same reason as ADR-031'sFormats(): conflicts must be detectable and the routing table printable);Evaluate(ctx, expr, src)— unchanged in shape.
The core registry (pure Go, in pkg/model/expression) folds claims
into a language→engine map at construction: duplicate claims are loud
construction-time errors naming both kinds; the startup config prints
the routing table; evaluation routes by the expression's Language().
An unclaimed language is a classified error listing the registered
claims; an empty language is a classified error too (the metamodel's
0..1 exists for the Definitions-level default-language inheritance — an
interchange concern deferred with interchange itself; programmatic
expressions always know their language).
WithExpressionEngine becomes repeatable — each call registers
another engine (the ADR-031 semantic; pre-1.0, the old replace-wholesale
meaning is retired and documented). The zero-config default registry
contains the batteries pair (goexpr + lite), so every existing
functor expression works untouched — the compatibility bar for this
change.
The batteries are removable from the runtime:
WithoutDefaultExpressionEngines() starts the registry empty — every
engine then registers explicitly (including re-adding goexpr/lite,
which stay public constructors). A goexpr-free runtime rejects functor
expressions exactly like any unclaimed language — loud, with the claims
listed. This answers "don't pay for what you don't route" at the registry
level; physically extracting GExpression/goexpr into an adapter
module is rejected (§4): it is stdlib-only (nothing to shed; the linker
already drops unreferenced code), and the whole test/example/model corpus
builds functor conditions — which core tests could no longer import under
the core⊅adapters rule.
2.2 Two evaluation loci, one contract¶
The FormalExpression kinds split honestly by where evaluation lives:
- The functor kind (
gobpm:goexpr, existing) self-evaluates: its logic is the Go closure. Its engine — the registeredgoexpr— remains the delegate it always was, now claiming exactly its own language. - The text kind (new) cannot self-evaluate: it carries
language bodyand its ownEvaluatefails loud ("a text expression evaluates through the engine registry") — interpretation belongs to the routed engine. Engines reach the source through aBody()capability (a small interface the text kind implements; the functor kind doesn't) — theFormalExpressioncontract itself stays untouched for existing implementations.
The text kind is constructed with a required language and body
(fail-fast; the metamodel's 0..1 on language is the interchange
affordance, as with scriptFormat), and optionally the result type
(evaluatesToTypeRef's programmatic analog).
2.3 The battery — gobpm:lite, a stdlib-only in-core evaluator¶
The text battery is a small expression language evaluated by an in-core engine (pure stdlib — the dependency-light contract holds, which is what makes in-core placement legitimate where interpreters were not):
- Values: numbers (the numeric family — int, int64, float64 —
unifies to float64 inside lite: a text language cannot demand Go type
knowledge, so
total > 100works for an int datum; the deliberate, documented relaxation of the dtable strictness, justified because lite has no Go-typed operand side), strings (single or double quoted), booleans,nil, and time (time.Timedata — the timer layer's value type — with atime("RFC3339")builtin for literals). Cross-family comparisons (string vs number, time vs number, …) stay loud errors — never a silent false. - Data access: bare names resolve through the scope walk-up; dotted
and bracketed structural paths ride the landed SRD-042 resolver
(
data.ResolvePath) — record fields, array indices and map keys with one syntax. A missing datum or dead path fails loud;has("name-or-path")probes optional data (the house split, third time identical). - Operators: comparisons (
== != < <= > >=; ordering for numbers, strings and times), arithmetic (+ - * / %), boolean (and or not), string concatenation (+on two strings), parentheses. A deliberately tiny builtin set (has,len,time) — anything richer belongs to FEEL and its adapter. - Kind and language: the engine registers
gobpm:litealongsidegoexprin the zero-config default registry — out of the box a model mixes functor and text expressions freely. There is no single "default engine": each expression routes by its own language — a functor expression togoexpr, a text one to whatever it names. Lite's convenience constructor (lite.Expr(body)) mints text expressions pre-taggedgobpm:lite, making lite the default text language in practice without any registry-level fallback.
Worked examples over the actual value model (the values package's
kinds — single values, arrays, structures/records, maps):
| Datum (values kind) | Expression | Result |
|---|---|---|
total = 150 (int Variable) |
total > 100 and total <= 500 |
true — int promoted into the numeric family |
rate = 0.19 (float64 Variable) |
total * (1 + rate) |
178.5 (float64) |
tier = "gold" (string Variable) |
tier == "gold" or tier == "vip" |
true |
name (string Variable) |
"Dear " + name |
string concatenation |
approved = true (bool Variable) |
approved and not blocked |
boolean logic |
deadline (time.Time Variable) |
deadline > time("2026-08-01T00:00:00Z") |
time ordering against a literal |
created_at, updated_at (times) |
updated_at > created_at |
time-vs-time ordering |
items (Array) |
len(items) > 0 and items[0] == "sku-1" |
length + index access (the path resolver) |
order (Record/structure) |
order.total > 100 and order.customer.tier == "vip" |
nested field paths |
rates (Map) |
has('rates["EUR"]') and rates["EUR"] < 1.2 |
map-key path access with an existence probe |
Why not FEEL in core: FEEL is a large, evolving standard whose faithful
implementation is an adapter-scale dependency (roadmap WS-E reserves
adapters/feel); a "small FEEL subset" in core would be a permanently
misleading claim. gobpm:lite claims nothing but itself.
2.4 Observability¶
Expression evaluation is a hot path fanning across every condition — a
per-evaluation fact would be the flood ADR-013's masking/volume rules
exist to prevent (the DataChange lesson). Decided: no per-evaluation
facts; the registry appears in the startup config (kinds + routing
table), and evaluation failures surface through the consumers' existing
error paths, which already carry node/instance context.
2.5 Engine notes (deviations & choices)¶
| Choice | Standard position | Engine choice |
|---|---|---|
| Language routing | language URI, 0..1, with a Definitions-level default |
multiple registered engines, core-routed by enumerable claims (conflicts loud); empty language is a loud error — the Definitions default inheritance rides interchange (§2.1) |
| Text expressions | FormalExpression body + language |
the text kind with required language/body and a Body() capability; self-evaluation refused loud — the routed engine interprets (§2.2) |
| The default language | Definitions-level expressionLanguage |
zero-config registry = goexpr + lite; no implicit default-language rewriting; WithoutDefaultExpressionEngines() empties the registry for explicit-only wiring (§2.1/§2.3) |
| The battery language | not mandated (FEEL is DMN's, not BPMN's mandate) | gobpm:lite — a tiny stdlib-only language that claims nothing but itself; FEEL/JUEL are adapters (§2.3) |
| Numeric typing in lite | — | the numeric family (int/int64/float64) unifies to float64 inside lite (a text language has no Go-typed operand side); cross-family comparisons stay loud (§2.3) |
| Evaluation facts | — | none per evaluation (the hot-path flood guard); the registry is startup-visible (§2.4) |
3. Grounding¶
| Claim | Source |
|---|---|
FormalExpression: language String attr 0..1, evaluatesToTypeRef |
the vendored extract, elements/foundation.md §FormalExpression |
| The default expression language lives on Definitions | the data.FormalExpression doc comment (landed, quoting the spec) |
| The current seam: one method, pass-through default, replace-wholesale option | pkg/model/expression/expression.go, pkg/model/expression/goexpr (landed) |
| The functor kind + its language | pkg/model/data/goexpr (gobpm:goexpr, landed) |
| Ten-plus runtime consumers over the scope surface | conditions/timers/MI/loops/correlation/conditional (landed; grep ExpressionEngine()) |
| The registry conception: enumerable claims, loud conflicts, core routing | ADR-031 v.1 §2.1 (landed: pkg/script) |
| The structural-path machinery the lite language reuses | SRD-042 lineage (data.ResolvePath, landed) |
| FEEL reserved as an adapter | the roadmap, WS-E |
4. Alternatives considered¶
- Keep single-engine replace-wholesale (status quo). Rejected — a mixed-language model is impossible; wiring FEEL breaks every functor expression silently, the exact shadowing class the registries were built to kill.
- Dispatch inside a composite adapter (a user-assembled multiplexer engine). Rejected — every embedder rebuilds the same router; conflicts stay silent; the core owns routing everywhere else.
- FEEL (or a subset) as the in-core battery. Rejected — §2.3; the honest claim is a tiny language of our own naming.
- Text expressions that self-evaluate (embedding the lite parser in
the model layer). Rejected — couples
pkg/model/datato an evaluator and forecloses engines interpreting the same body differently; the split of loci (§2.2) keeps the model layer inert. - Per-evaluation observability facts. Rejected — §2.4, the hot-path flood guard.
- Extracting
GExpression/goexprinto an adapter module. Rejected — stdlib-only code with zero dependency weight (dead-code elimination already handles binaries); core tests, examples and model fixtures build functor conditions and cannot import adapters (ADR-003 §4.4 direction). The legitimate removability lives at the registry:WithoutDefaultExpressionEngines()(§2.1).
5. Consequences¶
Positive: the Expression Layer epic closes conceptually — pluggable,
simultaneous engines routed by the spec's own attribute; text
expressions unlock low-ceremony modeling and pave interchange; the lite
battery gives total > 100 and has("tier") out of the box; FEEL/JUEL
slot in later beside — not instead of — everything else.
Negative / cost: the expression.Engine interface widens (pre-1.0;
one external implementor class — adapters — none shipped yet); the
WithExpressionEngine semantic changes from replace to register
(documented); a new in-core parser to maintain (bounded by the tiny
grammar).
Follow-ups this conception sets up: adapters/feel (WS-E) beside
lite; the Definitions-level default-language inheritance with
interchange; richer lite builtins only if demand proves them.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 (Accepted) | 2026-07-25 | Ruslan Gabitov | Accepted with both landing SRDs: the widened engine contract, the language registry (itself an Engine — every consumer untouched), the repeatable/opt-out wiring and the text kind landed first (SRD-066); the gobpm:lite stdlib evaluator with the goexpr+lite batteries default and the three-site mixed-engine example completed the conception (SRD-067). adapters/feel remains the noted follow-up. |
| v.1 | 2026-07-25 | Ruslan Gabitov | Draft conception, completing the Expression Layer epic's design. Multiple expression engines register simultaneously (repeatable WithExpressionEngine; enumerable Languages() claims folded into a core routing registry — duplicate claims loud, unclaimed/empty languages loud with the claims listed; zero-config = goexpr + lite, so functor expressions keep working untouched). Two evaluation loci: the functor kind self-evaluates (its engine stays the delegate), the new text kind (required language + body, a Body() capability) refuses self-evaluation — the routed engine interprets. gobpm:lite is the stdlib-only in-core text battery covering the full value model — numeric-family promotion to float64 (documented; cross-family stays loud), strings, booleans, time (ordering + the time() RFC3339 builtin), arrays/records/maps through the SRD-042 path resolver, has/len/time builtins only — with worked §2.3 examples per kind. There is no registry-level default engine — each expression routes by its own language; lite.Expr(body) makes lite the default text language by construction; WithoutDefaultExpressionEngines() empties the zero-config registry (the batteries are removable from routing — physically extracting the stdlib-only goexpr into an adapter is rejected, §4). FEEL/JUEL remain adapters. No per-evaluation facts (the hot-path flood guard); the registry is startup-visible. Implementation rides the accompanying SRDs (routing + the text kind, then the lite evaluator). |