JEXL expression dialect in workflow templates
Context
T-Bone workflow templates resolve {{...}} placeholders through a
hand-rolled parser in apps/brain/src/modules/domain/workflow/engine/template.ts.
The dialect grew incrementally: paths (trigger.*, nodes.*), secret:,
fallbacks (||, ??), and most recently ternaries with equality. Workflow
authors now need arithmetic (+ - * /) and rounding, and each addition
strains the parser further — it matches flat operator-token layouts and has
no expression tree, no precedence, and no parentheses.
Forces at play:
- Flexibility for workflow authoring is the priority; more expression features are certain to be requested.
- Security: expressions execute server-side in brain; anything
eval-shaped is unacceptable. Authors are ADMIN-only, so the threat model is defense-in-depth rather than untrusted input. - Maintenance: we do not want to own and evolve a custom expression grammar forever, nor accumulate a legacy dialect that silently grows.
- Zero regression: published workflows and pinned execution graphs
(
WorkflowExecution.pinnedGraph) must keep evaluating byte-for-byte identically, including quirky legacy semantics (empty array/object is falsy, strict==). - Authoring tooling: Flank’s template autocomplete and any live validation must work in the new dialect. A dialect that the editor cannot assist with pushes authors back to the legacy one, which would stall the migration this ADR is meant to enable.
Decision
Adopt the JEXL expression dialect for workflow template placeholders,
opted into per placeholder with an explicit = prefix: {{= expr }}.
Placeholders without the prefix keep going through the existing legacy
parser, which is feature-frozen from now on — all new expression
capabilities land in JEXL only.
Zero regression is structural: {{= ...}} never parsed in the legacy
dialect (it fell through to path resolution and yielded undefined), so no
existing workflow can depend on it. The prefix doubles as an in-band dialect
signal at the caret, which lets Flank’s autocomplete stay dialect-correct
without threading per-field state through the form components.
Within a single config field, placeholders must all belong to one dialect:
mixing legacy and {{= }} in one field is rejected by validation. The rule
keeps per-field semantics unambiguous, pushes authors to finish a field
rather than half-migrate it, and makes a graph scan classify every field
cleanly.
Implementation: the original jexl package (2.3.0, ~167k weekly
downloads, one runtime dependency on @babel/runtime), wrapped in a single
adapter module. Ship local type declarations, since the package is untyped.
The same package is used in Flank for parse-time validation, which pins the
grammar on both sides and so removes parser drift between editor diagnostics and
runtime. It does not make the two identical: jexl holds transform and function
registrations per Jexl instance, and Flank builds its own instance registering
only the ?? binary operator. An expression calling a Brain helper therefore
parses cleanly in the editor while only Brain can evaluate it.
The adapter registers each helper both as a transform and as a function,
because the call form is what authors reach for first and each registration is
one line: round / floor / ceil (with an optional decimal-digits argument),
truthy replicating legacy isTemplateTruthy (empty array/object falsy) so
rewriting a legacy fallback stays mechanical, plus length and number, which
exist to work around two package behaviours documented below. It blocks
__proto__ / constructor / prototype path segments as defense in depth. The
evaluation context is materialized from ExecutionContext as plain JSON
(trigger, nodes keyed by both id and alias, alias winning as in
resolve()), and the existing exact-placeholder native-type return is
unchanged.
A broken expression throws rather than resolving to undefined, and so
does one that evaluates to NaN. A legacy path that does not resolve is
ordinary; a malformed expression is an authoring error, and failing the node
with the parser’s message beats silently writing an empty value or NaN into a
prompt.
Four package behaviours were verified empirically against 2.3.0, because three of them contradict what the syntax suggests and all four shape the docs:
- The pipe binds tighter than arithmetic.
price * 1.23|round(2)rounds1.23, not the product — it is valid and silently means something else. The call formround(price * 1.23, 2)or explicit parentheses are what authors want, so the docs lead with the call form. - A ternary cannot follow a call or a transform.
truthy(x) ? a : bis a syntax error, and so isitems|length ? a : b; both parse once the condition is parenthesised. The parser has no transition from a finished call to?, and a transform is the same kind of node. The adapter appends the fix to the parser’s opaque “Token ? (question) unexpected” message, since the empty- collection check the docs teach runs straight into this. array.lengthdoes not resolve. JEXL reads a property off an array by taking its first element, sofaces.lengthisundefinedrather than a count. Hence thelengthhelper, and hence the docs cannot teachitems.lengthas the empty-collection check.__proto__is reachable through computed access.nodes["__proto__"]returnsObject.prototype, whilenodes.a.constructoris already rejected by the lexer. A recursive proxy over the scope is therefore the guard, not a string check on the source.
Three facts were verified against current code:
- The executor’s data gate keeps working unchanged. It extracts predecessor
references with
NODE_REF_REinside each placeholder, which matchesnodes.<alias>after any non-identifier character, so references nested in a JEXL call such asround(nodes.a.width * 2)are still collected. - Placeholder extraction cannot contain
}, becausePLACEHOLDER_REuses[^}]+. JEXL object literals are therefore unsupported inside placeholders. Accepted: no planned use case needs them, and widening the regex would require brace balancing. - The
secret:placeholder branch is dead code. Nothing in the repository supplies aSecretResolver: the flank and sirloin secret stacks were deleted, brain has no secret store, and the remainingsecretResolverplumbing is pass-through only — so{{secret:name}}has always resolved toundefined. Removing the branch is behaviour-preserving, since an unmatched path already yieldsundefined, and is done as part of this work.
Flank needs editor support for the new dialect, and it is not free. The
autocomplete trigger derives suggestions from the whole substring after
{{, so {{= matches none of its patterns and the dropdown silently stops
appearing. The rework makes suggestions token-aware (complete the path token
before the caret rather than the whole placeholder body), adds transform
suggestions after | gated on the = prefix, and surfaces the type and
description that node outputSchemas already declare but Flank currently
discards.
Full migration of stored workflows to JEXL and deletion of the legacy parser are deferred to a later phase (FOXY-727 P4).
Consequences
Easier
- Arithmetic, rounding, comparisons, parentheses, and string operations in any config field of any node.
- Future expression features are a
jexl.addTransformcall, not a grammar change. - The dialect outlives the package: JEXL has multiple implementations
(original,
@pawel-up/@hypatiatechforks, mozjexl, pyjexl) and the adapter is the only coupling point, so the package is swappable or vendorable. - Nothing about the stored graph shape changes, so node config validation, the executor, and Flank’s form components stay dialect-agnostic.
- Sharing one parser with Flank enables live syntax diagnostics that match runtime exactly.
Harder / risks
- No stored signal records how much legacy syntax remains, so the P4 sunset needs a graph scan rather than a cheap query. Acceptable: the migration has to walk every stored graph anyway, and the single-dialect-per-field rule makes that scan unambiguous at field granularity.
- Forbidding mixed dialects concentrates rewrite risk: adding one rounded
value to a prompt with eight placeholders mandates rewriting all eight,
including fallbacks whose truthiness semantics change. Mitigated by a
targeted warning on
||/??over array/object-typed operands. - The dialect is invisible without editor affordance — nobody discovers
{{=on their own. Flank must offer it in the dropdown, and the docs tutorial carries the rest. - Two dialects coexist until P4: legacy truthiness (empty array is falsy)
differs from JEXL’s JS semantics. Mitigated by the per-field marker, the
truthytransform, and durable docs recommending JEXL for everything new. jexlhas had no release since 2020. Accepted: it is a small, dependency-light grammar interpreter with no I/O and noeval, inputs are ADMIN-authored, and it is vendorable. A frozen grammar is desirable for a dialect persisted in stored graphs.- The feature freeze on the legacy parser must be enforced in review; a note lives in the workflow module agent docs.
Rollback
- The prefix hook is a few lines in
resolvePlaceholder; removing it (or flagging it off) restores prior behaviour exactly. No stored data changes in P0.
Alternatives Considered
- Per-field dialect marker stored on the node (a “legacy engine” toggle in
the editor). Considered on 2026-08-04. It would make the remaining
legacy surface a cheap database query, giving the P4 sunset a burn-down,
and would let the new dialect drop the
=. Rejected as materially more scope for little gain: it changes the stored graph shape and forces node config validation, the executor’s per-field resolution, and Flank’sSchemaFormto become dialect-aware, while the burn-down it buys is also obtainable by scanning stored graphs — which the migration must do anyway. The inline prefix additionally keeps the dialect signal at the caret, where the editor needs it. - Extend the hand-rolled parser (Pratt rewrite). Gives one coherent dialect but commits us to owning a custom grammar forever — every future operator or function is our parser work. Rejected on the no-maintenance-burden priority.
- Dedicated “expression” node instead of a dialect. A new core node
evaluating JEXL, consumed via
{{nodes.calc.value}}. Rejected: every computed value would require an extra node plus wiring, bloating graphs and hurting authoring flexibility — the priority this work serves. It also costs more (node + registration + editor palette) while still leaving the legacy dialect as the only in-field syntax. - Silent hybrid (JEXL as fallback when legacy parsing fails). No new syntax, but dialect choice becomes implicit and ambiguous per expression; authors cannot tell which semantics apply.
- Real JavaScript in a sandbox, as n8n does. Maximum flexibility and
zero dialect to learn, and rejected on security. n8n evaluates
{{ }}contents as JavaScript and defends it with AST sanitizers (ThisSanitizer,PrototypeSanitizer,DollarSignValidator) plusisolated-vmV8 isolates with memory and timeout limits — and was still broken by two sandbox-escape RCEs in 2026 (CVE-2026-1470, CVE-2026-0863). A non-Turing-complete grammar with no host access has no equivalent escape surface. - CEL (Common Expression Language). Re-evaluated 2026-08-04 and rejected
on author ergonomics, not maturity — the earlier “immature JS
implementations” objection no longer holds, as
@marcbachmann/cel-jsis at 8.0.0 (July 2026) with ~368k weekly downloads, zero dependencies, and bundled types. Three properties make it a poor fit here: CEL integers areBigInt, and resolved values are persisted as JSON, whereJSON.stringifyon aBigIntthrows; CEL forbids mixed int/double arithmetic, so pixel math likewidth * 1.5fails depending on how a JSON number is coerced; and CEL’s||is boolean-only, so the fallback idiom that dominates existing templates becomeshas(x) ? x : defaultor requires optional types. Since migration is a hand rewrite per field, a more verbose dialect directly slows the sunset. - Immediate full migration to JEXL (drafts + promote + sunset). Sound plan and kept as the P4 follow-up, but not needed to unblock math.
- Other engines.
expr-eval: critical RCE CVE-2025-12735 (Nov 2025), unmaintained — disqualified.jsonata: mature but a different query-language paradigm with sequence-semantics footguns; oversized for config-field expressions. Full template engines (Handlebars/Liquid/Nunjucks): string-oriented output conflicts with native-type placeholder resolution andsecret:handling. @hypatiatech/jexlfork instead of the original. Previously chosen here for its TypeScript rewrite. Rejected on 2026-08-04: 564 weekly downloads, no dependents, no release since December 2025, and a four-star repository with a single maintainer. Local type declarations over the original package carry less risk than depending on that.