Why specs beat prompts
A prompt tells Claude what you want right now. A spec tells Claude — and every future reader, human or AI — what the system must do, how you'll know it does, and what it must never break.
When developers first adopt Claude, most fall into conversational coding: describe a feature in a sentence, accept whatever comes back, then spend five follow-up messages steering it. It works for small things. It collapses on anything with real stakes — because the acceptance criteria live in your head, the constraints were never written down, and next week nobody (including Claude in a fresh session) can reconstruct why the code looks the way it does.
Spec-driven development (SDD) inverts the flow. You invest your thinking up front in a short, structured document — intent, requirements, acceptance criteria, constraints, out-of-scope — and Claude implements against it. The spec becomes the contract, the test oracle, and the review checklist all at once.
Prompt-driven
- "Add an override flow for denied auths" — 7 words, 40 hidden assumptions
- Requirements discovered mid-implementation, one correction at a time
- Done = "looks right to me"
- Context lost when the session ends
- Review means reading a diff cold
Spec-driven
- One page: 7 requirements, 5 acceptance criteria, 6 explicit non-goals
- Ambiguity resolved before code exists — the cheapest place to fix it
- Done = every criterion demonstrably passes
- Spec survives the session; any fresh Claude instance can pick it up
- Review means checking implementation against contract
There's a second, less obvious payoff: writing the spec is where Claude helps most. Before any code, Claude is an excellent adversarial reader of your intent — it will surface edge cases, ask about failure modes, and flag contradictions in minutes. The scenarios below use Claude in both roles: spec co-author first, implementer second.
Seven words, forty assumptions
"Add an override flow for denied auths" sounds like a complete instruction. Here is what it doesn't say — every one of these is a real decision that will be made by someone, and without a spec that someone is whoever happens to be at the keyboard:
- Who may override? Any reviewer? Only medical directors? What happens to everyone else — silent no-op, 403, or an error page?
- What must they supply? Free text, a coded reason, both? How much text is enough? Is 49 characters enough?
- What happens to the denial? Updated in place, or preserved with the override appended? If updated, what does the audit trail show a year later?
- From which states? Only
DENIED, or anything not yet approved? What's the response when the request is in the wrong state — 400, 409, or a redirect? - Who gets told? The provider, the member, the reviewer who denied it? Through which channel? Once, or once per subscriber?
- Is it reversible? Can an override be overridden? By whom, how many times?
- What must not change? Which modules are frozen, which tables append-only, which existing behavior is load-bearing?
Prompt-driven, these get answered one surprise at a time — usually as corrections after you've read code that already made a choice. Spec-driven, they're answered in one ten-minute pass while everything is still cheap to change. Same decisions either way; the only variable is whether you make them deliberately.
The cost curve
Ambiguity has a price, and the price rises by roughly an order of magnitude at every stage it survives.
| Caught at | Costs | In Scenario 2's terms |
|---|---|---|
| Spec | One sentence, one conversation | "Is 0.85 itself approved, or not?" — asked and answered before any code exists |
| Plan | A step rewritten | The comparison shows up in a plan step; you notice the boundary is wrong |
| Build | Rework, plus re-review | Half a day, a re-read of the diff, tests rewritten |
| Review | An argument with no written contract to settle it | "That's what I assumed." "That's not what I meant." Nobody is wrong |
| Production | Incident, remediation, trust | Every request that scored exactly 0.85 since the bug shipped was auto-approved — a policy question about each one, and a support ticket that started with "some auths look wrong" |
Nothing about this is specific to AI. What AI changes is the speed at which an ambiguity travels from the first stage to the last.
Three jobs, one document
A spec earns a page because it does three jobs that would otherwise need three artifacts — and, more to the point, usually get none.
As a contract
- What the system must do, agreed before anyone builds
- Names the blast radius: constraints, frozen zones, non-goals
- Settles disputes by reference instead of seniority
As a test oracle
- The ACs compile into the test list, one per criterion
- Makes "done" checkable instead of felt
- Lets a reviewer who didn't build it judge the result
And third, as memory. Sessions end, contexts compact, people leave. The spec is the one artifact that carries the decision and its reasoning forward — which is why MIG-01's resolved question could still shape a spec written months later (SEC-09).
Why better models make this matter more, not less
The intuition runs the other way — surely a stronger model needs less hand-holding? — but the constraint was never the model's coding ability. Three things get harder as generation gets better and faster:
- Aiming costs more than shooting. When implementation is minutes, the expensive error is a well-built wrong thing. Precision about the target is now the scarce input.
- Review capacity becomes the bottleneck. A model can produce more diff per hour than any human can meaningfully review cold. A spec changes the unit of review from "read 600 lines and guess the intent" to "check each stated requirement against evidence".
- Plausibility outruns correctness. Generated code is confident and idiomatic whether or not it matches your policy. The stated contract is what separates "looks right" from "is right" — and it must exist before the code, or it'll be written to match it.
"Isn't this just waterfall?"
Fair question, and the answer is in the size and the lifecycle. Waterfall means a large design produced up front for a large delivery, with change treated as failure. SDD is a one-page spec for one slice, written minutes before the build, amended out loud whenever implementation proves it wrong (SEC-10). The migration in SEC-09 isn't one grand design — it's five specs, each shipped and verified before the next is written.
The honest comparison isn't to waterfall; it's to test-driven development, one level up. TDD says: state the expected behavior before writing code, in a form that can fail. SDD says the same thing about the decisions above the code — who may, what must not change, what "done" means — and then hands those statements to TDD as the test list.
The one-sentence rule: if you can't state how you'd verify the work is done before the work starts, you don't have a spec yet — you have a wish.
And the limit: none of this applies to everything. Spec what touches decisions, data, money, or security; skip it for renames, log lines, and copy tweaks. A team that specs everything abandons the practice within a couple of sprints — see SEC-14.
The companion app
This guide is built around a runnable application: priorauth-sdd, a Spring Boot slice of a healthcare Prior Auth Portal. Read the sections for the reasoning; run the labs on the app for the reflexes. Everything that follows references it.
You don't have to install anything to follow along. Each scenario below carries its lab inline as an interactive walkthrough — what you type, what Claude answers, and the app's state before and after every step. Step through it as you read; run it for real when you want the reflexes. Once you know the material, all four labs sit together on the quick-reference page — the fastest way to re-check a command or a step order without re-reading the section.
The domain in one breath
Providers submit prior-authorization requests. A clinical-rules score routes each one: strictly above AUTO_APPROVE_THRESHOLD (0.85) auto-approves, otherwise manual review. Reviewers approve or deny. Every decision is an append-only Decision row plus an audit event, and providers hear about it through a (deliberately quirky) notification service. Small enough to hold in your head, real enough to have frozen zones, compliance rules, and edge cases worth specifying.
Prior authorization was chosen deliberately: it has every property that makes SDD worth the ceremony, at a scale you can read in an afternoon. There's decision logic where a boundary matters, an audit trail that must not be rewritten, roles with different powers, compliance consequences when a rule is wrong, and a legacy quirk nobody would design on purpose but everyone must preserve.
score > 0.85
SUBMITTED ──────────────────────────────▶ AUTO_APPROVED
│
│ score ≤ 0.85
▼ reviewer approves
MANUAL_REVIEW ──────────────────────────▶ APPROVED
│
│ reviewer denies
▼ medical director overrides (SPEC-2026-041)
DENIED ─────────────────────────────────▶ APPROVED_OVERRIDE
# Every transition appends a Decision row. None is ever updated or deleted.
The moving parts
| Class | Does | Why it matters to a spec |
|---|---|---|
ScoringService | Routes on the threshold comparison | Where the boundary lives — the whole of DEFECT-2026-117 |
DecisionService | submit, review, and (after Lab 2) override | Existing methods are a frozen zone; specs may add, not edit |
RequestStore | In-memory store; appendDecision, decisionsFor | There is no updateDecision. Append-only is enforced by the API's shape |
NotificationService | Provider and reviewer messages, via an outbox() | Holds the EMAIL/FAX quirk; pinned by MIG-01's characterization suite |
AuditService | emit(type, actor, requestId, detail) | The observable behind every "and an audit event is written" clause |
Two absences are deliberate. There is no database — an in-memory store stands in, so nothing in the labs is about Flyway or JPA. And there is no auth framework: the caller's role arrives in an X-User-Role header. Both keep the labs about the method rather than about infrastructure, and both are stated as constraints in CLAUDE.md so Claude doesn't helpfully introduce them.
mvn test # baseline: 3 tests, green (and deliberately incomplete) mvn spring-boot:run curl localhost:8080/api/requests # four seeded requests
The seed data is chosen to make the first lab land in about ten seconds:
| Request | Score | Routes to | Why it's there |
|---|---|---|---|
REQ-1001 | 0.92 | AUTO_APPROVED | Clearly above — must stay unaffected by the fix (R-2) |
REQ-1002 | 0.61 | MANUAL_REVIEW | Clearly below |
REQ-1003 | 0.85 | AUTO_APPROVED ← the bug | Exactly on the boundary. The star of DEFECT-2026-117 |
REQ-1004 | 0.40 | MANUAL_REVIEW | A candidate to deny, then override in Lab 2 |
The API surface
| Endpoint | Purpose |
|---|---|
POST /api/requests | Submit; scores and routes immediately |
GET /api/requests · GET /api/requests/{id} | Read state — how you see a lab's before and after |
POST /api/requests/{id}/review | Reviewer approves or denies |
POST /api/requests/{id}/override | Does not exist yet — Lab 2 builds it from SPEC-2026-041 |
What's been planted, and where
Start on the start branch. main holds the solved reference — all three specs VERIFIED, the override endpoint built, the defect fixed — which is useful for comparing your work afterwards and useless for doing the labs. The learner's starting state is the branch (and tag) below:
git clone <repo> && cd priorauth-sdd git switch start # or: git switch -c lab-1 v1.0-start mvn test # 3 tests, green
On that branch the repo sits in a specific state so each lab has something real to bite on:
| Artifact | State | Sets up |
|---|---|---|
ScoringService | >= where policy says > | Lab 1 — a defect with no failing test yet |
specs/DEFECT-2026-117.md | APPROVED | Lab 1 — plan and build immediately |
specs/SPEC-2026-041.md | APPROVED, unbuilt | Lab 2 — an endpoint that doesn't exist |
specs/MIG-01.md | DRAFT + one open question | Lab 3 — /plan must refuse |
NotificationService | EMAIL/FAX quirk, uncharacterized | Lab 3 — behavior worth pinning before extraction |
specs/SPEC-2026-042.md | DRAFT (on main) — SLA-breach warnings | Lab 4's output, for comparison after you write your own |
CLAUDE.md · .claude/commands/ | Working agreement + five commands | All labs — the gates are already mechanical (SEC-12) |
Before you start: the labs commit real changes. Work on a branch (git switch -c lab-1) so you can reset and re-run a lab from a clean baseline — the second attempt, once you know where the trap is, teaches more than the first. If a lab goes sideways, git switch main and start over; nothing outside the repo is touched.
How to learn with it — the four labs
Each of the three worked scenarios below ends with its lab embedded right there — step through it in the page, no setup required. Lab 4, where you author a spec of your own, sits with the templates in SEC-15. Then run the same lab on your own machine: the walkthrough gives you the shape, the app gives you the real failing tests, the gate that actually refuses, and a fresh-context review of a diff you didn't watch being written.
| Lab | Replays | Time | The loop | Watch for |
|---|---|---|---|---|
| 1 · Defect as a spec | Scenario 2 (SEC-08) | 30 min | /plan DEFECT-2026-117 → approve → /build → /clear → /verify | Does the build session write the failing boundary tests before the fix? Does the verify session catch anything it missed? |
| 2 · Feature from a spec | Scenario 1 (SEC-07) | 60–90 min | Same loop on SPEC-2026-041 (denial override, approved but unbuilt) | AC-5's second half — the ACCESS_DENIED audit event — is the classic half-pass: a criterion whose first clause passes and second is silently missing (SEC-06). Does your verify step catch it? |
| 3 · The spec gate itself | Scenario 3 (SEC-09) | 45 min | /plan MIG-01 must refuse (DRAFT + open question). Resolve, approve, then build tests-only | R-4 makes any production-code change a failure — git diff --stat on src/main must stay empty. |
| 4 · Author your own spec | — | open-ended | /spec <your intent> — SLA-breach warnings are teed up in the code comments | Claude interrogates you before drafting. The interrogation is the lab. |
Do the labs in order — each one assumes the habits of the last, and Lab 4 takes the training wheels off.
The five-stage workflow
Every scenario in this guide follows the same loop. Two of the five stages are human gates — points where you approve before anything proceeds.
■ blue = Claude does the heavy lifting ■ green = you hold the gate
State the problem and the outcome in plain language. What changes for the user? What must not change?
Co-author the spec with Claude. Claude drafts and interrogates; you resolve every open question, then approve.
Claude proposes an implementation plan mapped to requirement IDs. You approve the plan, not just the vibe.
Claude implements against the approved spec. Deviations require a spec amendment, not a silent workaround.
Walk the acceptance criteria one by one. A fresh Claude session reviews the diff against the spec it didn't write.
The loop is not a line
Drawn as five boxes it looks like a conveyor belt, which is the reading that makes people call it waterfall. The back-edges are the method. Two of them exist, they're both normal, and a team that never takes either one is skipping something:
1 Intent
│
▼
2 Spec ◀───────────── amendment: the build found a gap [SEC-10]
│ GATE ▲
▼ │
3 Plan ◀── the plan escapes the │
│ GATE blast radius, or misses │
▼ a requirement │
4 Build ─────────────────────────────┘
│
▼
5 Verify ── FAIL ──▶ back to Build under the SAME spec
│ (never edit the spec so the code passes)
│ every ID PASS
▼
VERIFIED
The direction of each back-edge matters. A gap found during the build goes up to the spec gate for a human decision. A failure found during verification goes back to the build, never up — because a FAIL means the code is wrong, not the contract. Confusing those two is how spec directories start describing imaginary systems.
What each stage leaves behind
Every stage produces a durable artifact. That's what lets the work survive a session ending, and it's the checklist for "are we actually doing this?"
| Stage | Artifact | Where it lives |
|---|---|---|
| 1 · Intent | A paragraph of problem and outcome | The conversation — the only stage with no file |
| 2 · Spec | The spec, Status: APPROVED, Open questions empty | specs/SPEC-YYYY-NNN.md, committed |
| 3 · Plan | Ordered steps, each citing R-x / AC-x, plus the test list | The approved plan; its structure survives in the commits |
| 4 · Build | Commits citing IDs; one test per AC; any amendments | git log and the spec's Amendments section |
| 5 · Verify | Per-ID table with evidence; Status: VERIFIED | The spec's Verification record, committed with the code |
Stage 1 in practice: intent, not solution
The most common way to start badly is to state a design and call it an intent. The difference is whether Claude can still ask you useful questions afterwards.
A solution wearing an intent's clothes
- "Add an
overridestable with a foreign key to decisions, an endpoint, and a React modal." - Nothing left to interrogate — the shape is already fixed
- The why is gone, so nobody can challenge the how
- Requirements end up describing the design (SEC-14, spec theater)
An intent
- "Compliance wants medical directors to reverse a denial with a written, audited justification. Reviewers can only approve or deny today."
- States the problem, who feels it, and what exists now
- Leaves the design open for the plan gate to decide
- Claude's first move is questions, not code
If you do already know how it should be built — often you do, and you're right — say so as a constraint rather than as the intent: "reuse the existing notification service, no new channel." That way the design decision is recorded, reviewable, and testable, instead of being smuggled in as an assumption.
Where you actually enter the loop
Stage 1 isn't always the start. Three common entry points, all ending in the same place:
| You're starting from | Enter at | Looks like |
|---|---|---|
| A feature request | Intent | Scenario 1 — a paragraph, then interrogation |
| A production incident | Spec, directly | Scenario 2 — observed vs expected are already known; write them down |
| A large migration | Slicing, then Spec per slice | Scenario 3 — the slice list is the design; each slice runs the whole loop |
The unit of the loop is a slice, not a project. One spec, one plan, one build, one verification, and something shippable at the end. If a run of the loop can't end with something you'd merge, it's too big — split it before stage 2.
Who does what, and how long it takes
The loop is often described as slow. It isn't — the human minutes are concentrated at two points, and they replace hours spread across the build.
| Stage | Claude does | You do | Your time |
|---|---|---|---|
| 1 · Intent | Reads the code around the change so its questions are informed | State the problem in a paragraph. No precision required yet | 2 min |
| 2 · Spec GATE | Interrogates you, then drafts every line of the spec | Answer ~8 questions; read the draft critically; flip Status to APPROVED | 10–20 min |
| 3 · Plan GATE | Proposes steps, each citing the IDs it satisfies | Check the IDs and the blast radius; approve | 2–5 min |
| 4 · Build | Implements; stops if the spec is wrong | Approve amendments if any surface | ~0 |
| 5 · Verify | In a fresh context: per-ID PASS/FAIL with evidence | Read the table; check the FAILs and the half-passes | 5 min |
What "approve" actually means at each gate
Approval is the one thing that cannot be delegated, so it's worth knowing what you're looking for. Both gates have a short checklist.
Spec gate — read for
- Every requirement can fail (SEC-05's five tests)
- Boundaries are exact:
>or>=, 49 or 50 - Every "and" in an AC is deliberate — it'll need two assertions
- Out of scope names the adjacent good ideas, not absurd ones
- Open questions is empty. Non-negotiable
Plan gate — read for
- Every step cites requirement IDs — a step with none is scope creep
- No requirement is unaccounted for across the steps
- Nothing touches a frozen zone or adds an unapproved dependency
- Tests are generated from the ACs, not invented alongside
- The order is right: for a defect, the failing test comes first
Why the gates matter
The Spec gate is where you buy back the hours you'd otherwise spend correcting a confident but wrong implementation. The Plan gate catches architectural drift — Claude proposing a new table when a column would do, or touching a module you consider frozen. Approving a plan takes two minutes; unwinding an unapproved one takes an afternoon.
They also fail in opposite directions, which is worth naming. Skip the Spec gate and you get a fast build of the wrong thing. Skip the Plan gate and you get the right behavior implemented in a way you'd never have chosen — a new table, a touched frozen module, a dependency added at 2am. The Plan gate is the cheaper of the two to hold, and the one people drop first.
The fresh-context verification trick
The same Claude session that wrote the code is a biased reviewer of it — it knows what it meant. So verification uses a clean session (or /clear in Claude Code): paste the spec, point at the diff, and ask for a per-ID verdict with evidence. This is the single highest-leverage habit in this guide, and SEC-11 is devoted to doing it properly.
Anatomy of a workable spec
A spec Claude can build against fits on one page. Below is the reference structure — every field earns its place, though Requirements and Acceptance criteria carry the most weight, and Constraints and Out of scope save the most trouble.
One paragraph. The problem, who feels it, and the outcome. No implementation language allowed here.
Numbered, testable statements (R-1…R-n). Each uses must / must not. If a requirement can't fail, it isn't one.
Given / When / Then scenarios (AC-1…) — Gherkin's grammar, borrowed on purpose (SEC-06). These become your tests and your verification checklist. The three highest-value fields in any spec are this one, Constraints, and Out of scope.
What the implementation must respect: stack, patterns, performance budgets, frozen modules, compliance rules.
Explicit non-goals. This is what stops Claude from "helpfully" building the admin screen you didn't ask for.
Anything unresolved. The spec cannot be approved while this list is non-empty.
Field by field
Six fields, and each one fails in a characteristic way. What follows is what each is for — the deep dives on the two that carry the most weight are SEC-05 and SEC-06.
| Field | Its job | Fails when |
|---|---|---|
| Intent | Why this exists, for whom, and what changes. The only field a non-engineer must be able to read | It describes a design ("add a table with…") instead of a problem. Then nobody can challenge the design |
| Requirements | The behavioral rules, each able to fail (SEC-05) | They're adjectives — "robust", "properly audited" — or they restate the intent |
| Acceptance criteria | How you'll know, concretely enough to become tests (SEC-06) | They repeat the requirements with Given/When/Then bolted on, adding no numbers or observables |
| Constraints | The blast radius: stack, patterns, budgets, frozen zones, compliance rules | Left empty — so the feature quietly becomes a refactor |
| Out of scope | The adjacent good ideas you are deliberately not building | It lists absurdities nobody would build, instead of the plausible next steps a helpful implementer drifts into |
| Open questions | The gate itself — non-empty means not approvable | Questions get "resolved" by deleting them rather than answering them |
Two fields deserve a note beyond the table. Constraints and Out of scope are the cheapest lines in the document and the ones most often skipped — together they're what makes a diff reviewable, because they define what should not be in it. And Open questions is the only field with mechanical force: /plan refuses to run while it's non-empty, so an unanswered question physically blocks the build rather than being carried into it as an assumption.
The status lifecycle
Status is not decoration — it's the state machine the tooling reads. Each transition is a different act, performed by a different party, unlocking a different stage.
DRAFT ──────────▶ APPROVED ──────────▶ VERIFIED │ │ ▲ │ │ │ Claude drafts it. You flip it, by A fresh session flips it, /plan REFUSES. hand, after reading. only when every ID PASSes. Nothing may be Unlocks /plan and The build session may built. /build. never flip it itself.
| Transition | Who | Precondition |
|---|---|---|
| — → DRAFT | Claude, via /spec | You've been interrogated and answered |
| DRAFT → APPROVED | You, editing the file | Open questions empty; you've actually read the ACs and boundaries |
| APPROVED → VERIFIED | A fresh session, via /verify | Every R-x and AC-x PASS, with evidence, and the record pasted in |
| APPROVED → APPROVED′ | You, approving an amendment | The build hit a gap and stopped (SEC-10) |
Note what has no transition: there is no path from a failed verification to VERIFIED that runs through editing the spec. And nobody may set VERIFIED on their own work — that single rule is what keeps the status meaningful enough for CI to gate on (SEC-15).
Naming and numbering
One file per spec, named by a stable ID that never changes: SPEC-YYYY-NNN.md for features, DEFECT-YYYY-NNN.md for defects, MIG-NN.md for migration slices. The prefix tells you the shape to expect — a defect spec leads with Observed and Expected, a feature spec with Intent.
Stability is the whole point: that ID is cited in plan steps, commit messages, test names, code comments, and verification records. Renaming a spec breaks a chain that git log --grep can otherwise follow years later. Supersede rather than rename — a new spec, with a line in the old one pointing forward.
Testable vs. untestable — the acid test
| Untestable (rewrite it) | Testable (buildable) |
|---|---|
| "Overrides should be fast." | "Override decision endpoint must respond in < 400 ms at p95 under 50 concurrent users." |
| "Handle errors gracefully." | "If the clinical-rules service is unreachable, the request must be queued for retry and the reviewer shown status PENDING_RULES — never a 500." |
| "Only the right people can override." | "Only users holding role MEDICAL_DIRECTOR may submit an override; all other roles receive 403 and an audit event is written." |
How long should a spec be?
Long enough to remove ambiguity, short enough that you actually read it at the gate. In practice that lands at one page. The failure modes sit on both sides: a three-line spec pushes every real decision into the build, where it gets improvised; a six-page spec doesn't get read, so the gate becomes theater and Claude has more context than it can hold in working attention.
If a spec is growing past a page, that is almost never a writing problem — it's a scoping problem. The work wants to be two or three specs. Scenario 3 shows what that looks like when the work is genuinely large.
A note on where the effort goes. You do not write most of this. Claude drafts the whole document after interrogating you; your job is to answer its questions and to read the result critically. SEC-16 takes the obvious objection head-on.
Writing requirements that can fail
This is the skill the whole method rests on. A requirement that cannot fail is a sentence, not a requirement — and Claude will happily "satisfy" it with code you never wanted.
The difference between a spec that works and one that wastes an afternoon is almost always here. Below are the five questions to ask of every line you write, then a pattern library you can copy from — each pattern is a shape that recurs in real systems, with the weak version teams actually write and the strong version that survives review.
The five tests
| Test | Ask | Fails when… |
|---|---|---|
| 1 · Falsifiable | Can I describe an implementation that violates this? | "The API should be well designed." Nothing could contradict it. |
| 2 · Observable | What would I point at to prove it — a status code, a row, a log line, a number? | "Overrides are properly audited." Properly how? Audited where? |
| 3 · Attributed | Who or what performs the action, and who is refused? | "Users can override denials." Which users? What happens to the rest? |
| 4 · Bounded | Where exactly is the edge — inclusive or exclusive, 49 or 50, before or after? | "High-scoring requests auto-approve." 0.85 is exactly the bug in Scenario 2. |
| 5 · Protective | What must this change not break? | Silence. The frozen zone goes unstated and the refactor arrives uninvited. |
Test 4 deserves its own warning. Boundaries are where specs are quietly wrong, and where Claude will pick a reasonable-looking default that happens to differ from your policy. > versus >=, 50 characters versus "at least 50", "within 48 hours" versus "after 48 hours" — write the comparison, not the adjective.
A pattern library
Most requirements in transactional systems are one of about eight shapes. Learn the shapes and drafting stops being a blank page.
| Pattern | Weak | Strong |
|---|---|---|
| Permission who may, who may not |
"Only medical directors can override." | "Only role MEDICAL_DIRECTOR may submit an override; every other role must receive 403 and an ACCESS_DENIED audit event." |
| Validation exact boundary + response shape |
"Justification must be substantial." | "Justification must be ≥ 50 characters; 49 must return 422 with a field-level error naming justification." |
| State transition legal from-states, illegal ones |
"Overrides apply to denied requests." | "Override is permitted only from status DENIED → APPROVED_OVERRIDE; any other source status must return 409 and leave the status unchanged." |
| Side-effect count exactly N, not "at least" |
"An audit event is emitted." | "Exactly one audit event and exactly one provider notification are produced per successful override." Counts catch double-firing; existence checks don't. |
| Immutability what may never be touched |
"Keep history for audit." | "The original denial Decision row must remain byte-identical; the override is appended as a new row." |
| Frozen zone the blast radius |
"Reuse existing services." | "DecisionService gains at most one new method; existing methods must be unchanged. NotificationService methods are pinned by MIG-01's suite." |
| Error mapping each failure → one status |
"Handle bad input." | "Unknown request id → 404; wrong source status → 409; invalid justification → 422; wrong role → 403. Never 500 for any of these." |
| Idempotence / dedup what repeat calls do |
"Warn when the SLA is breached." | "A request receives at most one warning per threshold over its lifetime; a sweep finding no newly-crossed threshold emits nothing." |
The count trick. Of all eight, side-effect counting earns its keep fastest. "An audit event is emitted" passes even when your code emits three. "Exactly one" turns a vague nod into an assertion — and double notifications are one of the most common defects in event-driven code.
Writing what must not change
Requirements describe the new behavior; constraints and out-of-scope describe the blast radius, and they do more work than their length suggests. In SPEC-2026-041 one constraint — DecisionService gains at most one new method — is what keeps a feature from turning into a refactor. In DEFECT-2026-117, R-2 ("must not alter behavior for any score other than the boundary") is what makes a one-character fix stay a one-character fix.
Out-of-scope lines are cheap to write and expensive to omit. Every one of them is an argument you are choosing not to have during the build:
## Out of scope
Reversing an override; bulk overrides; changes to the scoring
engine or threshold; new admin screens; provider-facing appeal flow.
Note what that list is doing: it names the adjacent good ideas. Not absurd things nobody would build, but the plausible next features a helpful implementer would drift into. That is the list worth writing.
Try it. Take the last ticket you shipped and rewrite its title as three requirements using the patterns above — one permission or validation, one side-effect count, one immutability or frozen-zone line. If you can't name what must not change, you've found the risk in that ticket.
Acceptance criteria that become tests
Requirements say what must be true. Acceptance criteria say how you'll know — concretely enough that each one compiles into a test and answers, on its own, "is this done?"
An AC has three parts and no fourth: Given a starting state, When a specific action, Then an observable result. If you can't fill all three, you don't have an AC — you have a requirement with the labels rearranged.
From criterion to test, literally
Here is AC-1 from SPEC-2026-041 and the test it became in the companion app. Read them side by side: the Given is the fixture, the When is the request, the Then is the assertion block.
Given a DENIED request, when a medical director submits a valid override, then status becomes APPROVED_OVERRIDE and the original denial row is unchanged.
/** AC-1: valid override -> APPROVED_OVERRIDE, original denial row unchanged. */ @Test void ac1_validOverrideApprovesAndPreservesDenialRow() throws Exception { deniedRequest("OV-1"); // Given List<Decision> before = store.decisionsFor("OV-1"); mvc.perform(post("/api/requests/OV-1/override") // When .header("X-User-Role", "MEDICAL_DIRECTOR") .header("X-User-Id", "md-42") .contentType("application/json") .content(body("MEDICAL_NECESSITY", VALID_JUSTIFICATION))) .andExpect(status().isOk()) // Then .andExpect(jsonPath("$.status").value("APPROVED_OVERRIDE")); List<Decision> after = store.decisionsFor("OV-1"); assertThat(after).hasSize(before.size() + 1); assertThat(after.subList(0, before.size())) .containsExactlyElementsOf(before); // prior rows byte-identical }
The last assertion is the interesting one. "The original denial row is unchanged" could have been tested lazily — check the row still exists, check the status field. Instead it compares the entire prior list element-by-element, so any mutation of history fails the test. That precision came from the wording of the requirement, not from the test author's inspiration.
Where this comes from: BDD and Gherkin
If Given/When/Then looked familiar, it should. That grammar is Gherkin — the language Cucumber, SpecFlow and Behave parse — and the practice built around it is BDD (behaviour-driven development). This guide borrows the grammar deliberately, and stops short of the tooling. Both halves of that choice are worth understanding.
The grammar earns its place because of what the third clause forces. "Then" demands an observable — a status code, a row, a count — so a criterion written this way cannot stay an adjective. That is the same discipline SEC-05's five tests apply to requirements, and it is why every AC in this guide reads like a scenario rather than a wish.
The scope is where the two differ. BDD covers the acceptance-criteria layer and the conversation that produces it. A spec wraps that layer in the things Gherkin has no syntax for:
| BDD / Gherkin idea | Its counterpart here |
|---|---|
| The three amigos — business, dev and test agreeing examples before code | The interrogation (SEC-07) plus an accountable approver who is not the author (SEC-13) |
| Specification by example — concrete cases, not abstractions | ACs carrying real numbers: 49 characters, exactly 0.85, exactly one audit event |
| Outside-in — start from the behaviour, work inward | /plan rule 5: end with the test list, one test per AC, named after it |
| Declarative, not imperative scenarios | "ACs are not implementation steps" — below |
Gherkin's And chaining clauses | Count the conjunctions, count the assertions — the half-pass rule |
| Living documentation — the spec stays true because it runs | The verification record, committed beside the code with per-ID evidence (SEC-11) |
| — no equivalent — | Constraints, frozen zones, out of scope, and the DRAFT→APPROVED→VERIFIED lifecycle |
That last row is the substantive gap. Gherkin describes what the system should do; it has nothing to say about what a change may not touch. "DecisionService gains at most one new method" and "NotificationService methods are pinned by MIG-01" are not scenarios — and they are exactly the lines that kept Scenario 1's feature from becoming a refactor. A practice built only on scenarios leaves the blast radius unstated, which is the failure SEC-14 files under silent drift.
The half-pass — the failure mode ACs exist to catch
Look closely at AC-5: "then 403 is returned and an access-denied audit event is written." Two clauses. An implementation that returns 403 and writes nothing satisfies the half you'd notice by hand and fails the half that matters to your auditors — and every manual test still "works".
Half-passing implementation
- Reviewer gets 403 ✓
- Audit stream: silent ✗
- Manual testing: looks correct
- Discovered: at the next compliance review
What the test asserts
.andExpect(status().isForbidden())assertThat(audit.events()).anyMatch(e -> ACCESS_DENIED && actor && requestId)- Plus: status still
DENIED— refusals change nothing - Discovered: before merge
Rule: count the clauses in every AC, then count the assertions in its test. If an AC contains "and", the test needs at least two assertions. This one habit catches more real defects than any other in this guide.
Asserting on counts, not existence
AC-4 — "exactly one audit event and one provider notification" — needs a technique, because both streams already contain rows from the setup. The test snapshots the sizes before, then asserts the exact delta and inspects the new entry:
int auditBefore = audit.events().size(); int outboxBefore = notifications.outbox().size(); // … perform the override … assertThat(audit.events()).hasSize(auditBefore + 1); // exactly one assertThat(audit.events().get(auditBefore)) .containsEntry("type", "OVERRIDE") .containsEntry("actor", "md-42"); assertThat(notifications.outbox()).hasSize(outboxBefore + 1);
A test that only asserted "an OVERRIDE event exists" would pass while the endpoint fired twice — a duplicate-notification bug that reaches providers as two faxes and reaches you as a support ticket.
Negative criteria: what must stay put
Every rejection path deserves an AC that asserts nothing happened. In the 422 case the test checks the status is still DENIED and the decision count is unchanged; in the 409 case, the same. Without those lines, an endpoint that validates correctly but has already mutated state passes your happy-path tests and corrupts data on every bad request.
Coverage heuristic for a CRUD-ish endpoint: one AC for the happy path, one per rejection (403 / 404 / 409 / 422), one for side-effect counts, and one for what must remain unchanged. Five to seven ACs, and the test class writes itself — which is exactly what Claude does with them.
What ACs are not
- Not implementation steps. "Add a column to the decisions table" is a plan step, not a criterion — it describes work, not evidence.
- Not restated requirements. If AC-2 is R-2 with "Given/When/Then" bolted on, you gained nothing. The AC must add the concrete numbers and the observable.
- Not a test plan. ACs are stable behavioral claims; the tests are one way to demonstrate them. Keep framework names out.
Try it. Take the AC-5 pattern and apply it to a permission check in your own system. Write the criterion with an "and" clause for the audit trail, then go look at the existing code. In most codebases the second clause is missing — that's a real finding, produced by a sentence.
Scenario 1 — New feature
The compliance team needs a manual override flow: when the rules engine denies a prior-authorization request, a medical director must be able to override the denial with a documented justification.
Stage 1 · Intent, in your own words
You start with exactly the kind of message you'd otherwise fire off as a one-shot prompt — but you address it to the spec, not the code:
# To Claude (claude.ai or Claude Code, plan mode)
Draft a spec for a denial override feature in the Prior Auth Portal.
Context: requests scoring below AUTO_APPROVE_THRESHOLD (0.85) route to
manual review; reviewers can currently only approve or deny. Compliance
wants medical directors to be able to override a denial with a written
justification, fully audited.
Before drafting, ask me every question you'd need answered to make
this spec unambiguous. Do not write any code.
What comes back is not a draft — it's an interrogation. Every one of these questions is a decision that would otherwise be made silently, by whoever is implementing, at the moment they hit it:
⏺ Before I draft, I need decisions on:
1. Who may override? A role, or named individuals?
2. Justification: free text, coded reason, or both? Minimum length?
3. What happens to the original denial row — updated, or preserved?
4. Which source statuses allow an override? What about a request id
that doesn't exist?
5. Who is notified, and through which channel?
6. Is an override reversible? By whom?
7. What must this change NOT touch?
8. What's explicitly out of scope for this spec?
And here is the entire human contribution to a seven-requirement spec — eight answers, most of them a few words:
› 1. role MEDICAL_DIRECTOR, from the X-User-Role header
2. both — 50 char minimum, plus a coded reason enum
3. preserved, always. append a new row
4. DENIED only; anything else 409; unknown id 404
5. provider, existing notification service, no new channel
6. no — out of scope
7. DecisionService's existing methods. add a method, don't edit one
8. bulk overrides, appeals, admin screens, real auth
Notice what just happened at answer 4. "Unknown id 404" is a distinction nobody asked for in the original request and that a helpful implementer would have resolved on their own — probably as a 409, since the request isn't DENIED. Getting it asked at the spec gate cost one clause; getting it wrong in production costs an integration partner debugging why their typo reports a state conflict.
Ten minutes of answering replaces a day of drift, and the document that comes out is written entirely by Claude.
Stage 2 · The approved spec
Medical directors can override a denied prior-auth request with a documented clinical justification, preserving the original decision for audit. Providers are notified of the reversal through the existing notification pipeline.
- R-1Only callers presenting role
MEDICAL_DIRECTOR(viaX-User-Role, withX-User-Ididentifying the actor) may submit an override; any other role receives 403. - R-2An override must carry a justification of ≥ 50 characters and one coded reason from a new
OverrideReasonenum (MEDICAL_NECESSITY, PEER_REVIEW_OUTCOME, DOCUMENTATION_SUPPLEMENTED). - R-3The original denial
Decisionrow must remain untouched; the override is appended as a new row against the same request. - R-4Every successful override emits exactly one audit event (actor, request id, reason code) via
AuditService. - R-5Overrides are valid only for requests in status
DENIED; any other status returns 409. Unknown request ids return 404. - R-6Provider notification reuses
NotificationService.notifyProviderOfDecision— no new channel or method. - R-7A successful override sets the request status to
APPROVED_OVERRIDE(new enum value).
- AC-1Given a DENIED request, when a medical director submits a valid override, then status becomes
APPROVED_OVERRIDEand the original denial row is unchanged. - AC-2Given a justification of 49 characters, when submitted, then the API returns 422 with a field-level error.
- AC-3Given a request in status
MANUAL_REVIEW, when an override is attempted, then the API returns 409 and the status is unchanged. - AC-4Given any successful override, then exactly one audit event and one provider notification are produced.
- AC-5Given a user with role
REVIEWER, when they attempt an override, then 403 is returned and an access-denied audit event is written.
Endpoint: POST /api/requests/{id}/override on RequestController. DecisionService gains at most one new method; existing methods frozen. In-memory store only. Tests derived directly from AC-1..AC-5.
Reversing an override; bulk overrides; scoring/threshold changes; new admin screens; provider-facing appeal flow; real authentication (header role simulation is the repo's convention).
Stage 3 · Plan against IDs
In Claude Code, you stay in plan mode and ask for an implementation plan keyed to the spec:
Read SPEC-2026-041.md. Propose an implementation plan. Each step must reference the requirement IDs it satisfies. Flag any step that touches code outside the stated constraints.
The plan that comes back is six steps, and every one of them carries the IDs it exists to satisfy:
1. Add OverrideReason enum + APPROVED_OVERRIDE status [R-2, R-7] 2. DecisionService.override(...) — ONE new method; existing methods untouched (frozen zone) [R-3, R-4, R-5] 3. POST /api/requests/{id}/override — role guard from X-User-Role, 422 validation, 404/409 mapping [R-1, R-2, R-5] 4. ACCESS_DENIED audit emit on the 403 path [AC-5] 5. Tests generated from AC-1..AC-5, one per criterion [AC-1..AC-5] 6. Provider notification via existing NotificationService [R-6]
Because every step carries IDs, a step with no ID sticks out immediately — that's scope creep announcing itself. Read the plan the other way too: every requirement should appear somewhere. If R-6 appeared in no step, the notification would simply never have been built, and you'd find out at verification instead of here.
Step 4 is worth pausing on. It exists as its own step, with its own ID, because AC-5 has two clauses and the second one is the forgettable half. A plan that folded it into step 3 would be perfectly reasonable — and would make the omission invisible.
Stages 4–5 · Build, then verify cold
You approve the plan and let Claude implement, with one standing instruction: "If the spec turns out to be wrong or incomplete, stop and propose an amendment — do not improvise." After the build, you /clear and run the fresh-context review:
Here is SPEC-2026-041 and the diff. For each of R-1..R-7 and AC-1..AC-5, state PASS or FAIL with file/line evidence. Then list anything in the diff not required by the spec.
In a typical run this catches one real issue — say, the 403 path returns correctly but never writes the access-denied audit event (AC-5 half-pass). That's a two-line fix now, and a compliance finding six months later if missed.
What the spec produced
Here is the entire production change to DecisionService — one new method, with the requirement IDs visible in the code itself. Read it against the requirements above and notice how little interpretation was left to the implementer:
/** * SPEC-2026-041: a medical director overrides a DENIED request. * The original denial Decision row is left untouched (R-3); the override * is appended as a new row, exactly one audit event is emitted (R-4), * and the provider is notified through the existing channel (R-6). */ public AuthRequest override(String requestId, String actorId, OverrideReason reason, String justification) { AuthRequest request = store.find(requestId) .orElseThrow(() -> new IllegalArgumentException("no such request: " + requestId)); if (request.getStatus() != RequestStatus.DENIED) { // R-5 → 409 throw new IllegalStateException("request not in DENIED: " + requestId); } request.setStatus(RequestStatus.APPROVED_OVERRIDE); store.save(request); store.appendDecision(new Decision(UUID.randomUUID().toString(), requestId, RequestStatus.APPROVED_OVERRIDE, actorId, "override (" + reason + "): " + justification, Instant.now())); // R-3: append audit.emit("OVERRIDE", actorId, requestId, "reason " + reason); // R-4: exactly one notifications.notifyProviderOfDecision(request); // R-6: existing channel return request; }
Three things in that method exist because a line in the spec demanded them. R-3 is why there is an appendDecision and no mutation of the denial row. R-4 is why there is exactly one audit.emit — a second one, added later "for completeness", would fail AC-4's count assertion. R-6 is why this reuses notifyProviderOfDecision rather than introducing a channel, which would also have broken MIG-01's characterization suite.
Equally instructive is what the class does not contain: no changes to submit() or review(). That's the constraint "DecisionService gains at most one new method" doing its job — a sentence that cost ten seconds to write and prevented an unreviewable diff.
The other half lives in the controller, and it's where four requirements become four distinct exits. Read it as a map of the spec's error table:
@PostMapping("/{id}/override") public ResponseEntity<?> override(@PathVariable String id, @RequestHeader(value = "X-User-Role", required = false) String role, @RequestHeader(value = "X-User-Id", required = false) String userId, @RequestBody OverrideBody body) { String actor = (userId == null || userId.isBlank()) ? "unknown" : userId; if (!"MEDICAL_DIRECTOR".equals(role)) { // R-1 / AC-5: reject AND leave an audit trail of the attempt. audit.emit("ACCESS_DENIED", actor, id, "override attempted with role " + role); return ResponseEntity.status(HttpStatus.FORBIDDEN)… // 403 } // R-2 / AC-2: the spec requires 422 with a field-level error, // not Spring's default 400, so the length rule is checked here. if (body.justification() == null || body.justification().length() < 50) { return ResponseEntity.unprocessableEntity() .body(Map.of("field", "justification", …)); // 422 } try { return ResponseEntity.ok(decisions.override(id, actor, body.reason(), body.justification())); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); // R-5: 404 } catch (IllegalStateException e) { return ResponseEntity.status(HttpStatus.CONFLICT)… // R-5: 409 } }
Three details in that method are traceable to a specific sentence someone wrote before any code existed:
- The audit emit sits above the return. That ordering is AC-5's second clause. Delete that one line and every manual test still passes, the endpoint still refuses reviewers, and your compliance trail silently loses every attempted misuse.
- The length check is hand-rolled rather than a bean-validation annotation. Not stylistic — AC-2 demands 422 with a field-level error, and Spring's default for a violated constraint is 400. The AC named the status code, so the implementation had to earn it.
- Two exception types map to two status codes.
IllegalArgumentException→ 404,IllegalStateException→ 409. The service throws domain-shaped errors; the controller translates them into R-5's contract. Without R-5 spelling out both, one of these would almost certainly have become a 400.
What verification actually checked
The fresh session's job here is narrow and mechanical: twelve IDs, twelve verdicts, evidence on each. Two rows carry most of the risk:
| ID | What a lazy check would accept | What the evidence must show |
|---|---|---|
| AC-1 | "The denial row is still there" | The whole prior list compared element-by-element — containsExactlyElementsOf(before) — so no field of any earlier row changed |
| AC-5 | "403 is returned for a REVIEWER" | 403 and an ACCESS_DENIED event naming that actor and request, and the status still DENIED |
The spec's verification record in the repo is worth reading in full for its last line, which is the one most reviews omit entirely: "Changes in the diff not required by the spec: none."
Walk it, step by step
Below is this exact scenario as Lab 2 in the companion app — what you type, what Claude answers, and the app's state before and after each step. Step through it here, then run it for real.
Scenario 2 — Defect as a spec
Spec-driven isn't just for features. A bug is a violated spec that was never written down — so write it down now, and the fix becomes verifiable instead of vibes.
The report from support: "Some auth requests are getting auto-approved that shouldn't be." Investigation shows requests with a score of exactly 0.85 are auto-approving, but the policy says the threshold must be exclusive — 0.85 should route to manual review. A one-character fix (>= → >)… which is precisely why it deserves a spec: one-character fixes to decision logic are where silent regressions live.
Triage: from a complaint to two sentences
A support report is not a defect spec. It's a symptom, a sample size of "some", and an implied theory. The work of triage is to turn it into two sentences that can be tested — and the discipline is that you must be able to write both before you touch the code.
| Stage | What you have |
|---|---|
| Report | "Some auth requests are getting auto-approved that shouldn't be." Unfalsifiable as written — which requests? approved by whom? shouldn't according to what? |
| Reproduction | REQ-1003, score exactly 0.85, status AUTO_APPROVED. One concrete case beats a hundred anecdotes |
| Rule | The policy says auto-approval requires a score above the threshold. Not "high", not "at or above" — above |
| Observed | Requests with score == 0.85 receive status AUTO_APPROVED. |
| Expected | Auto-approval requires score > AUTO_APPROVE_THRESHOLD (strict). Boundary scores route to MANUAL_REVIEW. |
If you cannot write the Expected line without hedging, you have a policy question, not a defect — and the right next step is a conversation with whoever owns the policy, not a patch. That distinction alone prevents a category of "fixes" that quietly change business rules.
Requests with score == 0.85 receive status AUTO_APPROVED. Seed request REQ-1003 demonstrates it on every startup.
Auto-approval requires score > AUTO_APPROVE_THRESHOLD (strict). Boundary scores route to MANUAL_REVIEW.
- R-1Comparison must be strict;
0.85exactly must route to manual review. - R-2The fix must not alter routing for any score other than the boundary.
- R-3Regression tests must pin behavior at exactly
0.85, plus0.8499and0.8501, and must be shown FAILING against current code before the fix is applied. - R-4Historical decisions must not be retroactively modified; remediation of already-approved boundary cases is a separate work item.
- AC-1Given a request with score 0.85, when submitted, then its status is
MANUAL_REVIEWand a reviewer-queue notification is sent. - AC-2Given scores
0.8501and0.95, when submitted, then bothAUTO_APPROVED. - AC-3Given scores
0.8499and0.50, when submitted, then bothMANUAL_REVIEW. - AC-4The full baseline suite stays green.
Minimal diff. ScoringService only, plus tests.
Changing the threshold value; refactoring the scoring service; remediation of past boundary approvals.
The instruction to Claude Code is then almost boring — and that's the point:
Fix DEFECT-2026-117 per the spec above. Write the R-3 boundary tests FIRST and show me they fail on current code, then apply the minimal fix and show them passing. Touch nothing else.
Notice what the spec bought you: a failing-test-first workflow (Claude proves the bug before fixing it), a minimality contract (R-2 forbids the drive-by refactor), and an explicit deferral of the messy remediation question so it doesn't get improvised into this change.
Red first — the step people skip
Before any fix, the boundary test runs against the current code and fails. That failure is the deliverable of the step: it proves the test actually detects the bug. A test written after the fix proves only that it agrees with whatever the code now does.
R-3 asks for three cases, not one, and the choice of the other two is the interesting part:
/** AC-1: exactly 0.85 routes to MANUAL_REVIEW and notifies the reviewer queue. */ @Test void boundaryScoreRoutesToManualReview() { AuthRequest r = decisions.submit( new AuthRequest("T-BOUNDARY", "M-B1", "444", "97110", 0.85)); assertThat(r.getStatus()).isEqualTo(RequestStatus.MANUAL_REVIEW); assertThat(notifications.outbox()) // routed AND queued .anyMatch(s -> s.channel().equals("QUEUE") && s.body().contains("T-BOUNDARY")); } /** AC-2: strictly above the threshold auto-approves. */ @Test void scoresAboveThresholdAutoApprove() { // 0.8501 and 0.95 /** AC-3: strictly below the threshold routes to manual review. */ @Test void scoresBelowThresholdRouteToManualReview() { // 0.8499 and 0.50
Pinning only 0.85 would be a trap. A "fix" that inverted the comparison entirely — routing everything to manual review — would pass that single test while breaking the product. The neighbours at 0.8499 and 0.8501 are what make the test suite describe a threshold rather than a single point, and the far values (0.50, 0.95) are R-2's minimality contract expressed as assertions: prove the rest of the range didn't move.
The first test also asserts something beyond routing: that the reviewer queue was notified. Routing a request to manual review without telling anyone is a different bug wearing the same status field, and one line of assertion closes it.
The one Gherkin construct worth stealing
A boundary is a table of examples, and Gherkin has a shape built for exactly that. Written as a Scenario Outline, all three of DEFECT-2026-117's cases collapse into one scenario and an Examples block:
# the same three cases, as a Gherkin Scenario Outline Scenario Outline: routing at the auto-approval threshold Given a request scoring <score> When it is submitted Then its status is <status> Examples: | score | status | # why this row exists | 0.85 | MANUAL_REVIEW | # the boundary itself — the defect | 0.8501 | AUTO_APPROVED | # neighbour above: proves it's a threshold… | 0.8499 | MANUAL_REVIEW | # …and not a single pinned point | 0.95 | AUTO_APPROVED | # far side: R-2's minimality contract | 0.50 | MANUAL_REVIEW | # far side, below
Read as a specification that is genuinely better than the prose version: the table makes the shape of the rule visible, and an empty row is a conspicuous gap. Steal this whenever a criterion is really a table — boundaries, permission matrices, status transitions — even if you write it in Markdown inside the spec and never run a Cucumber runner over it.
The companion app keeps the JUnit version because the reader of these tests is an engineer and the runner is already there. What it kept from Gherkin is the thinking: enumerate the rows, then let each row become an assertion.
[ERROR] ThresholdBoundaryTest.boundaryScoreExactlyAtThresholdRoutesToManualReview:34 expected: MANUAL_REVIEW but was: AUTO_APPROVED Tests run: 6, Failures: 1 — BUILD FAILURE ← the bug, reproduced
Then the fix, which is the entire production diff:
- if (request.getScore() >= AUTO_APPROVE_THRESHOLD) { + if (request.getScore() > AUTO_APPROVE_THRESHOLD) { // R-1
Tests run: 6, Failures: 0 — BUILD SUCCESS
One character. And the reason it deserved all this ceremony is precisely that it's one character: a change this small is invisible in review, sits in the middle of decision logic, and silently reclassifies every request at the boundary. R-3 — pinning 0.85, 0.8499, and 0.8501 — is what stops the next well-meaning refactor from putting the = back.
Why the deferral matters more than the fix
R-4 and the out-of-scope line both point at the same uncomfortable fact: every request that landed exactly on the boundary has already been auto-approved, and those decisions are on record. Deciding what to do about them — re-open? notify? leave them? — is a policy question involving compliance and clinical staff, not a coding question. Without the spec, that question arrives mid-build and gets answered by whoever is at the keyboard at 4pm. With it, the fix ships today and the policy question becomes its own work item with the right people in the room.
A defect spec vs a ticket
Most teams already write something when a bug arrives. The question is whether that artifact can be built and verified against, or only read.
A good ticket
- Title, reproduction steps, severity, a screenshot
- Says what went wrong
- Closed when someone says it's fixed
- Silent on what must not change
- Silent on the already-approved boundary records
- Lives in a tracker; unfindable from the code in a year
A defect spec
- Observed and Expected, both testable
- Says what correct behavior is, exactly
- Closed when three pinned tests pass and a fresh session says so
- R-2 forbids collateral change
- R-4 defers remediation deliberately, in writing
- Lives beside the code;
git blamefinds it
The difference costs about ten extra minutes at the start of a P2 and pays for itself the first time someone asks why a comparison is strict.
Real-world habit: keep defect specs in the repo next to feature specs. Six months later, "why is this comparison strict?" is answered by git blame pointing at DEFECT-2026-117 instead of an archaeology session.
Walk it, step by step
This defect is planted in the companion app's scoring service, spec pre-approved. Step through Lab 1 below and watch the order of operations: the failing boundary test lands before the fix.
Scenario 3 — Migration slice
Large migrations fail as prompts and succeed as a stack of small specs. The unit of work is a slice: independently shippable, independently verifiable, reversible.
The Prior Auth Portal's notification logic — provider emails, reviewer alerts, channel selection — lives tangled inside the monolith. The goal is to extract it into a standalone service. Asking Claude to "extract the notification service" in one go produces a heroic, unreviewable 4,000-line diff. Spec-driven, the work becomes a numbered sequence where each spec has its own acceptance criteria and its own rollback line:
| Slice | Spec intent | The bar it must clear |
|---|---|---|
| MIG-01 | Characterization tests around current notification behavior — no production code changes | Every existing notification path covered by a test asserting today's behavior, including the weird ones |
| MIG-02 | Introduce NotificationPort interface inside the monolith; route all callers through it | MIG-01 suite fully green; zero behavior change |
| MIG-03 | Stand up priorauth-notifications service implementing the port over HTTP, behind a feature flag, in shadow mode | Shadow output matches monolith output for 100% of a 48-hour replay window |
| MIG-04 | Cut over per notification type, flag-controlled | Flag off restores monolith path in < 1 minute with no data loss |
| MIG-05 | Delete dead monolith code | Only after two clean weeks on MIG-04; MIG-01 suite retargeted at the service |
Two things make this shape work with Claude specifically. First, MIG-01 is a spec whose deliverable is tests — Claude excels at reading legacy code and writing characterization tests, and those tests then become the safety net every later slice is verified against. Second, each slice fits comfortably in one Claude Code session with room for the plan gate and verification, so you never carry fragile mid-migration context across sessions — the specs are the persistent context.
What makes a slice a slice
"Break it into smaller pieces" is advice everyone agrees with and nobody can act on, because the hard part is knowing where to cut. A slice is not "a day of work" or "one module" — it's a unit with four properties, and the fourth is the one that does the work:
- Independently shippable. It can go to production alone and leave the system in a coherent state. If two slices must ship together, they're one slice.
- Independently verifiable. It has its own acceptance criteria that can pass or fail without reference to later slices.
- Session-sized. It fits in one Claude Code session with room for the plan gate, the build, and a fresh-context verification. If you're carrying half-built state across a
/clear, the slice is too big. - Reversible in one sentence. You can state how to undo it, in one sentence, without conditionals.
The migration rule: if a slice can't state its rollback in one sentence, the slice is too big. Split it before Claude writes a line. "Turn the flag off" is a sentence. "Turn the flag off, then replay the queue, unless the cutover already drained it, in which case…" is a confession.
Test the MIG series against that: MIG-01 — delete the test file. MIG-02 — revert one commit; behavior never changed. MIG-03 — the service is in shadow mode, so stop reading its output. MIG-04 — turn the flag off. MIG-05 — restore deleted code from git, only ever attempted after two clean weeks. Five sentences, no conditionals.
MIG-01 in depth: characterizing what's actually there
The first slice writes no production code at all, and it is the one people want to skip. Resist that: everything downstream is verified against it. A characterization test doesn't assert what the code should do — it pins what it does, quirks included, so that any later change in behavior shows up as a failure rather than as a surprise in production.
The Prior Auth Portal has exactly the kind of quirk that makes this concrete. Provider notifications choose a channel like this:
// Quirk preserved on purpose: auto-approvals email the provider, // everything else "faxes" them. String channel = switch (request.getStatus()) { case AUTO_APPROVED -> "EMAIL"; default -> "FAX"; };
Nobody would design that. It is also, almost certainly, load-bearing: some downstream fax integration, some provider expectation, some compliance artifact depends on it. The whole discipline of MIG-01 is captured in one comment the spec requires the tests to carry:
/** * R-1, R-2: every status other than AUTO_APPROVED "faxes" the provider. * This EMAIL-vs-FAX split is PRESERVED behavior, not ENDORSED behavior — * fixing the quirk is explicitly out of scope for MIG-01. */ @Test void everyOtherStatusNotifiesProviderByFax() { for (RequestStatus status : RequestStatus.values()) { // exhaustive, not sampled if (status == RequestStatus.AUTO_APPROVED) continue; NotificationService svc = new NotificationService(); svc.notifyProviderOfDecision(request("N-2", status)); assertThat(svc.outbox()).containsExactly(new Sent( "FAX", "1112223333", "Request N-2 for member M-500 is now " + status)); } }
Two details are worth copying. The loop is over RequestStatus.values(), so a status added next year is automatically covered — a sampled test would silently ignore it. And the assertion is containsExactly against a whole Sent record: channel, recipient, and exact body text. Extraction is precisely the kind of change that alters a message's wording by accident, and a test that only checked the channel would wave it through.
Proving a negative: the empty diff
MIG-01's sharpest requirement is R-4: zero production files modified. A tests-only spec whose build "just cleaned up one small thing" has failed, because the safety net now describes code that changed while nobody was watching. That requirement is verifiable with a command rather than an assurance:
$ git diff --stat 380f336..HEAD -- src/main
(empty) ← R-4 satisfied, and provably so
This is a good habit beyond migrations. When a requirement says something must not happen, ask what command proves it. "We didn't touch production code" is a claim; an empty git diff --stat is evidence.
The seam, the shadow, the flag
The middle slices each answer one question, and each one's acceptance criterion is chosen so that "it works" is measurable rather than felt.
| Slice | The question it answers | How you know |
|---|---|---|
| MIG-02 | Can every caller go through one interface? | MIG-01's suite is green and the diff changes no behavior — the interface is a seam, not a rewrite. If the characterization tests fail here, you didn't extract, you changed. |
| MIG-03 | Does the new service produce identical output? | Shadow mode: both paths run, only the monolith's output is delivered, and the two are compared. 100% match across a 48-hour replay window — real traffic, not fixtures. |
| MIG-04 | Can we switch, and switch back? | Cut over one notification type at a time behind a flag. The AC is about the rollback: flag off restores the monolith path in under a minute with no data loss. |
| MIG-05 | Is the old path really dead? | Two clean weeks on MIG-04 first, then delete — and retarget MIG-01's suite at the new service so the safety net survives the code it was written against. |
MIG-03 is where most extraction attempts are quietly wrong, and shadow mode is why this shape catches it. Running both implementations against real traffic and diffing the output finds the cases your tests didn't imagine — the request with a null NPI, the status nobody produces any more except in reprocessed 2023 records. When a mismatch appears, it is not automatically a bug in the new service: half the time the monolith is doing something undocumented, and the correct response is to go back to MIG-01 and add a characterization test for the behavior you just discovered.
Carrying decisions across slices
A migration spans weeks and many sessions, so the thing that most often breaks is not the code — it's memory. This is where the spec files earn their place beyond any single build.
MIG-01 in the companion app shipped with one open question: are SLA-breach warnings in scope for the eventual extraction? The answer was no — they aren't implemented, so there is no current behavior to characterize. But the decision didn't evaporate when the session ended; it was written into the spec, along with its consequence for a slice that didn't exist yet:
## Resolved questions - SLA-breach warnings in scope for the eventual extraction? No (resolved 2026-08-25): they are not implemented, so there is no current behavior to characterize. MIG-01 pins existing behavior only. Carried forward to MIG-02: the extraction interface (NotificationPort) must not preclude adding SLA-breach warnings later.
Months later, when SPEC-2026-042 proposes SLA-breach warnings for real, it can state its own migration impact precisely — a new notification method widens the surface MIG-02's port must accommodate — because the earlier decision is still on file. No one had to remember it. That is what people mean when they say the specs are the persistent context, and it is the single biggest reason large migrations succeed spec-driven and fail conversationally.
How migrations go wrong anyway
| Failure | Looks like | Guard |
|---|---|---|
| Characterization tests assert the intended behavior | Tests written from the docs or from what the code "obviously means"; they pass, then the extraction changes real behavior nobody pinned | Write tests by running the code, not by reading intentions. Quirks get pinned and commented as preserved-not-endorsed |
| Fixing the quirk during extraction | "While we're here, EMAIL for everything" | Out of scope on MIG-01, and MIG-02's AC is literally "zero behavior change". Fix quirks in their own spec, after the move |
| Slice too big | Rollback sentence needs an "unless"; the session runs out of room mid-build | The four properties above. Split before writing a line |
| The flag never comes out | MIG-04 ships, everyone moves on, both paths live forever | MIG-05 exists as a numbered slice with an entry condition (two clean weeks), not as a vague intention |
| Deleting before the net is retargeted | MIG-05 removes the code MIG-01's tests exercise; the suite is deleted with it | Retargeting the characterization suite at the new service is part of MIG-05's acceptance criteria |
Walk it, step by step
MIG-01 ships in DRAFT with one open question, so /plan MIG-01 must refuse. Lab 3 below walks the refusal, the resolution, and a build whose entire production diff is empty.
When the spec turns out to be wrong
It will. Implementation is the most honest review a spec ever gets, and roughly one build in three surfaces something the spec didn't answer. What you do in that moment decides whether SDD survives contact with reality.
There are exactly two options. Amend the spec, or silently improvise. The second is the one that kills the practice — not because the improvisation is usually wrong, but because after it, the spec no longer describes the system. Once that's true twice, nobody trusts the specs, verification becomes meaningless, and the team quietly returns to prompting.
The STOP rule
The standing instruction lives in CLAUDE.md, so it applies even when you forget to say it:
# from the working agreement - If implementation reveals the spec is wrong or incomplete: STOP, propose an amendment to the spec file, wait for approval. No silent workarounds, no "improving on" the spec.
Four triggers should stop a build:
- A gap. The spec doesn't say what to do in a case the code must handle.
- A contradiction. Two requirements can't both hold, or one conflicts with a constraint.
- An impossibility. The requirement can't be met within the constraints — a frozen module would have to change.
- A discovered cost. It's satisfiable, but at a price you'd want to weigh (a new dependency, a schema migration, a big performance hit).
What stopping looks like
Take the error mapping in SPEC-2026-041: wrong role → 403, wrong source status → 409, bad justification → 422. Now suppose the spec had said only "any status other than DENIED returns 409", and the build reaches this case: what if the request id doesn't exist at all?
"Not DENIED" technically covers it, so 409 is defensible — but 409 means "conflict with current state", and a request that was never there has no state. The honest answer is 404. That's a gap, not a judgment call to make silently, and the build stops rather than picking:
Build, step 3 (override endpoint error mapping).
R-5 maps "any status other than DENIED" to 409 but is silent on a request id that does not exist. Returning 409 would report a state conflict for an entity with no state.
Extend R-5: "An unknown request id must return 404. A known request in any status other than DENIED must return 409." Add a test pinning the 404 path.
One extra branch in the controller; no change to DecisionService; no change to any other AC. Alternative considered: leave 409 for both — rejected, it hides typos in client integrations as state conflicts.
The copy-paste version of that block is in SEC-15. Append amendments under an ## Amendments heading rather than editing the original requirement in place — the point is that the change is visible, dated, and carries the alternative you rejected.
You approve in about fifteen seconds, the spec is edited, and the build resumes.
The cheaper version of the same decision. In the shipped repo this amendment never happened — because the question got asked at the spec gate instead. R-5 reads: "any other status returns 409. Unknown request ids return 404." Same decision, same words, one stage earlier and at a fraction of the cost (SEC-01's cost curve). The amendment path is what it looks like when a question slips past the gate — which is why amendments are a healthy signal, not a failure: roughly one build in three should produce one, and zero means people are improvising silently.
Either way the decision ends up in the file, and the test that enforces it is named for the requirement — r5_unknownRequestId404() — so a year from now the 404 branch is traceable to the sentence that created it.
Amendment or drift?
Not every mid-build discovery deserves ceremony. The test is whether the change alters what the system does or merely how.
| Situation | Verdict | Why |
|---|---|---|
| Spec says 409 for a case that should be 404 | Amend | Observable behavior changes; a client can tell. |
| Extracting a private helper to keep the method readable | Just build | No observable change, inside the spec's blast radius. |
| Adding an index to make an AC's latency budget achievable | Amend (constraints) | Touches schema — outside what was approved. |
Naming the enum OverrideReason vs ReasonCode | Just build | Internal naming; the spec named behavior, not identifiers. |
| Realizing two ACs contradict each other | Amend — and stop | No implementation can satisfy both; building anything is guessing. |
| "While I'm here, this method should be async" | Neither — out of scope | New work. New spec, or a backlog item. |
Amending after the build — and after release
If verification catches a miss, the fix goes under the same spec: the spec was right, the code was wrong. The temptation to edit the spec so the code passes is the single most corrosive move available to you; it converts your test oracle into a transcript of whatever happened. If you ever genuinely need to change an approved spec's behavior after release, that's a new spec that supersedes it, with a line in the old one pointing forward. Specs are append-mostly for the same reason the Decision rows are.
Watch for the polite improviser. Claude is helpful by disposition; without the STOP rule it will resolve small ambiguities on your behalf and mention it in passing, or not at all. The rule in CLAUDE.md plus the "list anything in the diff not required by the spec" line in verification are the two mechanisms that make improvisation visible.
Verification that actually catches things
The highest-leverage habit in this guide, and the one teams skip first. Done properly it takes five minutes and regularly finds something the build session was blind to.
Why a fresh session, mechanically
The session that wrote the code isn't lazy — it's compromised. It holds the intent it was aiming at, so when it re-reads its own diff it sees the plan, not the text. Ask it "does this satisfy AC-5?" and it answers from memory of what it set out to do. A session with no memory of the build has only two inputs: the spec and the diff. That's precisely the comparison you want, and it's why /clear isn't a formality.
Three ways to get a clean reviewer, in order of convenience: /clear in Claude Code; a brand-new session with the spec and git diff pasted in; or a subagent dispatched to review while you keep working — useful because it can run the suite itself and report back without polluting your context.
The protocol
A verification prompt should ask for five things. Dropping any one of them is where verifications go soft:
- A verdict per ID. Every R-x and every AC-x, PASS or FAIL — not a prose summary.
- Evidence for each verdict, in two parts: the implementation that does it, and the test that pins it. File and line, or command output. "Looks correct" is not evidence.
- A verdict on the constraints too. Frozen zones and blast-radius limits are requirements; they need a row and evidence like anything else.
- Unrequired changes. Everything in the diff the spec didn't ask for — this is the drift detector.
- A real test run. The actual
mvn testsummary, pasted, not "tests should pass".
Ask 2 is the one most often collapsed to a single column, and splitting it matters. Code without a test is unprotected — the next refactor can undo it silently. A test without the corresponding implementation line means the verifier is trusting the test's name. You want both, side by side, so the pair can be checked against each other.
verify.md is in SEC-12)You did NOT write this code. Review it against the spec only. 1. Read specs/<ID>.md and run: git diff <base>..HEAD 2. For every R-x and AC-x, output a row: ID | PASS/FAIL | evidence (file:line or command output). No verdict without evidence. 3. List every change in the diff NOT required by the spec. 4. Run the test suite and paste the real summary line. 5. If and only if every row is PASS, append the table to the spec's Verification record and set Status: VERIFIED.
What a good verdict row looks like
These are real rows from SPEC-2026-041's record in the companion app. Read the AC-5 row closely — the one criterion with two clauses gets both of them evidenced, on both sides:
| ID | Implementation evidence | Test evidence |
|---|---|---|
| AC-5 PASS | RequestController.java:84-87 — emits ACCESS_DENIED (actor, request id, detail) AND returns 403 | OverrideFlowTest.java:139-151 — asserts status().isForbidden() AND an audit event with type ACCESS_DENIED, actor reviewer-7, requestId OV-5; also asserts the request stays DENIED |
| R-4 PASS | DecisionService.java:85 — a single audit.emit; no other emit in the success path (store.save, appendDecision, notifyProviderOfDecision emit nothing — checked in each, all untouched by the diff) | OverrideFlowTest.java:115-136 — audit count is exactly before+1 across the full HTTP flow, plus the event's type/actor/requestId/detail |
| R-6 PASS | DecisionService.java:86 calls the existing method; NotificationService.java has an empty diff vs the base — no new method or channel | OverrideFlowTest.java:132-134 — exactly one new outbox entry containing the request id and APPROVED_OVERRIDE |
Notice how R-4's evidence proves a negative: to claim "exactly one audit event", the reviewer had to look at every other method in the success path and confirm none of them emits. That's the difference between checking that the required thing happened and checking that nothing else did.
Constraints get a verdict too
The most valuable row in that record isn't a requirement at all — it's the constraint, and its evidence is a diff rather than a line number:
| Constraint | Verdict | Evidence |
|---|---|---|
DecisionService gains ≤ 1 new method; existing methods frozen | PASS | git diff 4fe0399..HEAD -- DecisionService.java contains exactly two hunks: one added import and the appended override method. submit and review are byte-identical to the baseline |
A verification that skips the constraints will happily pass a change that satisfies every requirement while quietly rewriting a frozen module. Requirements say what must be true of the new behavior; constraints say what must remain true of everything else, and only one of those is visible in the feature's tests.
The unrequired-changes list, done honestly
Ask 4 usually comes back as "none", and that answer is worth distrusting. Here is the real list from SPEC-2026-041's verification — four items, none of them misconduct, each one recorded with a reason:
- Constructor now injects
AuditService— required to emit the AC-5 event. In scope: a requirement can't be met without it. - Two placeholder comments removed ("SPEC-2026-041 will add this") — replaced by the implementation. In scope: housekeeping the spec implied.
- Actor falls back to
"unknown"whenX-User-Idis absent — unspecified but benign; keeps audit events well-formed. A judgment call, now visible instead of buried. - A second 422 branch for a null
reason— implements R-2's "one coded reason", but no AC covers it.
That last item is the reason this ask exists. It isn't drift — the code is right, and R-2 demands it. What it reveals is a gap in the acceptance criteria: a requirement with no criterion pinning it, which means nothing would fail if a future change deleted that branch. Verification found a hole in the spec, not in the code. The fix is a new AC, and it costs a line.
Non-blocking notes
A good record also carries observations that are not failures. From the same verification:
Minor notes (non-blocking): AC-2/AC-3 tests assert request status and
decision rows but not audit/outbox counts; no state change there is
nonetheless guaranteed because the 422/409 paths exit before any emit or
notify. An unknown `reason` string (not in the enum) yields Spring's default
400 rather than 422 — outside the ACs, not a spec violation.
Both notes describe real limits of the work, honestly, without inflating either into a blocker. This category matters: without it, a reviewer facing something imperfect-but-acceptable has only two moves — fail the build or say nothing — and the second one wins far too often.
Verification theater
- "All requirements are satisfied."
- "The implementation looks correct and follows best practices."
- "Tests should pass."
- Reviewed by the session that wrote it
- No mention of anything extra in the diff
Verification
- One row per ID, PASS/FAIL
- File:line or command output on every row
- Pasted suite summary with real numbers
- Fresh context, no build memory
- Explicit "unrequired changes: none" — or a list
Verify the tests, not just the code
The tests are part of the deliverable, and they're the part a green build cannot vouch for — a suite that passes proves the tests agree with the code, not that they assert anything worth asserting. Four checks, all cheap:
| Check | Failure it catches |
|---|---|
| Does each AC have a test named after it? | Coverage gaps hiding behind a high test count. In the companion app the names are literally ac1_… through ac5_…, so the mapping is inspectable at a glance |
| Does the test assert the observable the AC names? | A test that checks a status code when the AC demanded a status code and an audit event — the half-pass, one level down |
| Would the test fail if the behavior regressed? | Tautologies, over-mocking, and assertions on values the test itself just set |
| Do rejection tests assert that nothing happened? | An endpoint that validates correctly but has already mutated state |
The sharpest version of check 3: for any test you doubt, ask what single-line change to the production code would make it fail. If you can't name one, the test isn't pinning anything.
What counts as evidence, by requirement type
"File and line" is the default, but some requirements can't be proved that way — and those are exactly the ones people wave through.
| Requirement says | Evidence must be | Example |
|---|---|---|
| Behavior happens | Implementation line + a test that fails without it | 403 at RequestController.java:84 + ac5_… |
| Exactly one of something | A count delta, plus an inspection of every other path that could also emit | R-4's single audit.emit, with the other three methods checked |
| Something must not change | A command whose output is empty | git diff --stat 380f336..HEAD -- src/main |
| A module is frozen | A scoped diff showing only permitted hunks | "exactly two hunks: an import and the new method; submit/review byte-identical" |
| Prior records are immutable | A whole-collection comparison, not a spot check | containsExactlyElementsOf(before) |
| A quirk is preserved | Exhaustive enumeration, not sampling | The loop over RequestStatus.values() in MIG-01 |
Verify the whole change, not the last commit
A verification is only as good as the range it read. The failure is mundane and common: reviewing git diff HEAD~1 after a five-commit build, so the first four commits — including the interesting ones — are never examined.
Name the range explicitly, and record it. Every verification record in the companion app opens with its own: "against git diff 4fe0399..HEAD (four commits)". That single line makes the review reproducible: anyone can re-run the exact comparison later and check the verdict for themselves.
git diff main...HEAD # everything on this branch — the usual case git diff <base-sha>..HEAD # when the spec's work started mid-branch git diff <base>..HEAD -- src/main # scoped, for a frozen-zone claim
Handing it to a subagent
Verification is the ideal task to dispatch: it needs a clean context by definition, it can run the suite itself, and it returns a compact table rather than filling your session with file dumps. Give it four things — and note that the isolation only holds if the agent doing the review isn't the one that wrote the code:
You did NOT write this code. Verify SPEC-2026-041.
· spec: specs/SPEC-2026-041.md
· range: git diff 4fe0399..HEAD
· run: mvn test, and paste the real summary line
· return: one row per R-x, AC-x AND constraint, with
implementation evidence and test evidence in
separate columns; then every change in the diff
not required by the spec; then non-blocking notes.
Half-passes are FAIL. No verdict without evidence.
Reading a FAIL correctly
A FAIL is the system working. The response is to fix the code under the same spec, then re-verify — from another fresh session, because the one that just reasoned about the diff is no longer clean either. What a FAIL must never trigger is an edit to the spec that makes the current code correct (see SEC-10).
Re-verification must re-check every ID, not just the one that failed. The fix is new code, written under time pressure, on a path the suite was demonstrably not covering — which is precisely the condition under which something else breaks. A second pass that only revisits the failed row is how a two-line fix ships a regression alongside it.
And distinguish the two ways a verification can be wrong. A false FAIL is cheap: you look, disagree, and move on. A false PASS costs you the entire mechanism, which is why the standard is evidence rather than judgment — a reviewer who says "PASS, looks right" has given you nothing to check, whereas one who cites a line has given you something you can disagree with.
Watch specifically for the half-pass: a verdict that says PASS but whose evidence covers only one clause of a two-clause criterion. If AC-5 says "403 and an audit event", a PASS citing only the controller's 403 branch is a FAIL that hasn't noticed yet. When you read a verification record, check the evidence against the conjunctions in the criterion.
Where the record lives
Paste the table into the spec's Verification record section and commit it with the code. That turns "we tested it" into a durable artifact: the spec file now contains the contract, the decision history, and the proof, in one place a future reader — or a regulator, or a fresh Claude session — can consume without you.
Definition of done, precisely: every AC demonstrably passes, the suite is green, a fresh context has produced a per-ID table with evidence, that table is pasted into the spec, and Status reads VERIFIED. Anything less is "probably done".
Wiring it into Claude Code
SDD works in any Claude surface, but Claude Code gives it mechanical support: persistent project instructions, plan mode as a native gate, and slash commands to make the workflow one keystroke.
1 · Make the repo spec-aware with CLAUDE.md
CLAUDE.md is read at the start of every session — put your SDD contract there so it applies even when you forget to say it:
# Working agreement - Specs live in /specs, one file per spec, named SPEC-YYYY-NNN.md - Never implement without an APPROVED spec. If asked to, draft the spec first and stop for approval. - Every plan step and every commit message references requirement IDs. - If implementation reveals the spec is wrong: STOP, propose an amendment, wait for approval. No silent workarounds. - Definition of done: all ACs pass + fresh-context review recorded at the bottom of the spec file.
What belongs in CLAUDE.md — and what doesn't
The file is small on purpose. Everything in it is paid for on every single session, so it earns its place only if it must survive your forgetting. Three tests: is it always true (not per-feature)? Would violating it produce a bad outcome you'd only notice later? Is it short enough to still be read at the bottom of the file?
| Goes in CLAUDE.md | Goes in the spec | Goes in a command |
|---|---|---|
| The gate rule: no code without an APPROVED spec | What this change must do (R-x) | The steps of one ritual |
| The STOP-and-amend rule | How we'll know (AC-x) | "Interrogate me first" |
Permanent frozen zones (Decision rows are append-only) | This change's blast radius | Output format of a verdict table |
| Definition of done; who may verify | The verification record itself | "Refuse if Status ≠ APPROVED" |
| Stack facts that constrain every change (Java 21, in-memory store) | Deviations from those facts, argued | — |
Two entries in the companion app's agreement are worth stealing outright. The first is a one-paragraph domain in one breath — the scoring threshold, the routing rule, the append-only decision trail — so that no session starts by guessing what the system is for. The second is the verification etiquette, stated as a rule rather than a hope:
## Verification etiquette
- The session that built the code never verifies it. Verification
happens after /clear or in a new session.
Rules like these work because they're loaded before you ask for anything. A rule you have to remember to state is a rule you'll skip exactly when you're in a hurry — which is exactly when it mattered.
2 · Use plan mode as the physical gate
Stages 1–3 happen in plan mode (Shift+Tab to toggle) — Claude can read the codebase, run searches, and reason, but cannot edit files. That distinction is the whole trick: the model can do all the investigation needed to write a good spec while being unable to start implementing it.
The mode switch is the signature. You don't leave plan mode until the spec and the plan are approved, so "we agreed before building" stops being a claim about discipline and becomes an observable fact about the session. It also removes the most common way SDD dies quietly — the drift from "let me just sketch the approach" into a half-built feature nobody specified.
The failure it prevents. Ask nicely — "don't write code yet" — and you're relying on instruction-following under pressure, in a long session, with an eager assistant. Switch modes and the same request is enforced by the harness. Prefer mechanisms to manners for anything you care about.
3 · Slash commands for each stage
Custom commands collapse the ceremony to muscle memory. Each one is a markdown file containing a prompt, version-controlled with the code and improvable the moment you notice a gap.
How a command is actually wired
Claude Code has unified custom commands with skills: both are markdown files, and a file's name becomes the command you type. Two layouts work, and the companion app uses the first because it's the fewest moving parts:
| Layout | Path | Invoked as |
|---|---|---|
| Command file used here | .claude/commands/verify.md | /verify |
| Skill directory | .claude/skills/verify/SKILL.md | /verify |
| Personal (all your projects) | ~/.claude/commands/… or ~/.claude/skills/… | same |
| Namespaced | .claude/commands/sdd/verify.md | /sdd:verify |
Project-level definitions take precedence over your personal ones with the same name, which is the behavior you want: the repo's working agreement should beat an individual's habits. Namespacing is worth using once you have more than a handful — /sdd:spec, /sdd:plan keeps the whole ritual under one prefix.
Arguments. Whatever the user types after the command is substituted into the file. $ARGUMENTS takes the lot — that's all these five commands need, since each takes one spec ID. Positional forms ($1, $2, and named arguments declared in frontmatter) exist when a command genuinely takes two things.
Frontmatter. Optional YAML at the top of the file tunes how the command behaves. The ones that matter for a spec workflow:
| Field | Does | Why you'd want it here |
|---|---|---|
description | What the command is for; shown in the / menu | Makes the ritual discoverable to a new team member |
argument-hint | Autocomplete hint, e.g. <SPEC-ID> | Stops /verify being run with no spec |
allowed-tools | Pre-approves specific tools for that turn | Let /verify run the test suite without a prompt |
disable-model-invocation | Only you can trigger it; Claude can't invoke it itself | Right for gates — /verify should be a deliberate act |
model | Overrides the model for that command | Rarely needed; occasionally useful for a heavy review |
Injecting live context. A command can run a shell command before it loads and substitute the output, using ! with backticks. This is genuinely useful for verification, which otherwise starts with you pasting a diff:
--- .claude/commands/verify.md, with the diff pre-loaded ---
Fresh-context verification for spec: $ARGUMENTS
Here is the diff under review:
!`git diff main...HEAD`
You are a reviewer who did NOT write this code…
Two cautions. If that command fails, the whole invocation aborts — so keep injected commands boringly reliable. And this is the part of the tooling that moves fastest; the layouts and fields above are current as of Claude Code 2.1.x, and the docs are the authority when they disagree with any guide, including this one.
| Command | Does | The line that does the work |
|---|---|---|
/spec <intent> | Interrogates you, then drafts a DRAFT spec from the house template | "Interrogate me FIRST… do not draft until I answer" |
/plan SPEC-2026-041 | ID-mapped plan; refuses on an unapproved spec | "If Status is not APPROVED, refuse and stop" |
/build SPEC-2026-041 | Implements the approved plan, committing per step | "If the spec turns out wrong: STOP, propose an amendment" |
/amend SPEC-2026-041 | Stops the build and proposes a spec change | "Never edit an existing requirement in place" |
/verify SPEC-2026-041 | Fresh-session review: per-ID PASS/FAIL with evidence | "Half-passes are FAIL with an explanation" |
These are thin files — the point is that they encode each ritual once. Here is /spec in full:
Draft a spec for the following intent: $ARGUMENTS Process: 1. Read CLAUDE.md and skim the relevant code so your questions are informed. 2. Interrogate me FIRST: ask every question needed to make the spec unambiguous (edge cases, failure modes, roles, boundaries, non-goals). Number the questions. Do not draft until I answer. 3. Then write the spec to specs/SPEC-2026-NNN.md (next free number) using specs/SPEC-TEMPLATE.md. Status: DRAFT. Requirements must pass the acid test: each one can fail. Put anything I left unresolved under "Open questions". 4. Stop. Do not plan or write code. The spec is approved only when I change Status to APPROVED.
Two lines do the heavy lifting. Interrogate first inverts the default — without it you get a plausible draft built on invented assumptions, which is worse than no draft because it looks finished. And stop makes approval a human act with a physical signature: you, editing a line in a file.
Read /plan in full and notice how much of it is refusal and constraint rather than instruction. A command is the right place to encode the rules of a ritual, because it applies them the same way on your worst day as your best:
Produce an implementation plan for spec: $ARGUMENTS Rules: 1. Read the spec file. If Status is not APPROVED, refuse and stop. 2. If "Open questions" is non-empty, refuse and stop. 3. Plan as an ordered list of small steps. EVERY step cites the requirement IDs it satisfies (R-x / AC-x). A step with no ID is scope creep — do not include it. 4. Explicitly flag any step that would touch code outside the spec's Constraints, and any AC you believe cannot be tested as written. 5. End with the test list: one test per AC, named after it. 6. Stop for my approval. Do not write code.
Rule 4 is the quiet star. It asks Claude to report two things it would otherwise be tempted to smooth over: work that escapes the approved blast radius, and acceptance criteria that cannot be tested as written. The second is a free spec review — the plan stage catching a weak AC before it becomes a weak test. Rule 5 turns the ACs into the test list mechanically, which is why the resulting test class reads as one test per criterion, named after it.
/build is where the defect discipline lives, and it's two sentences:
2. For defect specs: write the failing tests FIRST, run them, show me the failure, then apply the minimal fix and show them passing. 3. If the spec turns out wrong or incomplete: STOP, propose an amendment in the spec file, and wait. No silent workarounds. 4. Run `mvn test` at the end. Do not declare done — verification happens in a fresh session via /verify.
"Do not declare done" is doing more than it looks. Without it, a build session ends with a confident summary that reads like completion, and the natural human response is to move on. Withholding that judgment from the session that wrote the code keeps the definition of done where it belongs: in a context that has never seen the build.
And /verify states the standard of evidence in its first two lines — the sentence that separates verification from verification theater:
You are a reviewer who did NOT write this code. Do not trust intentions; trust evidence. 2. For EVERY R-x and AC-x: output PASS or FAIL with file:line evidence. Half-passes are FAIL with an explanation. 3. List every change in the diff not required by the spec.
Writing your own: four principles
The five commands above are short, and almost none of their content is instruction. That's the pattern worth copying.
1 · Encode refusals, not just steps. The valuable lines are the ones that make Claude decline. /plan spends two of its six rules refusing to run at all. A command that only describes the happy path adds convenience; a command that refuses adds a gate.
2 · Own the output format. /verify specifies a row per ID with file:line evidence, and that specification is the difference between a verdict table and a paragraph of reassurance. Where the shape of the output is the quality bar, put the shape in the command.
3 · Name the stop. Every command that precedes a human decision ends by stopping: "do not plan or write code", "stop for my approval", "do not declare done". Without an explicit stop, a helpful assistant continues into the next stage — and the gate you thought you had was never there.
4 · One ritual per command. Merging /plan and /build would save a keystroke and delete a gate. The seam between two commands is where the human decision lives, so put the seams where the decisions are.
Commands are a retrospective artifact. Every time a session goes wrong in a way you'd rather not repeat — it built without approval, it "fixed" a quirk, it declared itself done — the fix belongs in a file, not in your memory. Add the line to the command (a ritual) or to CLAUDE.md (a standing rule). The practice improves by accumulating those lines.
Test a command by trying to break it
A command that has never been given bad input is a hypothesis. The interesting test is whether the refusals fire, and the companion app is set up so you can run exactly that test:
| Try | Expected | What it proves |
|---|---|---|
/plan MIG-01 (DRAFT, one open question) | Refuses, citing the status and the question | The gate is mechanical, not aspirational — this is Lab 3 |
/build a spec you never approved | Refuses; offers to draft or to run /plan | CLAUDE.md's rule survives a direct request |
/verify in the session that just built | It'll comply — nothing can detect this | The one rule no file can enforce. /clear is yours to remember |
That last row is worth sitting with. Commands and CLAUDE.md can enforce a great deal, but they cannot detect which session they're running in. Fresh-context verification is the part of the method that stays a human discipline — which is why SEC-14 treats self-review as one of the failures that quietly ends the practice.
Inside /amend
SEC-10's stop-and-propose flow deserves the same treatment as the other stages, so the companion app ships it as a command. Its most important section isn't the template — it's the classification, which forces the interruption to justify itself:
2. Classify what you found, and say which it is: - GAP: the spec does not answer a case the code must handle - CONTRADICTION: two requirements, or a requirement and a constraint, cannot both hold - IMPOSSIBILITY: the requirement cannot be met within the constraints - COST: satisfiable, but at a price I should weigh 3. If it is none of those, it is not an amendment. Naming, private helpers and other choices with no observable behavior change are just build work — continue without asking. New capability is out of scope: say so and leave it for a separate spec.
Rule 3 is what keeps the mechanism from becoming its own anti-pattern. Without it, "stop and ask" degrades into asking about everything, you start waving interruptions through, and the one that mattered gets waved through with them.
You are hand-rolling something that has tools
Worth saying plainly: this loop is not novel, and there are packaged implementations of it. GitHub's Spec Kit gives you a specify CLI that scaffolds a constitution → specify → plan → tasks → implement pipeline across some thirty agents, Claude Code included. OpenSpec keeps a directory of capability specs alongside proposed changes and folds one into the other when a change ships. Gemini's Conductor drives a conductor/ folder of spec.md, plan.md and status.md from its own slash commands. Different nouns, same three moves: agree an artifact before code, derive a plan from it, generate the work from the plan.
| This guide | Spec Kit | OpenSpec | Gemini Conductor |
|---|---|---|---|
CLAUDE.md working agreement | constitution | project conventions | conductor/ context |
/spec → specs/SPEC-…md | /specify | changes/<id>/proposal.md | /spec → spec.md |
/plan GATE | /plan | design.md | /plan → plan.md |
/build | /tasks + /implement | tasks.md | /implement |
/verify + verification record | — | openspec validate | /status |
Status: lifecycle in the file | — | openspec archive | status.md |
Read that table by its gaps — they're the informative part. Spec Kit has no verification stage of its own, so the habit SEC-11 spends two thousand words on is yours to add. OpenSpec's delta model — current truth in specs/, a proposed change in changes/, archived into the specs once it ships — is frankly better than this guide's single-file lifecycle once a capability outlives the change that created it, and it's the idea most worth stealing whether or not you adopt the tool. And none of the three can enforce the one rule that matters most: the session that built the code never verifies it. No CLI can tell which context it is running in. That stays human, in every tool.
When to adopt one: you're past one repo and one person, you want the scaffolding generated rather than pasted, or your specs have outlived their changes and you need the delta model. When not to: while you're still learning what each stage is for. A tool that scaffolds the plan gate for you teaches you nothing about why the gate exists — and when it does something surprising, the difference between a user and a practitioner is whether you can say which stage it's standing in for.
Conductor is the useful counter-example here, because it's Gemini-side: the same loop, a different vendor's tooling. That's the strongest evidence that what you're learning is a method rather than a Claude Code feature. The mechanism transfers; the tooling doesn't. (Tool details as of August 2026 — this paragraph is the fastest-ageing thing in the guide, which is itself the argument for learning the mechanism.)
4 · Spend context deliberately
A long session is not a free good: as context fills it gets summarized, and the details that survive are not necessarily the ones that mattered. SDD's answer is that the durable state lives in files, not in the conversation — which is why the practice tolerates session boundaries so well.
- One slice, one session. If a slice doesn't fit with room for the plan gate and verification, the slice is too big (SEC-09), not the context too small.
- Clear between build and verify. Not a formality — it's what makes the reviewer a different reader (SEC-11).
- Recover from the artifacts, not the transcript. If a session is compacted or lost mid-build, you resume from the spec, the approved plan, and
git logwith its requirement IDs. Nothing important was only in the chat.
That last point is the practical version of "specs are the AI's memory". The test of it is simple: if your session died right now, could a fresh one pick the work up from the repo alone? Under SDD the answer is yes, and it's yes by construction rather than by luck.
5 · Commit discipline: IDs in the message
Every commit cites the requirement IDs it advances. It costs nothing at commit time and changes what your history can answer:
git log --oneline --grep="R-3" # every commit that touched immutability git log -S"APPROVED_OVERRIDE" # … and the spec that introduced it
Six months later, "why is this comparison strict?" is git blame → DEFECT-2026-117 [R-1] → a spec file with the observed behavior, the expected behavior, and the verification record. That chain is the difference between a codebase you can reason about and one you excavate.
6 · Let a subagent do the verifying
Dispatch it with the spec ID and the diff range, keep working, and read the verdict when it lands — SEC-11 has the exact ask to send. The one rule: the agent that built the code is never the agent that reviews it.
Where the artifacts live
priorauth-sdd/ ├── CLAUDE.md # the working agreement — loaded every session ├── specs/ │ ├── SPEC-TEMPLATE.md │ ├── DEFECT-2026-117.md # Status: VERIFIED + verification record │ ├── SPEC-2026-041.md │ ├── MIG-01.md # resolved questions carried to MIG-02 │ └── SPEC-2026-042.md # Status: DRAFT — nothing may be built yet ├── .claude/commands/ # spec, plan, build, amend, verify └── src/
Everything the practice needs is version-controlled next to the code it governs — which is what makes it survive people leaving, sessions ending, and models changing. If you'd rather adopt a tool than a convention, the landscape table above maps this layout onto Spec Kit, OpenSpec and Conductor. For current Claude Code capabilities and configuration details, check the official docs, which move faster than any guide.
Adopting it on a team
Solo, SDD is a personal habit. On a team it becomes infrastructure — and the failure modes change from "I skipped a step" to "we have two definitions of done".
Start with one spec, not a policy
The adoption that works: pick the next change that touches decisions, data, money, or security, run it end to end, and put the spec in the pull request. The adoption that fails: announce that all work now requires specs. The first produces an artifact people can argue with; the second produces compliance theater by the second sprint.
The first month
Order matters more than speed. Each step earns the credibility the next one spends, and putting the CI gates first — the instinct of every engineering manager who likes this idea — is the reliable way to kill it.
| Week | Do | Why this order |
|---|---|---|
| 1 | One person runs the full loop on one real change that touches decisions, data, money, or security | You need a concrete artifact to argue about, not a proposal. Pick something that would genuinely hurt if it went wrong |
| 2 | Same person again, but the verification is done by someone else | The first time a colleague's fresh-context review finds something real, the practice sells itself. No slide deck does this |
| 3 | Add CLAUDE.md and the commands to the repo. A second person runs the loop | Now the ritual is in the codebase rather than in one person's habits — and the second person's friction tells you what the commands are missing |
| 4 | Agree the threshold (what needs a spec), then add the CI checks | Enforcement lands only after the team has felt the benefit and agreed the scope. Enforce first and it reads as bureaucracy — accurately |
The step people skip is week 2. Doing your own verification is the natural next move and it teaches nothing about the team-scale value, because the discovery you're trying to produce — someone else, reading only the spec, found something I couldn't see — can't happen alone.
Review starts from the spec
Reviewing an AI-written diff cold is miserable and unreliable — it's a lot of plausible code and no stated intent. With a spec, review has a spine: read the spec first, then check the diff against it. Most review comments collapse into three questions.
| Reviewer asks | Looking for |
|---|---|
| Does every AC have a test, and does each "and" clause have an assertion? | Half-passes (SEC-06) |
| Is anything in this diff not required by the spec? | Silent drift (SEC-10) |
| Is the verification record present, per-ID, with evidence? | Self-review and verification theater (SEC-11) |
A PR template that asks for the spec ID, the verification table, and "unrequired changes: none / list" makes those three questions automatic.
Who approves, who verifies
Two roles, and the only rule that matters is that they're different people from the builder — or in the verifier's case, a different context.
| Role | Should be | Should not be |
|---|---|---|
| Approver (DRAFT → APPROVED) | Whoever is accountable for the behavior. For a permission rule that's often a lead or compliance owner, not the author | A rotating duty, or "whoever is around". Accountability is what makes reading the boundaries feel worth five minutes |
| Verifier (→ VERIFIED) | Anyone but the builder — a teammate, or a fresh session/subagent. It's a mechanical job: check evidence against IDs | The build session, ever. This is the one rule no tooling can enforce for you (SEC-12) |
The failure mode to watch for is the spec police: one enthusiast becomes the sole approver, the queue backs up behind them, and the team learns that specs mean waiting. Distribute approval by area from the start. The gate is a role, not a person.
When approver and author genuinely disagree about a requirement, don't split the difference in the text — that produces a requirement that can't fail. Escalate to whoever owns the policy, then record the outcome as a resolved question in the spec, with the date and the reasoning. MIG-01's carried-forward decision (SEC-09) is exactly this artifact, and it's why the same argument doesn't recur next quarter.
Where non-engineers finally fit
An underrated effect: a spec is the first artifact in the delivery chain that a compliance lead, a clinical reviewer, or a product manager can actually read and challenge. They can't review a diff, and a ticket doesn't state the rule precisely enough to disagree with. But "only role MEDICAL_DIRECTOR may override; every other role receives 403 and an ACCESS_DENIED audit event" is a sentence a compliance officer can confirm or correct in ten seconds.
In practice that means the Intent, the Requirements, and the Out-of-scope list should be readable without knowing the stack — which is also why implementation-shaped requirements (SEC-14's spec theater) are worse than they look: they don't just weaken verification, they lock the people with the domain knowledge out of the review.
What to enforce mechanically
Culture decays; CI doesn't. Three cheap checks carry most of the weight:
- Spec referenced. Commits on a feature branch must cite a spec ID matching a file in
/specs. Catches spec-free work early. - Status gate. A PR touching code whose spec is still
DRAFTfails. This is the approval gate expressed as a build step. - Verification record present. Before merge, the spec file must contain a non-empty Verification record. Cheap to check, hard to fake accidentally.
Notice what none of these check: quality. They check that the process produced its artifacts. The artifacts are what make quality reviewable by a human.
Fitting it to the process you already have
Nobody is starting from zero — you have tickets, and probably ADRs or RFCs. A spec doesn't replace any of them; it fills the gap between "we agreed to do this" and "here is the code". The common adoption failure is duplicating one of these into another, so it's worth being explicit about which answers what:
| Artifact | Answers | Lifespan |
|---|---|---|
| Ticket | Why we're doing this, who asked, how urgent, when it shipped | Closed and forgotten |
| ADR / RFC | Which architecture we chose and what we traded away | Years; spans many changes |
| Spec | What this one change must do, how we'll know, and what it must not break | Permanent, tied to the code it governs |
| Tests | Whether it still does it | Runs forever; silent about why |
Practical wiring: the ticket links the spec ID, the PR names it, the commits cite its requirement IDs. An ADR that constrains a change gets quoted in that spec's Constraints — that's how a long-lived architectural decision reaches the build, rather than living in a wiki nobody opens during implementation.
Starting in a codebase with no specs
Don't backfill. Writing specs for code that already exists produces spec theater at scale — documents that can't fail, describing decisions nobody is making. Instead:
- Specs accrete from the next change onward. After six months, the parts of the system that changed have specs; the dormant parts don't need them.
- When you touch something risky and undocumented, characterize it first. MIG-01's pattern (SEC-09) works outside migrations: a small tests-only spec that pins current behavior, then the real change against that safety net.
- Let defects seed the directory. Every P1 and P2 becomes a defect spec, which is the cheapest possible entry point — you were writing a postmortem anyway, and the shape is nearly identical.
When two specs touch the same code
Constraints and frozen zones function as a lock table written in English, which is what makes parallel work safe. Two specs can be built simultaneously as long as their blast radii don't intersect — and when they do, you find out at the plan gate rather than in a merge conflict.
The concrete case in the companion app: SPEC-2026-042 adds one method to NotificationService, whose existing methods are pinned by MIG-01's characterization suite. That's why 042's constraints say the new method must be additive and MIG-01's tests must stay green untouched. Two specs, one module, no collision — because each one wrote down what it was allowed to touch.
When a genuine conflict does appear, sequence rather than merge: one spec ships and is verified, then the second is re-planned against the new baseline. Merging two half-built specs is how you get a change nobody can verify against either contract.
Onboarding and parallelism
New engineers read /specs instead of interviewing people — the directory is a narrated history of every consequential decision, in the order they were made. Onboarding stops being a function of who happens to be free that week.
Measuring whether it's working
| Signal | What it tells you |
|---|---|
| Share of merged changes with a spec and a verification record | Whether the practice is real or ceremonial |
| Findings per verification (should be > 0 sometimes) | If it's always zero, verification has gone soft — nobody catches nothing forever |
| Amendments per spec (1 in 3 builds is healthy) | Zero means people improvise silently; three per spec means specs are being approved too fast |
| Rework after merge on spec'd vs unspec'd changes | The one number that survives contact with a skeptical manager |
Don't turn these into targets. Each of those numbers is diagnostic, and each is trivially gameable the moment it becomes a goal. Make "findings per verification" a KPI and you'll get invented findings; make "amendments per spec" one and you'll get amendments for variable names. Read them as symptoms — a run of zero-finding verifications is a prompt to go and read one, not a metric to publish.
Objections you'll actually hear
All four of these are reasonable, and answering them with enthusiasm rather than specifics is how adoption stalls.
| "…" | The honest answer |
|---|---|
| "This will slow us down." | It front-loads ten to twenty minutes and removes correction rounds. Measure rework on spec'd vs unspec'd changes for a month and let the number decide |
| "It's bureaucracy." | It is, if applied to everything — which is why the threshold rule exists (SEC-14). Bureaucracy is process without a consumer; here the consumers are the tests, the reviewer, and the fresh session |
| "Claude is good enough that this is overkill." | Capability was never the constraint. No model can know that 0.85 routes to manual review or that the denial row is immutable for audit reasons. Better models make the build cheaper, which moves more of the value into the decisions |
| "Our work isn't like that — it's exploratory." | Then don't spec it. Prototypes and investigations are where specs genuinely defeat the purpose. Write one afterwards, if the prototype becomes real |
Notice that two of the four answers are "you're right, don't do it here". A practice that claims to fit every situation gets applied to a rename, annoys everyone, and is dropped entirely — including on the changes that needed it.
The audit story
Every regulated organization is currently asking the same question about AI-generated code: how do you know what it does, and who approved it? SDD answers with artifacts rather than assurances — every change traces to an approved spec, with a recorded verification performed by a context that didn't write the code. That trace is worth more than any statement about which model you used.
Anti-patterns, and what to do when it goes wrong
Every one of these is a real failure mode teams hit in their first month of SDD. Name them so you can catch them.
| Anti-pattern | Smell | Correction |
|---|---|---|
| The novel | Spec runs six pages; nobody, including Claude, holds it all at once | One page per spec. Big work = a stack of small specs (see Scenario 3) |
| The rubber stamp | You approve Claude's drafted spec in 10 seconds without reading it | The gate only works if you actually read. Minimum: read every AC and every out-of-scope line aloud |
| Spec theater | Spec written after the code, to look compliant | Plan mode until approval — make it mechanically impossible to build first |
| Silent drift | Implementation "improves on" the spec without amendment | The CLAUDE.md STOP rule + fresh-context review, which flags unrequired changes |
| Untestable requirements | "Should be robust", "handle edge cases" | Run the five tests from SEC-05: if it can't fail, rewrite it until it can |
| Self-review | The session that built the code also verifies it | Always /clear or a new session for verification — no exceptions |
| Specs for everything | A spec to rename a variable; the team quietly abandons the whole practice | Threshold rule: spec anything touching decisions, data, money, or security; skip it for trivia |
Each one below is shown as the artifact it actually produces — the spec text, the diff line, or the review comment you'd really see. They're worth recognizing by shape, because in the moment every one of them feels like good judgment.
1 · The novel
SLA-breach warnings sound like one feature until you start writing. The draft that tries to finish the topic in a single spec looks responsible and is unbuildable:
The novel — one spec, six pages
- Warn reviewers at 36h and 48h
- …and auto-escalate breaches to a supervisor
- …and pause the clock while awaiting provider documents
- …and support per-plan and per-procedure SLA tiers
- …and honor a business-hours calendar with holidays
- …and add a provider-facing SLA report
- …and remediate everything already breached at deploy
SPEC-2026-042 as written
- Warn the reviewer pool at 36h and 48h. That's it.
- Everything else lands in Out of scope, by name
- Nine requirements, six acceptance criteria, one page
- Each deferred item can become its own spec, later, with its own gate
Read the real out-of-scope block and notice it isn't a shrug — it's a list of decisions, each one an argument that won't happen mid-build:
## Out of scope
Auto-escalation or reassignment of breached requests; pausing the clock
(e.g. awaiting provider documentation); per-procedure or per-plan SLA
tiers; provider-facing SLA reporting; business-hours calendars; a
background scheduler (follow-up spec if wanted); remediation of requests
already breached at deploy time.
The novel's tell is that its requirements stop being independent: R-7 only makes sense if R-4 was implemented a certain way. When requirements start depending on each other, you're looking at two or three specs stacked into one document.
2 · The rubber stamp
The most common failure, and the most expensive, because it neutralizes the gate while leaving it visible. Claude produces a clean, confident, well-formatted spec; it looks finished, so you approve it in ten seconds.
Here is what that costs, in this exact codebase. A drafted requirement:
R-1: Requests scoring at or above AUTO_APPROVE_THRESHOLD (0.85) are
auto-approved; all others route to manual review.
It reads perfectly. It is also the bug from Scenario 2, promoted to policy — "at or above" is >=, and the actual rule is strictly greater. Approve that in ten seconds and every downstream mechanism now works flawlessly to enforce the wrong threshold: the plan cites it, the tests assert it, and verification returns all-PASS. The spec was the last place that error could be caught cheaply, and the gate was where it would have been caught.
The tell is not that you approve quickly — experienced readers do. It's that your approvals never change anything. If you've never once sent a draft back over a boundary, a missing "and" clause, or an out-of-scope line, you aren't holding a gate; you're initialling one.
Cheap defense: read the boundaries out loud. "Strictly greater than zero point eight five." "At least fifty characters, so forty-nine fails." Saying a comparison aloud catches what skimming a paragraph never will.
3 · Spec theater
The spec written after the code, to satisfy a process. It's easy to spot once you know the shape: the requirements describe the implementation's structure rather than the system's behavior.
Theater — written from the diff
- "R-2:
RequestControllershall expose anOverrideBodyrecord with reason and justification fields." - "R-3:
DecisionServiceshall have anoverride()method." - "R-4: The controller shall call
audit.emit."
Spec — written before it
- "R-2: Override requires a justification of ≥ 50 characters plus a coded reason."
- "R-3: The original denial row must remain immutable; the override is appended."
- "R-4: Exactly one audit event per override."
The left column can't fail — it's a description of code that already exists, so every requirement is trivially satisfied and the verification is a tautology. It also can't be reviewed by a compliance lead, who cares about immutable audit trails and has no opinion about record types. The mechanical fix is the one in SEC-12: stay in plan mode until the spec is approved, which makes writing code first physically awkward rather than merely discouraged.
4 · Silent drift
The most corrosive one, because it never looks like disobedience — it looks like helpfulness. Mid-build on SPEC-2026-041, this appears in the diff:
// in NotificationService — nobody asked for this + case APPROVED_OVERRIDE -> "EMAIL"; // overrides are good news, email them
It's defensible! Faxing good news is silly. But R-6 said "reuse the existing notification service — no new channel", MIG-01's characterization suite pins APPROVED_OVERRIDE to FAX as preserved behavior, and somewhere a provider integration is parsing faxes. One helpful line has changed observable behavior outside the approved blast radius, and if the suite hadn't caught it, the spec directory would now describe a system that doesn't exist.
Two mechanisms catch drift, both mechanical rather than cultural: the STOP rule in CLAUDE.md (SEC-10), and the "list anything in the diff not required by the spec" line in the verification prompt (SEC-11). The second catches drift even when the first is ignored — which is exactly why that line belongs in every verification, every time.
5 · Untestable requirements
These survive review because they sound like the kind of thing a requirement says. Run them through the five tests in SEC-05 and they evaporate:
| Written | Why it can't fail | Rewritten |
|---|---|---|
| "Overrides must be properly audited." | "Properly" has no observable. Any log line satisfies it | "Exactly one audit event per override, carrying actor, timestamp, request id, and reason code." |
| "The notification service should be reliable." | Nothing could contradict it | "Exactly one provider notification per decision; a failed send is retried three times, then recorded as UNDELIVERED." |
| "Handle invalid input gracefully." | A 500 with a friendly message arguably qualifies | "Unknown id → 404; wrong status → 409; short justification → 422 naming the field. Never 500." |
| "High scores auto-approve." | No boundary — the reader picks one | "score > 0.85 auto-approves; exactly 0.85 routes to manual review." |
6 · Self-review
Here are two verdicts on the same diff — one from the session that wrote it, one from a fresh context — where the 403 path was implemented but the audit event was forgotten:
Same session reviewing itself
- "AC-5 — PASS. The role guard returns 403 for non-medical-director roles and writes an access-denied audit event."
- Reported from memory of the plan, not from the file
- No evidence cited
- Half-pass ships
Fresh context, evidence required
- "AC-5 — FAIL. 403 returned at
RequestController.java:86, but noACCESS_DENIEDemit on that branch —audit.emitappears only in the success path." - Two clauses in the criterion, two checks
- Two-line fix, caught before merge
Note that the left-hand verdict isn't a lie — the implementer meant to write both halves. That's the whole problem, and why the isolation has to be structural rather than a matter of care.
7 · Specs for everything
The overcorrection that kills adoption in month two. A spec to rename providerNpi to providerNumber produces a gate, a plan, and a verification record for a change your compiler already verified — and after three of those, the team concludes the practice is bureaucracy and drops all of it, including on the changes that needed it.
The threshold rule, applied to this repo:
| Change | Spec? | Because |
|---|---|---|
Threshold comparison >= → > | Yes — DEFECT-2026-117 | Decision logic. One character reclassifies every boundary request |
| New override endpoint with role guard | Yes — SPEC-2026-041 | Security, audit trail, immutable records |
| Extracting the notification service | Yes — MIG-01…05 | Large blast radius; needs slices and rollbacks |
| Renaming a field; extracting a private helper | No | No observable behavior change; the compiler and tests are the review |
| Adding a log line; fixing a typo in a message | No | Reversible, no decision encoded |
| Bumping a patch dependency | No — unless it changes behavior | Then it's a change to behavior, and the rule applies |
Spec what touches decisions, data, money, or security. Skip it for everything else, and let the saved credibility fund the gates that matter.
Self-check. Look at your last three specs. Did any approval change the draft? Did any verification report a finding? Did any build produce an amendment? Three no's means the ceremony is running without the machinery underneath it.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Claude starts coding despite a DRAFT spec | Working agreement not loaded, or the request was phrased as an emergency | Put the rule in CLAUDE.md, not in a message; stay in plan mode until approval |
| Verification always says everything passes | Same session reviewing itself, or a prompt with no evidence requirement | /clear first; demand file:line evidence and a pasted suite summary |
| The build keeps asking questions | Spec approved with real ambiguity still in it | Good — that's the STOP rule. Answer them as amendments; interrogate harder next time |
| Specs feel like overhead on small changes | Threshold set too low | Spec what touches decisions, data, money, or security. Skip renames and copy tweaks |
| Diff contains work nobody asked for | Missing constraints and out-of-scope lines | Name the frozen zones and the adjacent good ideas (SEC-05) |
| Tests pass but behavior is wrong in production | ACs restated the requirements instead of adding observables | Rewrite ACs with concrete numbers and side-effect counts (SEC-06) |
Copy-paste kit
Everything you need to start on your next real task — today, not after a pilot program.
# SPEC-YYYY-NNN · <title> Status: DRAFT | APPROVED | VERIFIED ## Intent <one paragraph — problem, who, outcome. no implementation talk> ## Requirements R-1: <must / must not statement that can fail> ## Acceptance criteria AC-1: Given <state>, when <action>, then <observable result> ## Constraints <stack, patterns, budgets, frozen zones> ## Out of scope <explicit non-goals> ## Open questions <must be empty before status: APPROVED> ## Verification record <fresh-context review output pasted here after build>
# 1 · Spec (plan mode) Draft a spec for: <intent>. Interrogate me first — ask every question needed to remove ambiguity. No code. # 2 · Plan (plan mode) Read <spec>. Propose a plan; every step cites requirement IDs; flag anything outside the constraints. # 3 · Build Implement the approved plan. If the spec is wrong, STOP and propose an amendment. Commit per slice, IDs in messages. # 4 · Verify (fresh session) Spec + diff attached. Per R-x and AC-x: PASS/FAIL with evidence. Then list every change not required by the spec. # 5 · Amend (mid-build, when the spec is wrong) Stop. Do not work around it. Write the amendment block into the spec file, state the impact and the alternative you rejected, and wait for my approval before resuming.
The amendment block
SEC-10's protocol, as something you can paste. Append it to the spec under an ## Amendments heading — never by editing the original requirement in place, because the point is that the change is visible and dated. The Rejected line is not decoration: it's what stops the same question being re-litigated in six months.
## Amendments ### A-1 · <one-line summary> (proposed YYYY-MM-DD) Found during: <stage — e.g. build, step 3> Gap: <what the spec fails to answer, or which lines conflict> Proposed: <the exact new or amended R-x / AC-x text> Impact: <blast radius: files, other ACs, frozen zones> Rejected: <the alternative you considered and why not> Status: PENDING | APPROVED YYYY-MM-DD | WITHDRAWN # Build stays paused while Status is PENDING. Nothing on this # path is committed until it reads APPROVED.
The verification record
What a fresh session pastes back into the spec when it's done. Every row carries evidence; the bottom two lines are the ones a reviewer reads first.
Fresh-context verification, YYYY-MM-DD. Reviewer did not write the code; findings are from reading `git diff <base>..HEAD`, the source, and running the suite in a clean session. | ID | Verdict | Evidence | |------|---------|---------------------------------------------------| | R-1 | PASS | ScoringService.java:19 — strict `>` | | AC-5 | PASS | 403 at RequestController.java:86; ACCESS_DENIED | | | | emit at :85 — both clauses checked | Test suite: <paste the real summary line> Changes in the diff not required by the spec: <none | list them>
The defect spec
Defects get a shorter shape: what you saw, what should happen, and the minimality contract that keeps a one-line fix from becoming a refactor.
# DEFECT-YYYY-NNN · <title> Status: DRAFT Severity: P1 | P2 | P3 ## Observed <the wrong behavior, stated so it can be reproduced> ## Expected <the correct behavior, with the exact boundary or comparison> ## Requirements R-1: <the corrected rule, stated exactly> R-2: The fix must not alter behavior for any case other than <the one named above>. R-3: A regression test must pin <the boundary>, plus one case each side of it, and must be shown FAILING against current code before the fix is applied. R-4: Historical records must not be retroactively modified; remediation is a separate work item. ## Acceptance criteria AC-1: Given <the failing input>, when <action>, then <correct result> AC-2: Given <a case just above the boundary>, then <unchanged> AC-3: Given <a case just below>, then <unchanged> AC-4: The full baseline suite stays green. ## Out of scope <the tempting adjacent fixes, and the remediation question> ## Verification record
R-2, R-3, and R-4 are near-universal for defects — copy them as written and fill in the specifics. They encode, respectively: no drive-by refactors, prove the bug before fixing it, and don't improvise a data-remediation policy at 4pm.
The working agreement
Drop this in CLAUDE.md at the repo root and the rules apply on every session, including the ones where you're in a hurry.
# Working agreement ## Spec discipline - Specs live in /specs, one file each: SPEC-YYYY-NNN.md or DEFECT-YYYY-NNN.md. - Never implement without a spec whose Status is APPROVED. If asked to build without one, draft the spec first and stop for approval. - Stages: Intent -> Spec (gate) -> Plan (gate) -> Build -> Verify. Stay in plan mode through both gates. - Every plan step and every commit message references requirement IDs. - If implementation reveals the spec is wrong or incomplete: STOP, propose an amendment, wait for approval. No silent workarounds, no "improving on" the spec. - Definition of done: all ACs demonstrably pass, and a fresh-context verification is pasted into the spec's Verification record. ## Frozen zones - <append-only tables, pinned public methods, generated files> ## Verification etiquette - The session that built the code never verifies it. Verification happens after /clear or in a new session.
Keep it this short. Every line is read on every session, and a file long enough to skim is a file that gets skimmed.
For teams: the PR template and three CI checks
SEC-13's process, made concrete. The PR template turns the three review questions into fields nobody can leave blank by accident:
Spec: <SPEC-YYYY-NNN> (Status must be APPROVED or VERIFIED) - [ ] Every AC has a test; each "and" clause has its own assertion - [ ] Verification record pasted into the spec, per-ID, with evidence - [ ] Verification ran in a fresh context, not the build session Changes not required by the spec: <none | list, with justification> Amendments raised during build: <none | A-1, A-2>
And the gates worth enforcing mechanically — deliberately crude, because their job is to catch the accidental case, not to defeat a determined engineer:
#!/usr/bin/env bash set -euo pipefail SPEC_ID=$(git log origin/main..HEAD --format=%B | grep -oE '(SPEC|DEFECT|MIG)-[0-9-]+' | head -1) fail() { echo "spec gate: $1" >&2; exit 1; } # 1 · a spec is referenced at all if [ -z "$SPEC_ID" ]; then fail "no spec ID in any commit message"; fi FILE=$(ls specs/"$SPEC_ID"* 2>/dev/null | head -1 || true) # || true: pipefail if [ -z "$FILE" ]; then fail "no spec file for $SPEC_ID"; fi # 2 · it is not still a draft if grep -q '^Status: DRAFT' "$FILE"; then fail "$SPEC_ID is DRAFT — not approved"; fi # 3 · verification actually happened if ! sed -n '/## Verification record/,$p' "$FILE" | grep -qE 'PASS|FAIL'; then fail "$SPEC_ID has no verification record" fi echo "$SPEC_ID: referenced, approved, verified ✓"
Two shell details are deliberate, and both were bugs on the first draft of this script. It uses if rather than cmd && { exit 1; }, because under set -e that shorthand doesn't fail the way most people expect. And the ls pipeline needs || true, because pipefail otherwise aborts the script — with no message — for the one case you most want a clear error on: a spec ID that has no file. A gate that dies silently is worse than no gate, so run yours against a known-bad input before trusting it.
As in SEC-13: these check that the process produced its artifacts, not that the work is good. That judgment stays human — the artifacts just make it possible in ten minutes instead of an hour.
Authoring your own spec, walked
Lab 4 takes the training wheels off: you bring an intent, Claude interrogates you, and the draft stops at the approval line. Notice how little you actually type.
Where to start tomorrow
- Pick one real task — the next feature or defect that touches decision logic or data.
- Spend 20 minutes at the Spec gate with Claude interrogating your intent. This is the whole method in miniature.
- Run the fresh-context verification even if everything looks fine. The first time it catches something the build session missed, the practice sells itself.
- Commit the spec with the code. The repo's
/specsdirectory is the artifact that turns this from a personal habit into a team standard.
Questions and objections
Starting with the one everyone raises first, and ending with the limits of the method. Several of these answers are "don't do it here" — a practice that claims to fit everything gets applied to a rename and abandoned by month two.
"Isn't this defeating the point of AI?"
If the AI can investigate the issue or build the feature, doesn't writing a detailed spec first just move the work back to you?
It would — if you were the one writing the detail. You're not. Look again at Scenario 1: Claude read the codebase, surfaced the edge cases, asked the questions, and drafted every line of SPEC-2026-041. The human contribution was answering roughly eight questions, most with a nod at a proposed default. The effort you spend at the Spec gate isn't writing — it's deciding, and the decisions (48-hour SLA or 24? who gets notified? what's out of scope?) are exactly the ones no model can make for you. The spec is where the output of AI investigation gets frozen, not homework you complete before the AI is allowed to think.
Ambiguity is cheap before code and expensive after
Scenario 2's defect was literally >= versus > — a one-character disagreement about what "above the threshold" means. With a spec, that's a line item settled in a two-minute review. Without one, the argument happens in production, and you settle it by archaeology on the code. Every hour "saved" by skipping the spec is borrowed against debugging intent later, at a much worse interest rate.
AI-built code needs an oracle
The fresh-context verification from SEC-11 only works because the acceptance criteria say precisely what "done" means — exactly one audit event, 422 on a 49-character justification. Without that contract, a verifying session can only check that the code agrees with itself. Detailed ACs are what make "AI checks AI" honest instead of circular.
The spec is the AI's memory
Sessions end, contexts compact, work resumes weeks later. The spec file is the one artifact that carries intent across all of that — it's why the migration slices in Scenario 3 can hand resolved questions forward from MIG-01 to MIG-02 without anyone re-deriving them.
"Our specs will go stale."
Only if you treat them as documentation. A spec is a record of a decision at a moment, like a commit — it doesn't rot, it accumulates. SPEC-2026-041 will still correctly describe why the override endpoint returns 409 in five years, even if six later specs have changed everything around it. What goes stale is a wiki page trying to describe the current state of a system; specs never claim to do that.
"Claude is good enough now that this is overkill."
Answered at length in SEC-01 and again as a team objection in SEC-13. The short form: capability was never the binding constraint — shared understanding is, and no model can know that the notification quirk is deliberate rather than a bug worth fixing.
"We already do TDD. Isn't this the same thing?"
Complementary, and they meet at the acceptance criteria. TDD tells you how to grow code from a test; SDD tells you which tests are the ones that matter and who agreed to them. The ACs in SEC-06 are the handoff point — they're written before any code, and they compile into exactly the tests TDD would have you write first. What SDD adds is the layer above: the constraints, the frozen zones, the out-of-scope list, and a verification pass performed by someone (or something) that didn't write the code.
"Should we be writing Gherkin and running Cucumber?"
Only if non-engineers actually read or write the scenarios. That is the whole test, and it is worth being blunt about because the answer is usually no.
Gherkin buys you one real thing: a spec a product owner or compliance lead can read and edit, which then executes. Where that audience genuinely exists — regulated domains with a business analyst who will open the .feature file — it is worth the machinery. Where it doesn't, you are paying for a step-definition layer that sits between the criterion and the code, and the payment is ongoing: steps get reused across scenarios until changing one breaks three others, and a regex-matched sentence is a level of indirection every reader has to traverse.
AI shifts that calculation further. The friction Cucumber removed was the cost of turning an agreed example into a test — and that cost is now close to zero: /plan emits the test list from the ACs, and /build writes tests named for the criterion they came from. Meanwhile the step-definition layer becomes a second generated artifact that can drift from both the scenario and the code, and so a second thing your verification has to check. That's a poor trade for a benefit you weren't collecting.
What this guide recommends instead: keep Gherkin's grammar and its Examples tables (SEC-06, SEC-08), keep the ACs in the spec where the constraints and out-of-scope lines live beside them, name each test after the AC it proves, and skip the runner. If your product owner ever does start reading the tests, revisit — that's the signal, and it's the only one.
"What if I disagree with the spec Claude drafted?"
Then the gate just did its job. Send it back with the correction — that exchange is cheaper than every later mechanism for catching the same disagreement. If you find you never disagree, re-read SEC-14 on the rubber stamp.
"Isn't Claude just going to agree with whatever I say?"
This is the sharpest version of the objection, and the answer isn't "no, trust it" — it's that the method is arranged so agreement can't be the only thing you get. Three of its mechanisms exist specifically to counter an agreeable reviewer:
- Interrogation before drafting. Asked to question you first, Claude reliably surfaces edge cases and contradictions — it's generating disagreement rather than being asked to confirm a conclusion.
- Evidence instead of verdicts. "PASS" is easy to say;
RequestController.java:84is checkable. Demanding file:line and pasted command output converts agreeable prose into claims you can falsify in ten seconds. - A reviewer with no stake. A fresh session has no memory of the intent it's meant to be pleased with. It isn't agreeing with you, because it never heard you.
The residual risk is real, so learn the tell: all-PASS with vague evidence. A verdict table where several rows say "implemented correctly" without a location is agreement wearing a table's clothes. Send it back and ask for the line.
"What if I don't know the answer to one of Claude's questions?"
Then you've found the most valuable question in the batch, and the correct move is not to guess. Put it in Open questions and go ask whoever owns the policy — the compliance lead, the clinician, the product owner. The spec cannot be approved while that list is non-empty, which means the gate has just stopped you from encoding a guess as a rule.
This is the mechanism working exactly as designed, and it's the one people quietly subvert by answering "let's say 50 characters" and moving on. If you must proceed, record the guess as a guess — "provisional, pending confirmation from compliance" — so the next reader knows which lines are load-bearing decisions and which are placeholders.
"Can Claude approve its own spec, or verify its own build?"
No, and these are the two rules with no technical enforcement behind them — which makes them the two worth defending hardest.
Approval is where a human takes responsibility for the behavior. Automate it and every remaining mechanism still functions perfectly, enforcing whatever the draft happened to say (SEC-14, the rubber stamp). Verification fails differently: a session can be told to review its own diff and will comply, producing a fluent all-PASS table built from its own intent rather than the text. The /clear is doing the work there, not the prompt.
"Do I need all this on a solo project?"
Scale it down honestly. Alone, the parts that earn their keep are the acceptance criteria and the fresh-context verification — the first because your future self is a different reader, the second because you don't have a colleague to catch the half-pass. The status flips, the CI gates, and the formal verification records are coordination machinery; with nobody to coordinate with, they're overhead.
A perfectly good solo version: a ten-line spec in the repo with intent, four ACs, and a constraint, then /clear before reviewing. That's most of the value for about two minutes of ceremony.
"Does this only work in Claude Code?"
No — the method is surface-independent. You can run the whole loop in claude.ai by pasting the spec and the diff. What Claude Code adds is mechanical support: plan mode makes the gate physical rather than a promise, commands make the ritual one keystroke, CLAUDE.md makes the rules survive your forgetting, and /clear gives you a genuinely fresh reviewer. Those turn a discipline problem into a tooling problem, which is a much easier problem — but the discipline is the method, not the tool.
"There are tools for this. Why hand-roll it?"
Because the mechanism is what you keep. Spec Kit, OpenSpec and Conductor each implement this loop with their own nouns (SEC-12 maps them stage by stage), and the fastest way to be able to choose between them — or to debug one when it surprises you — is to have run the loop once with nothing hidden. A reader who has written their own /plan knows exactly what a scaffolded plan step is standing in for; a reader who started with the CLI knows a command.
There's a practical half too. Five markdown files you wrote are five files you can change the afternoon you discover your gate is in the wrong place — and early on it usually is. Adopting a tool before you know what your gates should be locks in someone else's answer to a question you haven't asked yet.
None of which is an argument against the tools. Once the loop is habit, packaged scaffolding is a straight win, and OpenSpec's delta model in particular solves something this guide's single-file lifecycle doesn't. Learn it by hand, then adopt deliberately.
"Does it work for things that aren't application code?"
Wherever there are decisions and observable behavior, yes. Infrastructure changes are arguably the strongest fit — blast radius and rollback are already how people think, and "what must not change" is the whole game. Data pipelines fit well (the ACs become data quality assertions). Prompt and agent changes fit surprisingly well, because "how will we know this is better?" is exactly the question that usually goes unasked.
Where it fits badly: anything whose output is judged rather than checked — visual design, copywriting, exploratory analysis. If you can't state an observable, you can't write an AC, and the honest response is to skip the method rather than fake it.
"Our verifications never find anything. Is that good?"
It's a warning, not a trophy. Nobody catches nothing forever — a run of clean verifications usually means the reviewer is trusting intentions, has the build context, or is answering without evidence. Go read one: check whether every row cites a location, whether the "unrequired changes" line was answered at all, and whether an AC with two clauses had both of them evidenced. If those are absent, the verification wasn't finding nothing; it wasn't looking.
The honest caveat: this rigor is calibrated to the stakes. For a throwaway script, a prototype, or an open-ended investigation, drafting a full spec genuinely would defeat the purpose — let the AI run, and write the spec afterward, if at all, as a record of what you learned. The skill isn't "always write specs." It's knowing that AI does the investigating, drafting, building, and verifying — and the spec is the checkpoint where a human owns the decisions before hundreds of lines get built on top of them.