Reactive engine internals
How the runtime turns a compiled model + a mutation into settled, derived, constraint-checked state. This is the as-built internal reference for contributors.
1. Dependency graph
Node kinds
- BASE — a path mutated directly by callers (e.g.
$.order.subtotal). Not owned by a derivation. - DERIVED — a path computed by a derivation expression (e.g.
$.order.total). Cannot besetValue()d. - META — a
path#propertykey such as$.loan.principal#maximum, computed by a meta derivation. - Synthetic —
$constraint:<id>and$effect:<id>nodes, registered so they become dirty when their inputs change.
Construction (ModelSpecCompiler)
For each derivation {path, expr}: ExpressionPathExtractor.extract(expr) walks the JSONata AST
to collect all $.-prefixed references Ri; for each, graph.addEdge(Ri, targetPath)
(“targetPath depends on Ri”); targetPath is registered DERIVED. Meta derivations use
nodeKey = path + "#" + property.toLowerCase() (META). Constraints/effects register
$constraint:id / $effect:id with edges from every referenced path (an effect’s edges come from its
trigger + dedupeKey + prompt/at/afterMs/requests expressions).
Soundness invariant — dependencies must be statically extractable
Incremental evaluation is only correct if every input a derivation/constraint reads at runtime
is also an edge in the graph. ExpressionPathExtractor discovers edges by static analysis of the
JSONata AST, so the reactive guarantee holds only for statically-resolvable references
($.a.b, $.items[*].x). Constructs whose effective path is computed at evaluation time — dynamic
key lookup ($lookup($, $someKey)), fully data-driven navigation, indices derived from other
fields — are not seen by the extractor. Such an expression can read a field with no corresponding
edge, so a change to that field will not mark the derivation dirty and its value can go silently
stale. This is unsupported. ModelSpecValidator makes a best-effort attempt to flag expressions
that reference no extractable path (or use known dynamic-lookup forms) so authors get a signal
instead of silent staleness; when in doubt, restructure to use static paths.
Cycle detection
Kahn’s topological sort: if processed nodes < total nodes, a cycle exists.
DependencyGraph.CyclicDependencyException reports the involved keys; ModelSpecValidator
surfaces it as a validation error.
Structures (actual)
class CompiledModel {
ModelSpec spec;
DependencyGraph graph; // nodes, edges, evaluationOrder, evaluationLevels
Map<String, DerivationSpec> derivationByPath;
Map<String, MetaDerivationSpec> metaDerivationByKey;
List<ConstraintSpec> constraints; // effects via spec.effects()
}
class DependencyGraph {
Map<String, NodeInfo> nodes; // key → {key, NodeKind}
Map<String, Set<String>> dependents; // key → downstream
Map<String, Set<String>> dependencies; // key → upstream
List<String> evaluationOrder; // topological (sources first)
// evaluationLevels(): List<List<String>> grouped by depth = 1 + max-predecessor-depth
}
2. Model state
class ModelState {
ObjectNode baseDoc; // mutable base state
Map<String, JsonNode> derivedCache; // "$.order.total" → value
Map<String, JsonNode> metaCache; // "$.loan.principal#maximum" → value
Set<String> dirtyPaths;
Snapshot transactionSnapshot; // non-null while a transaction is open
}
getValue(path)checksderivedCachefirst, thenbaseDoc.at(JsonPointer).mergedDocument()deep-copiesbaseDocand splices allderivedCachevalues in — used by constraint and effect evaluators so they can reference derived fields. A mutation cycle now performs exactly one full materialization:DerivationEvaluator.evaluateAndMergebuilds one merged document on the first level with work and carries it forward, splicing each level’s results in viaModelState.spliceDerivedbefore the next level reads it (instead of re-deep-copying per level). It hands that same document back toModelRuntime.mutate, which reuses it for the meta-derivation, constraint, and effect phases (all take a merged-doc overload). So a cycle no longer pays one deep copy per level, one per global constraint, and one for effects — it pays one, total (zero if no derivations ran, in which case the phases share a single lazily built copy). The splice is length-aware (setDerivedInDoc): it never grows an array to fit a derived index, so a stale entry such as$.items[2].lineTotalleft after the array shrank is skipped instead of reappearing as a phantom element — no separate stale-entry clear pass is needed.- Transaction model:
beginTransaction()takes aSnapshot;commit()discards it;rollback()callsrestore(snapshot). withModel(newCompiledModel)carries forwardbaseDoc, a filteredderivedCache(only paths still derived in the new model), andmetaCache— used by spec evolution.
record Snapshot(String modelId, String modelVersion,
ObjectNode baseDoc, // deep copy
Map<String, JsonNode> derivedCache, // unmodifiable copy
Map<String, JsonNode> metaCache) {} // no timestamp; blobs excluded
3. Reactive algorithm (ModelRuntime.mutate)
mutate(model, state, mutations): // mutations = Map<JsonPath, JsonNode>
0. SchemaValidator pre-checks each mutation against the effective schema (422 before any tx)
1. state.beginTransaction() // rollback snapshot
2. for (path, value) in mutations:
assert model.derivationFor(path) == null // derived fields are read-only
state.setValue(path, value); dirtyPaths.add(path)
3. dirty = BFS over graph from dirtyPaths
+ wildcard pattern matching: for each "[*]" node, DirtyPropagator.matchesPattern(node, path)
4. for nodeKey in evaluationOrder where DERIVED and dirty: // EAGER only
result = JSONata(expr) against the carried-forward merged document; write derivedCache
(one merged doc per cycle; prior-level results spliced in before this level reads it)
(LAZY derivations marked stale, evaluated on demand)
5. for nodeKey in evaluationOrder where META and dirty: // per-element for [*]
write metaCache
6. for constraint where "$constraint:id" in dirty:
global → eval against mergedDocument(); scoped → against field value
ROLLBACK → collect; FLAG → record
if any ROLLBACK: state.rollback(); throw ConstraintViolationException
7. for effect where "$effect:id" in dirty:
if trigger(mergedDocument()) == true and not guarded: emit EffectRequest (data, no I/O)
8. state.commit(); clearDirty(); fire effectSink post-commit (shell executes async, folds back
as a later mutation via keyed CAS); append traces to ring buffer
Evaluation-context rules
- Derivation
expr→ a level-aware view of the carried-forward merged document: base fields + all derivations from prior topological levels. Same-level derivations cannot see each other (their results are spliced in only after the whole level finishes). Wildcard expressions also receive$parentbound to the current array element. - Meta derivation
expr→ the same per-cycle merged document for whole-document properties; the element object for per-element ([*]) properties. - Constraint (global) and effect
trigger/dedupeKey→ the same per-cycle merged document (derived values visible).
Level-ordered evaluation
DerivationEvaluator.evaluateAndMerge groups nodes by evaluationLevels(). It materializes the
merged document once (on the first level with dirty EAGER work) and carries it forward: every
node in a level reads the pre-level view, then the level’s results are written back to derivedCache
and spliced into the shared merged document (ModelState.spliceDerived) so the next level sees
them. Within a level, derivations are evaluated sequentially (order is irrelevant — same-level
nodes are mutually independent by construction). The final merged document is returned to
ModelRuntime.mutate and reused by the meta/constraint/effect phases — one full deep copy per cycle.
Intra-level virtual-thread parallelism was removed: the per-task executor/future overhead dominated
the actual per-expression cost, and the real hot-path cost was the merged-document deep copy (now
amortized to one per cycle), not the lack of parallelism.
Concurrency model and scaling ceiling
A single model serializes all mutations under one lock (synchronized(runtime) in
ModelService), so there is no intra-model parallelism by design — it is what makes the reactive
pipeline deterministic. Scale horizontally by running many independent models; a single hot or
shared model is a throughput ceiling. The mutation queue (valem.mutation-queue-size) bounds
in-flight mutations per model and returns 429 on overflow. See
../deployment/operations.md for the operational
guidance.
4. Explainability trace
record DerivationTrace(
String targetPath, // "$.order.total" or "$constraint:id"
String expression,
List<String> inputPaths, // paths referenced (not values)
JsonNode result, // value for derivations; null for constraints
Boolean constraintPassed, // true/false for constraints; null for derivations
String errorMessage) // set when evaluation threw
Both derivation and constraint evaluations write to one ring buffer (size 500) in
ModelRuntime.traceLog; oldest entries drop when full; cleared on explicit restore().
ModelRuntime.explain(prefix) returns traces whose targetPath starts with prefix.
Exposed via GET /models/{id}/explain/{path}.
5. Technology stack
| Concern | Choice |
|---|---|
| Language | Java 21 (records, sealed interfaces, pattern matching) |
| JSON model | Jackson JsonNode / ObjectNode; JsonPointer navigation |
| Expression evaluator | JSONata2Java (org.json_kula.jsonata_jvm) — pre-compiles to bytecode; ExpressionCache memoises per runtime |
| AST walker | ExpressionPathExtractor — extracts JsonPath dependencies |
| JsonPath search / wildcards | org.json_kula.tracked_json (JsonPathSearch, TrackedJsonNode) |
| JSON Patch (RFC 6902) | org.json_kula.tracked_json.json_patch.JsonPatch (compile().apply()) |
| Web framework | Spring Boot 3 — REST + bare WebSocket |
| Build | Maven multi-module |
Path notation: spec/API use JsonPath ($.order.items[*].qty); the runtime converts to
JsonPointer via PathConverter. JSONata expr fields use JSONata’s own dot-notation
(order.total) and are never rewritten.
Related
- Architecture overview — component map and data flow.
- The reactive pipeline — the same cycle, explained rather than specified.
- Model spec format — what the compiler consumes.