Chapter 5 of 7 · 22 min read
A stale step is only caught by a check that runs before it
On this page
A plan is written in one moment and executed over the several minutes that follow, and the world does not hold still in between. A Saturday reconciliation run of the kind chapter 4 worked through — where an expiry rule on a bucket deleted a vendor export mid-run — makes the shape visible. Step three fetched a key that no longer existed, step four loaded zero rows into a staging table, and every step from there to the end reported success. Nothing raised. What went wrong is not that a step failed; it is that a step succeeded at the wrong thing while carrying an assumption that had quietly stopped being true.
A step in that condition is stale. Its tool, its arguments and its purpose
were fixed against a snapshot of the world taken at planning time, and the
snapshot has since moved. One property of staleness makes it much harder to
catch than an ordinary failure, and it is worth having in hand before any
machinery arrives: staleness is invisible from inside the thing going stale. A
step holds arguments, not a clock. warehouse.load pointed at a source that
vanished ten minutes ago is byte-for-byte the step it was when the plan was
written, and running it produces a well-formed load of nothing. The staleness is
a relation between the step and a system the step never reads, so no amount of
inspecting the step reveals it.
That is what a precondition is for: a claim about the state of the world that has to hold for a step to mean what it was written to mean, evaluated against the world rather than against the step. Its partner is a postcondition, the same kind of claim about the state a step was supposed to leave behind. This chapter is about where those claims get their facts, what happens when one of them fails, and which failures they still cannot see.
Where a check gets its facts
A check is a function. Like any function, its output can only vary with its inputs — that is not a fact about checkers, it is the definition of a function, and it settles every case rather than the ones anybody happens to test. So the first question to ask of any precondition check is not how good it is. It is what it reads.
Consider a check assembled the obvious way: a model call receiving the plan and
the transcript of the run so far, answering whether step four's precondition
holds. Enumerate what is in that prompt. The plan is a document the model wrote
before the run began. The transcript is a record of what the run has done, and
every entry in it was generated by the run — a tool name the executor
dispatched, an argument object the model chose, a return value a tool reported
about itself. Chapter 4 established what that last category is worth: a load of
zero rows reports {"status": "ok"}, because loading zero rows is a legal load.
Nowhere in that prompt is there a reading of S3.
The consequence follows immediately. The verdict is computed from inputs that do not include the object store, so the verdict cannot vary with whether the object is in the object store. Such a check is not unreliable, or noisy, or sometimes wrong. It is constant with respect to exactly the property it was built to detect. Whatever it answers, it answers again in the world where the file is present and in the world where it is gone, because those two worlds are identical as far as its inputs are concerned.
What that licenses is the whole of the teaching here. A check reading the plan and the transcript certifies that the plan is internally consistent — that step four's stated precondition follows from step three's declared effect — and certifies nothing whatever about whether the world agrees. Both are real properties and only one is the property wanted. A check that draws its facts from the plan is a check that will confirm the plan.
This is the single most common way precondition validation gets built and the
single most common way it produces nothing, and it fails silently. The silence
is not incidental: a verdict constant at satisfied: true produces exactly the
output stream a correct checker produces on a healthy system, green every time,
with no error rate to watch and nothing to alert on. The only observable
difference between a working gate and one wired to nothing is a failure it
should have caught, which arrives once and arrives as an incident.
The design decision that produced it is separable from the requirement, and worth naming. The requirement was to decide whether step four may run. Nothing in it says a language model must be what decides. The model got the job because the precondition had been written as English prose — the March export has been downloaded to the load staging area — and prose needs a reader. Change the form of the precondition and the reader becomes unnecessary.
The probe is the check
A precondition earns the name when it is a read-only call against the system it makes a claim about, whose result the harness compares to an expected value without asking anybody's opinion. That means preconditions are not prose. They are bound tool calls, and the model's job at plan time is to name which one.
PROBES = {
"object_exists": lambda bucket, key: s3.head(bucket, key)["size_bytes"] > 0,
"table_rowcount": lambda table, min: warehouse.scalar(
f"select count(*) from {table}") >= min,
"column_exists": lambda table, col: warehouse.scalar(
"select count(*) from information_schema.columns "
"where table_name=%s and column_name=%s", table, col) == 1,
"job_state": lambda job_id, want: warehouse.job(job_id)["status"] == want,
"index_valid": lambda name: warehouse.scalar(
"select indisvalid from pg_index where indexrelid = %s::regclass",
name) is True,
}Read the entries against the test from the previous section. object_exists
calls s3.head, so its answer is fixed by what is in the bucket at the instant
it is asked. column_exists queries information_schema.columns, the
catalogue the warehouse keeps of its own schema. index_valid reads
indisvalid from pg_index, the flag Postgres sets to say an index is usable
rather than merely present. Every one of those
functions issues a request that leaves the process. None of them can be satisfied by anything the model wrote.
A step then declares which probes have to hold going in and which have to hold coming out:
{
"n": 4,
"tool": "warehouse.load",
"args": {"target": "staging.vendor_invoice_202603", "source": "/tmp/2026-03-invoice.csv"},
"effect": "write",
"depends_on": [3],
"pre": [{"probe": "object_exists", "bucket": "vendor-drops",
"key": "vendor/exports/2026-03-invoice.csv"}],
"post": [{"probe": "table_rowcount", "table": "staging.vendor_invoice_202603",
"min": 60000}]
}Nothing in that object is prose. pre names a probe from the registry and
supplies its arguments, so the harness constructs the call without interpreting
anything, and post does the same for the state the step should leave behind.
The depends_on edge from chapter 3 said only that step four waits on step
three; it now has a companion saying what the waiting was for.
Replaying Saturday against this executor produces a different transcript at the same instant.
09:12:04 step 3 s3.get key=vendor/exports/2026-03-invoice.csv
<- {"error": "NoSuchKey", "status": 404}
09:12:04 step 3 POST-CHECK object_exists(vendor-drops, vendor/exports/…) -> False
FAILED. step 3 did not reach its declared state.
09:12:04 classify: precondition of a downstream step; no remaining step produces it
action: REPLAN (budget: 1 of 2 replans used)Each of those three lines does a job the others cannot. The first is the tool's own report, which is all chapter 4's executor had. The second is an independent reading of S3 taken after the call, converting a return value into a fact. The third classifies the failure — a downstream step needs this state and no remaining step produces it — which selects the response. The 404 was in chapter 4's transcript too; what is new is the line below it that asked somebody other than the run.
The post check on step 3 and the pre check on step 4 are asking the same question of the same system, which is deliberate redundancy and costs one extra HEAD request. The post-check attributes the failure to the step that was supposed to establish the state; the pre-check catches the case where something else destroyed it in between. On Saturday they would both have fired. On a run where the lifecycle rule deleted the file between steps 3 and 4, only the pre-check fires, and that is the case worth spending 40 milliseconds on.
The redundancy buys attribution rather than detection, which is what makes it worth paying for. A step that failed to establish its own state is a bug in the step; a state that was established and then destroyed is a bug outside the run entirely — on Saturday, a lifecycle rule nobody had thought about since January.
Most conditions belong to the tool, not to the task
Having the model author probes at all is a weaker design than it first appears, for two reasons. If it writes free-form expressions, you have handed it an execution channel — a path by which text the model generated is run as code against your own systems — whose safety is whatever it emitted, and you have made the check exactly as reliable as the plan it is supposed to check. If it selects from a registry, as above, you have fixed the safety problem and kept the reliability one: a step whose probe the model forgot to declare executes unguarded, and forgetting is common.
The second is the one that survives casual review. A free-form expression is
visibly the model's work. A missing pre field is not visible at all: the plan
renders as a complete, well-formed object, passes the schema check because the
schema cannot require a field whose necessity depends on the task, and executes.
An omission has no representation in the artefact it is missing from.
The way out is to notice that most preconditions are properties of the tool rather than of the task. s3.get always requires its object to exist. warehouse.load always ought to end with more rows in the target than it started with. Those conditions are true of every plan that ever uses those tools, which makes them the tool author's business.
TOOL_CONTRACTS = {
"s3.get": {
"pre": [("object_exists", ("bucket", "key"))],
"post": [("local_file_nonempty", ("dest",))],
},
"warehouse.load": {
"post": [("table_rowcount", ("target",), {"min": 1})],
},
"run_sql": {}, # too general to have a contract
}
def conditions_for(step, phase):
fixed = TOOL_CONTRACTS.get(step["tool"], {}).get(phase, [])
return bind(fixed, step["args"]) + step.get(phase, [])The contract's arguments are bound from the step's own arguments, so the harness constructs the probe without the model naming anything. On Saturday, no plan-declared precondition was required to catch the deleted export: s3.get's contract fires on the missing object, and warehouse.load's contract fires on the zero-row load, both from a table written once by whoever added those tools.
Trace that derivation and the model's declared preconditions never enter it. The
tool contract supplies the probe, the step's own arguments supply the bindings,
and conditions_for adds the plan's pre list on the end as a separate term.
Delete that term and Saturday is still caught. What the arrangement licenses is
therefore exact: the guard rails exist whether or not the model was thorough,
and they certify nothing about the conditions only the task knows.
Its residual belongs in the same breath. An omitted pre field can no longer
break the guard rails, and can no longer be detected by watching them either,
since a plan with an empty pre list behaves like a plan with a good one until
a task-specific condition is the one that mattered. That failure mode moved from
fatal to silent, which is better and is not safe.
run_sql having an empty contract is the honest end of the same argument. A
tool general enough to run any statement has no condition true of every call, so
there is nothing for a contract to say and everything guarding it has to come
from the task. Generality and guardability trade against each other directly.
What remains for the model to declare is the task-specific part, and it is a much smaller and more honest job. min: 1 on a load is a tool property; min: 60000 on this load, because the invoice says 61,400 lines, is a fact about March that only the plan knows. Splitting the two means the guard rails exist whether or not the model was thorough, and the model's contribution is additive rather than load-bearing.
Read those two numbers as the gap between them rather than as levels. min: 1
rules out one failure only, a load that committed nothing, and it is the
strongest thing a tool author can say without knowing the task. min: 60000
additionally rules out a load that took the first few thousand lines and
stopped. The distance between them is exactly the knowledge the plan
contributed.
Six answers to a failed check
Failing loudly on everything is as wrong as failing on nothing, because a large fraction of check failures are the run correctly discovering that it has less work to do than it thought. The response has to be chosen per failure class.
| What failed | Situation | Action | Why |
|---|---|---|---|
post already true before the step ran | Column already added by an earlier attempt | Skip the step | This is the recovery probe from chapter 3 doing its job; re-running ADD COLUMN errors |
pre unmet, an earlier step in the plan produces it | Async backfill still RUNNING | Wait, bounded, then re-probe | The dependency edge meant completion; nothing is wrong yet |
pre unmet, nothing in the plan produces it | The deleted export | Replan | The world differs from the plan's assumptions; the path has to change |
post unmet, transient signature (timeout, 429, 503) | Warehouse under load | Retry the same step, bounded | The action was right and the system was busy |
post unmet, step's effect was money or external | Credits partially issued | Halt and hand back | You do not know how much of it happened; a machine should not guess |
| The probe itself errored | Warehouse unreachable | Halt | Unknown world state is not a passing check |
The table is easier to hold if it is read as a sequence of gaps rather than a list of six situations. One question separates every row from its neighbours: who owes the state that is missing, and are they still going to deliver it. Where the plan owes it and has already delivered, skip. Where the plan owes it and has not delivered yet, wait. Where nothing in the plan owes it, replan, because the path has to change. Where the system was momentarily at fault, retry. Where nobody can say and money has moved, halt. The response is a function of who owes the state, and the rows are the five answers plus one.
The last row is the plus one, and it is the one that gets written wrong first. try: probe() except: pass turns every outage into a green light, and it is a natural thing to write because probe failures are noisy in development. A probe that cannot answer has to be treated as a failure of the step it guards.
That is the defect the chapter opened with, arriving at a different layer.
except: pass makes the verdict constant with respect to the probe's answer,
exactly as a plan-reading checker made it constant with respect to S3. Both wire
a quantity that was supposed to track a dangerous property to something that
cannot vary with it, and both show green on a broken system. Wherever a check
has an unconditional path to true, that path is the check.
The skip row is the one that makes long runs survivable. A run that dies at step 5 of 7 and is restarted from the top will, with skips, walk steps 1 through 4 in about two seconds of probes and resume real work at 5. Without them, step 1 raises column already exists and somebody has to hand-edit the plan at 3 a.m.
The lifecycle of a step
Two things in that machine are worth naming. Executing is reachable only from Probing, so there is no path to a side effect that does not pass a fresh read of the world. And Verifying never returns to Done on the strength of the tool's own return value — the zero-row load that reported {"status": "ok"} on Saturday takes the edge to Replanning, because table_rowcount(min=60000) is a different question from did the call raise.
Both are claims a reader can check by counting arrows rather than accept, and a property enforced by the shape of a state machine holds on every path through it, including the paths nobody thought of.
The window a probe cannot close
Between the probe returning true and the tool call landing there is a gap, and the world can change inside it. The pre-check on step 4 reads the object at 09:12:04.031 and the loader opens it at 09:12:04.088; if the lifecycle rule fires in those 57 milliseconds, the check passed and the step still fails. This is the ordinary time-of-check-to-time-of-use race, and precondition probes do not eliminate it. They shrink it from the length of the run — the plan's assumptions were established at 09:02, 610 seconds before step 4 — down to the length of one round trip.
The arithmetic of that shrinkage is the whole benefit, and it generalises past this run. Exposure to a stale assumption is the interval between the moment the assumption was last verified and the moment it is relied upon. Without probes that interval runs from plan time to step time, here roughly ten minutes; with probes it runs from the probe's response to the tool's request, which is one round trip. So the window does not close. It is divided by the ratio of a round trip to a run, and that ratio is a property of the network and the run length rather than of the step, which is why the argument needs making only once.
Shrinking a 610-second window to a 57-millisecond one is worth doing and is not the same as closing it. Where the gap is genuinely intolerable, the answer is not a better probe; it is to make the step itself carry the condition. warehouse.load with a min_rows=60000 argument that fails the load rather than committing an empty table needs no pre-check for that property, because the check and the action are the same operation. Every condition you can push into the tool call is a condition that stops being a race. Probes are for the conditions you cannot.
That is the only move that reaches zero, by the same argument running backwards. A race exists because a check and an action are two events separated by time; make them one event and there is no interval for anything to happen in. No improvement to the probe gets there, since a faster probe divides the window by a larger number and division never reaches zero. The residual is every condition that cannot be folded into a tool call — most of them, because folding requires the tool's author to have offered an argument for exactly the condition you need.
What eleven probes cost
The reconciliation plan has seven steps carrying eleven probes between them. Ten are warehouse queries against system catalogues or small aggregates, averaging 130 ms; one is an S3 HEAD at about 25 ms. Skips and waits add re-probes, so a typical run issues fourteen and spends roughly 1.8 seconds on validation. Against a run whose median wall time is 4 minutes 40 seconds, that is 0.6% — and against the Saturday run, which burned 600 seconds on a lock timeout in the migration case and came within one approval threshold of 61,400 wrong credits in this one, the comparison is not close.
Those figures close on themselves, and checking it takes a moment. Ten warehouse queries at a hundred and thirty milliseconds each, plus a HEAD at about twenty-five, land a little over a second and a third; the three further probes skips and re-probes contribute bring the total to the stated one and eight tenths of a second. Now reach the fraction by a route sharing no step with that sum: under two seconds against just under five minutes of median wall time is roughly six parts in a thousand, the stated percentage. Agreement between two independent paths is not evidence that the measurements are right. It is evidence that they came from the same runs, which is the cheaper mistake to make and the harder to notice.
The cost that is easy to underestimate is not latency. It is that eleven probes are eleven more pieces of surface area: each one names a resource, and when the resource is renamed the probe breaks, and a broken probe halts the run. Teams that add gating and then find their agent halting on healthy systems usually have a probe asserting something slightly stronger than the step needs — a rowcount of exactly 61,400 rather than at least 60,000, an index name that a migration changed. A precondition should be the weakest statement that still rules out the failure you are guarding against.
Why strength costs availability decides how every probe in a system gets written. A probe is a predicate over world states, and strengthening it shrinks the set it accepts. Some of the states it stops accepting are the failures it guards against, which is the point; the rest are healthy states that merely differ from the one its author had in mind. A probe demanding exactly the March line count rejects every world in which the export moved by a line, and an export moving by a line is a normal Tuesday. A probe demanding at least sixty thousand accepts all of those and still rejects the empty load. The rule is not a preference for looseness: every unit of strength past what the guarded failure requires buys nothing and costs a class of healthy runs.
Steps a probe cannot be written for
Some steps have no probe available at all, and pretending otherwise is worse than admitting it. The vendor's re-export endpoint accepts a request and reports nothing about whether the export will land; there is no read-only call that answers "is this going to work". A step like that gets one of two treatments. Either it is made tolerant, so that failing is cheap and detectable downstream — request the re-export, then poll for the object with a deadline, which converts an unprobeable action into a probeable state. Or, where it has an external or money effect and cannot be made tolerant, it holds for human approval, which is a probe whose implementation is a person. Writing pre: [] on such a step and letting it through is the third option, and it is the one that reads as complete in the plan while guarding nothing.
The polling treatment is a genuine conversion rather than a workaround, and
seeing why tells you when it is available. A request is an event, and events are
not observable after the fact by anything but a log. A file in a bucket is a
state, and states are what a read-only call reports. So the poll does not make
the unprobeable action probeable; it appends a probeable state and checks that
instead. That works whenever the action's purpose is to bring some state about,
and fails when the effect is the event itself — an email to a customer leaves no
state in any system you can read, which is why the external effect from
chapter 2 routes to a person.
Gating turns silent wrongness into loud stopping, which is a large improvement and a limited one. The plan is still a set of the model's claims. A step whose post probe checks the wrong property passes while doing nothing useful — a table_rowcount on the staging table says the load happened and says nothing about whether the rows are March's. A step with no post at all, because the model omitted it and the schema check only requires criteria on write steps, sails through Verifying unexamined. The checks verify that declared states were reached; they cannot verify that the declared states were the right ones to aim for.
That gap is the shape this chapter opened with, one level up, and it has no
equivalent fix. A probe reads the world, so it settles whether the declared
state was reached. Nothing it reads settles whether that was the right state,
because the right state is a property of the request a person made and lives in
no system a probe can call. object_exists is authoritative about the object
and has no opinion about whether that object was the one the task needed.
The bigger open question is the Replanning state, which the machine above enters from four different edges and which nothing so far defines. When the export is gone, "replan" could mean asking the model for a new path with the 404 in context, and it could equally mean asking the model for the same five steps again with slightly different wording, which is what happens by default and which will fail identically. The difference between those two behaviours is not a prompt detail. It decides whether a run that hits an obstacle converges on a way around it or spends its budget circling.
A step is stale when the world has moved out from under it and it cannot tell, because the fact that changed lives in a system it never reads. That single property dictates the answer: the only thing that catches a stale step is a call to the system that changed, issued after the change could have happened and before the step commits. Every design decision here follows from it. Probes are bound tool calls because prose needs a reader and a reader can be talked round. Most conditions live on tools rather than in plans because a condition true of every use of a tool should not depend on a model remembering to write it down. The window shrinks to one round trip because that is the shortest interval a separate check can occupy, and reaches zero only where check and action become one operation.
The trade is clean: silent wrongness becomes loud stopping, at about six parts in a thousand of wall time, and with new failure modes of its own, since every probe is a resource name that can go out of date and a probe stronger than its step needs will stop healthy runs. It leaves the harder half untouched. The checks confirm that declared states were reached and say nothing about whether those were the states to aim for, and stopping only helps if what happens next is better than what was happening before.
Planning — read the current version at https://daviddominguez.dev/learn/en/planning/a-stale-step-is-only-caught-by-a-check-that-runs-before-it