Skip to content
8 min read · 1,626 words

Orchestration

This is a featured battery

Large enough to warrant its own section. This page is the hub; the spokes cover the lifecycle, the plan IR, predicates, execution state, the prose renderer, approval, the agent tools, and the store contract.

A model handed tools does not plan. It acts, reacts, and burns cycles in a loop where the "plan" is merely an unapproved transcript of calls that already executed. Handing an agent live credentials and treating the audit log as the control plane is an absurd way to run production software. When an unvetted call drops a table or charges a card, reviewing the transcript after the fact is not governance; it is an autopsy.

We built this because the alternative is what the industry currently calls normal.

This battery moves the review to the front. A plan is a saved graph of the steps the model intends to take — steps as boxes, arrows for what follows what, and no loops, so a step can never lead back to itself. That graph is a staging environment for tool calls: the model explores, stages the work it wants to do, and stops. The whole graph is reviewed and approved before a single side effect runs.

The graph is also content-addressed: it has a fingerprint (a digest) computed from everything in it, so changing any staged argument changes the fingerprint. That is what an approval binds to.

typescript
import {
  createOrchestration,
  InMemoryPlanStore,
  createStructuredCell,
} from '@nhtio/adk/batteries/orchestration'

const orchestration = await createOrchestration({
  store: new InMemoryPlanStore(),   // BYO for anything durable
  invocable: myToolAllowlist,       // tier C: what a staged `call` may invoke
  deps: { evaluators: [createStructuredCell()] },
})

The shape

Three states, and the transitions between them are the entire design:

The permission gate IS the reviewable → executable transition. This is not a convenience or a convention; it is structural. "Approved" and "executable" are one physical state in the lifecycle, not two boolean columns that can drift out of sync when an engineer forgets a condition. There is no detached approval flag to overlook. Re-gating after a modification requires zero enforcement logic: any edit forces an unfreeze back to editable, and the only path back to executable runs straight through the gate again.

Approval binds to the plan's digest and its authority set — the canonicalised union of what every reachable call claims it may do. Mutate a single node and the digest changes. An approval granted to the old content is cryptographically meaningless against the new.

Seven node kinds

KindWhat it is
entryWhere external input enters. Exactly one per plan, no incoming edges.
callA staged tool invocation — the kind the whole staging environment exists to gate.
reasonAn LLM dispatch that terminates in a forced tool call carrying structured output.
transformA pure read over an artifact a previous call produced.
branchTwo-way: a predicate picks match or no_match.
selectN-way: a predicate names a case, or falls to the required default.
joinA barrier. Fires once, when every route into it has arrived.

Six edge handles decide when an edge fires: always, match, no_match, default, error, and case_${label}. On success, every applicable handle fires exactly once; always fires alongside match. On failure, only error fires. Do not mistake always for a finally block: it is a success-path edge, nothing more.

What you bring

The battery ships the machinery and none of your operational policy. You supply:

  • A PlanStore. InMemoryPlanStore is the reference implementation; durable persistence is entirely your responsibility. runPlanStoreConformance from /conformance is an executable vitest suite that proves an implementation actually satisfies the contract — especially the edge cases teams routinely botch, like re-entering an aborted run.
  • The tier-C allowlist (InvocableTools): what a staged call is permitted to invoke, along with output schemas so downstream transform nodes can be statically validated at freeze.
  • CallInvokerFn — how a staged call actually executes. The battery orchestrates; it never dispatches a tool directly.
  • ReasonerFn if you author reason nodes, or the bundled createDispatchReasoner from /dispatch_reasoner.
  • Which predicate cells are wired. Zero cells are wired by default: a plan containing a branch without a registered cell is rejected loudly at freeze, never silently ignored.

Honest limits

We would rather state these plainly than have you discover them in production.

We are about to state some opinions

These are choices, not facts, and you are free to disagree. Every one of them is a limit we could have hidden and chose to write down instead.

The lock is coordination, not mutual exclusion. PlanLockFactory is a TTL lease without a fencing token. No lease of this shape can stop a network-partitioned or GC-stalled holder from continuing past expiry while a second executor legitimately claims the lease. Supplying one makes concurrent execution unlikely rather than routine; it does not make it impossible, and we are not going to write a sentence that implies otherwise so the feature list reads better. The residual failure mode — a double-invoked node — is handled where that risk actually belongs: at the node level via onIndeterminate and replaySafe.

foldRun does not detect process death. An executor that crashes mid-flight leaves an event log byte-identical to a live in-flight call: run_started, node_entered and nothing more. The difference between active execution and a dead process is liveness, not history, and no pure fold over persisted events can infer it. An abandoned run will read running indefinitely until an operator or supervisor resumes it and appends run_interrupted{process_death}. The fold does not guess.

Convergence is not semantic merge. The op log converges: two actors folding the same set of ops arrive at the exact same state, regardless of arrival order. But two actors independently rewiring the same branch will converge on a graph neither intended. Freeze validation refuses the result loudly at the boundary, which is the only correct failure mode; validation cannot reconstruct intent, and no amount of clever merge algorithm can either — the intent was never written down anywhere.

There is no dry run, and there will not be one. You cannot execute a plan "safely" to see what it would do, because a rehearsal that mocks the dangerous parts is a rehearsal of a different plan. The deterministically rendered prose is what explains a plan — which is precisely why the renderer is load-bearing infrastructure rather than cosmetic decoration. See The Prose.

Arguments are STAGED at approval time. A NodeRef in a staged argument renders as provenance ("← every output of step list_files"), never as a fabricated preview value, because that value does not exist yet. What the operator reviews and approves is the authority bound, not an imaginary preview of runtime data.

A template cannot launder its own parameters — stated with exact precision, because that is the true invariant. The engine does not track a substituted value's template origin through subsequent graph edits, because nothing in a mutable graph can do so reliably. Once instantiated, the graph is simply an ordinary plan and the injected value is an ordinary literal.

Registering a durable store's reader resolver is load-bearing for resume, not optional hygiene. Without it, a transform attempting to read a persisted artifact fails immediately, naming the unresolvable tag. In-memory and fetch resolvers auto-register; a durable store's resolver must be registered explicitly by you, because only your host environment holds the live binding that a serialised locator cannot carry.

registerOrchestrationEncodables() must run before any decode(). encode() works without it; decode() throws on every ADK primitive it encounters, naming the type constructor it cannot rebuild. Hydration without it fails loudly rather than silently returning an unprototyped plain object.

Which cells a browser can use: structured and jexl yes, lua no — Lua requires worker_threads and POSIX SIGKILL to enforce execution boundaries. It is strictly Node-only and reachable only through its own deep subpath.

jexl is frozen. Last published 2022-06-19 — complete and stable rather than abandoned, but a dependency that no longer moves. That is an acceptable trade for a closed, expression-only grammar, and it is an intentional choice rather than an unexamined default.

wasmoon's count hook and allocator cap are undocumented at its TypeScript surface. The Lua cell probes them against canaries at construction and falls back to watchdog-only, reporting the reduced guarantee through status() rather than claiming enforcement it cannot substantiate.

And the Lua watchdog's last resort is SIGKILL on the HOST PROCESS. wasmoon is WebAssembly, so the VM runs in-process on the main thread; the watchdog is a worker, but what it kills is your process — a synchronous WASM loop owns the main thread, so nothing less can break it. The count hook and allocator cap normally stop a runaway first, but if a non-terminating predicate reaches the deadline it takes the host down with it. If that is unacceptable, do not wire this cell for untrusted predicates. See Predicates & Cells.

prospective() was considered and rejected, as was a per-node digest. Both decisions are recorded here as considered rather than overlooked: prospective() would have pretended to offer a dry-run mode that this design does not support, and a per-node digest adds redundant state to keep in sync with the plan digest that approval already binds.

Where next