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.onloadcompletes - 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:
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:
<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.typeReffrom 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.- Name-based heuristics.
string-typed inputs whose name containsdatum/date/dagdefault to today's date;geboortedefaults to a random adult birth date;aantal/bedrag/inkomendefault to0. 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':
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.
DSO â DMN deep-link import¶
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:
- fetches the standalone DMN XML â
GET REACT_APP_BACKEND_URL/v1/dso/toepasbare-regels/{dmnId}/dmn(?env=prodonly whenenv=prod); - prefills
dmnDatawithfileName: decision-{dmnId}.dmn, the fetched XML, and the primary decision key â keeping the tab interactive (isImported: false, not the preserved mode); - prefills the Service tab (title/identifier/description from the DSO activity) and Organization tab (from the resolved authority);
- strips the import params via
history.replaceStateso 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:
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.deploymentEndpointis removed â it no longer has a caller.apiConfig.baseUrlreadsREACT_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.