This record explains the choices that still shape FEEL. It is organized by topic, with one current account of each choice. Superseded behavior, completed implementation diaries, old measurements and abandoned proposals have been removed. Git keeps that history.
Identifiers are stable citations, not an implementation sequence; gaps are intentional. Update the relevant record when a choice changes, including its callers' references. Assign a new identifier only for a distinct decision, using the next unused number above the existing identifiers. Open work belongs in the backlog.
A recorded choice is not a correctness proof. The type-system assessment states the contracts and the remaining proof audit. The compiler assessment distinguishes implemented containment from proposed compiler improvements. Neither finite regression coverage nor a recorded mechanism establishes general soundness.
- Values and numeric behavior
- Syntax and names
- Evaluation and diagnostics
- Temporal choices
- Functions and extensions
- Declarations and types
- Inference and narrowing
- Compiler and resource boundaries
- Embedding and verification
Values and numeric behavior
D-02 Values and type descriptions are separate data
Value modules live under Feel.Value; Feel.Type describes types. Numbers are finite Decimals, strings UTF-8 binaries, dates ISO Dates, booleans booleans, null nil, and lists Elixir lists. Times, date-times, ranges and functions have dedicated representations. Years-and-months durations store months; days-and-time durations store seconds. They remain separate because months have no fixed length in seconds. Contexts use the ordered representation in D-135 internally.
D-12 Decimal literals and unary signs
A sign is a parser operation, not part of a number token, so 1 -1 is still subtraction. Parsing keeps scale where representable, rounds coefficients to 34 digits half to even, and enforces decimal128 exponent limits. Zero clamps its exponent and has no negative-zero representation. A literal outside the supported format is a syntax error. Feel.Value.Number owns this behavior.
D-59 Arithmetic follows decimal128 value bounds
Arithmetic rounds to 34 digits half to even, preserving the required scale where representable. Division uses the exact quotient at the preferred scale if it fits, otherwise a rounded quotient. Representability is a property of the value: removable trailing zeros do not make a result overflow. A result outside the format becomes null with :overflow, not infinity. Constant failures can also produce preparation warnings. Exact-number fitting and bound comparison avoid unnecessary conversion of very large integers to decimal text; this is not a general preparation-time bound.
D-214 A host Decimal that arithmetic refuses is no number where a value is checked
Feel.Value.Number.value?/1 is Feel.Runtime.Number.valid?/1: a finite Decimal of at most 34 digits whose exponent is inside decimal128, the numbers that every arithmetic function accepts. A host can bind another, such as a value with 35 digits that equals 1. Under a declared number it does not conform, so it follows the path of any non-conforming value: null with :type_mismatch, or for a type marked ! an error of kind :input. This holds for a bound name, for the members of a bound list or context, and for the guards of Feel.Backend.Beam.Scalar, so the analysis may rely on a declared number being null or a number of the format. The guards test integers only: a coefficient below 10^34 and an exponent from -6176 to 6111. A number with a larger exponent conforms, and the fast path leaves it to the fallback, which answers the same. Feel.Runtime.Kind.of/1 still calls any finite Decimal a number, and arithmetic refuses an operand that is not valid with :argument_out_of_domain; a value bound without a declared type is not checked at all. A value the runtime produces has to pass the same test, since the analysis calls it a number: the days of a duration near the end of the range, the years of one with 35 digits of months, and the seconds of one with the smallest exponent, are rounded to 34 digits, as a quotient is.
D-62 Powers are rounded once
Numeric a ** b computes a high-precision power and rounds the result once to the FEEL format, rather than multiplying already-rounded values repeatedly. This preserves the specification's payment example. Domain failures and overflow are runtime outcomes; null operands follow D-103. Prefix negation binds tighter than power, so -3 ** 2 is 9. Feel.Runtime.Number.Power implements the operation.
D-220 An end of a number set is a number of FEEL, and what reads one that is not stays total
A bound is a Decimal that NumberSet cuts to the range, not to 34 digits, and the runtime functions take only numbers (Feel.Runtime.Number.valid?/1). floor, ceiling and round * at one scale took the limit from inside an open end as a whole number: for (-max..max), the type of modulo(a, a), floor has the end max - 1, which has 6,145 digits. Number.power/2 answers :invalid_operand for it, and Intervals.corner/2 had no clause (floor(modulo(k, k)) ** 1 raised CaseClauseError while preparing); sqrt and log gave it to Power.ln/1, which raised ArithmeticError. Three changes, each with its own tests. The rounding image cuts every end outward to 34 digits (down for a low end, up for a high one, as the hull for many scales already did), so floor of (-max..max) is [-max..max]: one step wider than needed, and the whole number. Intervals.power/2 takes a corner that is not a number as any size and null, as add/2, subtract/2, multiply/2 and divide/2 take one. The sqrt, log and exp images produce no image for an end that is not a number (the call has the type of its signature). The last two stay because a set can still have such an end: the months of a years and months duration are integers of any size (D-59). A scan of the node types of about 8,000 generated expressions found the rounding image to be the only place that produced such an end from numbers of the format.
D-135 Context order is observable inside the engine
Feel.Value.Context keeps written entry order independently of dependency evaluation order. Updating an existing key preserves its position; a new key is appended. Merge, get entries, formatting and JSON conversion use that order. Plain host maps have sorted-key order when an order is needed. Equality compares keys and values, not insertion order. Boundary results are plain maps by default; contexts: :ordered preserves ordered contexts. Context types and shape summaries do not encode order. TCK examples whose written key order is also alphabetical do not distinguish this choice from sorted keys; the engine's order-specific regressions establish the intended behavior.
D-168 Shared structures can be compared without expanding them
Runtime equality recognizes supported lists or contexts that are the same VM term before recursively walking their contents. This prevents repeated traversal of shared structures whose expanded tree is much larger than their retained representation. It is an equality fast path, not a general theorem that arbitrary foreign host values have FEEL equality semantics.
Syntax and names
D-05 Names have a canonical spelling
Names are token sequences. Feel.Name.canonical/1 inserts spaces between adjacent word tokens and removes spaces around name symbols, so a - b and a-b denote the same scoped name. Scope entries, context keys and bindings use canonical strings; capitalization remains significant.
D-213 A string key is a name only when the lexer reads it as exactly a name
A context entry written as a string is keyed by the canonical name (D-05) when the text is a name, so "first name" and first name are one key. Feel.Syntax.Lexer.name_tokens/1 decides it: the text lexes without error, its first token can start a name, every token can continue one, and only white space lies between the tokens and around them. A comment is not white space here, since the lexer drops it and the text would not be what it is read as. Any other string, such as "a - b = c", "hello world!" or "a /* c */ b", is the key exactly as written, so two such texts that differ only in white space are two keys. The parser (which names an entry binds for the entries to read) and Feel.Analysis.References.key/1 (the key the analysis and both backends use) both use this one predicate. Feel.Name.canonical/1 is still the canonical form of a text that is known to be a name.
D-06 Lexing does not decide whether a token sequence is a name
The lexer keeps raw tokens, spans and preceding whitespace. The parser groups them using the names in scope, since a-b can be a name or subtraction. Scope names pass through the same lexer so matching uses the same tokens as expression parsing.
D-07 Whitespace and apostrophes have explicit lexical rules
When a code point is both grammar whitespace and an allowed name character, whitespace wins. Zero-width joiners remain name characters. Both ASCII ' and U+2019 are accepted as apostrophe symbols in names; neither introduces a string literal.
D-09 Comments are whitespace and do not nest
// ends at a line terminator; /* ... */ ends at the first closing delimiter. Openers take precedence over division and multiplication, and comment text inside a string is ordinary string content.
D-10 Strings preserve unknown backslashes
Only ASCII double quotes delimit a string. The grammar's escapes, Unicode escapes and valid surrogate pairs are decoded; malformed Unicode is rejected. An unrecognized escape keeps its backslash because the grammar also allows a plain backslash. A raw line break (U+000A through U+000D) ends the string; the lexer reports it as unterminated.
D-13 Reserved words cannot start unquoted names
The reserved set is and between else every external false for function if in instance null of or return satisfies some then true. A reserved word can occur later in a name. not, list, context, range, e and E are ordinary words; an exponent marker has that role only inside a number token.
D-15 The AST retains source syntax without resolving it
Names remain token lists and @ literals remain text until analysis. Spans include grouping parentheses, but parentheses do not need their own AST node. Analysis resolves names, temporal values, call signatures and evaluation choices into the IR.
D-25 Type parsing takes the longest known type name
Built-in and model-defined type names can contain several words. The type parser consumes the longest known name; an unknown qualified name is kept for an analysis diagnostic. Built-in type names are not shadowed. -> is one token for function types, and type nesting counts against the parser's depth limit. Host-only composite declarations are described in D-178.
D-26 Lookup uses the nearest context
Locals, filter bindings, context entries and enclosing contexts are searched from the inside out, then the global scope. Contexts are not merged during lookup. Built-ins are a final fallback, so a local or scoped value shadows a built-in function. Lookup order is prepared rather than repeatedly indexing the same context stack.
D-27 Unknown member names use a lexical boundary
After ., after ..., and for unknown keys in filter brackets, the parser takes a name-start word followed by non-keyword words. Inside a filter, a known scoped name still uses longest matching. This bounds ambiguity without making parsing depend on inferred runtime structure. Reading member names from declared context types is not implemented; see the backlog.
D-28 Expression parsing has one precedence model
The precedence-climbing parser uses longest scoped-name matching. Binary operators, including **, associate left; prefix negation binds tighter than power. The final part of if, a loop or a function extends to the enclosing delimiter. Brackets parse their first expression once, then distinguish a list, grouping or interval by the next token. A potentially ambiguous upper [ is classified by lookahead rather than unrestricted backtracking. Comma-separated tests in parentheses belong to in.
D-37 Missing names remain diagnosable at runtime
With an explicit scope, an undeclared name is a preparation warning by default (unknown_names: :warn), and the runtime can still find it in the bindings. unknown_names: :null makes it null with a warning and does not read the bindings. unknown_names: :error fails preparation. Without a scope, ordinary names are resolved dynamically. An unbound runtime name gives null and a warning. Scope validation and runtime lookup are separate policies.
D-49 Dialects share a parser, but syntax and semantics are separate
FEEL is the default. S-FEEL checks the parsed tree and tokens against a restricted grammar, reporting :outside_s_feel; it does not fork evaluation semantics. Its practical/literal grammar options remain explicit. B-FEEL semantics are not implemented. A prepared expression keeps its dialect and extensions; a run cannot replace them.
D-177 Parse scopes can reuse name indexes
Feel.Syntax.ScopeIndex prepares scoped name/type tokens for longest-match parsing and can be reused across texts with the same scope. Matching follows the relevant token prefixes instead of repeatedly joining every possible suffix. This is a reusable parse input, not inference of names from runtime values or a proof of linear parsing for all inputs.
D-83 Backticks delimit canonical names
Backticks delimit an already valid canonical FEEL name, including one that ordinary lexical boundaries would split. They are accepted in FEEL without an extension switch and reported once per expression as :quoted_name information. They do not permit arbitrary text, noncanonical spacing, escapes for a backtick, or quoted declarations of parameters/iterators. S-FEEL rejects them. This syntactic addition cannot change a formerly valid core expression; value-changing additions remain opt-in extensions.
Evaluation and diagnostics
D-04 Runtime operations return outcomes; the environment applies policy
Pure runtime operations return values, missing results or diagnostics. Feel.Runtime.Env.settle attaches spans, applies limits and reports outcomes. An ordinary lenient operation error yields null plus an error diagnostic; declaration stances can keep invalid values, as D-138 describes. Strict mode halts at the first error. Warnings and information do not halt strict execution. Parse/analysis failures, strict halts, limit failures, non-null input violations and backend failures use {:error, %Feel.Error{}}. A successful evaluation returns its value and diagnostics together.
D-201 Diagnostics identify the text their spans belong to
Feel.Diagnostic.origin carries a host-defined identity. A run has an origin (Feel.run/3, :origin, nil unless the host gives one), and a function written in an expression keeps the origin of the run it is made in: its body reports with it wherever it is called. A function passed to another unit's function, and one that function makes and gives back, therefore report in their own text and not in the caller's. Feel.Value.Function.with_origin/2 replaces the origin of a function that a run without one made. Argument conversion and arity errors remain at the call site. The runtime restores the caller's origin after a call and compares origins as well as spans when deciding whether an inner error explains a null operand. A function made without an environment (Invoke.closure/3) has no origin and runs under its caller's.
Linked members use their member keys: the function of a member gets its key unless it has an origin already, so a member that only returns another's function does not take it over. DMN runs every unit with its key as the origin, then maps body diagnostics through that unit's source; a diagnostic of a unit's own text has the unit's key as its origin, not nil. FEEL carries the identity without interpreting it. A host that binds a function made by a run without an origin still needs to supply this context itself.
D-46 Conversions are observable information
Accepted implicit conversions report :implicit_conversion, including declaration checks, built-in arguments, function parameters and iteration/filter sources where applicable. Fractional-to-integer conversion is truncation toward zero and is reported too. Conversion reports must survive optimization when evaluation would produce them; a value's type alone is not an effect summary.
D-53 Only if selects a branch lazily
if evaluates its condition and only the selected branch; only true selects then. Null and non-booleans select else, unless the node is a boxed one (D-212). Operator operands, including both operands of and and or, are evaluated. Static facts used on the right operand do not permit skipping its runtime errors.
D-212 Nodes built from a box can be strict about their condition
DMN 1.6 requires the if of a boxed conditional, the brackets of a boxed filter and the satisfies of a boxed some or every to resolve to a boolean (10.2.1.8 to 10.2.1.10), and the DMN TCK expects null with an error for a value that is not one. FEEL text keeps Tables 50 and 51, where such a value is false. A layer above FEEL that builds the node from a box marks it boxed: true in its meta; the parser never does. The analyzer copies the mark to the IR node and Feel.Runtime.Boxed applies it in both backends. The BEAM scalar path takes a marked if into its subset for a condition that is a boolean or null and hands every other run to the interpreter on an IR that keeps boxed (its fallback once dropped it, and gave the else branch where the interpreter gave null with an error).
A marked node is null with a :type_mismatch error when its condition is neither a boolean nor null. null stays false, and a number in a filter's brackets stays an index. For a filter, some and every, one such element anywhere makes the whole result null, so a true does not decide a some that has a bad element after it; the quantifier loop ends at the first. The node's type has Null where the condition's type allows such a value. The optimizer keeps a marked if whose constant condition is not a boolean or null: it must not fold it to the else branch, which would lose the error.
When the condition's type makes the error certain, the analyzer says so while preparing, as a warning with details.always, and gives the node the type Null (an if has unreachable branches). That is an if whose condition has no boolean and no null in its type, a filter whose brackets have no boolean, number or null over a source that has an element (a list written out with an item, or a value that is neither null nor possibly empty), and a some or every whose body has no boolean and no null over domains that have an element and cannot fail. A source or domain that may be empty is not flagged, because the brackets or body would not run. The run still reports the error from the node, so the value and the flag do not depend on the layer above, which can read details.always as an error of the model. The node is not folded to a constant null: a constant reports nothing.
D-58 Collection literals preserve evaluation semantics
List elements evaluate in order. Context keys are discovered first; entry expressions evaluate in dependency order, allowing forward references and diagnosing cycles. A function entry captures its surroundings without making its own key an implicit recursive binding. Written context order is kept separately (D-135). Collection construction passes through the runtime's applicable limits.
D-61 Membership and range values remain distinct
in binds its subject once and combines test results with three-valued OR; all tests are evaluated. A range value is not a list of tests. Prefix tests can also form ranges with missing endpoints, which are distinguished from explicit null endpoints. A != prefix range keeps the endpoint on both sides and includes it in neither, an explicit range-value choice rather than a complement type. A reversed range literal holds no values; the stricter range(string) behavior is recorded in D-73. Input-entry satisfaction follows D-40 instead of returning three-valued results.
D-69 between reads its subject once
value between low and high uses the three-valued combination of value >= low and value <= high, with the subject evaluated once. Both bounds are evaluated, and the resulting diagnostics remain observable even when one comparison determines the Boolean result.
D-65 Paths distinguish entries, properties and descendants
A path uses a canonical member key, not a variable lookup. It can select context entries, supported value properties or project entries through a list. Missing members diagnose ordinary path access. Descendant selection visits nested values using context/list ordering; types and node shapes retain only the structure they can represent. Closed context summaries can establish absence, whereas an open context declaration cannot rule out extra entries.
Normalization preserves nested exclusions. Paths requests a positive upper approximation at each union it inspects: the source, list elements, and entries traversed for descendants. It returns a selected context entry's original union, keeping its exclusions for later readers. This local projection prevents an excluded context from reaching the no-property fallback and proving a false list<Null> result; regressions cover paths and descendants with declared result checks.
Descendant node shapes obey the same rule. An excluded context whose internal structure is not known cannot establish zero descendants or an empty iteration domain. Filter name discovery also inspects excluded context bases; subsequent entry narrowing preserves the exclusions and the element's precedence over outer names.
D-71 Filters choose index or predicate behavior explicitly
A numeric bracket value selects an index; a Boolean predicate keeps elements for which it is true. Where analysis cannot decide, a dynamic runtime mode applies the value-based rule. Context elements expose item and their keys under filter lookup rules. Scalar sources can be treated as singleton lists with a conversion report. Index zero has no element; null and out-of-range cases follow runtime filter diagnostics rather than host indexing behavior.
D-86 Iteration belongs to a shared runtime
for, some and every use Feel.Runtime.Iteration on both backends. Later domains can read earlier iterators; domains accept lists, supported whole-number/date iteration ranges, or a converted singleton. partial is the results accumulated by a for, in result order. Index-only reads can use the existing accumulator without copying the whole list each turn. A quantifier evaluates every element, including after its result is decided; a boxed strict body that is not a boolean or null stops the loop with an error. Empty products have the usual empty-list / existential-false / universal-true results. Iteration and list-construction budgets are checked while work proceeds.
D-100 Quiet parameters suppress argument diagnostics
A built-in parameter can be marked quiet. Its argument runs in a temporary lenient environment whose reports are discarded and whose surrounding mode/diagnostics are restored afterward. Analysis warnings from that argument are suppressed too; analysis errors, limits and aborts still apply. This is broader than suppressing only a missing lookup: arithmetic errors inside the argument are quiet as well. Only the argument is quiet. Camunda is defined uses this mechanism; get or else does not.
D-103 Null values and error diagnostics are separate choices
Equality treats null = null as true, null against a non-null value as false, and != as its opposite. Operators do not unwrap singleton lists. The current ordering policy restricts ordering to ordered kinds; true <= true is null, despite the alternative reading as < or =.
| Operation with a null operand | Policy |
|---|---|
| Arithmetic and unary negation | Null with :null_operand |
<=, >=, hence closed bounds and between comparisons | Null with :null_operand |
<, > | Null without that diagnostic |
| Boolean logic | Three-valued result without that diagnostic |
Non-Boolean operands of and/or can produce unknown without an error; not of an invalid argument reports a type mismatch. A null already explained by an error inside the operation, including applicable binding checks, is not reported again. These compatibility choices are centralized in NullOperand, Compare and Env. The TCK flags are inconsistent for identical expressions: 0105#023 expects null for 10 + null without an error flag; 0100#error_when_add_lhs_number_to_rhs_null sets it. The accepted flag mismatches record the resulting deviations. These choices do not resolve every ambiguity in the specification.
Temporal choices
D-54 Calendar arithmetic and zones have explicit policies
The implementation uses these policies where the specification or compatibility evidence needs an interpretation. They are distinct from numeric approximation in the analyzer.
| Question | Current choice |
|---|---|
| Fractional months after scaling a years-and-months duration | Nearest whole month, ties toward positive infinity |
| A month addition whose destination lacks the original day | Clamp to the last day of the destination month |
| Adding seconds to a date-time with a zone ID | Elapsed time: resolve an instant, add, then convert back |
| Adding months to that date-time | Change local calendar fields and retain the zone ID |
| Local time in a zone gap / overlap | Shift forward through the gap / choose the earlier instant |
| Unknown zone ID | Reject it as a value; operations on malformed host-built values diagnose it |
| Zoned versus unzoned equality/order | Adopt the local-field comparison policy where the runtime supports mixed zones |
| Subtracting a zoned and an unzoned value | Null with :offset_mismatch |
| Offset time ordering | Subtract the offset without wrapping at the day boundary |
| Time-only value with a zone ID | Standard offset on 2000-01-01, independent of today's clock |
| Date versus date-time, or the two duration families, in comparison | Distinct kinds; no implicit cross-kind comparison |
24:00:00 as a time | Normalize to midnight; date-time normalization can carry the date |
Date-only text passed to date and time | Midnight without a zone; retain an explicitly supplied zone |
A zone on a date (2012-12-25+01:00) | Accepted and dropped; a date has no zone |
Date bounds and invalid-domain errors remain diagnostics. Calendar conversion, elapsed time and wall-clock arithmetic must not be conflated. Feel.Runtime.Temporal and Feel.Value.Zone own these choices. The vendored 0068 equality and 0100 arithmetic cases exercise offset comparisons and mixed-offset subtraction; implementation regressions also cover the policy choices the TCK leaves open.
D-118 Fractional seconds are retained, but comparison has a coarser rule
Time and date-time values keep decimal fractional seconds, while their equality and order use the whole-second rule selected from specification/TCK evidence. Duration comparison is not truncated this way. Arithmetic and value can expose fractions that temporal equality does not distinguish. Analysis keeps fractional coordinates separately so it can model these operations without treating comparison coordinates as the entire value.
D-68 Temporal value uses one epoch and explicit offsets
The epoch is 1970-01-01T00:00:00Z. A date's value is its midnight UTC seconds; a zoned date-time's is its instant, including fractions. An unzoned date-time has no instant value and reports an error. A time's value is local seconds minus its offset, without day wrapping; an unzoned time uses local seconds. Zone gaps, overlaps and time-only zone IDs follow D-54. Consequently temporal equality does not imply equality of the full fractional value.
Functions and extensions
D-56 Built-ins share a signature registry and call boundary
Area modules describe all 86 standard built-ins in Feel.Builtin.Function records. The registry is cached in :persistent_term and can be cleared after code reload. Known named calls are assigned a signature and reordered during analysis; dynamic calls resolve names at their runtime boundary. Unknown, repeated or missing argument names and wrong arities are runtime errors with preparation warnings where discoverable. Structural errors such as invalid arity carry fixed IR diagnostics. Static argument-type mismatches remain preparation warnings; runtime value validation constructs its own diagnostic independently of the analysis tier (D-219). Named signature selection respects parameter order when names overlap. Arguments already accepted need no conversion; otherwise the applicable signature's conversions are reported. Whole-number parameters truncate toward zero. Built-ins also exist as function values.
D-79 Built-in edge cases follow explicit runtime policies
The implementation preserves these choices rather than delegating them accidentally to host behavior:
| Area | Choice |
|---|---|
| Numeric rounding | decimal is half-even; up/down are away from/toward zero; half-up/down ties follow those directions |
modulo | Exact remainder with the divisor's sign |
| String positions | Unicode code points, not UTF-16 units or grapheme clusters; positions are one-based and may count backward |
| String case | Full Unicode mapping without a locale |
| Literal string matching | Match text literally; regex functions are separate |
string join | Omit null items; null delimiter means no delimiter |
| Empty aggregates | sum, mean, median, min, max yield null without an error; mode yields []; product and sample stddev report an error |
| Numeric aggregates | Invalid or null elements are errors, rather than being omitted; stddev is the sample statistic |
| List equality/search | Use FEEL equality; null can match null; distinct values keeps the first occurrence |
| List positions | No position zero; negative positions count from the end; each operation checks its own permitted bounds |
| Range relations | Use the point/range formulas separately, including their behavior on empty and unbounded ranges |
| Missing context key | get value yields null without the missing-path error produced by ordinary path access |
| Context construction | Duplicate entry keys are errors; merge is shallow; nested put requires intermediate contexts |
| Formatting | Nested temporal values use FEEL literal notation; ranges have literal text; functions and containers holding them have no string form |
Specifications, fixtures and tests hold the detailed cases. Compatibility never silently overrides a known defect: accepted deviations are listed in the TCK tooling.
D-73 Number and range conversion parse data, not arbitrary programs
number(string) uses numeric-literal syntax with an optional minus; it does not trim whitespace or accept +, non-finite values or host-specific notation. Grouping separators are removed before decimal separator replacement, without validating grouping positions. Already numeric input is accepted. range(string) permits supported literal endpoints and designated temporal constructors on one positional string literal. It does not evaluate arbitrary endpoint expressions or resolve names. Reversed endpoints are rejected by this conversion although a reversed range literal is an empty range; that inconsistency remains an explicit current choice.
D-80 Regular expressions implement a translation boundary
FEEL asks for XPath-style patterns; Erlang supplies PCRE. Feel.Runtime.Regex translates the supported syntax and rejects unsupported constructs rather than exposing all PCRE behavior. Replacement syntax and flags follow the FEEL contract. Matching has a backtracking budget; this does not by itself bound all compilation work or guarantee complete XPath-regex compatibility.
D-215 Regular expression matches are found one at a time
A pattern that is only text is searched with :binary.match/3. For any other pattern, the engine is asked for one match at a time, starting from a byte offset in the whole text, so ^, $ and the characters before the offset mean what they mean in the whole text. The steps are the same as in the engine's own :global run, including the retry that follows an empty match, and a test compares the matches with that run on random patterns and texts. replace, split and scan end at the first match that passes the cap of the result, so a walk costs only as much as the part it covers and builds nothing past the cap. The limit reported is the first one the text crosses, either the cap or the engine's backtracking budget, so it is not always the engine's. The public :re.run/3 is not used for this: each call checks the whole text for UTF-8 again, and a million matches never end. The function internal_run of :re (four arguments), which the engine's own loop calls for each match, is undocumented; the first call checks the text and raises if it is not UTF-8; later calls are told that it has already been checked. The steps are those of the loop of OTP 28 and later; the loop of OTP 27 steps over an empty match differently, so an OTP release before 28, or one without the function, lists all matches at once (the matches are the engine's on every release; only the cost of the walk is as before).
D-81 Extensions are explicit preparation inputs
Feel.Extension supplies opt-in functions, overloads and temporal parsing conveniences. The Camunda suite groups its functions, overloads and permissive temporal forms. Backticks follow the independent syntax policy in D-83. An extension can answer calls rejected by the core, and constant folding must use the same extension configuration as runtime execution. Prepared expressions keep that configuration. Determinism, clock and entropy requirements remain visible; extension support is not a claim of complete Camunda compatibility. An external function declaration is rejected during analysis unless an extension adds the function for its kind (D-211).
D-82 Functions are closures with explicit host boundaries
A FEEL function captures its enclosing bindings. Calls bind positional or named arguments, apply parameter declarations and run through Feel.Runtime.Invoke on either backend. Native functions are Feel.Value.Function values with explicit boundary conversion types, not arbitrary executable text from the model. Signature metadata is observable through type tests; the distinction between that metadata and inferred body behavior is represented explicitly by arrows and call refinements (D-171). A function definition does not implicitly bind itself; linked recursion is D-188.
D-211 External functions are calls of an extension's function
function(a, b) external {kind: description} has no meaning in FEEL itself: the body names a kind and describes the function in that kind's terms. Feel.Analysis.External reads it as function(a, b) external <kind>(description, [a, b]) when an extension adds the function named external <kind> (D-81), and analysis continues on that ordinary function and call: its closure, declared types, impurity (no folding) and both backends are the existing ones. Without such an extension the function stays external and preparation fails with :not_implemented. feel knows no kind. Since external is a keyword, a model cannot write the call by that name.
Java is the sibling application feel_java, which feel does not depend on. Its extension adds external java, which sends the class, the method signature and the arguments to FeelJava.Sidecar: a process that owns a JVM (priv/Sidecar.java, run as source) over a port with a framed binary protocol (FeelJava.Protocol). The application starts nothing; a host starts the sidecar with a policy.
- Policy.
:allowlists classes (every public static method they declare) orClass#method; the default is nothing. It is enforced in Java before the class is loaded, because models are untrusted. Parameters are primitives, their boxes,String,Object,Number,BigDecimal,BigIntegerand arrays of them; results are numbers, strings, booleans, null or lists of them. What an allowed class lets through is the host's responsibility (Integer.getIntegerreads a system property). - Conversions. A number must be exact for
byte,short,int,longandBigInteger; a float result is its shortest decimal; a one-character string is achar; a value that is not a list becomes an array containing just itself, which is how a varargs method is called; null is any reference parameter. Returned numbers are read byFeel.Value.Number.parse/1, so they have FEEL's 34 digits and range. - Limits. A request is counted exactly while it is made and refused when it is over
:max_message, before a JVM is started. A class name over 255 bytes and a method signature over 4096 are refused before that, in Elixir and again in Java:FeelJava.Protocolholds the numbers and the process passes them to the JVM (--max-class,--max-signature, as--max-message). They bound what a model's text can make the JVM do before the policy is asked, which a megabyte of[]in a signature would otherwise spend the whole:timeouton. A response is written to a buffer that stops at the limit; a reason is cut by bytes to what a message may hold. A call past:timeoutkills the JVM (Java cannot be interrupted) and the next call starts one. A JDK older than 17 is refused with its version. The child spec's shutdown time covers a wait for the JVM. Every failure is null with:external_function_failedand the reason. - FEEL containment. Ordinary public FEEL/DMN calls use a worker deadline, including request encoding and waiting for the sidecar. Java calls return ordinary outcomes; there is no weighted step cost. Trusted callers can opt out with
sandbox: false, as the BEAM backend requires. The Java allow policy, message/name caps and JVM limits apply in either mode. Startup and active methods have separate sidecar timeouts; a FEEL worker ending does not cancel an accepted Java request or close the shared sidecar's port. Hosts must bound concurrency and permitted capabilities. - The request is made by the caller. A value that a model shares (
a1 = [a0, a0],a2 = [a1, a1]: forty list nodes) counts as a tree of a trillion leaves in a message, which copies it without the sharing (a list of 22 levels is 1.4 GB in the receiving process). So the caller makes the request as a binary and sends the binary. The size it may have is the process's:max_message, which the process keeps in its own dictionary for its callers to read withprocess_info(no message, so no place in the queue behind a running call; and gone with the process, however it ends); a process that is not found by its pid on the node is asked. The encoding stops at the size, a shared value is met once for each time it is written, and the bytes met, including for a refused request, are available as per-call transport statistics. - Ending the JVM. The JVM exits when its input is closed, even in the middle of a method: the main thread reads the requests and a daemon thread runs them, and the main thread calls
Runtime.halt(also for a frame over the limit), since a thread that is not a daemon and a shutdown hook that does not end would keep a JVM that returned frommainor calledexit. The process closes the port when it stops a JVM, so a timed-out JVM ends where there is nokillprogram (a small container image), and a JVM whose BEAM was killed does not keep running a method.kill -9is still sent first. AnyErrorof a call (a class that will not initialize) is its answer and not the end of the JVM, and a method that returns with its thread interrupted does not end the thread that answers. - Class path. The JVM is started with
--class-pathof its own:priv/classesoffeel_java(in the package, with a.keep) and the jars in it, or the host's:classpath(which is not empty). Without it the source launcher takes the working directory andCLASSPATHof the BEAM, and a class file there would be code behind any allowed name that the JDK does not have. The folder is for a checkout or a path dependency; insidedeps/it is replaced by an update, so an installation names a folder of its own. - Host. The JVM runs with the English locale, because
String.formatreads it and the same model has to give the same text on every host.:jvm_argscome after the defaults and win where they say the same, except for the collector: one that they choose replaces the default one, since two are a JVM that does not start. The options are checked when the process starts, as in DD-39. - Rejected. A NIF would embed a JVM by JNI: a fault takes down the node and a call holds a scheduler. A table of JDK methods in Elixir would pass the TCK without being able to call Java.
- Costs. A JDK 17 or later; a start compiles the source; calls are serialized per sidecar, and the call of a caller that a guard has stopped keeps running for as long as
:timeout. Instance methods, constructors and other kinds are in the backlog. - Gate.
apps/feel_java/scripts/check_java.shruns google-java-format (AOSP),javac -Xlint:all -Werrorand SpotBugs at every priority, with downloaded tools held to a pinned SHA-256. It is acheck.shstep and a CI job; CI runs the tests on a JDK 17.
D-87 Sorting is stable and calls the supplied function
sort requires a compatible two-argument function, even for an empty input. Stable merge sort moves a right item ahead of a left item only when precedes(right, left) is true. False, null or a lenient error does not move it; diagnostics remain observable, and strict errors halt. Calls of FEEL comparators count toward the function-call budget. The result with an inconsistent or partial ordering can depend on the original item positions; the library does not manufacture a total order.
Declarations and types
D-22 Declared relations are structural
Lists and ranges are covariant; contexts use width/depth conformance; functions use contravariant parameters and covariant results, ignoring parameter names. Named types resolve structurally. Conversion is a separate relation. Equivalence accepts equal terms or mutual conformance, so reordered union alternatives can be equivalent. A failed bounded inclusion query need not prove semantic inequality. The join returns a conservative common supertype; it need not be the least expressible one for function or composite types. These are bounded procedures, not a complete semantic subtyping decision procedure.
D-23 Conformance and instance of treat null differently
Ordinary declarations admit null. instance of instead accepts null only for Null, so null is not an instance of Any or number. A required context member must exist even if its declared type is nullable. Composite runtime membership checks the original declarations directly, including preserved function signatures. Analysis normalization preserves nested exclusions rather than replacing the excluded parts by positive approximations (D-178).
D-24 Implicit conversion follows failed conformance
A value that conforms is returned unchanged. Otherwise the conversion boundary tries singleton-list unwrapping, singleton-list wrapping and date-to-date-time conversion in its supported order. At most one step is used by default; permitted combinations require compose: true. Operators do not apply these declaration conversions to every operand. Conversions preserve diagnostics and must not be identified with subtyping. Selecting a built-in signature at a declared function boundary is the additional conversion described in D-197.
D-197 Declared function conversion may select a built-in signature
After ordinary conformance and the standard implicit conversions fail, a built-in with type-specific refinements can become the first non-variadic signature within a declared function type. This supports passing abs to function<number> -> number, as the TCK requires. The resulting function enforces that signature's arity, parameter names and argument conversions before calling the built-in. Conversion is reported as :function_signature information; :report mode does not perform it.
The original value and instance of are unchanged: abs itself still has its broader function type. The conversion applies at supported parameter, binding and result boundaries, not recursively inside containers. If several signatures fit, registry order selects one; there is no combined-arrow adapter.
D-39 Analysis types make null and value sets explicit
The internal Feel.Type.Union is distinct from declared types. [] is no possible value; [:null] contains null; [:any] is the non-null universe; [:any, :null] includes both and can describe foreign host terms. Alternatives carry scalar sets and structural parts. Strings support intervals and excluded points, not just finite enumerations. Bulk normalization avoids repeated incremental joins. Budgets can replace sets with larger bounds in positive positions. NumberSet construction, union and intersection select :outer, :inner or :exact; complement and inclusion are exact over the supplied sets. Ordinary union normalization still widens large scalar descriptions, so it is not unrestricted exact set algebra. Certain rule bounds use a separate lower-bound path (D-158).
D-48 Non-null declarations are explicit
{:non_null, type} and T! exclude null without changing ordinary FEEL type names. The mark is accepted in expression type syntax as well as host declarations. A rejected non-null input is an input error, not a lenient substitution of null. Non-null result constraints are currently rejected. Public function projection preserves outer non-null parameter marks and checks parameter variance; where nested detail cannot be projected safely, the whole function projects to Any (D-171).
D-51 Constraint locations distinguish elements from collections
Allowed values of a collection item definition apply to its elements; a collection-level type constraint applies to the collection. Component constraints use paths through context keys and list elements. Null exemptions and non-null declarations are separate rules. These constraints are not implicitly part of every instance of check.
D-99 Item definitions become reusable scope data
Feel.ItemDefinitions resolves named definitions, components, collections and inherited constraints into the declarations used by Feel.Scope. It does not read a DMN document itself. Definitions are structural aliases, not nominal identities. Recursive expansion is controlled separately (D-189).
D-138 One declaration stance controls checking and assumptions
declarations: is the public policy; analysis: independently selects inference cost/precision. Result declarations are checks at the expression's boundary, and bindings are checked before their allowed values where the stance requires it.
| Stance | Invalid binding | Allowed-value narrowing |
|---|---|---|
:enforce | Convert or replace with null and report an error | Yes |
:enforce_wide | Same checks | No |
:trust | Host promises validity; no checks | Yes |
:audit | Keep value and report an error | Yes, conditional on host validity |
:report | Keep value; type error or allowed-value warning | No |
:ignore | No checks | No |
Unchecked kinds are still assumptions, even when allowed-value narrowing is off. Under :core, allowed-value refinement is absent and :trust/:audit are rejected. The guarantees remain conditional on the declarations and host assumptions of the chosen stance; they are separate from algebra repairs.
D-178 Composite declared types enter through the host API
The host can supply {:union, types} and {:not, type} declarations. Their values are checked through the composite translation; expression syntax does not offer general unions, intersections or arbitrary difference. Conversions into union alternatives are not generally provided. Normalization preserves nested exclusions, including beneath another exclusion or a function domain. Public projection can lose precision but must not strengthen a function promise by widening its accepted-input domain. The nested-context and double-negation membership rules are kept as regressions; a broader independent constructor audit remains open.
D-189 Recursive items have an explicit guarded graph mode
Feel.ItemDefinitions.new/2 rejects recursive definitions by default. The legacy recursive: :any option still cuts back edges and is explicitly approximate. recursive: :guarded keeps a finite {:recursive, name, graph} descriptor for recursive data, with one-step unfolding. Each cycle must consume a context member or list element; a cycle through a function signature is rejected. A finite function item may accept or return recursive data. Membership checks the entire finite value, including missing fields. Graph paths apply inherited and component constraints at every occurrence. Boundary conversions keep their existing meaning; nested field conversions are not added.
Constraint verification consumes occurrences incrementally with a traversal cursor and shared reversed paths. It stops on the first rejection and constructs a forward path only for that diagnostic. Both graph traversal and the enclosing path/list selectors stay lazy, so an invalid root does not first materialize its descendants. Successful traversal avoids forwarding each occurrence through every ancestor. Recursive.parts/4 remains an explicit materialization API; runtime verification does not use it.
Cycle validation visits shared definitions once per root, using graph components to reject function cycles and the unguarded subgraph to reject alias-only cycles. Union.from_spec/1 projects recursive descriptors to a structural upper bound with a shared 256-node budget. Budget exhaustion widens the analysis only; runtime membership still checks the full finite value. Consumers must not use that bound as proof of membership in a recursive target. Conformance checks stay, instance of has no positive proof from the projection, negative narrowing keeps the original bound, and recursive function domains use the empty lower bound when constructing an upper arrow. Public function projection may use Any where its type language cannot express that empty domain. Negation of a recursive target projects to Any | Null; runtime Boolean membership remains exact. Recursive conformance is deliberately conservative, accepting identical reachable graphs under a consistent one-to-one renaming and ordinary lattice rules rather than attempting general recursive subtyping. The comparison preserves constructors, constraints and reference sharing while ignoring unrelated definitions, so imported callable signatures do not fail solely because their frames rename types. Tests include an independent finite-tree oracle, deep invalid and valid values, and optimized/unoptimized execution on both backends and tiers.
Parameter specialization and result boundaries use post-conversion upper bounds for recursive declarations, even if the operand fits their projection: membership in an upper bound cannot prove that singleton conversion leaves the operand unchanged. Specialization drops pre-conversion shape, callee and origin evidence for these parameters, including captured parameters of returned closures. An enforced recursive result remains the closure's exact runtime result signature on both backends, through surrounding allowed-value and binding checks. Its analysis signature conservatively uses Any for that result; the body bound remains available as a call refinement. Collection classifiers unfold recursive heads and aliases so that singleton wrapping and element allowed values retain their ordinary meaning.
The analyzer selects each closure's result signature once, storing it in meta.returns before optimization. Both backends use the stored type: folding a conditional to a recursive declaration must not change observable signatures. Runtime composite membership, including list elements, checks the original declarations even when recursion occurs only in a value's retained signature.
The same signature rule applies to functions declared in scopes and supplied as values: a recursive result has Any | Null as its observable result bound, with the structural projection retained only as a call refinement. Function membership and value projection handle those overloads without using their refinements as signature evidence. Graph projection widens recursive function results before unfolding erases their markers; functions reached inside a recursive graph may consequently lose call precision as well. Runtime signature tests use the retained declarations and conservative conformance relation, not their structural upper projections.
D-190 Embedded declarations enforce every applicable constraint
A :declared AST node lets a caller put type and allowed-value boundaries inside an expression tree, not only at its root. Analysis lowers it to the ordinary conformance and allowed-value checks and applies inherited/component constraints from the declared type. A check that can reject must include null in its result bound, even for a non-null successful value. Optimizer removal of a check must be justified separately. The reproduced function-related bypasses are repaired and retained as regressions. Recursive checks remain when only an upper projection fits; preserving declaration meaning across every proof consumer is still an audit obligation.
Inference and narrowing
D-109 Temporal refinements use coordinates suited to each operation
Dates use day-number sets and durations use month/second sets. Times and date-times need multiple coordinates for local fields, instants, zones and fractions. Arithmetic, properties and inverse rules operate on these coordinates within budgets. A coordinate approximation is not a complete set of all relations among temporal fields. Calendar clamping and zone changes must use runtime semantics.
D-122 Inverse arithmetic narrows operands conservatively
Selected numeric, date and duration operations have inverse rules used when a comparison constrains the result. Rounding, representability failures and calendar clamping matter; inverse images must retain every input that can reach the outcome. Unsupported or over-budget cases keep broader inputs. Time/date-time inverse coverage remains partial. These rules compose with the outcome model in D-194.
D-196 Ordering requires an ordered kind on both sides
<, <=, > and >= yield null if either operand is a boolean, list, range, context, function or foreign value, whatever the other operand is. Inference reports the corresponding certain mismatch, including against Any or a named type. A null operand retains its separate diagnostic policy (D-103). Only numbers, strings, dates, times, date-times and the two duration kinds have ordering operations.
The supported interpretation of <=/>= follows the specification's domain table, so equal booleans or lists do not become true through the alternative < or = reading. Runtime pair tables and OrderingKindsTest hold inference to that choice. Equality and inequality remain separate operations.
D-203 Arithmetic with unknown operands uses the possible result kinds
An operand of a kind unsupported by its operator makes the result null even when the other operand is unknown. A string supports only +; date/time kinds support + and -; durations support all binary arithmetic except **; numbers support all five operators. Boolean, list, context, range, function and foreign kinds support none. Known incompatible pairs remain mismatches.
For an unknown operand that may support the operation, inference joins the results of its possible kind pairs and null, using the existing known-pair rules. Thus number + Any yields number | Null, string + Any yields string | Null, and number * Any can also yield either duration kind. Bounds of the known operand are forgotten for this calculation. Bare-kind results are cached. ArithmeticKindsTest compares inference with runtime pair tables.
Unary negation and repeated reads of an unknown value use the additional rules in D-207.
D-207 Negation and repeated reads retain their possible result kinds
Negation of an unknown value yields number | years and months duration | days and time duration | Null. In full analysis, two reads recognized as one value use same-kind pairs, rather than all pairs of independent operands. Thus v + v has only number, string or duration results; v - v has numeric or duration zero results; v * v is nonnegative or null; and v / v is one or null. v ** v is a number or null. The core tier retains the independent-operand approximation for repeated reads.
A years-and-months duration divided by itself is one only when it is nonzero and its month count can be converted to a runtime number. Absolute month counts of at least 10^34 fail that conversion; inference preserves null for them. A duration with a set of values added to itself is twice a value of the set, as x + x is for a number, so a gapped input does not add its parts to each other: months in [P1M..P2M], [P6M..P7M] give [P2M..P4M], [P1Y..P1Y2M], not also [P7M..P9M]. A sum that could exceed the range of numbers is any duration for months (they never overflow in the run) and has a null for seconds, as for two values. A duration with no set, the whole kind, is as for two. A string added to itself still combines its possible strings pairwise (see the backlog).
A unary test does not read these types to decide a null: an expression of ? that can be null or a boolean is decided by its value (D-209), so -? does not match a null input however it is typed.
D-204 Date joins and selected duration failure regions retain discrete semantics
Adjacent day-number intervals join into one date set; dates are discrete even though numeric and second-duration sets admit fractions. Failure regions for days-and-time duration arithmetic reuse numeric rules on seconds. Date arithmetic with a bounded month duration uses a conservative region of years that can cross the calendar boundary, retaining extra dates rather than dropping failures. Very large second durations are formatted in scientific seconds to bound diagnostic text.
Month-duration products and quotients still use an unrestricted failure region because month scaling rounds. Functions called with conditions still retain generic body-node types; specializing those nodes requires joining call analyses and proving which functions can escape.
D-133 Refinement is an optional analysis tier
:full uses sets and condition facts; :core keeps kinds and explicit nullability with fewer facts. Feel.Analysis.Refinement is the seam, including operation sets and final node-type settling. The execution contract is intended to be the same in either tier; the result-check regressions exercise both. Finite equivalence tests do not establish a universal guarantee, and a cheaper tier does not replace the proof obligations of the analysis.
D-151 Built-in result rules can depend on argument structure
Registry rules such as result_from retain element types for supported list transformations and aggregates instead of using only a generic declared result. Those rules are specific analyses, not Hindley–Milner polymorphic schemes. Their coverage is partial and must include conversion and failure possibilities.
D-152 Structural exclusions are bounded analysis representations
An :except member describes a structural kind less selected subtypes; overloads and the distinction between empty and nonempty lists add precision. Normalization, context expansion, arrow combinations and scalar sets have resource caps. Dropping detail can be safe for an upper bound while being unsafe under negation, contravariance or certainty. Normalization now retains nested exclusions and function domains; readers request positive approximations explicitly. Function projection and set erasure check variance before retaining an arrow. Neither membership agreement nor a failed subtype check supplies an independent proof for the wider bounded algebra.
D-155 Call analysis considers compatible signatures and argument relations
Result inference selects signatures compatible with argument bounds, with conservative fallback when it cannot decide. same_kind rules compare related point/list arguments where represented; empty or unbounded values prevent some mismatch proofs. Narrowing by a call must retain functions and foreign host terms when the call accepts them, not treat Any as only primitive FEEL kinds. Missing inverse or signature detail loses precision; it must not silently exclude accepted inputs.
D-161 Refinements explain selected failures without promising exactness
Result rules use decimal arithmetic, value sets and available grain to improve bounds. NullReasons searches edge values for witnesses and can suggest constraints that avoid a failure. This is bounded sampling, not an exhaustive witness search. A retained null need not have a discovered witness, and a value set need not be exact. Grain and relation propagation remain incomplete.
D-170 Local inference can recognize related values
Repeated reads and selected runtime identities can improve bounds: x - x, self-comparison, empty membership, list indexing and small explicit quantifier products have dedicated rules. Related allowed-value declarations can inform bounds under a bounded dependency analysis. These rules do not form a general relational solver or prove identities for arbitrary effectful expressions. Conversion, foreign values, null and eager evaluation remain part of each rule's preconditions.
D-171 Function overloads and observable signatures have distinct roles
A built-in with several signatures is one function value described by an overload, not an arbitrary choice of one function. Calls can use compatible arrows and refinements; a declared arrow does not assert that the value has no other arities. Runtime signature metadata remains distinct from an argument-specialized body summary. The reviewed baseline violated this contract through projection, nullability and variance. Function literals now use their observable, nullable runtime signature as the arrow and their inferred body result only as a call refinement. Normalization preserves nested exclusions; public projection and set erasure discard an arrow when weakening its parameter would strengthen its promise. The regressions include both original result-check bypasses.
Higher-order list images also judge callbacks from runtime arrows only. A Boolean call refinement does not make an observable Any result signature acceptable to sort or list replace. An excluded function alternative remains a possible callback; it is not merely a position argument. Recorded arrows guarantee signatures without exhausting them: a value typed as a three-argument function, such as matches, can also have a two-argument Boolean signature. A qualifying arrow establishes acceptance; other function descriptions, including overloads and excluded bases, retain both a possible list result and null. Missing signatures and call refinements establish neither rejection nor guaranteed acceptance. This preserves result checks after narrowing.
D-219 Literal function signatures are independent of refinement and optimization
The analyzer stores Union.to_spec(IR.plain_type(body)) from a kind-only analysis of the written environment as meta.returns on a function node. Both backends construct runtime signature metadata from it. Full analysis first makes this core pass when the syntax contains functions. Private AST occurrence keys keep signatures distinct even when source spans coincide; reanalysis for calls reuses the captured signature. Each pass retains the specialization budget. Previously the backends projected IR.type(body) after optimization, so narrowing a body could turn a rejected callback into an accepted one and let an optimized expression bypass a result check. The captured signature is also the function's arrow; the precise body result is a separate call refinement (D-171). Projection starts from the plain type deliberately: exclusions in a body's type are analysis facts, not additional observable signature promises. Neither condition facts, argument specialization nor optimizing the body changes meta.returns. Full-only declaration stances preserve verification policy in the signature pass without using allowed-value narrowing. Regressions compare signature predicates, result checks and callback diagnostics through direct values, lists and contexts on both backends and analysis tiers.
Static call mismatches remain preparation warnings and null result bounds. They do not embed inferred types in runtime diagnostics: the ordinary built-in boundary validates actual values and produces the runtime diagnostic. Structural call errors such as invalid arity still carry their fixed diagnostic. The tier differential sweep compares complete outcomes in lenient and strict modes.
D-173 Never describes code that cannot execute
The empty union marks the nodes of a branch that has been established as unreachable, and selected cases produce an unreachable_branch warning. It must not mark an eagerly evaluated operand merely because its value cannot decide a Boolean result. Branch discovery and warnings are bounded analyses, not complete reachability decisions; unsupported cases remain broader.
D-193 Callable and structural summaries travel with bindings
Callees and node shapes retain selected known functions, literal cardinalities and members through names, parameters, iterators, returned functions and repeated selections. Function bodies can be reanalyzed with argument types and condition origins. Memoization keys must include those assumptions; alternative counts, depth and source budgets bound selected paths. Exhaustion falls back to type-based inference, which must preserve the appropriate approximation direction. This metadata is not a portable runtime function contract. Nested functions created inside a returned container are widened back to their written parameter types and Any | Null results where the call cannot preserve their original shape (D-203). A unique unspecialized written arrow can be paired for a top-level returned closure; equal argument or entry arrows do not prove identity. Nested closures and overloads conservatively use Any | Null results. Specialized body arrows must not become inspectable runtime signatures.
A union of returned function types remains alternatives, not an overload requiring every arrow. Call-result refinements are attached only to a unique written/refined pair; ambiguous alternatives retain their broader written union.
In linked preparation, completeness is kept separately from closure identities. Syntax and retained shapes take precedence, including iterator elements and forwarded container arguments; compact completeness is the fallback when discovery cannot retain the alternatives within its budgets, or body specialization cannot proceed within its depth or source budget. It must not replace a literal closure's identity and thereby prevent argument-specific body analysis while that analysis is available. Context-entry values retain the marker after refinement settles and before local bindings are made, including containers whose shapes exceed the 32-entry limit. Using the complete written result bound does not spend more body budget. Ordinary paths and descendant selections propagate completeness from their source. This covers functions inside the selected descendant lists without inferring completeness from their arrow types. A source with unproven host functions remains unproven, and known identities still take precedence where discovery succeeds.
Function and overload types do not establish an exhaustive arity list, and contain no parameter names. Unknown positional arities or uncovered parameter domains start at Any | Null before applicable call refinements; named calls through types alone remain Any | Null. Known function syntax and built-in registry entries can still establish arity errors. Parameter callee identities are cleared if signature conversion may replace the function (D-197), including conditional, list and context alternatives. See the function-type readers.
D-194 Conditions have true, false and unknown outcomes
Narrowing tracks facts for all three outcomes; an else combines false and unknown. Logical operators, Boolean comparisons and selected predicates compose those facts. A binding to a condition can retain its origin, including through supported parameter passing. Facts about locals refer to their binding, not just their spelling. Selected operation/call inverses include failure regions so the else case keeps inputs that produce null. Unsupported regions retain the whole possible input. Representation and inference defects described in the assessment still qualify these intended upper-bound contracts.
Compiler and resource boundaries
D-20 The IR carries semantic choices and source locations
Feel.IR nodes retain spans and inferred types. Analysis resolves names, call arguments, context ordering, filter modes and decoded literals before either backend runs. Advisory metadata, callable summaries and inferred bounds are not interchangeable with proofs authorizing execution changes.
D-21 The interpreter is the default and a reference implementation
The interpreter creates no generated modules or atoms. The optional BEAM backend compiles prepared IR; its performance benefit depends on workload and reuse. Backend comparisons check values and diagnostics on finite corpora. Both backends share runtime semantics, so agreement cannot exclude shared bugs.
D-38 BEAM modules have explicit identity and lifetime
IR is emitted as Erlang abstract forms. Generated module names are hashes of that IR and the runtime cache stamp; loaded code is not replaced or killed while running. Release unloads eligible code but never reclaims its atom name. Use the BEAM backend for a bounded set of controlled expressions and the interpreter for unbounded external churn. The cache stamp prevents reuse of modules from an older compiler/runtime contract. There is no ABI negotiation or persisted generated-artifact format; rebuild prepared expressions on upgrade.
D-223 Evaluation counters and returned values keep their limits
Feel.Runtime.Budget owns cumulative iteration, call and diagnostic counters for sequential runs in one process. DMN shares the handle across dependencies, targets and final invocation; call depth remains local to a call stack. Unexpected exceptions invalidate the handle because partial consumption cannot be recovered. Allowed-value checks keep their work counters while isolating diagnostic scope. A runtime halt carries its environment through those scopes: each restores its earlier diagnostics, and the run boundary settles the complete counters exactly once. Normal alternative checks still exclude enclosing diagnostics from their success/error analysis. Handles are process-owned; direct FEEL callers use sandbox: false when sharing one, or create and use the handle inside a single enclosing worker.
Returned values are inspected for string and list bounds, including folded constants, nested contexts and range endpoints. Shared worker deadlines and transfer limits contain recursive membership, constraint traversal and output inspection (D-221). There is no general step or cumulative allocation quota. Direct host type queries and trusted calls require host containment.
New generated module names have a serialized VM-lifetime quota of 50,000, configurable as :feel, :max_generated_modules. Zero permits reuse only; :infinity removes the quota but keeps an atom-table headroom check. Refusal returns :too_many_generated_modules before interning a new name. Release and application restart do not refund it. Other atoms, concurrent compilation and retained code remain host responsibilities. Generated code uses main's cache-stamp contract; prepared expressions are rebuilt on upgrade.
D-45 Optimization must preserve observable execution
The optimizer folds safe constants, simplifies selected branches and removes checks it considers redundant. Prepared expressions keep the unoptimized IR and accept optimize: false. Folding uses the prepared dialect, extensions and limits; cached constant metadata avoids reevaluating nested subtrees. The required contract includes value, diagnostics, conversion, strict stopping and resource behavior. The signature-related check-removal counterexamples are repaired and retained as regressions on both backends and analysis tiers. Passing those cases does not establish this contract for every optimization.
D-113 Emission splits large generated functions
Chunking separates long bodies, collection construction and large-arity work into helpers with only the live variables they need. This avoids known host-compiler costs and limits for those shapes. It is not a proof of linear compilation for arbitrary control flow. Long branch chains, large constants and early generation accounting remain open work items in the compiler roadmap.
D-140 Preparation reuses local analysis metadata
Node shapes, constants, references and indexed facts are computed or memoized near their construction rather than repeatedly walking nested subtrees. Branch-chain facts and body reanalysis reuse work within their assumption context. These are preparation-local optimizations; they do not justify sharing results across different scopes, origins, declaration policies or effects. Optional narrowing validation cross-checks memoized answers against uncached facts, using the same algebra; it changes measured work and is not an independent semantic oracle. Public preparation is contained by the shared worker deadline and heap policy. There is no general preparation step quota; local analysis caps control selected precision and specialization work.
D-148 Executables keep environment templates, not a fixed run clock
Compilation keeps environment settings; a run instantiates its clock, limits, mode and entropy. Without an explicit clock, one OS instant is captured and converted to a date-time only if read. now() and today() share that run instant; today() uses the clock's own date/zone. A supplied clock function is read once per run. Preparation and constant folding cannot freeze nondeterministic calls.
D-154 Limits count selected work and construction
Feel.Limits is the source of defaults. Limits cover source bytes, syntactic/type depth, name tokens, selected narrowing work, generated statements, collection sizes, iterations, call depth/count, diagnostics and regex backtracking. Loops check growth while building results; host compiler emission currently has a final statement-count check after emission. These are logical counters, not a hard bound on total VM CPU or memory. Preparation and hostile host callbacks need additional containment; see the security model.
D-210 Numerical cost estimates were superseded by worker deadlines
The former runtime charged logarithms, exponentials, square roots and powers in weighted steps. D-221 replaces that accounting with whole-operation containment. Numerical range/precision safeguards and cached logarithmic constants remain; sandbox deadlines bound whole operations without estimating individual CPU costs.
D-221 Shared BEAM containment replaces general step accounting
Feel.Worker owns deadlines, process heap policies (including shared binaries), bounded input/result transfers and caller/child cleanup. Feel and Dmn protect complete operations by default; DMN XML parsing and optional table checks also call this worker directly. The shared defaults are five seconds and 128 MiB, with input/result caps defaulting to the heap allowance, including preparation and compilation. Larger artifacts need explicit host-selected guards. Nested mandatory work reuses a stricter enclosing policy, while tighter optional analysis uses owned child workers with the earlier deadline.
max_steps, weighted built-in costs and comparison budgets are removed. Cheap call, iteration, depth, diagnostic and value-size limits remain. Cumulative construction-byte accounting and its shared atomic counter are removed: the worker heap policy bounds live process memory at garbage collection, while individual string/list checks apply in both modes. DMN and its FEEL units share the same worker policy. Trusted calls with sandbox: false have no worker deadline or aggregate memory protection. This is an explicit resource-policy change: removed step and cumulative-memory options are rejected, not ignored. A BEAM worker is not a total VM memory boundary and cannot undo capabilities granted to trusted host callables. Transfer accounting uses a byte-budgeted continuation stack instead of an independent representation depth cutoff. Recursion signature probes inherit the chosen run policy. Worker termination details report observed exit/exception reasons with a bounded diagnostic payload; a :killed exit is not classified as memory exhaustion because an explicit kill can have the same reason.
D-222 An extension may declare that it adds data only
Feel.Extension has an optional callback data_only?/0. Returning true promises that nothing the extension returns is a function value or holds one, and that a diagnostic it makes has no :origin (Feel.Diagnostic): it cannot bring the text of another expression into a run. Feel.Extensions.data_only?/1 answers for a set of extensions (an empty set is data only; a module that does not declare it, or something that is no extension, is not). Nothing in feel acts on the answer. A host that copies a run into a worker does (DMN, DD-44): without the declaration it has to copy the diagnostic source of every element of the model, because an extension is trusted code that could return a function written elsewhere.
The callback is called once, when the set is prepared (Feel.Extensions.new/1, inside the protected preparation), and the answer is recorded in the set. A host reads the record (Feel.Extensions.data_only?/1 of a prepared set runs no extension code) and never calls the callback where its own deadline does not hold, such as the caller's side of a call before a worker has started (DMN records it at compilation, DD-44). A callback that raises or exits counts as false; one that blocks is stopped by the deadline of the preparation that calls it.
The default is the conservative one, so an existing extension keeps its behavior until someone who has read it declares it. A wrong true does not change a value; it makes a diagnostic of a function that came from elsewhere lose the element it belongs to when the run is in a worker. FeelJava.Extension declares it: a Java result is a number, a string, a boolean, null or a list of those, and its failures carry no origin. The bundled :camunda extensions do not declare it.
D-164 Scalar specialization has a guarded fallback
The BEAM scalar fast path checks supported assumptions and falls back to the interpreter when it cannot answer. Replaying speculative work is safe only before observable effects have committed. This is a deliberately limited specialization surface, not general deoptimization for host callbacks or arbitrary effectful operations.
Embedding and verification
D-40 Input entries return satisfaction, not a three-valued answer
Unary tests lower to expressions of the input ?; dynamic matching handles lists, ranges and values. All alternatives are evaluated. An entry is satisfied only by a true result; not(...) requires the alternatives to be false, so unknown is not converted to success. The wildcard - matches every input, including null. This differs from expression in, which can return null (D-61). An expression of ? is decided by its value and not by its type (D-209).
D-209 A unary-test expression of ? is decided by its value, not by its type
A unary test that is an expression has a value, with ? bound to the input. If the expression reads ? and the value is a boolean or null, the value is the answer: null is not satisfied, and not(...) of it is not either. A list matches an input equal to an element, a range an input within it, and any other value an equal input. An expression that does not read ? is compared in the same way, so a null input matches a null value (a null literal, a name bound to null). Both backends call Feel.Runtime.UnaryTests.matches/3 for this.
The expression reads ?, its value is | Satisfied | not(...) satisfied |
|---|---|---|
true or false | the value | the opposite |
null (for any input) | no | no |
| a non-null value that is not a boolean, list or range | the input equals it | the input does not |
| a list or a range | the input is in it | the input is not |
The analyzer lowers a test to ? = e only where that gives this answer for every value of its type (Analyzer.compared_by_equality?/2): the type consists of kinds compared by equality and, when e reads ?, has neither null nor boolean. Any other expression is a :match node. So inferred precision and the declared type of the input never change a result: ? + 1, -? and (function(x) x)(?) are not satisfied by a null input however the input is declared, and a boolean that ? gives in a mixed type (if ? > 5 then true else 7) is its own answer. in follows the same rule with its three-valued result: null in (? + 1) is null.
The specification gives a boolean expression of ? as its own value (Table 56) and says nothing about other expressions; the TCK has no case that uses ? (0072-feel-in lists the syntax in a comment only); Camunda's feel-scala and Drools leave such a null unsatisfied. A rule by static type cannot cover a type the analysis does not know. Treating that null as the answer makes a declaration change a result, and treating it as equal to the input lets a null boolean that an opaque type hides match a null input. Equality with a non-boolean value of ? is kept, which feel-scala and Drools do not do. UnaryExpressionOfInputTest checks every kind of type, null and non-null input, and null and non-null value against this table on both backends, optimized and not.
D-174 Embedders can prepare a source AST directly
Feel.prepare_ast/3 accepts trees built by an embedding layer, with source/spans supplied for diagnostics. Such nodes use the same analysis, optimization and runtime paths as parsed expressions. This avoids regenerating FEEL text and losing model structure; it does not remove the need for correct node contracts or source mapping.
D-175 Decision tables are dedicated AST/IR nodes
A table keeps its hit policy, input/output declarations, rule order and selected output evaluation. It is not translated to nested general expressions that would obscure diagnostics or evaluate outputs that the policy does not require. Both backends call the shared table runtime. Static rule questions are a separate facility (D-158).
D-195 Rule entries narrow that rule's outputs
The analyzer reads all entries of a rule, meets their facts, then analyzes its outputs under those facts. A positive entry uses the in predicate's true outcome; not(...) uses its false outcome, which excludes null. A dash uses the column's input values when present. Repeated inputs, paths, operations and supported numeric calls use the existing subject and preimage machinery.
This applies to every hit policy because only matching rules have their outputs evaluated. Default outputs and output-column value declarations remain outside the rule. Earlier columns do not yet narrow later entries; failed earlier rules do not narrow a FIRST rule. Contradictory facts do not assign a dead rule's outputs Never; dead-rule reporting remains a separate Feel.Rules question. Facts are skipped when the outputs and constraints cannot use them, avoiding extra work for constant tables. table_narrowing_test.exs and the table sweep exercise the rule on both execution paths.
D-188 Explicit links enable recursive knowledge functions
Feel.Recursion resolves links to functions whose closures are created in the invocation environment. This supports embedding-layer recursion without adding an implicit self-binding to every FEEL function. The interpreter handles these links, with call count/depth limits; it is not general recursion support in all backends. The DMN layer decides which cycles are permitted.
Before probing signatures, the linker prepares each member again from its saved AST and preparation options. Its peer bindings carry internal callee evidence that they are single-signature FEEL closures (or null), distinct from the partial signature guarantees of ordinary function types. Aliases and selected containers retain this evidence through Callees; shadowing parameters and ordinary host bindings do not acquire it. Positional self-calls with broad argument types retain the peer's result bound and possible conversion failure. Named self-calls use that bound or null when they supply at most the parameter count, and null otherwise. This bound does not prove a name match: unknown or duplicate names still fail binding, and omitted parameters receive null. Their observable signatures and binding conversions stay consistent instead of widening to Any when the open-function invocation rule is repaired.
D-200 Linked closures are cached within a run
The first call of a linked member constructs its closure and stores it in Feel.Runtime.Env.links, keyed by the link identity and member key. Later calls reuse it. A new run or a separate link has its own cache, so returned function values need no process dictionary or table with a shared lifetime. The cache keeps called closures and their binding/link maps until the run ends.
Closure construction reports binding diagnostics once, when it happens; call-depth and call-count limits still apply to every call. A first construction inside a quiet argument can have its diagnostics suppressed while the cached closure is kept, following the quiet-call policy.
Links take their signatures from a probe with peers bound to null. When constructing the actual closure, the runtime checks that its parameters and result type still match that signature. A member whose function shape depends on a peer is null through its link and reports :type_mismatch; strict mode stops on that diagnostic. The check applies inside sandboxed and trusted calls alike.
D-158 Rule analysis distinguishes conclusions from uncertainty
Feel.Rules asks coverage, overlap and dead-rule questions using possible-input upper bounds and certain-input lower bounds. The verdict interface distinguishes a conclusion, a refutation with a witness, and unknown. Failed subtype proof is not proof of a gap. Certain numeric sets now use bounded subsets; certain string sets discard information when their point budget is exhausted. They never use an enlarged hull or a widened string kind to establish certainty. Numeric complement and inclusion are exact at the interval cap. Range exclusion combines the excluded endpoint images with exact union before complementing them: widening that union would shrink the complement and invent range-subtype and coverage proofs. The retained exclusion planner bounds this work to six negations with at most sixteen intervals each. The boundary regressions keep the 17-point example of section 2.3. String alternatives use bulk StringSet.union_all/2; negated lists use intersect_all/1, followed by the lower-bound point/exclusion budget check. Interval ranges do not count toward that budget, so combining them one prefix at a time would restore quadratic work. Reduction-count regressions cover both directions on disjoint open intervals. Witnesses are not universally validated against original entries; the broader proof audit remains open.
D-159 Node monitoring checks exercised runs
Feel.Paranoid evaluates unoptimized IR while checking monitored node values against inferred types and compares prepared execution where used by sweeps. Comparisons keep diagnostic order and every field, including spans, origins, messages, details and error kinds. Embedded values use recursive equality; decimal scales are ignored and FEEL implementation closures are ignored when comparing function signature metadata. Comparing two function values checks parameters and result type, not what the function does; a test that needs the behavior calls the function itself. It has monitoring exclusions for selected eager operands and function bodies. Membership uses the implementation's own type machinery, so a quiet monitor does not independently establish semantic membership or soundness.
D-198 Result compatibility asks whether conversion can succeed for some value
A declared result is certainly incompatible only when none of its inferred alternatives can convert. The analyzer recognizes refined date alternatives by kind, so date sets can convert to date-time. Structural overlap is considered recursively rather than rejecting a context merely because one entry's type is broad. A possible conversion keeps the applicable result type; an impossible one still yields null and a mismatch warning. This repaired under-approximated result nodes that the DMN audit found, without changing binding or call-argument conversion policies.
D-199 Verification includes rejected declarations and whole-model chains
Feel.Sweep.Rejected builds declared AST nodes that source parsing cannot express, placing constraints on context entries, list elements and function results. Its pools include both accepted and rejected values; a coverage assertion requires both outcomes. Feel.Sweep.Tables exercises entry facts and rule outputs, while Feel.Sweep.ExtensionConditions checks facts when extensions answer calls.
Dmn.TCK.Audit runs planned units with Feel.Paranoid and compares their chained values with ordinary prepared execution. It uses TCK cases, bounded single-input variants and separate audit models with rejected declarations. Function-body findings are attributed to their owning unit through the caller. It is not a complete model checker: unused units, ungenerated input combinations, recursion and nondefault extension configurations can remain unexercised. Membership still uses the project's own predicate and needs independent checking.
D-180 Regression evidence includes generated sweeps and mutation checks
Unit tests, backend/optimizer comparisons, finite-grid sweeps, construct-suite gaps and checked narrowing tables exercise different contracts. Regular tests run deterministic slices; full sweeps and mutation scripts provide additional coverage. Keep failing reproducers rather than shrinking pools to hide a finding. Independent oracles are still needed at algebra, cap and variance boundaries. BEAM-heavy differential tests run after the async phase through Feel.BeamCase, reducing code-server contention. Ordinary tests do not unload shared generated modules; unloading is tested separately. Cost checks use reductions where elapsed time would measure machine contention rather than work.
See the sweep workflow.
Feel.SpecVectors classifies printed/copied rows as :spec / :xref, which are the intended oracles. :derived and :interpretation rows are coverage aids, not authoritative expected answers. This classification is heuristic and must be checked against a row's provenance before trusting it. Decimal fixtures and the official TCK are separate external evidence. The TCK report records the corpus, runner options, accepted deviations and a dated snapshot, not a percentage proof of the language semantics.
D-206 Mutation generation tests the limits of the sweeps
Feel.Sweep.MutantGenerator changes selected syntax-tree nodes of analysis files. Seeds reproduce a sample; a control run first checks rewriting without a mutation. The driver can run slices, full sweeps, unit tests and type comparisons in temporary copies. A generated survivor is reviewed, not automatically classified as a defect. Timeout-only failures are marked flaky and unit failures are retried alone before being counted.
The handwritten suite keeps specific wrong rules and their intended detectors. Some are held by unit tests rather than sweeps; an equivalent or safely widening change may legitimately survive. Generated experiments exposed missing extension-condition, rounding-boundary, mixed context-key and negative-median-overflow coverage. The added regression cases keep those checks. Survivor triage, more samples and wider extreme-value coverage remain in the backlog; generated runs are on demand, and D-217 adds a CI check of the handwritten mutants.
D-217 Handwritten mutants run in CI, and a second sample is part of the evidence
A second generated sample (seed 2, 100 of 4,107 sites, including inference.ex, calls.ex and analyzer.ex, which the default file list leaves out) found three wrong rules that no test covered. Each is closed with a unit test and is a handwritten mutant, nineteen in all: a quotient of numbers near zero by divisors of which only some have a whole reciprocal; two times whose sets share no number but may be equal (a time with a zone is compared by instant, one without by clock); and the sum of numbers far below zero, which the overflow check covered on the upper side only.
near != @zero in Images.List.tiny_mean?/1 is not dead, as D-206 read it. Both operands are sets of numbers, and the comparison is false only when the one number near zero is exactly 0. That separates a mean of numbers that cancel and leave 1E-6176 (null or 0, by the order of the sum) from one that cancels to 0. A unit test and a mutant cover the rule; the sweeps have no number near 1E-6176 and do not see it.
Twelve more types that are too small were covered by unit tests alone. Their argument is a set (a month in [1..12], a pattern that is one text), while the sweeps ran bare kinds; D-218 adds the calls and conditions that reach them.
mutants.exs --terms says whether the whole sweeps can see a mutant at all: a sweep fails one only if a type moves or the sweep raises. The mutants job of CI runs the handwritten mutants on the slices and fails when a mutant is not what it is expected to be, so a catch that has become thin is noticed when a generator or pool moves the slice. It is a job of its own, not a step of check.sh; it takes about two hours on a hosted runner, so it is in .github/workflows/weekly.yml, on Mondays and on request, and not in the per-push CI.
D-218 Sweeps run set-typed arguments and outcome conditions for the rules that read them
Twelve mutants of the second sample made a type too small and only a unit test failed. The rule of each reads an argument that is a set of values (a year, an hour, a pattern that is one text, a null start, a dividend with a long rest) or the facts of a comparison's null outcome used as a condition, and the sweeps ran bare kinds. Calls therefore lists 22 calls of date, time, replace, substring, stddev and modulo with such arguments (@sets), and Narrowing.outcomes/0 compares a number and an unknown value with true and false as conditions. A name that may be null is narrowed first, or its null would make the call null and say nothing about the rule.
The additions are deliberately small: sets and unions in every pool were 42 % more bindings of the call sweep for no catch (D-206). Against the sweeps without them: the call sweep gains 0.04 % of its expressions, 0.009 % of its bindings and 0.05 % of its reductions, the narrowing sweep 0.8 % of its expressions, 1.9 % of its bindings and 0.8 % of its reductions (0.1 s and 0.6 s of processor time against 30 to 50 s and 80 to 100 s). With them, all twelve mutants (ten sites) fail a sweep on the tree of D-217: through 1 to 5 expressions in the slices and 1 to 15 in the whole sweeps. date with a year past the calendar is seen in the slice by one binding of an expression, so that catch is thin.
The twelve are generated names that exist only on the tree of D-217, so eight handwritten mutants carry them into CI, one for each way the sweeps reach a rule (the two outcome conditions, date, time, stddev, modulo, replace, substring); the handwritten list is 27 and each of the eight is caught in the slice by 2 to 5 expressions. These are calls chosen for the rules that were found, so a rule of the same kind in another function is not seen until its call is listed.