Skip to content

Decision models (DMN)

Decision Models is not yet available in a released build. The DMN parser and evaluator described on this page are implemented and tested, and a businessRuleTask inside a deployed BPMN process already evaluates decision tables through them — but the standalone decision-management API (creating, listing, and editing dmn_decision resources) is gated behind a feature flag that is off in every shipped image today, so every route for it returns 404. Nothing below is reachable through the product yet.

A DMN 1.3 decision table is a spreadsheet-shaped set of rules: input columns, output columns, and rows where each cell is a condition or a result. Orvanta’s evaluator supports the five standard “single output” hit policies:

Hit policyBehaviour
UNIQUEExactly one rule may match; more than one match is an evaluation error.
FIRSTThe first matching rule (in document order) wins.
PRIORITYThe matching rule with the highest declared output priority wins.
ANYMultiple rules may match, but they must all produce the same output.
COLLECTEvery matching rule’s output is returned as a list.

Cell expressions use FEEL — see the cell grammar below. Input/output values in Orvanta’s evaluator are scalars, temporal values, and lists only — a variable holding a JSON object is omitted from what a decision table can see rather than failing the evaluation; if the table references that name anyway, it resolves as the same null a missing key would produce, never a hard error.

COLLECT’s numeric aggregators are implemented, not just the plain list form: C+/aggregation="SUM", C</MIN, C>/MAX, and C#/COUNT all run, alongside plain C (every matched rule’s output, as a list). An aggregated COLLECT is defined over a single numeric output column only — a compound-output table under C+/C</C> is an evaluation error naming the column. Both spellings found in the wild are accepted (hitPolicy="C+" and hitPolicy="COLLECT" aggregation="SUM"); if a document sets both, they must agree. Orvanta pins two readings the spec leaves open, matching Camunda/Flowable/Drools rather than a literal reading of the spec text:

CaseOrvanta’s answer
C+/C# over repeated identical outputs (e.g. [10, 10, 5])Summed/counted as-is (25, 3) — not de-duplicated first.
C+/C# with no matching rule0 (the additive/counting identity).
C</C> with no matching rulenull — the minimum/maximum of nothing has no value.
Plain C with no matching ruleThe whole result is null (unchanged from before aggregators existed).

A declared outputValues domain is enforced under every hit policy, not just documentation and not just PRIORITY’s ranking. DMN 1.3 §8.2.10 lets an output column declare the list of values it’s allowed to produce. Orvanta’s evaluator checks every candidate rule’s output against that list at the same point it applies a hit policy — a rule whose output falls outside a declared outputValues domain is rejected (OutputValueNotAllowed) rather than silently returned, under UNIQUE/FIRST/ANY/COLLECT exactly as much as under PRIORITY.

Orvanta’s FEEL evaluator (orvanta-feel) is a hand-written, deliberately partial implementation — not a full FEEL engine. Input-entry (unary test) cells support:

  • Bare-any: - or an empty cell.
  • The literal null (and not(null)).
  • Literal equality: a bare "gold", 42, true, or an explicit = x.
  • Comma-separated lists as OR: "NL","BE".
  • Comparisons <, <=, >, >= over number, string, date, time, date-and-time, or duration.
  • Ranges [1..10], (1..10), and mixed brackets [1..10)/(1..10], over the same set of types (["a".."m"), [date("2026-01-01")..date("2026-12-31")]).
  • not(...) and comma lists inside it (not("NL","BE")).
  • The in operator (in [1..10], ? in ("a","b")) — the implicit-input form ? is only meaningful on the left of in.
  • and/or conjunction/disjunction between tests (and binds tighter than or).
  • A comparison or range bound can itself be a variable name resolved from the table’s own inputs (< limit, [low..high]) instead of a literal.

Output-entry cells support number/string/boolean/temporal literals and simple arithmetic (+ - * /, unary -, parentheses) over identifiers.

Temporal literals: date("YYYY-MM-DD"), time("hh:mm:ss[.fff][Z|±hh:mm]"), date and time("YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]"), and duration("P…") (FEEL’s two distinct duration types — P1Y2M years-and-months, P1DT2H30M days-and-time — do not compare against each other). A column’s declared typeRef (string, number, boolean, date, time, date and time/dateTime, dayTimeDuration, yearMonthDuration) coerces an inbound value to that type before any rule runs — a JSON "100" reaching a typeRef="number" column matches >= 100 rather than erroring. An unrecognised or absent typeRef (including a custom itemDefinition) is Any: no coercion, no check.

Anything outside this subset — full FEEL contexts/functions/for-loops, qualified names like customer.tier on the left of a comparison, IANA zone identifiers (time("10:00:00@Europe/Paris")) — is a parse error, not a silent partial match.

Hard caps enforced during parsing, before any rule runs:

LimitValue
DMN XML document size1 MiB
Rules per decision table2,000
Input columns per table64
Output columns per table64
A single cell’s text4,096 bytes
A single FEEL entry’s parse/eval recursion depth64

A document over any of these limits fails to parse with an explicit error rather than being read partially.

Beyond a single decision table, DMN lets decisions reference each other through a Decision Requirements Diagram (DRD) — <decision> elements linked by <informationRequirement>, with <decisionService> elements exposing a composed subset of them as one callable unit. Orvanta’s DMN engine parses and evaluates this graph — resolving <inputData>, requirement edges, and decision services, with explicit errors for a malformed graph (a dangling reference, a required-decision cycle, a decision missing the <variable> its result binds under, and so on).

None of this is reachable in a released build yet. Like the standalone decision-management API above, the DRD canvas and a dedicated decision-service evaluation route don’t ship until the end of the programme this belongs to (orvanta-platform#731) — the engine and route exist, but nothing in the product surfaces them.

One exception, already live today: if a businessRuleTask (or a Flowable serviceTask flowable:type="dmn") references a <decisionService> rather than a plain decision, Orvanta deploys it — with a warning — and evaluates only the single decision the service names. The surrounding DRD composition (multiple output decisions, encapsulated decisions feeding into them) is not honoured; only ever the one referenced decision’s own table runs.

A businessRuleTask links to a decision table with the orvanta:decisionRef extension attribute, whose value is the path of a stored dmn_decision resource — not an inline document. At runtime the engine resolves that path once per process (before execution starts, alongside script bindings) and evaluates it synchronously and inline on the worker — no separate job is dispatched, the same way a gateway condition evaluates inline.

camunda:decisionRef and flowable:decisionRef are parsed but deliberately ignored — they never bind as an Orvanta decision reference. If you’re importing a Flowable or Camunda process with a business rule task, re-point it at an Orvanta orvanta:decisionRef after import; see Importing from Flowable/Camunda.

A businessRuleTask evaluates its decision table against the merge of three sources, least to most specific (a later source wins a name collision):

  1. The process’s start payload (flow_input) — its fields are readable under their own bare name, so a table authored against the start form’s field names (age, country, amount) reads them directly, and additionally and unshadowably under the flow_input. prefix (flow_input.age) — the same name a JavaScript input transform or gateway condition uses for the same value.
  2. results.<elementId> — upstream activity results.
  3. The current scope’s named process variables — the more likely intentional input than a same-named activity result.

A JSON object flattens into dotted keys before this lookup ({"applicant": {"age": 35}} contributes applicant.age), and any value with no FEEL representation is omitted from the table’s inputs — logged by name and reason rather than dropped silently. This is exactly the same input mapping POST .../evaluate (and the DMN test panel, which calls it) uses, so a table that matches in a running process behaves identically when tested standalone.