---
url: 'https://adk.nht.io/batteries/orchestration/predicates.md'
description: >-
  The evaluator seam and the three cells that implement it — zero-dependency,
  expression-only, and sandboxed Lua — with the honest limits of each.
---

# Predicates & Cells

## LLM summary — orchestration predicates

* `PredicateEvaluator`: `readonly id`, `load()`, `validate(node)`, `evaluate(node, ctx)` — **every method async**.
* `PredicateContext` = `{outputs: OutputTable, frame: FrameRef}`. `PredicateVerdict` = `{kind:'branch', matched}` | `{kind:'select', caseLabel: string|null}`; `null` routes to `default`.
* **Nothing is wired by default.** A plan containing a `branch`/`select` with no matching cell is REFUSED at freeze, naming the node.
* Three cells: `createStructuredCell()` (zero-dep, browser-safe), `createJexlCell(options?)` (browser-safe, optional `jexl` peer), `createLuaCell(options?)` (**Node-only**, optional `wasmoon` peer).
* structured: `{path, op, value?}` with a CLOSED operator set — `eq`,`ne`,`lt`,`lte`,`gt`,`gte`,`in`,`contains`,`truthy`,`exists` — composed with `all`/`any`/`not`. For a `select`, `predicate` is a RECORD mapping each case label to its own predicate.
* jexl: an expression SOURCE STRING. Reach a node output by BARE NODE ID, or an exact table key via `outputs['nodeId:branchKey']`. A node reached on >1 branch is dropped from the bare surface.
* lua: a source STRING; context is `ctx['nodeId:branchKey'][i].json.field`. For a `select` the chunk RETURNS a label or nil. **The VM runs IN-PROCESS (wasmoon is WASM); the watchdog worker's last-resort timeout `SIGKILL`s the HOST process, not an isolated evaluator.**
* **No JavaScript cell, deliberately**: JS-in-JS has no preemption story — SES `Compartment.kill()` is a documented no-op and SES `lockdown()` is process-global in Node. structured terminates by construction, jexl is expression-only, lua is killable in a worker.
* Every cell: pre-marshalled plain-data snapshot, no clock, no randomness — so `branch`/`select` are safe to re-enter unconditionally on resume. A fault yields the NEGATIVE verdict, never a throw.

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 `await`s `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 **closed** — `eq`, `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.

::: warning 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.

::: warning 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.

::: warning 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`.

::: warning 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.
:::

::: warning 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.
