Write the spec for Change B

Day 1: From chat to spec · Exercise 3 of 4 · 15 min

Turn a plain-English feature brief into a spec file with four numbered acceptance criteria that a command can fail, and get the agent to tell you which of them it cannot.

Setup

You continue in day1-work, the copy of your lane that Exercise 1 made at the course root; the shipped sandbox/ is never edited. Paste this block from the course root — run cd .. first if your shell is still in the copy — and it leaves you back there. Every later command and path on this page is written from inside day1-work. Both lanes paste the same block:

cp day-1-from-chat-to-spec/starter/specs/TEMPLATE.md day1-work/specs/daily-summary.md && cd day1-work && ls specs

It prints TEMPLATE.md and daily-summary.md. Open the copy in your editor, and start Claude Code inside day1-work if no session is running there. The skeleton is the starter’s, not the lane’s own specs/TEMPLATE.md, so both lanes get the same sections; its grid rows carry both lanes’ commands, and you delete the one that is not yours. You are writing a file today, not code: Day 2 hands it to a runner.

Starting here, or starting fresh

If you have no day1-work yet, or want a clean one, make it from the course root, then paste the setup block above.

Swift lane
rm -rf day1-work && cp -R sandbox/swift day1-work
Kotlin lane
rm -rf day1-work && cp -R sandbox/kotlin day1-work

Fifteen minutes is the budget. If you reach it before step 5, skip to step 6 and expect check one to list what you skipped: bounds and verification come back tonight in the wrap-up’s first challenge.

Steps

  1. Read the brief in place at ../day-1-from-chat-to-spec/starter/change-b-brief.md; nothing copies it. It is in a product manager’s words: no file names, no commands, no criteria, and two open questions nobody has answered. Three minutes, once through.
  2. Fill the goal in one or two sentences, in behaviour terms — what is true after the change that is not true now — and write at least two non-goals, one about the thing sitting next to the change.
  3. Write four numbered acceptance criteria, one each for: declined transactions listed but not totalled; a pending total per day beside the posted total; days newest first with no empty day in between; and per-currency totals on a day holding more than one currency. The brief asks for the no-empty-day half to stay true, not to become true: today’s summary never returns an empty day, so that half is a regression guard, and its assertion needs an input that could produce one.
  4. Fill the criterion-to-command grid underneath: for each number, the command you would run and the test name that would go red without it. A row you cannot fill is a criterion to rewrite.
  5. Fill bounds, verification and done from the template. Bounds are paths, in and out; verification is the exact command and what counts as success. Add the one line to the template’s definition of done that is specific to Change B. Then give each of the brief’s two open questions its destination, as Lessons 4 and 5 list them: a numbered criterion when you can decide it, folded into an existing criterion’s assertion or input so the count stays at four, a non-goal when the answer is no, and a stop condition when deciding it changes what a test asserts and nobody has decided it; when nobody can, it goes back to whoever can. You can decide it when the answer follows from a convention already in the module.
  6. Clear the conversation, then audit. In Claude Code, run /clear, then paste the prompt below exactly as written.
  7. Rewrite and re-run. For every criterion the agent says it cannot make fail, rewrite it and paste the same prompt again; two criteria proven by one assertion get the same treatment. One answer is not a rewrite: if a criterion cannot fail because no input in the fixture triggers it, the fix is an input, not a sentence. Note in its grid row the input the assertion has to build, and move on.
  8. Confirm you changed nothing but the spec with checks three and four under Self-check.
  9. Compare against the reveals at the bottom.

The audit prompt

Paste this in full. The paragraph that forbids edits is the one people trim, and step 8 is how you find out whether it reached the agent.

Read the spec at specs/daily-summary.md and the existing LedgerSummary source and tests.

For each numbered acceptance criterion, name the single command and the single test assertion that would fail if that criterion were not met. If a criterion cannot be made to fail by any command, say which one and explain in one sentence why not. If two criteria would be proven by the same assertion, say which two.

Do not change any file. Do not write the implementation. Do not propose new criteria unless I ask.

The same prompt ships at ../day-1-from-chat-to-spec/starter/prompts/e3-audit-criteria.txt if you would rather paste from a file. /clear empties the conversation, so the audit cannot score your spec against a transcript it can still see; Lesson 6 explains it as a budget decision.

Self-check

Four commands decide it

Run all four from inside day1-work. One: no placeholder survives.

grep -n '<' specs/daily-summary.md

It prints nothing. Any line it prints is template guidance, in angle brackets, that you left in place.

Two: four criteria, four grid rows.

grep -c '^| [0-9] |' specs/daily-summary.md

It prints 4, as it does on an untouched template: it catches a row you deleted, not one you left empty.

Three: the spec is the only thing the agent left behind. This is Exercise 1’s comparison against the shipped lane, in the brief form that names files instead of printing them:

Swift lane
diff -rq -x .build ../sandbox/swift .
Kotlin lane
diff -rq -x build -x .gradle -x .kotlin ../sandbox/kotlin .

One line comes back, and it is your spec:

Only in ./specs: daily-summary.md

A Files … differ line naming a source file is the finding. An Only in .: LEDGER-7.md line is Exercise 2's ticket still in the copy, not a finding.

Four: the suite is still green, because nothing implemented anything:

Swift lane
swift test

The verdict and count lines of a green run, recorded from this course's sandbox on 2026-09-22:

Test Suite 'All tests' passed at 2026-09-22 17:15:20.343.
	 Executed 34 tests, with 1 test skipped and 0 failures (0 unexpected) in 0.019 (0.024) seconds
Kotlin lane
./gradlew test

Gradle's verdict on a green run, recorded the same day:

BUILD SUCCESSFUL in 530ms

Done when: every row of your grid names a command and a test name, the audit reports no criterion it could not make fail with an input you named and no two criteria proven by one assertion, the diff against the shipped lane names only your spec, and your lane’s test command is still green.

If you see this instead

If the diff reports a source file as differing — your lane’s LedgerSummary source, say — the agent implemented instead of auditing. Save your spec, take the reset at the bottom of this page, put the spec back, then paste all three paragraphs and audit again.

If it names a second file of its own — an Only in ./specs: change-b.md line, say — the agent wrote its own spec rather than reading yours. Delete it: the audit is only worth something against the file you wrote.

If the audit objects to nothing, read its rows, not its verdict. Two rows naming one assertion is a finding. So is a row naming a test that already exists and already passes: that criterion describes today’s behaviour, not the change.

If the test command goes red, something edited code: a red suite today is a fact about the run and not about your spec.

Compare

Open these after your own four are written, not before. The answer key at day-1-from-chat-to-spec/starter/answers/e3-spec-daily-summary.md points at the full reference spec, change-b-reference-spec.md, beside it.

The reference four criteria, with their grid rows
  1. A declined transaction appears in its day's transactions list and contributes to no total on that day.
  2. Each day reports a pending total separately from its posted total, and a transaction contributes to exactly one of the two.
  3. Day sections are returned newest day first, and a day with no transactions is not returned at all.
  4. A day holding more than one currency reports a posted total and a pending total per currency, and never sums two currencies into one figure.

The test names are written the Swift lane’s way. In the Kotlin lane each is a backticked sentence in LedgerSummaryTest, like the existing `days come out oldest first with no gaps invented`.

#Command that fails if this is brokenTest or assertion
1swift test / ./gradlew test testDeclinedIsListedButNotTotalled — 2026-03-03 lists LK-006 among its transactions, and no total on that day includes -13999.
2swift test / ./gradlew test testPendingHasItsOwnTotal — 2026-03-03's USD pending total is -1725 and its USD posted total is 0.
3swift test / ./gradlew test testDaysAreNewestFirstWithNoGaps — the fixture returns 2026-03-06, 03-05, 03-04, 03-03, 03-02 in that order; with 2026-03-04's rows removed the result has four sections and no 2026-03-04.
4swift test / ./gradlew test testPerCurrencyTotalsOnAMixedDay — 2026-03-03 reports EUR, GBP and USD separately, and its EUR posted total is -4890.

Every row names a different assertion and a different fixture fact, which is the check on the four. Criteria 1 and 2 keep their “and” despite the split rule in Lessons 4 and 5 because one assertion proves both halves; split, they would be two rows naming the same assertion. Row 3 needed an input: the fixture's five days are consecutive, so nothing in it can make the no-empty-days clause fail, and the assertion removes a day's rows itself. Today’s byDay already never returns an empty day, so that clause is a regression guard; only the newest-first order is new. The input is step 7’s exception: settle it in the spec now, rather than find out on Day 2 that a runner went green without ever testing it.

Bounds name paths: the summary source and its test file may change; the fixture, CLAUDE.md and the formatter that Exercise 2 touched may not. The reference adds one line to the template’s five in the definition of done: no transaction was dropped from a day section, only excluded from a total. Its notes also say where the two open questions went: the declined-only day became a stop condition, and the pending refund was answered, because a signed pending total follows the module’s existing arithmetic, and it folds into criterion 2’s assertion rather than becoming a fifth criterion.

The two unfalsifiable criteria almost everyone writes

"The summary is accurate." Accurate against what? With no oracle, neither you nor the agent can tell whether it holds, so whoever is holding the diff marks it done. Rewritten: for every day in the fixture, the posted total for a currency equals the sum of the amountMinor values of that day's posted transactions in that currency, and equals nothing else. Now both sides are numbers from a file in the repository, and a test can compute both.

"Performance is acceptable for large ledgers." Acceptable to whom, at what size, measured with what? Making it checkable means inventing a row count, a pass count and a time budget, and that invention is the tell. For a module this size, delete the criterion and write a non-goal instead.

Two more the audit will not flag, because a command can fail for both. "Add a pendingTotalMinor field to DaySection" is a plan step wearing a criterion's number: criteria say what is true afterwards, plans say how. "Refactor byDay for readability" is checkable only by opinion, and a second change riding along with the first.

Reset

The reset throws the copy away and makes a new one from the shipped lane, your spec included. Save the spec first if you want to compare it on Day 2, which runs a sharpened reference spec either way:

mkdir -p ../work && cp specs/daily-summary.md ../work/

Then, from inside day1-work:

Swift lane
cd .. && rm -rf day1-work && cp -R sandbox/swift day1-work && cd day1-work
Kotlin lane
cd .. && rm -rf day1-work && cp -R sandbox/kotlin day1-work && cd day1-work

Put your spec back with cp ../work/daily-summary.md specs/, and start Claude Code again inside the new copy.

Sources

Terminal output here was recorded from this course's sandbox in both lanes on 2026-09-22, the day the cited documentation was accessed.