Chapter 5 — Nodes That Govern Themselves
# Chapter 5 — Nodes That Govern Themselves
Last chapter you built a machine that runs. This chapter you build one that can be trusted — because its nodes carry their own government with them. In most frameworks, safety is a wrapper: a middleware that inspects your component from the outside and hopes it understands what it sees. Relurpify types governance into the node itself. A node does not merely do something; it declares what it will do, how often it may be done, where it prefers to run, and what recovery owes it — and the graph validates those declarations before a single node executes.
Then you use it. The chapter’s build is the reason you came: the critic pipeline as a hand-composed graph — drafter → critic → gate → human approval → conditional revise → re-review — with a required checkpoint policy on the revision and a hard wall between the drafter’s model and the critic’s.
# Where you are
Sixteen tests green in cognitionzoo/critic/; the graph API — build,
seal, execute, preflight — is yours. This chapter adds one overlay file,
critic_graph_test.go, and takes you into
execution/agentgraph/contracts.go, the file where the framework says
what a node is allowed to be.
# The NodeContract, field by field
NodeContract is a plain struct (execution/agentgraph/contracts.go:69),
and every node that implements one extra method carries it:
// ContractNode extends Node with an explicit execution contract.
type ContractNode interface {
Node
Contract() NodeContract
}The struct has nine fields — five class dimensions and four capability/risk fields. Here is the whole card, with the exact vocabulary each field accepts:
| Field | Values (closed set) | What it declares |
|---|---|---|
SideEffectClass |
none, context, local, external, human |
The replay sensitivity of execution: touches nothing; writes context; mutates local state; reaches outside; or needs a human |
Idempotency |
unknown, replay-safe, single-shot |
Whether re-running the node is expected to be safe |
PreferredPlacement |
any, local, remote, sticky-session |
Preflight’s tie-breaker when choosing among matching capabilities |
CheckpointPolicy |
none, preferred, required |
Whether persisted recovery is optional or required |
Recoverability |
none, in-process, persisted |
The recovery guarantee the node expects |
RequiredCapabilities |
capability selectors | What the node refuses to run without — preflight blocks on a miss |
MaxRiskClass |
risk classes (read-only … destructive) | Preflight refuses capabilities that classify above the ceiling |
RequiredTrustClass |
trust classes | Preflight refuses capabilities below the floor |
ContextPolicy |
a StateBoundaryPolicy |
The node’s declared read/write boundary over the envelope: key patterns, memory classes, entry-size and collection caps |
One struct comment deserves quoting in full, because it is the honesty policy for the last three rows:
Placement, recoverability, and checkpoint policy are contract metadata, not runtime persistence ownership.
The classes are declarations the framework validates and preflight
consumes — they are not magic. CheckpointPolicyRequired does not make
persistence happen; it makes the node say that persistence is required,
and the composition wiring that can honor it is the runtime composition
you will meet in Part III. Saying so is not a weakness. A framework that
pretends declarations are enforcement is how wrappers drift from truth.
# Defaults are typed too
If a node does not implement ContractNode, the graph resolves a
type-driven default — governance is not optional for the unwashed:
| Node type | Default contract |
|---|---|
HumanNode |
SideEffectHuman, single-shot |
| Terminal, Conditional, Observation, System | SideEffectNone, replay-safe |
| Stream | SideEffectContext, replay-safe |
| Tool | SideEffectExternal, unknown |
And for tool nodes, the contract is not just defaulted — it is
classified from what the tool declares. toolNodeContract reads the
tool’s capability descriptor and maps its effect classes onto the
side-effect and idempotency dimensions: a tool that declares filesystem
mutation or network egress classifies external/single-shot; a tool
that declares nothing classifies none/replay-safe — trusted to have
no effects because it declared none. Your Chapter 4 read-only stringkit
tool resolves exactly that way, and the lab asserts it, capability pinned
and all.
# Validation refuses garbage
Graph.Validate runs every node’s contract through
validateNodeContract, which checks each closed set (a typo in a class
spelling is a build-time refusal with the node’s ID attached), validates
the ContextPolicy, and enforces one rule with teeth: a tool node that
declares no required capabilities is rejected — because a tool node
without a declared need is a tool node nobody can preflight. The lab
plants both sins and captures the refusals.
# Control flow: decisions and pauses
Two shipped node shapes cover the control flow the critic pipeline needs.
Decisions. ConditionalNode computes the next branch dynamically:
its Execute evaluates a predicate over the envelope and stores the
decision in its result ({"next": …}). Routing still belongs to the
graph — the edges out of a decision node carry ConditionFuncs that test
the decision. Decisions route; edges choose; nothing executes on a
predicate’s word alone.
Pauses. HumanNode is a HITL pause inside a graph — and the text
owes you the plain truth about its mechanism: it is a synchronous
callback. Execute invokes the node’s callback with the envelope and
blocks until the callback returns; there is no suspend-the-graph and
resume-tomorrow machinery behind it. Its contract is SideEffectHuman /
single-shot — a human decision may not be replayed on the graph’s
authority. One more honest fact, discovered while building this chapter’s
lab: as of this tree, ConditionalNode and HumanNode carry unexported
id fields and ship no constructors, so they cannot be validly
constructed outside the agentgraph package.
[NEEDS CONFIRMATION: constructors for ConditionalNode/HumanNode at BETA-RC1-FALL2026] The lab therefore does what a builder extending the
framework would do: it mirrors HumanNode’s contract on a
hand-implemented approval node — same classes, same synchronous
decision — and reads HumanNode’s default contract through its zero value
to prove the mirror is faithful.
Checkpoints. A node may request a checkpoint on the envelope —
env.RequestCheckpoint(reason, priority, evictMemory) — and the shipped
ReWOO checkpoint node does exactly that ("rewoo:<phase>"). The request
is the node’s; materialization belongs to the persistence wiring. The
revise node in this chapter’s build follows the same shipped pattern.
# Hands-on: the critic pipeline as a graph
The overlay adds critic_graph_test.go. The graph:
drafter → critic → gate ──(revise)──→ approve ──(approved)──→ revise ──→ critic
└─(finish)──→ done └─(denied)────→ doneRead it as the thesis, drawn. Three model nodes, and they are not the
same model — each holds its own FakeModel with its own script and its
own prompt, so the critique is structurally incapable of seeing the
drafter’s reasoning, and the revision is structurally incapable of
grading itself:
drafter— Chapter 4’s node, unchanged: streams a draft from its model, writescritic.draftwithOriginLLM.critic— the independent critique phase. Reads the draft, streams a verdict (issuesorclean) from its model, writescritic.verdict. Contract:SideEffectNone,replay-safe— a critic that mutates nothing and can be replayed.gate— a system node with decision semantics: verdict isissuesand revisions are under the bound →revise; otherwisefinish. The edges out of the gate route onnext.approve— the human pause. MirrorsHumanNode’s contract (SideEffectHuman,single-shot); its synchronous decision callback records the decision in the envelope withOriginUser— in a unit test the decision is scripted, because there is no human in a unit test; the pause is real control flow.revise— the revision, under a required checkpoint policy: contractSideEffectContext/replay-safe/CheckpointPolicyRequired/persisted; increments the revision counter, requests a checkpoint on the envelope, and streams a revised draft from its model. Then the edge back tocriticcloses the loop.
The loop is bounded twice: the scripted critique eventually says clean,
and the graph’s visit cap stands behind that as backstop. Two scenarios
run:
Approved. First critique says issues, the human approves, one
revision runs, the re-review says clean, the run ends at the terminal —
with every claim asserted against the envelope and the call counters:
final verdict: clean (critic model calls: 2)
revisions: 1 (revise model calls: 1)
final draft: a better draft
approval: approved (OriginUser)
checkpoint: requested by the revise nodeDenied. Same graph, the pause says no: the revise model is never called, the revision counter never moves, and the run ends honestly at the terminal. A denied revision leaves no trace of having almost happened.
Two more tests round out the chapter:
TestContractDefaultsAreTyped—ResolveNodeContracton shipped values: terminal isnone/replay-safe;HumanNodeishuman/single-shot; your stringkit tool classifiesnone/replay-safebecause it declared no effect classes, withtool:read_stringkitpinned as its required capability.TestContractValidationRefusesGarbage— a node declaringSideEffectClass("wing-it")is refused withinvalid side effect class "wing-it"; a tool-typed node that declares no capabilities is refused withdeclares no required capabilities. The contract’s vocabulary is closed, and validation holds the door.
./verify.sh build ch05== tier 1 (build): ch05 ==
ok codeburg.org/lexbit/relurpify/cognitionzoo/critic 0.059s
[PASS] build: ch05 compiles clean and its tests passTwenty tests green in the package — and Part II’s promise is one
chapter from kept: the critic pipeline exists as a working, governed,
hand-composed graph. What it does not yet have is a command to run it or
a grammar to address it. That is the gap between here and
agent fixer uses critic, and it is exactly Part III’s subject.
# Gotchas
- A contract is a declaration, not a force field.
CheckpointPolicyandRecoverabilityare metadata the framework validates and consumes — persistence itself is wired at composition. Claim less than you enforce and more than you mean; the struct comment says which is which. - Contract classes are closed sets. A misspelled class is not a fallback to default — it is a validation error at graph build. This is the AST-gate taste applied to your own types.
- The human pause is synchronous. Do not build long-running approval
flows inside a
HumanNodecallback in production; the pause blocks the graph’s run loop, and the honest long-form answer (persisted interaction frames) lives in Euclo’s interaction layer, not in this node. ConditionalNode/HumanNodecannot be constructed outsideagentgraphat this tree (unexportedid, no constructors). Mirror their contracts on your own nodes, as the lab does — and read their defaults throughResolveNodeContractto keep the mirror honest.- The revise loop needs two bounds. A scripted-or-real critique that
never says
cleanplus a missing visit cap is an infinite loop with good manners. Bound the revisions in the envelope and keep the graph’s cap as the backstop.
# Summary
NodeContractis nine fields: five closed-set class dimensions (side effect, idempotency, placement, checkpoint policy, recoverability) plus required capabilities, a risk ceiling, a trust floor, and a declared context boundary. Governance is typed into the node, validated at graph build, and consumed by preflight — not wrapped around it.- Defaults are typed too: every node resolves a contract, and tool nodes' contracts are classified from what the tool declares — a tool that declares no effects is trusted to have none, and its required capability is pinned regardless.
ConditionalNodedecides (and edges route on the decision);HumanNodepauses via a synchronous callback with ahuman/single-shotcontract; checkpoints are requested by nodes on the envelope and materialized by persistence wiring.- The critic pipeline now exists as a governed, hand-composed graph: three model phases that cannot see each other’s reasoning, a bounded conditional revise loop, a human pause whose denial leaves no trace, and a required checkpoint policy on the revision.
# Check yourself
- Name the five class dimensions of
NodeContractand give the exact value your revise node declares for each — and the two extra fields it sets. - Your tool’s descriptor declares filesystem mutation. What does
toolNodeContractclassify it as, and what would preflight do if your node contract setMaxRiskClassbelow that classification? - Why does
validateNodeContractrefuse a tool node that declares no required capabilities? - The human pause denied the revision. What in the envelope proves the revision never ran — and what does the pipeline’s structure prove about the critic never seeing the drafter’s reasoning?
CheckpointPolicyRequiredis on the revise node’s contract, yet nothing persisted in the lab. Which sentence fromcontracts.goexplains that, and what arrives later to honor it?
Answers. 1. SideEffect (context), Idempotency (replay-safe),
PreferredPlacement (unset — any), CheckpointPolicy (required),
Recoverability (persisted); plus the revise node sets nothing else —
the critic and drafter declare none/replay-safe. (If you said
external, reread: a revision writes context, not the world.) 2.
external/single-shot — filesystem mutation is an external effect and
a single-shot risk; preflight would refuse every matching capability as
capability_constraints_unsatisfied, blocking the graph. 3. Because a
tool node without declared needs cannot be preflighted — the gate exists
to check declared needs, and an undeclared need is an uncheckable one.
4. The revision counter is absent and the revise model’s call count is
zero; structurally, the critic holds a separate model instance with its
own script, so the drafter’s reasoning never entered the critic’s
context. 5. “Placement, recoverability, and checkpoint policy are
contract metadata, not runtime persistence ownership.” The runtime
composition (Part III) wires the persistence that materializes the
node’s checkpoint request.
# Try it yourself
- Cap the gate, feel the backstop. Change the gate’s revision bound
from 2 to 99 and script a critic that always says
issues. Predict what stops the run (the visit cap), then run it and read the error. You have just met the second bound you will always keep. - A stricter critic. Give
criticNodea contract withRequiredTrustClassset to the workspace-trust class and re-run the preflight against an empty registry. Read thecapability_constraints_unsatisfiedissue. Trust floors are for capability-backed nodes — decide for yourself whether a model-only critic should carry one, and write down your argument; it returns in Chapter 7 as a contract-design question.
# Where next
Part II closes next chapter with the graph at full strength: parallel
branch subgraphs and their envelope merge — immutable snapshots, deltas
merged in declaration order — plus RetrievalNode, StreamTriggerNode,
and the grounder. The critic pipeline grows an ensemble flavor: N
independent critiques, merged. Then a small runner command executes it
offline end to end — the first production importer your package has ever
had, and the moment the consumer gate has been waiting for since
Chapter 2.
The lab lives at devdocs/agent-construction-relurpify/labs/ —
verify.sh gates ch05 re-runs everything this chapter proved, and
transcripts/ch05-critic-graph.md holds the captured run and the honest
notes on ConditionalNode/HumanNode constructors.