The PRD comes first: the spec that prevents the agent from writing incorrect code
The model doesn't make mistakes due to ignorance: it makes mistakes because it filled the gap the prompt left, and filled it in a plausible way. What changes in the code when the contract comes before the request.
In a hurry? Ask Claude for the TL;DR — it reads the page and summarises it.
en Machine translation by qwen3:32b, reviewed by the author. Read the Portuguese original
I asked the agent for an endpoint with listing, pagination, and filtering. A one-and-a-half-sentence prompt. It returned in less than a minute: typed, validated, with tests, everything properly indented.
It passed code review because there was nothing visibly wrong. What was wrong were the decisions I didn’t ask for and didn’t read:
paginastarted at0, while the rest of the API starts at1;- the ordering was
ORDER BY nome, without a tiebreaker — with two suppliers sharing the same name, pagination would repeat one record and skip another; totalcounted the entire table, not the filtered result;tamanhohad no upper limit:?tamanho=100000would fetch the whole table;- a page beyond the end returned
404.
None of this is the model’s stupidity. Each item is a gap I left in the request, which it filled itself with the most common value it had seen. The problem isn’t that it makes mistakes — it’s that it makes plausible mistakes. Absurd code I catch in ten seconds; reasonable code that contradicts the rest of the system passes code review and becomes a bug three weeks later.
The bottleneck moved
In 2023, the bottleneck was the model writing code that compiles. In July 2025, with Sonnet 4 and Opus 4 in Claude Code, with Cursor, with Aider, writing code for a well-defined task stopped being the hard part. The bottleneck became the precision of the specification the agent receives.
Rewriting the prompt until it’s right is a lottery: each round reveals another thing I should have said. Prose has a structural flaw — it has no place for what I didn’t think of. A spec does, and an empty section is annoying.
Spec-driven is not PRD-driven
The two documents describe the same delivery, and it’s tempting to conclude one replaces the other. It doesn’t — they answer different questions, for different readers.
The PRD answers why to do it and for whom. The reader is humans: who prioritizes, who sells, who will inherit this in a year. It exists to align product decisions, and thus carries business context, success metrics, and discarded alternatives.
The spec answers exactly what the code must do. The reader is the agent. It exists to remove ambiguity from implementation, and within it, anything that doesn’t change the code is noise.
| PRD | task spec | |
|---|---|---|
| reader | people who decide | the agent that implements |
| answers | why, for whom, how much it’s worth | what comes in, what comes out, what not to do |
| granularity | a feature | a task, a diff |
| lifespan | while the feature exists | updated or deleted with the commit |
| typical error | too vague to prioritize | too vague to implement |
Combining them in a single file seems like a shortcut and ends up costing more. The agent doesn’t separate motivational context from requirements: it weighs everything in the prompt equally. “Suppliers are central to the purchase flow” isn’t harmless there — it’s a token competing with the line stating that total counts the filtered result.
The PRD still serves its purpose. It’s just not what I give to the agent. When it exists, the spec is derived from it — not as a summary, but as what remains after removing everything that doesn’t become code.
The same request, written in both ways
The task is small and real: list suppliers with pagination and search. First, how I used to write it.
Cria um endpoint GET de listagem de fornecedores com paginação
e um filtro de busca por nome. Segue o padrão dos outros endpoints.
Each term here silently delegates a decision. “Pagination” is offset or cursor? “Search” is prefix, LIKE, or equality? “The pattern of other endpoints” refers to which file, if there are three?
Now the same task as a versioned file, in docs/specs/fornecedores-listagem.md.
# spec: GET /api/suppliers
# # contract
GET /api/fornecedores?pagina=1&tamanho=20&busca=&status=ativo&ordem=nome
| parâmetro | tipo | padrão | validação |
|-----------|---------|--------|----------------------------------------|
| pagina | inteiro | 1 | mínimo 1; 0 ou negativo → 400 |
| tamanho | inteiro | 20 | 1 a 100; acima de 100 → 400 |
| busca | string | — | 2 a 80 chars; nome E cnpj; sem acento |
| status | enum | todos | ativo, inativo, todos; fora → 400 |
| ordem | enum | nome | nome, criadoEm, -criadoEm |
The response body enters as a literal example, not a description:
{
"itens": [
{ "id": "b7c1…", "nome": "Metalúrgica Andrade", "cnpj": "12345678000190",
"status": "ativo", "criadoEm": "2025-07-14T12:00:00Z" }
],
"pagina": 1, "tamanho": 20, "total": 137
}
The cnpj without a mask appears in the example — it’s worth more than the paragraph explaining it comes without a mask.
Then comes the part that decides the result.
# # edge cases
- lista vazia → 200 com `itens: []` e `total: 0`. Nunca 404.
- página além do fim → 200 com `itens: []`. Nunca 404.
- busca com 1 caractere → 400. Não é busca, é varredura de tabela.
- CNPJ mascarado (12.345.678/0001-90) → normaliza para dígitos e compara.
- empate: `ORDER BY nome ASC, id ASC`, sempre. Sem o desempate por id
a paginação repete registro entre páginas.
- `total` conta o resultado do filtro, não a tabela.
- registro com `deletadoEm` preenchido nunca aparece, em nenhum status.
# # out of scope
- não criar índice nem migration; é outra tarefa
- não tocar em GET /api/fornecedores/:id
- não adicionar cache, Redis ou repositório novo — usar o `db`
que já existe em src/infra/db.ts
- não trocar o validador; o projeto usa Zod, mantenha
- sem paginação por cursor nesta entrega
What changes in the returned code is verifiable line by line. The tiebreaker case, for example:
// without the spec — unstable pagination when there are duplicate names
db('fornecedores').orderBy('nome', 'asc')
.limit(tamanho).offset((pagina - 1) * tamanho);
// with the spec — the tie-breaker was written as an edge case
db('fornecedores').orderBy('nome', 'asc').orderBy('id', 'asc')
.limit(tamanho).offset((pagina - 1) * tamanho);
This is the kind of bug code review doesn’t catch. It doesn’t break with ten lines in the table and disappears when you try to reproduce. It appears on the fifth page, in production.
What doesn’t go into the spec
Business justification, personas, adoption metrics, and motivational paragraphs stay out. They don’t change a single line of generated code and consume context the agent could use better by reading the repository.
The rule: if deleting the sentence doesn’t change the returned code, it goes out. “Suppliers are central to the purchase flow” goes out. “total counts the filtered result” stays in.
The spec above fits in sixty lines. When I used to write a page and a half, the agent obeyed half — and I didn’t know which half.
Out-of-scope is the section that pays the most
If I could keep only one section, it would be this one. It’s where the agent invents the most, and invents upwards: creates a non-existent repository layer, adds caching, changes the validator, refactors the neighbor “on the way”, writes a migration with a new index.
It’s not sabotage: in the material it was trained on, “good listing code” comes with these things, and without a written boundary it completes the pattern. By pointing to the existing file, it uses what’s there — and the diff becomes small enough for me to review properly.
Acceptance criteria must be executable
“Must work correctly” isn’t a criterion, it’s a wish. A criterion is the test name and the command that runs it.
# # acceptance criterion
testes em testes/fornecedores.listagem.spec.ts, todos verdes:
1. deve_retornar_200_e_lista_vazia_quando_nao_ha_resultado
2. deve_retornar_400_quando_tamanho_acima_de_100
3. deve_paginar_sem_repetir_registro_quando_ha_nomes_iguais
(3 fornecedores de mesmo nome; páginas 1 e 2 com tamanho 2;
os conjuntos de id não podem se intersectar)
4. deve_ignorar_mascara_de_cnpj_na_busca
5. deve_excluir_registro_deletado_logicamente
comando: pnpm vitest run testes/fornecedores.listagem.spec.ts
Each test name came from an edge case in the previous section — the translation is mechanical, and today I write edge cases already thinking about this.
pnpm vitest run testes/fornecedores.listagem.spec.ts Test Files 1 passed (1) Tests 5 passed (5)
With criteria like this, the agent closes the loop itself: runs, reads the failure, fixes, runs again. Without this, I’m the one closing the loop, one round at a time, in chat.
From endpoint to the whole system
An endpoint doesn’t prove any method. The real test was generating a medical scheduling CRUD this way — «MEDIR» entities, «MEDIR» endpoints —, with a spec per task, never a system-wide spec. A single thirty-page document returns an agent that does everything half and nothing to the end.
Scheduling is a cruel domain for prose prompts, because almost every rule is a decision no one states:
- two requests for the same time: is the second an error, a queue, or a fit?
- canceling deletes the record or marks
canceladoEm? (clinical history isn’t deleted — but the agent doesn’t know that) - does a canceled time slot return to the agenda immediately, or only after confirmation?
- does the duration come from the procedure or the professional’s agenda?
- what timezone does the time arrive in, and what does the database store?
- do holidays and vacation blocks enter the availability calculation?
None of these appear in a request to “do the scheduling CRUD”. All appear in production. And each one the agent decides alone, it decides well — with the most common answer from training, which in clinics is usually wrong: real DELETE instead of logical cancellation.
The rule that stayed: a task that fits in a reviewable diff, a spec, a test file. When the spec goes over sixty lines, it’s not a big spec — it’s two tasks.
Where the spec lives
A spec glued to the prompt dies with the session. The file stays in the repository, versioned with the code, and is cited by path:
# the entire request, after the spec exists
claude "implemente docs/specs/fornecedores-listagem.md"
# in Aider it's the same idea: the spec is entered as read, not as edit
aider --read docs/specs/fornecedores-listagem.md src/rotas/fornecedores.ts
The Aider --read imports more than it seems: it loads the spec into context without adding it to the editable files. I’ve seen agents “resolve” a discrepancy between spec and code by rewriting the spec — which is the worst possible outcome, because it erases the only thing that served as a reference.
The project’s CLAUDE.md — .cursorrules, in Cursor — declares the convention once:
Specs de tarefa ficam em docs/specs/. Antes de implementar, leia a spec
citada. Se algo do pedido não estiver na spec, pergunte em vez de decidir.
The last sentence changes behavior the most: “ask instead of deciding” replaces silent decisions, which are expensive, with questions, which are cheap.
Outside the agent, the spec also pays: it becomes the PR description almost without editing, and code review stops being “does this look right?” and becomes “does this match the file next to it?”. AWS launched Kiro this week, an IDE that makes the requirements file mandatory before generating code — I’m not the only one going this way.
Where this doesn’t pay off
The fixed cost of writing the spec doesn’t disappear when the task is small. I don’t write specs for:
- five-minute tasks: renaming variables, adjusting logs, bumping dependency versions. The spec costs more than the task.
- exploration: when I don’t know what I want, the spec is what I’m trying to discover. Then the vague prompt is the right tool — I ask for three approaches and choose one. The spec comes later, based on what I chose.
- disposable prototypes: if the code dies on Friday, the agent’s silent decisions cost nothing.
The cutoff: I write a spec when the code will be maintained by someone else — including me in three months — or when the error is silent. Otherwise, prompt and review suffice.
The honest limit
The spec doesn’t make the agent smarter. If I specify incorrectly, it executes the error with precision — it stops being the bug author and becomes my executor. It’s better, because the bug ends up in a reviewable file, but it’s not magic.
The spec doesn’t replace review. It changes what I review: instead of reading the code looking for what’s wrong, I read the diff against the file. It’s an easier question to answer.
And the cost is certain while the gain is still my hypothesis. The spec enters every call as an input token, and this is billed — with an online model, context becomes a monthly bill. When the model runs on your own GPU, the bill is different, and it’s in the post about VRAM.
| Same request | Rounds until diff enters | Corrections after review | Input tokens |
|---|---|---|---|
| Prose prompt, 2 lines | «MEDIR» | «MEDIR» | «MEDIR» |
| Versioned spec, 60 lines | «MEDIR» | «MEDIR» | «MEDIR» |
Writing the spec above costs me «MEDIR» minutes — the number that decides if the method is worth it, and the first one I’ll time.
If I started over, I’d change the order: I’d write the five test names first and let the rest flow from them. From top to bottom, I spend time on the descriptive part — which the agent would infer itself — and rush to the edge cases, the only section it can’t guess.
The spec doesn’t make the model more capable. It just removes the chance for it to guess.