How feel is put together: the stages an expression goes through, which module does what, and where the rules live. It describes the code as it is. What is still to be written is in todo.md, and why things are as they are is in decisions.md; the ids (D-138, D-27, and so on) refer to the latter. Compatibility results are recorded in the dated TCK report.

For recommended changes and priorities, use the compiler assessment. The correctness contracts, remaining audit work and the target type design are in the type-system assessment. This module guide describes mechanisms rather than establishing their soundness.

What it is, and what it is not

feel is a library for FEEL, the expression language of DMN 1.6: it parses an expression, checks and types it, and runs it. It is standalone, with two runtime dependencies (decimal for numbers and tz for the IANA time zone database).

It is the expression layer of a DMN engine. A decision table is a node of its syntax tree (D-175), but it has no text: the DMN layer (apps/dmn) reads a model and builds the trees (Feel.prepare_ast/3, D-174). The boxed expressions, business knowledge models and decision requirements graphs belong to the caller, who hands feel the text of an expression (or a tree it built), a scope that says which names and types it may use, and the values of the names.

Two ideas shape the rest:

  • Prepare once, run many times. Everything that does not depend on the data (parsing, name resolution, typing, checking calls, compiling) happens in Feel.prepare/2. Feel.run/3 only evaluates.
  • A semantic error is a null and a diagnostic, not an exception. Lenient mode carries on evaluating, and strict mode ends with {:error, _} at the first error (D-04). Syntax, preparation, input and resource failures also return error values.

The pipeline

 source text
     |  Feel.Syntax.Lexer          raw tokens, with byte spans and white-space flags
     v
 tokens
     |  Feel.Syntax.Parser         groups names using the scope; Feel.Syntax.SFeel checks S-FEEL
     v
 AST   (Feel.AST)
     |  Feel.Analysis.Analyzer     resolves names, types every node, checks calls, adds the
     v                             verification nodes for the scope
 IR    (Feel.IR) + type, free names, diagnostics
     |  Feel.Optimizer             folds what is constant, drops checks the types settle
     v
 IR
     |  Backend.compile/2          interpreter: keeps the IR
     v                             beam: Emitter -> Loader -> a loaded module
 Feel.Expression                   what prepare/2 returns, and run/3 takes back
     |  Feel.run/3, Feel.test/4    Backend.run/3, on Feel.Runtime and Feel.Builtin
     v
 {:ok, value, diagnostics}  or  {:error, %Feel.Error{}}

Feel (lib/feel.ex) is the facade and runs calls in a sandbox by default. sandbox: false turns that off for trusted input. prepare/2 is the left half of the picture and run/3 the right half. eval/3 does both once, and builds a scope from the bindings when it is not given one (D-37). parse/2 stops after the AST, test/4 runs unary tests (a decision table's input entries) against an input.

What is chosen at prepare/2 stays with the prepared expression: the dialect (D-49) and the extensions (D-81) go into the executable and from there into every run. Supplying either to run/3 returns an input error under the default sandbox, or raises ArgumentError with sandbox: false. The mode, the clock and the limits are set per run.

The analysis tier changes inferred bounds and preparation warnings, while execution must preserve values, ordered diagnostics and strict failures under the same declaration assumptions. Literal function signatures are captured in their written environment by kind-only analysis. Full analysis makes that additional bounded pass when the syntax contains functions, then uses the captured signatures alongside more precise call refinements. Call specialization and optimization preserve them, including when a function escapes through a list or context (D-219).

Static argument-type mismatches stay preparation warnings. The runtime built-in boundary checks actual values and constructs its own diagnostic, so inferred type text does not leak into a tier's runtime error. Structural errors such as invalid arity keep a fixed diagnostic in the IR.

Every failure has a kind (Feel.Error), and the kind says when it happened:

KindWhenMeaning
:syntaxpreparethe source does not parse, or (S-FEEL) is outside the subset
:limitprepare or runan engine limit or sandbox deadline/transfer bound was passed; Limits and Security model describe enforcement
:analysispreparecalls or types could not be resolved, or a construct is not written yet (:not_implemented); a name the scope lacks is only a warning by default, and an error with unknown_names: :error (D-37)
:backendpreparethe backend could not produce something that runs (BEAM only)
:strictruna strict run ended at its first error
:inputprepare or runinvalid sandbox options/backend, or a bound value breaks a declaration of the scope, such as number! (D-48)
:workersandbox prepare or runan unexpected worker termination

Problems that are not failures are Feel.Diagnostics, with a severity (:error, :warning, :info), a code, a byte span into the source and an optional hint. Those found while preparing are in Feel.warnings/1; those found while running come back with the value.

Module map

Paths are under lib/feel/, except the facade lib/feel.ex.

LayerModulesRole
Facadelib/feel.ex, expression.ex, scope.ex, item_definitions.ex, dialect.ex, limits.ex, error.ex, diagnostic.ex, name.exthe API, and the data it passes around
Preparationpreparation.ex, recursion.exshared parsed/AST preparation, conservative binding dependencies and linked-callee evidence
Containmentworker.ex, worker/{transfer,preflight}.ex, facade wrapperswhole-operation deadlines, process heap policies, bounded transfers and caller-side selection
Extensionsextension.ex, extensions.ex, extension/camunda/{functions,overloads,temporal_formats,json}.exthe behaviour for additions to FEEL, how a set of them is resolved, and the bundled Camunda extension (D-81); Java is a separate application
Valuesvalue.ex, value/*how each FEEL type is represented (D-02)
Typestype.ex, type/*the types of the specification, and the richer ones used for inference
Syntaxsyntax/{lexer,token,characters,parser,name_set,type_parser,s_feel}.ex, and ast.exsource to tree
Analysisanalysis/analyzer.ex, analysis/signatures.ex for stable literal signatures (D-219), and its helpers, for names, calls and loops (scope_stack, references, quoted_names, calls, invocations, iterations, paths, filters), for types (inference, allowed_values), the seam to the proofs about values (refinement, D-133), for sets (intervals, narrowing, preimage, test_sets, integer_set) and for temporal values (at_literals, date_arithmetic, date_properties, duration_properties, month_scaling, moment_arithmetic, moment_properties, moment_tests, moment_zones), and ir.extree to typed IR
Optimizeroptimizer.exIR to IR
Backendsbackend.ex, backend/interpreter.ex, backend/beam.ex, backend/beam/{emitter,scalar,chunks,loader}.exIR to something that runs
Runtimeruntime.ex, runtime/*the semantics: arithmetic, comparison, logic, temporal, paths, filters, calls, unary tests, verification
Built-insbuiltin.ex, builtin/*the library of functions, one module per area (the list functions in two more, under builtin/list/)

Values and types

A value is a plain Elixir term (D-02): nil, boolean, a UTF-8 binary, a finite Decimal, an Elixir Date, a list, and Feel.Value.Context for a context. The types that need more are structs: Feel.Value.Time, DateTime, the two durations (separate on purpose, D-02), Range and Function.

A context keeps the order of its entries (D-135), which the specification leaves open and the TCK does not test: the entries by key in a map, and the keys in order. A literal has the order it is written in, and not the order its entries are evaluated in (D-58), which the IR carries in meta.order when they differ. get entries, context put, context merge, context(...), string(...), the descendant and to json show the order; = ignores it. Outside the engine a context is a plain map with string keys, as the specification and the TCK have it: the value of a run is converted (contexts: :ordered keeps the order), a map a host binds is a context in the order of its keys, and a native function receives and returns maps. The engine reads a plain map as a context wherever it reads one (Feel.Value.Context.is_context/1), so nothing converts a binding, and builds a %Context{} wherever it makes one; the order of a plain map is worked out only when something asks for it (put/3 and merge/2 into one are constant time). A function is a value of one of three kinds: :feel, a closure written with function(...), :builtin, and :native, an Elixir function that the host exposes and that is called through the boundary types of its signature (D-82). Each module in value/ says how its values look (t), describes its type (type/0) and recognizes its values (value?/1). value/zone.ex and value/format.ex are helpers (time zones, and the text of a value as string(...) writes it).

There are two ways to describe a type, and the distinction matters for the analysis (type-system.md goes through both, and sets them against the types of TypeScript and Elixir).

  • Feel.Type is the type system of the specification (section 10.3.2.9): a flat lattice with :any on top and :null at the bottom, and null belongs to every type, so number means "a number or null". conformance.ex, instance.ex and conversion.ex relate types to each other and to values, and apply the implicit conversions (D-24). Feel.Type.parse/2 reads type expressions such as list<number>. Feel.ItemDefinitions can also construct guarded recursive data graphs (recursive: :guarded). Feel.Type.Recursive unfolds them for complete finite-value membership and constraint paths; analysis uses a bounded upper projection instead of expanding the graph indefinitely. Recursive signature projections and declaration checks follow the guarded-type contract.
  • Feel.Type.Union is what the analyzer infers with (D-39): a list of alternatives with :null among them when the value may be null. It can say "never null" and "only null", which the specification's types cannot, and it can carry a set of values for an alternative (D-39, D-109): age + 1 for an age in [0..150] is number in [1..151] | Null. The sets are Feel.Type.NumberSet (intervals of numbers within decimal128) and Feel.Type.StringSet (single strings and intervals, in the order of the run), and dates and durations are put in the same algebra by their image as numbers (Feel.Type.Image). Times and dates and times are put in the same algebra too (D-109): by the second they fall in, on three number lines (the clock of the values with no zone, and the instant and the clock of those with one; Feel.Type.Moment), since their order depends on the zones, and by the fraction of the second, on a fourth (D-118). Feel.type/1 converts back to the specification's type.

! after a type (number!, D-48) is an extension for the declarations of a scope: the name is never null. It is not part of the FEEL expression grammar. The same holds for the two other types that the lattice cannot express, which a host declares: {:not, T} (Any except T) and {:union, [T, U]} (T | U, D-178). They are defined by the union they make (Feel.Type.Composite), so the analysis relies on that union and the run asks Feel.Type.Union.contains?/2 about it.

Syntax

The lexer is context-free and never raises (D-06). It cannot tell a-b the name from a - b the subtraction, because that depends on which names are in scope, so it emits raw words, numbers, strings and symbols with their text and spans. The parser (syntax/parser.ex) is hand-written precedence climbing (D-28) that reads the whole grammar and decides the grouping of names from the Feel.Scope and the built-in names. The scope's names are lexed with the same lexer, so the parser compares tokens, not strings. They are kept in a Feel.Syntax.NameSet (syntax/name_set.ex), which says whether a run of tokens is a name, only the start of one, or neither, so that reading a name costs the tokens it shares with a known name and not the length of the run (D-177). A name is canonical (Feel.Name, D-05): white space is not part of it.

The parser reads FEEL whatever the dialect. S-FEEL is a check made on the tree and the text after the parse (s_feel.ex, D-49). The size of the source is limited in the lexer, and the nesting depth and the length of the names that an expression gives to a parameter, an iterator or a key (D-154) in the parser; see Limits.

Feel.AST is the tree as written: names are token lists and temporal literals are text (D-15).

A name in backticks (`first name`, D-83) is the one thing the lexer groups itself: one :quoted_name token, checked to be a name in the canonical form (the words with one space, nothing but name characters), so the parser needs no scope to know where the name ends. It is not DMN 1.6, so it is read in every dialect but S-FEEL and reported once for the expression as an :info (Feel.Analysis.QuotedNames).

Analysis

Feel.Analysis.Analyzer is the largest module. It walks the AST with a ScopeStack and produces the IR, the inferred type of the whole, the free names and the diagnostics. An error returns {:error, diagnostics}; a construct it does not handle yet returns the diagnostic :not_implemented (D-28, D-56), so that preparing fails with an :analysis error and never raises.

What it does:

  • Names. Looked up innermost-first in a ScopeStack (analysis/scope_stack.ex, D-26): the special contexts of the constructs, then context entries, then the scope, and the built-ins last, so any of them can shadow a built-in. A name becomes a :var, a :local, a :builtin or a name that is resolved against the keys of a context at run time. A scope is not taken to have every name, so one that is missing from it is a variable of type Any | Null that is looked up in the bindings when the expression runs, with an :unknown_name warning and a suggestion when a name in the scope is close. unknown_names: :null makes it a null with a warning, and :error an error (D-37).

  • Types. Every node carries a union type. analysis/inference.ex has the result type of each operator, intervals.ex the algebra of number sets for + - * /, test_sets.ex reads unary tests as sets of values (of numbers, strings, booleans, and dates and durations by their images, D-109), date_properties.ex, duration_properties.ex and moment_properties.ex give the properties of a date, a duration, and a time or a date and time from its set, moment_tests.ex compares the sets of a time and a date and time (D-109), moment_arithmetic.ex adds a duration to them and subtracts them (D-109), moment_zones.ex gives their value, time offset and timezone, and preimage.ex gives the numbers an operand can be when an operation is in a set (D-122), operands.ex and date_preimage.ex do the same for the dates and the durations (D-122), and narrowing.ex narrows the type of a name, of a path that starts at one, or of the element of a filter by the comparisons, between, in, instance of, and and or around it: in the branches of an if (if y != 0 then x / y else null, D-194), in the right operand of an and, in that of an or and in the upper bound of a between (D-194). A fact about a path narrows its source (p.age > 5 leaves p a context with that age, and d.month > 6 leaves the days of a date's set from July, D-194, and s.hours > 2 the amounts of a duration's set from two hours, D-194), and a local, a parameter of a function or an entry of a context, is narrowed as a name is, identified by the depth of the context that binds it, so that a name bound again deeper takes no fact (D-193), and Feel.Type.Union.intersect/2 is the meet of two types, which several of the facts use. The facts that the conditions compose about one subject are counted: more than max_narrowing_facts of them that have to be applied at each read of it is a :limit error of the analysis (:too_many_facts, D-154), since the work for a subject grows with the square of their number. A test checks inference.ex against the runtime on the same operands, and the narrowing against the runtime on every pair of a set of values. narrowing.md has the rules of the narrowing, with a page for each type and one for the functions that have several signatures. Unknown arithmetic operands use the possible result kinds of the runtime tables, including number-or-duration negation. Full analysis also recognizes selected repeated reads of one value (D-203, D-207); self-division of month durations preserves zero and numeric-conversion failures. Repeated duration addition still combines independent intervals, and the core tier keeps independent-operand types for repeated reads. These rules improve precision; the type-system assessment states the correctness contracts and the remaining soundness audit.
  • Temporal literals and arithmetic. analysis/at_literals.ex decodes an @"..." literal when the expression is prepared, so that it is a constant of the IR (and reports a spelling that an extension reads, D-81). date_arithmetic.ex (D-109) and month_scaling.ex (D-109) work out the dates that a date plus or minus a duration can be, and the months of a years and months duration times or divided by a number. The properties of a date and of a duration are whole numbers: integer_set.ex makes the Feel.Type.NumberSet of them, and type/exact_number.ex does the floors and comparisons that need exact numbers, since the arithmetic of Decimal rounds (D-109).
  • Paths and filters. analysis/paths.ex types a.b and a...b from the type of the source: an entry of a context, a property of a date, a time, a duration or a range, or the same from each element of a list (D-65). analysis/filters.ex decides from the type of the brackets whether source[brackets] is an index, a predicate, or one that the value decides at run time, binds item and the entries of an element as names, and types the elements the predicate keeps (D-71).
  • Calls and loops. analysis/calls.ex finds what is called, puts named arguments in the order of the parameters, and types the result (D-56) by the signatures that the arguments may match (D-56: abs(dd) of a duration is a duration). Invalid argument counts or names give :arity_mismatch; incompatible argument values give :argument_out_of_domain. Both can be preparation warnings. At runtime, structural errors use fixed IR diagnostics, while type mismatches use the ordinary boundary's actual-value checks. invocations.ex types the call of a value (f(1), g(3)(4)) from what the callee can be (D-82), and iterations.ex types for, some and every from the types of their domains and of the body (D-86).
  • Lists and contexts. The entries of a context can read each other's keys in any order (D-58). analysis/references.ex reads what each entry reads from the tree, and the analyzer evaluates them in dependency order.
  • Unary tests. Lowered to expressions of the input ?, a name bound for the run (D-40): < 5 is ? < 5. A test whose meaning depends on the value (a list, a range, a boolean or a null that an expression of ? gives) is a :match node; the value decides the meaning, never the type (D-209).
  • The declarations of the scope. See below.

What a scope declares

Feel.Scope carries the names and their types, the type names a model defines, the allowed values of some names (the source text of unary tests, as in a DMN item definition), and the type and allowed values of the result. The analyzer turns the declarations into nodes around the expression:

allowed            the result is null with an error if its allowed values reject it   (D-138)
  conform          the result is converted to its declared type, or null with an error (D-138)
    conform_bindings    the bound values are converted to their declared types         (D-138)
      verify_bindings   the bound values are checked against their allowed values      (D-138)
        <the expression>

One option, declarations:, says what this is used for (D-138). It controls two things: what the run does with a bound value that does not fit (verify: :enforce converts it or makes it null with an error, :report leaves it and only reports it, false ignores it), and whether the allowed values narrow the types (narrow), which is what lets the analysis prove things about a name, and so is safe only when the run enforces them or the host guarantees them. The analyzer keeps the two apart in its context, and Feel.Analysis.Declarations works them out from the option, which is a stance:

declarations:A bound value that does not fitThe allowed valuesNodes in the IR
:enforce (default)converted, or null with an errornarrow the typesboth, :enforce
:enforce_widethe samedo not narrowboth, :enforce
:trustnot looked at: the host guarantees the valuesnarrow the typesnone
:auditstays, and is an errornarrow the typesboth, {:report, :error}
:reportstays, and is reported (an error for a type, a warning for allowed values)do not narrowboth, {:report, ...}
:ignorenot looked atdo not narrownone

The tiers: refined types and kind-level types

The full tier tracks literals, value sets and condition facts through the refinement modules. Those modules can dominate preparation cost on some expression shapes. analysis: selects the tier (D-133):

TierTypesA condition
:full (default)refined sets for numbers, strings, booleans, dates, times and durations, with bounded approximationsnarrows the names it reads in its branches
:coreof the kinds: number, string, list<number>, a context with these entries; null is trackedsays nothing

Both tiers are intended to preserve execution semantics. They differ in inferred precision, preparation warnings, optimization opportunities and preparation cost. Equivalence tests exercise that contract, but finite comparisons do not establish a universal guarantee. Use scripts/bench.exs prepare to measure the cost on a workload. Under :core, allowed values do not refine analysis types, and :trust and :audit are rejected. Both tiers retain the repaired signature/result-check regressions; selecting a tier does not replace the wider algebra and effect audit.

Feel.Analysis.Refinement is the seam. The analyzer asks the refinement for the facts of a condition (facts/3: nothing in the core tier), the type of a constant (constant_type/2) and the narrowing of a type by a fact (narrow/2 and the functions that combine facts, which belong to Narrowing), and no module of the refinement is called from the analyzer other than through it. settle/2 is the safety net: every node the analyzer returns goes through it, and in the core tier it takes the sets out of the type of the node, so that a set that some part made (the result of a built-in function, a property of a date, the elements of 1..3) goes no further than the node. It looks at the alternatives of the type and not at its depth, for the parts of the type of a node are those of the nodes below it, which were settled; the type of a node that was folded to a value is erased whole. Inference types the kind of an operation and whether it can be null, and asks Refinement.Operations, which is part of the seam, for the sets of the result and for whether the sets of two operands decide a comparison (D-133). Two callers are outside the seam, and are functions of types that need no tier, since the types of the core tier have no sets to work on: Paths (the properties of a date, a duration, a time and a date and time from their sets) and AllowedValues (the tests of a name as sets). The test of the seam (test/feel/analysis/refinement_test.exs) reads the calls between the modules and fails if a module of the refinement has a caller that is not in its list.

The IR

Feel.IR is the resolved form that both backends run. A node is {kind, meta, ...}, where meta has the byte :span (so a run-time diagnostic points at the source) and the :type. ir.ex documents each node. A type expression is read on its own for instance of; external function definitions require a registered extension function for their kind (D-211). Without one, preparation fails with :not_implemented. Host-native functions can also be supplied as values with declared signatures (D-82). A function (function) and the call of a value are produced (D-82, Feel.Runtime.Invoke), and so are for and quantified (some, every; D-86, Feel.Runtime.Iteration). A quiet node is the argument of a parameter that is read without reporting (is defined, D-100): evaluation inside it is lenient and what it reports is not kept (Feel.Runtime.Env.quietly/2). A new kind of node needs a clause in the analyzer, the interpreter and the emitter (see "Extending it").

Optimizer

Feel.Optimizer (D-45) is a function from IR to IR between the analyzer and the backends, on by default. Its contract is to preserve the value, ordered diagnostics and source locations, and the point where a strict run ends. The regression suites exercise this contract; the remaining proof audit is described in the type-system assessment. It folds operations on constants (only when the run would report nothing), takes the branch of an if with a constant condition, and drops a conversion or a verification that the analysis has already shown to hold. Feel.Expression keeps both the optimized IR and the unoptimized_ir/1. Tests run the same expressions both ways. An upper recursive projection cannot justify removing a conformance check or prove that conversion leaves its operand unchanged. Result and specialized parameter bounds describe converted values.

Backends

Feel.Backend is a behaviour: compile(ir, options), run(executable, bindings, options) and an optional release(executable). compile/2 takes profile: :fast | :explain; :explain is meant to report the rules and branches a run took, for audit trails, and it is not implemented: both backends and the emitter raise ArgumentError for it (Feel.Backend.validate_profile!/1), so that a caller who asks for it is not given a run without the report. The option stays as a placeholder for it (todo.md).

  • Feel.Backend.Interpreter (default) walks the IR. It generates no code, loads nothing and creates no atoms, so it is the right choice for expressions from sources that are not trusted. It is the reference (D-21).
  • Feel.Backend.Beam (backend: :beam) emits Erlang abstract format directly, never source text, so nothing from the expression is interpreted as code, and a name from the expression is never turned into an atom (beam/emitter.ex). The generated function is a straight line of calls to Feel.Runtime; a body of more than 32 statements is cut into functions that call one another (beam/chunks.ex, D-113), so that long straight-line bodies avoid known host-compiler costs. The values of a long list, context or call are collected a few at a time, and a fun with many parameters reads them from a tuple, for the same reason (D-113). This does not establish linear compilation for nested branch chains or arbitrary expressions. beam/loader.ex compiles and loads it under a name derived from a hash of the IR and runtime cache stamp (Feel.Generated.E<hex>), so the same expression is one module, a loaded module is never replaced, loads of one name are serialized, and release/1 unloads a module once nothing runs it, at a cost in proportion to the number of processes in the VM, and without freeing its atom (the figures are in its documentation). compile/2 first emits forms under a fixed placeholder name, counts their statements and refuses an expression over max_generated_statements before creating a distinct module-name atom. Accepted forms receive their IR-and-cache-stamp-derived name before the Erlang compiler runs. This avoids permanent names for oversized expressions, while successful compilation still consumes a module-name atom. Refusal returns an error of kind :limit, code :too_large_to_compile (D-154). The limit bounds the statement count because the compiler's time and memory grow with it for large generated modules; the interpreter makes no code and has no such limit. The cost is a module and an atom per distinct expression, which the VM does not free, so it is meant for expressions that are trusted and run often. For an expression that only does scalar work on declared names (beam/scalar.ex, D-164) it is a function that reads the names in one pattern, does scalar operations without an environment and checks individual constructed strings. It runs the expression in the interpreter from the start when its assumptions fail. Measure preparation, successful specialization, fallback and release separately on the intended workload; a backend has no fixed speed advantage.

Both backends call the same Feel.Runtime, so the semantics are written once. They must give the same value and the same diagnostics, in the same order, and tests check it.

Runtime

Feel.Runtime is the contract between the compiler and the code it generates. Its abi/0 cache stamp (runtime.ex) participates only in generated module names. Changing the stamp prevents reuse of code emitted against an older runtime contract. No attributes or version-negotiation layer remain. Prepared expressions must be regenerated on upgrade; persisted generated .beam files are unsupported. The modules under runtime/ are functions on values, each citing the tables of DMN 1.6 it implements. The operators are pure; the modules that run parts of the expression or have something of their own to report (filter, iteration, invoke, allowed_values, binding_types, result_type, temporal_text) are given the environment and return it:

ModuleArea
env.exthe environment of one run: mode, dialect, clock, limits, diagnostics, and what the run has added up (calls and iterations)
bindings.exlooking up a name in the bindings, shared by both backends
kind.exthe kind of a value, which the operator tables dispatch on
number.ex, number/power.exdecimal128 arithmetic: 34 digits, half-even, the scale rules, the exponent range; ** as one correctly rounded power (D-62)
logic.exthree-valued and and or (Tables 51, 52)
compare.ex= != < <= > >= across kinds (Tables 53-55)
arithmetic.ex+ - * /, ** and negation (Tables 57-61 and 63)
temporal.exdates, times and durations: exact time in ticks of 10^-34 s, floating, offset and IANA zones (D-54)
unary_tests.ex, range_literal.exthe tests, and a range as a value (D-61)
range_text.exreading the text of a range, for range(...) (D-73)
path.exselecting an entry or a property, and the descendant (D-65)
filter.exsource[brackets]: the index, the predicate and the choice between them by value (D-71)
regex.exthe regular expressions of XPath, read strictly and written as a pattern of the Erlang engine, for matches, replace and split (D-80)
invoke.exfunction values and the call of one: closures, host functions, the conversion of the arguments to the parameters' types (Table 64), and the depth of calls (D-82)
iteration.exfor, some and every for both backends: the domains, and the elements taken, counted against max_iterations (D-86)
null_operand.exwhich null operands are an error and which are only missing data (D-103)
temporal_text.exreading the text of a time and a date and time, with the spellings of the extensions that are on (D-81)
type_check.exinstance of (Table 62)
partial.exthe results of a for so far, for a body that only indexes partial (D-86)
equal_items.exwhich items of a list are equal to an earlier one, without comparing every pair: distinct values and the extension's duplicate values
allowed_values.ex, binding_types.ex, result_type.exthe run-time half of the scope's declarations

An operator does not touch the environment. It returns an outcome: {:ok, value}, :missing (the result is null because an operand is null, which is not a mistake), or {:error, diagnostic}. The backend passes it to Env.settle/3 with the span of the node. That puts the span on the diagnostic, leaves out a null operand that an error inside the operation already explains (D-103), and checks the size of every value against the limits. Env.report/2 is the single place where a diagnostic is collected (lenient) or ends the run (strict): settle/3 ends in it, and so do the modules that are given the environment and have a warning or an info of their own, such as the conversion of a bound value, a filter, an iterator or a spelling of a time. A strict run ends by a throw that Env.run/2 catches. A run that exceeds a limit ends the same way, in either mode, with an error of kind :limit: the size of a list or a string and the depth of calls of function values (max_call_depth, which stops a function that is passed to itself), the number of calls of functions of FEEL (max_calls, D-154, which the depth does not bound) and the number of elements that the iterators of for, some and every have taken (max_iterations, D-86), the backtracking of a regular expression (regex_match_limit), and, for the run as a whole, the diagnostics it has reported (max_diagnostics). All of them have the code :limit_exceeded, and the details of the diagnostic name the limit.

Evaluation order is part of the semantics. Both operands of a binary operator are always evaluated, and and or included, so an error in the operand that does not matter is still reported (D-53). An if evaluates the condition and only the branch it takes (D-53). The elements of a list are evaluated in order, and the entries of a context in the order they depend on each other (D-58).

Built-in functions

The 86 names of section 10.3.4 are listed in builtin.ex, because the parser needs them to read multi-word names. The functions are in one module per area (builtin/conversion.ex, string.ex, list.ex, number.ex, temporal.ex, range.ex, context.ex, boolean.ex), and a test keeps the two lists equal (D-56).

A function is a Feel.Builtin.Function (D-56): a name, signatures (parameters with names, types and whether they are optional), the function itself, pure? and never_null?. The analyzer turns a call into {:call, meta, {:builtin, meta, name}, arguments} with the arguments in the order of the parameters; both backends evaluate the arguments in order and Builtin.call/3 runs the function and returns an outcome to settle. That is the whole contract. A call of a pure function on constants is folded only when the evaluation succeeds without diagnostics. All 86 standard names have implementations. A registered name without one would be a :not_implemented analysis error. A function is found by name in a table that is built once, from the areas, and kept in :persistent_term (Builtin.function/1, D-56; Builtin.clear_cache/0 forgets it), since an area builds its list of functions anew at each call and every call of a built-in in a run looks one up.

The functions of an area share how they take their arguments (Feel.Builtin.Arguments, D-79): a null or a value of the wrong kind is null with an :argument_out_of_domain error, a list of one is taken as its element where a scalar is wanted, a value is taken as a list of one where a list is wanted, and a position is cut to a whole number. A parameter can be variadic (Feel.Builtin.Function.parameter/3), as in sum(1, 2, 3) and append(list, a, b). A call by name takes the signature that has the names, with the order in which they are written deciding between two signatures that have the same names (before(point, range) and before(range, point)), and signatures that differ only in types count as one (abs(n: x) is for each kind of abs, D-56). The list functions are two modules under builtin/list/ (the aggregates and the others), since an area module has nothing but names/0 and functions/0. The regular expressions of matches, replace and split are read by Feel.Runtime.Regex, which writes the dialect of XPath as a pattern of the Erlang engine (D-80).

Extensions

Engines add to FEEL: functions DMN does not have, more signatures for the ones it has. The core does not, and does not name any such thing. Feel.Extension (D-81) is a behaviour for a module that says what it adds, and Feel.prepare(source, extensions: [MyApp.Shout]) switches it on. The option is [] by default, and then the library is DMN 1.6 and nothing else. Feel.Extensions resolves and checks what is given once, and it travels with the prepared expression into every run, as the dialect does.

An extension adds, and never changes the result of an expression that is valid without it: what it adds was an error before (an unknown function, a call with a number of arguments that no signature takes) or was not FEEL. The TCK expects a few such errors (number("1.000.000,01", "."), 0058 #016: "will error when too few params"), so an extension that turns one into a value deviates from it on purpose, and names the node (D-81). :camunda gives number that signature of two arguments (D-81), and the call is still null with an error (another code), so it declares no node: apps/bdm_tck/test/fixtures/tck/deviations/camunda_overloads.txt is empty. Today it has three callbacks that add behavior, the first two lists of Feel.Builtin.Functions, the same structure as a built-in and called the same way (D-56), and one optional declaration:

  • functions/0 adds functions with names that DMN 1.6 does not have. A name of several words is read as one name wherever the extension is on, as string length is.
  • overloads/0 adds signatures to a function that DMN 1.6 has. The function of DMN 1.6 runs first, and an overload gets its turn only if that reports an error, in the order of the extensions; its call may return :next to say the arguments are not for it. If every overload does, the error of DMN 1.6 stands.
  • temporal_text/1 gives other spellings of the text of a time or a date and time (13:30, T23:59:00, 2018-04-29T09:30:00+02:00[Europe/Berlin]). When time(...), date and time(...) or @"..." cannot read its text, the library reads the first spelling it can read as the string of DMN 1.6 that it stands for, and reports it as an :info (:temporal_spelling). The rules are Feel.Value.Temporal.Spellings, which the library also uses on its own, with no extension, to say in the hint of the error how DMN 1.6 writes the text (D-81).
  • data_only?/0 (optional) says that the extension adds data only: nothing it returns is or holds a function, and a diagnostic it makes has no origin (D-222). A host that copies a run into a worker (DMN, DD-44) then need not copy the diagnostic sources of the elements the run does not use. An extension that does not declare it is taken to be able to return a function written elsewhere. The callback is asked once, when the set is prepared, and the answer is recorded (Feel.Extensions.data_only?/1 of a prepared set runs no extension code); keep it a constant. feel_java declares it.

The places where the core decides ask Feel.Extensions: the parser which words form a function name, the analyzer which functions and signatures exist, the run which function to call. A call that an extension may answer is marked extension: true in its metadata, and the analyzer and the optimizer fold it with the extensions of the expression (D-45), as they fold a call of a function of DMN 1.6; the optimizer is given the dialect and the extensions for that. scripts/tck.exs --extensions runs the TCK with extensions on and lists the nodes that stop passing: those an extension does not name are its bugs (with :camunda on, none of the 3,557 nodes changes). The bundled extension is :camunda (lib/feel/extension/camunda/; the name stands for :camunda_temporal_formats, :camunda_overloads and :camunda_functions, each of which can be switched on alone), reads those spellings, gives six functions additional signatures (D-81), and adds sixteen functions of its own (D-81), to json and from json among them (json.ex). The optional feel_java application adds Java external functions through the same mechanism; it is not a dependency of feel. An extension that does change a node of the TCK declares it in apps/bdm_tck/test/fixtures/tck/deviations/<name>.txt, and Feel.TCKExtensionsTest checks the list. Syntax is not an extension: a name in backticks is always read, and reported (D-83).

Dialects

Feel.Dialect names :feel, :s_feel and :b_feel (D-49). The dialect is chosen at prepare/2 and goes into the executable and from there into every run; a run cannot choose one. S-FEEL is a restriction of the syntax and runs as FEEL. B-FEEL, which differs in its semantics (an error gives a value of the type and not null), is accepted and runs as FEEL: the dialect is carried in Env, and nothing reads it yet.

Limits

Feel.Limits (limits.ex) caps the work an expression can cause. A limit is a non-negative integer or :infinity, the defaults are provisional, and exceeding a limit is an error of kind :limit. The stage that checks it decides when the error comes:

LimitCheckedCode
max_source_bytesby the lexer, at prepare:source_too_large
max_nesting_depth, max_name_tokens (D-154)by the parser, at prepare:nesting_too_deep, :name_too_long
max_narrowing_facts (D-154)by the analyzer, at prepare:too_many_facts
max_generated_statements (D-154)by the BEAM backend, at prepare, before the Erlang compiler runs; the interpreter ignores it:too_large_to_compile
max_list_length, max_string_lengthby Env.settle/3, on every value a run makes:limit_exceeded
max_iterations (D-86), max_call_depth (D-82), max_calls (D-154), regex_match_limitduring the run:limit_exceeded
max_diagnostics (D-154)by Env.report/2, at each diagnostic that is collected:limit_exceeded

A run that exceeds a runtime limit ends in either mode, and the diagnostic details name the limit. These limits describe selected work and individual values rather than elapsed time or all memory held by the VM. Calls with sandbox: false have no whole-operation deadline. Preparation still has expensive source shapes; use the sandbox for untrusted preparation as well as evaluation.

Security model

The untrusted data are FEEL source and bindings, DMN XML and imported models. Host extensions, native functions, clocks, entropy providers and import resolvers are trusted code with the host's capabilities. The library combines language-level construction limits with BEAM process containment; it does not create an OS security boundary.

Shared worker and public wrappers

Feel.Worker lives in feel, which dmn already depends on. Ordinary Feel and Dmn entry points wrap their operation with this worker by default; XML parsing and optional DMN table checks also call it directly. There is one implementation of deadlines, heap policies, transfer checks and cancellation.

Entry pointProtected operation
Feel.eval/3Preparation, execution, context conversion and diagnostics
Feel.parse/2Parsing, returning an ordinary AST
Feel.prepare/2, prepare_ast/3Preparation, returning an ordinary Feel.Expression
Dmn.load/2XML parsing and conversion to a model
Feel.run/3, test/4One evaluation of a prepared expression
Dmn.compile/2XML loading when bytes are supplied, model conversion, import resolution and compilation
Dmn.evaluate/4, evaluate_all/4, invoke/4The complete operation across all FEEL units and result conversion
Dmn.prepare_call/3Nothing runs: sandbox: and guard: are checked once, and the decision name and plan are fixed
Dmn.run/2One evaluation of the prepared decision, admitted as Dmn.evaluate/4 is

Prepared expressions, compiled models and returned FEEL closures are ordinary terms. Each sandbox call applies a fresh policy; preparing once does not determine the policy of later calls. FEEL sandbox APIs reject the BEAM backend, whose global atoms and modules cannot be reclaimed by killing a worker. BEAM preparation and execution of an expression require sandbox: false, for a bounded set of expressions controlled by the host. The flag skips the outer worker and transfer checks for that call; engine limits remain. It cannot escape an existing worker. Dmn.compile/2 prepares for the interpreter unless the caller chooses backend: :beam (DD-41), and that choice is accepted in the default worker: the DMN compiler asks FEEL for the backend itself, inside its own worker, and the modules and atoms it loads stay in the VM when that worker is gone. Containment of such a compile bounds its time and memory and not what it leaves behind, so :beam is for models the caller trusts. Running a model compiled that way is contained like any other. XML and optional table analysis keep their own workers even when the outer DMN boundary is disabled. Direct backend, analysis and type-helper calls do not establish the outer operation boundary; use the listed facade operations to contain untrusted preparation and interpreter evaluation.

The preflight

A worker is another BEAM process, so whatever it runs is copied into its heap. A call that captured the whole compiled model and every input would copy all of it each time and count all of it against max_input_bytes, so that a model larger than the allowance would be unusable however small the decision it is asked for. The preflight is the step, in the caller and before any worker exists, that decides what the job closure holds. The internal run_prepared entry point of Feel.Worker starts the policy and deadline and hands them to a selector: Dmn.Execution.prepare/6 for a DMN call, and the binding selection of Feel.run/3 and test/4 for FEEL (Feel.Worker.Preflight has the budget they share).

For a DMN call the preflight

  1. resolves the requested names and orders the decisions, business knowledge models and inputs they need, once, with the planner of Dmn.Compiled;
  2. keeps only those units, in the compact form a worker needs (the executable, its reads and recursion groups, faults and options; not the source text, the syntax tree, the IR or the diagnostics that stay with the caller);
  3. keeps every input key, because the run validates all of them, and replaces large or composite values the decision does not read;
  4. decides whether the source of other units must travel too, which is necessary only when a run could report a diagnostic whose origin is another unit (a function supplied by the caller, a calendar module, an extension that is not declared data only, see below);
  5. returns the closure, which then passes the ordinary transfer check and is copied.

It runs outside the worker, so it must be bounded without one. It has a budget of at most 100,000 steps (less for a small memory or input policy), reads the deadline when it starts and then every 64 steps, and runs no host code: no extension, callback or resolver is called, and whether an extension adds data only is read from a record made at preparation or compilation. Whatever it cannot decide (a name or key that is too long, an unusual spelling, a spent budget) makes it send the original, complete argument instead, which faces the same transfer check, so a fallback costs time and never skips a check. Expiry of the deadline is a :timeout. Selection is an optimization of what is copied and never validation: the worker validates options, names, input keys and values again, and strict errors, diagnostics and the result are produced and checked inside it.

What it costs and what it saves, measured on an Apple M1 Pro with OTP 29 (the measurements further down say how): about 3 µs for the preflight, next to about 8 µs for the transfer check of the job and about 17 µs for the spawn, the copy and the reply. It is what keeps that overhead from growing with the model: one decision of a model of 1 to 30,000 decisions costs 22–36 µs contained. The remaining cost grows with what has to travel, about 3 µs per KB of payload, and is large only when source maps must travel with the job (414 KB and about 1.2 ms a call for a model of 1,087 decisions with an extension that is not declared data only).

Ownership, deadlines and transfer limits

The guard: policy has four options:

OptionDefaultEnforcement
timeout5,000 msDeadline covering preparation/compilation or evaluation, result checking and sending
max_memory134,217,728 bytes (128 MiB)Heap policy installed at worker spawn, including shared binaries at garbage collection
max_input_bytesmax_memoryConservative bound on the function capture copied into the worker
max_result_bytesmax_memoryConservative bound on the complete returned term, including diagnostics

These defaults apply to each complete public operation, including FEEL preparation and DMN loading and compilation. The returned expression/model must fit the result allowance too. Large artifacts, long import budgets and external startup/queue waits can need explicit larger guard: options. Changing an inner XML/import budget never widens the outer operation's deadline or heap policy. Later evaluations get fresh policies rather than inheriting the compilation's larger allowance.

A monitored controller owns a linked, monitored worker and monitors the caller. Caller death or deadline expiry terminates owned work; termination waits for workers to die before the terminal reply. Replies use aliases so a timed-out call cannot leave late result messages. The worker sends the result directly to the caller; the controller receives only a delivery marker, avoiding a second result copy.

Worker failures expose the observed reason in FEEL's first diagnostic details and DMN's Error.details. {:exit, :killed} may mean a heap-policy termination or an explicit kill; it is not proof of memory exhaustion. Controller exits stay distinguishable as {:exit, {:controller, reason}}, and caught exceptions as {:raised, exception}. Additional reason details have a fixed 4,096-byte conservative transfer allowance; larger terms are omitted with an explicit marker. These diagnostic details do not change deadline or transfer-limit codes. Fatal worker monitor exits bypass the worker's ordinary result path. Before forwarding one, the controller checks the complete error envelope against the smaller of 4,096 bytes and max_result_bytes. An oversized envelope becomes {:exit_reason_omitted, :too_large} internally and the public details become %{reason: :omitted, reason_omitted: :too_large}. The marker also passes the result allowance; an allowance too small for it yields :result_too_large. Small observed reasons stay intact. The controller receives raw VM exit signals before sanitizing them. This initial receipt is outside the worker heap policy; trusted host callbacks must bound the exit reasons they produce. The caller likewise receives a raw monitor signal if the controller itself crashes; its returned error is sanitized after receipt. These VM-generated signals are not covered by the result-transfer bound. The controller cannot safely be given a heap-kill policy that would bypass its owned-child cleanup.

Feel.run/3 captures only the executable, selected bindings and run options; source, AST, duplicate IR and preparation diagnostics stay with the caller. Feel.prepare/2 records the analyzer's conservative external dependencies on the interpreter executable, including declaration checks, allowed-value tests and returned closures. Selection uses exact canonical keys, always keeping the implicit unary-test input ?. A missing exact key keeps the complete map so runtime alias lookup and missing-name diagnostics are unchanged. Executables built directly without dependency metadata also use the complete map. eval/3 keeps all bindings because preparation infers a scope from them. worker/transfer.ex keeps its conservative transfer accounting.

Dmn.Execution builds a compact view from the current compiled units: required executables, reads, recursion groups, faults and runtime options. It leaves the original model and duplicate expression representations with the caller. Selection and execution share the iterative planner in Dmn.Compiled: it produces one ordered dependency closure across all requested targets. The caller supplies its existing preflight budget and deadline. A successful projection captures the ordered plan together with its matching immutable compact model in the job closure. Evaluation and invocation reuse that plan after the normal option, name, input and argument validation; the worker does not walk the dependencies a second time. The plan is included in input transfer accounting, is never stored on the reusable compiled artifact, and cannot be supplied through public options. A complete-input fallback carries no plan, so execution computes it inside the worker. Trusted calls likewise plan during execution. A Dmn.PreparedCall holds its own plan for runs that are not in a worker (sandbox: false, or inside one); in a worker its request is selected like an evaluate/4 request, so the worker gets the part of the model the decision needs and not the prepared value. Explicit frames process target and requirement lists incrementally, mark every member of a recursion group before following its external requirements, and then emit its members together in compiler order. Repeated edges, group members, emission and frame unwinding all consume caller work steps. The payload builder only compacts the planned units; it does not traverse dependencies again. No planner callbacks, bulk graph expansion or persistent plan cache are introduced. Oversized keys, uncertain metadata or work exhaustion fall back to complete guarded input; deadline expiry stays a timeout. Ordering consumes the existing budget rather than widening it, so some complex projections can fall back sooner. Their original payload still passes ordinary transfer admission and containment. The plan uses current units, avoiding both an unbounded caller stack and a per-target cache that could grow quadratically or become stale after an artifact is edited. The compact view carries only requested target names and the selected units' source maps. It has no model-wide name/ambiguity maps. The source of a unit outside the selected graph is needed only when a run could report a diagnostic whose origin is that unit: a function value made in another unit carries its origin, and so can a diagnostic made by trusted host code. A bounded iterative inspection of the selected expressions, the selected input values and the run options shares the planner's budget and deadline and decides. These cases make the extra source maps travel: functions supplied directly, nested in data or captured in closures; FEEL extensions that do not declare data_only?/0 (D-222); non-ISO Date calendar modules; pre-existing diagnostic origins; and BEAM executables prepared with such an extension. Only those cases scan the remaining units and build a diagnostic source index, omitting unneeded executables and already selected sources. The extra source walk shares the same budget and deadline; exhaustion falls back to complete guarded input. No callback runs during admission. All source mapping stays inside the worker; strict errors and the complete diagnostic envelope still face its deadline, heap policy and result-transfer bound.

Extension code never runs in this inspection, which happens in the caller before the worker exists: whether an extension adds data only is asked when the set is prepared (Feel.Extensions.new/1) and when Dmn.compile/2 compiles (Dmn.Compiled.feel_data_only), both inside their protected operation, and recorded. A callback that blocks is stopped by that operation's deadline.

What a prepared expression holds does not depend on the call, so the compiler asks once: Dmn.Compiler.Unit.callback_free is true, false or nil (not looked at), set from the final expression (DD-44). Only what a worker is given is inspected, here and in the walk of a nil unit: the executable, the linked peers and the extensions, not the syntax tree, the IR or the scope, which stay with the caller and can be large (every unit of a model with thousands of item definitions carries them). true skips the walk of that unit in every call and false ends the inspection at once; nil, as in a unit that was built or edited without the compiler, is walked, so the flag only saves work and is never the sole evidence. Whoever changes expression on a compiled unit resets it. A BEAM executable is inspected like the interpreter's: generated code calls only Feel.Runtime, the built-in functions and the extensions it was made with, and holds no function value of its own, so it is opaque exactly when its extensions are. The common interpreter/data path, with no extension or only data-only ones, has no scan or transfer of unrelated model metadata. A model with an extension that is not declared data only, a supplied function or a custom calendar incurs model-size source costs, about 3 µs per KB of payload: 414 KB for a model of 1,087 decisions, 1.2 ms a call, against the 2.9 KB and 39 µs it needs. Uncertain or exhausted projections still pay whole-model costs. Both can exceed small input-transfer allowances.

When the planner selects every current unit, equal map sizes prove the selected subset contains all available source maps. Selection skips capability inspection and the extra source index, while still checking its cooperative deadline. Already-mapped DMN faults are not inspected for origins: execution copies them directly into the result or strict error. Their complete transfer accounting is unchanged. Finite Decimals with exactly the standard fields, sign ±1, nonnegative integer coefficient and integer exponent are proven callback-free in constant time. Extended/malformed structures still use the bounded walker. This proof never normalizes the value or exempts large integer fields from transfer checks. Custom Date calendars are opaque because temporal operations can dispatch through their module atom even when the input contains no function terms.

For DMN evaluation, the payload keeps every input key and the values of required inputs; unused large or composite values can become nil. Small scalars, short binaries and Decimals are kept to avoid duplicating key normalization; this heuristic never exempts their contents from the transfer check. Dmn.Run still validates and normalizes every key in the worker. Selection uses the same bounded name normalization, preserving alias collisions and namespace precedence. Invocation arguments stay complete because their parameter names are established by the actual function.

Worker.run_prepared/2 is an internal entry point for trusted library selectors. It starts and inherits the policy before selection, then applies the ordinary input check and dispatch to the selected closure. worker/preflight.ex bounds selection and capability inspection by the smaller memory/input policy, with a ceiling of 100,000 work steps; uncertain or excessive selection falls back to the original checked payload. It reads the clock before the selection starts and then every 64 steps (a step is constant work and runs no host code); the transfer walk checks every 256. Target and FEEL dependency keys longer than 256 bytes, and graph keys longer than 4,096 bytes, also fall back. DMN input selection normalizes only name components up to 256 bytes and namespaces up to 1,024 bytes. Longer or invalid forms are validated by the original guarded path. Expiry stops the operation instead of starting a fresh deadline. Host callbacks, FEEL execution, type conversion and result conversion stay inside the worker.

Transfer work grows with the actual captured input and result. max_input_bytes limits that payload, not unused caller-owned arguments: omitting an oversized unused value can change a former :input_too_large into success without increasing what crosses the boundary. Used values, callback environments, binary backing storage and results keep their full checks. Necessary large inputs and source metadata can still dominate a short evaluation. Benchmark trusted engine work separately from sandboxed public calls; neither has a general constant-overhead guarantee.

Measured on an Apple M1 Pro with OTP 29 (apps/tck_eval, 3,493 decision calls of the TCK, fewer than its 3,511 result nodes because one call can cover several nodes; models compiled with recursion, the Java extension only where a model has a Java function; the machine was not idle, so treat differences below about 10 % as noise): a contained call takes about 100–130 µs for the interpreter against 67–68 µs with sandbox: false, and loading and compiling the models takes 15–30 % longer, because the compiled model is size-checked and copied back out of the worker. Inside the process the overhead of a call splits roughly into the preflight (about 3 µs), the transfer check of the job (about 8 µs) and the spawn, copy and reply (about 17 µs), and grows with the payload: about 3 µs per KB. bench_payload.exs and bench_sandbox.exs (see the DMN README) print the same split for independent decisions; tck_eval prints a sandbox: false row beside each contained one. The TCK has few large payloads, so a model whose extension is not declared data only is the case to measure for your own workload.

worker/transfer.ex bounds traversal before copying input and inside the worker before returning results. It counts repeated heap structure, captured function environments and referenced binary backing storage conservatively, including non-byte-aligned bitstrings and the variable boxed storage of remote references. A tail-recursive traversal uses explicit continuation frames for tuple fields, list tails, map iterators and function captures; it never expands an entire wide container and has no independent composite-depth cutoff. Every visited occurrence consumes the byte allowance, so the work and continuation storage stay bounded. A shared DAG can still be rejected even if its live heap is small. Traversal stops when its allowance is exhausted; it does not flatten the complete DAG first. Input admission runs in the caller. Its transfer walk checks the shared deadline at entry, every 256 traversal steps (including continuation unwinding), and completion or byte exhaustion. Expiry returns :timeout rather than :input_too_large. These are cooperative checks, not scheduler preemption: an individual VM operation or a descheduled caller can still delay the response. Result checking stays inside the preemptible worker, under its heap policy and controller deadline. No second input copy or extra admission process is needed.

Internal pipeline stages run within the outer job without repeating transfer checks. Explicit nested worker policies reuse an enclosing worker when its policy is already at least as restrictive. A tighter operation gets an owned child with the earlier deadline and the smaller inherited memory/transfer allowances. Optional DMN table analysis can expire independently and report an unfinished check; expiry never proves coverage or justifies removing a runtime check. Parent termination cancels owned children, and child cleanup completes before a terminal reply. Recursion signature probes receive the run options too: standalone linking stays protected by default, while DMN's internal probes respect trusted opt-out or stay within the existing outer worker. The shared preparation pipeline attaches conservative binding dependencies before optimization, including when a standalone recursive member is prepared again with single-signature callee evidence. That repreparation uses the shared worker and the link caller's sandbox policy. DMN establishes this evidence during compilation. Its compact worker expressions keep the peer names needed to reuse it, while omitting saved preparation options, ASTs and duplicate IR. Evaluation installs links without analyzing or optimizing those compiled members again.

The worker distinguishes timeout, input-size and result-size failures from exceptions and exits. An unexplained :killed exit is a worker failure; it is not inferred to be memory exhaustion from its timing. The FEEL and DMN wrappers translate these outcomes into their public error structures.

Engine limits and worker memory

Keep the engine limits enabled. Construction checks can reject oversized strings and lists before allocation, while calls, iterations, depth, diagnostics and regex backtracking have their own limits. General step accounting, list-walk charges, numerical cost estimates and comparison budgets are absent; a deadline covers expensive individual operations without estimating their CPU cost. Numerical precision/range safeguards and local analysis budgets are still in place.

Regex matching streams results on OTP 28 and later when the engine's internal iterator is available. On older OTP releases it lists matches before applying the output cap, preserving that engine's empty-match behavior. Engine limits and worker containment still apply, but the fallback may allocate the match list before the next heap-policy check at garbage collection.

There is no cumulative allocation counter, shared atomics budget or retained-byte state in the runtime environment. The worker heap policy bounds live process memory at garbage collection; per-value checks reject individual oversized strings and lists in both execution modes. A DMN operation runs its FEEL evaluations in the same worker, so they share that worker's deadline and heap policy without threading an allocation budget through the runtime. Trusted calls have no aggregate memory bound supplied by the library.

Limits of containment and host responsibilities

A process heap policy is not an instantaneous or whole-VM memory ceiling. Shared binaries may be charged at their full backing size, and some allocations occur before the next garbage collection. A deadline cannot interrupt arbitrary non-yielding native code. Process termination cannot undo external effects or contain independent processes spawned by a host callback. An external runtime, such as the optional Java extension, needs its own host policy. The Java sidecar encodes a bounded request in the FEEL caller before sending it to its long-lived server. Its allow policy, message/name caps, JVM heap options and startup/method timeouts apply independently of the BEAM worker. A FEEL worker timing out does not cancel an accepted Java request; the shared JVM continues until the method returns or the sidecar stops it. Queued calls stay serialized, so the host must bound concurrency.

The interpreter does not generate modules or atoms from source. XML rejects DOCTYPE before parsing and bounds document structure through Dmn.Limits. Imports require an explicit resolver rather than automatically opening model-supplied locations. These controls restrict language and model input; they do not restrict arbitrary code supplied by the host.

Trusted BEAM compilation has a separate VM-lifetime quota for new generated module names: 50,000 by default, configured through :feel, :max_generated_modules. Refusal happens before atom creation; existing names can be reused. Neither unloading code nor restarting the application refunds it. This does not bound other atoms, concurrent compilations or retained code (D-223).

The application must bound concurrent jobs, accepted input and retained prepared artifacts. There is no global worker queue or pool. Measure representative preparation, large bindings and returned results before choosing larger allowances. From the umbrella root, MIX_ENV=test mix run apps/feel/scripts/bench_sandbox.exs compares warmed direct and sandbox calls, including complete public preparation and compilation with median/max times and outcome codes. Outstanding allocation audits and preparation bounds are in the resource backlog.

Tests and how the code is checked

Unit tests and doctests live under test/feel/. The checks cover several distinct contracts:

  • Runtime results against inference, narrowing facts and selected declaration boundaries.
  • Analysis tiers, optimized/unoptimized evaluation and backend behavior against each other.
  • Decimal128 arithmetic against Python decimal fixtures, and specification examples with explicit provenance.
  • Generated expression sweeps and the narrowing documentation tables; see the sweep workflow for commands and coverage limits.
  • The official DMN TCK through the FEEL-only and whole-model runners. The TCK report records scope, options and baselines.

Finite tests provide evidence for exercised cases. Shared type predicates and missing cap, variance or composite cases can hide defects; the type-system assessment states the contracts these cases protect and the broader audit that the existing suite does not complete.

Three scripts show what is going on:

mix run scripts/steps.exs "price * quantity" --bind price=2 --bind quantity=3 --backend beam
(cd ../bdm_tck && mix run scripts/tck.exs)
mix run scripts/bench.exs --quick

steps.exs prints the data structure after each stage (lex, parse, analyse, optimise, compile, run) with the time of each, and compares the backends and the optimized with the unoptimized IR. With --extensions camunda (names or modules, D-81) every stage has the extensions, step 0 lists them, and a call that an extension may answer shows [extension]. tck.exs prints how the TCK went, what is pending and why, and takes --model, --list, --update, --extensions and --analysis core. bench.exs measures cost, with no dependency, in four suites: how prepare/2 grows with the size of the source for a few dozen shapes of expression, split into its phases (prepare); how a run grows with the size of its data, for the list, string and context functions and the loops (scale); what a run costs on each backend with the optimizer on and off, and how many runs the BEAM backend needs to pay for its compile (run); and the cost of a decision table's worth of unary tests (table). prepare and scale fit the exponent of the growth (constant, linear, quadratic or worse), each shape and each expression says what is expected of it, and --check exits 1 when one grows faster (one that is flagged is measured twice more, and the median of the three counts, since a busy machine moves a single figure); the few that are known to be worse are marked in the script and written down in todo.md. The Camunda extension has constructs of its own in each suite (camunda_..., with the extension on whatever else is asked), and --extensions camunda puts it on for all the others. bench.exs profile runs :tprof on the phases. --json FILE saves the figures and --compare FILE lists what moved, with --fail-on-regression. Times belong to the machine they were made on; fitted exponents also depend on the workload, sizes, warm-up and measurement noise. They are empirical scaling evidence, not complexity proofs.

Two more scripts check the code instead of showing it. scripts/check.sh runs the quality checks at the same time (--serial: one after the other), all of them even when one fails, and says which failed: format, compile (with warnings as errors), credo, deps (an unused entry of mix.lock), audit (mix hex.audit, which needs the network), docs (mix docs with warnings as errors: a dead link, a reference to a function that is not there), dialyzer, test (the tests, the doctests and the TCK baseline), sweeps (the complete generated soundness sweeps), java (the Java quality gate), and the two runners tck and dmn_tck. The output of each step is kept in tmp/check/. --fast runs format, compile and Credo (the latter on changed files). --only, --skip and --fail-fast select checks, and the exit status is 1 if a step failed. scripts/camunda_examples/run.sh runs the code examples of the Camunda FEEL documentation with DMN 1.6 alone and with :camunda on, and writes tmp/camunda_examples/report.md by default; it fetches the pages and needs python3 with beautifulsoup4. Generated reports are local artifacts rather than maintained compatibility promises.

Extending it

  • A new IR node. Add it to the table in ir.ex; produce it in analysis/analyzer.ex with its type from analysis/inference.ex (and references.ex or narrowing.ex if it binds or tests names); evaluate it in backend/interpreter.ex; emit a call for it in backend/beam/emitter.ex (the emitter calls into Feel.Runtime and puts no semantics of its own in the generated code); and let the optimizer fold it if it should. The backend-equivalence test shows a gap.
  • A node that a layer above FEEL builds. A node with no text, which Feel.prepare_ast/3 takes and the parser never makes (the decision table, D-175, and the declared value, D-190, are the two there are). It goes through the same places: the node in ast.ex, a clause of analyze_clause/2 in the analyzer (or, as for a declared value, the nodes that exist), the optimizer, Walk.value_of/3 in backend/interpreter/walk.ex and Emitter.expression/2, with the semantics in a module of runtime/ that both call. Keep the shape {kind, %{span: …}, parts…}: References, the quoted names and AST.shift/2 walk any such tuple. apps/dmn/docs/feel-integration.md has the whole path, with the diagrams, and a checklist.
  • A new operator or runtime function. Write it in runtime/ as a pure function that returns an outcome, citing the table of the specification. If generated code calls it, changing its name, arity or meaning requires bumping the cache stamp (Feel.Runtime.abi/0).
  • A built-in function. Add a Feel.Builtin.Function to functions/0 of its area module, with the signatures from the specification so that named arguments work. Its call takes the Env and the evaluated arguments and returns an outcome; Feel.Builtin.Arguments has what the functions share for dealing with a null, a list of one, or a position. Use specification examples, signature tests, sweeps and the TCK to check the resulting behavior.
  • Something DMN 1.6 does not have. Not in the core: an extension (Feel.Extension) that adds a function or an overload, so that switching it on cannot change a result that was valid without it.
  • A decision. Where the specification is silent or contradicts itself, the choice goes into decisions.md as the effective choice and rationale; update an existing record when it changes. Cite the relevant specification sections and vendored test cases there. Open work belongs in todo.md.

Use the specification section cited by a decision and the vendored fixture provenance to investigate a semantic question. Private sibling checkouts and extraction notes are not prerequisites for this repository.