Module 13: AI-Assisted Software Development
Framing a Task So the Result Can Be Checked
Turning a vague request into a bounded task with acceptance criteria, choosing what context to supply, and writing the criteria before the code exists.
Lesson 40 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can turn a vague request into a task with acceptance criteria specific enough that anyone, including you three weeks from now, can verify the result rather than judge it.
Why it matters
"Improve the matching rule" produces something. Whether it is right is then a matter of opinion, arrived at by reading the whole diff and forming an impression, which is slow and unreliable and gets worse as the change gets larger.
The same request with criteria attached, "a record with no published amount must return incomplete rather than no match, and the four existing tests must still pass", produces something you can check in seconds. The criteria did the review before the work started.
This is not really about AI. It is how a well-written ticket differs from a badly written one, and it has always paid. It simply matters more when the thing doing the work is fast, confident, and willing to fill any gap in the specification with a guess.
Concept
A well-framed task has five parts:
- The goal, in one sentence, saying what should be true afterwards.
- The boundary: which files may change, and which must not.
- Acceptance criteria: specific, checkable statements, ideally as assertions.
- The constraints: what must not change, what must keep passing, what patterns to follow.
- The evidence required: the diff, the test output, an explanation in plain language.
A criterion is verifiable when two people would agree whether it is met without discussing it. "Handle missing data gracefully" is a judgement. "verdict({'amount': None}) returns 'incomplete'" is a criterion. Write them as assertions where you can, because then checking is running them.
The best time to write them is before the work exists. Criteria written afterwards describe what was built, which is how the test in Module 5 came to assert that a closed opportunity should be shortlisted.
Context is the second half. Supply the file being changed, the tests that must keep passing, the shape of the data, and the conventions to follow. Do not supply the whole repository, real records, credentials, or anything restricted. Too little context produces invented APIs; too much buries the request and increases what you must review.
Size is the third. A task is too large when you cannot state its criteria, when it touches parts of the system you cannot check, or when you would not be able to review the diff carefully. The right response to a large task is to split it, not to accept a change you will approve by scrolling.
One test of your own framing: if you cannot write the acceptance criteria, you do not yet know what you want, and no assistant can supply that.
Read the code
A poorly framed request:
Make the matching rule better at handling edge cases.
The same work, framed:
Goal.
verdict(record)must distinguish a record that cannot be judged from one that does not match.Boundary. Change
matching.pyonly. Do not changereport.pyor any fixture.Acceptance criteria.
assert verdict({"set_aside": "Total Small Business"}) == "incomplete" assert verdict({"amount": 310000}) == "incomplete" assert verdict({"amount": 42000, "set_aside": "Total Small Business"}) == "no match" assert verdict({"amount": 310000, "set_aside": "8(a)"}) == "no match" assert verdict({"amount": 310000, "set_aside": "Total Small Business"}) == "match"Constraints. The existing tests in
test_matching.pymust still pass. Keep the current function signature. No new dependencies.Evidence. Show the diff, the full test output, and two sentences on what changed and why.
The second version is longer to write and shorter to check. Every criterion is executable, so "is it done" is answered by running them rather than by reading.
The boundary matters as much as the criteria. Without it, a plausible way to satisfy every assertion is to change the caller instead of the function, and the diff would still be green.
Predict the output
Suppose an assistant returns this implementation for the framed task. Predict which criteria pass.
def verdict(record, minimum=100000):
if not record.get("amount"):
return "incomplete"
if record["amount"] < minimum:
return "no match"
if record.get("set_aside") != "Total Small Business":
return "no match"
return "match"
Check your prediction
Criteria 1, 3, 4, and 5 pass. Criterion 2 fails:
AssertionError
verdict({"amount": 310000}) returns "no match", because a record with an amount and no set-aside falls through to the category check, which sees None and reports a mismatch. The criterion says it should be "incomplete".
Note also if not record.get("amount"), which treats a genuine amount of 0 as absent, exactly the truthiness trap from Module 2. No criterion covers that case, so it passes review, which is a fair reminder that criteria bound what you checked and not what is true.
Modify the code
Add a sixth criterion to the framed task: assert verdict({"amount": 0, "set_aside": "Total Small Business"}) == "no match". Predict what the implementation above now does.
What changes, and why
It returns "incomplete" and the new criterion fails.
not 0 is True, so an amount of zero is treated as missing. The correct reading is that zero is a published value, and a published value below the minimum is a mismatch rather than a gap.
The repair is if record.get("amount") is None:, which asks the question that was meant. The wider point is that the criterion found the defect the moment it existed, and would have found it in the original implementation too. Adding a case to the specification is cheaper than any amount of reading, and it is the right response to noticing an ambiguity.
Debug the bug
A task was framed like this:
Add a
--sinceoption so the report only includes notices posted on or after a date. Update the tests.
The assistant returned a diff that adds the option, adds a test, and also changes DEFAULT_LIMIT from 25 to 100, reformats report.py, and renames fetch_shortlist to get_shortlist with the call sites updated. Every test passes.
What's actually wrong
The work is done, three unrequested changes came with it, and the framing invited all three.
"Update the tests" is an open instruction. It permits changing existing tests, which is how a specification quietly moves. The constraint should be "existing tests must pass unchanged; add new ones for the new behaviour".
No boundary was given, so nothing said DEFAULT_LIMIT was out of scope. That change alters the behaviour of every existing caller and is invisible in a green test run if no test pins the default.
The rename and the reformat are noise in the diff. Both may be improvements; neither was asked for, and together they make the actual change hard to find. A reviewer scrolling three hundred lines to locate ten is a reviewer who will miss something.
"Every test passes" is not evidence for an unrequested change, only for the requested one.
The reframed task:
Goal.
reportaccepts--since YYYY-MM-DDand includes only notices posted on or after that date. Boundary.report.pyandtest_report.pyonly. Acceptance criteria. Omitting--sinceincludes every notice.--since 2026-03-01includes a notice posted on2026-03-01and excludes one posted on2026-02-28. An unparseable date exits with a message naming the option. Constraints. Existing tests must pass unchanged. No renames, no reformatting, no changes to defaults. Add new tests for the new behaviour only. Evidence. The diff and the full test output.
Then the correct response to a diff containing a rename is to ask for it to be removed and proposed separately. Splitting unrelated changes into separate reviews is not bureaucracy; it is what keeps each one reviewable.
Try it yourself
Implement verdict to satisfy exactly the five criteria below, no more and no less. You are on the receiving end of the framed task from this lesson.
Loading this exercise…
Practical challenge (optional)
Optional: take the last change you made in this course and write the task you would have handed to someone else: goal, boundary, acceptance criteria as assertions, constraints, and required evidence. Then check whether your actual change satisfies every criterion you wrote. Writing the specification for work you have already done is the fastest way to discover which parts you never actually decided.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Name the five parts of a well-framed task.
- What makes an acceptance criterion verifiable rather than a judgement?
- Why should criteria be written before the work rather than after?
- What are the two signs that a task is too large?
Answers
- The goal, the boundary, the acceptance criteria, the constraints, and the evidence required.
- Two people would agree on whether it is met without discussing it. Written as an assertion, checking it is running it.
- Criteria written afterwards describe what was built rather than what was wanted, so they can never disagree with the implementation and cannot catch a defect in it.
- You cannot state its acceptance criteria, or you would not be able to review its diff carefully. Either one means split it.
Sign in to track your progress on this exercise.
Summary and next step
Goal, boundary, criteria, constraints, evidence; write the criteria as assertions and write them first; supply the context the task needs and nothing private; and split anything you could not review. Next: reading the diff that comes back, and finding the change nobody asked for.