The spec and its criteria
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.
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:
## 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.
## 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 numbered criterion, when you can decide it;
- a non-goal, when the answer is no;
- a stop condition, when deciding it changes what a test asserts and nobody has decided it;
- back to whoever can decide it, when nobody can.
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:
…/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.
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.
| # | Criterion | Command that fails if this is broken | Test or assertion |
|---|---|---|---|
| 1 | LK-014 renders as -¥128,000 | LEDGER_RUN_KNOWN_ISSUES=1 swift testLEDGER_RUN_KNOWN_ISSUES=1 ./gradlew test | KnownIssueTests.testLEDGER7_JPYRendersWithTwoDecimalPlacesKnownIssueTest, the JPY row test |
| 2 | Every non-JPY row renders exactly as it does now | swift test./gradlew test | GoldenFixtureTests.testAmountTextMatchesTheFixtureGoldenFixtureTest, the every-row amount test |
| 3 | The fixture file is not edited | diff ../sandbox/swift/Tests/LedgerKitTests/Fixtures/transactions.tsv Tests/LedgerKitTests/Fixtures/transactions.tsvdiff ../sandbox/kotlin/Fixtures/transactions.tsv Fixtures/transactions.tsv | no output, and an exit status of 0 |
Plain swift test skips the LEDGER-7 test.
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 test
./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.
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.
practitioners-04— guides and sensors as the two halves of an agent harness, sensors being the feedback that triggers self-correction before human review. Birgitta Böckeler on harness engineering, SE Radio 730, 2026-07-22.practitioners-09— the three-tier action-boundary structure with an explicit "ask first" tier requiring human approval before the agent acts. Addy Osmani, how to write a good spec for AI agents, 2026-01-19.cc-core-19—/clearresets the session to empty context, from Slash commands (accessed 2026-09-22).cc-core-32— the version this course was checked against, from the Claude Code changelog (2026-09-22).