The spec and its criteria

Day 1: From chat to spec · Lessons 4 and 5 of 6, with Lesson 5 starting halfway down · 16 min

You wrote most of a spec an hour ago and let it scroll off the screen: the goal, bounds, verification and stop from Lessons 1 and 2 are its content, and what they lacked was a path. Put them in a file, number the criteria, and give each one a command that turns red when it is not true, and you have what the agent reads before it guesses and what you hold the diff against afterwards.

Watch first · 4 min

Why Software Factories Fail

AI Engineer, 2026-07-23. Segment 14:58 to 19:17, the closing two chapters: planning up front, then what too many bad PRs cost. Watch for what the speaker writes down before any code is generated, and for how specific it gets: not a description of the feature, but the types, the call graph and the slices. Keep one question open while you watch: which of the things he writes down would still be useful to a run that started tomorrow with no memory of him.

Also worth four minutes, outside the lesson's sixteen: Spec-Driven Dev Is Back. But Not How You Think (GOTO Conferences, 2026-04-27, Daniel Terhorst-North and Gojko Adzic), from 10:39. Watch for the moment the argument turns from what a rule says to whether anything enforces it.

From a prompt you typed to a file you keep

A prompt lasts as long as its conversation, and /clear empties the conversation. The same words in specs/daily-summary.md survive it, and survive a colleague opening the repository next week.

Nothing about the content changes when you move it; the usual mistake is adding to it. Background, rationale and the approach you abandoned tell the run neither what to do nor when to stop, and they lengthen what a reviewer reads. Keep a sentence if it passes Lesson 6's test for a line of CLAUDE.md: if you removed it, would the agent make a specific mistake you can name?

What a spec contains

Seven headings, and a spec that has them is finished: goal, non-goals, numbered acceptance criteria, bounds, verification, stop conditions, definition of done. The new three are: non-goals, in the next section; numbered criteria, further down this page; and the definition of done, the list you check before calling the work finished. The criterion-to-command grid is a sub-part of the numbered criteria, not an eighth heading, which is why the template carries it as a ### under them.

Everything else is commentary. The skeleton Exercise 3 copies in — ../day-1-from-chat-to-spec/starter/specs/TEMPLATE.md — ships those headings with angle-bracket guidance to delete as you fill them. Two name paths and a command, so they differ by lane; here they are for Exercise 1's merchant-search change:

Swift lane
## Bounds

**In bounds — may change:**

- Sources/LedgerKit/LedgerFilter.swift
- Tests/LedgerKitTests/LedgerFilterTests.swift

**Out of bounds — must not change:**

- Sources/LedgerKit/Money.swift
- Tests/LedgerKitTests/Fixtures/transactions.tsv
- CLAUDE.md

## Verification

swift test        # Success: the run reports 0 failures.
Kotlin lane
## Bounds

**In bounds — may change:**

- src/main/kotlin/ledgerkit/LedgerFilter.kt
- src/test/kotlin/ledgerkit/LedgerFilterTest.kt

**Out of bounds — must not change:**

- src/main/kotlin/ledgerkit/Money.kt
- Fixtures/transactions.tsv
- CLAUDE.md

## Verification

./gradlew test    # Success: the run prints BUILD SUCCESSFUL.

Paths, not prose. "Be careful around Money" is not a bound, because no diff can be checked against it; every changed file is either on a list of paths or it is not.

That is also where a spec stops and Lesson 3's plan starts. The spec holds what the change must achieve and its limits; the plan, which the agent writes after reading the code, holds the steps, their order and the files to edit. Bounds say which files may change, not which ones will.

Non-goals do more work than goals

The goal tells the agent where to go; the non-goals tell it where to stop being helpful. An agent that has read your code can see adjacent improvements nobody asked for, and one line — do not change the output format of the existing summary — removes that class of diff.

If you write only one non-goal, make it about the thing sitting next to the change. The Change B brief in Exercise 3 hands you two, in product's words: no currency conversion, and nothing about the screen.

Open questions land here too, but not only here. Does a day with only declined transactions still appear? It must not sit in the file unanswered: the agent will answer it quietly, and you will find the answer in the diff. Give it one of four destinations:

A spec is re-runnable

The test of a spec is whether a session with no memory of your afternoon produces the same change from it. Day 2 hands one to a runner that works without you in the room, and whatever you left implicit becomes its improvisation.

Where the spec lives

One file per change, next to the code: specs/daily-summary.md inside day1-work, the copy of your lane Exercise 1 makes, where every command today runs. A document that accumulates every change is one nobody re-reads.

A useful team norm: the spec is part of the pull request and is reviewed before the diff, because the criteria say whether the change was worth making and the tests already say whether it compiles.

When a spec is overhead

For a change under about twenty lines with a test that already fails, the failing test is the spec: it states the criterion, and the diff is reviewable at a glance. A file for that change is ceremony, and ceremony is how a practice gets dropped.

Write one when the change spans files, when someone else will run it, or when you will run it more than once. When you cannot decide, write it.

Lesson 5: Criteria a command can fail

The spec is written. This half makes each of its numbered criteria something a run can fail.

The only test a criterion has to pass

A criterion that cannot make a command exit non-zero is a hope: you can write it down, hand it to an agent, and ship the opposite without anything going red. So read each criterion and ask which command turns red if it is not true. If you can name the command and the assertion inside it, you have a criterion; if you cannot, you have an aspiration, and an aspiration cannot be violated.

Birgitta Böckeler calls the instructions you feed forward guides, and the feedback that makes an agent correct itself sensors. Your spec is a guide; the command behind each criterion is the sensor, and a run with no sensors goes wherever the guide points.

Numbered and binary

Number the criteria, so you can reject a plan by saying criterion 4 has no command beside it. Binary means a run either satisfies a criterion or does not, with no interpretation between output and verdict: "totals are accurate" needs a human to rule on what accurate covers, and "LK-018 renders as -€12,345.67" does not.

A criterion joined by "and" is usually two criteria wearing one number. "LK-014 renders as -¥128,000 and no other row changes" is proven by two different tests, so the grid below splits it into criteria 1 and 2, and a red run tells you which half broke.

From behavior to a failing case

The move that turns a behavior into a criterion is to pick the input that violates it and write that input down: not the rule, the counterexample. LEDGER-7 arrived as "yen amounts show cents", and the criterion is the fixture row LK-014, a JPY debit of 128,000 minor units, rendering as -¥128,000 and not -¥1,280.00.

You already watched that one fail. The gated run from Exercise 2, inside day1-work, prints both:

Swift lane
…/Tests/LedgerKitTests/KnownIssueTests.swift:35: error:
-[LedgerKitTests.KnownIssueTests testLEDGER7_JPYRendersWithTwoDecimalPlaces] :
XCTAssertEqual failed: ("-¥1,280.00") is not equal to ("-¥128,000")
 - LEDGER-7: LK-014 (JPY) rendered the wrong amount.
  expected: -¥128,000
  actual:   -¥1,280.00
JPY has no minor unit, so Money.decimalPlaces(for: "JPY") should be 0
and is 2. See KNOWN-ISSUES.md.

The absolute path is shortened to … here.

Kotlin lane
KnownIssueTest > LEDGER-7 the JPY row renders with decimal places it should not have() FAILED
    org.opentest4j.AssertionFailedError: LEDGER-7: LK-014 is -128000 minor units
    of JPY. The fixture expects "-¥128,000" and TransactionFormatter.amountText
    produced "-¥1,280.00". Money.decimalPlaces returns 2 for JPY; JPY has no
    minor unit. ==> expected: <-¥128,000> but was: <-¥1,280.00>

The violating case is the test: criterion and assertion are one sentence written twice.

The criterion-to-command grid

A grid is the cheapest way to see which criteria are real: one row per number, the command that fails if it is broken, and the test or assertion inside it. The template carries the same grid without the Criterion column, because there the criterion text sits in the numbered list right above it. One row per criterion, even when every row names the same test command with a different test name; the row you cannot fill is the finding.

A grid for the LEDGER-7 fix, filled.
#CriterionCommand that fails if this is brokenTest or assertion
1LK-014 renders as -¥128,000
LEDGER_RUN_KNOWN_ISSUES=1 swift test
LEDGER_RUN_KNOWN_ISSUES=1 ./gradlew test
KnownIssueTests.testLEDGER7_JPYRendersWithTwoDecimalPlaces
KnownIssueTest, the JPY row test
2Every non-JPY row renders exactly as it does now
swift test
./gradlew test
GoldenFixtureTests.testAmountTextMatchesTheFixture
GoldenFixtureTest, the every-row amount test
3The fixture file is not edited
diff ../sandbox/swift/Tests/LedgerKitTests/Fixtures/transactions.tsv Tests/LedgerKitTests/Fixtures/transactions.tsv
diff ../sandbox/kotlin/Fixtures/transactions.tsv Fixtures/transactions.tsv
no output, and an exit status of 0
Swift lane

Plain swift test skips the LEDGER-7 test.

Kotlin lane

Plain ./gradlew test does not run the LEDGER-7 test at all.

So the plain command cannot fail criterion 1, which is why criterion 1 names the gated form and criterion 2 the plain one. Row 3 shows the column is not only tests: a diff against the shipped copy exits non-zero the moment the fixture changes.

Row 2's command is the one you run most:

Swift lane
swift test
Kotlin lane
./gradlew test

Skip the grid and a green run ships behavior nobody asked for: the suite proves whichever criteria had tests. The grid is the wrong tool for a throwaway spike you will delete, or a change that asserts no behavior at all: with no criterion to hold, there is no row to fill, so run the suite and move on.

Criteria mapped to the checks that can fail them A two-column mapping. On the left, three numbered criteria from the LEDGER-7 spec and a fourth line that carries no number. On the right, three checks, each naming a test and the command that runs it. Criterion 1, LK-014 renders minus 128,000 yen, reaches the known-issue JPY test, run by the gated test command. Criterion 2, other rows unchanged, reaches the golden fixture test, run by the plain test command. Criterion 3, fixture file not edited, reaches a diff of the working copy's fixture against the shipped sandbox copy. The fourth line, wording reads well, reaches nothing: it stops at a cross marked nothing can fail this, and it is drawn unnumbered because a line with no check on its right-hand side is one a green run would silently claim as passed if it sat in the numbered list. criterion check that can fail it 1 LK-014 renders -¥128,000 known-issue JPY test gated test command 2 other rows unchanged golden fixture test plain test command 3 fixture file not edited fixture unchanged diff against sandbox/ unnumbered wording reads well nothing can fail this
Each numbered criterion reaches a named check; the line with nothing on its right must not carry a number.

Words that make a criterion unfalsifiable

Some words reliably mark a line that cannot fail. Properly. Correctly. Reasonable. Gracefully. Performant. Handles edge cases. Consistent with the rest of the codebase. Each one moves the decision from a command to a conversation after the code is written.

Treat them as a lint on your own spec: ask which single input would be the counterexample, and write that instead. When you cannot answer, the requirement is still open, and the honest move is to send it back.

Criteria you cannot automate

Some real requirements have no command: wording a support agent reads out loud, visual polish, whether a screen reads like the rest of the app. No assertion decides these.

List them separately as manual criteria, each naming who checks it and what evidence counts: a screenshot on the pull request, a five-minute pass on a device by an engineer who did not write the change. Addy Osmani's spec structure does the same for actions, with an explicit "ask first" tier.

Do not number them beside the automated ones, where a run that never looked at them claims them as passed. Two lists, one of which a human signs.

Let the agent draft, you decide

Drafting criteria from a ticket is work an agent is good at: it surfaces the empty case, the duplicate, the one that crosses a day boundary. The prompt hands over the ticket and one constraint.

Read LEDGER-7.md.

Propose acceptance criteria for this fix as a numbered list. For each one,
name the single command and the single test assertion that would fail if
that criterion were not met, and say plainly when there is none.

Do not write any implementation, do not change a file, and do not propose
more than six criteria.

Then you cut, and the cutting is yours: an agent drafts agreeable criteria, and the one that constrains it — the fixture may not be edited — is the one you insist on. Exercise 3 turns this round: you write the four criteria for Change B, then have the agent name the command and assertion for each and say which it cannot make fail.

Quick check

Rewrite "euro amounts are formatted correctly" so it can fail.

"LK-018 renders as -€12,345.67." Then name the command and the assertion, the golden fixture test, so there is a specific line that goes red when it is not true. If you also want to say no other row changes, that is a second numbered criterion, not an "and" on this one.

You cannot think of a single non-goal for your change. What does that usually mean?

The goal is too broad to bound, so anything adjacent looks in scope. Split it into changes you can draw a line around, or accept that the agent may touch whatever seems related and say so: that is a decision, not an accident.

Sources

Test output on this page was recorded from this course's sandbox on 2026-09-22, in both lanes. The cited sources were accessed the same day.