# Dmn

A **DMN 1.6 engine** for Elixir that **compiles** a model. It reads the XML (the six namespaces of 1.1 to 1.6), finds the models it imports, and compiles every element of the decision requirements graph to a prepared, typed FEEL expression (`:feel`, the package next to this one), once. Evaluating a model means running those expressions in the order given by the plan.

> **Status: 0.1.0, active development.** The [TCK report](https://github.com/FeeJai/BDM/blob/main/apps/bdm_tck/test/fixtures/tck/README.md#compatibility-snapshot)
> records compatibility results, runner options and accepted deviations. Remaining engine gaps are in
> [the backlog](docs/todo.md). 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 result-check and static-rule contracts, their DMN integration regressions, and the
> remaining proof-audit work; it does not claim general analyzer soundness.

## Installation

Add `dmn` to your dependencies; it brings `feel` with it:

```elixir
def deps do
  [
    {:dmn, "~> 0.1.0"}
  ]
end
```

## Quick start

```elixir
xml = File.read!("pricing.dmn")

{:ok, model} = Dmn.load(xml)                       # read XML with limits; nothing resolved yet
{:ok, compiled} = Dmn.compile(model)                # every text parsed, typed and prepared, once

{:ok, price, diagnostics} =
  Dmn.evaluate(compiled, "Final Price", %{"List Price" => Decimal.new("125.00"), "Customer Tier" => "gold"})
```

Values are FEEL values (numbers are `Decimal`s, contexts are maps). Problems with a model are in `compiled.diagnostics`; problems from a run are in `diagnostics`, and each diagnostic gives the element, the place within it (`[:rule, 3, :input_entry, 2]`) and the position in its text. `mode: :strict` ends a run at the first error.

Call a business knowledge model or a decision service as a function with `Dmn.invoke/4`; evaluate several decisions at once with `Dmn.evaluate_all/4`.

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.

A business knowledge model whose variable names a `functionItem` exposes that item's `outputTypeRef` as its result to calling decisions, including across aliases and imports. Its formal parameters determine the inputs. Without its own result declaration, a service with one output returns that decision's value; several outputs return a context keyed by canonical decision name, and zero outputs return an empty context. A missing or `Any` result declaration keeps these derived result types. A readable result declaration other than `Any` controls the final conversion. A function item on the service's own variable contributes its output type; a function-valued output decision keeps its function layer. Recursive results keep exact runtime signatures for calls from decisions. See [the callable-type decision](docs/decisions.md#dd-33-business-knowledge-callers-use-the-output-of-a-function-item).

A service inherits faults from its inputs as well as its encapsulated and output decisions. Supplying an input value does not bypass an invalid input declaration. Strict invocation stops on those faults; lenient invocation reports them. Compile diagnostics keep the original declaration as their owner.

Successful compilation does not establish model validity: semantic errors are retained in `compiled.diagnostics`, and a run continues past them (lenient) or stops at the first one its decision needs (strict). Invalid options, loading failures and resource limits can instead return `{:error, %Dmn.Error{}}`. `Dmn.validate(compiled)` is the gate that refuses a model with any error, whatever is asked of it, and `Dmn.compile(model, validate: true)` does both. The limits you compile with (`feel_limits:`) are also the limits of its runs, unless a run is given its own `limits:`.

Additions to DMN 1.6 are **extensions**, and there are none by default (`Dmn.Extension`, DD-28). A model written for DMN 1.1 names the date and time types `dateTime`, `dayTimeDuration` and `yearMonthDuration`; `Dmn.compile(model, features: [:dmn_1_1_types])` (or `extensions: [:dmn_1_1_types]`) reads them as the types of DMN 1.6 (unless the model defines a type of that name), and without it they are undefined types. The DMN TCK runs with it enabled.

The prepared expressions run on FEEL's **interpreter** backend, which generates no code and loads nothing into the VM, so an untrusted model cannot create modules or atoms. A caller that trusts its models can ask for the faster BEAM backend with `Dmn.compile(model, backend: :beam)` (DD-41): it compiles each expression to a module, which is never unloaded, and creates atoms that are never freed. Both give the same results (with `recursion: true` the business knowledge models and decision services stay on the interpreter); a `backend:` that is neither returns `{:error, %Dmn.Error{code: :invalid_options}}`. Unlike FEEL's own APIs, which refuse the BEAM backend inside a worker, `Dmn.compile/2` accepts it in its default worker, and the modules and atoms it loads stay loaded when the worker ends: the choice, and its constraint, are the caller's.

## What it does that a walking evaluator does not

- **Typing is FEEL's.** The item definitions become types, allowed values and type constraints; a value that does not fit is converted or is `null` with an error; the analysis narrows according to what the allowed values say, warns about a branch that cannot run, and finds the type of a decision before anything runs.
- **A decision table is a node of FEEL's tree**, typed by its outputs and run by FEEL on both backends. The questions about it that the types can answer are asked at compile time: rules of a `UNIQUE` table that share an input, a rule of a `FIRST` table that is never reached, inputs that no rule takes. Certainty sets retain subsets at numeric/string representation caps, so exhausting a budget cannot establish the former false coverage, overlap or dead-rule proofs. Wildcards use their effective `inputValues`; explicit entries can match outside those values. Tests that depend on model/local bindings, aliases, dynamic lookup, extensions or indirect calls remain unknown, while independent rules remain checkable. Tables skipped because of their size or missing inputs report `:table_not_checked`. Individual questions may remain unknown, so silence is not a coverage or disjointness proof. Reported witnesses are not yet universally rechecked against the original entries; see the [table-checking contract](docs/feel-integration.md#step-5-questions-about-a-table).
- **Recursion is off, and yours to turn on.** DMN 1.6 has no recursion (a cycle of requirements is an error, 6.3). `Dmn.compile(model, features: [:recursion])` (or `recursion: true`) runs business knowledge models and decision services that invoke themselves or each other (a `fibonacci`, a walk over a tree), and guarded recursive item definitions. Each data cycle must cross a context member or list element; alias-only cycles and cycles through function signatures remain errors. Runtime checks validate every finite descendant and its constraints. Static analysis uses conservative upper bounds, so full data validation does not imply general recursive subtyping or precise inference. A requirements cycle with a decision on it remains an error, and runtime function recursion is subject to the configured limits. No model can switch the feature on ([DD-22](docs/decisions.md#dd-22-recursion-is-caller-selected-and-limited-to-knowledge-functions)).
- **Imports** are found by a resolver you give (`Dmn.Resolver.map/1`, `Dmn.Resolver.directory/2`), and the elements of an imported model are `Import.Name`, with the types `Import.tName`. A relative `locationURI` is resolved against the document that has it (RFC 3986), so the resolver is given the importer, and `Dmn.Resolver.directory/2` resolves that way. A namespace is one document: an import that names it at a location whose document is a different one is an error and is not used, no matter who names it or what the text says, and `compiled.imports` records which document (its location and the hash of its bytes) each model is.

## Repeated evaluation

For repeated evaluation of one decision, prepare its target and dependency plan once. Both backends use the same API:

```elixir
{:ok, call} = Dmn.prepare_call(compiled, "Final Price", mode: :strict)
{:ok, value, diagnostics} =
  Dmn.run(call, %{"List Price" => Decimal.new("125.00"), "Customer Tier" => "gold"})
```

`prepare_call/3` fixes the options of `evaluate/4`, including `guard:` and `sandbox:` if given. `run/2` validates and normalizes fresh inputs and computes fresh results and diagnostics each time. It preserves declared types, limits and the clock of each run; it does not cache results. The call keeps the compiled model, and any BEAM modules must stay loaded while it is used. For several output decisions that share dependencies, `evaluate_all/4` runs each dependency once in that evaluation. Preparing a call selects an element; BKM and decision-service function invocation still uses `invoke/4`. Inside a worker (the default) `run/2` selects and admits the request as `evaluate/4` does, so the worker gets the part of the model the decision needs; the prepared plan itself is only used with `sandbox: false`.

## Security model

Ordinary `Dmn` APIs sandbox work by default, sharing `Feel.Worker` with FEEL. Compiling XML bytes covers parsing, model conversion, import resolution and compilation under one deadline. A loaded `Dmn.Model` is also accepted. Results are ordinary `Dmn.Compiled` models that can be reused; each evaluation needs its own protection.

```elixir
# Give a known large compilation more headroom; evaluation keeps the defaults.
{:ok, compiled} = Dmn.compile(xml, guard: [timeout: 5_000, max_memory: 268_435_456])
Dmn.evaluate(compiled, "Decision", inputs)
Dmn.evaluate_all(compiled, ["Decision"], inputs)
Dmn.invoke(compiled, "Knowledge model", arguments)
```

The guard options are `timeout` (default 5,000 ms), `max_memory` (default 134,217,728 bytes, or 128 MiB), `max_input_bytes` and `max_result_bytes` (both default to `max_memory`). The worker checks captured input and complete result transfers, including diagnostics, and includes shared binaries in the process heap policy at garbage collection. Timeout and transfer failures return `%Dmn.Error{kind: :limit}` with codes `:timeout`, `:input_too_large` or `:result_too_large`; unexpected sandbox worker termination returns `:worker_failed` with the operation's error kind. Its `Dmn.Error.details.reason` records the observed outcome, for example `{:exit, :killed}`, `{:exit, {:controller, :killed}}` or `{:raised, exception}`. A heap-cap kill and an explicit kill can have the same reason, so neither is reliably labelled `:memory`. Reasons exceeding a 4,096-byte conservative transfer estimate become `reason: :omitted, reason_omitted: :too_large`. Fatal worker monitor failures are sanitized before they are forwarded to the caller. Their complete error envelope must fit both 4,096 bytes and `max_result_bytes`; otherwise the reason is omitted. If the omission marker itself cannot fit, the error is `:result_too_large`. The controller initially receives raw VM exit signals outside the worker heap policy, so trusted callbacks and resolvers must keep exit reasons bounded. The defaults cover loading and compilation as well as evaluation, including the returned model. Large import graphs, tens of thousands of definitions, or type-heavy compilation artifacts can need explicit larger guards. A longer `import_timeout` or `xml_timeout` does not extend the outer deadline. Known slow external callbacks, Java startup/restarts or queued sidecar work also need a host-selected deadline that covers their complete call. Raising a guard keeps containment in place; `sandbox: false` is reserved for trusted calls. Each later evaluation gets its own policy. Timeout and transfer errors keep their distinct limit codes. Use `sandbox: false` rather than `guard: nil` to opt out for trusted input. `Dmn.load/2`, `compile/2`, `evaluate/4`, `evaluate_all/4`, `invoke/4` and `prepare_call/3` accept these guard options; `prepare_call/3` checks them once, and `run/2` applies them to every run of the prepared decision. `compile/2` accepts XML bytes or a loaded model; bytes include loading in the same deadline. Caller-side selection and transfer-size traversal check that deadline cooperatively. Expiry returns `:timeout` before evaluation starts, including when the byte allowance would also be exhausted. This cannot preempt a single VM operation or arbitrary non-yielding native code.

Evaluation selects a compact dependency graph, including any recursion groups. This selection is the [preflight](https://github.com/FeeJai/BDM/blob/main/apps/feel/docs/architecture.md#the-preflight): it runs in the caller, before the worker starts, and decides what is copied into it. The original model, ASTs, duplicate IR and unused executables stay with the caller. Faults, limits and diagnostic source information are kept for the selected units. Only the requested target names travel with that view; the model-wide name and ambiguity maps stay with the caller. The source of a unit outside the graph is needed only if a run could report a diagnostic whose origin is that unit, which a function made elsewhere or trusted host code can cause. A bounded inspection of executable data, selected inputs and run options decides this: supplied functions (including nested or captured callbacks), host-provided non-ISO Date calendar modules and FEEL extensions that do not declare that they add data only (the `data_only?` callback of `Feel.Extension`; `feel_java` declares it) make an additional source index for units outside the graph, without their executables, travel with the view. A BEAM executable counts as opaque only when its extensions are. This preserves source locations and strict errors; source mapping still happens inside the worker under its deadline, heap and result-transfer policy. No callbacks run during selection. What the compiled expressions contain does not depend on the call, so the compiler records it once on each unit (`Dmn.Compiler.Unit.callback_free`, DD-44) and the inspection does not walk them again. A unit without the record is walked, so a compiled unit that is edited by hand must have it reset to `nil`. If the plan selects every current unit, all available source maps are already in the payload: source selection skips capability inspection and the extra index entirely. Already-mapped DMN faults are copied directly into results and do not participate in origin inspection. They still undergo the complete input and result transfer checks. Finite Decimal scalars use a constant-time proof that they contain no callbacks. Malformed or extended Decimal structures still take the bounded walk. Every Decimal, including large integer coefficients and exponents, keeps its complete transfer accounting; selection does no numeric normalization or host callback execution. Selection and execution use one iterative dependency planner in `Dmn.Compiled`. It preserves target and requirement order, visits shared dependencies once across all targets, and keeps recursion groups together after their external requirements. Selection supplies its existing work budget and deadline: each planner frame, including repeated edges, group members and result emission, consumes a step. On success, its ordered plan travels with the matching compact model in the per-call worker closure and is reused for evaluation or invocation. The plan itself undergoes input transfer checks. Execution still validates options, names, input keys and invocation arguments before using it. No plan is stored on the compiled artifact or accepted through public options. Each call plans its current units, so artifact edits cannot leave a stale plan. Trusted calls and complete-input fallbacks plan during execution. Oversized metadata, exhausted planning or uncertain target spellings fall back to the existing complete, guarded input. Ordering now participates in that same budget, so complex projections can fall back earlier; the fallback still faces the full input transfer check and original deadline. Reusing the plan trades runtime graph traversal for transferring its ordered keys; its timing benefit depends on the model and payload.

Each evaluation or invocation snapshots its clock once. Iteration, FEEL-call and diagnostic counts share one budget across dependencies, all requested targets and final invocation. Prepared calls start with fresh counters and a fresh clock on each execution. Model admission also caps the total number of units before preparing expressions (`Dmn.Limits.max_units`, default 10,000).

For `evaluate/4` and `evaluate_all/4`, unused large or composite input values can be replaced with `nil` in the worker payload, but every original key is kept for validation. Small scalars are kept to avoid normalizing thousands of ordinary numeric keys twice; their complete contents still face the transfer check. Invalid unused keys still fail; canonical name collisions and namespaced-input precedence are unchanged. `invoke/4` keeps its arguments because the callable's actual parameters are established during execution. Required data, callback captures and complete results still undergo transfer checks. `max_input_bytes` applies to the selected payload, so omitted unused values need not fit it. Reusing a model avoids compilation; selected source metadata and consumed large inputs still impose transfer costs.

For ordinary models with data inputs, selecting one small decision does not scan or copy metadata from unrelated units. Work depends on the selected graph and supplied input keys and values, rather than total model size. Capability inspection shares the existing selection budget and cooperative deadline, including tuple/map/list continuations. Wide/deep data or shared DAGs can exhaust that budget and fall back to complete guarded input. Functions, non-ISO calendar callbacks and FEEL extensions that are not declared data only can require extra source metadata proportional to the model: about 3 µs per KB of payload, so a model of 1,087 decisions with such an extension sends 414 KB and takes about 1.2 ms a call instead of 39 µs. Uncertain target spellings keep the complete guarded model. Source selection uses the same work/deadline budget, skips already selected units and falls back conservatively when it cannot finish. No policy limits are widened.

**What containment costs.** A contained call copies the decision's dependency view into a new process and its result back, and checks both. The price is mostly fixed per call plus a part that grows with the copy. To measure selection, input checking and protected/trusted evaluation separately on 1–30,000 independent decisions, run from the root:

```sh
MIX_ENV=test mix run apps/dmn/scripts/bench_payload.exs
```

The script measures empty inputs and 10,000 unused Decimal bindings by default (`--bindings 0,10000`). It prints selected-unit/name/extra-source counts, admission status, result codes and timings for both sandbox modes. Empty inputs use the default 128 MiB input allowance; binding-map cases use 20 MB to expose unwanted whole-model fallbacks. A rejected call is reported explicitly, rather than compared as if it had evaluated successfully. Compilation/setup is excluded. With empty inputs the script also inspects a 64 KB selection policy; the one-unit request should stay compact at every size.

`MIX_ENV=test mix run apps/feel/scripts/bench_sandbox.exs` additionally measures full public FEEL preparation and DMN compilation from XML and loaded models. It reports median/max times and result codes over five calls per sandbox mode; these are warmed-module measurements, not cold-VM startup. The heap and transfer defaults are 128 MiB. A proposed one-second deadline was also evaluated: ordinary preparation/compilation benchmarks fit it, but repeated full Java-enabled TCK runs had one or two call timeouts. The default therefore remains five seconds. Hosts can request a shorter deadline for their measured workloads; Java startup and queue waits are part of that allowance.

The figures below are from 19 October 2026: Elixir 1.20.4, OTP 29.1.1, an Apple M1 Pro with ten schedulers, `--count 10`, median of seven batches, compilation excluded. The machine was not idle (load average about 4), so differences under about 10 % are noise. One constant decision with empty inputs:

| Model decisions | Contained (µs) | `sandbox: false` (µs) |
| --- | ---: | ---: |
| 1 | 21.9 | 3.5 |
| 100 | 24.3 | 3.2 |
| 3,000 | 26.1 | 5.4 |
| 10,000 | 35.5 | 10.2 |
| 30,000 | 32.1 | 10.0 |

Each payload held one unit, one target name and no extra source entries, including under the 64 KB selection policy. With 10,000 unused Decimal bindings and the 20 MB input policy, both modes pay for the binding map itself:

| Model decisions | Contained (ms) | `sandbox: false` (ms) |
| --- | ---: | ---: |
| 1 | 38.8 | 34.8 |
| 100 | 38.6 | 34.4 |
| 3,000 | 39.7 | 42.2 |
| 10,000 | 44.7 | 44.7 |
| 30,000 | 48.1 | 50.4 |

All calls succeeded without fallback or increased selection budgets. Necessary input-key validation and copying still scale with the binding map, and both modes varied with host load. Complete public preparation/compilation measured these median / maximum milliseconds over five individual calls per mode, with ordinary defaults. Every measured call succeeded. XML compilation includes loading; loaded-model setup is excluded:

| Operation | `sandbox: false` | Contained |
| --- | ---: | ---: |
| FEEL prepare arithmetic | 0.019 / 0.289 | 0.049 / 0.153 |
| FEEL prepare 1,100 terms | 6.721 / 20.282 | 9.420 / 9.655 |
| DMN compile 1 decision, XML | 0.082 / 0.282 | 0.121 / 0.158 |
| DMN compile 1 decision, model | 0.031 / 0.115 | 0.080 / 0.290 |
| DMN compile 100 decisions, XML | 5.410 / 5.975 | 6.259 / 6.439 |
| DMN compile 100 decisions, model | 3.046 / 3.801 | 3.973 / 4.345 |
| DMN compile 1,000 decisions, XML | 51.595 / 53.488 | 59.829 / 62.221 |
| DMN compile 1,000 decisions, model | 28.911 / 29.426 | 39.187 / 43.285 |

Over the whole DMN TCK (`apps/tck_eval`, 3,493 decision calls, fewer than the 3,511 result nodes because one call can cover several nodes; models compiled with recursion and the Java extension only where a model has a Java function), a contained call on the interpreter takes about 100–130 µs against 67–68 µs with `sandbox: false`, and loading and compiling the models takes 15–30 % longer, since the compiled model is size-checked and copied back. The BEAM backend is in the same range. The `tck_eval` report prints both modes as separate rows.

Pass `sandbox: false` on each trusted call to skip the whole-operation worker and transfer checks. It does not disable FEEL/model limits or leave an already active worker. XML parsing and optional table checks keep their own bounded workers; this flag skips the outer operation boundary. Recursion signature probes inherit the run options as well, so trusted recursive evaluation does not silently start a default worker. Protected recursion remains within its enclosing worker. Recursive members receive their single-signature callee evidence during compilation. Compact payloads keep that evidence and exclude saved preparation data; evaluation does no reanalysis.

```elixir
{:ok, compiled} = Dmn.compile(xml, sandbox: false)
Dmn.evaluate(compiled, "Decision", inputs, sandbox: false)
```

`Dmn.XML` uses `:xmerl_sax_parser` and rejects a `DOCTYPE` before parsing. No atoms are created from the document, and `Dmn.Limits` bounds XML bytes, depth, elements, attributes and names. XML parsing always uses the shared worker because the parser can take quadratic time in one element's attributes. Optional table checks get their own worker when they need a tighter deadline; exhausting a check reports an unfinished analysis rather than a definitive finding. FEEL uses the interpreter unless the caller asks for `backend: :beam` for models it trusts (DD-41): that choice is accepted in the worker, and the modules and atoms it loads outlive the worker. FEEL keeps its structural, call, iteration and per-value size limits. The FEEL evaluations within one sandbox operation share its worker heap policy and deadline. There is no cumulative allocation quota; trusted evaluation keeps per-value checks but has no aggregate memory protection.

An `<import locationURI>` is never opened automatically: a host-supplied resolver finds the model. `Dmn.Resolver.directory/2` stays below its root and refuses links. Resolvers, extensions and host functions remain trusted capabilities. BEAM process containment cannot undo external effects, contain independent callback processes or interrupt arbitrary non-yielding native code. A heap policy is checked at garbage collection and does not cap total VM memory. The application must bound concurrency, accepted input, retained models and any external runtime it enables. See the shared [security architecture](https://github.com/FeeJai/BDM/blob/main/apps/feel/docs/architecture.md#security-model) and [XML and import policies](docs/decisions.md#xml-and-imports) for details.

## Documentation

| Document | Topic |
| -------- | ----- |
| [docs/feel-integration.md](docs/feel-integration.md) | DMN-to-FEEL compilation, with diagrams: the virtual source, boxed-expression mapping, node analysis and execution. |
| [docs/decisions.md](docs/decisions.md) | The decisions of this layer (DD-01 …). The decisions of the language are in `feel`. |
| [docs/todo.md](docs/todo.md) | What fails on the TCK and what is not done. |

## Development

```sh
mix test                              # in the umbrella root, or here; the DMN TCK runs as part of it
cd ../bdm_tck && mix run scripts/dmn_tck.exs               # the report of the TCK through this engine
cd ../bdm_tck && mix run scripts/dmn_tck.exs --list fail   # the nodes that fail, with what was expected
```

## License

GNU Affero General Public License v3.0 only; the text is `LICENSE`.
