@divmain/jdm-asm
v1.1.3
Published
JDM to AssemblyScript WASM Compiler - Transforms JSON Decision Models into efficient WebAssembly
Readme
jdm-asm
A high-performance JDM (JSON Decision Model) compiler that transforms decision models into WebAssembly modules using AssemblyScript. Compatible with GoRules zen-engine JDM format, with single-digit microsecond single-decision latency and, for small-to-moderate decision tables, roughly 5-10x faster single-threaded execution than zen-engine in the benchmarks below. (Multi-threaded/IPC throughput depends on the available worker budget and can be lower than in-process zen-engine for cheap decisions; see Performance.)
Table of Contents
- Overview
- Installation
- Quick Start
- Usage Guide
- JDM Format Reference
- Architecture
- Performance
- Compatibility with zen-engine
- Development
- License
Overview
jdm-asm compiles JSON Decision Models (JDM) into optimized WebAssembly modules. This approach moves expensive parsing and interpretation to compile-time, resulting in significantly faster runtime execution compared to traditional JavaScript rule engines.
Key Features:
- Ultra-Low Latency: ~3-7 microsecond execution for typical decisions in single-threaded mode
- High Single-Threaded Performance: roughly 5-10x faster than zen-engine for small-to-moderate decision tables (single-threaded; see measured numbers and caveats in Performance)
- Type Safety: TypeBox schemas for input/output validation
- zen-engine Compatible: Drop-in replacement for most JDM files
- Extended Hit Policies: Supports DMN-standard policies (unique, ruleOrder, outputOrder, priority)
- Parallel-Ready: Compiled decisions are self-contained, so you can create one instance per worker thread for high-throughput batch processing
Installation
npm install @divmain/jdm-asmRequirements:
- Node.js 20+
- TypeScript 5.0+ (for TypeBox schemas)
Quick Start
import { Type } from '@sinclair/typebox';
import { compile, createCompiledDecision } from '@divmain/jdm-asm';
// 1. Define input/output schemas
const InputSchema = Type.Object({
customerType: Type.String(),
orderAmount: Type.Number(),
});
const OutputSchema = Type.Object({
discount: Type.Number(),
});
// 2. Define your decision model (or load from file)
const jdm = {
nodes: [
{ id: 'input', type: 'inputNode', name: 'Request', position: { x: 0, y: 0 } },
{
id: 'table',
type: 'decisionTableNode',
name: 'Discount Rules',
position: { x: 200, y: 0 },
content: {
hitPolicy: 'first',
inputs: [
{ id: 'in1', name: 'Customer', field: 'customerType' },
{ id: 'in2', name: 'Amount', field: 'orderAmount' },
],
outputs: [{ id: 'out1', name: 'Discount', field: 'discount' }],
rules: [
{ _id: 'r1', in1: '"premium"', in2: '> 100', out1: '0.15' },
{ _id: 'r2', in1: '"standard"', in2: '> 100', out1: '0.10' },
{ _id: 'r3', in1: '', in2: '', out1: '0' },
],
},
},
{ id: 'output', type: 'outputNode', name: 'Response', position: { x: 400, y: 0 } },
],
edges: [
{ id: 'e1', sourceId: 'input', targetId: 'table', type: 'edge' },
{ id: 'e2', sourceId: 'table', targetId: 'output', type: 'edge' },
],
};
// 3. Compile to WebAssembly
const compiled = await compile({
jdm,
inputSchema: InputSchema,
outputSchema: OutputSchema,
});
// 4. Create decision instance
const decision = await createCompiledDecision(compiled);
// 5. Evaluate
const result = await decision.evaluate({
customerType: 'premium',
orderAmount: 150,
});
console.log(result); // { discount: 0.15 }
// 6. Clean up when done
decision.dispose();Usage Guide
Defining Schemas
jdm-asm uses TypeBox schemas to define the structure of input and output data. Schemas enable:
- Compile-time type checking
- Runtime input validation
- Optimized memory layout for WASM
import { Type } from '@sinclair/typebox';
// Simple flat schema
const InputSchema = Type.Object({
age: Type.Number(),
name: Type.String(),
active: Type.Boolean(),
});
// Nested schema
const InputSchema = Type.Object({
customer: Type.Object({
id: Type.String(),
tier: Type.String(),
loyaltyYears: Type.Number(),
}),
order: Type.Object({
total: Type.Number(),
items: Type.Array(Type.Object({
sku: Type.String(),
price: Type.Number(),
quantity: Type.Number(),
})),
}),
});
// Output schema
const OutputSchema = Type.Object({
eligible: Type.Boolean(),
discount: Type.Number(),
message: Type.String(),
});Creating JDM Files
JDM files are JSON documents containing nodes and edges that form a decision graph:
{
"nodes": [
{
"id": "unique-node-id",
"type": "inputNode|outputNode|expressionNode|decisionTableNode|switchNode|decisionNode",
"name": "Human-readable name",
"position": { "x": 0, "y": 0 },
"content": { }
}
],
"edges": [
{
"id": "unique-edge-id",
"type": "edge",
"sourceId": "source-node-id",
"targetId": "target-node-id"
}
]
}Every JDM must have:
- Exactly one
inputNode(entry point) - At least one
outputNode(exit point) - A connected path from input to output
Compiling Decisions
The compile() function transforms JDM into WebAssembly:
import { compile } from '@divmain/jdm-asm';
const result = await compile({
// Required
jdm: jdmObject, // JDM as object or JSON string
inputSchema: InputSchema, // TypeBox schema
outputSchema: OutputSchema,
// Optional
optimize: true, // Enable WASM optimizations (default: true)
debug: false, // Include AS source and WAT in output
noMatchBehavior: { // When no rules match:
type: 'returnNull' // 'returnNull' | 'throwError' | 'returnDefault'
},
loadDecision: (key) => {}, // Custom loader for sub-decisions
});⚠️ Security: sub-decision keys are model-controlled path components. The
keypassed to yourloadDecisionoriginates from the decision model (decisionNode.content.key), not from the library. jdm-asm performs no filesystem access of its own for sub-decisions — it simply forwards the key to your loader. If your loader resolves keys against the filesystem, treat each key as an untrusted path component: a key such as../../etc/passwdwill traverse out of your intended directory if you naivelypath.join(root, key). Confine resolution to a fixed root and reject traversal — see Decision Node.
Compilation Result:
interface CompilationResult {
wasm: Uint8Array; // Compiled WASM binary
schemaHash: bigint; // Hash of the input/output schemas (schema identity only)
marshalCode: string; // JS code for data marshaling (embeds SCHEMA_HASH)
validationCode: string; // JS code for input validation
outputValidationCode?: string; // JS code for output validation (optional)
wat?: string; // WAT text format (debug mode; see note below)
watUnavailable?: boolean; // WAT skipped for a large module (debug mode)
assemblyScript?: string; // Generated AS source (debug mode)
}ℹ️
debug: truedoes not guaranteewat. WAT (WebAssembly text) emission is intentionally skipped for large generated modules — Binaryen's text emitter has O(n²) memory complexity and can run out of memory on big decision tables. In that casecompile({ debug: true })returnswat: undefinedand setswatUnavailable: trueso you can tell "WAT skipped because the module is too large" apart from a present-but-empty emission. When WAT is generated,watUnavailableisfalse. Outside debug mode bothwatandwatUnavailableareundefined. TheassemblyScriptsource is always available in debug mode regardless of module size.
⚠️ These artifacts are a single mutually dependent unit. The
marshalCode,validationCode,outputValidationCode, andwasmfields are produced by onecompile()call and must be stored, transported, and executed together — never mixed across compilations.schemaHashbinds only the input/output schemas (not the compiled decision logic inwasm), and onlymarshalCodeembeds it, so schema-hash verification is a schema-identity check, not a whole-artifact integrity check. See Security: Generated Code Trust Boundary.
⚠️ Security: a
CompilationResultis executable code, not inert data. ThemarshalCodeandvalidationCodefields are plain JavaScript source strings that are executed via theFunctionconstructor when the result is turned into a runnable decision (viacreateCompiledDecision, or the publicly re-exportedloadGeneratedMarshaling/loadGeneratedValidationloaders). Executing aCompilationResultis equivalent to running trusted program input in your process. Treat it with the same trust as the JDM you compiled it from: never build aCompilationResult(or itsmarshalCode/validationCodefields) from data received across an untrusted boundary (network request bodies, user uploads, third-party storage, etc.), and never forward one across a trust boundary to be executed without re-establishing that its source is trusted. See Security: Generated Code Trust Boundary.
Executing Decisions
Use createCompiledDecision() to instantiate and execute compiled decisions:
import { createCompiledDecision } from '@divmain/jdm-asm';
// Create instance (instantiates WASM module)
const decision = await createCompiledDecision(compiledResult);
// Evaluate with input data
const output = await decision.evaluate({
customerType: 'premium',
orderAmount: 150,
});
// Reuse for multiple evaluations (recommended for performance)
for (const input of inputs) {
const result = await decision.evaluate(input);
processResult(result);
}
// Release resources when done
decision.dispose();CompiledDecision API:
class CompiledDecision {
// Evaluate with input
evaluate<I, O>(input: I): Promise<O>;
// Get WASM memory (debugging)
getMemory(): WebAssembly.Memory | null;
// Memory statistics (committed linear-memory capacity in bytes; NOT actual
// heap/arena consumption). `committed` is an alias of `current`.
getMemoryStats(): { current: number; maximum: number; committed: number };
// Release resources
dispose(): void;
}Configuration Options
No-Match Behavior
Configure what happens when no rules match:
// Return null (default)
{ type: 'returnNull' }
// Set error code and return null
{ type: 'throwError' }
// Return a specific default value (scalars only)
{ type: 'returnDefault', value: 0 }The returnDefault value must be a scalar (string, number, boolean, or
null). Non-scalar defaults (objects or arrays) are rejected at compile time
with a structured error rather than silently degrading. For a decision table the
scalar default is assigned to every output field; to produce a structured
no-match result, use explicit output fields or a dedicated default branch
instead.
Can be set globally via compile options or per-node in the JDM content. An
unknown type (for example a typo like 'returnDefaults') is likewise rejected
at compile time rather than silently falling back to returnNull.
Compilation Cache
jdm-asm caches WASM compilations to disk for faster subsequent builds:
import {
clearCache,
getCacheStats,
pruneCache,
getCacheDirectory,
} from '@divmain/jdm-asm';
// Clear all cached compilations
clearCache();
// Get cache statistics
const stats = getCacheStats();
// Remove old entries
pruneCache();JDM Format Reference
Node Types
Input Node
Entry point for the decision graph.
{
"id": "input-1",
"type": "inputNode",
"name": "Request"
}Output Node
Exit point that returns accumulated context data.
{
"id": "output-1",
"type": "outputNode",
"name": "Response"
}Expression Node
Evaluates expressions and sets output fields.
{
"id": "expr-1",
"type": "expressionNode",
"name": "Calculate",
"content": {
"expressions": [
{ "id": "e1", "key": "total", "value": "price * quantity" },
{ "id": "e2", "key": "tax", "value": "total * 0.08" },
{ "id": "e3", "key": "grandTotal", "value": "$.total + $.tax" }
],
"passThrough": true, // Include input fields in output
"inputField": "order", // Scope to nested field
"outputPath": "result" // Store results under this key
}
}Self-Referential Expressions: Use $ to reference earlier computed values within the same expression node (e.g., $.total refers to the total computed above).
Decision Table Node
Rule-based decision table with conditions and outputs.
{
"id": "table-1",
"type": "decisionTableNode",
"name": "Discount Rules",
"content": {
"hitPolicy": "first",
"inputs": [
{ "id": "in1", "name": "Customer Type", "field": "customerType" },
{ "id": "in2", "name": "Order Amount", "field": "orderAmount" }
],
"outputs": [
{ "id": "out1", "name": "Discount", "field": "discount" }
],
"rules": [
{ "_id": "r1", "in1": "\"premium\"", "in2": "> 100", "out1": "0.15" },
{ "_id": "r2", "in1": "\"standard\"", "in2": "> 100", "out1": "0.10" },
{ "_id": "r3", "in1": "", "in2": "", "out1": "0" }
],
"passThrough": false
}
}Unary Mode: In decision table cells, expressions are implicitly compared against the column's input value ($):
| Cell Value | Interpreted As |
|------------|----------------|
| "admin" | $ == "admin" |
| > 100 | $ > 100 |
| >= 18, < 65 | $ >= 18 and $ < 65 |
| [1..10] | $ >= 1 and $ <= 10 |
| "a", "b", "c" | $ == "a" or $ == "b" or $ == "c" |
| ["gold", "platinum"] | $ in ["gold", "platinum"] |
| `` (empty) or - | true (always matches) |
Switch Node
Conditional branching based on expressions.
{
"id": "switch-1",
"type": "switchNode",
"name": "Route",
"content": {
"statements": [
{ "id": "s1", "condition": "status == 'active'" },
{ "id": "s2", "condition": "status == 'pending'" },
{ "id": "s3", "condition": "" }
],
"hitPolicy": "first"
}
}Edges from switch nodes use sourceHandle to specify which branch they belong to (matching the statement id).
Decision Node
References external sub-decisions for modular composition.
{
"id": "sub-1",
"type": "decisionNode",
"name": "Calculate Shipping",
"content": {
"key": "shipping-rules.json"
}
}The loadDecision option in compile controls how sub-decisions are loaded. The
content.key above is passed verbatim to your loadDecision function; jdm-asm
does not read the filesystem for sub-decisions itself.
⚠️ Security: treat
content.keyas an untrusted path component. Sub-decision keys are part of the decision model, so they are model-controlled input. A filesystem-backed loader that resolves keys naively — for examplepath.join(root, key)— will follow../traversal and read files outside the intended directory when given a key like../../secrets.json. This is not a defect in jdm-asm (the library never touches the filesystem for sub-decisions); it is a responsibility of any consumer whose loader maps keys to files. A filesystem-backed loader must:
- Confine resolution to a fixed root directory, and
- Reject any key that escapes that root after resolution.
import path from 'node:path'; import { readFileSync } from 'node:fs'; function createSafeLoader(root: string) { const resolvedRoot = path.resolve(root); return (key: string) => { const target = path.resolve(resolvedRoot, key); // Reject anything that resolves outside the root (path traversal). const rel = path.relative(resolvedRoot, target); if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) { throw new Error(`Sub-decision key escapes root: ${key}`); } return JSON.parse(readFileSync(target, 'utf-8')); }; }The filesystem loader shipped under
tests/is test-only infrastructure that deliberately uses an unguardedpath.join(root, key); do not copy it into production consumers.
Function Node
Function nodes are explicitly unsupported by design. A function node embeds
arbitrary JavaScript that would need a JavaScript runtime at decision-evaluation
time; jdm-asm compiles decisions ahead of time to WebAssembly and has no such
runtime bridge, so the code could never execute. Rather than silently emitting a
placeholder that returns incorrect results, the compiler rejects any model
containing a function node with a clear UNSUPPORTED_FUNCTION_NODE
CompilationError. This applies on every compilation path — both the root
decision and any loaded sub-decision. Express the logic using expression,
decision table, or switch nodes instead.
Expression Language
jdm-asm supports the ZEN expression language:
Literals
42, 3.14, -5 // Numbers
"hello", 'world' // Strings
true, false // Booleans
null // Null
[1, 2, 3] // Arrays
{key: value} // ObjectsOperators
| Category | Operators |
|----------|-----------|
| Arithmetic | +, -, *, /, %, ^ (power) |
| Comparison | ==, !=, <, >, <=, >= |
| Strict equality | ===, !== (aliases of ==/!=; equality is strict/non-coercing) |
| Logical | and, or, not |
| Null coalescing | ?? |
| Membership | in, not in |
| Ternary | condition ? then : else |
Access Patterns
object.property // Member access
object["key"] // Bracket access
array[0] // Index access
nested.deeply.nested.value // Chained accessTemplate Literals
`Hello, ${name}!`
`Order ${id}: ${items.length} items totaling ${total}`Interval Notation (DMN-style)
[1..10] // 1 <= x <= 10 (inclusive)
(1..10) // 1 < x < 10 (exclusive)
[1..10) // 1 <= x < 10
(1..10] // 1 < x <= 10Built-in Functions
Array Functions
| Function | Description | Example |
|----------|-------------|---------|
| sum(array) | Sum numeric values | sum([1, 2, 3]) → 6 |
| avg(array) | Average of values | avg([1, 2, 3]) → 2 |
| min(array) | Minimum value | min([3, 1, 2]) → 1 |
| max(array) | Maximum value | max([3, 1, 2]) → 3 |
| count(array) | Array length | count([1, 2, 3]) → 3 |
| sort(array) | Sort ascending | sort([3, 1, 2]) → [1, 2, 3] |
| flat(array, depth?) | Flatten nested arrays | flat([[1], [2, 3]]) → [1, 2, 3] |
| contains(array, val) | Check membership | contains([1, 2], 2) → true |
Higher-Order Functions
| Function | Description | Example |
|----------|-------------|---------|
| filter(array, predicate) | Filter elements | filter(items, # > 5) |
| map(array, transform) | Transform elements | map(items, # * 2) |
| reduce(array, expr, init) | Reduce to value | reduce(nums, acc + #, 0) |
| all(array, predicate) | All match | all(scores, # >= 60) |
| some(array, predicate) | Any match | some(items, # == "gold") |
Note: Use # to reference the current array element in predicates.
String Functions
| Function | Description | Example |
|----------|-------------|---------|
| upper(str) | Uppercase | upper("hello") → "HELLO" |
| lower(str) | Lowercase | lower("HELLO") → "hello" |
| trim(str) | Remove whitespace | trim(" hi ") → "hi" |
| substring(str, start, end?) | Extract substring | substring("hello", 0, 2) → "he" |
| indexOf(str, search) | Find index | indexOf("hello", "l") → 2 |
| startsWith(str, prefix) | Check prefix | startsWith("hello", "he") → true |
| endsWith(str, suffix) | Check suffix | endsWith("hello", "lo") → true |
| split(str, delim) | Split to array | split("a,b,c", ",") → ["a", "b", "c"] |
| join(array, delim) | Join to string | join(["a", "b"], "-") → "a-b" |
| replace(str, find, repl) | Replace first | replace("hello", "l", "L") → "heLlo" |
| replaceAll(str, find, repl) | Replace all | replaceAll("hello", "l", "L") → "heLLo" |
| contains(str, substr) | Check substring | contains("hello", "ell") → true |
Math Functions
| Function | Description | Example |
|----------|-------------|---------|
| abs(num) | Absolute value | abs(-5) → 5 |
| floor(num) | Round down | floor(3.7) → 3 |
| ceil(num) | Round up | ceil(3.2) → 4 |
| round(num) | Round nearest | round(3.5) → 4 |
Date/Time Functions
| Function | Description | Example |
|----------|-------------|---------|
| date(str) | Parse ISO date to timestamp (seconds) | date("2025-03-20T10:30:00Z") |
| date("now") | Current timestamp | date("now") |
| time(str) | Parse time to seconds since midnight | time("10:30:00") → 37800 |
| duration(str) | Parse duration string | duration("24h") → 86400 |
Type Functions
| Function | Description | Example |
|----------|-------------|---------|
| number(str) | Parse to number | number("42") → 42 |
| string(val) | Convert to string | string(42) → "42" |
| keys(obj) | Get object keys | keys({a: 1}) → ["a"] |
| values(obj) | Get object values | values({a: 1}) → [1] |
Unknown functions are rejected at compile time. A call to a function that is
not a supported built-in (for example, a typo such as sbustring(...), or a
zero-argument call to a nonexistent name) fails compilation with an
UNKNOWN_FUNCTION CompilationError rather than silently evaluating to null.
Decision Table Hit Policies
| Policy | Behavior | zen-engine |
|--------|----------|------------|
| first | Return first matching rule | Yes |
| collect | Return all matches as array | Yes |
| unique | Return single match (error if multiple) | No |
| ruleOrder | Return all matches in rule definition order | No |
| outputOrder | Return all matches sorted by output value | No |
| priority | Return highest priority match | No |
Architecture
Compile Phase vs Execution Phase
jdm-asm operates in two distinct phases:
Compile Phase (~250ms-10s depending on complexity/hardware):
- Parse JDM JSON and validate structure
- Build dependency graph from nodes/edges
- Topologically sort for evaluation order
- Generate AssemblyScript code for each node
- Compile AssemblyScript to WebAssembly
- Generate JavaScript marshaling code
Execution Phase (~3-1000 microseconds):
- Validate input against schema
- Marshal JavaScript object to WASM linear memory
- Call WASM
evaluate()function - Unmarshal result from WASM memory to JavaScript
This separation enables high performance: expensive compilation happens once, while fast execution occurs many times.
Data Marshaling
Data flows between JavaScript and WASM via typed memory:
JavaScript WASM Linear Memory
────────── ──────────────────
{ name: "John", ──► [ValueMap]
age: 30, ├─ length: 2
tags: ["a","b"] } ├─ key0 → "name" (UTF-16)
├─ val0 → [STRING, ptr → "John"]
├─ key1 → "age"
├─ val1 → [FLOAT, 30.0]
├─ key2 → "tags"
└─ val2 → [ARRAY, ptr → [...]]Value Types (tagged union):
| Tag | Type | Storage | |-----|------|---------| | 0 | Null | Tag only | | 1 | Boolean | Tag + u8 | | 2 | Integer | Tag + i64 | | 3 | Float | Tag + f64 | | 4 | String | Tag + pointer to UTF-16 data | | 5 | Array | Tag + pointer to element array | | 6 | Object | Tag + pointer to ValueMap |
Memory Layout and Region Separation
A compiled module uses a single WebAssembly linear memory shared by three consumers, laid out by fixed-address convention:
0 ~50KB 192KB 512KB
├─ static ───┼─ AS managed ─────┼─ host input ───────┼─ custom output heap ─►
│ data │ (GC) heap ▲ │ (marshal buffer) │ (bump allocator)
│ │ grows upward │ up to 320KB │ grows upward- Static data ends at
__heap_base(the AssemblyScript-provided offset). - The managed (GC) heap grows upward from
__heap_base. Deserializing input allocates managedValue/Map/Array/string objects here. - The host input / marshal buffer starts at 192KB. The JS marshal allocator rejects serialized inputs larger than 320KB (it would otherwise cross into the custom output heap at 512KB).
- The custom output heap (a bump allocator) starts at 512KB.
These boundaries are a layout convention, not a hard partition: the
incremental GC's allocator claims all free memory above __heap_base and is not
told about the marshal/output regions. Separation is therefore enforced to
different degrees (PERF-001):
Enforced (static boundary). The runtime exports
getHeapBase(), and both the WASM entry point andCompiledDecisionverify at instantiation that__heap_basesits below the 192KB marshal region. A module whose static data reached the input region — e.g. a decision table with an enormous number of string constants — fails closed deterministically instead of silently marshaling input over live static data.Best-effort (dynamic boundary). Managed allocations made while deserializing a near-limit input could in principle grow the GC heap past the 192KB/512KB boundaries into the marshal or output region. This is bounded but not eliminated by the 320KB input cap and the module's
--maximumMemoryceiling. This is a single-tenant robustness edge (there is no other tenant's memory to reach), not a sandbox-isolation concern. For very large inputs combined with large intermediate value graphs, run evaluation in an isolated worker with your own resource limits (see Runtime Input Limits below).This is a deliberate, accepted trade-off, not an unaddressed gap. The runtime ABI is single-tenant and non-reentrant (one evaluation per instance), so the only bytes a runaway GC heap could overwrite belong to that same evaluation. There is no cross-tenant isolation to breach, so the worst case is a corrupt result for the caller who supplied the oversized input — a condition the input-size cap and
--maximumMemoryceiling already bound. Adding a per-allocation region guard would tax the evaluation hot path on every allocation for no isolation benefit, which is why only the static boundary (a single, cheap instantiation-time check) is enforced. If you need parallel or mutually isolated evaluations, use a separate compiled instance per worker; a WASM instance's linear memory is already isolated from every other instance.
Security: Generated Code Trust Boundary
A CompilationResult is executable code, not inert data. Its marshalCode
and validationCode fields are plain JavaScript source strings, and the public
API executes them via the Function constructor:
createCompiledDecision(result)runs bothmarshalCodeandvalidationCode.- The re-exported
loadGeneratedMarshaling(marshalCode)loader runsmarshalCode. - The re-exported
loadGeneratedValidation(validationCode)loader runsvalidationCode.
This is a deliberate design choice (generated code is emitted as source strings
rather than opaque closures), which means a CompilationResult carries the same
trust level as the JDM and schemas you compiled it from. Executing one is
equivalent to running trusted program input inside your process.
Trust expectations:
- Only compile trusted JDM. The contents of
marshalCode/validationCodeare derived from the JDM and schemas passed tocompile(). Compiling attacker controlled JDM and then executing the result runs attacker-influenced code. - Never source a
CompilationResultfrom an untrusted boundary. Do not build aCompilationResult(or populate itsmarshalCode/validationCodefields) from network request bodies, user uploads, third-party storage, or any other untrusted input, and then execute it. - Never forward across a trust boundary without re-validation. If a
CompilationResultcrosses a process/service boundary, treat the receiving side as executing untrusted code unless the source is independently trusted.
In short: pass to createCompiledDecision and the loader functions only results
produced by your own compile() call on trusted inputs.
Artifact coupling — treat the outputs as one unit.
A CompilationResult's marshalCode, validationCode, outputValidationCode,
and wasm fields are a single mutually dependent unit emitted by one
compile() call (typically shipped together, e.g. as a WASM bundle). They must
be stored, transported, and executed together and never mixed across
compilations or distributed separately:
- The marshaling code encodes the exact memory layout the
wasmbinary expects. - The validators enforce the schemas that layout was generated for.
Combining artifacts from different compilations can silently marshal or interpret data under the wrong contract.
Schema-hash verification only partially guards this:
- Only
marshalCodeembeds aSCHEMA_HASH.createCompiledDecisioncompares it against the result's declaredschemaHashand fails closed on mismatch. The generated validators carry no embedded identity of their own. schemaHashbinds the input/output schemas only. It does not identify or verify the compiled decision logic (thewasmbinary): two compilations with identical schemas but different decision tables/expressions produce the same hash. It is a schema-identity check, not a whole-artifact integrity check, and cannot detect awasmfield swapped for one built from different logic under the same schemas.- The embedded-hash check is skipped when
marshalCodecarries noSCHEMA_HASH(it reads asundefined), for backward compatibility with artifacts generated before schema hashing existed.
Because the hash does not cover the WASM decision logic, keeping the artifacts
together is the contract that guarantees integrity. Do not rely on schemaHash
alone to detect a mismatched or substituted binary.
Security: Trusted Compilation Inputs
Decision models passed to compile() (the JDM graph, its expressions, and the
input/output schemas) are treated as trusted program inputs, in the same
sense that source code fed to a compiler is trusted. The compiler is a build-time
tool, not a sandbox for hostile input.
Concretely, compile() and the parsing/graph/codegen passes it drives
(parseJDM, parseExpression, buildGraph, and AssemblyScript compilation)
do not impose limits on, or otherwise sandbox against, adversarial models:
- No cap on model byte size, node count, edge count, or decision-table rule count.
- No cap on expression length or nesting depth.
- No cap on graph depth or sub-decision fan-out.
- The parser, AST walkers, graph builder, and code generator recurse in proportion to model structure, so a pathologically deep or large model can drive long compile times or deep recursion.
This is a deliberate design choice consistent with the generated-code trust boundary: a model you are willing to compile is a model you already trust to run.
Caller responsibilities:
- Only compile models you trust. Do not feed
compile()JDM, expressions, or schemas sourced directly from an untrusted boundary (network request bodies, user uploads, third-party storage, etc.). - If you must compile externally supplied models, isolate the compile step
yourself. Enforce your own size/complexity limits before calling
compile(), and run compilation under appropriate resource bounds (separate process, timeouts, memory/CPU limits) that you control.
Compilation is a one-time, offline-style cost; the runtime evaluate() path is
unaffected by this assumption because it executes already-compiled, bounded WASM.
Security: Runtime Input Limits and Resource Budgets
The runtime evaluate() path does not bound input depth, input size, or
per-evaluation work. This is a deliberate design tradeoff: enforcing
performance and resource budgets is the responsibility of the host application
that evaluates compiled policies, not of this library.
Concretely, the marshaling, WASM deserialization, and output unflattening paths:
- Recurse over nested structure with no depth budget. Input marshaling
(
marshalCode), the WASM-side Value deserialization, and output unflattening all recurse in proportion to the nesting depth of the payload. A sufficiently deeply nested input or output can exhaust the host (JavaScript) call stack or the WASM call stack. - Do not detect cycles. Cyclic input (an object or array that references itself) is unsupported; it will recurse until the stack overflows rather than being detected and rejected. Only acyclic (tree-shaped) data is supported.
- Impose no per-evaluation quotas. There is no cap on serialized input byte size, array/object element counts, output size, or total evaluation work. The only bound is the coarse compiled WASM memory maximum, so a large but schema-valid payload can consume the whole module memory budget.
Valid, acyclic, reasonably sized payloads — including deeply-nested-but-finite structures — marshal, evaluate, and unmarshal correctly. The absence of limits is about adversarial or pathological inputs, not everyday nested data.
Caller responsibilities:
- Bound input size and nesting depth before calling
evaluate(). Reject or truncate untrusted payloads that exceed limits you choose (maximum serialized size, maximum nesting depth, maximum array/object element counts). - Never pass cyclic data to
evaluate(). Break cycles first, or reject the input. - Enforce timeouts and resource bounds you control. Run evaluation under appropriate wall-clock timeouts and memory limits (for example in a worker with a watchdog) so a single large evaluation cannot monopolize resources.
Performance
Benchmark Results
How to read these numbers. They were regenerated from the benchmark harness in this repository (
npm run bench), which measures jdm-asm against its public execution contract and reports three clearly separated, never-conflated metrics:
jdm-asm-raw— raw-WASM hot path only (marshal + evaluate + unmarshal on a pre-instantiated module, no input validation). A theoretical ceiling, not the shipped contract.jdm-asm-e2e— the public API path a consumer actually uses (input validation + marshal + evaluate + runtime error checking + unmarshal). This is the number to compare against zen-engine for single-process use.jdm-asm-ipc— worker round-trip from the main thread, including cross-thread (structured-clone) serialization of input and output.Batch benchmarks cycle through several distinct, deterministic input variants (not one repeated object) and are measured at two batch sizes. Numbers vary by hardware, runtime, load, and — for the IPC path — the number of worker threads actually spawned. Treat them as indicative, and re-run
npm run benchon your own hardware for decisions that depend on them.
Recorded configuration
| Property | Value |
|----------|-------|
| Base commit | c7d93f6 (plus the benchmark-harness changes in this change set) |
| CPU | Apple M4 Pro (14 logical cores) |
| Runtime | Node.js 26.3.1 (arm64-darwin) |
| Comparison engine | @gorules/zen-engine 0.51.5 |
| Worker threads | 2 total (1 worker × 2 pools). On this host the default worker budget resolved to 1 (memory-aware limit freemem / 256 MiB), so each of the two viable scenario pools received a single worker. |
| Batch sizes | 250 and 1000 decisions, cycling distinct input variants |
| Scenarios measured | Simple (merch-bags, exact-match rules) and Moderate (shipping, computed cart + tags) |
Complex (~8000-rule) scenario omitted. On this configuration the harness skips the Complex scenario during warmup: its evaluated output does not satisfy the benchmark's declared
ComplexOutputSchema(a requireddiscountfield is absent), so the harness transparently drops it rather than reporting a fabricated result. Earlier "Complex" and "16-thread" rows are therefore not reproducible from the current harness and have been removed. If you need large-table numbers, fix the Complex fixture/schema pairing and re-run.
Single-Decision Latency (single-threaded)
Per-call latency with no batching or concurrency. jdm-asm-e2e is the shipped
public path; jdm-asm-raw is the no-validation ceiling.
| Scenario | jdm-asm-e2e (avg) | jdm-asm-raw (avg) | zen-engine (avg) | e2e speedup | |----------|-------------------|-------------------|------------------|-------------| | Simple | 3.0 µs | 3.2 µs | 32.0 µs | ~10x | | Moderate | 6.5 µs | 6.3 µs | 34.3 µs | ~5.3x |
For typical decision tables (5-50 rules), expect sub-10 microsecond single-threaded execution times.
Single-Decision Latency Percentiles
jdm-asm-e2e (public path), with zen-engine for reference:
| Scenario | jdm-asm-e2e p50 | jdm-asm-e2e p99 | zen-engine p50 | zen-engine p99 | |----------|-----------------|-----------------|----------------|----------------| | Simple | 3.0 µs | 3.1 µs | 28.4 µs | 58.6 µs | | Moderate | 6.5 µs | 6.6 µs | 31.8 µs | 63.6 µs |
Single-Decision Throughput (decisions/second)
| Scenario | zen-engine | jdm-asm-raw | jdm-asm-e2e | |----------|------------|-------------|-------------| | Simple | 32.0K | 313.4K | 331.9K | | Moderate | 29.2K | 160.0K | 154.2K |
Single-Thread Batch Throughput (public path, varied inputs)
Batches evaluated on the main thread via the public evaluate path, cycling
through distinct input variants.
| Batch size | Scenario | zen-engine | jdm-asm-e2e | speedup | |------------|----------|------------|-------------|---------| | 250 | Simple | 151.1K | 306.8K | 2.0x | | 250 | Moderate | 125.0K | 133.2K | 1.1x | | 1000 | Simple | 147.4K | 304.6K | 2.1x | | 1000 | Moderate | 118.6K | 135.3K | 1.1x |
Multi-Thread (IPC) Batch Throughput (2 worker threads total, varied inputs)
Worker round-trip throughput including cross-thread serialization. With only two worker threads available on this host, the IPC path does not universally beat in-process zen-engine: for the cheap Moderate decision the serialization overhead makes it slower. Throughput here scales with the number of workers actually spawned (see the recorded configuration).
| Batch size | Scenario | zen-engine | jdm-asm-ipc | ratio (jdm-asm ÷ zen) | |------------|----------|------------|-------------|-----------------------| | 250 | Simple | 141.0K | 147.1K | 1.04x | | 250 | Moderate | 118.4K | 85.2K | 0.72x (slower) | | 1000 | Simple | 138.8K | 155.5K | 1.12x | | 1000 | Moderate | 118.5K | 85.5K | 0.72x (slower) |
Multi-Thread IPC Round-Trip Latency (main-thread perspective)
p50 / p95 / p99 over the 1000-decision batch, 2 worker threads total:
| Scenario | jdm-asm-ipc | zen-engine | |----------|-------------|------------| | Simple | 2.5 ms / 4.9 ms / 5.3 ms | 3.6 ms / 5.4 ms / 5.9 ms | | Moderate | 4.8 ms / 9.3 ms / 9.8 ms | 4.3 ms / 6.8 ms / 7.8 ms |
Compilation Time
| Scenario | WASM Size | Compile Time | |----------|-----------|--------------| | Simple | 34 KB | 1.3s | | Moderate | 56 KB | 0.7s |
When to Use jdm-asm
Best for:
- Ultra-low latency requirements (sub-10 µs for typical decisions)
- High-throughput APIs evaluating hundreds of thousands of decisions per second
- Latency-sensitive applications where p99 matters
- Simple to moderate complexity rules (5-50 rules)
- Cases where rules change infrequently (amortize compilation cost)
Consider zen-engine when:
- Rules change frequently (compilation cost is high)
- You need function nodes / custom JavaScript logic (unsupported by jdm-asm, see below)
- Quick prototyping without schema definitions
Compatibility with zen-engine
jdm-asm is designed as a drop-in replacement for zen-engine with some differences:
Fully Supported:
- All node types (input, output, expression, decision table, switch, decision)
- Expression language (operators, functions, access patterns)
- Decision table hit policies:
first,collect - Sub-decision composition
Extended Features (not in zen-engine):
- DMN-standard hit policies:
unique,ruleOrder,outputOrder,priority - Compile-time optimization
- Parallel execution via per-worker decision instances
Limitations:
- Function nodes: Explicitly unsupported by design. jdm-asm compiles decisions
ahead of time to WebAssembly and has no JavaScript runtime bridge, so the arbitrary
JavaScript in a function node could never be executed. Compiling any model that
contains a function node fails with an
UNSUPPORTED_FUNCTION_NODECompilationError. Express the logic using expression, decision table, or switch nodes instead. - Division by zero: Returns
nullinstead ofInfinity - Full ISO 8601 durations: Simple formats (
24h,30d) work; complex format (P1Y2M3D) not supported
Development
# Install dependencies
npm install
# Build TypeScript
npm run build
# Build AssemblyScript runtime
npm run asbuild
# Run tests
npm run test
# Run benchmarks
npm run bench
# Lint code
npm run lint
# Type check
npx tsc --noEmit
# Run tests with coverage
npm run test:coverageTesting and Coverage
Code coverage is collected via the V8 provider (@vitest/coverage-v8) and
covers src/**/*.ts. The src/runtime/** directory is intentionally
excluded from coverage: the AssemblyScript runtime is compiled to
WebAssembly and executed as WASM rather than run directly as TypeScript under
V8, so the coverage tooling cannot observe its execution. Including it would
falsely report near-zero coverage for code that is actually exercised.
The runtime's behavior is instead validated behaviorally by the WASM
integration/execution test corpus in tests/integration, which compiles JDM
models and runs the resulting WebAssembly end-to-end. Coverage is
informational only — no coverage thresholds are configured, so a coverage run
never fails the build on numeric grounds.
Project Structure
jdm-asm/
├── src/
│ ├── compiler/ # TypeScript compiler code
│ └── runtime/ # AssemblyScript runtime
├── tests/
│ ├── unit/ # Unit tests
│ ├── integration/ # Integration tests
│ └── helpers/ # Test utilities
├── test-data/ # Ported zen-engine test fixtures
├── benchmarks/ # Performance benchmarks
├── build/ # Compiled WASM output
└── dist/ # Built npm packageLicense
MIT License - see LICENSE for details.
