dmn is a layer above FEEL. It owns the model (the XML, the graph of decisions, the boxed expressions) and FEEL owns what an expression means: how names are found, how values are typed and converted, what the operators do, how a run is bounded. The two meet in a few places. This page says where, what each side hands to the other, and how a node that FEEL did not have (a decision table, a value with a declared type) was added to FEEL so that the rest of it, the analyzer, the optimizer and both backends, treats it as it does the nodes it always had.

Read it before changing either side of the seam, or before adding a boxed expression that FEEL has no node for.

The current DMN decisions explain this layer's choices. The FEEL decisions cover AST preparation (D-174), table nodes (D-175), declaration boundaries (D-190) and linked recursion (D-188). The README covers the public API; the XML and import policies describe model loading and resolution before FEEL preparation.

The path from a DMN model to a prepared FEEL expression, in six steps, with the calls that cross between dmn and feel

The numbers in the figure are the steps of this page. (The figures are SVG: click one to open it at full size.) Steps 2 to 4 are done once per unit, a unit being one element of the decision requirements graph (a decision, a business knowledge model, a decision service); the plan of a model says which units a unit reads.

What crosses the seam

FEEL givesUsed byFor
Feel.ItemDefinitions (new/1,2, scope/2, variable/2)Dmn.Compiler.Types, Dmn.Compilerthe item definitions of a model as FEEL types, every constraint of a type as a list (variable/2, D-190), and the typed scope of a unit
Feel.Scope (new/1, put_name/3, fetch_name/2)Dmn.Compiler, Dmn.Compiler.Envthe names a unit may read, each with its declared type
Feel.parse/2 with :scope, :scope_index, :entryDmn.Compiler.Fragmentthe syntax tree of each text of the model
Feel.Syntax.ScopeIndexDmn.Compiler.Envwhat the parser needs of a scope, made once and added to a name at a time (D-177)
Feel.AST (the nodes) and Feel.AST.shift/2Dmn.Compiler.Expression, .Table, .Tree, .Fragmentthe tree of a unit, built from nodes and from parsed fragments
Feel.prepare_ast/3Dmn.Compiler, Dmn.Run.invoke/4analysis, typing, optimization and compilation of a tree that was not parsed (D-174)
{:decision_table, …} and {:declared, …}Dmn.Compiler.Table, .Expressionthe two nodes that FEEL gained for a layer above it (D-175, D-190); a declared value is held to every constraint of its type (D-190)
Feel.Expression.unoptimized_ir/1, Feel.warnings/1Dmn.Compiler.Checks, Dmn.Compilerthe IR with its types, and what was found while preparing
Feel.Rules (overlaps/3, dead/3, coverage/3)Dmn.Compiler.Checksquestions about a table, asked before it runs
Feel.run/3Dmn.Runthe run of a prepared expression, with Feel.Limits, :mode, the clock
Feel.Recursion.link/1, run/3Dmn.Runthe units of a recursion group, each run with links to the others it reads (the :recursion feature, DD-22, DD-29, D-188)
Feel.run/3 :origin, Feel.Value.Function.with_origin/2, Feel.Diagnostic.originDmn.Runwhich text the span of a diagnostic is a range of, when a run is of more than one: the key of the unit that is run, which a function written in it keeps wherever it is called (D-201)
Feel.ItemDefinitions.new/2 with recursive: :guardedDmn.Compilerfinite graphs for guarded recursive data, with complete finite-value membership and descendant constraint checks when the recursion feature is enabled (D-189)
Feel.Type.Conversion, step :function_signaturethe types dmn declaresa built-in with several signatures, given where a function type is declared, becomes the function of the signature that fits (D-197)

The table lists the main semantic interfaces. dmn also uses Feel.Worker and its bounded preflight utilities for containment and compact input selection. No function of FEEL depends on dmn.

Why a tree and not text

DMN 1.6 says that boxed expressions "can be mapped to FEEL text" (10.2.2). The obvious engine does that: it writes the FEEL of a context, a function, a table, and parses it. That loses what the model knows. A name has to be escaped. A diagnostic points into text that nobody wrote. A table of hundreds of cells is one long string, parsed again for every evaluation. And a decision table has no FEEL: a UNIQUE table with two matching rules is null with an error that names the rules, a PRIORITY table orders by the position of an output among the output values, and the outputs of a rule that does not count are not computed. Written as FEEL these need helper functions, lose the type of the result, and report errors that belong to the helpers (this was tried, and is why D-175 exists).

So dmn builds the tree itself, from the nodes of Feel.AST and from the parses of the texts that the model does contain, and gives it to Feel.prepare_ast/3. The meaning of every part stays FEEL's.

A unit, end to end

A model with one input and one decision table:

<inputData id="customer" name="Customer type"><variable name="Customer type" typeRef="string"/></inputData>
<decision id="discount" name="Discount">
  <variable name="Discount" typeRef="number"/>
  <informationRequirement><requiredInput href="#customer"/></informationRequirement>
  <decisionTable id="t1" hitPolicy="UNIQUE">
    <input id="i1"><inputExpression typeRef="string"><text>Customer type</text></inputExpression>
      <inputValues><text>"Gold", "Silver", "Bronze"</text></inputValues></input>
    <output id="o1" typeRef="number"/>
    <rule id="r1"><inputEntry><text>"Gold"</text></inputEntry><outputEntry><text>0.2</text></outputEntry></rule>
    <rule id="r2"><inputEntry><text>"Silver", "Bronze"</text></inputEntry><outputEntry><text>0.1</text></outputEntry></rule>
  </decisionTable>
</decision>

Dmn.compile/2 makes one unit for the decision Discount. Its prepared expression has this IR (Feel.Expression.unoptimized_ir/1, abridged; the types are what the analyzer found):

{:conform, type: number in {0.1, 0.2}, or null}                        result_type: number   (the variable of the decision)
└─ {:conform_bindings, :enforce, [{"Customer type", {1, 14}, :string}], …}   the scope: the input is read as a string
   └─ {:decision_table, span {0, 0}, :unique, nil, inputs, rules, outputs}   the node dmn built
        inputs   [{ {:var, "Customer type"},  {:unary_tests, [? = "Gold", ? = "Silver", ? = "Bronze"]} }]
        rules    [{"r1", [{:unary_tests, :positive, [? = "Gold"]}],               [{:conform, {:const, 0.2}, :number}]},
                  {"r2", [{:unary_tests, :positive, [? = "Silver", ? = "Bronze"]}], [{:conform, {:const, 0.1}, :number}]}]
        outputs  [{nil, nil, nil}]                                              (no name, no order, no default)

Four things in it came from dmn: the type to convert the result to, the typed scope that turned the read of Customer type into a conversion, the table node, and the declared column: <output typeRef="number"/> makes each output entry a declared value, which is the :conform around it (DD-25). Everything else is what FEEL makes of the parts: ? in an entry is the value of its column, typed string, so an entry is a test of a string. The source of the unit is its texts, one after another (next section). Running it:

Dmn.evaluate(compiled, "Discount", %{"Customer type" => "Silver"})    #=> {:ok, Decimal.new("0.1"), []}
Dmn.evaluate(compiled, "Discount", %{"Customer type" => "Platinum"})  #=> {:ok, nil, []}

The second is null because no rule counts and there is no default (DMN 1.6, clause 8 and 10.3.2.10). This should not come as a surprise: compile/2 already reported it, with an input that triggers it, as the :incomplete_table diagnostic of the decision (see "Step 5").

Step 1: the types

Dmn.Compiler.Types hands the item definitions of a model, and of the models it imports under the names of the imports, to Feel.ItemDefinitions.new/2 as plain data, with default cycle rejection or recursive: :guarded when the caller enables recursion. From them, Feel.ItemDefinitions.scope/2 makes the Feel.Scope of a unit: each name it reads (an input, a decision) with its declared type, allowed values and type constraints. A business knowledge model or a decision service it invokes is in the scope as a function type. The model is the only source of those types, and dmn has no typing of its own: what a typeRef means, what an implicit conversion is and how a value that does not fit becomes null with an error are FEEL's (DMN 1.6, 10.3.2.9; D-138). The following declarations determine what dmn hands over:

  • Every constraint of a type. A type can have its own allowed values, those of its components, a type constraint, a second list. FEEL checked a declared value against one list of allowed values, so {age: 200} satisfied a tPerson whose age is [0..150]. Types.result/2 and declared/2 now hand over the list that Feel.ItemDefinitions.variable/2 gives, and a declared value is held to all of it (D-190, DD-25).
  • What a function gives. A function item names its result type with outputTypeRef. FEEL's arrow keeps the type but not its constraints, so Types.function_outputs/1 and output_declared/3 recover the named result's constraints for the body (DD-25).
  • What a caller sees (DD-33). A business knowledge model uses Types.knowledge_type/4: its formal parameters determine the inputs, and a function-typed variable contributes the function item's output as the result. For functionItem outputTypeRef="number", a numeric K(10) therefore has a numeric result, rather than a function result. Aliases and imports use the same rule; parameters in the function item do not override the model's formal parameters. A higher-order model names a function item whose output is another function type. A decision service uses Types.service_type/5: one output keeps its decision variable's type, including a function-valued output; several outputs form a context keyed by decision name, and no outputs form an empty context. The service's own variable can declare the final result, unwrapping only its outer function-item layer. A result other than Any controls the caller type after conversion; service input declarations determine parameters. The generated service body enforces a derived recursive result too, so its runtime signature agrees with the declaration used by calling decisions. Individual output constraints stay with their decisions.
  • A built-in where a function type is declared (D-197, TCK 0092 #014). A parameter, a name or a result declared function<number> -> number and given abs, which is made of signatures that differ in their types (a number, a duration), was refused: an overload is not an instance of one of its arrows (D-171, D-171), and making it one would be unsound, since a function type holds for any arguments and abs(duration) gives a duration. So instance of and the analysis are as they were, and the value is converted: Feel.Type.Conversion has a last step, :function_signature, that gives it the function of the one signature that fits, and the call converts its arguments to the parameter types as a call of a function written in FEEL does.
  • A type that refers to itself is an error by default. The :recursion feature enables guarded data graphs: every cycle crosses a context member or list element, and no cycle crosses a function signature. Membership and component constraints visit all finite descendants, with ordinary null exemptions and no implicit conversions inside fields. Analysis uses a bounded upper projection; it cannot treat inclusion in that projection as proof of membership or unchanged conversion. Alias-only and function-type cycles remain errors. The legacy FEEL recursive: :any mode is approximate and is not what the DMN feature uses (D-189, DD-22).
  • What the caller switches on. Dmn.compile(model, features: [:recursion, :dmn_1_1_types]) (DD-29) is the one option for what DMN 1.6 does not have: each is off unless the caller asks, none can be requested by a model, and each adds what was an error before. :recursion is the engine's own (Step 6); the others are extensions (Dmn.Extension, DD-28). The first of them appends the names that DMN 1.1 gave the date and time types (dateTime, dayTimeDuration, yearMonthDuration) to the item definitions, only where the model defines no type of that name, so that every place that reads a typeRef sees them and none has to map them. recursion: true and extensions: [...] still mean what they meant, and add to features:. The engine is DMN 1.6 unless the caller asks for more.

Four places where the types of a model enter FEEL: the variable of a decision, the scope, a declared value inside the tree, and function parameters

The four places are the rows of the figure. The first, second and fourth use options and a scope that prepare/2 always had. The third needed a node ({:declared, …}, below), because a decision's result was the only value that FEEL could convert and verify against a declaration, and a boxed context has an entry with a typeRef in the middle of an expression, and so does the output column of a decision table.

Step 2: parsing the texts

Every text of a model is parsed alone, where it stands, by Dmn.Compiler.Fragment:

Feel.parse(text, scope: env.scope, scope_index: env.index, entry: :expression | :unary_tests, limits: env.limits)
  • :entry is :expression for a literal expression, an input expression and an output entry, and :unary_tests for an input entry and for the input values of a column. A table's entries are not expressions. The output values of a column are read as a list expression ("High", "Low" is ["High", "Low"]), since they are the order of the priority policies.
  • :scope is what lets the parser read a name of several words. Customer type is one name only because it is in the scope, and the parser matches the longest name that the tokens make. The scope has the names of the unit's requirements, and, inside a boxed expression, the names that the expressions around bind: the entries of a context, the parameters of a function, the variable of a loop (Dmn.Compiler.Env.bind/2). Those are not typed here. The analyzer finds them again from the tree, with their types.
  • :scope_index exists for large scopes. The parser takes the names of a scope, lexes each to count its tokens, and builds a set, for every text. That is negligible for one expression but quadratic in the size for a context of 4,000 entries, each a text parsed against a scope that grows. Feel.Syntax.ScopeIndex.new/2 does it once, put_name/2 adds a name in time proportional to its tokens, and Feel.parse/2 reads from the index instead of from the scope (D-177). A parse that is given no index behaves as before.
  • What cannot be parsed is not an error of the model. It is a diagnostic of the element, with the place in it, and the fragment is null (an expression) or tests that nothing satisfies (an input entry: a rule with an entry that cannot be read never matches), in line with DMN 1.6, where an expression that cannot be evaluated gives null (7.3.8). The unit is prepared all the same.

Step 3: building the tree

The virtual source

FEEL reports where it found a problem as a byte range of one text (Feel.Diagnostic, a span). A unit has many texts, and syntax that has none. So a unit is prepared from a virtual source: the texts one after another, a newline after each, with the tree of each parse moved to where its text is (Feel.AST.shift/2), and a source map from a range of the source to a place in the model (Dmn.Compiler.Source).

The texts of a decision table, the glue parts of its nodes, the virtual source they make, and the source map that takes the span of a diagnostic back to a rule and an entry

  • A text is added with its origin, {element, path}, where the path is a place in the element: [:expression, :rule, 1, :output_entry, 1]. Source.add/3 returns where it starts, which is the offset of shift/2.
  • A node of the tree that stands for a boxed expression has no text. It gets a glue part: one byte of its own, an empty text and a newline, and the zero-width span at its start. A diagnostic on it is found in that part, so it has a place in the model like any other. The table has one, and so has the :declared node around each output entry (the two g parts of the figure), with the path of the entry it declares.
  • prepare_ast/3 is given the concatenation as its source. It does not read it, only keeps it, and the spans of the diagnostics are ranges of it. Source.find/2 turns a span into {element, path, offset, box} by a search over the parts (locate/2 and box/2 are its halves), and Dmn.Diagnostic.from_feel/4 turns a FEEL diagnostic into a diagnostic of the model. This holds for the diagnostics of preparation and for those of a run.

What a boxed expression becomes

The tree is built directly, as the analyzer wants it: a name is its list of tokens, a key is a string, and nothing is escaped.

Boxed expressionTree
literal expressionthe parse of its text
any expression with a typeRef{:declared, …} around it
context{:context, …}. The entry with no variable is the result, selected from the context under a key that no text can write
function definition (FEEL){:function, …} with the parameters typed by their typeRef
invocation{:call, …} with the arguments by name
relation, lista list of contexts, a list
conditional, filter, for, some, every{:if, …}, {:filter, …}, {:for, …}, {:quantified, …}; the first, second and the quantifiers are marked boxed: true (FEEL D-212, DD-38)
decision table{:decision_table, …} (a node of FEEL's own tree), each output entry and default output entry a {:declared, …} of its column (DD-25)
decision servicefunction(inputs) { decisions…, result }.result, each decision a declared value that reads what it requires and no other input or decision of the service (DMN 1.6, 10.4); the decisions report their own texts and their faults are the service's (DD-34)
business knowledge modela {:function, …} that is run once to give a closure, so calls, conversions and limits are FEEL's

Two things are done on the way, and both are about names:

  • The names that the compiler adds (the key of a context's result, the aliases of a service's outputs, the parameters of an invoke) begin with U+0001. XML 1.0 does not allow it in a document and the lexer of FEEL does not read it as part of a name, so no name, id or text of a model can be one, or collide with one (Dmn.Compiler.Tree.internal/1).
  • An entry that reads the name of its own key. {context01: context01} is a cycle in FEEL. In a boxed context it is the decision called context01 (TCK 1146, 0085; DD-12). Dmn.Compiler.Expression binds the outside value to another key of a context around it, and Dmn.Compiler.Rename renames the reads of the entry to that key. Every context of the tree has a span of its own, because Feel.Analysis.References finds the entries of a context by it.

The two nodes FEEL gained

Both are in Feel.AST and have no text: a layer above FEEL builds them, and the parser never does. A node is trusted to be well formed (a node that is not one, or a name that is not a list of tokens, raises); what is not trusted is meaning, which the analyzer checks as it does for a parsed tree.

{:decision_table, meta, policy, aggregation, inputs, rules, outputs}

PartIs
policy:unique, :first, :any, :priority, :output_order, :rule_order or :collect
aggregationnil, or :sum, :count, :min, :max for a :collect table of one output
inputsthe columns: {expression, input_values}, the input expression and the unary tests that its values are expected to satisfy (nil for none)
rules{id, entries, results}: the id for messages, one entry for each input ({:unary_tests, …}, or nil for -) and one output entry (an expression; for a column that declares a type, a {:declared, …} around it) for each output
outputs{name, order, default}: the name (nil for the one output of a table that has one), the output values as a list expression (the order of :priority and :output_order), and the default output entry

It is a node and not a translation (D-175) because its meaning is partly FEEL's, and the rest is not FEEL at all. The entries are unary tests of the input of their column, the outputs are expressions, and what the analysis knows of them gives the type of the table: those are FEEL's. Which rules count, which hit policy chooses among them, and that the outputs of the others are never computed belong to Feel.Runtime.DecisionTable.

{:declared, meta, expression, type, allowed_values}

The value of an expression that something declares a type and allowed values for, as a decision declares them for its result. dmn builds it where it knows the declaration: the value of an entry of a boxed context, of a decision inside a decision service, the body of a function, and any boxed expression that has a typeRef, and each output entry of a decision table whose column declares a type. The value is converted to the type (a list of one element is its element for a type that is not a list, a value is a list of itself for a list type), and is null with an error when it does not conform or does not satisfy the allowed values (DMN 1.6, 10.3.2.9). type is a Feel.Type.t() whose names are those of the scope, and allowed_values is the source of a list of unary tests, or, since D-190, the list of every constraint of a declared type: each one for the value or, down its steps (a key of a context, :each for the elements of a list), for a member of it. All of them have to hold, and the first one that rejects the value makes it null with the error that says where. A value that is null, and a member that is not there, are exempt.

Step 4: preparing

Feel.prepare_ast(tree, source, options) takes the options of prepare/2 except :entry and :dialect, which are those of a tree: an expression, and :feel. dmn gives it scope:, limits:, and, for a decision, result_type: and result_allowed_values:, the declaration of its variable (the type, and every constraint of it). The shared preparation pipeline (feel/preparation.ex) analyzes, optimizes and compiles the tree as it does a parsed one. It records conservative binding dependencies before optimization, including declaration checks and returned closures. Recursive groups receive complete peer evidence during compilation, so their compact runtime expressions do not need saved ASTs or preparation options. The semantic diagrams below omit the outer shared-worker boundary; public compilation includes every stage under the same deadline and heap policy.

How the analyzer learns a node

For the analyzer, a new node is a clause of analyze_clause (a private function) in Feel.Analysis.Analyzer.

The analyzer clauses for a declared value and for a decision table, and the type of a table by hit policy

:declared reuses existing code. The result of a whole expression is converted and verified when the scope or the options declare one (verify_result/3, D-138). The clause analyzes the expression, then runs verify_result/3 with the declaration of the node in place of the declaration of the expression. The nodes it makes are the :conform and :allowed that the optimizer, both backends and the audit already handle. Since D-190 the declaration may be a list of constraints: each is an :allowed node around the one before, and one that is for a member of the value (a component of a context, the elements of a list) has meta.path, the steps down to it. That is the one thing that the backends learned for it (Feel.Runtime.AllowedValues.verify_member/7, a clause of Walk.value_of/3 and of Emitter.expression/2). And since D-190 the type of an :allowed node has null in it unless its tests are certainly satisfied, since a value that a constraint rejects is null to whatever reads it afterwards.

:decision_table is analyzed in an order that the types need:

  1. Feel.Analysis.Tables.validate/6 checks the shape: a known policy and aggregation, an aggregation only for a :collect table of one output, named outputs when there are several, and in every rule one entry for each input and one output entry for each output. A node that is not a valid table is the error :invalid_decision_table at the span of the node, whoever built it.
  2. The input expressions are analyzed first, because their types are the types of the columns.
  3. The input values of the columns are analyzed next (analyze_table/5): tests of a member of the column's type, like the entries. A - is satisfied by the input values of its column, if it has some, so a rule with a - there says what they say.
  4. Each rule is analyzed as a unit (analyze_rule/3), because what its entries say of the inputs holds in its outputs (D-195). A test is analyzed in the context of a member of its column's type, the way the tests of in are: ? has the type of the column, so > 18 in a number column is a test of a number, and a test that no value can satisfy is reported. Then the entries become facts about the input expressions, as the condition of an if does for its branches: a positive entry is input in tests, not(…) is its outcome false (D-194), and a - is the input values of its column; the facts of the columns are met, and the outputs of the rule are analyzed in the context that has them (rule_context/4, refine/3). So in age >= 18 → age - 18 the age of the output is a number from 18, whichever hit policy the table has, since an output is computed only for a rule that matched. The default outputs and the output values are not under a rule and are analyzed in the context around. A rule whose outputs read no name, which is true of nearly every table, makes no facts. What the parts read, and what they report, is merged into the result of the node, and the errors of the entries and of the outputs of a rule are found together.
  5. refill/2 puts the answers back where the parts came from (an absent part stays absent).
  6. Feel.Analysis.Tables.result_type/5 makes the type of the table from the policy and the types of the outputs of all the rules and defaults. null is always in it: a table is null when no rule counts and there is no default, and when its hit policy breaks.

The result is an IR node of the same shape, with the type in its metadata. Other traversals of the tree (Feel.Analysis.References, the quoted names, Feel.AST.shift/2) do not need a clause for it: they walk any tuple whose second element is a metadata map with a :span, which is why the node has that shape.

The optimizer and the backends

The optimizer, the two backends and the shared runtime of a decision table

  • The optimizer (Feel.Optimizer) rewrites the parts of the table and never folds it: the entries are tests of inputs that are read when it runs.
  • The interpreter (Walk.value_of/3) evaluates the input expressions in order, then gives the runtime a function for each part: an entry is fn input, env -> … end that evaluates its tests with the input bound as ?, and an output is fn env -> … end.
  • The BEAM emitter (Emitter.expression/2) emits the same funs as abstract format (entry_function/2, outside_function/2) and a call of DecisionTable.run/5. It puts no semantics into the generated code, as it does for every operator.
  • The runtime (Feel.Runtime.DecisionTable.run/5) is where the hit policies are implemented, once, for both. Because the entries and outputs are functions, the entries of a rule after the first one that is not satisfied are not run, and the outputs of the rules that do not count never are. An error (several rules of a UNIQUE table, different outputs of ANY, an output that is not among the output values of a priority table) is null for the table with a diagnostic that names the rules, at the span of the node, and ends a run in mode: :strict.

test/feel_decision_table_test.exs runs every policy on both backends, which must give the same value and the same diagnostics. dmn runs on the interpreter unless the caller asks for the BEAM backend with Dmn.compile(model, backend: :beam) (DD-41); that one creates modules and atoms that are never freed and is for models that the caller trusts.

Step 5: questions about a table

The input entries of a table are unary tests, and the input of a column has the type that the analysis gave its expression, so a rule is a set of inputs and the questions about a table are questions about sets. Dmn.Compiler.Checks asks Feel.Rules after the unit is prepared:

  1. It reads Feel.Expression.unoptimized_ir/1 and finds the :decision_table nodes, wherever they are.
  2. Each node's span goes through the source map to a place in the model, and the place to the table of the model that it came from.
  3. The columns are the types of the inputs without null (a null input matches no rule). A bound containing only null falls back to non-null Any for checking. These are abstract column domains; a witness need not be reachable through the rest of the decision model. Prepared wildcard entries use the column's inputValues when present; explicit entries use their own test, unconstrained by inputValues. Wildcard identity comes from the prepared IR, so empty or commented wildcards and ignored column values agree with runtime behavior. Each query receives the unit's named type definitions through types: map, preserving aliases, imported types and multiword type parsing even when analysis resolved or folded away instance of. Tests that read model or local bindings, filter fields, dynamic names, extension calls or indirect calls remain open (:unknown cells in Feel.Rules), since reparsing their source without those bindings can resolve a different function. Their possible inputs are the whole column and their certain inputs are empty. A call lowered from a builtin alias keeps its binding provenance, so the original spelling cannot be reinterpreted as a different builtin. Indirect calls stay open even without a direct extension marker, since builtin function values use the runtime extensions. This keeps independent proofs available without evaluating model functions (DD-43). For example, with column inputValues of 1, a wildcard row accepts only 1, while an explicit 2 row still accepts 2. The second row is not dead under FIRST, and the two rows are disjoint under UNIQUE. Treating - as unrestricted would give the wrong proofs.
  4. Feel.Rules.overlaps/3 (an overlap in a UNIQUE table is a warning), dead/3 (a rule a FIRST table never reaches is a warning) and coverage/3 (an input no rule takes, in a table with no default, is an info) answer proved, refuted with an input that shows it or unknown, and the compiler turns definitive verdicts into diagnostics. Certainty uses lower bounds: numeric caps keep intervals without filling gaps, and string caps discard information. The certain-set contract are regressions, including UNIQUE and FIRST tables evaluated at a gap in the first rule. Unsupported questions can remain unknown; universal witness revalidation is still open work.

A table of more than 200 rules or 4,000 cells (rules times inputs) is not asked: the number of rule pairs is the square of the first, and each cell is prepared for each question. Source and column preparation happen inside the table's worker (its deadline and memory policy), after these size limits. Mismatched model and prepared row/column counts fail the check with :table_not_checked, rather than silently truncating it. Tables skipped for size or having no inputs also report :table_not_checked; unsupported questions can remain unknown. Absence of warnings or :table_not_checked does not establish that every question ran or had a definitive answer. check_tables: false turns the questions off (DD-20).

Standalone Feel.Rules calls accept named type definitions through the optional :types map, but do not receive value bindings or extensions. A caller must supply the original named types, each effective test, and an explicit :unknown cell when its other dependencies cannot be preserved. Passing original text that resolves differently outside the model is not a valid way to reuse the DMN checker's proof assumptions.

Step 6: running, and the way back

Dmn.Run runs the units a request needs, in the order of the plan, each once. For a unit it binds the values of the units it reads under their names and calls Feel.run(expression, bindings, options), with the caller's :mode, :clock, :entropy and :limits, and the key of the unit as the :origin. The values of the data are converted and verified where they are read, by the scope's declarations (DD-13), so no unit relies on another's check.

The public run APIs accept :mode, :sandbox, :guard, :clock, :entropy and :limits. Mode is :lenient (the default) or :strict. Containment defaults to on; sandbox: false opts out for trusted input. guard: configures the timeout, heap and input/result transfer allowances documented by Feel.Worker; guard: nil is invalid when containment is enabled. Repeated option keys are invalid, including nested guard keys with identical values. Unknown run keys, repeated keys and invalid modes are rejected before selection or worker startup; invalid guard settings are rejected before a worker starts. These errors return {:error, %Dmn.Error{kind: :run, code: :invalid_options}}, so a tiny worker cap cannot replace an option error with a termination error. The shared security model describes the policy.

Recursive groups keep single-signature callee evidence in their compiled expressions. Installing links during evaluation reuses that preparation; it does not analyze or optimize the model again.

What FEEL reports comes back through the source map. A diagnostic of a run has a span of the unit's source, Source.locate/2 finds the element and the place in it, and the Dmn.Diagnostic says rule 1, output entry 1 of the decision "Discount" and keeps FEEL's diagnostic. :strict mode stops at the first error and returns {:error, %Dmn.Error{kind: :run, code: :strict}} (DMN 1.6, 7.3.8).

A span is a range of one text, and a run can involve more than one: a business knowledge model that a decision calls, and each member of a recursion group, run their own text inside another's, so a span of the member's text, put in the caller's map, landed in the caller at whatever part has that range. So a diagnostic says which text its span is in (D-201): it has an origin (Feel.Diagnostic.origin, a term of the host's, nil for the text of the expression that was run unless the host gave the run an origin). Dmn.Run runs each unit with its key as the origin, and a function written in it, a business knowledge model or a decision service among them, keeps it wherever it is called, as does a function that a function with another origin writes. Feel.Recursion gives the members of a group theirs, the environment carries the origin while the body runs (Env.within/3), and Dmn.Run places a diagnostic by the source of the unit whose key is its origin; so the feel of a Dmn.Diagnostic has the key of its unit and not nil. An origin that is not the key of a unit of the model is not read, and nothing in a text of FEEL can set one.

Dmn.Run.invoke/4, calling a business knowledge model or a service as a function, builds a tree of its own: a call of the closure by name or by position, with the arguments as values bound under U+0001 names, prepared with prepare_ast/3 and a source of "".

Limits. The limits that a model is compiled with (feel_limits:) are kept by the compiled model and are the limits of its runs (Dmn.Compiled.feel_limits, DD-26); :limits given to a run replace them.

Recursion. DMN 1.6 has none (the requirements are acyclic, 6.3), so by default a cycle is :cyclic_requirements, and a business knowledge model or service that invokes itself by name (its own name is not in its scope) is :recursion_not_enabled. With the :recursion feature (Dmn.compile(model, features: [:recursion]), DD-22 and DD-29; recursion: true still works), business knowledge models and decision services that invoke themselves or each other run, and no node was added to FEEL for it. A group is the strongly connected set of those units, or a unit whose prepared logic reads its own name. Compilation establishes their single-signature callee evidence. The shared Dmn.Compiled planner orders the group after its external requirements; sandbox preflight carries that plan into execution with its matching compact model. Trusted calls and guarded fallbacks plan inside the run. Dmn.Run runs the members together and gives each the links to the members it reads, whereas other units are given values. A link (Feel.Recursion.link/1 and run/3, D-188) is a function that makes the member's closure at the first call in a run and keeps it in the environment of the run (Env.links, D-200), so a function value that is returned from a run relies on no shared cache with an independent lifetime. Feel.Limits bounds call count and depth; the sandbox deadline also bounds a complete recursive invocation. A cycle with a decision on it stays an error, since data cannot be derived from itself.

The feature also selects guarded item-definition graphs, independently of whether a knowledge function recurses. Those graphs keep full runtime membership and constraint checks; only their static projection is bounded. See Step 1 and DD-22 for the data-cycle restrictions.

What is not trusted

The XML, its imports, and the FEEL in them are written by someone else (DD-02). At this seam that means:

  • every text is prepared with the interpreter backend (unless the caller who trusts its models asks for backend: :beam, DD-41, which generates modules and atoms that are never freed) and Feel.Limits, which bounds selected construction and runtime work. Dmn.compile/2 includes XML loading, imports and compilation under one Feel.Worker policy; the evaluation and invocation entry points cover complete runs. Containment defaults to on; sandbox: false opts out for trusted input. Deadlines, bounded transfers and process heap policies complement engine limits; they do not bound total VM memory or arbitrary host effects (DD-15). See the shared security model;
  • a tree is trusted to be well formed, and it is dmn that builds it. A model cannot create a node, because every node is made from the model's data by the code that maps it, and the names that the compiler adds are not names a model can write;
  • a model that is wrong (a text that cannot be read, a name that is not in scope, a table with a missing entry) is compiled all the same, with diagnostics, and a fault of the element is an error of every run of it (DD-24). An error that is about an element (an unresolved requirement, two elements of one name, a type that is not defined) is a fault of its unit, so a strict run ends on it, and Dmn.validate/1, or validate: true for Dmn.compile/2, is the gate that refuses a model with any error (DD-24).

Adding a node

A boxed expression that FEEL has no node for (or a node that a future layer would build) goes through the same places as the two above. In apps/feel:

  1. lib/feel/ast.ex: the node in the table, its shape in Feel.AST.t/0, and a section that says what it means. Keep the shape {kind, %{span: …}, parts…}, so that the generic traversals keep working.
  2. lib/feel/analysis/analyzer.ex: a clause of analyze_clause/2. It analyzes the parts with the context they need (for the entries of a table, the member context of the column), merges what the parts found, makes the type, and reports a wrong shape at the span of the node. A new check of shape can live in its own module, as Feel.Analysis.Tables does.
  3. lib/feel/ir.ex: the IR node in the table and in the type, if the analyzer makes a new one. If it can be expressed in the nodes that exist, as :declared is, there is no more to do after this step.
  4. lib/feel/optimizer.ex: a clause that rewrites the parts, and a decision whether the node may be folded.
  5. Both backends: lib/feel/backend/interpreter/walk.ex (value_of/3) and lib/feel/backend/beam/emitter.ex (expression/2). Put the semantics in a module of lib/feel/runtime/ that both call (Feel.Runtime.DecisionTable), and bump the generated-code cache stamp (Feel.Runtime.abi/0) when changing calls emitted against that contract.
  6. Tests: both backends give the same value and diagnostics; Feel.Paranoid over the node (no value outside its type, the optimizer changes nothing); a wrong shape is an :analysis error.
  7. docs/decisions.md: an entry (D-nnn) with the problem, the decision, and what is open.

In apps/dmn, Dmn.Compiler.Expression (or its own module, as Dmn.Compiler.Table) builds the node from the model, with the glue span of its part (Fragment.glue/3), and registers what the checks need to find it again (state.tables).

Open implementation work is tracked in the FEEL backlog and DMN backlog.