Skip to content
3 min read · 608 words

Orchestration battery assembly

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.

PreconditionFailure
@nhtio/encoder resolvableE_ORCH_ENCODER_REQUIRED
Every wired cell's peer present (load() is awaited)E_ORCH_CELL_UNAVAILABLE
Every template validatedA 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.

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'))])

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.