Skip to content

DMN Implementation


Component structure

src/
├── components/tabs/
│   └── DMNTab.jsx                    # Main UI (900+ lines)
│       â€ĸ File upload + card display
│       â€ĸ Syntactic validation panel
│       â€ĸ API configuration
│       â€ĸ Deployment controls
│       â€ĸ Single evaluate (Postman-style)
│       â€ĸ Intermediate decision tests
│       â€ĸ Test cases upload + runner
│       â€ĸ Import preservation notice
│
├── utils/
│   ├── dmnHelpers.js                 # DMN TTL generation utilities (370 lines)
│   └── parseTTL.enhanced.js          # DMN block capture on import (523 lines)
│
└── config/
    └── vocabularies_config.js        # DMN entity type detection

DMN state shape

dmnData: {
  // File
  fileName: string,
  content: string,              // Raw DMN XML

  // Deployment
  decisionKey: string,          // Primary key extracted from DMN
  deployed: boolean,
  deploymentId: string | null,
  deployedAt: string | null,

  // API
  apiEndpoint: string,

  // Testing
  lastTestResult: object | null,
  lastTestTimestamp: string | null,
  testBody: string | null,

  // Import preservation (v1.5.1+)
  importedDmnBlocks: string | null,  // Raw Turtle lines preserved verbatim
  isImported: boolean,
}

Syntactic validation

Overview

When a DMN file is uploaded or an example is loaded, DMNTab.jsx calls the shared backend validation endpoint and stores the result in local component state.

// State added in v1.9.3
const [validationResult, setValidationResult] = useState(null);
const [isValidating, setIsValidating]         = useState(false);
const [validationExpanded, setValidationExpanded] = useState(false);

Backend call

const runBackendValidation = async (content) => {
  setIsValidating(true);
  try {
    const response = await fetch(
      `${process.env.REACT_APP_BACKEND_URL}/v1/dmns/validate`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content }),
      }
    );
    const data = await response.json();
    if (data.success) {
      setValidationResult(data.data);
      // Auto-expand the panel when errors are present
      setValidationExpanded(data.data.summary.errors > 0);
    }
} catch (err) {
    // Backend unreachable — surface a neutral "unavailable" state rather than
    // failing silently. DMN workflow remains fully functional; only the
    // syntactic pre-check is skipped.
    setValidationResult({
      valid: false,
      unavailable: true,
      parseError:
        'Syntax validation result not available — the validation backend ' +
        `could not be reached (${err.message}). DMN deployment and testing ` +
        'still work; only syntax pre-checks are skipped.',
      layers: { base: { issues: [] }, business: { issues: [] }, execution: { issues: [] }, interaction: { issues: [] }, content: { issues: [] } },
      summary: { errors: 0, warnings: 0, infos: 0 },
    });
  } finally {
    setIsValidating(false);
  }
};

The call is made:

  • After handleFileUpload — reader.onload completes
  • After loadExampleDMN — on successful fetch
  • Cleared in handleClearFile — setValidationResult(null); setIsValidating(false);

Backend unreachability handling

A network failure or unavailable backend does not block the DMN workflow — deployment, single evaluate, intermediate tests, and the test-cases runner all continue to function. From v1.9.5 onward the validation panel renders a distinct amber "Syntax validation result not available" state to make the situation visible, kept visually separate from the red "Syntax issues found" state that indicates an actual DMN problem. The amber state is signalled by validationResult.unavailable === true; the per-layer issue arrays and the error/warning/info counts are empty in that case.

Response shape

interface ValidationResult {
  valid: boolean;
  unavailable?: boolean;  // v1.9.5+ — true when the backend was unreachable; layers and summary are empty
  parseError: string | null;
  layers: {
    base:        { label: string; issues: Issue[] };
    business:    { label: string; issues: Issue[] };
    execution:   { label: string; issues: Issue[] };
    interaction: { label: string; issues: Issue[] };
    content:     { label: string; issues: Issue[] };
  };
  summary: { errors: number; warnings: number; infos: number };
}

interface Issue {
  severity: 'error' | 'warning' | 'info';
  code: string;      // e.g. 'EXEC-001', 'INT-005'
  message: string;
  location?: string; // element description e.g. '<decision id="d1">'
  line?: number;
  column?: number;
}

UI rendering

The validation panel is rendered inside the file card, between the file info row and the Deploy button. It is hidden when validationResult is null or isValidating is true (a spinner is shown instead).

The collapsible layer rows follow the same pattern as the Linked Data Explorer's DmnValidator component — each layer shows an icon and badge counts at a glance and expands on click to show individual issue rows.


Import preservation

Detection

vocabularies_config.js detects DMN entities before regular entities to avoid misclassification:

export const detectEntityType = (line) => {
  // DMN detection FIRST
  if (line.includes('a cprmv:DecisionModel')) return 'dmnModel';
  if (line.includes('a cpsv:Input'))          return 'dmnInput';
  if (line.includes('a cprmv:DecisionRule'))  return 'dmnRule';

  // Regular entity detection below...
};

Capture

parseTTL.enhanced.js captures DMN lines verbatim when detected:

let inDmnSection = false;
let dmnLines = [];

if (['dmnModel', 'dmnInput', 'dmnRule'].includes(detectedType)) {
  if (!inDmnSection) {
    inDmnSection = true;
    parsed.hasDmnData = true;
  }
  dmnLines.push(rawLine);  // exact line, no transformation
  continue;
}

if (parsed.hasDmnData && dmnLines.length > 0) {
  parsed.importedDmnBlocks = dmnLines.join('\n');
}

Export

ttlGenerator.js appends preserved blocks at the end of the generated output — after all form-based sections. Before appending, generateDmnSection runs normalizeImportedDmnBlocks (v1.10.2): a CPSV-AP conformance pass over the preserved cpsv:Rule (Decision Rule) blocks that injects missing dct:title/dct:description and repoints a cpsv:implements that targets the cpsv:PublicService to the eli:LegalResource (or drops it when no legal resource exists). The edits are additive/repointing only — no preserved triples are removed — and idempotent, so re-importing an already-conformant export is a no-op.


Primary decision key extraction

The extractPrimaryDecisionKey() helper skips constant parameters automatically:

// Filters decisions with p_* prefix (constants)
// Prefers a *root* decision — one no other decision requires via
//   informationRequirement/requiredDecision (v1.10.3)
// Document order breaks ties between multiple independent roots
// Falls back to the first decision if all are constants

As of v1.9.6 this helper lives in src/utils/dmnHelpers.js and is exported, so both DMNTab.jsx and the DSO import hook share one implementation instead of duplicating the parser.

Root-decision preference (v1.10.3). The helper previously returned the last non-constant decision, which broke models whose intended output decision is authored earlier or in a non-obvious position. It now prefers a decision that no other decision requires (a DRD root). When several independent roots exist (e.g. a combined Recht-Ên-Hoogte model) document order still decides, and a console warning points to the Decision Key dropdown. That dropdown renders on the DMN File card whenever a file has more than one testable decision, listing each as Name (id); selecting one updates the decision key and the evaluation URL everywhere.

Console log on extraction:

[DMN] Extracted primary decision key: "zorgtoeslag_resultaat" (skipped 8 p_* constant(s))



Request body generation

When a DMN file is loaded, generateRequestBodyFromDMN walks the top-level <inputData> elements and builds a starter request body. For each input it picks a starter value using three sources, in priority order:

  1. <inputValues> constraint (v1.9.5+). If the inputData name matches the <inputExpression> text of a decisionTable input column that carries an <inputValues> FEEL allowed-values list, the first allowed value is used. Quoted strings are unwrapped; booleans and numbers are coerced from their FEEL literal forms.
  2. typeRef from the inputData <variable> child. When no <inputValues> constraint applies, the type drives a switch: boolean → false, integer/long → 0, number/double/decimal → 0, date → today's date as a full ISO timestamp with offset, string → empty.
  3. Name-based heuristics. string-typed inputs whose name contains datum/date/dag default to today's date; geboorte defaults to a random adult birth date; aantal/bedrag/inkomen default to 0. Heuristics run only when steps 1 and 2 leave the value empty.

The resulting body is editable in the DMN tab's request-body panel before each evaluate call. Authors who want a richer starter body without writing custom code can simply add <inputValues> constraints to the relevant decisionTable input columns — this also documents intent in the DMN itself, which downstream tooling (validators, decision-table editors) can consume.

Date inputs are typed Date, not String (v2026.08.0). The typeRef="date" branch previously emitted { value: "YYYY-MM-DD", type: "String" } on the assumption that Operaton would convert internally. It does not, for any DMN that calls .year/.years on the input directly — confirmed from Operaton's own logs (DMN-01005 Invalid value â€Ļ for clause with type 'date'). The branch now emits a full ISO timestamp with offset and type: 'Date':

{ "geboortedatum": { "value": "1990-03-17T00:00:00+01:00", "type": "Date" } }

Output discovery

extractOutputsFromTestResult(dmnData) derives the Concepts tab's output variables from the last evaluate result — so it can only discover an output that the engine actually returned. A DRD root with hitPolicy="RULE ORDER" and no catch-all rule legitimately returns an empty result set against the auto-generated baseline request body, which left the tab with zero output concepts even though the DMN declares one. Inputs never had this problem: generateRequestBodyFromDMN reads <dmn:inputData> straight from the XML, independent of any result.

extractOutputsFromDMN(content, decisionKey) (v2026.08.0) closes the same gap for outputs, reading a decision's declared <dmn:output> name and type from the XML — namespace-agnostically, handling multiple output columns and the name-vs-label fallback. handleEvaluateDMN prefers the live result and falls back to the static read whenever it yields nothing:

const liveOutputs = extractOutputsFromTestResult({ lastTestResult: result });
const outputs = liveOutputs.length
  ? liveOutputs
  : extractOutputsFromDMN(dmnData.content, apiConfig.decisionKey);

Cell-level legislative grounding

extractRulesFromDMN also reads each decision-table cell's id, FEEL text and dct:source/cprmv:sourceQuote/cprmv:isBasedOn groundings into rule.inputEntries/outputEntries, which ttlGenerator.js publishes as per-cell cprmv:Rule resources. Building it surfaced — and fixed — a namespace defect that had silently broken every selector-based DMN lookup against real, dmn:-prefixed files.

See Cell-Level Legislative Grounding for the full design.


Test-case verification

evaluateTestCaseExpectation (v1.10.3) compares each uploaded test case's expected outputs against the engine's actual outputs and returns a verdict, so a case only passes when it is functionally correct — not merely because the HTTP call returned 200. It reads the expectation from the readable key=value, reden="â€Ļ" string, a structured expected object, or the special empty-result case, and yields one of:

Verdict Condition
PASS expected outputs match actual
FAIL mismatch — an Expected-vs-Actual table is rendered per output
ERROR the evaluate call itself failed
OK-unchecked no expectation could be parsed (amber; never a silent pass)

Summary and header counts are verdict-based. Run All Test Cases routes each case to its own decision — a case's optional decision field is the evaluation key, falling back to the selected Decision Key (v1.10.4).

Empty-result expectations (v1.10.6). The empty-result branch originally matched only the descriptive strings empty result / no matching rule, so a literal [] (or {}) expectation fell through to the key=value parser, found no pairs, and was judged OK-unchecked even though an empty engine response was exactly correct. The check now also matches a literal empty array or object, so [] is treated as expect an empty result set (e.g. Thuisbatterij jaarGebondenBudget years 2025/2028 outside the modelled range now pass). A non-empty response against an [] expectation still fails.


The useDsoImport hook (src/hooks/useDsoImport.js, v1.9.6) lets the Linked Data Explorer hand a toepasbare-regel DMN straight into the editor via a deep-link:

/?dsoImport=dmn&dmnId=<id>&env=<pre|prod>&activityName=<â€Ļ>
  &authority=<â€Ļ>&activityUrn=<â€Ļ>&fsRef=<â€Ļ>

On mount (guarded by a consumedRef against StrictMode double-invoke) the hook:

  1. fetches the standalone DMN XML — GET REACT_APP_BACKEND_URL/v1/dso/toepasbare-regels/{dmnId}/dmn (?env=prod only when env=prod);
  2. prefills dmnData with fileName: decision-{dmnId}.dmn, the fetched XML, and the primary decision key — keeping the tab interactive (isImported: false, not the preserved mode);
  3. prefills the Service tab (title/identifier/description from the DSO activity) and Organization tab (from the resolved authority);
  4. strips the import params via history.replaceState so a refresh can't re-import.

Deploy + test + publish then run through the existing DMNTab / PublishDialog flow. DMNTab hydrates its internal uploaded-file/decision-key/test-body/validation state from dmnData.content whenever content arrives from outside the tab and no local file was uploaded — without this, deploy/test stayed gated on the internal uploadedFile state.


Operaton calls go through the backend (v2026.08.0)

Deploy and evaluate both used to run browser → Operaton directly, which hits CORS in local development. Both now route through the Linked Data Explorer backend, mirroring the pattern runBackendValidation() already used for /v1/dmns/validate: browser → LDE backend (CORS-allowed) → Operaton server-to-server.

Deploy — handleDeployDMN posts to:

POST {REACT_APP_BACKEND_URL}/v1/dmns/deploy

Evaluate — Evaluate Decision, Run intermediate tests and Run test cases all go through one shared helper, evaluateViaBackend(decisionKey, bodyStr), forwarding the request body unchanged:

POST {REACT_APP_BACKEND_URL}/v1/dmns/evaluate/{decisionKey}
Content-Type: application/json
Body: { "variables": { ... } }

The backend calls Operaton's own /engine-rest/deployment/create and /engine-rest/decision-definition/key/{decisionKey}/evaluate on the editor's behalf. Both routes are documented from the backend side in the Linked Data Explorer's API Reference — note the evaluate route is a raw passthrough that returns Operaton's own JSON rather than the backend's usual {success, data, error} envelope, which is why the tab can read the response directly. Consequences in the tab:

  • The Evaluation URL preview in the API Configuration panel shows the backend URL actually called, not an Operaton URL.
  • apiConfig.deploymentEndpoint is removed — it no longer has a caller.
  • apiConfig.baseUrl reads REACT_APP_OPERATON_URL, falling back to the production instance. It identifies the Operaton engine the backend should target; without it, local development silently pointed at the shared ACC/PROD engine instead of a local container.

URI generation

DMN URIs are derived from the service identifier. Given service.identifier = "aow-leeftijd":

<https://regels.overheid.nl/services/aow-leeftijd/dmn>
    a cprmv:DecisionModel .

<https://regels.overheid.nl/services/aow-leeftijd/dmn/input/1>
    a cpsv:Input .

<https://regels.overheid.nl/services/aow-leeftijd/rules/DecisionRule_2020>
    a cpsv:Rule, cprmv:DecisionRule .

Spaces in the service identifier are replaced with hyphens. Full URIs in the organisation field are used as-is; short IDs are expanded.


Shell script parity

The intermediate tests and test cases features in the UI mirror the shell scripts in examples/organizations/*/:

Shell script UI equivalent
test-dmn-zorgtoeslag.sh DMN Tab → Intermediate Decision Tests
test-cases-zorgtoeslag.sh DMN Tab → Test Cases

The shell scripts can still be used for CI/CD automation independently of the UI.