# Architecture

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](todo.md), and why things are as they are is in [decisions.md](decisions.md); the ids (D-138, D-27, and so on) refer to the latter. Compatibility results are recorded in the [dated TCK report](https://github.com/FeeJai/BDM/blob/main/apps/bdm_tck/test/fixtures/tck/README.md).

For recommended changes and priorities, use the [compiler assessment](compiler-architecture.md). The correctness contracts, remaining audit work and the target type design are in the [type-system assessment](type-system-analysis.md). 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:

| Kind | When | Meaning |
| ---- | ---- | ------- |
| `:syntax` | prepare | the source does not parse, or (S-FEEL) is outside the subset |
| `:limit` | prepare or run | an engine limit or sandbox deadline/transfer bound was passed; [Limits](#limits) and [Security model](#security-model) describe enforcement |
| `:analysis` | prepare | calls 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) |
| `:backend` | prepare | the backend could not produce something that runs (BEAM only) |
| `:strict` | run | a strict run ended at its first error |
| `:input` | prepare or run | invalid sandbox options/backend, or a bound value breaks a declaration of the scope, such as `number!` (D-48) |
| `:worker` | sandbox prepare or run | an unexpected worker termination |

Problems that are not failures are `Feel.Diagnostic`s, 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`.

| Layer | Modules | Role |
| ----- | ------- | ---- |
| Facade | `lib/feel.ex`, `expression.ex`, `scope.ex`, `item_definitions.ex`, `dialect.ex`, `limits.ex`, `error.ex`, `diagnostic.ex`, `name.ex` | the API, and the data it passes around |
| Preparation | `preparation.ex`, `recursion.ex` | shared parsed/AST preparation, conservative binding dependencies and linked-callee evidence |
| Containment | `worker.ex`, `worker/{transfer,preflight}.ex`, facade wrappers | whole-operation deadlines, process heap policies, bounded transfers and caller-side selection |
| Extensions | `extension.ex`, `extensions.ex`, `extension/camunda/{functions,overloads,temporal_formats,json}.ex` | the behaviour for additions to FEEL, how a set of them is resolved, and the bundled Camunda extension (D-81); Java is a separate application |
| Values | `value.ex`, `value/*` | how each FEEL type is represented (D-02) |
| Types | `type.ex`, `type/*` | the types of the specification, and the richer ones used for inference |
| Syntax | `syntax/{lexer,token,characters,parser,name_set,type_parser,s_feel}.ex`, and `ast.ex` | source to tree |
| Analysis | `analysis/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.ex` | tree to typed IR |
| Optimizer | `optimizer.ex` | IR to IR |
| Backends | `backend.ex`, `backend/interpreter.ex`, `backend/beam.ex`, `backend/beam/{emitter,scalar,chunks,loader}.ex` | IR to something that runs |
| Runtime | `runtime.ex`, `runtime/*` | the semantics: arithmetic, comparison, logic, temporal, paths, filters, calls, unary tests, verification |
| Built-ins | `builtin.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](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](type-system.md#guarded-recursive-types).
- `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](#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](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](type-system-analysis.md#2-contracts-and-evidence) 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 fit | The allowed values | Nodes in the IR |
| --------------- | ------------------------------- | ------------------ | --------------- |
| `:enforce` (default) | converted, or `null` with an error | narrow the types | both, `:enforce` |
| `:enforce_wide` | the same | do not narrow | both, `:enforce` |
| `:trust` | not looked at: the host guarantees the values | narrow the types | none |
| `:audit` | stays, and is an error | narrow the types | both, `{:report, :error}` |
| `:report` | stays, and is reported (an error for a type, a warning for allowed values) | do not narrow | both, `{:report, ...}` |
| `:ignore` | not looked at | do not narrow | none |

#### 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):

| Tier | Types | A condition |
| ---- | ----- | ----------- |
| `:full` (default) | refined sets for numbers, strings, booleans, dates, times and durations, with bounded approximations | narrows the names it reads in its branches |
| `:core` | of the kinds: `number`, `string`, `list<number>`, a context with these entries; `null` is tracked | says 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:

| Module | Area |
| ------ | ---- |
| `env.ex` | the environment of one run: mode, dialect, clock, limits, diagnostics, and what the run has added up (calls and iterations) |
| `bindings.ex` | looking up a name in the bindings, shared by both backends |
| `kind.ex` | the kind of a value, which the operator tables dispatch on |
| `number.ex`, `number/power.ex` | decimal128 arithmetic: 34 digits, half-even, the scale rules, the exponent range; `**` as one correctly rounded power (D-62) |
| `logic.ex` | three-valued `and` and `or` (Tables 51, 52) |
| `compare.ex` | `= != < <= > >=` across kinds (Tables 53-55) |
| `arithmetic.ex` | `+ - * /`, `**` and negation (Tables 57-61 and 63) |
| `temporal.ex` | dates, times and durations: exact time in ticks of 10^-34 s, floating, offset and IANA zones (D-54) |
| `unary_tests.ex`, `range_literal.ex` | the tests, and a range as a value (D-61) |
| `range_text.ex` | reading the text of a range, for `range(...)` (D-73) |
| `path.ex` | selecting an entry or a property, and the descendant (D-65) |
| `filter.ex` | `source[brackets]`: the index, the predicate and the choice between them by value (D-71) |
| `regex.ex` | the regular expressions of XPath, read strictly and written as a pattern of the Erlang engine, for `matches`, `replace` and `split` (D-80) |
| `invoke.ex` | function 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.ex` | `for`, `some` and `every` for both backends: the domains, and the elements taken, counted against `max_iterations` (D-86) |
| `null_operand.ex` | which `null` operands are an error and which are only missing data (D-103) |
| `temporal_text.ex` | reading the text of a time and a date and time, with the spellings of the extensions that are on (D-81) |
| `type_check.ex` | `instance of` (Table 62) |
| `partial.ex` | the results of a `for` so far, for a body that only indexes `partial` (D-86) |
| `equal_items.ex` | which 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.ex` | the 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.Function`s, 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:

| Limit | Checked | Code |
| ----- | ------- | ---- |
| `max_source_bytes` | by 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_length` | by `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_limit` | during 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 point | Protected operation |
| --- | --- |
| `Feel.eval/3` | Preparation, execution, context conversion and diagnostics |
| `Feel.parse/2` | Parsing, returning an ordinary AST |
| `Feel.prepare/2`, `prepare_ast/3` | Preparation, returning an ordinary `Feel.Expression` |
| `Dmn.load/2` | XML parsing and conversion to a model |
| `Feel.run/3`, `test/4` | One evaluation of a prepared expression |
| `Dmn.compile/2` | XML loading when bytes are supplied, model conversion, import resolution and compilation |
| `Dmn.evaluate/4`, `evaluate_all/4`, `invoke/4` | The complete operation across all FEEL units and result conversion |
| `Dmn.prepare_call/3` | Nothing runs: `sandbox:` and `guard:` are checked once, and the decision name and plan are fixed |
| `Dmn.run/2` | One 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:

| Option | Default | Enforcement |
| --- | --- | --- |
| `timeout` | 5,000 ms | Deadline covering preparation/compilation or evaluation, result checking and sending |
| `max_memory` | 134,217,728 bytes (128 MiB) | Heap policy installed at worker spawn, including shared binaries at garbage collection |
| `max_input_bytes` | `max_memory` | Conservative bound on the function capture copied into the worker |
| `max_result_bytes` | `max_memory` | Conservative 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](https://github.com/FeeJai/BDM/blob/main/apps/dmn/README.md#security-model)) 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](#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](todo.md#resource-bounds).

## 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](https://github.com/FeeJai/BDM/blob/main/apps/feel/test/fixtures/spec/README.md).
- Generated expression sweeps and the narrowing documentation tables; see the [sweep workflow](https://github.com/FeeJai/BDM/blob/main/apps/feel/scripts/sweep/README.md) for commands and coverage limits.
- The official DMN TCK through the FEEL-only and whole-model runners. The [TCK report](https://github.com/FeeJai/BDM/blob/main/apps/bdm_tck/test/fixtures/tck/README.md) 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](type-system-analysis.md#2-contracts-and-evidence) states the contracts these cases protect and the broader audit that the existing suite does not complete.

Three scripts show what is going on:

```sh
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.
