# Current DMN design decisions

These records explain the effective choices in the DMN layer. Superseded variants, implementation chronology and old performance figures have been removed; Git history keeps them. FEEL's language/runtime choices are in [its decision record](https://github.com/FeeJai/BDM/blob/main/apps/feel/docs/decisions.md). Open work is tracked in [the backlog](todo.md).

Identifiers remain stable citations, with intentional gaps. Update a record and its references when a choice changes; new decisions use the next unused number above the existing identifiers. A recorded mechanism is not a soundness or security proof. FEEL's [type-system assessment](https://github.com/FeeJai/BDM/blob/main/apps/feel/docs/type-system-analysis.md#2-contracts-and-evidence) states the contracts for optimized checks and static tables and the remaining proof audit. They have DMN integration regressions; they are not a general soundness guarantee.

- [Compilation and declarations](#compilation-and-declarations)
- [XML and imports](#xml-and-imports)
- [Execution and validation](#execution-and-validation)
- [Compatibility evidence](#compatibility-evidence)

## Compilation and declarations

### DD-01 Compile a model into prepared units and dependency metadata

Each DRG element becomes a unit with prepared FEEL logic, scope, source mapping and diagnostics. The shared planner orders required units per request instead of reparsing expression text. Sandbox preflight and execution reuse the same successful plan with its compact immutable model; no per-target plan cache is stored on the artifact. Business knowledge models evaluate to closures; decision services expose the selected decisions through a function. This keeps a single FEEL implementation for names, types, conversions and execution. Compilation has a cost before the first evaluation, and does not imply a fixed speed advantage on every workload.

### DD-25 Boxed expressions use AST nodes with declaration boundaries

The compiler builds FEEL trees directly and maps their virtual-source spans back to model elements. Decision tables keep their own node (FEEL D-175); declarations inside a context, function or service use `:declared` boundaries (FEEL D-190). Input/output columns and expression values receive all applicable inherited and component constraints, not only one allowed-value list. This avoids text rewriting and preserves runtime policy-specific diagnostics. A boxed conditional, filter, `some` or `every` is marked strict about its condition (DD-38).

### DD-33 Business knowledge callers use the output of a function item

A business knowledge model's variable can name a function item whose `outputTypeRef` describes the result. `Types.knowledge_type/4` gives callers an arrow from the model's formal parameter types to that result type, resolving aliases and imported type names. Thus a numeric `K(10)` can participate in `K(10) + 1`; the caller no longer treats the result as the function item itself.

Only one function layer is unwrapped. A model returning a function declares a function item whose output is that function type. Parameters written in the function item do not replace the model's formal parameters. Older models whose variable names the result type directly keep that meaning. `Types.output_declared/3` separately keeps the body's result constraints, including those reached through aliases; a caller's arrow is not a replacement for those checks.

Decision services use `Types.service_type/5`. One output has its decision variable's type; several outputs form a context with the canonical decision names and each declared output type (zero outputs form an empty context). A function-valued output decision keeps its function layer. The service's own variable describes the function or, for older models, its result directly: a readable declaration other than `Any` supplies the caller's final result type, after the same conversion the service body enforces. A function item unwraps only that outer service layer, resolving aliases and import frames. Its formal parameter declarations do not replace the service inputs. Output and service constraints remain runtime boundaries; greater caller precision does not justify removing those checks.

### DD-34 A decision service reads what its decisions require and reports their texts once

A decision service is one function expression with its decisions inside it (DD-01), but its decisions keep their own units. The service carries the faults of its encapsulated and output decisions: an unresolved or mistyped requirement, a duplicate id or name, an unresolved import, a requirement cycle, and errors in their texts. A strict call therefore ends where a strict evaluation of the decision would. The service also carries the faults of its input data and input decisions, even though their values are supplied by the caller. Repeated references contribute each inherited fault once. Both direct invocation and invocation from a decision stop on these faults in strict mode; lenient runs report them and continue as before.

The compiler reports the text of a decision on the decision. The service omits diagnostics at `[:decision, id, :expression | …]` and `[:decision, id, :variable | …]` and does not check those tables again. Input declaration errors belong to the input unit, rather than being reported again for the service parameter; its original fault is inherited. Run diagnostics still name the service and the path through the decision, because the service ran that text.

Inside the service each decision is read with the scope of its own requirements. The inputs and decisions of the service that it does not require are bound to `null` in a context around its tree (`hide/5`), since FEEL cannot unbind a name that the enclosing function binds and the input names must remain the parameter names for calls by name. This gives the same value as `Dmn.evaluate` (`null`, with the decision's own `:unknown_name` warning). A requirement on an input or decision that the service neither takes as an input nor contains is a `:service_requirement` error of the service. Knowledge requirements are exempt, because the service reads the knowledge its decisions require.

Rejected: calling each decision from the context as a function of its requirements. It would give the scope exactly, but costs a call (count, depth, `Feel.Limits`) for every decision of every call and gives the analysis a function type to see through where it now has the value.

### DD-37 External functions are run by an extension that names the kind

A `functionDefinition` of a kind other than FEEL (`Java`, `PMML`, `ONNX`) is `null` with `:unsupported_function_kind`, because DMN 1.6 does not say how it runs. An extension can run a kind with two optional `Dmn.Extension` callbacks: `external_functions/0` maps the `functionDefinition` kind to the FEEL kind, and `feel_extensions/0` names the FEEL extensions the model's expressions are prepared with. A boxed function of such a kind compiles to the FEEL external function (FEEL D-211), its body as the description and its parameters as the argument list; a literal `function(...) external {java: ...}` takes the same path. `dmn` names no kind: `DmnJava.Extension` (`apps/dmn_java`) runs Java through `feel_java`.

`Dmn.Compiled.feel_extensions` keeps the FEEL extensions, because a function resolves what it calls in the extensions of the expression that calls it, and `Dmn.invoke/4` prepares the call of a knowledge model itself. The text of a literal expression is parsed without them, so a function name of several words that an extension adds is not read as one name there (backlog).

Compatibility evidence: `Dmn.TCK.run(java: true)` compiles with `DmnJava.Extension` and starts a sidecar with the seven `java.lang` classes of 0076. All 14 of its known failures pass; they are `deviations/java.txt`, which is checked by a test that is skipped when no `java` is on the path. `literal_001` needed the comparator of the DMN runner (`doubles: true`) to accept a result truncated (not only rounded) to the digits of the expected number: the TCK has `Math.cos(123)` truncated to 8 decimals (`-0.88796890`; rounded, it is `-0.88796891`). The DMN runner's `doubles: true` comparator also admits the documented relative tolerance for longer double-computed expectations. The FEEL runner uses its narrower exact-or-rounded comparison. These are test-runner policies, not changes to engine arithmetic; see the [TCK report](https://github.com/FeeJai/BDM/blob/main/apps/bdm_tck/test/fixtures/tck/README.md#compatibility-snapshot). The spec does not say how a Java double becomes a number (todo.md). The default baseline is unchanged.

### DD-38 Each part of a unit knows its box, and boxed conditions are strict

Every part of the virtual source records the boxed expression it came from (`Dmn.Compiler.Box`: kind, `id`, label, line, declared type, and the part that must be a boolean). `Source.box/2` and `Source.find/2` give it for any span, so every node of a unit, parsed from a text or built from a box, can be traced to its box, and a `Dmn.Diagnostic` carries it as `box`. A part in no box, such as the wrapper of a decision service, has `nil`. The box is kept in the source map, not in the nodes' `meta`, because the analyzer builds new `meta` for IR nodes.

The compiler marks the nodes of a boxed conditional, filter, `some` and `every` as `boxed: true`, which makes a condition that is neither a boolean nor `null` give `null` with an error (FEEL D-212). When FEEL reports that this is certain (`details.always`), the compiler records it as a compile `:error` with the box, so `Dmn.Compiled.errors/1` lists it and validation refuses the model. The run reports the error from the node as well. It is not a unit fault reported by every run, because the node reports it only when it runs. A condition that can still be a boolean or `null` is not flagged.

### DD-12 A context entry can read an outside value of its own name

DMN boxed contexts can contain a key whose value refers to an outer binding with the same name. The compiler binds that outer value under an internal key so FEEL's context dependency analysis does not turn it into a self-cycle. This transformation is structural, and the source mapping is preserved.

### DD-13 Input conversion happens in each unit that reads it

Units use FEEL's enforcing declaration policy. An input is converted and checked where used rather than eagerly converted once for the whole model. This preserves the applicable declaration context and lazy decision dependencies, at the cost of repeated checks. Shared conversion would need a separate contract; it is not merely a missing optimization. The reader's type can differ from the input's type in an importing or invalid model, so trusting a check against another type could invalidate narrowing and check removal. Rechecking can dominate large collections with many readers. A future cache must include the declared type and value identity, and preserve each unit's diagnostic policy.

### DD-18 Model type environments retain import qualification

A model sees its own and imported definitions under their proper import names. Additional internal qualified names let referenced definitions resolve across the loaded graph without exposing collisions as user-writable names. Type environments are prepared for model contexts; their duplication across a large import graph remains a memory concern, not a bounded-cost guarantee.

## XML and imports

### DD-02 XML is untrusted input with explicit parsing boundaries

Use `:xmerl_sax_parser`, string names and explicit XML limits. Reject declarations that introduce an internal subset or external identifier before they can be read, and reject numeric character references longer than 8 hexadecimal or 10 decimal digits before invoking the parser. Only supported ASCII-compatible encodings pass that screen. Track namespace declarations on elements so the reader resolves names in the element's scope. The parser runs with a deadline because limits on bytes and depth do not bound every parser cost. These are specific defenses, not a claim that every VM failure mode is contained.

### DD-35 A namespace URI has a length limit

`Dmn.Limits.max_uri_length` (1,024 bytes by default) bounds the URI of a namespace declaration, whether default or prefixed; a longer one is a `:limit` error with the code `:uri_too_long`. The reader builds the namespace of each element from the parser's character list, so a document holds a copy of the URI for every element in it. The limit on the size of the document bounds the declaration, not those copies. At the defaults the worst case is `max_elements` times the URI limit, about 200 MB, which is under the default `max_memory`; a caller that reads untrusted models can lower either.

### DD-23 Imports have caller-controlled resolution and document identity

A model's `locationURI` is data, not authority to open a file or URL. A caller-supplied resolver returns bytes, optionally with the retrieved document's URI. `Dmn.load/2` accepts a `base_uri` for the root; imported models keep the URI their resolver returns. Resolvers receive the importing model, including that base. Imports are read breadth-first with graph-level budgets for reads, bytes and time.

One namespace identifies one document by the SHA-256 hash of its bytes. A location requested by a second importer is resolved and compared even if the location text or returned URI matches an earlier request. Requests by the same importer for the same location are reused; a location-free import accepts the namespace's existing model. Different bytes are an error and that import is not used. The root's bytes participate in the comparison. `compiled.imports` records location, URI and hash.

The directory resolver resolves relative references against the importer's base with dot-segment normalization and confines paths beneath its root. Links, root escapes, schemes, absolute paths, backslashes, directory-only paths and unsupported bases are rejected. Percent escapes are not decoded. Internal element keys combine namespace, U+0001 and element ID; imported values and types use aliases.


## Execution and validation

### DD-24 Compilation retains invalid units; validation is the explicit gate

Compilation normally returns a model with diagnostics, including structural errors. A run reports errors of units it needs and can continue in lenient mode; an unused invalid unit does not necessarily affect the requested result. `Dmn.validate/1`, or `validate: true` during compilation, rejects a model with any error. A boxed condition that can never be a boolean is such an error (DD-38). Duplicate/ambiguous names do not silently choose one definition. Unknown expression names remain warnings under the current policy, which means validation does not establish that every runtime binding is present.

### DD-20 Static table checks have a deadline and can return uncertainty

After preparation, the compiler finds decision-table nodes and asks `Feel.Rules` about overlap, coverage and unreachable rules. Findings map through virtual-source spans to model locations. Tables above 200 rules or 4,000 input cells, and tables with no inputs, are skipped without a `:table_not_checked` diagnostic. A spent deadline, memory failure, raised check or mismatched model/prepared dimensions produces that informational diagnostic. Unsupported proof questions may also remain unknown without a diagnostic. Silence is not proof that checking completed or that a table is covered, disjoint or fully reachable. `check_tables: false` disables the questions. FEEL uses possible-input upper bounds and certain-input lower bounds. Certain numeric bounds keep at most 16 intervals without filling gaps; certain string bounds drop information when the point budget is exhausted. The 17-even-point false overlap and dead-rule examples are DMN regressions, including a check that compilation completed rather than silently skipping table analysis. This repairs the demonstrated cap failure; reported witnesses are not yet universally validated against the original entries, and the broader proof audit remains open.

### DD-43 Table proofs follow prepared wildcard identity and preserve scope uncertainty

A wildcard uses its column's `inputValues` at runtime; an explicit entry can match outside those values. The checker uses the prepared IR to recognize wildcards, including empty or commented text, and ignored `inputValues`, then asks about the effective test for each cell. Applying column values to explicit entries, or treating a restricted wildcard as unrestricted, can invent dead rules and overlaps or hide real coverage gaps.

Source alone does not keep model or lexical bindings: `abs(1)` can call a BKM or local function rather than the built-in. The effective prepared test is inspected before reparsing. A cell reading a variable other than `?`, a local or dynamic name, or a call an extension may answer is passed to `Feel.Rules` as `:unknown`: its possible inputs are the whole column and its certain inputs are empty. Model functions are not evaluated during proofs. Independent cells and rules still support definitive diagnostics; ordinary built-in tests remain checkable. This deliberately sacrifices precision for scope-dependent cells until proofs can consume their resolved bindings directly.

Named types are preserved rather than inferred from the resolved IR. Resolving `instance of N` replaces `N` with its descriptor, and folding can erase the predicate entirely. The checker passes the unit's original type definitions to all three `Feel.Rules` queries through `types: map`, for both explicit entries and wildcard `inputValues`. This also preserves aliases, imported types and longest-name parsing: a type named `number and false` must not become a boolean conjunction. Proof preparation constructs a scope containing only these types, inside the existing worker; value bindings and extension calls keep the conservative treatment above. Regressions compare coverage, FIRST deadness and UNIQUE overlap with execution, and keep real overlap/deadness proofs.

Filter-field reads (`:item_field`) also depend on their enclosing scope: inside a filter over `[{abs: 2}]`, `abs` is a field rather than the builtin. Such cells remain `:unknown` instead of being reparsed with builtin semantics. This includes fields found in outer filter frames. Nested-filter regressions cover explicit entries and wildcard `inputValues`, false coverage, deadness and overlap diagnostics, and independent proofs from other cells and rules.

Calls through bindings keep `meta.from_binding` on the resolved builtin, even when analysis replaces an alias with a direct built-in call. For example, a context's `abs: floor` makes `abs(-1)` mean `floor(-1)`; reparsing `abs(-1)` alone would mean something else. These calls stay open. Indirect calls also stay open: built-in function values reached through lists, contexts or conditionals use the runtime extensions, but have no direct-call `extension: true` marker. Source preparation without those extensions cannot establish the same test. Regressions cover direct extension overloads and dynamic filter-name lookup as well as aliases and indirect calls.

Effective-source and column preparation run inside the table's worker, after the size checks in DD-20. Model/prepared rule and column counts must agree; mismatches fail the check rather than silently truncating zipped rows or cells. Universal witness revalidation and a scope-preserving proof interface remain open.

Rule-source and column preparation run inside the table's worker, after size limits are checked. Model and prepared row/column counts must agree before zipping them; a mismatch or preparation failure yields `:table_not_checked`, rather than checking a truncated table or escaping the guard.

### DD-15 Operations use shared BEAM containment by default

`Dmn.compile/2` covers XML loading, imports and compilation under one shared BEAM worker policy; evaluation, invocation and `Dmn.run/2` of a prepared call also cover complete runs. Containment is on by default. `guard:` configures it and `sandbox: false` skips the outer boundary for trusted calls. Engine call, iteration, depth and per-value limits and import/XML budgets still apply. This is not a complete bound on all VM memory or host effects. See the shared [security model](https://github.com/FeeJai/BDM/blob/main/apps/feel/docs/architecture.md#security-model). The XML/import policies above still apply; `Dmn.Limits` and `Feel.Worker` document enforcement.

FEEL refuses the BEAM backend inside its worker, because the modules and atoms it creates outlive the worker. `Dmn.compile(model, backend: :beam)` (DD-41) is accepted in the default worker: the DMN compiler prepares its expressions itself, inside its own worker, and the modules and atoms stay in the VM after the worker is gone. Containment of a compile bounds its time and memory, not what it leaves behind, so `:beam` is for models the caller trusts, and the caller must observe that constraint. Running a model compiled that way is contained like any other.

Containment costs time that grows with what is copied into the worker: the dependency view of the decision, its inputs and the source maps that travel with it (DD-44). A small model costs tens of microseconds per call on top of the call itself; the measurements are in the [DMN README](../README.md#security-model).

### DD-44 A compiled unit says once whether its expression can bring in an unseen origin

A worker is given a compact view of the model: the units the decision needs, and, only when a run could report a diagnostic whose origin is another unit, the source maps of the others. A function value made in another unit carries that unit's origin, and so can a diagnostic produced by trusted host code (an extension that returns a function, a calendar module, a function the caller supplies). That is a question about the selected expressions and the data, answered by a bounded inspection before the worker starts. Two things made the answer cost more than it had to:

- Any FEEL extension in the model, and any BEAM executable, took the conservative path, so a model of 1,087 decisions with the Java extension enabled sent 414 KB of source maps with each call (about 1.2 ms, against 39 µs for the 2.9 KB it needs). An extension that adds data only says so (the `data_only?` callback of `Feel.Extension`, FEEL D-222) and no longer forces it. An extension that does not say so, or says `false`, keeps it. A BEAM executable is inspected like the interpreter's: generated code calls only `Feel.Runtime`, the built-in functions and the extensions it was made with, and holds no function value of its own, so it is opaque exactly when its extensions are.
- The inspection walked the expression of every selected unit in every call (about 590 terms for a typical TCK decision). What an expression contains does not depend on the call, so the compiler asks once and keeps the answer in `Dmn.Compiler.Unit.callback_free`: `true` skips the walk, `false` ends the inspection, `nil` means the unit was not looked at and is walked as before. The flag is an optimization of an inspection that is still there, and whoever changes `expression` on a compiled unit must reset it to `nil`; a stale `true` would hide the source of a function that came from elsewhere in the diagnostics of a worker run (values are not affected).

The declaration is asked once, at compilation, where the extensions run inside the compile's worker, and recorded (`Dmn.Compiled.feel_data_only`; the prepared set of a unit records it too). The preflight runs in the caller before a worker exists, so it reads the records and never calls an extension. The compiler walks only what a worker is given of an expression (`executable`, `linked_peers`, `extensions`), not its syntax tree, IR or scope, which a model with thousands of item definitions carries in every unit.

The preflight reads the clock when it starts and then every 64 steps, instead of at every step (about 600 reads per call). A step is constant work and runs no host code, so expiry is seen within microseconds; the transfer walk already checks every 256 steps.

Not done: keeping the compiled model where the worker can read it without copying (`:persistent_term`, ETS) would remove the copy, and a reusable worker would remove the spawn. Neither has been measured. Persistent terms make an update a global collection, and a shared worker changes the isolation of calls from each other, so both would be design changes to FEEL D-221.

### DD-36 The entry points return error values for what a caller gives them

`evaluate/4`, `evaluate_all/4`, `invoke/4` and `compile/2` take what a caller got from outside (a JSON body decoded to `[]`, a query string for `guard`), so a bad argument gives an error value rather than an exception. A name that is not a string, or consists only of what FEEL calls white space, is `:unknown_decision`; inputs that are not a map of names, and `names` that are not a proper list, are `:invalid_inputs`; options that are not a keyword list, and a `guard:` that is not a valid `Feel.Worker` policy, are `:invalid_options`, with the kind of the phase (`:run`, `:compile`). The compiled model or the model is the caller's own value, and a function clause stays its error.

`Feel.Worker.run/2` checks its policy before starting work. Unknown or repeated keys, a deadline outside the timer range, a memory cap below the smallest heap, and invalid transfer allowances return `{:error, {:invalid_options, message}}`. Run wrappers validate unknown or repeated top-level keys and invalid modes before starting selection or a worker. This preserves `:invalid_options` even with a tiny worker allowance. An unknown key is refused because a typo of `timeout` must not silently keep the default and lose the bound the caller meant. `Dmn.XML` and the table checks call it with `Dmn.Limits`, so a limit the VM cannot honor is an error of the read, or an unchecked table, rather than an exception in the caller.

The private `call/5` of `Dmn.Run` keeps the code of the error of FEEL, `:strict` for the strict mode only, so a business knowledge model or a decision service that passes `Feel.Limits` is `:limit_exceeded`, as the same decision is under `evaluate/4`. `fuzz_test.exs` replaces each argument of the four functions by 25 terms and holds them to "nothing raises".

### DD-26 Compiled FEEL limits are the defaults of its runs

The `feel_limits:` supplied at compilation are kept in the compiled model. Evaluation uses them unless the caller supplies a replacement `limits:`. These limits apply to FEEL units; they are not a single aggregate budget for the entire model or all invocations in an evaluation.

<a id="dd-22-recursion-is-caller-selected-and-limited-to-knowledge-functions"></a>

### DD-22 Recursion is caller-selected for knowledge functions and guarded data

`features: [:recursion]` permits recursive business knowledge models and decision services. The compiler forms strongly connected knowledge groups and uses FEEL links to construct calls. A cycle involving a decision remains an error; a model cannot enable recursion itself. Linked calls currently use the interpreter and its runtime limits. Member closures are made once per run (FEEL D-200), and body diagnostics carry their unit's origin (FEEL D-201).

The same explicit option enables guarded recursive item definitions (`recursive: :guarded` in FEEL). A finite declaration graph keeps back references. Each cycle must consume a context member or a list element, and cannot cross a function signature. Runtime membership, declared binding/parameter and result checks visit the full finite value, including descendants never read by the expression. Component constraints and inherited constraints use graph paths and apply at every occurrence, with concrete failing paths. Existing boundary conversions and null exemptions remain in effect; this does not add conversions inside context fields. Ordinary function items may refer to recursive data.

Cycle validation uses graph components and an unguarded-edge subgraph rather than enumerating paths. Analysis projects the graph to a finite structural upper approximation, cutting repeated names and widening when a shared 256-node projection budget is exhausted. Runtime membership has no such cutoff. That approximation is not evidence that a recursive target is satisfied: recursive result checks stay, `instance of` cannot be proved true from the projection, else branches do not subtract it, and function domains use a conservative lower bound in contravariant positions. Type relations terminate and may refuse to prove conformance between differently written recursive declarations. Default cycle rejection is unchanged. Alias-only cycles and cycles through function signatures are explicit compile errors; complete recursive subtyping and inference remain outside this implementation.

An imported callable keeps its defining model's recursive result descriptor. The caller's import frame may rename its references and include unrelated definitions; FEEL compares the reachable graphs under a consistent one-to-one renaming. Constructors, constraints and sharing must agree, so valid imported BKM and service calls keep their contracts without treating a structural projection as equivalent to a recursive declaration.

Recursive parameter and result bounds describe values after boundary conversion. Function specialization discards operand shapes and origins that conversion can invalidate. Enforced recursive BKM results keep their exact declaration in runtime function metadata, so a decision can bind and call a BKM with the declared function item. Recursive collection heads, including aliases, receive ordinary singleton wrapping and element allowed-value checks.

A service also enforces its derived recursive result when its own variable does not establish that boundary. The generated function thereby keeps the same exact runtime result signature exposed to callers, for one output and for recursive members of a multi-output context. Individual output constraints remain with their original decisions; the derived boundary adds no duplicate constraints.

### DD-29 Caller-selected additions share the `features:` option

`Dmn.Features.resolve/1` accepts engine features such as `:recursion` and registered extension names or modules. No model can enable them. Invalid entries fail compilation as `:invalid_options`, naming the offending option. Direct compiler use normalizes the same configuration and raises on invalid options.

The legacy `recursion:` and `extensions:` spellings remain additive aliases: any spelling that enables a feature enables it; `recursion: false` does not override `features: [:recursion]`. Defaults enable no features. An extension may name FEEL extensions to prepare the model's expressions with (DD-37); features are not otherwise forwarded, and runtime evaluation uses the compiled units/groups without needing the original feature list.

### DD-39 The configuration of `load/2` and `compile/2` is checked where it is given

DD-36 left what a caller configures to raise: `Dmn.load(xml, %{})` was a function clause error, `limits: [bogus: 1]` a `KeyError` from `struct!/2` and `limits: [max_depth: -1]` an `ArgumentError` (in `load/2`, `Dmn.XML.parse/2` and `compile/2`), and `feel_limits: :x` was accepted and compiled with a deprecation warning. Configuration often comes from a file or the environment, so the same contract holds: `Dmn.load/2` with options that are not a keyword list, `limits:` that are neither `nil`, a `Dmn.Limits` nor a keyword list of its fields (with values that are positive integers), and `feel_limits:` of `compile/2` that is not a `Feel.Limits` are `{:error, %Dmn.Error{code: :invalid_options}}` with the option in `details.option`; nothing is read or compiled for them. The kind is `:model` for `load/2` and `Dmn.XML.parse/2`, which share the check, and `:compile` for `compile/2`. `Dmn.Limits.validate/1` is the check and gives the reason; `Dmn.Limits.new/1` is built on it and raises `ArgumentError` for an unknown field too, as its documentation says (it was `KeyError`). A `Feel.Limits` is checked field by field at the public DMN boundary, including a struct made by hand: every limit must be a non-negative integer or `:infinity`. `Dmn.Compiler.compile/2` called directly still raises for options that are none of these (DD-29).

### DD-41 The caller may choose the BEAM backend, and the interpreter stays the default

`Dmn.compile/2` prepared every expression for the interpreter backend of FEEL and said so in a comment: the BEAM backend loads a module for each expression and creates atoms that are never freed, which an untrusted model must not be able to do. The choice was made in the engine and the caller could not make it, and `backend: :beam` was accepted and ignored. A caller that trusts its models can want the faster backend, and a comparison of the engine with others (`apps/tck_eval`) has to be able to say which one it ran. So `compile/2` takes `backend: :interpreter | :beam`, `:interpreter` by default; as with the limits and the features, the choice is the caller's and nothing in a model can make it. A backend that is neither returns `{:error, %Dmn.Error{kind: :compile, code: :invalid_options}}` with `details.option: :backend`, before anything is compiled (DD-39). `Dmn.Compiler.compile/2` called directly reads the same option. Both backends give the same results (`Feel.Backend`); the constraint that `:beam` is for trusted models is the caller's to observe, and the option's documentation states it. With `recursion: true` (DD-22) a business knowledge model that has logic and a decision service can invoke itself, and they stay on the interpreter whatever is asked: the links of a recursion group are made of the IR that the interpreter walks (`Feel.Recursion`), and whether a unit reads itself is known only when its logic is prepared, so they are prepared for the interpreter from the start, and no budget of generated code (`max_generated_statements`) can end them. The rest of the model keeps the backend that was asked for.

### DD-42 Repeated decision calls may retain their target, plan and options

`Dmn.prepare_call/3` resolves a decision name, builds its dependency plan and fixes the options of `evaluate/4`, once. `Dmn.run/2` executes that `Dmn.PreparedCall` with fresh input validation and a fresh run state, through the same executor as `evaluate/4`. Values, diagnostics, recursion links and clocks are not cached. Containment applies to each execution as to `evaluate/4`: `sandbox:` and `guard:` are checked when the call is prepared. Inside a worker (the default) the request is selected and admitted as `evaluate/4` selects it, so the worker is given the part of the model the decision needs and the prepared plan is not copied; the plan is used when the run is not in a worker (`sandbox: false`, or already inside one). The value keeps its compiled model and refers to its existing prepared expressions; it generates no code and owns no module lifecycle. Changing a model, decision or options requires a new call. This prepares element evaluation, not the invocation of a BKM or decision service as a function. The direct benchmark in `apps/tck_eval` compares the ordinary and prepared APIs without transport and input/result conversion in the execution timer.

### DD-45 Admission and execution enforce their boundary contracts

Public load, compile and run options reject unknown and repeated keys before model work or callbacks. Both compile overloads validate `limits` and `feel_limits` before XML parsing or worker transfer admission. Nested input namespace keys must contain two names, and inputs whose canonical names collide are rejected before a winner can depend on map representation. Confirmed unresolved global names fail `validate/1` and `validate: true`, including unreachable branches; strict execution rejects them in selected units. Lenient execution keeps warning-and-null semantics. Dynamic filter fields remain local lookups, and are not unresolved global names.

Each evaluation, prepared-call execution and invocation snapshots the clock once. A prepared call keeps its configuration, not a clock instant. Input and target validation precedes that snapshot, so rejected requests cannot invoke the clock callback or have their errors masked by it. FEEL counters share one budget across the entire execution, including the final invocation, while `max_units` caps the imported graph before preparation. This is not a complete preparation-time budget. Large tables and tables with no inputs that the static checker cannot inspect emit `:table_not_checked` information rather than silently implying coverage.

Default shared worker containment includes referenced binaries at collections and bounds input and result transfers within the operation's deadline. Its heap policy can overshoot between collections; it does not reserve OS memory or contain independent processes, code, ETS or ports. The remaining resource audit and type/proof assurance work remains in the backlogs; these regressions do not establish formal soundness.

## Compatibility evidence

### DD-27 Values and error flags have separate baselines

The TCK runner compiles whole models and compares result nodes. Value baselines and individually accepted error-flag mismatches are distinct, so a formerly exact node cannot silently lose its flag agreement. Long decimal expectations from double-based references use the runner's documented relative-tolerance rule rather than demanding decimal128 equality with an imprecise reference.

`--update` rewrites the value baseline and accepted flag-mismatch list; both require review. Known failures and intentional option deviations remain explicit. The [TCK report](https://github.com/FeeJai/BDM/blob/main/apps/bdm_tck/test/fixtures/tck/README.md) is the single dated snapshot of counts, corpus revision and runner options. Passing this corpus is compatibility evidence, not a soundness proof or an exhaustive test of DMN 1.6.

### DD-28 Legacy temporal type names require an extension

`features: [:dmn_1_1_types]` adds `dateTime`, `dayTimeDuration` and `yearMonthDuration` as aliases without overriding types the model defines. The library enables no extension by default. The default DMN TCK runner explicitly enables these aliases; its configuration must therefore be stated with its results. Automatic selection from a model namespace is not implemented.
