Pular para o conteúdo
Fantástico Mundo de Jon
RSS

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:

  • pagina começava em 0, enquanto o resto da API começa em 1;
  • a ordenação era ORDER BY nome, sem desempate — com dois fornecedores de mesmo nome, a paginação repete um registro e pula outro;
  • total contava a tabela inteira, não o resultado do filtro;
  • tamanho não tinha teto: ?tamanho=100000 puxa 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)

o critério de aceite é isto ficar verde

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, 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.

Eu não tenho esse número, e prefiro dizer isso a encher uma tabela. O que falta comparar, entre o prompt em prosa de duas linhas e a spec versionada de sessenta, são três coisas: quantas rodadas até o diff entrar sem retoque, quantas correções aparecem depois da revisão, e quantos tokens de entrada cada um consome.

Quanto tempo custa escrever a spec acima é o número que decide se o método vale, e é o primeiro que eu vou cronometrar. Ainda não cronometrei.

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.