ZeNorm

Acceptance criteria that actually work

Treat acceptance criteria as executable contracts, not prose wishes. What makes a criterion verifiable, plus a rewrite table of common bad ones.

Alex Earll8 min read

Open a random ticket in most backlogs and the acceptance criteria section reads like a wish list. "Search works correctly." "Errors are handled gracefully." "The page is performant." These are not criteria. Nothing can be checked against them. They pass by acclamation — someone looks at the feature, feels okay about it, and moves the card.

That was always weak, but it was survivable when a human wrote the code, because the human carried the real criteria in their head. Hand the same text to an agent and the weakness becomes load-bearing: the criteria are the only contract, and a contract that cannot be falsified cannot be enforced.

The fix is to treat each criterion as an assertion someone could execute without asking you a follow-up question.

Four properties of a verifiable criterion

A criterion is verifiable when it has all four of these. Missing any one is usually the specific reason a criterion feels vague.

A named subject. What is being asserted about. Not "the system" — the endpoint, the button, the response body, the specific function. "The system validates input" leaves open which layer does it, which is exactly the thing that gets implemented in the wrong place.

A stated precondition. The circumstances under which the assertion holds. Most disagreements about whether something passes are actually disagreements about what state it was in. "Returns 404" — for a missing record, or for a record in another org, or for a soft-deleted one? Those may all be 404s, or they may deliberately not be.

A single observable outcome. One thing, observable from outside. A status code, a rendered string, a database row, a log line, a duration. If the criterion contains "and," check whether it is really two criteria that should fail independently. Compound criteria produce ambiguous results: half-passing is not a state anyone can act on.

Unambiguous pass or fail. No judgment call. The test: could two competent engineers, both acting in good faith, disagree about whether this is satisfied? If yes, it is not a criterion yet — it is a preference with confident phrasing.

A useful shape that tends to produce all four:

Given <precondition>, when <action>, then <subject> <observable outcome>.

Not a required format — plenty of good criteria are a single clause — but when one feels slippery, forcing it into this shape reveals which of the four parts is missing.

The anti-patterns

These five account for most of what goes wrong.

Adjectives standing in for thresholds. "Fast," "performant," "responsive," "scalable," "reliable." Every one is a number that has not been decided. "The export is fast" cannot fail. "The export of 10,000 rows completes in under 5 seconds on a warm cache" can. If you do not know the number, that is genuinely useful information — it means nobody has decided, and now is cheaper than later.

"Handles X gracefully." The most common offender, and it transfers no information at all. Graceful how? Retry, fail closed, fail open, show a toast, queue for later, log and continue? These are five different products. The word "gracefully" is where a decision goes to hide, and an agent will pick one of the five and never mention it.

Restating the title. Ticket: "Add CSV export." Criterion: "Users can export to CSV." Zero information added. This happens when criteria are written as a process requirement rather than as a thinking tool — someone needed the field filled in. A criterion that is entailed by the title should be deleted; it makes the section look complete while covering nothing.

Testing implementation instead of behavior. "Uses a useMemo for the filtered list." "Calls validateInput() before the DB write." These over-specify the how and under-specify the what. They pass while the feature is broken and fail on a legitimate refactor — exactly backwards. Behavior belongs in criteria. Implementation constraints, when they are real, belong in a decisions section with the reason attached.

There is a narrow exception: when the implementation is the requirement. "Password hashing uses bcrypt with cost factor 12" is legitimately about the how, because the how is what auditors check. But this is rarer than it appears in practice.

Criteria nobody can run. "Works on all supported browsers" when you have no cross-browser setup. "Handles 10,000 concurrent users" when you have no load test. Unverifiable criteria are worse than absent ones — they create the appearance of coverage. If you cannot check it, either build the check or move it to a known-risks section where it is honest about what it is.

Rewrites

BadGood
Search works correctlySearching "invoice" returns specs whose title or description contains "invoice", case-insensitive, max 20 results, ordered by updated_at descending
Errors are handled gracefullyA 5xx from the payment provider returns 502 with body {"error":"provider_unavailable"} and logs at error level with the request id
The page is performantThe spec list renders its first row within 1s of navigation at 500 specs, measured on a throttled Fast 3G profile
Users can't access other orgs' dataGET /v1/specs/:id for a spec in a different org returns 404 (not 403 — 403 confirms existence)
Form validation worksSubmitting with an empty title shows "Title is required" under the field, leaves other input intact, and issues no network request
Handles concurrent editsTwo PATCH requests to the same spec with the same version — the second returns 409 and does not modify the row
The retry logic is robustA 500 is retried at 1s, 4s, and 16s, then abandoned; a 400 is never retried
Good test coverageEvery branch in src/webhooks/retry.ts is exercised, including timeout, non-retryable status, and exhaustion
Migration is safeThe migration runs on a 2M-row copy of production in under 30s and takes no exclusive lock on specs
UI matches the designThe empty state shows the illustration, the heading "No specs yet", and a primary "New spec" button, in that vertical order

Two patterns run through the right-hand column. Every rewrite names something concrete — a status code, a string, a file, a number. And every rewrite is shorter to verify than it is to read, which is the actual goal. A criterion that takes longer to check than to write is a criterion people will stop checking.

Note the fourth row in particular. Rewriting it forced a real decision — 404 versus 403 — that the vague version hid. That happens constantly, and it is the main argument for doing this work: the rewrite surfaces decisions, not just wording.

Why this matters more with agents

Three reasons, in increasing order of importance.

A checkable criterion closes the verification loop. An agent can run "returns 409 on a version conflict" against its own implementation, see it fail, and fix it — without a round trip to you. It can do nothing at all with "handles concurrent edits." Every criterion an agent can self-check is a review cycle you do not spend. This is the difference between an agent that reports "done" and an agent that reports "done, and here is the criterion-by-criterion result."

Vague criteria get resolved silently. A human hitting "handles errors gracefully" has a decent chance of asking what that means. An agent picks an interpretation and implements it, in the same confident register it uses for things it actually knows. The ambiguity does not surface as a question. It surfaces as a design decision embedded in shipped code, discoverable only by reading — which is the failure mode described in the 70% problem.

Criteria are durable where conversation is not. Everything you say in a session is gone at the session boundary; written criteria are re-read at the start of the next one. And criteria that have become tests are stronger still, because they run on every session forever with no dependence on anyone remembering to look. That is the most reliable defense against the "helpful" reversion pattern in why AI coding agents lose context — a constraint in prose can be forgotten, a constraint in CI cannot.

The strongest version of a criterion is one that has become a test. Not all can — "the empty state shows the illustration" may not be worth an assertion — but the ones expensive to get wrong should be. Prose is where criteria go to be forgotten.

Negative criteria carry more weight than they look like they should

Most criteria say what should happen. The ones that say what should not happen tend to be higher-leverage, because they are the ones agents violate by trying to help.

Two distinct kinds, worth separating.

Non-goals bound the scope of the change:

## Non-goals
- No caching layer. Query the DB directly.
- No pagination in this change; the 20-result cap is the whole story.
- Do not modify the existing GET /v1/specs endpoint.

Negative criteria are checkable assertions about things that must not occur:

- Searching does not return archived specs, including exact title matches.
- No search query issues more than one DB round trip.
- Failed searches do not clear the current results view.
- The search endpoint never returns spec bodies, only id, title, and updated_at.

The difference is that a negative criterion can be tested. "Does not return archived specs" is a test. "No caching layer" is a scope boundary a human has to enforce at review.

Both are needed, and both defend against the same thing: an agent's strong prior toward doing more. Left unbounded, you get pagination you did not ask for, a cache invalidation bug in a feature that needed no cache, and an endpoint returning full spec bodies because returning more data seemed more useful.

That last one is the pattern worth internalizing. Nothing said not to include bodies. Including them looked generous. Now you have an N+1 and a payload-size problem in a search endpoint, from a decision nobody made.

A practical bar

You do not need many criteria. Five sharp ones beat fifteen soft ones, and the soft ones are actively harmful because they dilute the sharp ones.

Before you hand a spec to an agent, run each criterion through one question:

If someone claimed this was done and I disagreed, could I point at something that settles it?

If the answer is a status code, a string, a row, a log line, a number, or a test name — it is a criterion. If the answer is "well, I'd have to look at it and judge" — it is a preference, and it will be resolved by whoever writes the code. Increasingly, that is not you.

Keep reading