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 — «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»
Ainda não medi — por isso as células estão marcadas, não preenchidas. Método pretendido: mesmo repositório, agente sem histórico, contagem manual de rodadas até o diff entrar sem retoque, tokens lidos do relatório de uso da ferramenta.

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.