Nodes, Edges, Shared State

By the Syncfusion DevTutorials team · Last updated

Write a state contract: every field has one writer, every node reads only the fields it needs, and a checkpoint lets a crashed run resume.

Watch One Node Overwrite Another

Here is a small graph that checks a fix before it is marked ready. Three nodes run one after another: lint, tests, then report.

In this run, lint fails and the tests pass. The fix should be blocked. Run it and watch the field called status.

Two nodes write the same field

Lint wrote status: blocked. Then the tests node wrote status: ready over it. The report node read the whole state, saw "ready", and passed a broken fix.

No node did anything wrong on its own. The shape of the shared state was wrong: two nodes were allowed to write the same field. In this lesson you write a state contract that makes this impossible.

TL;DR

  • A graph's shared state is a small, typed record that travels with the work.
  • A state contract lists every field, its type, the one node that writes it, and the nodes that read it.
  • One writer per field. Two writers means one result can silently replace the other.
  • Whitelisted reads. Each node lists the fields it reads. No node reads everything (*).
  • Checkpoints. Save the state after each node, so a crashed run resumes instead of starting again.

A Real-World Example

Ravi's team has a graph that reviews pull requests. A lint node and a test node both set a field called verdict. Most days, both pass, so nobody notices.

One Friday, lint finds a secret key in the code and sets verdict: reject. Two seconds later the test node finishes and sets verdict: approve. The pull request is merged with the key inside.

The fix took one line. Lint writes lint_result. Tests write test_result. Only the review node reads both and writes verdict. Now the verdict has one owner, and it sees both results.

What You Will Learn

  • The kinds of node and the kinds of edge, as a graph file writes them
  • How to write a state contract: type, writer and readers
  • How to find a field with two writers, and fix it
  • Why a node should read only the fields it needs
  • How a checkpoint lets a crashed run resume

The Three Parts of Every Graph

In Lesson 1 you met the three parts: nodes, edges and shared state. Now you write them down in a graph file. Every graph in this course is a plain YAML file with the same parts.

graph: check-ticket
checkpoint: after_each_node
nodes:
  - { id: lint,   kind: function, reads: [diff], writes: [lint_result] }
  - { id: tests,  kind: function, reads: [diff], writes: [test_result] }
  - { id: report, kind: model,    reads: [lint_result, test_result], writes: [status, summary] }
edges:
  - { from: lint, to: tests }
  - { from: tests, to: report }
state:
  lint_result: { type: "pass | fail", writer: lint }
  test_result: { type: "pass | fail", writer: tests }
  status:      { type: "ready | blocked", writer: report }

Each node is one line. It has an id, a kind, the fields it reads and the fields it writes. Writing a node on one line means you rarely need to fix the spaces at the start of a line.

Seven Kinds of Node

KindWhat it isExample
functionPlain code. No model.Run the tests, check a schema
modelOne prompt in, one answer outWrite a summary
agentA full agent running its own loopFix a bug until the tests pass
routerReads the item and picks the next edgeSend risky tickets one way
joinWaits for parallel branches and merges themMerge three audit reports
orchestratorFinds work at run time and starts workersFind every old API call
gateStops and waits for a personApprove a payment change

Every agent node is a loop from the last course. It still needs a checker and a stop rule, which go under exit:.

Four Kinds of Edge

Read the kind of each edge

const edges = [
  { from: "lint", to: "tests" },
  { from: "route", to: "careful_fix", when: "risk == high" },
  { from: "fixer", to: "fixer" },
  { from: "split", to: ["security", "licence", "size"] },
];

function kindOf(e) {
  if (Array.isArray(e.to)) return "fan-out";
  if (e.from === e.to) return "loop-back";
  if (e.when) return "conditional";
  return "fixed";
}

for (const e of edges) console.log(e.from + " -> " + e.to + " : " + kindOf(e));

Expected output:

lint -> tests : fixed
route -> careful_fix : conditional
fixer -> fixer : loop-back
split -> security,licence,size : fan-out

A fan-in is the other end of a fan-out: several edges arriving at one join node. You meet it in Lesson 5.

The State Contract

The state: part of the file is the contract. It says what each field is and who owns it.

Before: lint and tests both write status, so the last one wins. After: each check writes its own result, and one node decides the status.

A good contract follows two rules.

Rule 1: every field has exactly one writer. If two nodes write the same field, the one that runs last wins, and the other result is lost without any error.

Find fields with more than one writer

const nodes = [
  { id: "lint",   writes: ["lint_result", "status"] },
  { id: "tests",  writes: ["test_result", "status"] },
  { id: "report", writes: ["summary"] },
];

const writers = {};
for (const n of nodes) {
  for (const f of n.writes) (writers[f] = writers[f] || []).push(n.id);
}

for (const [field, who] of Object.entries(writers)) {
  console.log(field + ": " + who.join(", ") + (who.length > 1 ? "   <- two writers!" : ""));
}

Expected output:

lint_result: lint
status: lint, tests   <- two writers!
test_result: tests
summary: report

Rule 2: every node reads only what it needs. A node that reads * gets the whole state, including fields it does not understand and results it should not trust. It also costs more tokens, because every field goes into its context.

What a node sees: the whole state, or a whitelist

const state = {
  ticket: "#7 empty list shows undefined",
  diff: "+12 -3 lines",
  lint_result: "fail",
  test_result: "pass",
  notes: "tried 4 ideas, 3 were wrong",
};

function view(reads) {
  if (reads.includes("*")) return state;
  return Object.fromEntries(reads.map((f) => [f, state[f]]));
}

const whole = view(["*"]);
const listed = view(["lint_result", "test_result"]);
console.log("reads *       -> " + Object.keys(whole).length + " fields: " + Object.keys(whole).join(", "));
console.log("reads a list  -> " + Object.keys(listed).length + " fields: " + Object.keys(listed).join(", "));

Expected output:

reads *       -> 5 fields: ticket, diff, lint_result, test_result, notes
reads a list  -> 2 fields: lint_result, test_result

Writing a writer: next to each field in state: is optional in the file, but it is a good habit. It makes the owner of every field visible at a glance, and the lab warns you when the nodes disagree with it.

Types Make Fields Checkable

A type says what a field may hold. "pass | fail" can only be one of two words. "ready | blocked" can only be one of two. A node that tries to write "looks fine to me" into a "pass | fail" field has done something wrong, and a check can catch it.

Plain text is fine for a summary a person reads. But any field another node decides with should have a small, exact type.

Checkpoints: Resume, Don't Restart

A graph run can stop halfway: the laptop sleeps, the network drops, a machine restarts. Without a checkpoint, the run starts again from the first node, and you pay for those nodes twice.

Without a checkpoint, a crash means starting again. With one, the run resumes after the last finished node.

A checkpoint saves the state after each node, in a file on disk. After a crash, the graph reads the file and continues from the last finished node. This is the same idea as the Loop course's state file, now for the whole graph.

Resume from a checkpoint

const order = ["lint", "tests", "report"];
const saved = { lint_result: "fail", test_result: "pass" }; // written before the crash

function resumeFrom(saved) {
  const done = { lint: "lint_result", tests: "test_result", report: "status" };
  return order.find((node) => !(done[node] in saved));
}

console.log("checkpoint has: " + Object.keys(saved).join(", "));
console.log("resume at: " + resumeFrom(saved));
console.log("nodes run twice: 0");

Expected output:

checkpoint has: lint_result, test_result
resume at: report
nodes run twice: 0

A crash halfway, with no checkpoint

A checkpoint is also how a graph survives a human gate. When a node waits a day for a person's approval, the state waits safely on disk, not in a chat window.

See the Contract in the Lab

Open the State tab for any scenario. It shows every field as a table: its type, who writes it and who reads it. A field with two writers is shown in red. You can edit the contract there: pick the one writer, tick the readers, set the type. Every change is written to the graph file.

The Canvas tab shows the same graph as boxes and arrows. Add a node from the palette, select it and press Connect to draw an edge, or Delete to remove it. It works with a mouse, a touch screen or the keyboard (Tab to a node, Enter to select, C to connect). The canvas and the graph file are always the same graph.

Read the state contract as a table

A Contract Check in Code

Here is the whole contract check in a few lines: one writer per field, and no node reading everything.

Check a state contract

const graph = [
  { id: "lint",   reads: ["diff"], writes: ["lint_result"] },
  { id: "tests",  reads: ["diff"], writes: ["test_result"] },
  { id: "report", reads: ["lint_result", "test_result"], writes: ["status", "summary"] },
];

const problems = [];
const owner = {};
for (const n of graph) {
  if (n.reads.includes("*")) problems.push(n.id + " reads everything");
  for (const f of n.writes) {
    if (owner[f]) problems.push(f + " has two writers: " + owner[f] + ", " + n.id);
    owner[f] = n.id;
  }
}
console.log(problems.length ? problems.join("\n") : "contract ok: " + Object.keys(owner).length + " fields, one writer each");

Expected output:

contract ok: 4 fields, one writer each

Lab Exercise: Write a Safe State Contract

Now fix the graph from the start of this lesson.

Write a safe state contract

Open the graph file. Change it so that:

  1. Every field has one writer. Only the report node writes status.
  2. No node reads *. The report node reads lint_result and test_result.
  3. The graph has checkpoint: after_each_node at the top.

You can make these changes in the Graph file, in the State tab, or both. Then press Check my graph. The check runs 3 versions: lint fails, tests fail, and both pass. Each version crashes at a different node. Your graph must end with the right status every time, and resume after the crash.

Don't fix the overwrite by changing the order of the nodes. Whichever node runs last still wins, and next week someone adds a fourth node. One writer per field is the only fix that keeps working.

Common mistakes

MistakeWrong ideaCorrect ideaWhat goes wrong
Two writers"Both nodes know the status, so both can set it."One node owns each field. Others write their own results.The last writer wins, and a failure is lost.
Reading everything"Give every node the whole state, just in case."Each node lists the fields it needs.Extra tokens, and nodes act on results that are not theirs.
Untyped decision fields"status can just be text."Small exact types: "ready | blocked".A node writes "probably fine" and nothing can check it.
No checkpoint"Runs are short, crashes are rare."Save state after each node.A crash halfway pays for the first half twice.
Fixing by order"Put the important node last."Fix the contract, not the order.The next new node breaks it again.

Exercises

Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.

What to Remember

  • A graph file has nodes (one per line), edges and a state: contract.
  • Every field has one writer. Two writers means the last one silently wins.
  • Every node lists what it reads. Nobody reads *.
  • Fields that other nodes decide with get small, exact types.
  • A checkpoint saves state after each node, so a crash means resume, not restart.

Further Reading

Next: Prompt Chaining. One agent investigates and writes in the same context, copies a dead end, and you split it into two nodes with a clean handoff.