---
url: 'https://adk.nht.io/assembly/batteries-orchestration.md'
description: >-
  Wire a plan store, a tier-C allowlist, predicate cells and a call invoker into
  a runner — and put the approval gate where a human can actually reach it.
---

# Orchestration battery assembly

## LLM summary — assembly

* `await createOrchestration({store, invocable, deps?, templates?})` — deep import from `@nhtio/adk/batteries/orchestration`, NOT re-exported from `@nhtio/adk/batteries`.
* Call `registerOrchestrationEncodables()` once at startup, BEFORE any `decode()`.
* Three preconditions fail at CONSTRUCTION: a missing `@nhtio/encoder` (`E_ORCH_ENCODER_REQUIRED`), a cell whose optional peer is absent (`E_ORCH_CELL_UNAVAILABLE`), and a template naming a tool outside the tier-C allowlist.
* Decided here and nowhere else: what `invocable` admits, who approves, and whether a durable store's reader resolver is registered.
* Give a conversational agent `tools('front')`; give an authoring agent `tools('authoring')`. Never hand tier B to the agent talking to the user.

The other orchestration pages dissect the pieces in isolation. This page is where they become an actual deployment — and where you are forced to make three decisions that no component can make for you:

* **What `invocable` admits.** This is your tier-C boundary: the absolute ceiling on what a staged `call` node can touch. Nothing else bounds it.
* **Who approves, and what they actually inspect.** The gate is a state-machine transition; putting a sentient operator behind it is your job.
* **Whether your durable store's reader resolver is registered.** Skip it, and resume works cleanly until the first `transform` over an artifact fails naming the tag.

## Construction

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

// Once, at startup, before anything decodes a persisted plan.
registerOrchestrationEncodables()

const orchestration = await createOrchestration({
  store: new MyDurablePlanStore(connection),

  // TIER C — the allowlist AND the registry, deliberately the same object.
  invocable: {
    has: (tool) => toolRegistry.has(tool),
    names: () => toolRegistry.names(),
    returns: (tool) => {
      const ctor = toolRegistry.get(tool)?.artifactConstructor
      return ctor ? { kind: 'artifact', artifactClass: ctor() } : undefined
    },
  },

  // Defaults for every run; any run may override.
  deps: {
    evaluators: [createStructuredCell()],
    invokeCall: async ({ tool, args, signal }) =>
      toolRegistry.get(tool).executor(ctx)({ ...args, signal }),
  },

  templates: [archiveFolderTemplate],
})
```

`createOrchestration` is **async**, and that is deliberate: three preconditions fail at construction rather than deferring the explosion to first execution.

| Precondition | Failure |
|---|---|
| `@nhtio/encoder` resolvable | `E_ORCH_ENCODER_REQUIRED` |
| Every wired cell's peer present (`load()` is awaited) | `E_ORCH_CELL_UNAVAILABLE` |
| Every template validated | A named error naming the template |

A broken deployment fails at **startup**, immediately, with a named error. It does not wait to fail part-way through a plan freeze months later under production load. That is the entire argument for validating at construction.

::: warning We are about to state an opinion
`@nhtio/encoder` stays an *optional peer of the package* even though this battery cannot run without it. Peer dependency metadata is package-wide rather than subpath-scoped; marking it required would force it onto consumers who never touch orchestration. Enforcing it at construction is where the requirement can be asserted without imposing that tax on everyone else.
:::

## The gate needs a human

The battery provides the lifecycle transition. It does not provide an operator.

```typescript
import { computeAuthoritySet } from '@nhtio/adk/batteries/orchestration'

// 1. The model authors, then freezes.
const { ok, issues } = await orchestration.freezePlan(planId)
if (!ok) return refuseToModel(issues)   // issues are model-addressed; hand them back

// 2. Render what the operator will actually read. There is no dry run.
const view = await orchestration.raw.plan(orchestration.store, planId)
const prose = orchestration.render(view, { audience: 'operator', view: 'as_planned' })

// 3. Show the prose AND the authority set, and wait for a real decision.
const state = await orchestration.store.readState(planId)
const authoritySet = computeAuthoritySet(view)
const decision = await askOperator({ prose, authoritySet })   // yours

// 4. The gate.
if (decision.approved) {
  await orchestration.approvePlan(planId, {
    planId,
    digest: state.digest,        // binds to what they were SHOWN
    authoritySet,
    decidedBy: decision.who,
    decidedAt: new Date().toISOString(),
    disposition: 'approved',
  })
}
```

Step 3 is the pivot. The cryptographic `digest` binds the approval to the exact content the operator inspected. If any rogue turn or background task edits the plan between rendering and approval, the transition is refused outright. The gate will never silently apply an approval to mutated content.

## Wiring the tools into a runner

Give the agent that talks to the user **tier A**, and nothing else:

```typescript
const registry = new ToolRegistry([
  ...Object.values(orchestration.tools('front')),   // list_templates, instantiate_plan, author_plan
])
```

A separate authoring agent — or an authenticated operator UI — gets tier B:

```typescript
const authoringRegistry = new ToolRegistry([...Object.values(orchestration.tools('authoring'))])
```

::: tip Do not hand tier B to the conversational agent
The tiers are a threat-model boundary. An agent holding `add_node` and `freeze_plan` can rewrite the graph it was supposed to present for scrutiny. Handing tier B to the conversational agent means your review surface describes a plan the model edited behind your back.
:::

## Executing

```typescript
const projection = await orchestration.executePlan(planId, {
  input: { folder: '/reports/q3' },
  signal: turnAbortSignal,
})

if (projection.outcome === 'aborted' || projection.outcome === 'halted') {
  // Where it stopped, and whether it can pick up.
  console.log(projection.interruption, projection.frontier.frames.map((f) => f.frame.nodeId))

  // Resumable causes re-enter the SAME run.
  await orchestration.executePlan(planId, { input, resumeRunId: projection.runId })
}
```

A halting failure cannot be resumed. Recovery requires `clonePlan`, which yields a cold `editable` plan whose prose warns explicitly that re-running repeats what the parent run already executed.

## Durable stores: register the resolver

```typescript
import { registerSpoolReaderResolver } from '@nhtio/adk/batteries/encoding'

registerSpoolReaderResolver('my-durable-spool', (locator) => new MySpoolReader(locator))
```

Artifacts persist in the run log as `{tag, locator}` handles — pointers, never payload bytes. Resume rebinds them through the resolver registry, which is the sole place a serialised locator can be turned back into a live reader. In-memory and fetch resolvers auto-register; your custom backend does not. Forget to register it, and your resumed execution will fail the moment an artifact is needed.

And verify the store itself:

```typescript
import { runPlanStoreConformance } from '@nhtio/adk/batteries/orchestration/conformance'

runPlanStoreConformance(async () => new MyDurablePlanStore(await freshConnection()))
```

## What this does not do

It does not dispatch tools on faith — `invokeCall` is entirely yours to implement. It does not decide who has authority to approve. It does not make concurrent execution impossible; the optional lock makes it unlikely. And it does not simulate execution: there is no dry run, so the rendered prose is the only review surface you get. Read it instead of skipping it.
