Skip to content

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) rounds 1.23, not the product — it is valid and silently means something else. The call form round(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 : b is a syntax error, and so is items|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.length does not resolve. JEXL reads a property off an array by taking its first element, so faces.length is undefined rather than a count. Hence the length helper, and hence the docs cannot teach items.length as the empty-collection check.
  • __proto__ is reachable through computed access. nodes["__proto__"] returns Object.prototype, while nodes.a.constructor is 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_RE inside each placeholder, which matches nodes.<alias> after any non-identifier character, so references nested in a JEXL call such as round(nodes.a.width * 2) are still collected.
  • Placeholder extraction cannot contain }, because PLACEHOLDER_RE uses [^}]+. 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 a SecretResolver: the flank and sirloin secret stacks were deleted, brain has no secret store, and the remaining secretResolver plumbing is pass-through only — so {{secret:name}} has always resolved to undefined. Removing the branch is behaviour-preserving, since an unmatched path already yields undefined, 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.addTransform call, not a grammar change.
  • The dialect outlives the package: JEXL has multiple implementations (original, @pawel-up/@hypatiatech forks, 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 truthy transform, and durable docs recommending JEXL for everything new.
  • jexl has had no release since 2020. Accepted: it is a small, dependency-light grammar interpreter with no I/O and no eval, 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’s SchemaForm to 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) plus isolated-vm V8 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-js is 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 are BigInt, and resolved values are persisted as JSON, where JSON.stringify on a BigInt throws; CEL forbids mixed int/double arithmetic, so pixel math like width * 1.5 fails depending on how a JSON number is coerced; and CEL’s || is boolean-only, so the fallback idiom that dominates existing templates becomes has(x) ? x : default or 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 and secret: handling.
  • @hypatiatech/jexl fork 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.