O PRD vem antes: a spec que impede o agente de escrever o código errado
O modelo não erra por burrice: erra porque fechou sozinho a lacuna que o prompt deixou, e fechou de um jeito plausível. O que muda no código quando o contrato vem antes do pedido.
Com pressa? Peça o TL;DR ao Claude — ele lê a página e resume.
Pedi ao agente um endpoint de listagem com paginação e filtro. Frase e meia de prompt. Voltou em menos de um minuto: tipado, validado, com teste, tudo indentado direito.
Passou na revisão, porque não havia nada visivelmente errado. O errado eram as decisões que eu não pedi e não li:
paginacomeçava em0, enquanto o resto da API começa em1;- a ordenação era
ORDER BY nome, sem desempate — com dois fornecedores de mesmo nome, a paginação repete um registro e pula outro; totalcontava a tabela inteira, não o resultado do filtro;tamanhonão tinha teto:?tamanho=100000puxa a tabela toda;- página além do fim devolvia
404.
Nada disso é burrice do modelo. Cada item é uma lacuna que eu deixei no pedido e ele fechou sozinho, com o valor mais comum do que já viu. O problema não é que ele erra — é que ele erra plausível. Código absurdo eu pego em dez segundos; código razoável que decidiu o oposto do resto do sistema passa pela revisão e vira bug três semanas depois.
O gargalo mudou de lugar
Em 2023 o gargalo era o modelo escrever código que compila. Em julho de 2025, com Sonnet 4 e Opus 4 no Claude Code, com Cursor, com Aider, escrever o código de uma tarefa bem delimitada deixou de ser a parte difícil. O gargalo virou a precisão da especificação que o agente recebe.
Reescrever o prompt até ele acertar é loteria: a cada rodada descubro mais uma coisa que deveria ter dito. Prosa tem um defeito estrutural — não tem lugar para o que eu não pensei. Uma spec tem, e seção vazia incomoda.
Spec-driven não é PRD-driven
Os dois documentos descrevem a mesma entrega, e é tentador concluir que um substitui o outro. Não substitui — eles respondem perguntas diferentes, para leitores diferentes.
O PRD responde por que fazer e para quem. O leitor é gente: quem prioriza, quem vende, quem vai herdar isso daqui a um ano. Existe para alinhar decisão de produto, e por isso carrega contexto de negócio, métrica de sucesso e alternativa descartada.
A spec responde o que exatamente o código deve fazer. O leitor é o agente. Existe para tirar ambiguidade da implementação, e dentro dela tudo que não muda o código é ruído.
| PRD | spec de tarefa | |
|---|---|---|
| leitor | pessoas que decidem | o agente que implementa |
| responde | por que, para quem, quanto vale | o que entra, o que sai, o que não fazer |
| granularidade | uma feature | uma tarefa, um diff |
| vida útil | enquanto a feature existir | atualizada ou apagada junto com o commit |
| erro típico | vago demais para priorizar | vago demais para implementar |
Juntar os dois num arquivo só parece economia e sai caro. O agente não separa contexto motivacional de requisito: ele pondera tudo que está no prompt com o mesmo peso. “Fornecedores são centrais para o fluxo de compras” não é inofensivo ali dentro — é token de entrada competindo com a linha que diz que total conta o resultado do filtro.
O PRD continua servindo. Só não é o que eu entrego ao agente. Quando existe, a spec nasce dele — não como resumo, mas como o que sobra depois de tirar tudo que não vira código.
O mesmo pedido, escrito das duas formas
A tarefa é pequena e real: listar fornecedores, com paginação e busca. Primeiro, como eu escrevia antes.
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.
Cada termo aqui delega uma decisão em silêncio. “Paginação” é offset ou cursor? “Busca” é prefixo, LIKE ou igualdade? “O padrão dos outros endpoints” é o de qual arquivo, se existem três?
Agora a mesma tarefa como arquivo versionado, em docs/specs/fornecedores-listagem.md.
# spec: GET /api/fornecedores
## contrato
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 |
O corpo da resposta entra como exemplo literal, não como descrição:
{
"itens": [
{ "id": "b7c1…", "nome": "Metalúrgica Andrade", "cnpj": "12345678000190",
"status": "ativo", "criadoEm": "2025-07-14T12:00:00Z" }
],
"pagina": 1, "tamanho": 20, "total": 137
}
O cnpj sem máscara está no exemplo — vale mais que o parágrafo explicando que ele vai sem máscara.
Aí vem a parte que decide o resultado.
## casos de borda
- 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.
## fora de escopo
- 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
O que muda no código que volta é verificável linha a linha. O caso do desempate, por exemplo:
// sem a spec — paginação instável quando há nomes repetidos
db('fornecedores').orderBy('nome', 'asc')
.limit(tamanho).offset((pagina - 1) * tamanho);
// com a spec — o desempate estava escrito como caso de borda
db('fornecedores').orderBy('nome', 'asc').orderBy('id', 'asc')
.limit(tamanho).offset((pagina - 1) * tamanho);
Esse é o tipo de bug que a revisão não pega. Não quebra com dez linhas na tabela e some quando você tenta reproduzir. Aparece na quinta página, em produção.
O que não entra na spec
Fora ficam justificativa de negócio, persona, métrica de adoção e parágrafo motivacional. Não mudam uma linha do código gerado e ocupam contexto que o agente usaria melhor lendo o repositório.
A regra: se apagar a frase não muda o código que volta, ela sai. “Fornecedores são centrais para o fluxo de compras” sai. “total conta o resultado do filtro” fica.
A spec acima cabe em sessenta linhas. Quando eu escrevia uma página e meia, o agente obedecia à metade — e eu não sabia qual metade.
Fora de escopo é a seção que mais paga
Se eu pudesse manter uma seção só, seria essa. É onde o agente mais inventa, e inventa para o alto: cria camada de repositório que não existia, adiciona cache, troca o validador, refatora o vizinho “de passagem”, escreve migration com índice novo.
Não é sabotagem: no material em que ele foi treinado, “código bom de listagem” vem com essas coisas, e sem fronteira escrita ele completa o padrão. Apontando para o arquivo que já existe, ele usa o que está lá — e o diff fica pequeno o bastante para eu revisar de verdade.
Critério de aceite precisa ser executável
“Deve funcionar corretamente” não é critério, é desejo. Critério é o nome do teste e o comando que o roda.
## critério de aceite
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
Cada nome de teste saiu de um caso de borda da seção anterior — a tradução é mecânica, e hoje escrevo os casos de borda já pensando nisso.
pnpm vitest run testes/fornecedores.listagem.spec.ts Test Files 1 passed (1) Tests 5 passed (5)
Com o critério assim, o agente fecha o ciclo sozinho: roda, lê a falha, corrige, roda de novo. Sem isso, quem fecha o ciclo sou eu, uma rodada por vez, no chat.
Do endpoint para o sistema inteiro
Um endpoint não prova método nenhum. O teste de verdade foi gerar um CRUD de agendamento médico assim — «MEDIR» entidades, «MEDIR» endpoints —, com uma spec por tarefa, nunca uma spec do sistema. Documento único de trinta páginas devolve um agente que faz tudo pela metade e nada até o fim.
Agendamento é um domínio cruel para prompt em prosa, porque quase toda regra é uma decisão que ninguém enuncia:
- dois pedidos para o mesmo horário: o segundo é erro, fila ou encaixe?
- cancelar apaga o registro ou marca
canceladoEm? (histórico clínico não se apaga — mas o agente não sabe disso) - horário cancelado volta para a agenda na hora, ou só depois de confirmação?
- a duração vem do procedimento ou da agenda do profissional?
- o horário chega em qual fuso, e o banco guarda o quê?
- feriado e bloqueio de férias entram no cálculo de disponibilidade?
Nenhuma dessas aparece num pedido de “faz o CRUD de agendamento”. Todas aparecem em produção. E cada uma que o agente decide sozinho ele decide bem — com a resposta mais comum do treino, que em clínica costuma ser a errada: DELETE de verdade em vez de cancelamento lógico.
A regra que ficou: uma tarefa que cabe num diff revisável, uma spec, um arquivo de teste. Quando a spec passa de sessenta linhas, não é spec grande — são duas tarefas.
Onde a spec vive
Spec colada no prompt morre com a sessão. O arquivo fica no repositório, versionado com o código, e é citado por caminho:
# o pedido inteiro, depois que a spec existe
claude "implemente docs/specs/fornecedores-listagem.md"
# no Aider é a mesma ideia: a spec entra como leitura, não como edição
aider --read docs/specs/fornecedores-listagem.md src/rotas/fornecedores.ts
O --read do Aider importa mais do que parece: ele carrega a spec no contexto sem colocá-la no conjunto de arquivos editáveis. Já vi agente “resolver” uma divergência entre spec e código reescrevendo a spec — o que é rigorosamente o pior desfecho possível, porque apaga a única coisa que servia de referência.
O CLAUDE.md do projeto — .cursorrules, no Cursor — declara a convenção uma vez:
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.
A última frase é a que mais muda o comportamento: “pergunte em vez de decidir” troca decisão silenciosa, que é cara, por pergunta, que é barata.
Fora do agente a spec também paga: vira descrição do PR quase sem edição, e a revisão deixa de ser “isso parece certo?” para ser “isso bate com o arquivo ao lado?”. A AWS lançou esta semana o Kiro, um IDE que torna o arquivo de requisito obrigatório antes de gerar código — não sou o único indo por aí.
Onde isso não compensa
O custo fixo de escrever a spec não some quando a tarefa é pequena. Não escrevo spec para:
- tarefa de cinco minutos: renomear variável, ajustar log, subir versão de dependência. A spec custa mais que a tarefa.
- exploração: quando não sei o que quero, a spec é o que estou tentando descobrir. Aí o prompt vago é a ferramenta certa — peço três abordagens e escolho uma. A spec vem depois, a partir do que escolhi.
- protótipo descartável: se o código morre na sexta-feira, decisão silenciosa do agente não custa nada.
O corte: escrevo spec quando o código vai ser mantido por outra pessoa — inclusive eu daqui a três meses — ou quando o erro é silencioso. Fora disso, prompt e revisão bastam.
O limite honesto
A spec não deixa o agente mais inteligente. Se eu especificar errado, ele executa o erro com precisão — deixa de ser o autor do bug e vira o executor do meu. É melhor, porque o bug fica num arquivo revisável, mas não é mágica.
A spec não substitui a revisão. Muda o que eu reviso: em vez de ler o código procurando o que está errado, leio o diff contra o arquivo. É uma pergunta mais fácil de responder.
E o custo é certo enquanto o ganho ainda é hipótese minha. A spec entra em toda chamada como token de entrada, e isso é cobrado — com modelo online, contexto vira conta no fim do mês. Quando o modelo roda na sua própria placa a conta é outra, e está no post sobre VRAM.
| Mesmo pedido | Rodadas até o diff entrar | Correções após revisão | Tokens de entrada |
|---|---|---|---|
| Prompt em prosa, 2 linhas | «MEDIR» | «MEDIR» | «MEDIR» |
| Spec versionada, 60 linhas | «MEDIR» | «MEDIR» | «MEDIR» |
Escrever a spec acima me custa «MEDIR» minutos — o número que decide se o método vale, e o primeiro que vou cronometrar.
Se eu recomeçasse, mudaria a ordem: escreveria os cinco nomes de teste primeiro e deixaria o resto sair deles. De cima para baixo, gasto tempo na parte descritiva — que o agente inferiria sozinho — e chego com pressa nos casos de borda, a única seção que ele não tem como adivinhar.
A spec não deixa o modelo mais capaz. Ela só tira dele a chance de chutar.