---
url: 'https://adk.nht.io/batteries/orchestration/plan-ir.md'
description: >-
  Nodes, edges, staged arguments and the lossless digest an approval binds to —
  plus why a reference is a class rather than a record.
---

# The Plan IR

## LLM summary — orchestration plan IR

* A plan is the FOLD of its op log. `foldOps(planId, ops, provenance?)` sorts by the three-part key `(lamport, actorId, opId)` and applies in that order; the highest key wins (LWW).
* `PlanOp`: `add_node`, `remove_node` (records `incidentEdgeIds`), `set_node_field`, `set_node_definition`, `set_node_phase`, `add_edge`, `remove_edge`, `set_bounds`.
* `NodeRef` and `ParamRef` are registered encoder CLASSES, not records — the guards are `instanceof`-backed.
* `NodeRef.branchId` names WHICH EXECUTION of a node to read. Omitted = "do not filter"; refused at freeze when more than one path reaches the node.
* The digest is `sha256(encode(sortPlainObjectKeys(view)))` — LOSSLESS. NOT `canonicalStringify`, which collapses `Date`/`RegExp`/`Map`/`Set` to `{}` and is proven to collide.
* Bounds are the fold SEED (`DEFAULT_PLAN_BOUNDS`), not an op: an empty log folds to revision 0 with a complete view and a stable digest.
* Topology invariants at freeze: exactly one `entry` with no incoming edges; everything reachable; acyclic over every handle; every `join` a diamond; edge ids match `/^[A-Za-z0-9_-]{1,64}$/`.
* TAINT: entry-derived data may reach a `reason` prompt but NEVER a `call` node's args. Cleared only by a `call` declaring that OUTPUT field in `declassifies` — per field, siblings stay tainted.

**IR** is *intermediate representation*: the shape a plan takes in memory and on disk — nodes, edges and staged arguments — before any of it runs. It is the thing an operator approves, so every decision about its shape is a decision about what an approval can be trusted to mean.

A plan is not a document you edit. Mutating a shared graph directly is a recipe for silent divergence and unverifiable state. A plan is the **fold of an op log** — an append-only sequence of edits that any number of actors can write to, folded deterministically into the graph everyone sees.

## The fold is convergent

Two actors holding the same op set must reach the exact same state, whatever order the ops arrived in and however many times they arrived. Network latency cannot decide state. Replay count cannot decide state. The fold sorts strictly by a three-part key and applies in that total order:

```
(lamport, actorId, opId)
```

`lamport` is a **logical clock**: a counter that only ever goes up, used instead of a wall clock because two machines' clocks disagree and a plan cannot depend on whose laptop was fast. `actorId` is who made the edit; `opId` is unique per edit.

All three parts are required, and the third is where lazy implementations fall apart. `(lamport, actorId)` alone is **not** a total order: one actor can legitimately emit two ops at the same lamport — a single logical instant can carry several edits — and then arrival order would decide which edit is "later". That silent non-convergence is precisely what an op log exists to eliminate. `opId` is unique by construction, so the triple is a genuine total order.

Element semantics are **last-writer-wins** (LWW), not add-wins: the highest-key op touching an element decides whether it exists. Add-wins is deliberately not attempted: add-wins requires causal context a scalar lamport cannot provide, and pretending otherwise is how distributed systems produce phantom resurrections.

A `remove_node` records its `incidentEdgeIds` on the op itself, so the cascade to those edges does not depend on the edges being present in the fold when the removal is applied.

## References are classes

A staged argument may be a literal or a `NodeRef` — a serialisable reference to another node's output:

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

new NodeRef('list_files', 'first', 'path')
```

`NodeRef` and `ParamRef` are **classes**, and that is load-bearing engineering rather than stylistic preference. A plain record can wear `{kind: 'nodeRef', node, select}` — it is an ordinary encodable record. If references were records with marker properties, you could never separate a real reference from user input that merely happened to match the shape, and a resolver keying on the marker would silently hijack and rewrite the literal. `instanceof` is unforgeable from incoming JSON payloads, and the encoder round-trips instances as `custom:NodeRef` rather than bare records.

`branchId` names **which execution** of a node to read. It is not an outgoing branch: a node fanning out to two successors still runs once and produces one output. What creates several outputs for one node is that node being *reached* by several paths. Omitted means "do not filter" — fine when exactly one path reaches the node, and refused at freeze when more than one does, because then the author must say which.

## The digest must be lossless

Every approval binds to the plan digest, so the digest is a security boundary rather than a cache key: if two plans with different staged arguments digest identically, approving one authorises the other.

The strategy is `sha256(encode(sortPlainObjectKeys(view)))` — sort **plain-object** keys only, and hand every encoder-owned value to the encoder untouched.

The obvious alternative was rejected, and it is worth knowing why. `canonicalStringify` walks with `Object.keys`, and `Date`, `RegExp`, `Map` and `Set` have no enumerable own keys — so it collapses each to `{}`. These two plans canonicalise to the exact same string:

```typescript
{ pattern: /^inv-\d+$/i,  when: <date A>, m: Map{k => 1} }
{ pattern: /^cust-\d+$/,  when: <date B>, m: Map{z => 9} }
// both → {"m":{},"pattern":{},"when":{}}
```

Read those two plans again. Different regex, different date, different map — and a digest that cannot tell them apart. An approval bound to that digest authorises a plan the operator never saw, which is the precise failure the digest exists to prevent.

That is not a hypothetical we reasoned our way to. It is executed as a test, on those exact values, because the first strategy we tried was the wrong one and we would rather you inherit the second one with the receipt attached.

## Taint

External input is **tainted**, and taint propagates transitively through data references. A tainted value may reach a `reason` node's prompt, but **never** a `call` node's args. This is the rule that stops a plan approved for `/reports/q3` from running against whatever path arrived in the request body:

```typescript
// Refused at freeze:
new NodeRef('entry', 'first', 'user_supplied_path')  // into call.args

// Allowed:
prompt: [{ text: 'Summarise:' }, new NodeRef('entry', 'first', 'topic')]
```

The distinction is what the rule rests on: a prompt is text a model reads, an arg is a value a tool acts on. Letting run-time input steer a staged argument directly means approving one plan and running another.

```mermaid
flowchart LR
  E["entry input"]:::tainted
  R["reason node prompt"]:::ok
  C["call node args"]:::blocked
  S["call node<br/>declassifies: safe"]:::clean
  D["call node args"]:::ok

  E -->|allowed — a prompt is text a model reads| R
  E -->|REFUSED at freeze — an arg is a value a tool acts on| C
  E -->|routed through a declared sanitiser| S
  S -->|safe field only; unsafe sibling stays tainted| D

  classDef tainted stroke-dasharray: 4 3
  classDef blocked stroke-width:3px
  classDef ok stroke-width:1px
  classDef clean stroke-width:2px
```

Taint clears only at a **declared** point: a `call` naming an output field in `declassifies`. The author is asserting "this tool's `safe_path` output is sanitised" — a claim an operator sees in the rendered prose and approves as part of the plan, rather than something the graph's topology accidentally implies.

That clearing is **per field**. A node declaring `output: ['safe', 'unsafe']` with `declassifies: ['safe']` launders exactly `safe`; a downstream call reading `unsafe` is still refused. And an **echo node does not launder**: a node that merely reproduces the entry value unchanged, declaring an output but no `declassifies`, leaves the value tainted. A `reason` node cannot declassify at all — an LLM output is non-deterministic generation, not a sanitiser.

Taint is computed at **freeze**, over the graph. It is a static property, so `OutputItem` carries no taint metadata and a violation is caught before approval rather than mid-run.

## Bounds are the seed

`DEFAULT_PLAN_BOUNDS` is the fold's starting point, not an implied op. A plan at revision 0 — an empty log — has a complete `RawPlanView` and a well-defined digest, and the first authoring op makes revision 1. Without a fixed seed, each store implementor would invent a default, and the same logical plan would digest differently across stores, breaking approval binding.

Bounds are plan **content**, so they are digested and an operator approves them:

| Bound | Default | What it caps |
|---|---|---|
| `maxNodes` | 256 | Nodes — and route length, since a route cannot revisit a node. |
| `maxEdges` | 512 | Edges. |
| `maxSteps` | 4096 | Total node executions in a run. |
| `maxConcurrentFrames` | 32 | Simultaneously live frames. |
| `maxEncodedBytes` | 1048576 | Encoded plan size. |
