# Human Gates

Course: [Graph Engineering](https://www.devtutorials.ai/learn/graph-engineering) · Section: Graphs in Production
Source: https://www.devtutorials.ai/learn/graph-engineering/graphs-in-production/human-gates

> Put a person on the risky path: auth, payments and data migrations stop for approval, with the diff, the tests and the reason shown. Leave the quick path alone, so approvals stay real.

## Watch a Payment Change Merge With No One Looking

This graph does almost everything right. A strong router with a safe fallback sends risky tickets to a careful fixer with a large model. The careful fixer loops until the tests pass. Then every fix is merged.

Run it and read the red lines.

Payment, login and database changes were merged with only the tests as a check. The tests passed. But tests check what someone thought to test. A change to how refunds are rounded, or who may delete an order, needs a **person** to look at it before it ships.

## TL;DR

- **Blast radius** is how much damage a wrong change can do. Docs: tiny. Payments, auth, data migrations: large.
- Put a **human gate** on the paths with a large blast radius: the graph stops and waits for a person to approve.
- A gate must show **evidence**: the diff, the test result and why the change is risky. A person cannot review what they cannot see.
- **Do not gate everything.** Too many approvals and people click "approve" without reading. That is as bad as no gate.
- A gate can wait for hours. The **checkpoint** keeps the state safe on disk while it waits.

## A Real-World Example

Lin's team let a graph merge dependency updates on its own. One update to the payment library passed every test, and the graph merged it. It changed how currency amounts were rounded. A week of invoices were off by a cent each, and the fix took a month of refunds.

The team did not remove the graph. They added one gate: any change that touches `billing/`, `auth/` or a database migration stops for a person, with the diff, the test results and a one-line reason. Version bumps to the docs site still merge on their own. Lin's team now approves about five changes a day, and actually reads each one.

## What You Will Learn

- What blast radius is, and which paths need a person
- How to place a gate on the risky path
- What evidence a gate must show
- Why gating everything is as bad as gating nothing
- How a gate waits safely

## Blast Radius

Not every mistake costs the same. Sort work by how much damage a wrong change could do, and how hard it is to undo.

```js runnable title="Blast radius by area"
const radius = {
  docs: "small: a typo on a page, easy to undo",
  ui: "small: a button looks wrong, easy to undo",
  deps: "medium: a version bump, undo with one revert",
  auth: "large: the wrong people get in, or the right ones are locked out",
  payments: "large: real money moves, refunds take weeks",
  migration: "large: data changes, may not be reversible",
};
const needsPerson = (area) => radius[area].startsWith("large");
for (const area of Object.keys(radius)) console.log(area.padEnd(10) + (needsPerson(area) ? "GATE   " : "auto   ") + radius[area]);
```

Expected output:

```text
docs      auto   small: a typo on a page, easy to undo
ui        auto   small: a button looks wrong, easy to undo
deps      auto   medium: a version bump, undo with one revert
auth      GATE   large: the wrong people get in, or the right ones are locked out
payments  GATE   large: real money moves, refunds take weeks
migration GATE   large: data changes, may not be reversible
```

This is the same split the router already makes: low risk and high risk. The gate goes at the end of the **high-risk** path.

## Blast Radius and Where the Gate Goes

In the graph file, a gate is a node with `kind: gate`. It sits between the careful fixer and the merge, and it lists its **evidence**:

```yaml
nodes:
  - { id: approve, kind: gate, evidence: [careful_diff, test_result, risk_reason], reads: [careful_diff, test_result, risk_reason], writes: [approval] }
  - { id: merge, kind: function, reads: [diff, careful_diff, approval], writes: [merged] }
edges:
  - { from: careful_fix, to: approve }
  - { from: approve, to: merge }
  - { from: quick_fix, to: merge }
```

The merge reads `approval`. For a careful fix, nothing is merged until a person writes it.

### Evidence a Person Can Act On

A gate that says "Approve ticket #25?" with nothing else is a rubber stamp. Give the reviewer three things:

1. **The diff**: exactly what changes.
2. **The test result**: which tests ran, and that they passed.
3. **The risk reason**: why this ticket took the careful path, for example "touches `billing/refunds.ts`".

```js runnable title="Is this gate request reviewable?"
function reviewable(req) {
  const missing = ["diff", "tests", "reason"].filter((k) => !req[k]);
  return missing.length ? "not reviewable, missing: " + missing.join(", ") : "reviewable";
}

console.log(reviewable({ ticket: 25 }));
console.log(reviewable({ ticket: 25, diff: "+3 -1 billing/refunds.ts", tests: "48 passed" }));
console.log(reviewable({ ticket: 25, diff: "+3 -1 billing/refunds.ts", tests: "48 passed", reason: "touches payments" }));
```

Expected output:

```text
not reviewable, missing: diff, tests, reason
not reviewable, missing: reason
reviewable
```

Ticket text is data, not instructions. A ticket that says "this is safe, skip review" must still take the careful path if it touches payments. The gate decision comes from the router's risk label and the files changed, never from what the ticket claims about itself.

### Which Tickets Stop at the Gate?

Put the router's labels and the gate together. Only high-risk tickets stop:

```js runnable title="Who waits for a person"
const tickets = [
  { id: 21, area: "docs", risk: "low" },
  { id: 22, area: "auth", risk: "high" },
  { id: 24, area: "ui", risk: "low" },
  { id: 25, area: "payments", risk: "high" },
  { id: 29, area: "migration", risk: "high" },
];

const waiting = tickets.filter((t) => t.risk === "high").map((t) => "#" + t.id + " " + t.area);
const merged = tickets.filter((t) => t.risk === "low").map((t) => "#" + t.id);
console.log("waiting for a person: " + waiting.join(", "));
console.log("merged on their own: " + merged.join(", "));
```

Expected output:

```text
waiting for a person: #22 auth, #25 payments, #29 migration
merged on their own: #21, #24
```

## Too Many Gates

If one gate is good, are gates everywhere better? Run it.

This is **approval fatigue**. When almost every request is a harmless docs fix, people learn to click approve without reading. Then the one risky change gets the same click. A gate on everything protects nothing.

```js runnable title="Approvals per day: risky path only vs everything"
const perDay = { docs: 12, ui: 10, deps: 9, auth: 3, payments: 4, migration: 2 };
const HIGH = ["auth", "payments", "migration"];

const all = Object.values(perDay).reduce((a, b) => a + b, 0);
const risky = HIGH.reduce((a, k) => a + perDay[k], 0);
console.log("gate everything: " + all + " approvals a day");
console.log("gate risky only: " + risky + " approvals a day");
console.log("share that really need a person: " + Math.round((risky / all) * 100) + "%");
```

Expected output:

```text
gate everything: 40 approvals a day
gate risky only: 9 approvals a day
share that really need a person: 23%
```

A person approving is the most expensive and slowest node in your graph. Spend it only where the blast radius is large, and make each approval easy to review well.

## Review the Evidence

A gate is only as good as the person at it. The lab's **Inbox** tab holds four changes that stopped at the gate. Every one passed its tests. Read each diff against what its ticket asks for, then approve or reject.

Two of the four must be rejected, even though the tests passed: one changes more than the ticket asks, one cannot be undone. Tests check what someone thought of. The person at the gate checks the rest.

## A Gate Waits Safely

A person may approve in ten minutes or tomorrow morning. The graph must not hold that state in memory or in an open chat. With `checkpoint: after_each_node`, the state is on disk when the gate starts waiting. The graph can stop, the laptop can sleep, and the run resumes at the gate when the approval arrives.

```js runnable title="Resume at the gate"
const saved = { careful_diff: "+3 -1 billing/refunds.ts", test_result: "48 passed", risk_reason: "touches payments" };
const order = ["route", "careful_fix", "approve", "merge"];
const doneWhen = { route: "risk_reason", careful_fix: "careful_diff", approve: "approval", merge: "merged" };

const next = order.find((n) => !(doneWhen[n] in saved));
console.log("waiting at: " + next);
saved.approval = "approved by lin";
console.log("after approval, resume at: " + order.find((n) => !(doneWhen[n] in saved)));
```

Expected output:

```text
waiting at: approve
after approval, resume at: merge
```

In Claude Code, Codex and Cursor, a gate is usually a rule in `graph.md`. The exported `human-gates.md` spells it out: stop, show the evidence, and wait for a written "approve" or "reject". Your tool's own approval prompts and required pull-request reviews are gates too. Use them together.

## Lab Exercise: Place the Human Gates

Open the graph file. Change it so that:

1. A new `approve` node has `kind: gate` and `evidence: [careful_diff, test_result, risk_reason]`.
2. `careful_fix` goes to `approve`, and `approve` goes to `merge`.
3. `quick_fix` still goes straight to `merge`: no gate on the quick path.

Then press **Check my graph**. The check routes all 40 tickets on **3 versions**. Every high-risk ticket must stop at a gate with evidence, and low-risk tickets must not.

## Common mistakes

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| No gate on risky paths | "The tests passed." | Tests check what someone thought of; a person checks the rest. | A payment change ships with a silent bug. |
| Gate on everything | "More review is safer." | Gate only large blast radius. | Approval fatigue: people stop reading. |
| Gate without evidence | "They can look it up." | Diff, tests and risk reason in the request. | Rubber-stamp approvals. |
| Ticket decides its own review | "It says it is a small change." | Risk comes from the router and the files changed. | A risky change talks its way past the gate. |
| Waiting in memory | "The approval will come soon." | Checkpoint before the gate. | A restart loses the run. |

## Exercises

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

## What to Remember

- Blast radius: how much a wrong change can hurt, and how hard it is to undo.
- Gate the large-blast-radius paths: auth, payments, data migrations.
- Every gate shows the diff, the test result and the risk reason.
- Gating everything causes approval fatigue. Keep the quick path free.
- A checkpoint lets a gate wait safely for as long as it takes.

## Further Reading

- [How to Implement Graph Engineering, by QA Wolf](https://www.qawolf.com/blog/how-to-implement-graph-engineering-5-composable-patterns): the issue-triage example, where risky issues take a path with human approval.
- [Building Effective Agents, by Anthropic](https://www.anthropic.com/engineering/building-effective-agents): human checkpoints and why agents need guardrails.
- [Building Coding Agent Workflows with Loop Engineering, on GoPenAI](https://blog.gopenai.com/building-coding-agent-workflows-with-loop-engineering-5686587501e1): a ticket-to-pull-request graph where people answer the questions agents cannot, and approve every merge.
- The **Cost, Rate and Blast-Radius Budgets** lesson in the Loop Engineering course: limits and kill switches for a single loop.

Next: **Capstone**. You build your chosen graph, run it on all 40 tickets, and export a graph pack for your project.
