Chapter 2 of 7 · 20 min read
A plan is an object a reviewer can read before anything happens
On this page
Three weeks after a reconciliation agent went to production, it truncated ledger.charges in the staging environment. Nothing was lost — staging is rebuilt nightly — but an incident review asked an uncomfortable question, because the agent had planning switched on and had emitted a plan before it acted. Somebody had read the run afterwards and found the offending step sitting there in plain text, four minutes before it executed.
The plan said this, as an ordinary assistant message:
First I'll pull the March export from S3 and get it into a clean staging table — I'll clear out whatever's there from the previous run first. Then I'll join it against our ledger, write up the discrepancies, and issue credits for anything the report flags as an overcharge.
Every word of that is true about what the agent did. "Clear out whatever's there from the previous run" turned into warehouse.truncate against ledger.charges rather than staging.vendor_invoice_202603, because the agent had confused which table it had loaded into. There was a four-minute window in which the plan was visible and the truncate had not happened. Nothing consumed that window, because there was nothing in the plan a consumer could act on. A regex for truncate finds nothing. A policy check that blocks writes to the ledger schema has no table name to match. A reviewer skimming the run sees a sentence that sounds correct.
That is the whole subject of this chapter. The plan bought the run a window; the window was worthless because the artifact was not in a form anything could inspect.
The failure is worth stating in its strongest form, because in that form it stops being an anecdote about sloppy prose. The plan was true. It was an accurate advance description of every action the agent went on to take, including the destructive one, and it was available for four minutes before anything happened. Truthfulness is the property people imagine they are buying when they ask an agent to plan, and here it was fully present and bought nothing, because truthfulness is not an input to any check the harness can run. A checker takes a step and a policy and compares identifiers. The prose plan contained no identifier — no table name, no tool name, no bucket key — so the checker had nothing to put on either side of a comparison, and its verdict was independent of whether the plan was accurate, inaccurate, benign or catastrophic. What a readable plan certifies is that a person could have objected. It never certifies that a program could have, and only the second one runs on every run at three in the morning.
Where the plan lives decides who can see it
There are three common places to put it, and they differ in ways that show up in production rather than in a demo.
Leaving it as assistant text is the default, because it happens for free — ask the model to plan and it writes a paragraph. The plan is then part of the conversation, which means it goes wherever your traces go — a trace being the stored record of one run's messages, tool calls and timings that your observability tooling keeps — and that is the end of its properties. It has no schema, so nothing can validate it. It is interleaved with the model's other prose, so extracting it later means parsing. And when the run gets long enough that history is compacted or trimmed, which is the harness summarising or dropping older messages so that the conversation still fits inside the model's context window, the plan is a candidate for eviction along with everything else, so a long run can end up executing steps 6 through 9 of a plan that is no longer in context.
That last consequence deserves its steps spelled out, because it is the one people find hardest to believe about their own system. Compaction works on the message array. The plan, in this arrangement, is a message in the message array. Compaction has no way to know that this particular assistant turn is load-bearing, since nothing marks it as different from the model's other prose. So on a long enough run the plan becomes eligible for removal on exactly the same terms as an old tool result. The agent does not stop following the plan at that point; it stops seeing it, and carries on from whatever summary replaced it. A plan that governs steps 6 through 9 and is absent from the context during steps 6 through 9 is not a control, and nothing in the run reports the transition.
Emitting it through a dedicated tool changes all of that. The model calls write_plan with a structured argument; the harness validates the argument, stores it outside the message array, and returns a short acknowledgement. This is what the todo-list tools in current coding agents do, and it is what the Deep Agents overview in LangChain's documentation describes as the planning tool pattern. The plan becomes a first-class object in your system: it has a schema, it lands in your logs with a stable shape, a check can run against it synchronously before the acknowledgement returns, and the harness — not the model — decides how much of it to keep in context.
The word doing the work in that paragraph is synchronously. A tool call is a request the harness answers, so the handler runs before the model receives anything back, and until the handler returns there is no next turn for the model to take. That is what turns a check into a gate: refusing inside the handler means the refusal happens at a moment when the agent cannot yet act, rather than being an observation about an action already taken. Every other placement in this section is judged against that one property.
Writing it to a file on disk is the third option, and it is the one coding agents converge on for long tasks. A todo.md the agent rewrites as it goes survives compaction, because it is re-read rather than remembered, and it survives process restarts. What it does not give you for free is a gate: a file the agent writes is a file the agent has already written, so any check on it runs after the write and has to be wired to block the next action rather than this one.
| Assistant text | write_plan tool | File on disk | |
|---|---|---|---|
| Has a schema | No | Yes, enforced by the harness | Only by convention |
| Machine-checkable before the next action | No | Yes, in the tool handler | Yes, if the harness re-reads it |
| Appears in logs as a distinct record | No — buried in message text | Yes | Yes |
| Survives context compaction | No | Yes — stored outside the messages | Yes |
| Can be revised mid-run | Only by writing another paragraph | Yes, as another tool call | Yes, by rewriting the file |
| Cost to adopt | Zero | A tool schema and a handler | A tool schema and a file convention |
Read the table by its gaps rather than by its rows and one thing is doing all the work. The file column matches the tool column almost everywhere: both survive compaction, both appear in logs, both can be revised. The two places they part are the first row and the second, and the second is a consequence of the first — a thing with no enforced schema cannot be validated by a handler, so it cannot be checked before the next action except by the harness choosing to re-read it and impose structure after the fact. The distance between the columns is therefore one property, not five, and that property is who owns the moment of writing. When the model writes into a handler you control, you get to answer; when the model writes to a filesystem, the write has already happened by the time you hear about it. The last row prices exactly that difference, and it is small.
For anything with side effects worth reviewing, the tool form is the one that earns its keep, because the gate is a function you control rather than a hope that somebody reads the transcript.
The fields that make a step checkable
A step is checkable when a program that has never seen this task can decide whether it is allowed. That rules out verbs and admits identifiers. The prose plan above fails on both counts; the same intent, written to a schema, does not.
The reason identifiers work and verbs do not is worth one more step down, because it is the whole design constraint. A check is a function from a step and a policy to a verdict, and it has to compute that verdict without any understanding of the task, since understanding the task is exactly what the harness does not have. So every term it compares has to be drawn from a finite, enumerable set the harness already holds — the list of tools that exist, the list of table prefixes that are protected. "Clear out whatever's there" is a phrase; there is no set it belongs to and no policy it can be matched against. ledger.charges is a string that either does or does not start with a protected prefix. The difference is not that one is more precise. It is that only one of them is comparable to something.
PLAN_SCHEMA = {
"type": "object",
"required": ["goal", "steps"],
"properties": {
"goal": {"type": "string"},
"steps": {
"type": "array",
"items": {
"type": "object",
"required": ["n", "tool", "args", "effect"],
"properties": {
"n": {"type": "integer"},
"tool": {"type": "string"},
"args": {"type": "object"},
# what this step does to the world, declared by the model
"effect": {"enum": ["read", "write", "external", "money"]},
"targets": {
"type": "array",
"items": {"type": "string"},
},
},
},
},
},
}tool and args make the step executable-in-principle, which is what lets a check compare it against the tools that exist. targets names the resources the step touches — table names, S3 keys, account IDs — which is what lets a check compare it against a policy. effect is the model's own claim about severity, and it is useful precisely because it can be wrong: a step declared read whose tool is warehouse.truncate is a contradiction the harness can catch without understanding the task.
That last clause is the sharpest mechanism in the schema and it is easy to read past, so here is why it works. The model emits two fields independently — tool, which says what will be called, and effect, which says how dangerous it is. The harness holds a third thing the model did not write: a table of which tools are destructive. Checking effect against that table is checking one model output against a fact the harness owns, and a mismatch is decidable without anybody knowing what March invoices are. So the mechanism buys a specific and narrow guarantee — a step cannot be mislabelled past the checker — and it buys nothing at all about steps that are labelled honestly. A warehouse.truncate declared write against a protected table is caught by the policy rule; a warehouse.truncate declared write against a table nobody protected sails through with every field accurate.
Rewritten to that shape, the incident's step 2 stops being ambiguous:
{
"n": 2,
"tool": "warehouse.truncate",
"args": {"table": "ledger.charges"},
"effect": "write",
"targets": ["ledger.charges"]
}There is now a table name in a field, and a fifteen-line check finds it.
PROTECTED = ("ledger.", "billing.", "prod_")
def check(plan, available_tools):
problems = []
for s in plan["steps"]:
if s["tool"] not in available_tools:
problems.append(f"step {s['n']}: no such tool {s['tool']!r}")
for t in s.get("targets", []):
if s["effect"] != "read" and t.startswith(PROTECTED):
problems.append(f"step {s['n']}: writes to protected target {t}")
if s["tool"] in DESTRUCTIVE and s["effect"] == "read":
problems.append(f"step {s['n']}: {s['tool']} declared as read")
return problemsThree comparisons in there, and each one draws its right-hand side from somewhere the model cannot reach: available_tools is passed in by the harness, PROTECTED is a constant in your source, and DESTRUCTIVE is a table the harness maintains alongside its tool definitions. That is the property to preserve if you write your own. The moment a check compares one part of the plan against another part of the same plan, it is asking the model to mark its own work, and it will pass.
The protected-target rule has a hole in it that the code itself makes visible, and finding it is a good exercise in reading a check for what it certifies rather than for what it looks like it does. The loop over targets is written to tolerate a step that has no targets field, and the schema lists n, tool, args and effect as required while leaving targets optional. Put those two facts together. For a step with no declared targets, the loop body never executes, so no protected-target problem can be appended, so the step is reported clean — and it is reported clean by identity, whatever tool it calls and whatever table sits in its arguments. The verdict does not depend on the step at all. What this check certifies is that no declared target is protected; what a reader will assume it certifies is that no protected resource is touched, and a step that simply omits the field slides between those two sentences. Requiring targets on every non-read step, or treating a missing targets as a problem in its own right, closes it; leaving args as the only record of what a step touches does not, because args has no fixed shape the checker can read a resource out of.
What a broken step looks like on paper
Two failure classes show up in plans, and they read differently.
An impossible step is one that cannot execute as written, and the giveaway is almost always a name. It calls a tool that is not in the tool list, because the model produced a plausible verb — notify_customer where the harness offers send_email. It references an artifact that does not exist, like an output of step 3 that step 3 never produces. Or it depends on a resource whose lifetime has expired: the March export lives in a bucket with a 24-hour lifecycle rule, so a plan written on the 12th that reaches for vendor/exports/2026-03-invoice.csv on the 14th names a key that S3 deleted. Impossible steps are cheap to detect at plan time and expensive to hit at execution time, and the second and third kinds are the reason chapters 4 and 5 exist.
The three kinds are not equally catchable at plan time, and the reason is that they differ in where the truth lives. A missing tool is settled entirely inside the request: the harness passed in the tool list, so the answer needs nothing external. A missing artifact is settled inside the plan: walk the steps and ask which one produces the thing step 3 consumes. An expired resource is settled nowhere in either — it is a fact about a bucket, at a time, and the answer can change between the check and the step. So the first two are decidable at plan time and stay decided, and the third is only ever a snapshot. That is the crack, and it is the reason the two chapters after this one exist rather than a single one.
An unsafe step executes perfectly and does something you did not want. The truncate is one. So is a step with "effect": "money" and no cap in its arguments, a step that emails a list whose size the plan never bounds, and a step that writes to a path outside the run's working directory. These are not detectable by asking whether the step will work — it will. They are detectable by comparing the step's declared targets against a policy that says what this agent is allowed to touch, which is why targets is in the schema at all.
Those four examples share a structure that generalises past the list. In each one the step is bounded on the inside and unbounded on the outside: it has a well-formed tool call and no ceiling on the size of its effect. The cap on a money step, the size limit on an email list, the directory root for a write — each is a number or a prefix that the task does not determine and the policy does, and its absence is what makes the step unsafe rather than merely large. So the useful question to ask of a plan is not whether each step is correct but whether each step's blast radius is written down somewhere, and by whom. A step that declares its own cap is a step the model bounded; a step whose cap comes from your policy is a step you bounded, and only the second survives a model that is confused.
Where the check sits in the loop from topic 10.3 is a small change with a large consequence: the plan is produced, checked, and only then does the loop begin executing anything.
The E --> B edge matters as much as the gate itself. A rejected plan is a repair opportunity, and the errors the checker produced are exactly the feedback the model needs to produce a valid one: told that notify_customer is not a tool and given the list that is, the next plan generally names the right tool. Two attempts is a reasonable ceiling; a plan that fails validation twice is a task the agent has misunderstood at a level replanning will not fix.
One thing about that diagram is a design decision rather than a requirement, and it is the branch at F. Routing to human approval on money and external effects means the gate consults a field the model filled in, so a step whose effect is understated skips the hold — and the mislabelling check earlier only catches understatement when the tool is in the harness's destructive table. Nothing forces this design. The same graph works with the branch reading the tool rather than the declared effect, which moves the decision entirely onto ground the harness owns and costs you the ability to hold a step whose danger is in its arguments rather than in its verb. Which of the two you want depends on your tool surface, and picking either one deliberately is the point.
The plan is a record, not a document
Treating the plan as a thing written once is the assumption that makes plan artifacts hard to audit later. Agents revise: a step turns out to need two steps, an argument was wrong, an obstacle appears. If revision happens by the model writing a new paragraph, the run ends up containing three overlapping accounts of what was going to happen and no way to tell which was in force when step 4 executed.
Through a tool, revision is a second write_plan call, and the harness gives it a version. The check runs again on the new version, because a revised plan is an unchecked plan — this is the case where the truncate would have slipped in on version 2 of a plan whose version 1 was clean. And every executed step records the plan version it came from, so the trace answers the question an incident review actually asks, which is not "what was the plan" but "what was the plan at 09:12:04".
Versioning also gives you the only cheap signal that a run is going badly, and cheap is meant literally. A run on plan version 5 is a run whose model has changed its mind four times, because a run's first version is not a revision and every version after it is exactly one — so the count of revisions is always the count of versions minus one. Nothing has to be instrumented to obtain that number. It already exists as a consequence of storing the versions at all, which means the cheapest health signal in the whole apparatus is a field you are keeping anyway, and counting versions costs nothing.
What to do about that count is chapter 6's problem, and it is a more interesting problem than "cap it at two"; the point here is that the count only exists because the plan is a record with revisions rather than prose that got overwritten.
The two things the checker never compares
None of this says the model reasons better because it planned. That claim is separately arguable and mostly unmeasured, and chapter 7 shows how to settle it for a specific agent rather than in general. The operational payoff stands on its own, and it is narrow enough to state exactly: the run now contains a concrete object, produced before any side effect, that a human or a program can evaluate and refuse. That is worth 1,200 tokens and three seconds on a task whose steps move money. It is worth nothing at all on a task whose steps are read-only queries, where the plan gets generated, checked, found clean, and then serves no further purpose except to occupy context.
It is also worth being honest about the scope of the check. Everything above validates the plan against the tool list and against policy. It does not validate the plan against the task — the truncate check fires on the wrong table, but nothing in that schema notices that the plan reconciles February's invoice when the ticket said March, or that it skips the step where the two record counts are compared before crediting anyone. Those are ordering and completeness properties, and a flat list of tool calls with argument objects carries no information about them.
The tempting belief here is the one to name, because it is what a clean checker run installs in a reader's head. Having watched the gate catch a truncate against a protected table, it is natural to conclude that a plan which passed the gate has been reviewed. It has not. Look at what the checker actually reads: the steps, the tool list, and the policy. The ticket is not among its inputs, and neither is anything else about what was asked for. So the verdict is a quantity computed entirely from inputs that exclude the task, which means it stays constant while the thing you care about varies without limit. A plan reconciling February's invoice passes every check that March's plan passes — same tools, same targets, same effects, same verdict — and the only difference between the two is the one difference the checker was never given. That is why a green gate is evidence about safety and no evidence at all about correctness.
The same gap explains why the two failure classes earlier are not symmetric in how far a plan-time check can take you. Impossible steps are largely settled by information the harness already holds, so a checker really can retire most of them before the run. Unsafe steps are settled by a policy, and a policy is a statement about resources rather than about goals, so a checker retires the ones your policy anticipated and no others. Neither class contains "right work, wrong month", because that is not a property of any step in isolation. It is a property of the relationship between the plan and the request, and nothing in a flat list of tool calls encodes a relationship.
A plan is worth its price when something other than the model can read it and refuse it before anything happens, and everything in this chapter is machinery for making that sentence literally true. Prose fails not through vagueness but through incomparability: a checker's inputs are enumerable sets, and a sentence belongs to none of them, so an accurate prose plan and an inaccurate one produce the same verdict, which is no verdict. A schema fixes that by forcing every step to name a tool, its arguments and the resources it touches, at which point a short function with a protected-prefix list can decide something. What it decides is real and it is narrow: this step calls a tool that exists, declares its severity consistently with a table you maintain, and does not name a resource your policy forbids. Every one of those is a claim about the step in isolation.
A list of steps in a schema is a checkable list of steps. Turning it into something that exposes a missing prerequisite before the run starts requires the plan to say what each step depends on, what it means for a step to have succeeded, and where the run can be picked up if it stops halfway.
Planning — read the current version at https://daviddominguez.dev/learn/en/planning/a-plan-is-an-object-a-reviewer-can-read-before-anything-happens