Skip to content
6 min read · 1,108 words

The Plan IR

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.

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:

BoundDefaultWhat it caps
maxNodes256Nodes — and route length, since a route cannot revisit a node.
maxEdges512Edges.
maxSteps4096Total node executions in a run.
maxConcurrentFrames32Simultaneously live frames.
maxEncodedBytes1048576Encoded plan size.