Module 4 — Verification and Review: Review, Don't Accept
4. Deriving the checklist FROM your specification and turning it into a PR template
Description
By the end of this lesson you will be able to take the specification you already wrote for a task — Module 2's six-section one: context, problem, scope, non-goals, technical constraints, acceptance criteria — and derive from it, in minutes, the exact checklist to apply to that particular diff. Not a generic eleven-point list good for any change: a line-by-line translation of your own specification into concrete review questions. You'll also be able to turn that checklist into your repository's pull request template, so the next review doesn't depend on anyone remembering anything.
This matters in real work because the generic checklist — the "check names, check complexity, check tests exist" one — is never going to tell you "you shouldn't have touched the reactions endpoint" or "you shouldn't have added that dependency," because those two things are specific to this task, not to any task. Only one document knows that: the specification you wrote before the agent touched a file. A teammate who inherits reviewing a PR they didn't specify themselves, or you yourself reviewing a change at eleven at night where "the tests already passed," need the same thing: a checklist coming from this task's contract, not from whoever's memory happens to be looking at the diff at that moment.
Connection to the module: the previous lesson gave you the order for reading a generated diff — first surface, then contracts, then logic, style last — and the idea that you have to "spot the change nobody asked for." This lesson turns that still somewhat intuitive idea into something you execute with the specification open next to you: at each of those reading stops, exactly which line of your own document you have to confirm against the diff. You stop trusting that "you'll notice" the unrequested change, and start having a list of the concrete ways this specific task could have expanded on its own.
The table's ticket, not the kitchen's general list
In a restaurant kitchen there are two documents posted near the line, and they serve different purposes. One is the house's general rules list: every plate goes out hot, sauce on the side, no fingers on the plate's rim. It applies to any dish, any night, no matter who ordered it. The other is the ticket the printer at table 7 just spit out, thirty seconds ago: steak medium, no onion, fries on the side instead of mashed potatoes.
The expediter checking the plate before it goes out to table 7 doesn't decide if it's right by looking at the general list. They look at table 7's ticket. If they only looked at the general list, a plate with onion — which this customer, on this order, explicitly asked to have without — would pass with no problem: it's hot, sauce is on the side, it looks perfect. It meets the entire general list and violates the one request that actually mattered.
A code review checklist has the same hidden trap. There exists, and is useful, a general catalogue of
things almost any change should meet — clear names, reasonable complexity, tests present; that
task-agnostic catalogue lives in another guide and isn't this lesson's topic. But that general catalogue
is never going to say "you shouldn't have touched app/routers/reactions.py," because it has no way of
knowing this task, specifically, forbade touching that file. Only one document knows that: the
specification you wrote for this change.
We call derived checklist the one built by translating, line by line, every section of your own specification — context, scope, non-goals, technical constraints, acceptance criteria — into a concrete review question for the diff in front of you. It isn't a list you memorize once and apply the same way every time, regardless of the task. It gets rebuilt, in minutes, every time the specification changes — because table 7's ticket doesn't work for table 12.
Worked example
Bring back the rate limit specification you built in Module 2: a limit of 20 requests per minute per IP
on POST /api/comments, with four explicit non-goals (no limit on other endpoints, no limit by
authenticated user, no CAPTCHA, no response schema change) and three technical constraints (use the
existing Redis with no new dependencies, max 5 ms extra latency, no framework upgrade). The agent
finishes the task and opens this diff:
diff --git a/app/routers/comments.py b/app/routers/comments.py
@@
+from app.middleware.rate_limit import rate_limit
+
+@rate_limit(max_requests=20, window_seconds=60, key="ip")
@router.post("/api/comments")
def create_comment(payload: CommentIn):
...
diff --git a/app/routers/reactions.py b/app/routers/reactions.py
@@
+from app.middleware.rate_limit import rate_limit
+
+@rate_limit(max_requests=20, window_seconds=60, key="ip")
@router.post("/api/comments/{comment_id}/reactions")
def create_reaction(comment_id: int, payload: ReactionIn):
...
diff --git a/requirements.txt b/requirements.txt
@@
fastapi==0.115.0
+slowapi==0.1.9
redis==5.0.8
Before looking at this diff with a reviewer's eye, you already know — from the previous lesson — in what order to read it: surface first (which files changed), then contracts and logic. What was missing was knowing, at the surface stop, what to compare those three files against. That's what the derived checklist gives you. It's built like this, taking every section of the specification and prefixing it with "does the diff...?":
Context → does the diff only touch what the context pointed to?
The specification said this lives in app/routers/comments.py. The diff does touch that file, but it
also touches app/routers/reactions.py and requirements.txt. ❌ Fails: two files outside what the
context bounded.
Scope → does a change exist implementing exactly the described limit?
20 requests/minute per IP on POST /api/comments, with the @rate_limit(max_requests=20, window_seconds=60, key="ip") decorator present on that route. ✅ Meets it.
Non-goals → does the diff leave every one of the four points untouched?
The first non-goal explicitly said: "no rate limit is added to any other endpoint." That same decorator
also shows up in create_reaction, in reactions.py. ❌ Fails: it's exactly the scope expansion the
non-goal existed to prevent. The other three non-goals (no per-user limit, no CAPTCHA, no response
schema change) are respected — there's no evidence otherwise in the diff.
Technical constraints → does the diff respect every limit?
The first constraint said "use the existing Redis; don't add a new dependency."
requirements.txt now includes slowapi==0.1.9, a new dependency. ❌ Fails. The other two constraints
(latency, no framework upgrade) can't be confirmed just by looking at the diff; they require running the
benchmark, which is exactly what's next.
Acceptance criteria → did you run the exact command and get the expected result?
pytest scripts/test_rate_limit.py -v
python scripts/bench_endpoint.py --route /api/comments
pytest tests/test_comments.py
What to expect:
scripts/test_rate_limit.py::test_rate_limit_blocks_after_20_per_minute PASSED
1 passed in 3.41s
Median latency /api/comments: +3.2ms vs baseline (threshold: +5ms) — OK
tests/test_comments.py ................. [100%]
17 passed in 2.05s
✅ All three criteria pass, green, no exceptions.
Here's the uncomfortable finding: all three acceptance criteria pass perfectly, and yet the derived
checklist flags two real failures — the reactions endpoint got a limit nobody asked for, and the project
gained a new dependency the constraint explicitly forbade. None of the tests you wrote in Module 2 were
designed to look at reactions.py or requirements.txt, because when you wrote them those files weren't
part of the task. Tests measure exactly what you asked them to measure; the derived checklist also looks
at what the specification said shouldn't happen. What to do with these two findings — ask for just that
part to be reverted, or reject the whole change — is the decision with cutoff rules you'll learn in
lesson 7 of this module; here the work ends in documenting the finding with evidence, not resolving it.
That same checklist, once applied, is almost verbatim the PR's body. Turning it into a reusable template means adding the same section headings, empty, so the next task fills them in again with its own specification:
<!-- .github/pull_request_template.md -->
## Specification
<!-- Link to the specification document (Module 2) used for this change. -->
## Scope check
<!-- One line per "Scope" bullet in the specification.
Confirm a corresponding change exists in the diff, with file:line. -->
- [ ]
## Non-goals check
<!-- One line per "Non-goals" bullet.
Confirm the diff does NOT touch any of these. -->
- [ ]
## Constraints check
<!-- One line per "Technical constraints" bullet. -->
- [ ]
## Acceptance criteria
<!-- One line per criterion. Paste the command and its actual output, not just the checkmark. -->
- [ ]
And here's what it looks like, already filled in, for this example's real PR:
## Specification
docs/specs/rate-limit-comments.md
## Scope check
- [x] 20/min limit per IP on POST /api/comments — app/routers/comments.py:14
## Non-goals check
- [ ] No other endpoint gets the limit — FAILS: app/routers/reactions.py:9
also has @rate_limit
- [x] No limit by authenticated user
- [x] No CAPTCHA
- [x] Response schema unchanged
## Constraints check
- [ ] No new dependencies — FAILS: requirements.txt adds slowapi==0.1.9
- [x] Latency: +3.2ms vs baseline (threshold +5ms)
- [x] No framework upgrade
## Acceptance criteria
- [x] `pytest scripts/test_rate_limit.py -v` → 1 passed in 3.41s
- [x] `python scripts/bench_endpoint.py --route /api/comments` → +3.2ms (OK)
- [x] `pytest tests/test_comments.py` → 17 passed
Notice two checkboxes stay unmarked, with the exact evidence of why, while the three acceptance criteria boxes are green. That document, saved alongside the PR, is what saves a teammate — or you yourself, three weeks from now — from having to reread the entire diff to know what got reviewed and what failed.
The mechanical translation: from specification section to checklist line
The example above used a concrete specification. What makes this method work with any task is that the translation from section to question doesn't change; only how many lines each section produces changes, based on how many bullets your specification has that time:
| Specification section | Review question it generates (one per bullet in that section) |
|---|---|
| Context | Does the diff touch only the files the context pointed to? If it touches another, why? |
| Scope | Does a change exist in the diff implementing exactly this? Point to file and line. |
| Non-goals | Does the diff leave this untouched? If it touched it, it's a finding, not an extra improvement. |
| Technical constraints | Does the diff respect this limit? Verify with a command when possible (git diff main -- requirements.txt, for example). |
| Acceptance criteria | Did you run the exact command or test? Was the result what was expected? Paste the output, not just the checkmark. |
For a normal-sized specification — one-line context, one scope, four non-goals, three constraints, three criteria, like the example's — the full translation produces around twelve checklist lines. Writing them takes two or three minutes: you copy every bullet from the specification, prefix it with the table's question, and you're done. You aren't inventing what to review from scratch; you're counting bullets. What actually eats up hours isn't writing the checklist — it's not having it, and deciding, change by change, what to look at in a diff with no document next to you telling you where to start.
One detail worth knowing if you work with changes of very different sizes: GitHub lets you save several
PR templates at once, in a .github/PULL_REQUEST_TEMPLATE/ directory (instead of a single
pull_request_template.md file), and whoever opens the PR picks which one to use by adding
?template=filename.md to the creation URL. That lets you have a shorter template for a change with a
single scope bullet and a fuller one for a change with several constraints, without forcing the same
checklist length on every task — the trust gradient by surface you already saw in lesson 2 of this
module, now reflected in how many lines the template itself carries.
Common mistakes
Confusing "I already ran the generic checklist" with "I already reviewed this change" (conceptual).
The underlying misunderstanding is treating review as a single homogeneous action, when in reality
they're two different checks with different goals: the general catalogue (names, complexity, tests
present) detects problems common to any code, and the derived checklist detects the scope decisions
specific to this task that nobody but your specification knows. If you only run the first, a change can
pass with flawless names, reasonable complexity, and green tests, and still have touched a file your
non-goal explicitly forbade — like what happened with reactions.py in the example — because the
generic catalogue was never going to mention that file. How to spot it: if your checklist has no word
that only appears in this particular task's specification — a file name, an endpoint, a number — it's
generic, not derived. How to fix it: the checklist derived from the specification always runs, in
addition to the general catalogue and not instead of it; the general catalogue lives in another guide
and stays useful, but it doesn't substitute for this one.
Deriving the checklist with the diff already open in another tab (practical). Writing the checklist
questions while glancing at what the diff already did, instead of looking only at the specification,
introduces a silent confirmation bias: it's easier to justify code already in front of you than to
question it from scratch. In the example above, someone who already saw reactions.py got the same
limit might think "makes sense, they probably would have asked for it anyway" and simply not write the
non-goal line that would have flagged it as a failure — rationalizing the scope expansion instead of
detecting it. How to spot it: for every line in your checklist, ask yourself whether you could have
written it exactly the same before seeing the diff, with only the specification open. If the answer is
no, you wrote it backwards. How to fix it: derive the complete checklist with the diff closed, only from
the specification document, and only afterward open it to apply it line by line.
Checking the PR's box with no evidence pasted next to it (practical). A checked box gives the same visual sense of "resolved" whether or not it has a command and a real output behind it; it's tempting to check it quickly to be able to open the PR and move on. The problem shows up later: if someone checks "acceptance criteria ✅" from memory, without having run the command that time, in three weeks nobody —not even that same person — can reconstruct what was actually verified, and the filled-out template ends up being pure compliance theater. How to spot it: open any PR merged a month ago with this template and ask yourself whether, with only what's written there, you can reconstruct the exact command that ran and what it returned. If you can't, those checks were empty inside. How to fix it: require every acceptance criteria box — and constraint box, when the check is a command — to come with the command and its real output pasted below, exactly as shown in the filled-out example above, never just the checkmark alone.
Exercises
Exercise 1. Here's the complete "export reports to CSV" specification you already solved in Module 2 (non-goals and constraints included). Derive the complete checklist: one review line per bullet of scope, non-goals, technical constraints, and acceptance criteria, using this lesson's translation table.
# Specification: export reports to CSV
## Context
GET /reports/:id endpoint in app/routers/reports.py.
## Scope
- New GET /reports/:id/export endpoint that returns a CSV file
with the same columns and rows the interface shows for that report.
## Non-goals
- No other export formats are added (Excel, PDF): CSV only.
- No asynchronous or email-based export is added; the file is
generated and downloaded within the same request.
- The existing GET /reports/:id endpoint and its JSON response
format don't change.
- No pagination or row limit is added in this task: the full
report is exported as it exists today.
## Technical constraints
- Reuse the same query and aggregation logic already used by
GET /reports/:id; don't write a parallel query for the CSV.
- Generate the file with the CSV library the project already uses
for other exports (app/utils/csv_writer.py); don't add a
new dependency for this.
- Export response time must not exceed 10 seconds for the
reference report used in tests (20,000 rows).
## Acceptance criteria
- GET /reports/demo-1/export returns a CSV whose row count matches
the count shown in the interface for the same report.
- The CSV includes a header row with each column's name.
See solution
## Scope check
- [ ] GET /reports/:id/export exists and returns a CSV with the same
columns and rows as the interface for that report.
## Non-goals check
- [ ] The diff does NOT add any export format besides CSV
(search for "xlsx", "pdf" in the diff).
- [ ] The diff does NOT add asynchronous export or email delivery; the
endpoint returns the file within the same request.
- [ ] GET /reports/:id (the existing endpoint) didn't change — neither
its logic nor its JSON response format.
- [ ] The export doesn't add pagination or a row limit: the full
report is exported.
## Constraints check
- [ ] The export endpoint reuses GET /reports/:id's query and
aggregation — no new parallel query exists.
- [ ] The CSV is generated with app/utils/csv_writer.py — no new
CSV library was added to the dependencies.
- [ ] Response time for the reference report (20,000 rows) is under
10 seconds.
## Acceptance criteria
- [ ] GET /reports/demo-1/export: CSV row count == count shown in
the interface for demo-1.
- [ ] The CSV includes a header row with each column's name.
Why it works: every line is born from a real specification bullet with the table's question prefixed to it — none was invented, and none got left out. Twelve specification bullets produced twelve checklist lines, with no need to remember a generic catalogue: counting the bullets and applying the corresponding question already defines the complete list.
Exercise 2. The agent delivers this PR description for the previous exercise's task. Apply the checklist you derived and decide, line by line, which boxes can be checked and which fail, with the exact evidence.
The diff adds
GET /reports/:id/exportinapp/routers/reports.py, reusing the samebuild_report_rows()function the existing endpoint already uses. It also addsGET /reports/:id/export.xlsxbecause "since we were already building the export, an Excel button is almost free and they'll probably ask for it later." It usesapp/utils/csv_writer.pyfor the CSV and adds theopenpyxl==3.1.5dependency to generate the Excel.GET /reports/:id(the old endpoint) wasn't touched.pytest tests/test_reports_export.py -vwas run:2 passed in 0.8s, confirming the CSV's row count matches the interface and the header is present. The benchmark against the 20,000-row report came in at 6.1 seconds.
See solution
## Scope check
- [x] GET /reports/:id/export exists and returns a CSV — confirmed by
pytest tests/test_reports_export.py
## Non-goals check
- [ ] FAILS: GET /reports/:id/export.xlsx (Excel format) was added,
violating "no other export formats are added: CSV only"
- [x] No asynchronous or email export — not mentioned in the diff
- [x] GET /reports/:id wasn't touched
- [x] No pagination or row limit added (nothing to the contrary mentioned)
## Constraints check
- [x] Reuses build_report_rows() — no parallel query
- [ ] FAILS: adds the openpyxl==3.1.5 dependency, and the constraint said
"don't add a new dependency for this"
- [x] 6.1s for 20,000 rows, within the 10s threshold
## Acceptance criteria
- [x] pytest tests/test_reports_export.py -v → 2 passed in 0.8s
(rows match, header present)
Why it works: just like in this lesson's example, the acceptance criteria pass green — the CSV works
exactly as asked — and yet the derived checklist flags two real failures: an export format the non-goal
forbade by name, and a new dependency the constraint forbade by name. Neither shows up in the tests,
because when those tests were written, .xlsx and openpyxl weren't part of the task. What to do with
these two failures — accept the CSV and ask for only the Excel part to be reverted, or reject the whole
PR — is the decision you'll learn to make with cutoff rules in this module's next lesson; here the
exercise ends in identifying and documenting the failure with evidence, not resolving it.
Summary and next step
What you learned in this lesson is that the checklist that actually matters for a change isn't a universal eleven-point list — that lives in another guide and stays useful, but for something else — it's the mechanical translation of your own specification, section by section, bullet by bullet, into concrete review questions for that diff. You also saw this translation isn't invented from scratch every time: it always follows the same pattern (context → surface touched, scope → change present, non-goals → change absent, constraints → limit respected, acceptance criteria → command and result), which is what makes it possible in minutes. And you saw how that same checklist, once filled in, is almost verbatim your PR's body, and how saving its shape — not its content, which changes with every specification — as a repository template means the next review starts from the contract and not from anyone's memory.
Before moving on you should be able to, without looking at the translation table: take any Module 2
specification and produce its complete derived checklist in under five minutes; apply that checklist to
a real diff and point out exactly which line fails, with the evidence; and have, ready to copy into a
real repository, a .github/pull_request_template.md file shaped like this method's five sections.
The checklist you just learned to derive has one line, the acceptance criteria one, that gets resolved by running a command and seeing a green result. But that green assumes something you haven't questioned yet: that the test you ran actually measures what it claims to measure, and doesn't just simulate it. The next lesson gets right into that — why a test written by the same agent that wrote the code is weaker evidence than it looks, and what to do about it.
Resources
- GitHub Docs — Creating a pull request template for your repository — where and with what exact name to create the file (
.github/pull_request_template.md), and how to keep several templates at once in.github/PULL_REQUEST_TEMPLATE/. - GitHub CLI — gh pr create — the
-T/--templateflag to open a PR using any text file as the initial body, useful for testing a derived checklist before committing it to the repository as an official template. - Google Engineering Practices — What to look for in a code review — the dimensions Google recommends covering in any review; useful as the general catalogue to contrast your derived checklist against, to confirm you didn't leave a generic angle uncovered.
- Claude Code — Code Review (
REVIEW.md) — how to declare, in a repository file, project-specific review rules (for example, "every new API route needs an integration test") so they get applied automatically on every PR; the same idea as the derived checklist, run by an agent on every push instead of by hand. - GitHub Docs — About issue and pull request templates — general overview of what PR templates are and how other real repositories use them.