SRD-067 — The gobpm:lite text-expression evaluator¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-07-25 |
| Owner | Ruslan Gabitov |
| Implements | ADR-032 v.1 §2.3 (the stdlib-only battery language: the value model with float64 numeric unification, structural-path data access, the tiny builtin set, the gobpm:lite claim in the zero-config registry) + §2.4 (no per-evaluation facts) |
| Upstream | SRD-066 v.1 (the registry and the text kind this engine plugs into) — sideways; ADR-011 v.7 §2.9.2 (the structural-path resolver lite rides) |
Lands ADR-032's evaluator half and closes #74: the in-core ##Lite
engine interpreting the gobpm:lite text language over process data,
registered beside goexpr in the batteries default — out of the box a
model mixes functor and text expressions freely.
§1 Background¶
- The routing half is landed (SRD-066 v.1):
expression.Registryroutes each expression by itsLanguage();data.TextExpressioncarries language + body behind thedata.BodyHoldercapability (pkg/model/data/text_expression.go); the batteries default today isgoexpralone (pkg/thresher/thresher.go— the registry build inNew;internal/enginert/enginert.go:72—defaultExprRegistry). - The runtime data surface already resolves structural paths: the
frame's
GetDatahands any name todata.ResolvePath(internal/scope/frame.go:196; the resolver —pkg/model/data/path.go:189), soorder.items[0].priceandrates["EUR"]arrive at aSource.Findas plain names. Lite therefore needs no path logic of its own — it passes the whole path token tosrc.Find(ctx, path). - The value model lite reads (the
valuespackage):Variable[T],Array[T](Count()),Map[T](Keys(),Entry()),Record.Variable[T].Type()names the payload type via reflection (variable.go:61) —bool,float64,string,Time— the same vocabularyGExpression.ResultType()exposes and the condition paths check (pkg/model/activities/activities.go:9:resultTypeBool = "bool"; the guard runs before evaluation, SRD-066 §10). - The condition consumers assert the evaluated value with an unchecked
res.Get(ctx).(bool)(pkg/model/activities/flowselect.go:103) — an engine returning a declared-type-mismatched value would panic the track. The engine side must therefore fail loud on a result-type mismatch instead of returning it. - The timer layer's value type is
time.Time; RFC3339 is the repo's interchange format for time literals (JSON, dtable precedent).
§2 Requirements¶
§2.1 Functional¶
- FR-1 — the language core (lexer + parser). Literals: numbers
(
150,1.5— all parse to float64, ADR-032 §2.3's numeric unification), strings (single- or double-quoted,\-escapes for the quote and the backslash),true/false,nil. Names: bare identifiers and structural paths (order.total,items[0],rates["EUR"]) lexed as one data-reference token. Operators, loosest to tightest:or→and→not→ comparisons (== != < <= > >=) → additive (+ -) → multiplicative (* / %) → unary-→ primary (literal, data ref, builtin call,( )). A syntax error is a classified error naming the byte offset and what was expected. The parse runs per evaluation (stateless engine; lite expressions are tiny — §4.3). - FR-2 — evaluation semantics. Operand kinds: float64, string, bool,
time.Time, nil. The numeric family (all Go int/uint widths, float32, float64) read from data unifies to float64. Comparisons: numbers, strings and times order (< <= > >=) and equate within their own kind; bools and nil equate only (==/!=); every cross-kind comparison is a loud classified error — never a silent false.and/orshort-circuit and require bool operands;notrequires bool.+adds two numbers or concatenates two strings;- * /and unary-require numbers;%ismath.Mod; division by zero is a loud error. Data reads go throughsrc.Find(ctx, <full path>); a missing datum or dead path fails loud (the resolver's own error relayed with the expression context). A whole-expression result ofnilis a loud error —nilis a comparison operand, not a value an expression may produce (values.Variablecannot carry it: itsType()reflects on the payload). - FR-3 — builtins (
has,len,time— nothing else).has(<string>)probes a name-or-path: resolution success →true, any resolution failure →false(the explicit opt-out of fail-loud, third time the house split).len(x): anArray→Count(), aMap→len(Keys()), a string → rune count; any other operand is a loud error; the result is float64.time(<string>)parses an RFC3339 literal totime.Time; a malformed literal is a loud error. Unknown function names are loud parse-time errors. - FR-4 — the engine surface. Package
pkg/model/expression/lite:New() *Engine;Type() = "##Lite";Languages() = {lite.Language}withLanguage = "gobpm:lite".Evaluate(ctx, expr, src)validates all params, requiresexprto implementdata.BodyHolder(loud error naming the capability otherwise), evaluates the body againstsrc, and returns the result as avalues.Variableof the produced Go kind. When the expression declares a result type (ResultType() != ""), the produced value'sType()must match it — a mismatch is a loud error (the §1 panic guard: a declared-boolcondition can never hand a string toflowselect.go:103). - FR-5 — conveniences.
lite.Expr(body string, opts ...options.Option)mints adata.TextExpressionpre-taggedgobpm:lite(ADR-032 §2.3 — lite as the default text language without any registry fallback).lite.Cond(body string, opts ...options.Option)=Expr+data.WithResultType("bool")— the one-call condition constructor (SRD-066 §10: the declaration is load-bearing on the condition paths). - FR-6 — the batteries wiring. The zero-config registry becomes
goexpr+litein both builders:thresher.New(the prepended defaults) andenginert.defaultExprRegistry. The startup routing table shows both claims (exprLanguage gobpm:goexpr: ##GoExpr,exprLanguage gobpm:lite: ##Lite);WithoutDefaultExpressionEngines()removes both. SRD-066's T-4 assertions update to the widened default (##GoExpr+##Lite). - FR-7 — the example.
examples/expression-routing/: one process showing the expression layer at three consumer sites with two engines mixed (per the owner's direction at the M2 gate) — a task's outgoing flows carrying a lite text condition (record path) beside agoexprfunctor (map key), an exclusive gateway branching on a litetime()comparison with a default flow, and a UserTask whose assignee is computed by a lite string expression (WithAssigneeExpr); split per the >80-line rule; the examples index and both READMEs gain the entry.
§2.2 Non-functional¶
- NFR-1 — stdlib-only. No new
go.moddependency — the in-core placement contract (ADR-032 §2.3). - NFR-2 — validate-all-params; no
Must*calls in library paths (the muststyle guard covers the new package). - NFR-3 — no per-evaluation facts (ADR-032 §2.4): no Reporter calls
anywhere in
lite. - NFR-4 — coverage:
make cigreen; diff-coverage ≥95% (aim 100%); touched functions ≥80%.
§3 Models (shapes)¶
// pkg/model/expression/lite (FR-4/FR-5)
const Language = "gobpm:lite"
func New() *Engine
func (e *Engine) Type() string // "##Lite"
func (e *Engine) Languages() []string // {Language}
func (e *Engine) Evaluate(ctx context.Context, expr data.FormalExpression,
src data.Source) (data.Value, error)
func Expr(body string, opts ...options.Option) (*data.TextExpression, error)
func Cond(body string, opts ...options.Option) (*data.TextExpression, error)
// internals (unexported): lexer.go (token stream over the body),
// parser.go (recursive-descent → ast nodes), eval.go (ast walk against
// data.Source; operand = one of float64|string|bool|time.Time|nil).
Worked full-path trace (the ADR-032 §2.3 table rows are the fixture
set): a process property order = values.Record{total: 150 (int),
customer: Record{tier: "gold"}}; the flow condition
lite.Cond(order.total > 100 and order.customer.tier == "gold").
The activity's flow-selection guard sees ResultType() == "bool",
the registry routes gobpm:lite → ##Lite, lite lexes
order.total as one data-reference token, src.Find(ctx,
"order.total") walks the record (ResolvePath), the int 150 unifies
to float64, both comparisons hold → values.NewVariable(true) — the
lane fires.
§4 Analysis & decisions¶
- §4.1 Paths delegate to the landed resolver. Lite lexes a
structural path as one token and hands it to
src.Findverbatim — the frame already routes every name throughdata.ResolvePath(§1). No second path grammar, no drift risk against SRD-042. - §4.2 Result-type enforcement lives in the engine. The condition
paths check the declaration before evaluation but assert the
value unchecked after it (§1) — the engine is the only place that
sees both.
goexpris safe by construction (its result type IS the functor's ItemDefinition); lite must check explicitly. - §4.3 Parse per evaluation, no cache. Lite expressions are tiny (one line); a body-keyed cache adds a concurrent map and an invalidation story for no measured need. Revisit only with profile evidence.
- §4.4
hasswallows all resolution errors. An existence probe that faulted on "exists but not Ready" would force callers back into try/catch reasoning;hasanswers exactly one question — "will reading this succeed right now" (mirrors the rules/luaIfPresent/hasprecedents). - §4.5 Rune count for
lenon strings. A text DSL user counting characters expectslen("привет") == 6, not 12 (Go's byte-len); arrays/maps count elements — consistent "how many things" semantics.
§6 Test scenarios¶
| # | Test | Verifies |
|---|---|---|
| T-1 | lexer/parser unit (lite) |
FR-1: literals, paths-as-tokens, precedence (a or b and c, not binding, unary -), parse errors with offset, unknown function loud |
| T-2 | the ADR table (lite) |
FR-2/FR-3: all ten ADR-032 §2.3 worked-example rows verbatim over real values kinds (int/float/string/bool/time Variables, Array, Record, Map) |
| T-3 | type discipline (lite) |
FR-2: cross-kind comparisons loud (string vs number, time vs number, bool ordering), non-bool and/not loud, / 0 loud, short-circuit proven (the right side would fault), nil equality, a whole-expression nil result loud |
| T-4 | builtins (lite) |
FR-3: has true/false on present/absent/dead-path, len on array/map/string-runes + loud on a number, time parse + malformed loud |
| T-5 | the engine surface (lite) |
FR-4: ##Lite/claims; non-BodyHolder expression loud; declared-bool + string result loud (the mismatch guard); nil/missing params rejected |
| T-6 | wiring (pkg/thresher, internal/enginert) |
FR-6: zero-config ##GoExpr+##Lite + both routing lines; opt-out removes both (SRD-066 T-4 updated) |
| T-7 | e2e (pkg/thresher) |
a process with a lite.Cond text condition and a functor condition side by side, zero extra registration — both lanes route and fire (the ADR's "mixes freely" promise) |
| T-8 | example smoke | FR-7: timeout 25 go run . in examples/expression-routing/ exits 0 — all three sites fire (both intake lanes, the urgent gateway branch, the lite-assigned approval completed by the matching actor) |
§7 Milestones¶
- M1 — the evaluator and the engine. FR-1…FR-5; T-1…T-5.
feat(expression): the gobpm:lite text-expression evaluator (SRD-067 M1). - M2 — batteries wiring, e2e, the example, doc sync. FR-6/FR-7;
T-6…T-8; CHANGELOG (the evaluator half), READMEs + examples index.
feat(thresher): gobpm:lite in the batteries registry (SRD-067 M2).
§8 Cross-doc¶
- Implements ADR-032 v.1 §2.3/§2.4 — closes the ADR (both halves landed → the ADR flips Accepted with this landing).
- Sideways: SRD-066 v.1 (the registry/text kind), SRD-042 v.1 (rides its resolver via the Source, no direct coupling).
- Closes #74 (the PR carries
Closes #74).
§9 Definition of Done¶
- [x] FR-1…FR-7 implemented; every §6 test exists and passes.
- [x]
make cigreen; diff-coverage ≥95% (aim 100%); touched functions ≥80%; no newgo.moddependency. - [x] The example runs (T-8); READMEs/examples index synced; CHANGELOG synced.
- [x] §10 filled; ADR-032 flipped Accepted; linked docs synced.
§10 Implementation summary¶
Landed on feat/expression-layer in two milestones, as planned:
| Milestone | Commit | Scope |
|---|---|---|
| M1 | 909ed60 |
FR-1…FR-5: pkg/model/expression/lite — lexer/parser/evaluator, the ##Lite engine with result-type enforcement, Expr/Cond; T-1…T-5 incl. all ten ADR-032 §2.3 rows verbatim (package coverage 97.1%) |
| M2 | 3b379ba |
FR-6/FR-7: goexpr+lite batteries in both builders, T-6/T-7, examples/expression-routing (three sites, two engines — the widened FR-7 scope), README/index/CHANGELOG sync |
- Verification: post-M2
make ciexit 0; diff-coverage 98.2% of 763 changed lines (min 95%) —lite.go/registry.go/wiring at 100%,eval.go96.2%,lexer.go99.4%,parser.go99.2%; no newgo.moddependency (NFR-1); no Reporter calls inlite(NFR-3); T-8 smoke exit 0 with all three sites firing. - Deltas vs the draft: (1) FR-7 widened at the owner's M2-gate
direction from a lite-only routing demo to the three-site
mixed-engine example (task flows with lite beside a
goexprfunctor, the XOR gateway with a litetime()branch, the lite-computed UserTask assignee viaWithAssigneeExpr) — FR-7/T-8 amended pre-landing in the same branch. (2) Engine note surfaced by T-3: a bare array reference reads the collection cursor's current element (theArray.Getcontract), while maps/records snapshot to Go maps and are loud non-operands — the tests document both behaviors. - Closure: with SRD-066 this completes ADR-032 v.1 (flipped Accepted) and closes #74.
Open questions¶
None — §4 resolves the design points inline.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-07-25 | Ruslan Gabitov | Initial draft — the ADR-032 §2.3 battery: the stdlib-only gobpm:lite evaluator (##Lite; float64 numeric unification, loud cross-kind comparisons, short-circuit booleans, structural paths via the landed resolver, has/len/time builtins), result-type enforcement in the engine (the condition-path panic guard), the Expr/Cond conveniences, goexpr+lite as the zero-config batteries, an example, and the #74 closure. |