Skip to content
Chapter 8 of 9 · Verifier-first designCheckers you already own

15 min read

Checkers you already own

On this page

An instance of the pattern is any place where something stochastic produces a candidate and something cheap and independent decides whether to keep it. The generator does not have to be a model call in the obvious sense: a model-rewritten search query feeding a vector store is a generator, because a small wording change reorders the neighbours. The checker does not have to be called a verifier, and usually is not — it is a Pydantic model, a try block, a provider's dry-run endpoint, a constraint at commit time.

Naming them together is worth doing for two reasons. The measurements this topic has established — what the accept set admits, what the check costs per candidate at the tail, what fraction of rejections were correct all along — apply to every instance, and asking them of five stages at once is much cheaper than asking them of one stage five times. And once you can see the pattern in a system you already run, you can see its absence in a feature somebody is proposing, which is the planning use it turns out to have.

Five stages, five different checkers

Here is a support-automation service traced end to end, from an inbound customer email to a drafted reply. Every stage that calls a model is paired with something that can reject what the model produced, and the rejections loop back to the same stage rather than failing the request.

Flowchart: Inbound email → Parse to ticket; Parse to ticket → Retrieve policy chunks (schema + invariants); Retrieve policy chunks → Plan tool calls (entity present in chunk); Plan tool calls → Apply account change (provider dry-run 422); Apply account change → Reply drafted (FK + unique + check constraints); Parse to ticket → Parse to ticket; Retrieve policy chunks → Retrieve policy chunks; Plan tool calls → Plan tool calls; Apply account change → Apply account change.

The stages differ in who owns the checker, what it binds and what escapes it, and those differences turn out to matter more than the fact that they all wrap a model call.

StageGeneratorChecker that already existedCost per checkWhat escapes
Parse email to ticketModel producing structured JSONPydantic schema (Python's runtime data-validation library) plus invariants (order referenced belongs to the sender)6 msCorrect-shaped ticket with the wrong intent
Retrieve policy chunksModel-rewritten search queryRetrieved chunk must contain the entity named in the ticket12 msTopically relevant chunk that is out of date
Plan a tool callModel producing argumentsProvider's own POST /v1/subscriptions?dry_run=true, returns HTTP 422, the unprocessable-entity status, with field paths140 msValid call that changes the wrong subscription
Apply the account changeThe write itselfPostgres constraints at commit20 ms p50Nothing at the schema level; intent still unchecked
Generate a one-off report scriptModel producing Pythonmypy --strict, a static type checker that reads the code without running it, plus the repo's test suite3.4 sA script that runs and computes the wrong aggregate

Two rows are worth dwelling on. The dry-run endpoint is a checker the team did not build and cannot change, which is a mixed blessing: it is authoritative in the sense that matters, since it is the same validation the provider will run on the real call, and it is also a network dependency with its own rate limit. Treating a provider's dry-run mode as part of the verification path means budgeting for it — this provider allowed 100 dry runs per minute against 300 real calls, so a loop that verified every attempt could exhaust the dry-run quota while the real quota sat idle.

The direction of that imbalance is the part worth reasoning through, because it is the opposite of what a quota is usually shaped for. Providers set dry-run limits below real limits on the assumption that a dry run is an occasional sanity check before a real call. A verifier-first loop inverts that assumption completely: it dry-runs every candidate, including every rejected one, and only the accepted candidates become real calls. So the loop consumes strictly more dry runs than real calls — at least one per attempt, and more than one per accepted output as soon as anything retries. Put the two quotas together and the ceiling on this service's throughput is not the real-call limit at all. It is the dry-run limit divided by attempts per accepted output, which lands under a third of the real-call limit, and the remaining two-thirds of the real quota is unreachable by construction. A rate limit on a checker is a rate limit on the whole loop, and it is worth checking that the provider's two limits were set by somebody imagining the same usage pattern you have.

The retrieval row is the one most teams do not recognise as an instance at all. A rewritten query is a generated artefact, retrieval is stochastic in effect, and there is a cheap deterministic check available: if the ticket names order SO-88214, a chunk that does not contain that string or the policy identifier associated with it is very unlikely to be the right context. That check takes twelve milliseconds and removed a failure class that had previously been handled by making the answering prompt more insistent.

Two properties make that check qualify as a verifier rather than a heuristic, and both are worth separating out because a lot of proposed checks have one and not the other. It is cheap: a substring test against a retrieved chunk, which is the same order of work as reading the chunk at all. And it is independent, in the sense this topic has been using — the order identifier it looks for came from the customer's own email, not from anything the model produced, so a model that rewrites the query badly cannot also arrange for the check to agree with it. Had the identifier been extracted by the same model that rewrote the query, the check would still be cheap and would be worth much less, because the two components would be wrong together whenever the extraction was wrong.

What none of the five checks does is decide whether the system understood what the customer wanted, and that residual accumulates down the chain rather than staying where it started. A ticket parsed with the wrong intent passes its own schema check, retrieves the policy for the wrong situation while satisfying the entity check, and produces a tool call the provider happily validates. Each stage's checker confirms local consistency, and the composition can be globally wrong with every light green.

The reason local checks compose so badly is worth being explicit about, because five green lights is a persuasive sight. Every one of these checkers takes the previous stage's output as its input and asks whether this artefact is well-formed with respect to that input. None of them has any access to whether the input was the right one. So a wrong intent is not a violation the entity check could detect — the chunk really does mention the order, and the order really is the one on the ticket. The check answers its question correctly, on a premise that is false. Chain five such checks and you have five correct answers to five questions, none of which is the question anybody cares about, and each green light is evidence about a link rather than about the chain.

That also says what a check capable of noticing would have to look like. It has to relate the last artefact to the first one, skipping every stage in between, because anything computed at a middle stage has already inherited whatever went wrong upstream. Where this had actually bitten, the team added one end-of-pipeline invariant — the drafted reply must reference the same order ID as the inbound email — which is crude, costs nothing, and caught the cross-wired cases that four local checks had waved through. It is crude precisely because it is the only kind of thing that is available at that distance: the two ends of a pipeline have very little in common, so the invariants that span them are few and shallow. They are also the only ones that can see a failure of composition.

Ranking work by the machinery you already own

The more useful consequence of seeing the pattern everywhere is what it does to planning. Four features arrived in the same quarter's backlog with roughly comparable business cases, and the question was which to build first.

The first proposal was to generate database migrations from a ticket description. The second was to draft release notes from merged pull requests. The third was to summarise customer calls into CRM fields, the structured records in the customer relationship management system that sales works out of. The fourth was to suggest a pricing tier for an inbound lead based on the company's public profile.

Scored on what could reject a bad output today, without building anything, they are not close. Release notes have no rejecter at all beyond a person who knows what shipped; the check is the reading, and the reading is the work. Call summaries have a partial one — every claimed fact could be required to appear in the transcript, which catches invention and leaves the selection of what mattered untouched — and CRM fields carry no downstream signal that would reveal an error until a deal is lost for unrelated reasons. Pricing tier suggestions are the worst case disguised as the best, because there is a beautiful-looking check available in the form of historical win rates, and it takes months to return a verdict on any individual suggestion, which is not a check at all in the sense this topic uses the word.

That last distinction is the one most worth having explicitly, because win rates have every property a good checker is supposed to have except the decisive one. They are objective, they are independent of the model, and they cost nothing to compute. What they lack is a verdict that arrives before the output is used. A checker's job is to reject a candidate instead of shipping it, so its verdict has to exist while the candidate is still a candidate. A signal that arrives after the price has been quoted, the deal has been worked and the quarter has closed is a measurement of a population, and a measurement of a population is what you use to decide whether to keep the feature — not what you use to decide whether to send this suggestion. Two different instruments, and the second one cannot substitute for the first however good it is. The test to apply to any proposed check is not only "what does it cost" but "does it answer in time to change what happens".

The migration proposal is the one where the machinery is already paid for. A structure-only dump of production is restored into a scratch database, the candidate migration is applied, and then three separate pre-existing things render judgement: the constraints and foreign keys defined in the schema, the repository's own test suite run against the migrated database, and a diff of the resulting schema against what the ticket asked for.

bash
#!/usr/bin/env bash
set -euo pipefail

createdb scratch_$RUN_ID
psql -q -d scratch_$RUN_ID -f dumps/schema_$(date +%F).sql     # structure only, < 24h old
psql -q -d scratch_$RUN_ID -f candidate/migration.sql          # rejects here on constraint errors
DATABASE_URL=postgres:///scratch_$RUN_ID pytest -q tests/       # the repo's existing suite
psql -q -d scratch_$RUN_ID -c '\d+ *' > after.txt
diff -u before.txt after.txt                                   # reviewed by a human, 8 lines typical
dropdb scratch_$RUN_ID

Two things about that script carry the whole design. The first is the set -euo pipefail on its second line, which makes the shell abort on the first command that returns a non-zero status. That single line is what turns a sequence of commands into a checker: psql exits non-zero when the migration violates a constraint, pytest exits non-zero when a test fails, and the script therefore stops at the first rejection with the failing tool's own message as the reason string. Without it the script would run to the end and report success regardless, which is the same defect as a verify that always returns true.

The second is what the check binds. The migration is applied to a scratch database restored from a structure-only dump, and that is safe for the reason the chapter on state gave: a migration's verdict depends on the schema, and a schema changes over days while the dump is under a day old. Nothing about this arrangement would survive being pointed at data — the same script cannot tell you whether a NOT NULL addition will succeed against production rows, because the rows are not there. The comment bounding the dump's age is the whole of the staleness argument, and it is the line to move if anybody ever asks to run this against a weekly dump.

Every line in that script existed before the feature was proposed. The dump is produced nightly for a disaster-recovery drill, the test suite runs on every commit, and the schema diff is a habit the team already had for hand-written migrations. Building the feature meant writing the generation prompt, the loop, and about sixty lines of orchestration, which is why it reached production in eleven working days while the other three were still being specified.

The general form of the question is how much of the check you would have to build, and it separates cleanly into four cases: the checker exists and can be reused as-is, the checker exists but binds the wrong state and would have to be relocated, the checker has to be built and its cost is bounded and known, or no cheap checker exists and one would have to be invented. The first case is a feature you can schedule. The last is a research project, and the useful move at planning time is to say so in those words rather than to accept a delivery date for it.

Those four cases are not a taxonomy for its own sake; they are four different answers to "when will this be done" and the difference between them is what the estimate is missing. Reusing an existing checker adds nothing to the estimate, which is why the migration feature came in at eleven days. Relocating one adds a bounded piece of work plus a re-measurement, because moving a check changes its accept set and the new accept set has to be established rather than assumed. Building one from a known design adds a number somebody can defend. Inventing one adds an unknown, and an unknown added to an estimate does not produce a larger estimate — it produces the same estimate with the unknown left out, because there is no number to add. That is the mechanism by which unverifiable features acquire delivery dates, and naming which of the four cases a proposal is in is the cheapest available defence against it.

Where the criterion misleads

Ranking by available machinery is a tie-breaker among features of similar value, and it stops being sound the moment the values diverge. A highly verifiable feature nobody wants is worth less than an unverifiable one that would transform a business process, and the honest response to the second is a research budget with a decision point rather than a refusal dressed up as engineering rigour. The criterion earns its keep because engineering estimates of features like these are otherwise dominated by the invisible cost of inventing a checker, which nobody puts in the estimate because nobody has noticed it is there.

The reuse itself carries a hazard that is easy to miss, and it is the accept-set question arriving in a new costume. A checker built for another purpose has an accept set shaped by that purpose. The repository's test suite covers the behaviour the team chose to test, so a migration that drops an index nothing asserts on passes cleanly and is discovered three weeks later when a report times out. Reusing free machinery is genuinely free in effort and is not free in guarantees, and the measurement that tells you which is the one already established: run a batch of deliberately bad candidates through the reused checker and count what it lets through.

Five instances of one pattern also do not necessarily want one implementation. Sharing the Verdict type, the attempt accounting and the three instrumentation series is worth doing, because it makes the stages comparable and the cost model computable across the whole request. Forcing five very different checkers behind one abstraction usually produces a framework in which the schema check and the provider dry-run and the test suite all have to pretend to be the same shape. Common measurement, separate mechanisms.

The pattern is already in your system, several times over, and finding the instances costs an afternoon at a whiteboard rather than a redesign. What that buys is a set of questions you can ask of every stage at once, and a planning criterion that puts the cost of inventing a checker into an estimate where it had previously been at zero.

Three of the four proposals above were not scheduled, and two of them were not scheduled because the check does not exist and cannot be built at a cost the feature justifies. That decision is the most valuable output of everything in this topic and it is also the hardest one to make in a room where a product manager has a reliability target and an executive has seen a demo. The last chapter is about how to make it, what a substitute check looks like when someone proposes one, and how to leave the conversation with a smaller problem that is worth more than the one you turned down.

Verifier-first design — read the current version at https://daviddominguez.dev/learn/en/verifier-first-design/checkers-you-already-own

Back to top