Skip to content
10 min read · 1,936 words

Predicates & Cells

A branch or select node carries a predicate. The node's evaluator field names which cell interprets it. The core plan IR refuses to harbor opinions about expression dialects: that concern belongs entirely to the cell that evaluates it, so syntax errors and dialect mismatches are diagnosed directly by the subsystem that understands the grammar.

typescript
export interface PredicateEvaluator {
  readonly id: string
  load(): Promise<void>
  validate(node: PlanNode): Promise<void>
  evaluate(node: PlanNode, ctx: PredicateContext): Promise<PredicateVerdict>
}

Every method is async. That is deliberate, not ceremonial. A synchronous isAvailable(): boolean cannot determine whether an optional ESM peer resolves through a dynamic await import(). Module resolution failure in modern JavaScript is an asynchronous event; a synchronous probe could do nothing better than guess from ambient environment hints. validate is async for the identical reason: the Lua cell cannot verify that a chunk compiles before its WebAssembly VM has finished loading.

Nothing is wired by default. If a plan contains a branch or select referencing an unwired evaluator, freeze refuses the plan immediately and names the offending node. There is no fallback interpreter, no silent skipping of unhandled branches, and no mid-run surprise twenty minutes into execution. Plan freeze also awaits load() across every registered cell, dragging missing peer dependencies into the daylight before a single node runs.

Two rules every cell obeys

The readable context is pre-marshalled plain data, never live runtime objects with reachable methods. Predicates read a bounded, frozen snapshot of state. They cannot trigger an accessor, leak execution into arbitrary host getters, or get poisoned by a prototype chain.

No clock, no randomness. In predicate evaluation, determinism is not an academic purity test—it is a runtime operational requirement. Evaluating the exact same predicate against the exact same data snapshot must yield the exact same verdict every single time. That absolute idempotence is the sole reason branch and select nodes can be re-entered unconditionally when an interrupted execution resumes.

Then there is the invariant that keeps your pipeline alive: a predicate is never allowed to crash a run. When an evaluator faults—whether from a malformed path, a type mismatch, or an unexpected runtime condition—it emits a negative verdict. A branch routes to no_match; a select routes to its default handle. It does not throw an unhandled exception, it does not mark the node failed, and it does not blow up an execution graph that an operator already approved.

structured — the default

Zero dependencies, no external parser, no guest runtime, terminating by mathematical construction, and by a wide margin the representation that small language models generate with the fewest hallucinations.

typescript
import { createStructuredCell } from '@nhtio/adk/batteries/orchestration/cells/structured'

// branch
{ path: 'list_files:e2:e0.count', op: 'gt', value: 0 }

// composed
{ all: [
  { path: 'report:e2:e0.status', op: 'eq', value: 'ready' },
  { not: { path: 'report:e2:e0.errors', op: 'truthy' } },
]}

The operator set is strictly closedeq, ne, lt, lte, gt, gte, in, contains, truthy, exists. That boundary is immovable; an author or LLM cannot invent operators and expect the engine to guess what they meant. truthy and exists are strictly unary and take no value.

A select node requires an n-way decision that a single scalar boolean cannot represent. Its predicate is therefore a record mapping every declared case label to its own predicate. The first matching case wins, evaluated strictly in the sequence declared by cases:

typescript
{
  evaluator: 'structured',
  cases: ['green', 'amber', 'red'],
  predicate: {
    green: { path: 'check:e2:e0.status', op: 'eq', value: 'green' },
    amber: { path: 'check:e2:e0.status', op: 'eq', value: 'amber' },
    red:   { path: 'check:e2:e0.status', op: 'eq', value: 'red' },
  },
}

Precedence follows the author's explicit cases array, not whatever order JavaScript happens to iterate object keys. Overlapping predicates resolve deterministically or not at all. At plan freeze, validate() unconditionally rejects non-record predicates and any declared case lacking a matching entry.

Path resolution defends every path segment against __proto__, prototype, and constructor. Checking solely at the root is useless: a nested reach walks straight through a root-only guard unless every step is verified.

jexl — expressions

An expression source string, parsed and executed by a discrete AST interpreter — never eval().

typescript
import { createJexlCell } from '@nhtio/adk/batteries/orchestration/cells/jexl'

'list_files.count > 0'
'items[.age >= 30][0].name == "ada"'
'(status == "ready" ? "go" : "wait")'

Output inspection reaches values by bare node id. But when a node has executed across multiple branches, that bare identifier becomes ambiguous. The evaluator refuses to pick an arbitrary winner or silently grab the last execution: the bare identifier simply does not exist in the scope. You must target the exact table key:

typescript
'outputs["list_files:e2:e0e2:e1"].count > 0'

jexl is expression-only by architecture: no statements, no variable assignments, no loops, and no function declarations. It is structurally non-Turing-complete, which is the exact property that makes it safe to run without a background watchdog thread. If you extend this cell, preserve that property or you destroy its entire security model.

We are about to state an opinion

Non-terminating is not the same as instant, and we are not going to blur the two. Structural non-Turing-completeness rules out a predicate that never returns. It does not rule out a predicate that takes a long time because the DATA is large: a collection filter is linear in the collection, so evaluation cost scales with whatever a call node returned.

Nothing caps that. maxEncodedBytes bounds the PLAN — the staged arguments an operator approves — not a tool's runtime output, and the predicate reads the output. Measured on this cell: a filter over a 200,000-element array evaluates in roughly 100ms, so ordinary data is nowhere near a problem. A pathological output is a different question, and the honest answer is that the bound is yours: if a tool can return unbounded data, cap it in your CallInvokerFn rather than assuming the predicate layer will absorb it.

The structured cell has the same property for the same reason. The Lua cell is the only one with a real instruction-and-memory ceiling — and the way it enforces the last-resort timeout is severe enough to have its own warning below.

Piped transforms (|) are strictly host-registered. You govern the callable surface down to the individual function. If you pass no transforms—the default configuration—the pipe operator resolves absolutely nothing:

typescript
createJexlCell({ transforms: { upper: (v) => String(v).toUpperCase() } })

The cell carries its own dialect linter. The two blunders LLMs repeatedly make are caught and diagnosed by name with explicit remediation, rather than thrown as cryptic parser syntax errors: using === instead of jexl's ==, and prefixing identifiers with ctx. when identifiers in jexl are already top-level scope.

We are about to state an opinion

jexl was last published on 2022-06-19. Treat that as stable, not abandoned—a closed expression grammar rarely needs code churn. But it is an unmoving, frozen dependency, and you should evaluate it with open eyes rather than assuming active upstream development.

lua — Node only

A sandboxed Lua chunk, reserved for the complex predicates that declarative structures and simple expressions genuinely cannot express.

typescript
import { createLuaCell } from '@nhtio/adk/batteries/orchestration/cells/lua'

"return ctx['list_files:e2:e0'][1].json.count > 0"

Context data is indexed directly by exact table key. For a select node, the Lua chunk returns the winning case label as a string, or nil to signal fallback to default.

The Lua sandbox is constructed strictly by allowlist with openStandardLibs: false. Nothing exists inside the VM unless explicitly provisioned. Global primitives like _G, getfenv, getmetatable, load, and dofile, along with standard library modules like io and os, are never injected. They are physically absent by construction. A script attempting to touch any of them triggers a Lua runtime fault, which the cell translates into a negative verdict instead of an unhandled host process exception.

We are about to state an opinion

wasmoon's instruction count hook and allocator ceiling are undocumented at its TypeScript surface. We do not pretend otherwise. The cell executes runtime canary probes during construction to verify whether those hooks actually bind. If a canary probe fails, the cell degrades gracefully to watchdog-only termination and advertises the reduced security boundary via status():

typescript
const cell = createLuaCell()
await cell.load()
cell.status()  // { guarantee, instructionLimit, memoryCeilingBytes, timeoutMs }

Inspect that status instead of hallucinating full VM-level enforcement. A library claiming security guarantees it cannot actually enforce in its host runtime is worse than useless; it is an active hazard. We report reality.

This cell is strictly Node-only. It relies directly on worker_threads and OS-level SIGKILL termination to enforce timeouts — and see the warning below for exactly what gets killed. Because of this, it is isolated in its own deep subpath and is deliberately excluded from the environment-neutral package barrel. That physical separation is the only reason browser and edge runtimes can import the orchestration battery without crashing their bundlers.

Why none of them is JavaScript

The obvious fourth cell is the one that isn't here. You are writing TypeScript, the runtime is already a JavaScript engine, and a predicate could just be a function. So the absence needs an argument, not a shrug.

Running JavaScript inside JavaScript is the one option with no preemption story at all. Every other cell can be stopped. The structured cell terminates by construction — it is a finite boolean tree with no loops to run away. jexl is expression-only, so there is no syntax for a loop to write. The Lua cell has a watchdog that can SIGKILL on expiry — at a cost spelled out above.

An in-process JavaScript guest has none of that. This repo already ships an SES-based evaluator, and its kill() is a documented no-op:

ts
async kill(): Promise<void> {
  /* Compartment cannot be forcibly killed; production adapters must use a worker. */
}

That is not an oversight to be patched. A Promise.race against a deadline reports a timeout while the offending code keeps running; for while (true) {} there is nothing to preempt and nothing to kill. A timeout you cannot enforce is a timeout you should not advertise.

There is a second cost, and it reaches beyond the predicate. SES is process-global in Node: the first guest bootstrap hardens the realm, and every later evaluation must verify the already-hardened realm rather than lock down again. Hardening intrinsics for the entire host process is a large, permanent side effect for the sake of evaluating count > 0.

We are about to state an opinion

A JavaScript cell is buildable — a worker thread with an external kill, exactly as the Lua cell does it. What it would not be is cheap, and it would buy remarkably little: the structured cell already covers the overwhelming majority of real predicates with zero dependencies and zero sandbox surface, and jexl covers most of the rest.

So the absence is a decision, not a gap. If you need one, the Lua cell is the worked example of how to do it responsibly — and the honest reading of that cell's status() reporting is how much work "responsibly" actually is.

We are about to state an opinion

The Lua watchdog's last resort is SIGKILL on the HOST PROCESS, and you need to know that before you wire this cell.

wasmoon is WebAssembly, so the Lua VM runs in-process, on the main thread. The watchdog is a worker thread, but the thing it kills is process.pid — your process. Not the evaluator, not the worker: the host.

That is deliberate, and the reasoning holds as far as it goes: a synchronous WASM loop owns the main thread, so worker.terminate() has nothing to terminate and process.exit() never runs. The only thing that breaks such a loop is the OS killing the process. A timeout you cannot enforce is not a timeout, and this cell would rather enforce it violently than advertise one it cannot deliver.

Be clear about what you are accepting: a non-terminating Lua predicate takes your whole process down with it. The instruction-count hook and allocator cap normally stop a runaway long before the deadline — the watchdog is the last resort, not the first — and if the construction canaries fail those probes, status() reports the reduced guarantee and the watchdog is all you have.

If a process-wide kill is unacceptable in your deployment, do not wire this cell for untrusted predicates. Use structured, which cannot loop at all.

Let us be completely explicit: none of these cells makes evaluating untrusted code intrinsically safe. Outside the mathematically bounded structured cell, no sandbox is an acceptable substitute for refusing to evaluate untrusted input in the first place.