Pular para conteúdo

0046 — Documento e telefone com máscara, e erro de formulário que a pessoa consegue ler

Status CONCLUÍDA
Branch spec/0046-mascaras-e-erros-na-analise-de-credito
Criada em 2026-08-24
Apps afetados api · web · e2e
Revisão do problema aprovada na rodada 1
Revisão da experiência fechada na rodada 5, sem revalidação
Revisão do detalhamento fechada na rodada 7, sem revalidação
Revisão da implementação aprovada na rodada 10 (branch inteira)

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

A spec tem três partes, escritas em ordem, e quatro portões — cada um revisado por um subagente de contexto limpo, em até 3 rodadas. A máquina de revisão é do plugin superpowers; a régua é a rubrica correspondente em .claude/rubrics/. Achados vêm classificados: qualquer Critical reprova, Important em aberto reprova salvo decisão registrada do usuário, e Minor vira pendência aceita.

  1. A Parte I diz qual é o problema e como se convive com ele hoje. Revisada na seção 6 (necessidade × jornada de hoje). É o portão mais barato de todos, e o único que pergunta contra que problema isto está sendo construído.
  2. A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
  3. A Parte III diz como se constrói. Revisada na seção 16 (segurança × clareza técnica) — autorização, isolamento, e se sobrou decisão para quem vai codar.
  4. A implementação é medida contra as três, na seção 17 (aderência × qualidade × testes) — uma revisão por fase, e uma da branch inteira antes de fechar.

Cada parte só se escreve depois que a anterior passou. Descrever tela sobre problema mal enquadrado, ou detalhar endpoint sobre experiência vaga, é o jeito mais caro de descobrir que era outra coisa.


Parte I — O problema

Aqui não entra solução: nem tela, nem endpoint, nem botão. Só o que dói, para quem, e o que se faz hoje.

1. Dor

O mesmo inquilino entra duas vezes no sistema, porque quem procura por ele não o encontra.

Ao abrir uma análise de crédito, a primeira coisa que se faz é procurar o inquilino por CPF ou CNPJ — o campo de busca diz isso com todas as letras: "Buscar por nome, CPF ou CNPJ…". Não achando, o formulário oferece cadastrar ali mesmo, e é o que a pessoa faz.

Só que o campo onde ela digita o documento não tem máscara, e nada normaliza o que ela escreveu: o que sai do teclado é o que vai para o banco. Uma pessoa digita 139.778.672-88, outra digita 13977867288, e o banco guarda os dois. A busca compara texto literal — então quem procura de um jeito não encontra quem foi cadastrado do outro, mesmo sendo o mesmo documento, e cadastra de novo.

O custo não é o cadastro duplicado em si; é o que ele produz:

  • O analista decide sobre um histórico partido em dois. Uma pessoa com análise negada no mês passado aparece como desconhecida na análise de hoje, porque a segunda entrada não tem passado nenhum. A recusa anterior não chega a quem está decidindo.
  • A conferência por pessoa deixa de fechar. O documento é o que amarra inquilino, garantia e cobrança; com dois cadastros para a mesma pessoa, "quantas garantias este inquilino tem" passa a depender de qual dos dois alguém abriu.
  • E ninguém é avisado. Não há erro, não há alerta: os dois cadastros são válidos aos olhos do sistema.

A segunda dor é do momento de salvar, e é o que faz a pessoa desistir do formulário. O que ela vê ao clicar em salvar com algo errado é uma das três coisas, e nenhuma diz o que fazer:

  • Um documento visivelmente errado passa. A conferência do formulário verifica apenas se o campo está vazio; 123 é aceito como CPF, viaja para a API, e a API também aceita, porque cpf e cnpj são texto livre para ela. O erro só aparece quando alguém for emitir a garantia — ou quando o boleto sair no nome de um documento que não existe.
  • A mensagem de erro promete detalhes que não aparecem. Quando a API recusa, ela devolve a lista de campos com problema, mas a tela mostra só a frase de abertura: "Alguns campos precisam ser corrigidos. Verifique os detalhes abaixo e tente novamente." — e abaixo não há nada. A pessoa fica com uma instrução que aponta para o vazio.
  • Do passo 2, o erro do passo 1 é invisível. O formulário tem duas etapas, e quem está na segunda não vê os campos da primeira. Enviando para análise dali, a conferência da etapa 1 não roda: quem recusa é a API, e a recusa chega como aquela mesma frase sem detalhes, falando de campos que não estão na tela.

O custo de não fazer: cada análise aberta é uma chance de duplicar um inquilino sem ninguém perceber, e cada formulário recusado é uma pessoa tentando adivinhar o que corrigir. As duas coisas acontecem na porta de entrada do produto — a análise de crédito é por onde todo negócio começa, e é a tela que a imobiliária parceira usa.

Não há volume a medir, e isto é limite declarado. A produção está no ar desde a spec 0028 e não tem cliente nenhum — só o usuário admin criado pelo seed. Então não existe operação real de onde tirar "quantos duplicados por mês". O que existe, e está na seção 3, é a prova de que o mecanismo funciona: a busca realmente não encontra, medida no banco do dev-remote.

2. Quem sente

  • AGENCY_USER — a imobiliária parceira. Sente primeiro, e é quem mais sente. É ela quem abre a análise de crédito no dia a dia. A tela é caminho obrigatório — não o papel: não há outra forma de uma garantia nascer, e por isso todo mundo que abre análise passa por aqui, não só a imobiliária. O produto reconhece o peso desse papel no próprio material de treinamento: o tutorial "Abrir uma análise de crédito" é declarado para o público AGENCY_USER e leva a /credit-analyses/create. É também quem tem menos contexto para se defender do erro: está fora da garantidora, cadastrando gente que acabou de conhecer, e frequentemente pelo celular.
  • ANALYST — o analista de crédito. Não sente ao digitar; sente depois, ao decidir. É ele quem recebe o histórico partido em dois e decide sem saber que decide incompleto. É a pior forma de sentir uma dor: sem sinal de que ela está acontecendo.
  • OPERATOR e ADMIN_TENANT — quem opera e administra a garantidora. Abrem análise também, e são quem descobre o duplicado — em geral tarde, quando alguém pergunta por que o mesmo nome aparece duas vezes.
  • Quem não sente: VIEWER (só lê) e ADMIN_GLOBAL (administra a plataforma, não o dado de negócio do cliente).

3. A jornada de hoje

O caminho, ponta a ponta

  1. A imobiliária fecha o aluguel e precisa da garantia. Abre a análise de crédito.
  2. Procura o inquilino pelo CPF, no campo que promete busca por documento. Digita como costuma escrever — com pontos e traço, ou só os números.
  3. Não encontra. Ou porque a pessoa realmente não está cadastrada, ou porque está cadastrada com o documento escrito do outro jeito. A tela não distingue os dois casos — nos dois ela diz que não há resultado.
  4. Cadastra o inquilino ali mesmo, no bloco que o formulário oferece: nome, tipo, documento e telefone. Digita o documento no formato que preferir. Digita o telefone no formato que preferir.
  5. Preenche o resto — imóvel, pacote locatício, prazo — e salva.
  6. Se algo estiver errado, o formulário avisa no campo quando é uma das coisas que ele confere (campo vazio, aluguel zerado); quando é a API que recusa, aparece uma faixa vermelha com a frase que promete detalhes que não existem. A pessoa relê os campos até descobrir sozinha, ou desiste e liga para a garantidora.
  7. Passa para a etapa de documentos, anexa o que precisa, e envia para análise.
  8. Fora do sistema: quando não descobre o que está errado, a imobiliária manda mensagem ou liga para a garantidora, que abre a mesma tela do outro lado para conferir. Quando o duplicado é descoberto, a conversa é a mesma, na direção contrária.

Onde exatamente quebra

Quebra no passo 3, e é conferível. A busca do inquilino compara texto literal — em apps/api/app/repositories/renter_repo.py, Renter.cpf.ilike(like) sobre o termo digitado, sem tirar pontuação de nenhum dos dois lados. Medido no banco do dev-remote, nas duas direções:

gravado pontuado: 139.778.672-88  →  busca por "13977867288"   encontra: 0
gravado só dígitos: 71948263564   →  busca por "719.482.635-64" encontra: 0

E os dois formatos convivem na mesma coluna porque cada tela grava de um jeito: no mesmo banco há 99 CPFs com pontuação e 172 só com dígitos (e, entre os telefones, 38 contra 119). Quem passa pelo cadastro de inquilinos grava só dígitos, porque lá o campo é mascarado e a máscara desmonta o valor antes de enviar; quem passa pela análise de crédito grava o que digitou, porque ali não há máscara nem normalização — apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx envia cpf: newRenter.doc.trim(), e .trim() só tira espaço.

O que estes números são, e o que não são. São do dev-remote, cuja base é seed mais dado deixado pela suíte de testes — não é operação de cliente, e não há operação de cliente em lugar nenhum ainda. Eles não medem frequência; medem que as duas gravações coexistem, que é o que a dor precisa que seja verdade.

Quebra também no passo 6, em três pontos distintos:

  • A conferência do formulário (CreditAnalysisForm.tsx:366-400) verifica apenas presença — !trim() — no nome e no documento. Não confere dígito verificador, formato nem telefone. Um CPF de três dígitos passa daqui.
  • A API não confere nada: em apps/api/app/schemas/renter.py:48-49, cpf e cnpj são str | None sem validador, embora a documentação do campo diga "no formato 000.000.000-00". O formato descrito ali é promessa, não regra.
  • O erro que a API devolve chega pela metade. Ela responde com message e details[] — a lista de campos com problema, montada em apps/api/app/core/exceptions.py:157-165 —, mas a tela guarda só a message (CreditAnalysisForm.tsx:574) e a mostra sozinha. A frase padrão dessa resposta é literalmente "Verifique os detalhes abaixo", e os detalhes foram descartados no caminho.

E quebra num quinto lugar, que foi reportado depois: o erro do telefone existe e não aparece. Foi observado em uso — telefone com formato errado, e nenhum aviso na tela. A investigação achou quatro mecanismos distintos capazes de produzir isso, e eles não se excluem:

  1. O texto de ajuda ocupa o lugar da mensagem de erro. Em apps/web/src/shared/components/MaskedField.tsx, o campo mascarado resolve o que exibir com helperText={fieldState.error?.message ?? fieldProps.helperText}. Os três telefones do cadastro de inquilinos passam um texto de ajuda permanente — "Somente ligações", "SMS e ligações", "Mensagens pelo app" (apps/web/src/pages/renters/form.tsx, :405 e :417). Havendo erro sem mensagem — uma regra escrita como required: true em vez de required: "texto", uma função de validação devolvendo false, ou um erro de servidor marcado sem texto —, fieldState.error?.message é undefined, o ?? cai no texto de ajuda, e o campo fica vermelho explicando para que serve. O estado de erro aparece; a explicação, não.
  2. O estado de erro pode ser desligado por quem chama. Na linha de cima, MaskedField.tsx:99, está error={fieldProps.error ?? !!fieldState.error} — e ?? só cede em null ou undefined. Um chamador que passe error={false}, que é o que se escreve naturalmente num formulário que calcula o próprio estado, anula o erro do react-hook-form inteiro: nem vermelho, nem mensagem. Hoje nenhum chamador faz isso (conferido: zero ocorrências de error={ nos usos de MaskedController), então este mecanismo é armadilha armada, não causa provada — mas está na mesma linha do defeito e sai junto.
  3. Não há regra nenhuma nos telefones. Nenhum dos três MaskedController de telefone recebe rules (apps/web/src/pages/renters/form.tsx:385-419), e a conferência da análise de crédito não olha telefone (CreditAnalysisForm.tsx:366-400). Telefone pela metade não gera erro porque não há o que gerar — é a forma mais simples de erro suprimido: ele nunca nasce.
  4. A máscara descarta o que não cabe, em silêncio. PHONE_MASKS (apps/web/src/shared/utils/masks.ts:18-21) tem dez e onze dígitos. Um número colado com código do país — +55 11 98765-4321, treze dígitos — não tem máscara que o comporte: os dígitos entram na ordem até encher, e os últimos são jogados fora. O campo aceita, fica com aparência válida, e guarda outro número. Este é raciocínio sobre o comportamento do componente, não medição — precisa ser confirmado na implementação, e está escrito assim de propósito.

Os quatro têm o mesmo desfecho e é por isso que estão juntos: o dado errado é gravado sem que ninguém seja avisado, que é a pior forma de um formulário falhar.

E quebra no passo 7 de um jeito que ninguém vê: enviando para análise a partir da etapa 2, a conferência da etapa 1 nunca roda. São duas linhas que juntas explicam isso: em apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:999, const id = analysisId ?? (await saveStep1(false)) — o ?? só chama o gravar quando a análise ainda não existe, e na etapa 2 ela sempre existe; e é dentro daquele gravar, em CreditAnalysisForm.tsx:516, que mora o if (!validate()) return null que seria a conferência. O que barra, então, é a API — e a recusa fala de campos que estão noutra etapa, fora da tela.

O que já se tentou

  • A solução existe no repositório e não chegou até aqui. O MaskedController (apps/web/src/shared/components/MaskedField.tsx) e as máscaras de CPF, CNPJ e telefone (apps/web/src/shared/utils/masks.ts) foram construídos e aplicados em três telas — cadastro de inquilinos, onboarding e minha garantidora. A análise de crédito, o cadastro de imobiliárias e o de imóveis ficaram de fora. Não é falta de ferramenta: é ferramenta que não foi aplicada onde mais importa.
  • A validação de CPF e CNPJ também já existe (validateCPF e validateCNPJ, no mesmo arquivo) e roda só no cadastro de inquilinos.
  • E a API já resolveu isto uma vez, para o CNPJ. Existe um tipo próprio, AppCNPJ (apps/api/app/schemas/common.py), que exige catorze dígitos e recusa o valor pontuado com a mensagem "CNPJ deve ser enviado somente com dígitos, sem pontos, barra ou traço". Ele é usado em imobiliária, garantidora e onboarding (apps/api/app/schemas/partner_agency.py:38 e :50, apps/api/app/schemas/tenant.py:41 e :56, apps/api/app/schemas/onboarding.py:115 e :131). O inquilino ficou de fora — e é exatamente onde o duplicado nasce, porque é o inquilino que se procura por documento. Não falta desenho: falta o mesmo desenho no lugar que importa, e não existe AppCPF.
  • Uma tela já sabe mostrar erro de campo direito. O cadastro de imobiliárias (apps/web/src/pages/agencies/form.tsx:81-91) lê a lista details[] da API, devolve cada erro ao seu campo e ainda imprime a lista. É a única do app que faz isso — e ela mesma não tem máscara nenhuma.
  • O padrão de exibição de erro está escrito e não está implementado. O docs/ui-patterns.md:514-522 descreve rolar até o primeiro campo inválido no envio. Nenhum formulário do app faz isso: não há uma única chamada de setFocus ou shouldFocusError em apps/web/src.
  • Contorno em uso hoje: a pessoa relê o formulário campo a campo até achar; não achando, telefona ou manda mensagem para a garantidora. O duplicado, quando aparece, vira conversa entre as duas pontas.

4. Resultado esperado

O que passa a ser possível:

  • Procurar um inquilino pelo documento e encontrá-lo, tendo ele sido cadastrado por qualquer tela e digitado de qualquer jeito.
  • Digitar CPF, CNPJ e telefone sem decidir o formato — a tela decide, e todas decidem igual.
  • Descobrir, ao salvar, o que exatamente está errado, inclusive quando o campo com problema está numa etapa que não está à vista.
  • Ser barrado na hora ao digitar um documento que não existe, em vez de descobrir isso quando a garantia for emitida.

Como se sabe que resolveu — verificável sem instrumentação:

  1. Um documento cadastrado pela análise de crédito e outro cadastrado pela tela de inquilinos ficam gravados no mesmo formato, e a busca encontra os dois digitando de qualquer das duas maneiras.
  2. Um CPF ou CNPJ com dígito verificador inválido não é aceito — nem pela tela nem pela API, e a recusa nomeia o campo.
  3. Salvando com erro, nenhuma mensagem promete detalhe que não mostra: ou o erro está no campo, à vista, ou está listado com o caminho até ele.
  4. Enviando para análise a partir da etapa 2 com um campo da etapa 1 inválido, a pessoa fica sabendo qual campo e em que etapa — hoje ela recebe uma frase genérica sobre campos que não vê.
  5. Não sobra tela em que o formato do documento dependa de quem digitou: um mesmo CPF cadastrado por qualquer caminho do produto fica gravado igual, e encontrável igual.
  6. E o que já estava gravado também. Rodado o script de normalização, nenhuma linha do banco guarda documento ou telefone com pontuação — conferível por consulta, e o número esperado é zero em todas as colunas que ele toca.

5. Fora de escopo

  • ~~Converter o que já está gravado nos dois formatos.~~ Isto entrou em escopo em 2026-08-24, por decisão do usuário, revendo a decisão tomada mais cedo no mesmo dia. A primeira versão desta seção deixava a conversão de fora e aceitava a consequência — cadastro antigo invisível à busca. O usuário pediu um script que deixe a base num padrão único, e com ele a consequência some: não sobra dado em dois formatos para a busca reconciliar. O que o script faz está na Parte III; o que muda aqui é que a exclusão deixou de existir.
  • Unificar inquilinos já duplicados. Fundir dois cadastros é decidir qual histórico vale, e isso é regra de negócio que ninguém escreveu. Esta spec impede o duplicado novo; não conserta o velho.
  • Tornar a busca por documento insensível à pontuação no servidor. Seria a outra metade da mesma dor, e ficou desnecessária: com a gravação nova normalizada e a base antiga convertida pelo script, não sobram dois formatos para o banco reconciliar — quem limpa a pontuação passa a ser a tela, antes de consultar (Parte II). Resolver no servidor custaria índice funcional e reescrita da busca, e compraria só a tolerância a um formato que deixou de existir. Fica registrado como o caminho se o problema voltar.
  • Máscara e validação em CEP. A máscara existe (CEP_MASK) e não é usada em lugar nenhum; o campo de CEP tem formatação própria escrita à mão. É a mesma classe de problema, mas o CEP não identifica pessoa nem entra em garantia — não duplica cadastro nem sai em boleto.
  • Rolagem até o primeiro erro nos demais formulários do app. O padrão está documentado e não implementado em lugar nenhum. Esta spec o implementa onde a dor está; espalhar para as outras telas é trabalho separado, sem dor descrita.
  • Validar telefone além do formato. A máscara garante DDD e quantidade de dígitos; se o número existe, atende ou é WhatsApp, esta spec não pergunta.
  • Mudar como a garantia ou a cobrança usam o documento. Nada a jusante muda.

6. Revisão do problema

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/problema.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 0 0 5 APROVADA

Resultado: aprovada na rodada 1.

O revisor abriu as dez citações de código e reproduziu no banco do dev-remote os quatro números e as duas buscas da seção 3 — os mesmos zeros nas duas direções. Também foi conferir o que a seção 3 afirma sobre o repositório: setFocus/shouldFocusError não aparecem uma única vez em apps/web/src, validateCPF/validateCNPJ rodam só no cadastro de inquilinos, e apps/web/src/pages/agencies/form.tsx e apps/web/src/pages/properties/form.tsx não importam Masked* nenhum.

Quatro dos cinco Minor foram corrigidos nesta rodada:

  1. A citação que não provava o mecanismo. CreditAnalysisForm.tsx:641 era o cabeçalho de handleSubmitForAnalysis; quem prova a afirmação são :644 (o analysisId ?? que pula o gravar) e :516 (o if (!validate()) return null que ele contorna). As duas entraram no lugar da primeira.
  2. O tutorial estava citado costurando dois campos — titulo e frase do catálogo — numa aspas só, e aquela frase não existe como título em lugar nenhum. Passou a ser citado pelo título real, mais o publico e o destino declarados no arquivo, que são o que de fato sustentam o argumento.
  3. "Caminho obrigatório" lia-se como se fosse do papel, e três linhas abaixo o texto dizia que OPERATOR e ADMIN_TENANT também abrem análise. O obrigatório é da tela; ficou escrito, para a Parte II não nascer escopada a um papel só.
  4. O item 5 de "como se sabe que resolveu" era solução dentro da Parte I — nomeava máscara e listava as três telas. Virou resultado observável, no nível dos outros quatro; a lista das telas continua na seção 3, onde ela é constatação do presente.

Pendências aceitas: nenhuma.

Emenda posterior à aprovação, registrada aqui para não passar em silêncio. Ainda em 2026-08-24, depois deste portão, o usuário reviu a decisão sobre o dado já gravado: em vez de normalizar só da gravação nova em diante, pediu um script que deixe a base num padrão único. A seção 5 foi editada — a exclusão saiu —, a seção 4 ganhou um sexto item de verificação, e o item sobre a busca no servidor teve o motivo reescrito. A dor, a jornada de hoje e quem sente não mudaram, que é o que este portão mede; o que mudou foi a fronteira, e ela mudou para incluir trabalho, não para excluir.

Um achado recusado, com o motivo. O revisor pediu que a citação apps/web/src/pages/agencies/form.tsx:81-91 virasse :80-91, para incluir a guarda if (e instanceof ApiError && e.details.length > 0). Fui conferir: :80 é } catch (e) {, e a guarda está em :81 — dentro do intervalo que já estava escrito. Não há o que corrigir.


Parte II — A experiência

7. Jornada pretendida

  • Ponto de entrada: nenhum novo. A pessoa chega onde já chegava — ao abrir uma análise de crédito, ao cadastrar uma imobiliária, ao cadastrar um imóvel. Esta spec não acrescenta tela, item de menu nem rota; ela muda o que acontece dentro de formulários que já existem.
  • Jornada, caso feliz: a pessoa procura o inquilino pelo documento e o encontra, tendo digitado com pontuação ou sem. Não existindo, cadastra ali mesmo: digita o documento e o telefone sem decidir o formato — a pontuação aparece sozinha enquanto ela digita, e some quando o valor é gravado. Ao salvar, ou grava, ou é levada até o primeiro campo que precisa de conserto, com a explicação no próprio campo.
  • Custo de uso: cai. Hoje a pessoa decide como escrever o documento e depois descobre que decidiu errado; passa a só digitar números. O sistema já sabe qual máscara usar — pelo tipo de pessoa escolhido no formulário para CPF ou CNPJ, e pela quantidade de dígitos para telefone fixo ou celular — e por isso não pergunta.

O que muda ao procurar

O campo de busca do inquilino passa a procurar pelos dígitos. Digitando 139.778.672-88, a consulta sai como 13977867288 — e encontra o cadastro, que a partir desta spec está gravado assim. Digitando 13977867288, nada muda; já era o que acontecia.

O campo continua sendo o mesmo, e continua aceitando nome. Ele promete "nome, CPF ou CNPJ", e a limpeza da pontuação vale só quando o que foi digitado tem cara de documento — isto é, quando não há letra nenhuma. Maria Silva vai inteiro para a consulta, com o espaço; Silva, 139.778 também, porque tem letra. Sem esse recorte, uma busca por nome perderia o que não fosse dígito, e o campo deixaria de fazer metade do que promete. (Decisão do usuário, 2026-08-24: resolver na tela, e não na API.)

E há uma terceira busca por documento, no painel do SaaS, que filtra a lista de garantidoras no próprio navegador (apps/web/src/admin/pages/tenants/TenantListPage.tsx:152, (t.cnpj ?? "").includes(term)) — sem ida ao servidor. Como o CNPJ da garantidora já é só dígitos, ela já hoje não encontra quem digita com pontuação. Recebe a mesma limpeza, pelo mesmo motivo das outras duas.

E a mesma limpeza vale na busca da lista de inquilinos, que é outra tela e o mesmo problema: ela promete "nome, documento ou e-mail" e manda o termo cru para a mesma comparação literal. Hoje, digitar 139.778.672-88 ali encontra — porque há registro gravado pontuado. Normalizando a base sem tocar nesse campo, ele passaria a não encontrar mais nada com pontuação: a spec teria criado uma regressão numa tela que não pretendia mexer. As duas buscas por documento do produto recebem o mesmo tratamento, e é por isso que a regra é do campo de busca por documento, não de uma tela específica.

Nas outras duas listas não há o que fazer, e vale registrar por quê. A busca de imobiliárias já limpa a pontuação, e do lado do servidor (apps/api/app/repositories/partner_agency_repo.py:66-72, que separa os dígitos do termo antes de comparar com o CNPJ) — ali a normalização da base melhora o resultado em vez de quebrá-lo. E a busca de imóveis não consulta o documento do proprietário, então não é alcançada.

E o cadastro antigo? Ele também é encontrado — não porque a busca aprendeu a ignorar pontuação, mas porque deixa de haver pontuação a ignorar: o script de normalização (Parte III) converte o que já está gravado. As duas metades se sustentam juntas, e é por isso que nenhuma delas sozinha resolveria: limpar só o que for digitado não alcança as 99 linhas que já estão lá, e limpar só o banco não ajuda quem digita com ponto.

O que muda ao digitar

A pontuação aparece sozinha, e o valor gravado não a leva junto. Digitando 139778672, o campo mostra 139.778.672; ao completar, 139.778.672-88. O que vai para o banco são só os dígitos. É o comportamento que o cadastro de inquilinos já tem, e é ele que passa a valer em toda parte — a divergência entre telas é a causa da seção 3, e ela só fecha se todas gravarem igual.

A máscara segue o que o formulário já sabe. No bloco de cadastro do inquilino, a escolha entre pessoa física e jurídica já existe e já troca o rótulo do campo entre "CPF" e "CNPJ"; ela passa a trocar a máscara também. Trocando o tipo com o campo preenchido, o que foi digitado é remascarado no formato novo — não se apaga o que a pessoa escreveu, porque apagar seria punir quem percebeu o próprio erro.

Telefone acomoda fixo e celular sem escolher antes. A máscara acompanha a quantidade de dígitos: (11) 3333-4444 com dez, (11) 98765-4321 com onze. Não há campo separado nem pergunta.

O que muda ao ler — a outra metade da mesma decisão

Gravar só dígitos obriga a formatar na leitura, e isto é regra, não consequência opcional. Hoje o documento "parece formatado" em várias telas por acidente: elas imprimem o valor cru, e ele vem pontuado porque alguém digitou pontuado. Normalizando a gravação sem mexer nessas telas, o produto passaria a exibir 13977867288 e 11987654321 onde hoje se lê 139.778.672-88 e (11) 98765-4321 — ficaria menos legível por causa da spec que existe para deixá-lo legível.

Então: todo lugar que mostra documento ou telefone gravado mostra formatado. A regra é do dado, não da tela — e ela vale para qualquer superfície, presente ou futura. A tabela abaixo enumera as que hoje imprimem cru, com o arquivo ao lado, porque é ela que alguém vai varrer na hora de implementar; a regra continua valendo para o que a tabela não pegou.

Onde O quê Arquivo
Lista de imobiliárias CNPJ e telefone, no desktop apps/web/src/pages/agencies/index.tsx e :296
Lista de imobiliárias o CNPJ e o telefone no cartão do celular apps/web/src/pages/agencies/index.tsx e :206
Detalhe da imobiliária telefone fixo, celular e WhatsApp apps/web/src/pages/agencies/show.tsx:210-212
Lista de inquilinos o telefone (o documento ali já formata) apps/web/src/pages/renters/index.tsx:50
Detalhe do inquilino, celular o CPF e os três telefones apps/web/src/pages/renters/show.tsx:131 e :145-147
Detalhe do inquilino, desktop o CPF e os três telefones apps/web/src/pages/renters/show.tsx:199 e :217-219
Lista de imóveis o documento do proprietário apps/web/src/pages/properties/index.tsx
Detalhe do imóvel o documento do proprietário apps/web/src/pages/properties/show.tsx:195
Formulário de imóvel, imobiliária escolhida o CNPJ na opção da busca e no cartão, e o telefone do cartão apps/web/src/pages/properties/form.tsx:209, :269 e :302
Análise de crédito, resultado da busca o documento na linha de cada pessoa apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx
Análise de crédito, inquilino escolhido o documento no resumo do bloco apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx
Análise de crédito, corresponsáveis o CPF ao lado do nome, em cada linha apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:1111
Detalhe da análise de crédito o telefone da imobiliária apps/web/src/pages/credit-analyses/show.tsx:1133
Detalhe da garantia o documento e o telefone do tomador, na mesma legenda apps/web/src/pages/guarantees/components/GuaranteeSections.tsx:90 e :157
Painel do SaaS, lista de garantidoras o CNPJ, no desktop e no cartão apps/web/src/admin/pages/tenants/TenantListPage.tsx:293 e :409
Painel do SaaS, detalhe da garantidora o CNPJ e os seis telefones apps/web/src/admin/pages/tenants/TenantDetailPage.tsx, :143-145 e :154-156
Painel do SaaS, painel inicial o CNPJ, no cartão e na célula apps/web/src/admin/pages/dashboard/AdminDashboardPage.tsx e :206

O painel do SaaS entra, e vale dizer por quê, já que a Parte I registra que o ADMIN_GLOBAL administra a plataforma e não sente a dor do duplicado. Não sente mesmo — mas essas três telas já exibem dígitos corridos hoje, porque o CNPJ da garantidora é obrigatoriamente só-dígitos desde que existe o tipo AppCNPJ. Não é regressão a evitar: é ilegibilidade que já está lá, e que se conserta aplicando a mesma função das outras treze superfícies. Nada de permissão ou isolamento muda — é exibição do que a tela já mostra. (Decisão do usuário, 2026-08-24.)

As três da análise de crédito são as que mais doem: é a tela onde a pessoa está procurando alguém por documento — ler 13977867288 ali e ter de conferir dígito a dígito contra o que tem na mão é o oposto de encontrar. E o detalhe da garantia é a mais fácil de esquecer, por ser a única fora dos três formulários e dos cadastros que os alimentam: é o documento do tomador aparecendo no dossiê do contrato, que é onde ele é lido por quem não cadastrou ninguém.

Como se confere esta tabela, e por que não é varrendo isMobile. A regra é do dado, então a varredura é pelo dado: cpf, cnpj, phone, owner_document em apps/web/src. Foi assim que as três superfícies do painel do SaaS apareceram — nenhuma delas seria encontrada procurando por caminho mobile, e duas das cinco telas que uma versão anterior desta nota apontava como "de risco" nem têm caminho duplicado para este dado (apps/web/src/pages/guarantees/show.tsx renderiza pelo GuaranteeSections, com grade responsiva e um ponto só; apps/web/src/pages/renters/index.tsx compartilha docOf e phoneOf entre tabela e cartão). O caminho mobile duplicado é uma das formas de perder uma linha, não a única — e conferir por ele deixa passar exatamente o que ele não cobre.

Documento e telefone são o mesmo assunto para efeito desta regra, e de quem quer que sejam — inquilino, imobiliária ou tomador da garantia. O que decide é o dado ter sido gravado como dígitos, não a quem ele pertence.

A formatação tolera as duas entradas, pelo mesmo motivo do campo: ela lê os dígitos e monta a pontuação, e um valor que chegue já pontuado sai igual a um que chegue limpo. Não depende de o script de normalização ter rodado.

Isto não é padrão novo. As funções que fazem isso já existem em apps/web/src/shared/utils/masks.ts e já são usadas em sete pontos do app — apps/web/src/pages/renters/form.tsx:263, apps/web/src/pages/renters/show.tsx:101, apps/web/src/pages/renters/index.tsx:49, apps/web/src/pages/agencies/show.tsx:200, apps/web/src/pages/credit-analyses/show.tsx:1035 e :1131, e apps/web/src/pages/credit-analyses/components/GuaranteeCreateDialog.tsx:283. O apps/web/src/pages/credit-analyses/show.tsx aparece nas duas listas de propósito: o CNPJ da imobiliária (:1131) já formata, e o telefone dela, na linha seguinte (:1133), não. O que falta é aplicá-las onde ainda não foram. É a mesma constatação da seção 3: a ferramenta existe e não chegou aos lugares que precisam dela.

Exatamente quais campos, em cada formulário

A lista é fechada para máscara. As duas últimas linhas são de outra natureza e valem mais longe: a conferência do telefone e a saída do placeholder alcançam telas que já têm máscara, pelos motivos que cada linha aponta.

Formulário Campo O que passa a ter
Análise de crédito, bloco de cadastro do inquilino CPF / CNPJ máscara pelo tipo de pessoa já escolhido no bloco, e conferência do dígito verificador
Análise de crédito, bloco de cadastro do inquilino Telefone máscara de telefone (dez ou onze dígitos)
Análise de crédito, busca do inquilino campo de busca limpeza da pontuação antes de consultar, quando não há letra
Lista de inquilinos, busca campo de busca a mesma limpeza — senão a normalização da base cria regressão aqui
Imobiliária CNPJ máscara de CNPJ e conferência do dígito verificador
Imobiliária Telefone fixo · Celular · WhatsApp máscara de telefone
Imóvel CPF/CNPJ do proprietário máscara pela contagem de dígitos — ver abaixo
Imóvel Contato do proprietário nada — ver abaixo
Telas já mascaradas — inquilino, Minha Garantidora, onboarding os quinze campos de telefone — 3 no cadastro de inquilinos, 6 em Minha Garantidora, 6 no onboarding a conferência que não existe hoje — ver "Erro que existe, aparece"
Telas já mascaradas — as mesmas três documento e telefone a saída dos placeholders de formato — o do RG fica, ver 7.3

O documento do proprietário do imóvel é um caso à parte, e a regra é outra. Ali não há escolha de pessoa física ou jurídica: é um campo só, rotulado "CPF/CNPJ do proprietário". Então a máscara se decide pela contagem: enquanto houver até onze dígitos, ela é de CPF; passando disso, vira de CNPJ, e o que já foi digitado é remontado no formato novo. É a mesma mecânica que o telefone já usa para distinguir fixo de celular, aplicada a documento. A conferência do dígito verificador segue a mesma decisão: onze dígitos são cobrados como CPF, catorze como CNPJ, e qualquer contagem entre os dois é documento incompleto.

O contato do proprietário não recebe máscara, e o script de normalização também não o toca — os dois pela mesma razão. O campo aceita telefone ou e-mail — é o que a API documenta e o que o tamanho do campo comporta. Mascará-lo como telefone impediria digitar o e-mail que hoje é uso previsto, o que seria trocar um problema de formato por uma funcionalidade a menos; e uma limpeza de pontuação passando por ele transformaria contato@imob.com.br em contatoimobcombr, destruindo o dado em vez de padronizá-lo. Fica texto livre, e fica fora do script. (Decisão do usuário, 2026-08-24.)

E há um caso que o script de normalização não resolve e a tela precisa aguentar: owner_document com doze ou treze dígitos — nem CPF nem CNPJ, digitado errado antes desta spec e gravado porque nada conferia. O script tira a pontuação; não inventa dígito.

Abrindo esse registro para edição, o campo mostra o que há e fica calado — pela mesma regra do parágrafo seguinte, que manda calar enquanto o documento está incompleto. Repreender alguém por um valor que ele nem digitou, no instante em que a tela abre, seria a pior versão do aviso: acusa quem não errou, e acusa antes de haver o que consertar. O erro aparece no salvar, como o de qualquer campo incompleto — e aí ele é legítimo, porque a pessoa está afirmando que aquele formulário está pronto.

O valor não é apagado, a tela não trava, e quem for corrigir vê o que estava lá.

E na leitura ele aparece como está, sem pontuação inventada: a regra manda formatar, e formatar um comprimento que não é de CPF nem de CNPJ produziria uma pontuação mentirosa. Doze dígitos saem como doze dígitos — feio, e verdadeiro. É a única exceção à regra de leitura formatada, e existe porque o dado é que está errado, não a exibição.

Documento inválido é dito na hora, não no salvar. Completando os onze ou catorze dígitos com um documento que não existe — dígito verificador errado —, o campo avisa ali, antes de a pessoa sair dele. Esperar o salvar para dizer isso seria guardar a informação até o momento em que ela custa mais.

Documento e telefone pela metade só são cobrados no salvar. Quem está com oito dígitos digitados ainda está digitando; acusar erro ali seria a tela reclamando de trabalho em curso. Então: enquanto incompleto, silêncio — inclusive ao sair do campo, porque trocar de campo e voltar é coisa que se faz. No salvar, o campo incompleto vira erro como qualquer outro, com rolagem e foco. A única exceção é o documento completo e inválido, que já se sabe errado no instante em que fecha, e é dito na hora.

Trocar o tipo de pessoa com o campo preenchido cai no mesmo silêncio. Um CPF de onze dígitos remascarado como CNPJ passa a estar incompleto — e incompleto não acusa até o salvar. O valor não se perde e a tela não repreende quem acabou de perceber que marcou o tipo errado.

Erro que existe, aparece — a regra contra o silêncio

Isto é regra de comportamento, e nasceu de defeito observado: telefone com formato errado, sem aviso nenhum na tela. As quatro maneiras de isso acontecer estão na seção 3; aqui está o que passa a valer.

  1. Estado de erro e mensagem de erro andam juntos, sempre. Não existe campo vermelho sem texto dizendo o que houve, e não existe texto de erro em campo que não esteja marcado. São a mesma informação em dois canais, e um sem o outro é meia informação — o vermelho sozinho diz "algo aqui" e faz a pessoa adivinhar.
  2. Texto de ajuda nunca ocupa o lugar da mensagem de erro. Havendo erro, o que se lê abaixo do campo é o erro; o texto de ajuda volta quando o erro sai. O que não pode acontecer é o campo ficar vermelho exibindo Somente ligações — que é ajuda, soa tranquilizador, e não diz o que corrigir.
  3. Toda regra de validação carrega o próprio texto. Regra que só devolve "inválido", sem frase, é a causa direta do item anterior: sem mensagem, sobra o texto de ajuda. Os textos estão na tabela abaixo, e é por isso que eles são parte da spec.
  4. Nenhum campo pode desligar o erro do formulário. Um campo que aceite receber "não há erro" por fora passa a poder contradizer a conferência que acabou de reprovar. A decisão de estar em erro é de quem valida, e o campo obedece.
  5. A máscara não descarta dígito em silêncio — e o campo avisa, não acomoda. Digitando ou colando mais do que o formato comporta — um telefone com código do país, um CPF colado com dígitos a mais —, o excedente não entra calado. A escolha é avisar, e não esticar o formato: um campo que acomodasse qualquer comprimento deixaria de ser um campo de CPF, e a máscara existe justamente para dizer o que cabe. Aceitar calado é o pior dos três desfechos, porque grava um valor diferente do que a pessoa forneceu e ela sai achando que deu certo.
  6. Telefone passa a ter conferência. Hoje não tem nenhuma, em tela nenhuma — é o motivo de o erro nunca nascer. Passa a ser cobrado no salvar: DDD mais oito ou nove dígitos.

Os quatro mecanismos não se consertam no mesmo lugar, e essa assimetria decide o escopo. Os de número 1, 2 e 4 vivem no componente de campo mascarado e nas máscaras (MaskedField.tsx, masks.ts): corrigidos ali, curam todas as telas do app de uma vez, inclusive as que esta spec não abre. O de número 3 — a ausência de regra — é por campo: ele só morre onde alguém escrever a regra, e nenhuma correção central o alcança.

Por isso a conferência do telefone vale em toda tela que tem campo de telefone, e não só nos três formulários em escopo: cadastro de inquilinos, Minha Garantidora e o passo de dados do onboarding entram por causa disto. Deixá-las de fora manteria o defeito exatamente onde ele foi observado — o cadastro de inquilinos é a única tela com texto de ajuda permanente nos telefones, que é o que faz a mensagem de erro sumir. (Decisão do usuário, 2026-08-24.)

E a regra vale para todo campo mascarado, não só telefone: um CPF com regra sem texto cairia no mesmo silêncio. O telefone foi onde apareceu, não onde o problema mora.

O que a pessoa lê

Os textos são parte da spec, e não escolha de quem implementa — é exatamente assim que se chega a um "verifique os detalhes abaixo" sem detalhes abaixo. Uma frase, sem ponto final, dizendo o que fazer:

Situação O que aparece abaixo do campo
CPF completo, dígito verificador errado CPF inválido — confira os números
CNPJ completo, dígito verificador errado CNPJ inválido — confira os números
CPF pela metade, no salvar CPF incompleto — faltam dígitos
CNPJ pela metade, no salvar CNPJ incompleto — faltam dígitos
Documento do proprietário com 12 ou 13 dígitos Documento incompleto — CPF tem 11 dígitos e CNPJ tem 14
Telefone pela metade, no salvar Telefone incompleto — informe DDD e número
Telefone com dígitos além do que cabe Telefone com dígitos a mais — informe DDD e número, sem o código do país
CPF com dígitos além do que cabe CPF tem 11 dígitos — confira o que foi colado
CNPJ com dígitos além do que cabe CNPJ tem 14 dígitos — confira o que foi colado
Documento do proprietário além de 14 dígitos Documento tem no máximo 14 dígitos — confira o que foi colado
Campo obrigatório vazio, no salvar o texto que a tela já usa hoje, inalterado
A API recusou nomeando o campo a mensagem que a API mandou, sem reescrita
A API recusou nomeando um campo que não está no formulário aberto vai para a faixa do topo, com o nome do campo na frente da mensagem

A mensagem da API vai como veio, e isso é regra. Ela é escrita em português no ponto de uso e já diz o que houve; reescrevê-la na tela criaria duas fontes para o mesmo texto, e a que o usuário lê seria a que ninguém revisou.

O diálogo do erro fora da vista abre com: Não foi possível enviar para análise — há campos a corrigir em outra etapa, seguido da lista.

O que muda ao salvar

A regra, em uma frase: nenhuma mensagem de erro aponta para algo que a pessoa não pode ver.

  1. O erro mora no campo. Cada campo com problema mostra a explicação abaixo de si, em estado de erro — não um resumo no topo, não um aviso genérico.
  2. A tela leva até o primeiro deles, sem a pessoa procurar: o campo recebe o foco e a página rola até ele ficar visível. Vale em qualquer conferência que produza erro inline — o clique de salvar, o avanço de etapa, a recusa da API —, e vale nos três formulários desta spec: análise de crédito, imobiliária e imóvel. Os demais formulários do app continuam como estão; espalhar a rolagem para eles está fora de escopo (seção 5).

"Primeiro" é o primeiro na ordem visual da tela, de cima para baixo — e isto é regra, não detalhe de implementação. A receita registrada no docs/ui-patterns.md:514-522 usa a primeira chave do objeto de erros, que é a ordem de registro dos campos, e as duas ordens não coincidem: mandaria a pessoa para o meio do formulário enquanto o primeiro erro visível ficou acima, fora da tela. Uma rolagem que para no lugar errado é pior que rolagem nenhuma, porque ela afirma "é aqui".

E os formulários têm linhas de dois campos, então "de cima para baixo" precisa de um desempate: dentro da mesma linha, vence o da esquerda. No celular a questão não existe — a mesma dupla vira duas linhas empilhadas, e a ordem vertical já decide. É a única diferença de comportamento da rolagem entre os dois viewports.

  1. Erro que a API recusa vira erro de campo. Quando a recusa nomeia campos, cada um é devolvido ao campo correspondente e tratado como os demais — inclusive na rolagem. A mensagem de abertura da API promete "verifique os detalhes abaixo"; os detalhes passam a existir.
  2. Erro que não é de campo nenhum continua sendo faixa no topo — falha de rede, indisponibilidade, recusa de negócio que não aponta campo. Esse não tem para onde rolar, e é o caso em que a faixa é a resposta certa.
  3. E o mesmo vale quando a API nomeia um campo que o formulário aberto não tem. É possível: a resposta fala do modelo, não da tela, e pode citar algo que só existe no cadastro completo daquela entidade. Não há campo para marcar nem para rolar até, então a mensagem vai para a faixa com o nome do campo na frente — porque uma recusa que não diz de que campo fala é a mesma frase vazia que esta spec veio matar.

E o caso que a rolagem não resolve: o erro fora da vista

E antes de tudo isso, uma coisa que hoje não acontece: a conferência da etapa 1 passa a rodar ao enviar da etapa 2. Hoje ela é pulada (seção 3), e quem barra é a API — é por isso que a recusa chega como frase genérica sobre campos que não estão na tela. Sem essa mudança o diálogo descrito abaixo não teria o que listar: ele mostra o que a conferência encontrou.

O formulário de análise de crédito tem duas etapas, e da segunda não se vê a primeira. Enviando para análise a partir da etapa de documentos com um campo da etapa de dados inválido, não existe "rolar até o campo" — ele não está na tela, está noutro passo.

A regra vale pela existência do erro oculto, não pela ausência do visível. Havendo erro na etapa aberta e na outra, o diálogo abre do mesmo jeito e lista os dois, agrupados por etapa — porque a alternativa seria a pessoa consertar o que vê, clicar de novo, e só então descobrir que havia mais. Dois cliques para saber o tamanho do problema é exatamente o que esta spec veio tirar. O diálogo só não abre quando todo erro está na etapa visível: aí inline e rolagem bastam, e um diálogo seria cerimônia sobre algo que já está à vista.

"O primeiro" da ação de navegação segue a ordem das etapas, e depois a ordem visual dentro dela — etapa 1 antes de etapa 2, e dentro de cada uma, de cima para baixo com o desempate da esquerda. É a mesma ordem em que a lista do diálogo é impressa, para o que se lê e o para onde se vai não discordarem.

Nesse caso abre-se um diálogo que:

  • diz que o envio não aconteceu e por quê;
  • lista os campos que precisam de conserto, com o nome da etapa em que cada um está;
  • cada linha da lista leva ao seu campo — nos dois viewports, e não só no celular: chegando lá, a rolagem e o foco acontecem como em qualquer outro erro;
  • oferece uma ação que leva ao primeiro deles, para quem não quer escolher;
  • oferece fechar, para quem quiser continuar onde estava.

Tocar numa linha fecha o diálogo — ele cumpriu o que tinha a cumprir, e mantê-lo aberto por cima do campo que a pessoa acabou de pedir para ver seria esconder o destino com o mapa. Corrigidos os campos, enviar para análise é acionado de novo, do mesmo botão de sempre: não há reenvio automático, porque a pessoa pode ter mudado de ideia no caminho, e um envio que acontece sozinho depois de uma correção é envio que ninguém pediu. Se ainda houver erro, o diálogo abre outra vez, com o que restou.

Ele não é um desenho novo. A forma já existe no próprio módulo de análise de crédito — o diálogo de tipo de pendência (apps/web/src/pages/credit-analyses/components/PendDialog.tsx) tem lista rolável com cabeçalho fixo (:328) e ações ancoradas no rodapé quando é celular (:302, com o comentário registrando que sobram ~180px com o teclado aberto). É essa forma que se reaproveita, e é por isso que a seção 7.2 abaixo descreve o mesmo comportamento: ele já foi resolvido uma vez, nesta mesma tela.

Por que um diálogo aqui, se o padrão diz "nunca modal". O docs/ui-patterns.md:506-512 encaminha validação de formulário para inline + rolagem, e proíbe toast e modal. A proibição pressupõe que o campo com erro esteja na tela — é isso que faz inline funcionar. Num formulário de duas etapas, esse pressuposto é falso metade do tempo, e o padrão não previu o caso: seguir a letra dele aqui produziria uma rolagem que não sai do lugar e um erro que ninguém encontra. A regra continua sendo inline; o diálogo é a exceção nomeada — erro fora da etapa visível —, e fora dela nenhuma tela desta spec abre modal para validação. (Decisão do usuário, 2026-08-24.)

O diálogo não é o lugar onde se conserta nada — não tem campo, não edita, não salva. Ele existe para dizer onde está o problema e levar até lá.

7.1 Desktop

Os campos não mudam de lugar, de tamanho nem de rótulo. A máscara vive dentro do campo que já existe; quem olha a tela antes e depois vê a mesma coisa, exceto pela pontuação que aparece enquanto se digita e pelo aviso abaixo do campo quando o documento não fecha.

A rolagem não disputa com a barra do topo, e vale dizer por quê — para ninguém gastar tempo compensando um problema que a casca já resolveu. A barra e o conteúdo são blocos irmãos dentro de uma coluna de altura fixa (apps/web/src/components/app-layout/index.tsx e :173): quem rola é o miolo, e a barra não fica por cima dele. Um campo trazido à vista não tem como parar atrás dela. O que continua sendo requisito é o campo parar com folga da borda superior do miolo, e o texto do erro — que fica abaixo do campo — entrar na área visível junto: parar com o campo colado no limite deixa a explicação fora da tela, e a explicação é o que a pessoa foi ler.

No formulário de análise de crédito, que é o mais longo dos três, tudo o que a seção anterior descreve acontece dentro da etapa aberta: os campos de documento e telefone ficam no bloco de cadastro do inquilino, que só aparece quando a pessoa escolhe cadastrar em vez de buscar. O diálogo do erro fora da vista é o único elemento novo, e ele só aparece a partir da etapa de documentos.

O diálogo, aqui, é caixa estreita e centrada — não ocupa a tela, porque em viewport largo o formulário atrás dele continua sendo contexto útil. A lista de campos rola dentro dele quando não couber, e as duas ações — ir ao primeiro erro e fechar — ficam no rodapé, sempre visíveis. É o mesmo desenho do diálogo de pendência que a tela já tem, que é estreito no desktop e ocupa a tela inteira no celular; a diferença entre os dois viewports é essa, e está descrita em 7.2.

Nos cadastros de imobiliária e de imóvel, que são de uma etapa só, o diálogo nunca aparece — não há etapa oculta, e portanto não há erro fora da vista. Ali a mudança é a máscara e a rolagem, e nada mais.

7.2 Mobile (375px)

Todo campo de documento e de telefone desta spec abre o teclado numérico do aparelho. Está escrito como requisito, e não como consequência da máscara, porque não é: máscara e teclado são coisas separadas, e hoje nenhum campo mascarado do app pede teclado numérico — nem os do cadastro de inquilinos, que a spec aponta como a referência a replicar. Quem implementar copiando a referência entregaria a máscara e não o teclado, e ninguém perceberia no desktop.

É o ganho que mais pesa em 375px. Digitar catorze dígitos de um CNPJ num teclado alfabético, caçando ponto, barra e hífen entre as teclas de símbolo, é o pior caminho da tela — e é o caminho de quem cadastra o inquilino em pé, na porta do imóvel, que é o uso real da imobiliária parceira. Os campos de busca ficam com o teclado normal, e isso é decisão. Eles aceitam nome ou documento, e teclado numérico é numérico: o aparelho não oferece letra, e digitar Maria Silva passaria a exigir descobrir como trocar de teclado — num campo cujo uso mais comum é justamente o nome. Não existe meio-termo aqui; a escolha é uma ou outra, e ganha o uso que não pode ficar inviável. (Decisão do usuário, 2026-08-24.)

A rolagem até o erro tem um adversário que só existe aqui: o teclado virtual. Ele cobre até metade da altura útil, e a área visível deixa de ser a janela. Um campo "trazido à vista" pelo cálculo ingênuo pode parar debaixo do teclado — e aí a pessoa vê a página se mexer e continua sem enxergar o erro, que é a pior combinação possível: movimento sem resposta. O campo precisa ficar visível com o teclado aberto, e o texto do erro, que fica abaixo dele, junto.

A barra de abas do rodapé, ao contrário do teclado, não é obstáculo — ela também é bloco irmão do miolo que rola — a coluna de altura fixa está em apps/web/src/components/mobile-shell/index.tsx, o miolo em :288 e a própria barra em :294, com flexShrink: 0 —, e não cobre conteúdo. Está dito aqui porque é o tipo de coisa que se compensa por precaução, gastando trabalho contra um problema que não existe.

O diálogo do erro fora da vista, em 375px, ocupa a largura da tela e é lista, não parágrafo: uma linha por campo, com o nome da etapa, cada uma com alvo de toque de 44px — tocar leva à etapa e ao campo. Havendo mais campos do que cabe, a lista rola dentro do próprio diálogo; a ação de ir ao primeiro erro e a de fechar ficam sempre visíveis, sem depender de rolagem.

O que não aparece no celular: nada a menos que no desktop. Não há coluna a colapsar nem ação a esconder — a mudança é toda dentro de campos que já estão nos dois viewports.

PWA: sem efeito próprio. Nenhum asset novo, nenhum comportamento offline: quem tem a tela instalada vê a mudança quando o service worker atualizar o bundle, como em qualquer tela.

7.3 Estados

  • Carregando: inalterado. A máscara é do campo e não espera resposta de servidor; a conferência do documento acontece no aparelho, sem ida à rede. Nada de "verificando documento…".
  • Vazio: um campo mascarado vazio mostra o rótulo e nada mais — sem a máscara como texto fantasma. Um campo que já exibe 000.000.000-00 antes de qualquer digitação é lido como preenchido, e quem confere um formulário rapidamente pula por cima dele.

Isto tem consequência prática: os placeholders de formato saem. O cadastro de imobiliárias traz hoje 00.000.000/0000-00 no CNPJ e (11) 3000-0000 / (11) 9 0000-0000 nos telefones — e o segundo nem coincide com a máscara que a spec descreve ((11) 98765-4321), então já hoje a tela ensina um formato e aceitaria outro. Com máscara, o formato se aprende digitando; um placeholder que contradiz a máscara é pior que placeholder nenhum.

E a regra é do app inteiro, não dos três formulários. Todo campo mascarado perde o placeholder de formato, onde quer que esteja: cadastro de inquilinos (cinco), Minha Garantidora (sete) e o passo de dados do onboarding (sete) — as três telas que já usam máscara hoje e mantiveram o placeholder. Fazer isso só nos formulários em escopo produziria exatamente a divergência entre telas que a seção 3 descreve como a causa de tudo: um app onde o mesmo campo se comporta de dois jeitos conforme por onde se chegou. É deleção de propriedade, não mudança de comportamento — nenhum outro aspecto dessas três telas muda.

O placeholder do RG fica, e a exceção tem regra: ele não recebe máscara nesta spec, e a justificativa para tirar os outros — "com máscara, o formato se aprende digitando" — não vale para um campo sem máscara. Tirá-lo removeria a única pista de formato sem pôr nada no lugar. A regra, então, é: some o placeholder de formato do campo que ganhou máscara; fica o do campo que não ganhou. Nenhum outro campo do cadastro de inquilinos muda. - Erro: é o assunto da seção 7 inteira. Resumido: erro de campo mora no campo, a tela leva ao primeiro na ordem visual, erro que não é de campo é faixa no topo, e erro em etapa oculta é o diálogo. - Sem permissão: inalterado, e nenhuma regra de acesso muda nesta spec. Quem não pode abrir uma análise de crédito continua não podendo, pelo mesmo caminho de hoje; quem vê um documento em modo leitura por não ter permissão para alterá-lo continua vendo em modo leitura — e ali o valor aparece formatado, pela regra de leitura descrita acima. (Naquele ponto específico a formatação já existe hoje; na maioria das superfícies de leitura, não — por isso é regra nova, e não constatação.) - Um estado que hoje não existe e passa a existir: o campo de documento com valor gravado antes desta spec, ao ser aberto para edição. O script de normalização (Parte III) tira a pontuação do que está no banco, mas o campo não pode depender de ele ter rodado — ele roda uma vez, e um registro pode chegar de um dump, de um ambiente ainda não convertido, ou de um caminho que ninguém previu. Então a máscara lê os dígitos e monta a pontuação, ignorando a que já estava lá: um CPF chegando como 139.778.672-88 aparece igual a um chegando como 13977867288. Sem isso, o valor pontuado entraria na máscara como catorze caracteres e apareceria truncado — e quebraria exatamente nos registros mais antigos, que são os que ninguém revisa.

8. Revisão da experiência

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/experiencia.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 2 3 4 REPROVADA
2 2 1 2 REPROVADA
3 0 5 4 REPROVADA
4 0 3 2 REPROVADA
5 1 2 2 REPROVADA
6 — — — —

Resultado: seguiu para a Parte III sem sexta rodada, por decisão do usuário (2026-08-24). O estado honesto é este: os cinco Critical/Important da rodada 5 foram corrigidos e não foram revalidados por um revisor. O risco que isso carrega é conhecido e tem nome — em quatro das cinco rodadas o que apareceu foi consequência não enumerada, e uma sexta rodada existiria para procurar a próxima. Fica registrado aqui em vez de virar surpresa no portão da implementação.

Por que este portão custou cinco rodadas, e o que cada uma achou

O padrão é um só, e vale registrar porque ele se repetiu: quase todo achado foi consequência não enumerada de uma decisão já tomada, e não decisão errada. A decisão de gravar só dígitos estava certa desde a rodada 1; o que faltava era percorrer tudo que ela alcança.

Rodada O que ela achou
1 A busca do inquilino ficou fora da jornada; o cadastro de imóvel não cabia na regra da máscara (campo único, sem tipo de pessoa; contato que aceita e-mail). Duas decisões do usuário.
2 A leitura. Gravar dígitos sem formatar na exibição deixaria o produto menos legível — nove superfícies imprimem cru. E a busca da lista de inquilinos, fora do escopo declarado, passaria a não encontrar quem digita com pontuação.
3 Cinco superfícies de leitura a mais, o teclado numérico apresentado como consequência da máscara (não é), e o placeholder do RG contado como se fosse de campo mascarado.
4 Duas superfícies de leitura no caminho mobile. E o achado que virou regra maior: as telas já mascaradas mantinham placeholder de formato, o que reproduziria a divergência entre telas que a spec existe para acabar.
5 A assimetria entre os mecanismos do erro suprimido — três se curam no componente compartilhado, um é por campo —, três superfícies do painel do SaaS, e uma terceira busca por documento.

A tabela das superfícies de leitura foi o artefato mais caro, e por um motivo que ficou escrito na própria seção: enquanto a instrução de conferência era "varra os arquivos com caminho mobile", ela perdia linha a cada rodada. Varrendo pelo dado — cpf, cnpj, phone, owner_document — as três telas do painel do SaaS apareceram de uma vez.

Decisões do usuário tomadas dentro deste laço, todas de 2026-08-24: limpar a pontuação na tela e não na API; máscara do documento do proprietário pela contagem de dígitos e contato do proprietário sem máscara; teclado numérico só nos campos de cadastro, não nos de busca; a conferência do telefone valendo também nas três telas já mascaradas; e o painel do SaaS entrando na regra de leitura formatada.

Pendências aceitas: —


Parte III — Detalhamento

Não preencher antes das revisões das seções 6 e 8 estarem aprovadas.

9. Regras de negócio

O formato canônico

  • RN1. Documento e telefone são gravados somente com dígitos. Vale para cpf, cnpj, owner_document e todas as colunas de telefone — não vale para owner_contact, que aceita e-mail (RN16c).
  • RN2. A tela envia dígitos, não o que está na máscara. O campo mascarado já desmonta o valor antes de entregá-lo ao formulário; o que a RN2 fixa é que nenhum formulário reenvia a string formatada — hoje a análise de crédito manda newRenter.doc.trim(), e .trim() só tira espaço.
  • RN3. A leitura formata. Toda superfície que exibe documento ou telefone gravado exibe pontuado. Exceção única: documento cujo comprimento não é de CPF (11) nem de CNPJ (14) sai como está — pontuar doze dígitos produziria uma pontuação mentirosa.

A entrada

  • RN4. A máscara é escolhida sem perguntar. Documento do inquilino: pelo tipo de pessoa que o bloco já tem. owner_document: pela contagem — até 11 dígitos, CPF; acima, CNPJ. Telefone: 10 dígitos fixo, 11 celular.
  • RN4a. Trocar o tipo de pessoa com o campo preenchido remascara o valor, sem apagá-lo.
  • RN5. A máscara não descarta excedente em silêncio. Dígito além do que o formato comporta produz erro, com o texto da RN9 — nunca truncamento calado.
  • RN6. O teclado é numérico nos campos de cadastro de documento e telefone. Nos campos de busca, não — eles aceitam nome (decisão do usuário, 2026-08-24).
  • RN7. Campo mascarado não exibe placeholder de formato. Vale em todo o app, e alcança as três telas já mascaradas. Campo sem máscara mantém o dele — é o caso do RG.

A conferência

  • RN8. CPF e CNPJ têm o dígito verificador conferido, na tela e na API.
  • RN8a. Na API isso é tipo, não validação avulsa: nasce AppCPF e o AppCNPJ existente passa a conferir o dígito verificador além do comprimento.
  • RN8b. Consequência declarada, e ela tem duas metades. Um CNPJ com catorze dígitos e verificador inválido é aceito hoje por imobiliária, garantidora, onboarding e inquilino pessoa jurídica, e passa a ser recusado.
  • No inquilino PJ a mudança é maior que nos outros três, e vale separar. Lá o cnpj é str | None puro hoje (apps/api/app/schemas/renter.py e :71), não AppCNPJ — então aplicar o tipo acrescenta de uma vez três recusas que não existiam: pontuação, comprimento e verificador. Nos outros três, as duas primeiras já valiam e só o verificador é novo. O mesmo desfecho da RN8e vale aqui: inquilino PJ gravado torto só volta a ser salvável depois de o CNPJ ser corrigido. Registro já gravado assim só falha quando alguém tentar atualizá-lo — leitura não valida (RN8c).
  • No dado, o custo é zero, e foi medido coluna a coluna no dev-remote: partner_agencies.cnpj (62 não nulos), tenants.cnpj (12), renters.cpf (271) — todos com verificador válido. renters.cnpj e properties.owner_document têm zero linhas não nulas, então não há inquilino PJ nem documento de proprietário a corrigir hoje. Os documentos das fixtures do e2e (apps/e2e/tests/fixtures/seeds/) também são todos válidos.
  • Nos testes, o custo é real, e são três contas — a maior delas é a do CPF. Em apps/api/tests/: 25 CPFs distintos com verificador inválido, em 20 arquivos (o AppCPF é tipo novo, então ali tudo passa a ser conferido); 23 CNPJs distintos com verificador inválido, em 17 arquivos; e 12 telefones pontuados, em 3 arquivos (test_onboarding.py, test_onboarding_cancel.py, test_guarantee_signature.py). Casos que merecem atenção porque asseveram o comportamento antigo, e não só usam um valor: test_rbac_security.py:664 afirma que a resposta devolve "222.222.222-22" pontuado — leitura que a RN1 invalida —, e test_tenant_isolation.py:645 posta CPF com verificador inválido esperando 201. Os dois arquivos são de isolamento e RBAC, que é onde o plano manda acrescentar negativos de acesso: quem seguir o plano os encontra vermelhos por outro motivo.
  • RN8c. O tipo entra apenas em schema de entrada. Schema de resposta não valida — ele descreve o que já está gravado, e validar na leitura transforma dado antigo em erro 500.
  • RN8d. Telefone e owner_document também ganham guarda na API, senão a RN1 é falsa para qualquer escrita que não venha da tela — e o invariante do CA23 poderia ser desfeito pela requisição seguinte. Nasce AppPhone (dez ou onze dígitos, sem pontuação; sem conferência de operadora, que a seção 5 tira do escopo), aplicado às colunas de telefone dos schemas de entrada de inquilino, imobiliária, garantidora e onboarding. E owner_document (apps/api/app/schemas/property.py e :76) passa a exigir dígitos com onze ou catorze posições e verificador válido. O max_length sai junto onde o tipo novo entra — o =18 do owner_document e o =20 de todas as colunas de telefone que recebem AppPhone (renter.py:53-55 e :74-76, partner_agency.py:40-42 e :52-54, tenant.py:43-45, :48-50, :58-60 e :70-72, onboarding.py:116, :119-120, :123-125, :133-135 e :145-147). Dois limites dizendo coisas diferentes sobre o mesmo campo é como um deles envelhece sem ninguém notar.
  • RN8e. Consequência do owner_document: um imóvel gravado com documento de doze ou treze dígitos (RN16d) só volta a ser salvável depois de o documento ser corrigido. É o mesmo que a tela já diz — a seção 7 acusa "documento incompleto" naquele campo —, e é o desfecho correto: o dado está errado, e a spec não inventa dígito para escondê-lo.
  • RN8f. String vazia não é documento inválido. Campo opcional recebendo "" é tratado como ausente, e não como valor de dez dígitos faltando um — devolver "deve conter 11 dígitos" para quem não preencheu nada é acusar o defeito errado. Em campo obrigatório, "" continua sendo erro — e o erro é o de campo obrigatório, não o de formato: dizer "informe 10 ou 11 dígitos" a quem não preencheu nada é acusar o defeito errado. São dois: apps/api/app/schemas/tenant.py e apps/api/app/schemas/onboarding.py.

E isso é trabalho, não consequência. Com o tipo aplicado, o pydantic não emite missing para um campo presente com valor "" — ele roda o validador e devolve o erro de formato. Nem min_length resolve, porque o texto dela é outro ("Este campo deve ter no mínimo 1 caractere(s)", apps/api/app/core/exceptions.py). O próprio validador recusa a string vazia com o texto de obrigatório: Telefone é obrigatório. Em campo opcional, "tratado como ausente" quer dizer None, não "": deixar a string vazia passar gravaria "" na coluna, que não é o formato canônico da RN1 e reapareceria na leitura como um campo que existe e está em branco. Hoje as seis telas filtram "" antes de enviar, então o caso não aparece pela interface; o tipo precisa aguentá-lo mesmo assim, porque a API não é só da interface.

  • RN9. Telefone é conferido: DDD mais 8 ou 9 dígitos. Vale em toda tela com campo de telefone — são dezenove campos em cinco telas: 1 na análise de crédito e 3 no cadastro de imobiliárias (as duas em escopo de máscara), mais 3 no cadastro de inquilinos, 6 em Minha Garantidora e 6 no onboarding (as já mascaradas). O cadastro de imóvel não entra: ele não tem campo de telefone — o "Contato do proprietário" é texto livre que aceita e-mail, e o telefone que aparece ali é o da imobiliária escolhida, em leitura (apps/web/src/pages/properties/form.tsx), coberto pela RN3. Inclui as três já mascaradas — é o único dos quatro mecanismos do erro suprimido que não se cura no componente compartilhado (seção 7).
  • RN10. Incompleto cala até o salvar; completo e inválido fala na hora. Sair do campo com o valor pela metade não acusa nada.

O erro

  • RN11. Estado de erro e mensagem de erro são inseparáveis. Não há campo marcado sem texto, nem texto sem marca.
  • RN11a. Texto de ajuda nunca substitui mensagem de erro. Havendo erro, o que se lê é o erro; a ajuda volta quando ele sai.
  • RN11b. Toda regra de validação carrega o próprio texto — os textos estão na seção 7 e são parte da spec.
  • RN11c. Nenhum campo pode desligar o erro do formulário. Quem decide se há erro é quem valida.
  • RN12. A tela leva ao primeiro erro: foco e rolagem, com o campo e o texto abaixo dele dentro da área visível. Primeiro é o primeiro na ordem visual, de cima para baixo, desempatando pela esquerda dentro da mesma linha.
  • RN12a. Vale nos três formulários em escopo, em qualquer conferência que produza erro inline — salvar, avançar de etapa, ou recusa da API.
  • RN13. O details[] da API vira erro de campo, e entra na conta da RN12.
  • RN13a. Recusa que nomeia campo ausente do formulário aberto — isto é, o que sobra depois do mapa da RN13d — vai para a faixa do topo, com o rótulo em português do campo na frente da mensagem, nunca o nome técnico: phone_mobile não diz nada a quem lê. O rótulo sai do próprio formulário — é o mesmo texto que o campo exibe quando ele existe na tela —, e o dicionário mora junto do mapa da RN13d, porque os dois respondem à mesma pergunta: o que este field é, aqui. E quando não há rótulo, o que acontece com um field que o formulário não conhece (rg, cep, ou um índice de lista, já que a API monta o nome com loc[-1] em apps/api/app/core/exceptions.py), a mensagem vai sozinha, sem nome na frente — inventar um rótulo técnico seria pior que não ter nenhum. Campo que está na tela e não está no mapa é defeito do mapa, não caso da RN13a.
  • RN13b. Erro que não é de campo nenhum continua na faixa do topo.
  • RN13c. A mensagem que a API devolve tem de estar em português e sem sobra técnica. Hoje um erro vindo de AfterValidator sai como "Value error, CNPJ deve ser enviado somente com dígitos, sem pontos, barra ou traço" — o prefixo é do pydantic v2, e o tradutor de apps/api/app/core/exceptions.py não o alcança porque _PYDANTIC_MESSAGES só tem a chave value_error.email (:33), que é de pydantic v1 e nunca casa. Isso é invisível hoje só porque o web descarta details[] — a RN13 o torna visível, e sem a RN13c a spec promoveria uma string em inglês a texto de erro inline, contradizendo a RN11b. O prefixo sai no tradutor, não em cada validador.
  • RN13d. O field da API é casado com o campo da tela por um mapa declarado, não por coincidência de nome. A API nomeia cpf, cnpj, phone_mobile; o formulário de análise de crédito guarda os erros sob chaves próprias — renterDoc, renterName, propStreet (apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:372-387) — enquanto posta cpf/cnpj/phone_mobile (:410-417). O mapa cobre os campos que a pessoa consegue consertar no lugar — e não "tudo o que o formulário posta", que era o que uma versão anterior desta regra afirmava e não se sustentava. Campo digitável de fora é defeito do mapa; campo que o formulário envia mas ninguém escreve (property_id, expected_updated_at, renter_type, o participants aninhado da criação) cai na RN13a de propósito: não há campo para marcar, e mandar consertar o que ninguém digitou é pior que a faixa do topo.

O formulário posta em quatro entidades — inquilino (:410-417), imóvel (:431-439), a própria análise (:454-462 na criação e :564-567 na atualização) e participante (:471, :477, :484, :495, :500 e :606). Os campos digitáveis de cada uma:

Corpo field da API chave do formulário
inquilino cpf, cnpj renterDoc
inquilino name renterName
inquilino phone_mobile, email, date_of_birth o campo correspondente do bloco
imóvel street, number, city, state propStreet, propNumber, propCity, propState
imóvel owner_name, property_type, area_m2 o campo correspondente do bloco
imóvel partner_agency_id agency
análise rental_value rental
análise guarantee_months months
análise condo_fee, property_tax, start_date, end_date, notes o campo correspondente
participante role, renter_id o controle correspondente da linha

A linha do participante é a exceção inteira, e ela tem causa: a API monta o nome do campo com loc[-1] (apps/api/app/core/exceptions.py), então participants[0].declared_income e participants[3].declared_income chegam idênticos — o índice, que é o que diria de qual linha se trata, morre antes de o web ver. E na criação o participante de índice 0 é o inquilino principal, cuja renda fica no bloco do inquilino, não numa linha de participante. Sem índice não há como escolher o controle certo, e apontar para o errado é pior que não apontar: declared_income, role e renter_id recusados vão para a faixa do topo pela RN13a, com os rótulos "Renda declarada", "Papel do participante" e "Inquilino". Corrigir isso de verdade seria a API devolver o caminho completo em vez do último segmento — mudança de contrato de erro que vale para o produto inteiro, e que esta spec não abre.

Revisado durante a implementação, e o motivo importa. A tabela acima mandava role e renter_id para "o controle correspondente da linha", e abria exceção só para declared_income — mas os três campos vêm do mesmo participants[i] e perdem o índice pelo mesmo loc[-1]. A exceção nunca foi sobre a renda; era sobre a linha. Uma revisão apontou a divergência entre o código e a tabela; o código estava certo, e o que faltava era a regra dizer o que ela já queria dizer.

Sem o mapa, cpf seria lido como "campo que não existe neste formulário" e cairia na faixa do topo — o oposto do que a RN13 quer, na tela que originou esta spec. - RN13e. Nos outros dois formulários não há mapa, e é por eles registrarem os campos com o nome da API (apps/web/src/pages/agencies/form.tsx, apps/web/src/pages/properties/form.tsx). A RN13a só vale para o que sobrar depois do mapa. - RN14. Erro em etapa não visível abre diálogo, e só nesse caso. Ele lista os campos agrupados por etapa, cada linha navega, e há uma ação para o primeiro. Havendo erro nas duas etapas, o diálogo abre e lista as duas. - RN14a. A conferência da etapa 1 passa a rodar ao enviar da etapa 2 — hoje é pulada, e sem isso o diálogo não teria o que listar. - RN14b. Tocar numa linha fecha o diálogo. Não há reenvio automático.

A busca

  • RN15. Campo de busca por documento limpa a pontuação antes de consultar, e só quando o termo não contém letra — senão a busca por nome perderia caracteres.
  • RN15a. São três buscas: a do inquilino dentro da análise de crédito, a da lista de inquilinos, e a da lista de garantidoras no painel do SaaS (esta filtra no navegador).
  • RN15b. A busca de imobiliárias já normaliza no servidor e não muda. A de imóveis não consulta documento.
  • RN15c. Pontuação, aqui, é o que não é dígito nem letra. E há duas armadilhas neste caminho, as duas descobertas no portão:

Primeira: os curingas do ilike são escapados — no servidor, e não na tela. % e _ têm significado dentro da consulta, e nenhum deles é o que a pessoa quis dizer ao digitar. Sem escapar, quem digita % recebe a lista inteira do escopo: o repositório monta %{termo}%, e um % no meio casa com tudo (apps/api/app/repositories/renter_repo.py).

O escape mora no repositório, com escape= explícito, e não na limpeza da tela. Três razões, e a terceira é a que decide:

  1. Escapar na tela não funciona sem o servidor cooperar. O .ilike() daquele repositório é chamado sem cláusula ESCAPE. No PostgreSQL a barra invertida é o padrão e um \% vindo do cliente até funcionaria; no SQLite, que é o dialeto da suíte da API, não há escape padrão — o \_ passaria a casar com uma barra invertida literal, e o teste diria uma coisa enquanto a produção diz outra.
  2. _ é legítimo. Aquela busca cobre também email, e sublinhado em e-mail é comum. Remover o curinga em vez de escapá-lo quebraria a busca por e-mail; escapar preserva.
  3. No servidor conserta de uma vez todos os chamadores daquela busca, e não só as três desta spec. (Vale ser exato: são os chamadores de search_with_count do inquilino. As outras buscas do produto — credit_analysis_repo.py:87, partner_agency_repo.py:69-71, property_repo.py:42, user_repo.py:187 — montam %{termo}% sem escape e continuam assim; são a mesma classe de defeito, fora do escopo desta spec, e ficam registradas aqui para quem for atrás.)

Consequência declarada: a API muda, ainda que pouco — search_with_count ganha escape=. O §12 diz isso.

Segunda: termo que a limpeza esvazia é enviado cru, e não vazio. Digitando só % ou ..., não há letra, a limpeza roda, e o que sobra é string vazia — que quer dizer "sem filtro" nos dois mecanismos: no repositório o filtro textual inteiro cai (apps/api/app/repositories/renter_repo.py, if term:) e devolve a página completa do escopo, e no painel do SaaS includes("") é verdade para todos. A tela mostraria a lista inteira para quem pediu uma coisa específica — o oposto do que se pediu.

As duas armadilhas se resolvem juntas, e é por isso que a ordem importa: manda-se o termo original, e é o escape do servidor que torna isso seguro. Um % original vira curinga literal, e nenhum inquilino tem % no nome, no documento ou no e-mail — então a busca não encontra nada, que é a resposta honesta. Sem o escape, esse mesmo % casaria com tudo; sem o fallback, o termo vazio derrubaria o filtro. Uma sem a outra produz exatamente o defeito que a outra evita.

E o painel do SaaS não escapa nada, porque ali não há curinga: apps/web/src/admin/pages/tenants/TenantListPage.tsx:143-147 filtra com String.prototype.includes, em memória. Escapar ali só faria uma garantidora com _ no nome fantasia deixar de ser encontrada.

O alargamento textual acontece dentro do escopo, com uma ressalva. tenant_id e partner_agency_id entram como condições AND antes do or_ de texto — mas o filtro de tenant é aplicado só if tenant_id: (renter_repo.py:40-41), e para o ADMIN_GLOBAL ele é None. Ou seja, para esse papel não há escopo a alargar porque não há escopo: é a mesma dívida que a ressalva do §10.1 registra, e as duas passagens falam do mesmo fato.

O dado já gravado

  • RN16. Um script converte o que está gravado para o formato da RN1.
  • RN16a. Colunas que ele toca: renters (cpf, cnpj, phone_landline, phone_mobile, phone_whatsapp), partner_agencies (cnpj e os três telefones), tenants (cnpj, os três telefones e os três contact_phone_*), properties (owner_document).
  • RN16b. É idempotente — rodar duas vezes não muda nada na segunda.
  • RN16c. Não toca owner_contact, que aceita e-mail: limpar pontuação ali transformaria contato@imob.com.br em contatoimobcombr.
  • RN16d. Não inventa dígito e não descarta linha. Documento com comprimento inválido perde só a pontuação e continua como está — corrigi-lo é decisão de quem o cadastrou, não do script.
  • RN16e. Ele não roda sozinho no deploy. É comando explícito, como o seed — ver a seção 12.
  • RN16f. Colisão com índice único aborta o script, sem gravar nada. Duas colunas que ele toca são únicas — tenants.cnpj (apps/api/app/models/tenant.py:25) e o par (tenant_id, cnpj) de partner_agencies (apps/api/app/models/partner_agency.py:20) —, e normalizar pode fundir dois valores que hoje são distintos. Medido: não há CNPJ pontuado no dev-remote hoje, então é risco teórico — mas a decisão precisa existir antes de o caso aparecer. Ele para, relata o par que colidiu e não escreve nada, porque escolher qual dos dois sobrevive é decisão de quem cadastrou, não do script.

  • RN16g. O padrão dele é relatar, não escrever. python -m app.db.normalize_documents sem argumento conta e imprime quantas linhas mudariam por coluna, e sai sem tocar em nada; escrever exige --aplicar. É o único componente desta entrega com acesso direto ao banco e sem filtro de tenant, reescrevendo coluna de todas as garantidoras — um comando assim que escreve por omissão é um comando que alguém roda por engano. E o modo relatório é o que torna o CA23 conferível antes de mexer no dado.

10. Autorização e isolamento

Esta spec não cria endpoint, não cria permissão e não muda nenhuma regra de acesso. O que ela faz é formato de dado, validação de campo e apresentação. Mas silêncio aqui não é resposta, e há três superfícies que merecem exame.

10.1 Matriz de papéis

Ninguém passa a alcançar o que não alcançava. A matriz não muda — o que muda é a forma do que cada um já via.

Papel Pode Não pode
ADMIN_GLOBAL ver CNPJ e telefone da garantidora formatados no painel do SaaS, onde já os via crus nada muda para ele nesta spec — ver a ressalva abaixo, que é dívida anterior e não desta entrega
ADMIN_TENANT as mesmas telas de hoje, com máscara na entrada e formato na leitura alcançar dado de outra garantidora
ANALYST idem idem
OPERATOR idem idem
VIEWER idem, em leitura escrever em campo nenhum — a máscara não afrouxa isto
AGENCY_USER cadastrar inquilino e imóvel pela análise, com máscara e conferência alcançar inquilino ou imóvel de outra imobiliária — o filtro por partner_agency_id é o de hoje

Uma ressalva honesta sobre o ADMIN_GLOBAL, que não é desta spec e não pode ser escrita como se estivesse resolvida. O rbac_seed.py:156 concede a ele todas as permissões menos duas de dossiê — inclusive renters:list —, e o renter_repo.search_with_count só aplica Renter.tenant_id == tenant_id quando há tenant_id. Ou seja, GET /api/v1/renters já devolve inquilino de todas as garantidoras para esse papel, hoje, antes desta spec. Esta spec não cria, não amplia e não reduz esse alcance — ela só formata o que o painel do SaaS já exibe, e o painel não lista inquilino. Está escrito aqui porque uma matriz de acesso que registra como garantia o que é dívida aberta é pior que matriz nenhuma: ela responde "sim" a isto está protegido? sem proteger, que é exatamente o achado da spec 0035.

E o usuário decidiu, em 2026-08-24, que isto é defeito — o SaaS admin administra a plataforma, e inquilino é dado de negócio do cliente. Vai virar um /fix próprio quando esta spec fechar, com diagnóstico e portão, e não de carona aqui: são duas coisas diferentes, e misturá-las faria um git revert de emergência levar junto o que não tem relação. Registrado aqui para não se perder no caminho.

O negativo que vira teste: o AGENCY_USER da Imobiliária B, buscando na análise de crédito pelo documento exato de um inquilino da Imobiliária A, não o encontra — a RN15 muda o termo enviado, não o escopo da consulta. É o teste que prova que a limpeza da pontuação não virou um caminho para ler o alheio.

10.2 Permissões

Nenhuma permissão nova, e nenhuma alterada. Não há o que acrescentar ao rbac_seed.py, e portanto o make seed do deploy não muda nada por causa desta spec.

Os endpoints tocados continuam exigindo exatamente o que exigem hoje — a mudança é no tipo do campo (RN8a), não no porteiro. Nenhum endpoint muda de método, path ou permissão.

10.3 Isolamento por garantidora (tenant)

  • Nenhuma FK nova é recebida no corpo, então não há resolução de escopo nova a fazer.
  • A RN15 é a única que mexe em consulta, e mexe no termo, não no filtro: a busca continua passando pelo mesmo repositório, com o mesmo tenant_id. Limpar a pontuação não alarga o conjunto de linhas visíveis — alarga o conjunto de termos que casam com as linhas que já eram visíveis.
  • A busca do painel do SaaS (RN15a) filtra no navegador, sobre a lista que a API já devolveu àquele papel. Não há consulta nova, e nada chega ao cliente que não chegasse.

10.4 Isolamento por imobiliária

Inalterado. O AGENCY_USER alcança o que o partner_agency_id derivado da sessão permite, como hoje. A RN15 não é atalho: o termo limpo é comparado dentro do mesmo escopo.

10.5 Plataforma × cliente (SaaS admin)

O painel do SaaS ganha formatação, e nada mais — e vale ser explícito porque a fronteira é justamente aqui.

  • O que ele passa a mostrar: CNPJ e telefone da garantidora, formatados, nas três telas que hoje os mostram crus. Dado de plataforma, não de negócio do cliente.
  • O que ele não passa a mostrar: nada. Nenhum campo novo, nenhuma linha nova, nenhuma contagem nova. Se depois desta spec o painel exibir documento de inquilino, de proprietário ou de tomador de garantia, é defeito — a fronteira da spec 0034 continua valendo inteira.
  • O ADMIN_GLOBAL não tem filtro de tenant, e é por isso que a frase acima importa: a regra da RN3 é "toda superfície que exibe documento gravado formata", e alguém poderia ler isso como licença para exibir documento onde ele não estava. Não é. A RN3 muda a forma do que já está na tela; ela não é autorização para pôr nada nela.

10.6 O script de normalização, que é o ponto que mais merece exame

Ele roda fora da API, com acesso direto ao banco, e portanto sem filtro de tenant nenhum — por desenho. É a mesma natureza do seed, e as mesmas cautelas valem:

  • É comando de operação, não rota. Não existe endpoint que o dispare, e não deve existir: um endpoint que reescreve coluna de todas as garantidoras seria uma superfície de ataque sem uso legítimo pela interface.
  • Ele não lê para fora nem exporta nada — só reescreve, no lugar, o mesmo valor sem pontuação. Não há saída de dado de cliente.
  • Ele não distingue tenant, e isso é correto aqui: o formato canônico é do produto, não do cliente. Distinguir seria deixar garantidoras em formatos diferentes, que é o defeito que a spec existe para acabar.
  • Quem pode rodá-lo é quem já tem acesso ao servidor de produção — a mesma porta do make seed e do make migrate. Não há elevação de privilégio: quem chega lá já podia escrever no banco.

11. Impacto por app

apps/api

  • Modelos/migrations: sem migration. Nenhuma coluna nasce, muda de tipo ou de tamanho — o formato canônico da RN1 cabe folgado no que já existe (apps/api/app/models/renter.py:81-88, partner_agency.py:25-29, tenant.py:25-41, property.py:43). O dado gravado é convertido pelo script da RN16, não por migration: ele é comando de operação, roda quando alguém manda, e não tem downgrade porque não há como devolver uma pontuação que ninguém guardou.
  • Schemas — é aqui que a RN8 vive. Em apps/api/app/schemas/common.py, ao lado do AppCNPJ que já existe (_validate_cnpj em :30-36, o tipo em :39): nasce AppCPF, e o _validate_cnpj passa a conferir o dígito verificador além do comprimento. O algoritmo entra escrito — são poucas linhas, estáveis desde sempre, e não vale uma dependência nova; o apps/web usa cpf-cnpj-validator (apps/web/package.json:22), e as duas implementações precisam concordar. O que prova a concordância é uma lista compartilhada — um punhado de documentos válidos e inválidos, versionada uma vez e exercitada dos dois lados. Ela nasce como JSON — não como módulo Python nem como TypeScript —, sob apps/api/tests/, e o e2e a lê do mesmo arquivo. O formato é parte da decisão: apps/api/tests/ é pacote Python e apps/e2e é TypeScript, e qualquer formato que só um dos dois leia produz as duas cópias que a lista existe para evitar. Sem ela, "concordam" é opinião.
  • Onde os tipos são aplicados, e onde NÃO — a distinção é o ponto mais importante desta seção. apps/api/app/schemas/renter.py tem quatro classes, e o tipo entra em duas: RenterCreate (:48-49) e RenterUpdate (:70-71), que são entrada.

RenterSummary (:12-17) e RenterResponse (a partir de :87) ficam como estão, e isto é regra (RN8c). As duas são resposta — o RenterSummary é montado nos serviços (apps/api/app/services/credit_analysis_service.py:890 e credit_analysis_participant_service.py:343) e viaja como o campo renter do participante da análise. Um AfterValidator ali validaria na leitura: as 99 linhas de renters.cpf hoje pontuadas estourariam ValidationError, e abrir uma análise de crédito daria 500 até alguém rodar o script — que, pela RN16e, não roda sozinho no deploy. É o oposto do que a RN8b promete quando diz "leitura não valida".

  • Os seis usos de AppCNPJ que já existem herdam a conferência do verificador sem mudar de linha — e todos os seis já são de entrada (partner_agency.py:38 e :50 são Create/Update; tenant.py:41 e :56 idem; onboarding.py:115 e :131 também).
  • Regras no service: nenhuma. Validação de formato é do schema; nenhum service ganha regra nova.
  • Repositório — a limpeza do termo da RN15c: apps/api/app/repositories/renter_repo.py escapa % e _ antes do ilike e passa escape="\\" explícito. Os outros dois repositórios de busca não mudam: o de imobiliárias já normaliza, e o de imóveis não consulta documento (RN15b).
  • Tradução do erro do pydantic: apps/api/app/core/exceptions.py remove o prefixo Value error, que o pydantic acrescenta à mensagem do AfterValidator. Sem isso a frase que a tela exibe dentro do campo começa por sobra técnica — e a RN11b diz que o texto é o da tabela da seção 7, não o que a biblioteca montou.
  • O script da RN16: apps/api/app/db/normalize_documents.py, ao lado do init_db.py e do rbac_seed.py, com a mesma forma de invocação (python -m app.db.normalize_documents). Imprime quantas linhas mudou por coluna, e não altera nada em modo de conferência.
  • Testes existentes que precisam mudar (RN8b): os 23 CNPJs com verificador inválido em 17 arquivos, e os 12 telefones pontuados em apps/api/tests/test_onboarding.py, apps/api/tests/test_onboarding_cancel.py e apps/api/tests/test_guarantee_signature.py. Trocar por documento válido e telefone só-dígitos — é a primeira coisa a fazer na fase da API, senão a suíte fica vermelha por motivo que não é defeito.
  • Testes novos: o verificador aceitando documento válido e recusando inválido; o AppCNPJ recusando o pontuado, como já faz, e agora também o verificador errado; o script idempotente (segunda execução muda zero linhas), preservando owner_contact e não alterando documento de comprimento inválido além da pontuação; e os negativos de acesso de 10.1 — o AGENCY_USER de outra imobiliária não encontra o inquilino nem buscando pelo documento exato.

apps/web

  • Componentes: apps/web/src/shared/components/MaskedField.tsx é onde três dos quatro mecanismos do erro suprimido morrem — as linhas :99 e :100 (RN11a e RN11c) alcançam todas as telas de uma vez. apps/web/src/shared/utils/masks.ts ganha o que falta para a RN5 e para a RN3.
  • Páginas com máscara nova: apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx (documento e telefone do inquilino novo), apps/web/src/pages/agencies/form.tsx (CNPJ e três telefones), apps/web/src/pages/properties/form.tsx (owner_document, com a máscara por contagem da RN4).
  • Páginas já mascaradas, que ganham conferência e perdem placeholder: apps/web/src/pages/renters/form.tsx, apps/web/src/pages/my-garantidora/index.tsx, apps/web/src/pages/onboarding/StepTenant.tsx — telefone (RN9) e placeholder (RN7) nas três, mais o dígito verificador do CNPJ nas duas últimas (RN8). O apps/web/src/pages/renters/form.tsx já tem validate de documento; apps/web/src/pages/my-garantidora/index.tsx e apps/web/src/pages/onboarding/StepTenant.tsx tinham CNPJ editável com rules={{ required }} e nada mais — era o estado antes desta spec, e é o que esta regra veio corrigir. Deixá-las de fora faria a RN8 — "na tela e na API" — ser falsa em duas telas, e o verificador errado só apareceria como 422.
  • Leitura formatada (RN3) — a enumeração com arquivo e linha está na seção 7, e é ela que vale. Os agrupamentos abaixo dizem onde o trabalho cai e não somam o total de propósito: uma versão anterior desta seção dava um número, o número não batia com a tabela, e o CA3 cobrava telas que o número deixava de fora. Quem for implementar percorre a tabela, não este parágrafo. As pastas alcançadas são pages/agencies/, pages/renters/, pages/properties/, pages/credit-analyses/, pages/guarantees/ e admin/pages/. Três pontos estão dentro do próprio formulário de análise de crédito; sete locais no painel do SaaS, em três telas (apps/web/src/admin/pages/tenants/TenantDetailPage.tsx, :143-145 e :154-156, TenantListPage.tsx:293 e :409, apps/web/src/admin/pages/dashboard/AdminDashboardPage.tsx e :206); quatro no detalhe do inquilino, porque ele tem dois layouts (apps/web/src/pages/renters/show.tsx:131, :145-147, :199 e :217-219); e dois no dossiê da garantia (apps/web/src/pages/guarantees/components/GuaranteeSections.tsx:90 e :157) — a única pasta alcançada pela RN3 que nenhum outro item do plano toca, e por isso a mais fácil de esquecer.
  • Buscas (RN15): a limpeza entra na chamada, não no rótulo do campo — apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx (searchRenters), apps/web/src/pages/renters/index.tsx:104 (o search que vai para a listagem) e apps/web/src/admin/pages/tenants/TenantListPage.tsx:152 (o filtro no navegador).
  • Referência que não se altera: apps/web/src/pages/credit-analyses/components/PendDialog.tsx é a forma que o diálogo da RN14 reaproveita — lista rolável com cabeçalho fixo e ações ancoradas no rodapé em 375px. Ele não é modificado; é citado para o diálogo novo não nascer com desenho próprio.
  • data-testid: o diálogo da RN14 é o único elemento novo — credit-analysis-erros-dialog, credit-analysis-erros-item-<campo>, credit-analysis-erros-ir-ao-primeiro, credit-analysis-erros-fechar. Os campos que ganham máscara mantêm os testids que já têm.
  • Sider: sem impacto. Nenhum módulo novo, nenhuma rota nova.
  • Menu e rota por permissão: sem impacto — nada muda de visibilidade.
  • Responsividade & PWA: a RN12 tem comportamento próprio em 375px (o teclado virtual, na seção 7.2) e a RN14 muda a forma do diálogo entre os viewports. PWA sem efeito próprio: nenhum asset novo, nenhum comportamento offline.

A varredura das enumerações, e o que ela conferiu

Quatro rodadas do portão acharam quatro achados da mesma classe — consequência não percorrida —, um por rodada. Antes da quinta, as enumerações foram percorridas mecanicamente, uma a uma, contra o código. O resultado fica aqui para o revisor não refazer o trabalho e gastar a rodada no que só ele vê.

Enumeração Como foi varrida Resultado
Superfícies de leitura (RN3) todo render de .cpf, .cnpj, .owner_document e phone_* em apps/web/src/**/*.tsx, separando binding de formulário de exibição completa — nada novo. Confirmado que apps/web/src/pages/properties/index.tsx não exibe o documento no cartão do celular (só owner_name, :426-427), e que apps/web/src/pages/renters/index.tsx:49-50 são helpers que servem os dois layouts, então um ponto cobre ambos
Campos de telefone (RN9) contagem por formulário corrigida: são 19 campos em 5 telas, não os 13 que uma versão anterior desta seção dizia. O cadastro de imóvel não tem campo de telefone
Colunas do script (RN16a) Mapped[str…] com nome de documento ou telefone em apps/api/app/models/ completa — 17 colunas em 4 tabelas (inquilino 5, imobiliária 4, garantidora 7, imóvel 1), exatamente as listadas
Schemas de entrada com telefone (RN8d) campos phone com Field( em apps/api/app/schemas/ completa — renter.py, partner_agency.py, tenant.py, onboarding.py, e nenhum outro
Endpoints alcançados (§12) consumidores dos schemas alterados em apps/api/app/api/v1/routers/ completa na rodada 4 pelo próprio revisor, inclusive os dois com porteiro fora do decorador

O que esta varredura não cobre, e é honesto dizer: ela confere que as listas batem com o código de hoje. Não confere julgamento — se uma regra está certa, se um texto é o melhor, se uma decisão é a boa. Isso continua sendo do portão.

apps/e2e

  • Specs: um spec novo para o ciclo de erro do formulário de análise de crédito — máscara aplicada, documento inválido barrado, rolagem até o primeiro erro na ordem visual, e o diálogo do erro fora da vista, nos dois viewports. Mais um cenário curto por formulário nos outros dois, e um de leitura formatada. O que não dá para provar por e2e — o teclado numérico da RN6 — fica declarado como verificação manual.
  • Seeds: nenhum novo. O seed_e2e já cria inquilino com documento; o que muda é o formato, e o próprio seed passa a gravar dígitos.

12. Contrato de API

Nenhum endpoint novo, nenhum removido, nenhuma mudança de método, path ou permissão. O que muda é o tipo de alguns campos, o efeito disso na resposta de erro, e uma mudança de comportamento no repositório de inquilinos: o ilike da busca passa a receber escape= explícito (RN15c). Ela não altera contrato — mesma requisição, mesma resposta —, mas altera o que a busca encontra quando o termo traz % ou _, e por isso está declarada aqui em vez de ficar como detalhe de implementação.

POST /api/v1/renters          (permissão: renters:create — inalterada)
PATCH /api/v1/renters/{id}    (permissão: renters:update / renters:update_sensitive — inalteradas)

{ "cpf": "13977867288", "cnpj": null, "phone_mobile": "11987654321" }

Passa a ser recusado, com 422:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Alguns campos precisam ser corrigidos. Verifique os detalhes abaixo e tente novamente.",
    "details": [
      { "field": "cpf", "message": "CPF deve ser enviado somente com dígitos, sem pontos ou traço" }
    ]
  }
}
O que foi enviado Status details[].message
139.778.672-88 422 CPF deve ser enviado somente com dígitos, sem pontos ou traço
13977867289 (verificador errado) 422 CPF inválido — confira os números
1397786728 (dez dígitos) 422 CPF deve conter exatamente 11 dígitos (somente números, sem formatação) — mesmo texto do CNPJ, logo abaixo, e pelo mesmo motivo: já existe e não muda
1198765432 num campo de telefone 422 Telefone deve conter 10 ou 11 dígitos, somente números

Os textos são parte da spec, pela RN11b, e o do verificador é o mesmo da tela, de propósito: a pessoa não deve ler duas frases diferentes para o mesmo defeito conforme onde ele foi pego. Os do CNPJ, escritos por extenso para o CA13a poder cobrá-los palavra por palavra:

O que foi enviado details[].message
12.345.678/0001-95 CNPJ deve ser enviado somente com dígitos, sem pontos, barra ou traço (já existe hoje, não muda)
verificador errado CNPJ inválido — confira os números
menos de 14 dígitos CNPJ deve conter exatamente 14 dígitos (somente números, sem formatação) — é o texto que já existe (apps/api/app/schemas/common.py) e não muda

É o mesmo formato de erro de hoje (apps/api/app/core/exceptions.py:157-165), e o ApiError do web já carrega details (apps/web/src/providers/api-fetch.ts:23 e :131). O que a RN13 acrescenta é consumi-la onde ela hoje é jogada fora: o cadastro de imobiliárias já a transforma em erro de campo (apps/web/src/pages/agencies/form.tsx) e serve de modelo; a análise de crédito e o cadastro de imóvel é que a descartam.

Os demais endpoints alterados, com a permissão de cada um — todas inalteradas, e todas já declaradas hoje:

Endpoint Permissão O que muda
PATCH /api/v1/partner-agencies/{id} partner_agencies:update (apps/api/app/api/v1/routers/partner_agencies.py:101) verificador no CNPJ, AppPhone nos telefones
PATCH /api/v1/tenants/{id} tenants:update (apps/api/app/api/v1/routers/tenants.py:95) idem, mais os contact_phone_*
POST /api/v1/onboarding/tenant também não é require_permission: get_current_user no router (apps/api/app/api/v1/routers/onboarding.py:52) mais duas guardas no serviço — conta ainda sem garantidora e e-mail verificado (apps/api/app/services/onboarding_service.py:136-145) verificador no CNPJ (RN8b) e AppPhone no phone_mobile, que ali é obrigatório (apps/api/app/schemas/onboarding.py)
PATCH de onboarding — dados da garantidora tenants:update (apps/api/app/api/v1/routers/onboarding.py:73) idem
onboarding — imobiliária partner_agencies:create (apps/api/app/api/v1/routers/onboarding.py:138) idem
POST /api/v1/properties properties:create (apps/api/app/api/v1/routers/properties.py:136) owner_document guardado (RN8d)
PATCH /api/v1/properties/{id} properties:update (apps/api/app/api/v1/routers/properties.py:178) idem
POST /api/v1/tenants não é require_permission: get_current_user no router (apps/api/app/api/v1/routers/tenants.py:59) mais _require_admin_global no serviço (apps/api/app/services/tenant_service.py:23) verificador no CNPJ (RN8b) e AppPhone no phone_mobile, que ali é obrigatório (apps/api/app/schemas/tenant.py)

Nenhum deles ganha ou perde porteiro — a mudança é o tipo do campo. Dois endpoints têm o porteiro escrito por extenso de propósito — POST /api/v1/tenants e POST /api/v1/onboarding/tenant. São os únicos cujo controle não está no decorador do router, e omiti-los faria um leitor concluir que estão desprotegidos. O segundo é onde uma garantidora nova digita o primeiro CNPJ da vida dela, e é o que a RN8b alcança primeiro.

Migration: nenhuma. Script: python -m app.db.normalize_documents, executado na box como o seed. Não entra no /ship (RN16e) — é operação pontual, e um passo que reescreve coluna em todo deploy é passo que ninguém revisa.

A ordem do deploy importa, e é esta: sobe a imagem, depois roda o script. Entre um e outro há uma janela em que o código novo convive com dado pontuado — e ela é inofensiva por construção, porque a RN8c mantém a leitura sem validação: as telas exibem o valor antigo, a busca não o encontra pelos dígitos, e nada estoura. Inverter a ordem também funciona; o que não pode é esquecer o script, e é por isso que ele tem critério próprio (CA23).

13. Critérios de aceite

O formato canônico

  • [x] CA1. Cadastrando um inquilino pela análise de crédito com CPF digitado como 139.778.672-88, a coluna cpf guarda 13977867288. (RN1, RN2)
  • [x] CA2. O mesmo CPF cadastrado pela tela de inquilinos e pela análise de crédito fica gravado idêntico nas duas. (RN1)
  • [x] CA3. Na lista de imobiliárias, no detalhe do imóvel e na linha de corresponsável da análise de crédito, o documento aparece pontuado. (RN3)
  • [x] CA4. Um owner_document com doze dígitos aparece com os doze dígitos, sem pontuação inventada. (RN3, exceção)

A entrada

  • [x] CA5. No bloco de cadastro do inquilino, trocar de pessoa física para jurídica com o campo preenchido remascara o valor sem apagá-lo. (RN4a)
  • [x] CA6. Colando +55 11 98765-4321 num campo de telefone, o campo acusa erro e não guarda um número truncado. (RN5)
  • [x] CA7. Nenhum campo mascarado por esta spec exibe placeholder de formato; o campo de RG continua exibindo o dele. (RN7)

    O CEP é exceção declarada, e não por esquecimento. Ele formata enquanto se digita (CepField.tsx) e mantém o 00000-000, mas a seção 5 o põe fora de escopo e a enumeração da 7.3 não o lista: ele não ganhou máscara aqui, e a RN7 fala do campo que ganhou. Tirar o placeholder dele durante a implementação foi mudança fora do escopo declarado — feita e revertida —, e é o tipo de coisa que faz uma spec virar outra sem ninguém decidir. Se o CEP tiver de mudar, é decisão de outra spec.

A conferência

  • [x] CA8. 13977867289 é recusado na tela, com CPF inválido — confira os números, sem chamar a API. (RN8)
  • [x] CA9. POST /api/v1/renters com "cpf": "13977867289" responde 422 nomeando o campo cpf. (RN8, RN8a)
  • [x] CA10. PATCH de imobiliária com CNPJ de catorze dígitos e verificador inválido responde 422 — hoje responde 200. (RN8b)
  • [x] CA10c. POST /api/v1/renters com "phone_landline": "" num campo opcional grava sem erro — string vazia é ausência, não telefone inválido. (RN8f)
  • [x] CA10d. POST /api/v1/tenants com "phone_mobile": "" — campo obrigatório — responde 422 com Telefone é obrigatório, e não com a mensagem de formato. (RN8f)
  • [x] CA10a. POST /api/v1/renters com "phone_mobile": "(11) 98765-4321" responde 422; com "11987654321", grava. (RN8d)
  • [x] CA10b. PATCH de imóvel cujo owner_document gravado tem doze dígitos responde 422 enquanto o documento não for corrigido, nomeando o campo — e o GET do mesmo imóvel continua respondendo 200. (RN8e, RN8c)
  • [x] CA11. Salvando o cadastro de inquilinos com telefone de oito dígitos, o campo acusa Telefone incompleto — informe DDD e número. (RN9) — é o critério do defeito relatado, e é na tela onde ele foi visto.
  • [x] CA12. Digitando três dígitos num campo de CPF e saindo dele, nada é acusado; clicando em salvar, é. (RN10)

O erro

  • [x] CA13. Nenhum campo do app fica vermelho exibindo texto de ajuda: havendo erro, o texto abaixo do campo é o do erro. (RN11, RN11a) — verificável forçando uma regra sem mensagem.
  • [x] CA13a. Cada texto de erro exibido pelos campos desta spec é um dos da tabela da seção 7, palavra por palavra — nenhum foi inventado na implementação. (RN11b)
  • [x] CA14. Salvando a análise de crédito com dois campos inválidos, a tela rola e foca o primeiro na ordem visual, e o texto do erro fica visível junto com o campo. (RN12)
  • [ ] CA15. Em 375px, com o teclado virtual aberto, o campo focado pela RN12 e o texto do erro abaixo dele continuam visíveis. (RN12, seção 7.2)

    Sem marca, e o motivo é do ferramental. Playwright não abre teclado virtual, e navegador desktop em 375px tem visualViewport.height === innerHeight — o ramo que este critério descreve nunca é exercitado. A implementação foi corrigida duas vezes nas rodadas 9 e 10 (a medida acontecia antes de o teclado abrir; e a segunda medição disparava também quando a barra de endereço reaparecia) e não foi observada em aparelho. Marcar aqui seria afirmar o que ninguém viu. - [x] CA16. Uma recusa da API que nomeia campos coloca cada mensagem no seu campo, e a rolagem vai ao primeiro deles. (RN13) - [x] CA16a. A rolagem acontece nas três situações, e nos três formulários em escopo: ao salvar, ao avançar de etapa e ao receber a recusa da API. (RN12a) - [x] CA16d. Uma recusa da API nomeando cpf, no formulário de análise de crédito, aparece no campo de documento do inquilino — não na faixa do topo. (RN13d) - [x] CA16e. A mesma recusa, nos cadastros de imobiliária e de imóvel, cai no campo correspondente sem mapa nenhum, porque ali o nome é o mesmo da API. (RN13e) - [x] CA16b. Uma recusa que nomeia campo ausente do formulário aberto aparece na faixa do topo com o nome do campo na frente da mensagem — não some, e não vira frase genérica. (RN13a) - [x] CA16c. Uma falha de rede ao salvar continua aparecendo na faixa do topo, sem tentar rolar até campo nenhum. (RN13b) - [ ] CA17. Enviando para análise da etapa 2 com o documento do inquilino inválido na etapa 1, abre o diálogo, listando o campo com o nome da etapa; tocar na linha fecha o diálogo e leva ao campo. (RN14, RN14a, RN14b)

    Sem marca: o diálogo não tem caminho pela interface. Ele só dispara quando a conferência falha ao enviar da etapa 2 — e a etapa 2 só é alcançada por um saveStep1 que acabou de validar com sucesso. O segundo gatilho, a recusa da API vinda do mesmo botão, existe no código e é o que sustenta a RN14; mas o submit da análise valida transição de status, não campo, então não há como produzi-lo. O código está escrito e revisado; o critério não é demonstrável. - [ ] CA18. Com erro nas duas etapas, o diálogo abre e lista os dois, agrupados. (RN14)

    Sem marca, e mais forte que o CA17: não há código para isto. A etapa 2 é só o DocumentsStep e não tem conferência de campo própria, então "erro nas duas etapas" é um estado que o produto não produz. O agrupamento por etapa no diálogo é rótulo fixo. O critério é vacuamente verdadeiro, e dizer isso é mais honesto que marcá-lo ou que deixá-lo em branco sem explicação. - [x] CA19. Nos cadastros de imobiliária e de imóvel — de uma etapa só — o diálogo nunca aparece. (RN14)

A busca

  • [x] CA20. Buscando 139.778.672-88 na análise de crédito, na lista de inquilinos e na lista de garantidoras, encontra o registro gravado como dígitos. (RN15, RN15a)
  • [x] CA21. Buscando Maria Silva nos mesmos três campos, o termo vai inteiro — a busca por nome continua funcionando. (RN15)
  • [x] CA21a. Digitando só ... nas três buscas, o resultado é vazio — e não a lista inteira do escopo. (RN15c, o fallback do termo original)
  • [x] CA21b. Digitando só %, o resultado é vazio nas três — e não a lista inteira. (RN15c, o escape do servidor) — é o caso que distingue as duas metades da regra: sem o escape, este devolveria tudo; sem o fallback, o ... do CA21a devolveria tudo.
  • [x] CA22. O AGENCY_USER da Imobiliária B, buscando pelo documento exato de um inquilino da Imobiliária A, não o encontra. (RN15, seção 10.4) — é o negativo de acesso.
  • [x] CA22b. O ADMIN_TENANT da Garantidora B, buscando pelo documento exato de um inquilino da Garantidora A, não o encontra. (RN15, seção 10.3) — é o gêmeo do CA22 no outro eixo de isolamento.
  • [x] CA22a. A busca da lista de imobiliárias continua encontrando por CNPJ digitado com e sem pontuação, sem ter recebido alteração no apps/web — quem normaliza ali é o servidor, e ele já normalizava. E a busca de imóveis segue sem consultar documento. (RN15b)

O dado já gravado

  • [x] CA23. Rodado o script, SELECT count(*) FROM renters WHERE cpf ~ '[^0-9]' devolve 0, e o mesmo vale para as demais colunas da RN16a. (RN16)
  • [x] CA24. Rodado duas vezes com --aplicar, a segunda execução relata zero linhas alteradas. (RN16b)
  • [x] CA24a. Rodado sem argumento, ele relata quantas linhas mudariam e não altera nenhuma — conferível repetindo a consulta do CA23 depois. (RN16g)
  • [x] CA25. Um owner_contact gravado como contato@imob.com.br continua igual depois do script. (RN16c)
  • [x] CA26. Um owner_document gravado como 123.456.789-0 (dez dígitos — o texto dizia doze, e o literal sempre teve dez; erro de aritmética da spec, corrigido na implementação) vira 1234567890 — perde a pontuação, e não ganha nem perde dígito. (RN16d)
  • [x] CA27. Com duas garantidoras gravadas como 12.345.678/0001-95 e 12345678000195, o script aborta relatando o par, e nenhuma das duas linhas é alterada. (RN16f)

14. Plano de implementação

Ordem obrigatória no monorepo: api → web → e2e.

Este plano foi reescrito contra as regras na rodada 6 do portão, que achou três itens faltando e um número que a seção 11 já tinha aposentado. A lição fica: o plano é a última seção a envelhecer e a primeira a mentir — quando uma regra muda, ele não protesta.

apps/api

  • [ ] Primeiro, os testes existentes — 25 CPFs com verificador inválido em 20 arquivos, 23 CNPJs em 17, 12 telefones pontuados em 3. Mais os dois que asseveram o comportamento antigo e mudam de sentido, não só de valor: test_rbac_security.py:664 (afirma CPF pontuado na resposta) e test_tenant_isolation.py:645 (posta CPF inválido esperando 201). Antes do resto, senão a suíte fica vermelha por motivo que não é defeito
  • [ ] apps/api/app/schemas/common.py — AppCPF e AppPhone novos; verificador no AppCNPJ; e o tratamento de "" — ausência em campo opcional, erro de obrigatório no obrigatório (RN8a, RN8d, RN8f)
  • [ ] A lista compartilhada de documentos válidos e inválidos, em JSON sob apps/api/tests/, que é o que prova a concordância com o cpf-cnpj-validator do web
  • [ ] Aplicar os tipos nos schemas de entrada (RN8c) — renter.py (cpf, cnpj e os três telefones), partner_agency.py, tenant.py, onboarding.py (telefones), property.py (owner_document)
  • [ ] Tirar o max_length de onde o tipo novo entrou — owner_document e os pontos de telefone enumerados na RN8d, sem contar aqui: a enumeração é lá, e um número repetido em dois lugares é um número que envelhece num deles
  • [ ] apps/api/app/core/exceptions.py — o prefixo Value error, sai no tradutor (RN13c). Sem este item a RN13 entrega texto em inglês inline
  • [ ] apps/api/app/repositories/renter_repo.py — escape= explícito no ilike (RN15c). É a única mudança de comportamento da API fora dos tipos, e o §12 a declara
  • [ ] app/db/normalize_documents.py — o script, com o modo relatório como padrão (RN16, RN16g)
  • [ ] Sem migration, e é decisão registrada na seção 11
  • [ ] Swagger — exemplos e descrições que passariam a ensinar o formato recusado, enumerados no item de docstrings da seção 11. O caso mais constrangedor: as descrições de CNPJ ensinam 12345678000190 (apps/api/app/schemas/tenant.py:41 e :56, apps/api/app/schemas/onboarding.py:115 e :131), cujo segundo dígito verificador está errado — o exemplo do Swagger passaria a ser recusado pela própria API
  • [ ] Testes novos: verificador dos dois lados, script idempotente, colisão de índice único, e os negativos de acesso — CA22 (imobiliária) e CA22b (garantidora)

apps/web

  • [ ] apps/web/src/shared/components/MaskedField.tsx primeiro — é onde três dos quatro mecanismos do erro suprimido morrem, e corrigi-lo antes faz as telas seguintes nascerem certas (RN11a, RN11c)
  • [ ] apps/web/src/shared/utils/masks.ts — o que falta para a RN5
  • [ ] O teclado numérico (RN6) — é trabalho, não consequência da máscara: hoje apps/web/src/shared/components/MaskedField.tsx não define inputMode em lugar nenhum. Nos campos de cadastro sim, nos de busca não
  • [ ] A rolagem da RN12, com o desempate da ordem visual
  • [ ] Os três formulários em escopo: máscara, conferência, e o mapa de details[] (RN13d)
  • [ ] As três telas já mascaradas: conferência de telefone (RN9), placeholders (RN7), e o verificador do CNPJ em Minha Garantidora e no onboarding (RN8)
  • [ ] O diálogo da RN14, com os data-testid da seção 11
  • [ ] A leitura formatada (RN3) — percorrendo a tabela da seção 7, que é a que vale. Não há total a marcar: um número aqui foi o que produziu, na rodada 6, um plano que deixava de fora as duas primeiras telas que o CA3 cobra
  • [ ] As três buscas (RN15) — limpeza da pontuação; o escape é do servidor, e o painel do SaaS não escapa nada

apps/e2e

  • [ ] Os specs da seção 11, lendo a lista compartilhada de documentos do mesmo arquivo da API

Fechamento

  • [ ] Escrever de volta no docs/ui-patterns.md o refinamento da ordem visual e a exceção do diálogo — sem isso a próxima spec relê Object.keys(errs)[0] como se fosse a receita boa
  • [ ] make test e make lint passando
  • [ ] Validado no browser em desktop e mobile, incluindo o teclado numérico da RN6, que nenhum teste automatizado alcança

15. Decisões em aberto

Nenhuma. As sete que apareceram durante a escrita foram todas decididas pelo usuário em 2026-08-24 e estão registradas onde valem: normalizar da gravação nova em diante e converter o já gravado por script (seções 4 e 5); limpar a pontuação na tela e não na API (seção 7); máscara do documento do proprietário por contagem e contato do proprietário sem máscara (seção 7); dígito verificador conferido também na API (RN8); teclado numérico só nos campos de cadastro (RN6); conferência de telefone também nas três telas já mascaradas (RN9); e o painel do SaaS entrando na regra de leitura formatada (seção 7 e 10.5).

16. Revisão do detalhamento

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/detalhamento.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 1 4 4 REPROVADA
2 0 4 5 REPROVADA
3 1 2 5 REPROVADA
4 1 4 2 REPROVADA
5 0 3 5 REPROVADA
6 0 7 3 REPROVADA
7 0 2 4 REPROVADA

Resultado: fechado por decisão do usuário (2026-08-24), sem veredicto de aprovação.

O estado honesto, e ele não é "aprovada": o portão nunca devolveu APROVADA. A última rodada reprovou com dois Important e quatro Minor, todos corrigidos — e não revalidados. O que sustenta a decisão de fechar não é um carimbo, é a curva: Critical por rodada em 1 · 0 · 1 · 1 · 0 · 0 · 0, achados caindo de sete para dois, e as três últimas rodadas confirmando o que a anterior tinha corrigido em vez de achar classe nova.

O que fica em risco, dito com nome: o padrão desta spec foi consequência não percorrida — não decisão errada. Sete rodadas acharam sete dessas, uma por vez, e a oitava provavelmente acharia mais uma. A aposta ao fechar é que o portão da implementação pegue o que sobrou com o código na frente, que é mais caro de consertar e mais fácil de enxergar. A rodada 5 foi a primeira sem Critical desde a 2, e foi também a primeira depois da varredura mecânica das enumerações (seção 11): ela auditou a varredura e confirmou cinco das seis listas, achando um erro de contagem numa delas.

O que cada rodada achou

Rodada O achado que valeu a rodada
1 Critical: a spec mandava aplicar o validador em RenterSummary, que é schema de resposta — validar na leitura daria 500 nas 99 linhas de CPF pontuado. Virou a RN8c. Mais: telefone e owner_document sem guarda na API, o prefixo Value error, que a RN13 tornaria visível, e o custo nos testes.
2 A matriz de acesso afirmava como garantia que o ADMIN_GLOBAL não vê inquilino de cliente — o rbac_seed.py:156 dá a ele todas as permissões menos duas, e ele já lista inquilino de todas as garantidoras. Virou ressalva declarada. Mais: o mapa entre o field da API e a chave do formulário, e a RN8 faltando em duas telas.
3 Critical: o POST /api/v1/onboarding/tenant ficou fora da tabela de contrato — e é o segundo endpoint cujo porteiro não está no decorador, além de ser onde uma garantidora nova digita o primeiro CNPJ. Mais: o mapa da RN13d cobrindo só parte do que o formulário posta, e o modo padrão do script.
7 Nenhum Critical, e as quatro mudanças da rodada 6 confirmadas. O achado que valeu: a correção da rodada 6 tinha apagado o fallback do termo vazio, e sem ele a limpeza esvaziava % na tela antes de o escape do servidor ver qualquer coisa — devolvendo a lista inteira, que é o defeito que o escape existia para consertar. As duas metades só funcionam juntas, e agora há um critério para cada. Mais: a metade obrigatória da RN8f não tinha texto, critério nem item de plano — e ela é trabalho, porque o pydantic não emite missing para campo presente com "".
6 Nenhum Critical, e o diagnóstico mais útil das seis: o §11 passou inteiro — as quatro enumerações da varredura conferidas, as dezenove citações abertas —, e o que falhava era o §14. O plano tinha ficado para trás das regras: faltava o item do exceptions.py (sem ele a RN13 entrega texto em inglês), faltava a maior parte do trabalho de API da RN8d, e ele ainda mandava conferir "os dezesseis pontos de leitura" — número que o §11 já tinha aposentado justamente por deixar fora as duas primeiras telas que o CA3 cobra. O plano foi reescrito contra as regras. Mais: o escape dos curingas não tinha onde morar (foi para o servidor, com escape= explícito, porque o SQLite da suíte não tem escape padrão), e o mapa da RN13d afirmava completude que não se sustentava.
5 Nenhum Critical. O achado mais afiado foi um autogol da própria regra: a RN15c mandava enviar o termo cru quando ele não sobrevivesse à limpeza, e um % cru vira %%% no ilike — devolvendo a lista inteira, o oposto exato do que a regra quer, e justamente sobre o exemplo que ela usa. Virou "escapar os curingas sempre". Mais: um quarto corpo postado pelo formulário (participante) fora do mapa, e um total de pontos de leitura que contradizia um critério de aceite.
4 Critical: o inquilino PJ ficou fora do alcance declarado da RN8b — e é onde a mudança é maior, porque ali o cnpj é str puro e ganha três recusas de uma vez, não só o verificador. A medição também o omitia. Mais: a conta dos testes estava incompleta e a maior parcela era a que faltava (25 CPFs em 20 arquivos, contra os 23 CNPJs já contados), o mapa da RN13d cobrindo um dos três corpos que o formulário posta, e a regra do termo vazio que não valia para o filtro em memória do painel do SaaS.

O padrão das três é o mesmo, e é diferente do que travou a Parte II: aqui os achados foram consequência técnica não percorrida — um schema de resposta tratado como entrada, um endpoint com porteiro fora do decorador, um invariante sem guarda. Nenhum exigiu repensar o desenho, e nenhum veio de decisão errada.

Riscos aceitos: risco de acesso que a revisão apontou e se decidiu não fechar nesta spec, com quem decidiu e por quê. Vazio se não houver — e o esperado é que esteja vazio.

17. Revisão da implementação

Preenchido pelo /spec implement, rubrica .claude/rubrics/implementacao.md. As revisões de fase (api, web, e2e) vão nas primeiras linhas; a da branch inteira, na última.

Rodada Escopo Critical Important Minor Veredicto
1 apps/api 2 6 5 REPROVADA
2 branch inteira 4 8 8 REPROVADA
3 branch inteira 3 6 7 REPROVADA
4 branch inteira — 2 8 REPROVADA
5 branch inteira 2 3 6 REPROVADA
6 branch inteira — 2 5 REPROVADA
7 branch inteira 1 2 6 REPROVADA
8 branch inteira — 2 5 REPROVADA
9 branch inteira — 2 7 REPROVADA
10 branch inteira — 2 8 REPROVADA
11 branch inteira — — 6 APROVADA

A linha da rodada 6 faltava, e as de baixo estavam um número adiantadas. São dez rodadas de branch — a tabela listava nove, e a narrativa abaixo tem dez seções. Encontrado pelo portão da spec 0047, que usava esta tabela como fonte e chegou ao número errado por causa dela. Quem paga o preço de uma tabela que não bate com o texto é sempre a spec seguinte.

Fase apps/api, rodada 1 — os dois Critical, e os dois eram meus

Um escapou de um regex; o outro é um detalhe do pydantic que morde calado.

  1. Os três telefones do PartnerAgencyUpdate ficaram str | None. Meu regex exigia max_length=20, com vírgula, e naquela classe os campos não têm description — terminam em max_length=20). O efeito era concreto: PATCH de imobiliária com {"phone_mobile": "(11) 98765-4321"} respondia 200 e gravava o valor pontuado, reabrindo a coluna aos dois formatos que o script acabara de unificar.
  2. AfterValidator não revalida o retorno contra a anotação. _validate_cnpj devolve None para "" (RN8f), e num campo obrigatório o pydantic aceita esse None sem reclamar: POST /api/v1/tenants com {"cnpj": ""} passou de 422 a 201 com CNPJ nulo. Eu tinha criado o AppPhoneRequired para o telefone e não vi que o documento obrigatório precisava do mesmo. Nasceu o AppCNPJRequired, e um teste que falha se ele sumir.

E o que a revisão mais cobrou não foi código, foi prova: o script da RN16 não tinha um único teste, o AppDocumento não era exercitado, a RN13c passaria verde se fosse revertida, e os dois negativos de acesso que a spec nomeia não existiam. Vinte testes entraram, e dois deles mudam a forma do código: o script separou a decisão da execução porque o UPDATE usa SQL de PostgreSQL e a suíte roda em SQLite — sem isso ele não seria testável, e um teste que dissesse cobri-lo estaria mentindo.

Nove exemplos de Swagger e duas descrições ensinavam o formato que a API agora recusa — inclusive a do endpoint que esta spec nasceu para consertar. Corrigidos.

A suíte: 740 verdes antes da revisão, 760 depois, zero vermelhos.

Branch inteira, rodada 1 — a correção carregava o defeito que ela conserta

Os quatro Critical são de uma família só: o details[] da API morrendo antes de virar erro de campo. É literalmente o defeito da seção 1 — "Alguns campos precisam ser corrigidos. Verifique os detalhes abaixo", sem detalhe abaixo — sobrevivendo dentro da spec escrita para eliminá-lo.

  1. O resolveEntities engolia a recusa antes do catch que a trataria. O cadastro de inquilino e o de imóvel dentro da análise capturavam o ApiError e faziam setRenterError(err.message) — e err.message, numa recusa de validação, é justamente a frase que promete detalhes. Isso tornava inalcançáveis oito das dez entradas do mapa da RN13d: só rental_value e guarantee_months chegavam, porque vêm por outro caminho.
  2. A RN5 não estava implementada, e o comentário dizia que estava. Ele mandava procurar a detecção de sobra "em excedeu", e excedeu não existe no apps/web. Com IMask, colar +55 11 98765-4321 entregava 55119876543 truncado — número plausível e errado, sem erro para ler. Os ramos d.length > CAPACIDADE.* de masks.ts eram inalcançáveis pela interface. Agora o campo recusa a colagem que não cabe, em vez de aceitá-la pela metade: truncar deixaria no estado um valor que pode até passar na validação do salvar.
  3. O formulário de imóvel — um dos três em escopo — não consumia details[]. Só setError(e.message). O mapeamento correto já existia na imobiliária; virou aplicarErrosDaApi, em shared/utils/foco-no-erro.ts, e os dois passam por lá.
  4. O negativo de acesso por imobiliária não existia — o docstring afirmava que os dois estavam ali e só o de garantidora estava. É o eixo que a §10.4 declara como o risco desta mudança: partner_agency_id é filtrado dentro do mesmo search que a RN15 reescreveu. Escrito, e visto vermelho com o filtro removido antes de ser dado por bom.

Um achado que a revisão não pegou, e apareceu ao escrever o teste do diálogo: a RN14 estava implementada e inalcançável. Ela tem dois gatilhos — o validate() local ao enviar da etapa 2 (que é o caminho da RN14a) e a recusa da API pelo mesmo botão —, e nenhum dos dois acontece: a etapa 2 só é alcançada por um saveStep1 que acabou de validar com sucesso. O caminho por onde a regra de fato acontece é outro: a recusa da API ao enviar da etapa 2 marca campos da etapa 1, que não estão montados; irAoPrimeiroErro não achava nada para rolar e a pessoa via a tela dos documentos exatamente como antes de clicar. Silêncio. Agora, quando não há para onde rolar, o diálogo é quem informa.

O preflight acusava dez citações erradas numa spec em que todas estavam certas, por dois defeitos da heurística dele — o comprimento mínimo aplicado dentro da regex desalinhava o pareamento dos crases e capturava a prosa entre dois trechos; e num parágrafo com várias citações ele comparava o trecho mais longo com todos os arquivos citados. Corrigidos em scripts/preflight.py, com uma citação errada plantada para provar que ele continua pegando o que deve. Um verificador que falha no texto certo ensina a ignorar o resultado.

A suíte da API: 760 verdes antes, 765 depois, zero vermelhos.

Branch inteira, rodada 2 — as correções tinham buracos, e um teste "corrigido" ainda não podia falhar

Os três Critical, todos da rodada anterior por consertar:

  1. declared_income entrou no mapa inline, e a RN13d proíbe isso por escrito. Ao fechar o mapa da rodada 1 acrescentei todo campo digitável — inclusive o único que a regra manda deixar de fora, e explica por quê: a API monta o nome com loc[-1], então a renda do participante 0 e a do 3 chegam idênticas. Marcar inline mandava consertar a renda do inquilino principal quando o recusado era o corresponsável.
  2. Dois dos dezenove campos de telefone ficaram sem conferência — os phone_landline de Minha Garantidora e do onboarding —, e o efeito era pior que antes da spec: a API passou a recusar, a tela não avisava, e a faixa do topo exibia "Verifique os detalhes abaixo" sem detalhe abaixo. A segunda dor da seção 1, criada por esta branch.
  3. No diálogo da RN14, toda linha levava ao mesmo campo. irAoCampo recebia a chave e a usava só para decidir se fazia algo, chamando em seguida irAoPrimeiroErro. Quem tocasse em "Prazo em meses" era levado a "Nome do inquilino" — a tela afirmando "é aqui" sobre outro campo, que é o defeito da RN12 dentro do componente escrito para evitá-lo. Nasceram o levarAoCampo e o mapa de data-testid por chave de erro.

E o achado que mais ensina: o teste que eu tinha "consertado" na rodada 1 continuava sem poder falhar. Ele trocara o mock por introspecção da classe real — model_fields[...].annotation is not AppCPF — e no pydantic v2 esse atributo nunca guarda o alias Annotated: para x: AppCPF ele guarda str. A comparação era verdadeira com validador e sem. Agora a asserção é de comportamento (model_validate com o valor legado), e o red-green foi feito: pondo AppCPF em RenterSummary e AppDocumento em PropertyResponse, os dois testes ficam vermelhos.

Dois achados foram recusados depois de medidos, e ficam registrados porque a medição é o argumento:

  • O Important sobre aplicarErrosDaApi era real e virou correção — erro posto em campo não registrado trava o handleSubmit para sempre, sem nada vermelho na tela. O helper passou a receber os nomes registrados; o que sobra vai para o resumo do topo, que é onde a RN13a manda.
  • O Minor sobre o preflight não tinha correção disponível. Tentei exigir que o trecho estivesse em pelo menos um dos arquivos citados no parágrafo; ela acusou dezenas de trechos legítimos, porque é exatamente isso que uma spec faz — cita o código anterior ao consertá-lo, valores de exemplo, textos de outra tela. Ficou como está, com o motivo escrito no arquivo para ninguém tentar de novo.

A suíte, depois das correções: API 765 verdes, zero vermelhos. O intermitente conhecido (test_partner_agency_status_filter) apareceu numa execução e foi medido em 17 rodadas isoladas — ~35% de falha, que é o sorteio de UUID que o CLAUDE.md descreve; o único ponto que esta branch toca naquele arquivo é o CNPJ da garantidora, sem caminho até a ordenação das imobiliárias.

E2E: 357 verdes, 4 vermelhos, nenhum deles regressão desta branch. Os quatro arquivos estão intocados por ela, e com o banco limpo 29 de 30 passam. Três falham por acúmulo de dado dentro da própria execução — a suíte cria uma centena de inquilinos antes de os specs de isolamento e do painel do SaaS rodarem, e eles apenas carregam a lista, sem buscar; o do seed sai da primeira página. A faxina do teardown resolve o acúmulo entre execuções, não dentro de uma. O quarto é o defeito de rota descrito abaixo.

A primeira execução completa, antes das correções: 351 verdes e 8 vermelhos. Dois eram meus (selectOption num Select do MUI, que é uma div). Cinco eram a poluição do banco outra vez — 119 inquilinos acumulados empurravam o do seed para fora da primeira página, e três specs que apenas carregam a lista falhavam por isso; limpo o resíduo anterior à faxina, passaram. O sexto é defeito real e anterior a esta branch: a rota /settings/credit-analysis-documents exige credit_analysis_document_requirements:update, mas a página tem modo leitura justamente para quem não tem essa permissão — quem deveria vê-lo nunca chega lá. App.tsx:435, intocado por esta spec; vai para um /fix próprio.

Branch inteira, rodada 3 — sem Critical, e o achado que sobrou é o mais caro da spec

Metade da RN10 existia num formulário só. A regra tem duas partes — incompleto fica calado, completo e errado fala na hora —, e a segunda estava implementada apenas na análise de crédito, via erroImediato. Os outros quatro formulários chamam useForm() sem mode, e as rules do react-hook-form só rodam no handleSubmit: quem digitasse um CNPJ com verificador errado no cadastro de imobiliária preenchia razão social, e-mail, três telefones e o endereço inteiro antes de saber. É a frase da seção 7 acontecendo — "esperar o salvar seria guardar a informação até o momento em que ela custa mais" — dentro da spec que a escreveu. Os cinco formulários agora conferem enquanto se digita.

E consertá-lo revelou um defeito no componente compartilhado, que nenhuma tela exercitava até aqui: o MaskedController trocava a mensagem de quem chama pelo genérico "Confira este campo". A conferência imediata vive fora do react-hook-form, então chega por error + helperText — e a precedência descartava justamente esse texto. O erro apareceria; a explicação que a spec escreveu, não.

O Critical da rodada 2 estava fechado em dezenove das vinte e uma chaves. Faltavam renterBirth e start no mapa de alvos do diálogo — os dois são DateInput e não tinham data-testid, o que é a causa de fundo: um mapa cujo alvo não existe falha em silêncio, caindo no primeiro erro da tela. Ganharam testid, e o mapa fechou.

Dois Minors valem registro porque mudam o que eu tinha escrito:

  • Object.keys(getValues()) não é o conjunto de campos registrados, e era a guarda que a rodada 2 introduziu contra o travamento do handleSubmit. Depois de um reset(registro) ele lista tudo que veio da API — id, tenant_id, partner_agency_id —, deixando passar exatamente o caso que a guarda existe para barrar. Quem responde "este campo existe?" é o DOM: o register põe o name no <input>, então a presença do elemento é a definição.
  • O placeholder do CEP voltou. Tirá-lo era mudança fora do escopo declarado — a seção 5 tira o CEP desta spec, e a RN7 fala de campo mascarado. O CEP formata à mão e não ganhou máscara aqui.

A suíte: API 766 verdes, zero vermelhos. make preflight e make spec-lint limpos.

E2E: 357 verdes, 5 vermelhos, nenhum regressão desta branch — e o conjunto que falha muda a cada execução, que já é o sinal. Três classes, todas com evidência:

  1. Sessão perdida (admin-interface, mobile-shell): o instantâneo da página mostra a tela de login. É a armadilha que o CLAUDE.md do e2e descreve — page.goto em rota protegida perde a sessão em memória. Os arquivos estão intocados por esta branch, e os mesmos testes passaram isolados noutra execução.
  2. Acúmulo dentro da própria execução (isolamento-imobiliaria): a suíte cria uma centena de inquilinos antes desse spec rodar, e ele apenas carrega a lista, sem buscar — o do seed sai da primeira página. Com o banco limpo, passa. A faxina resolve o acúmulo entre execuções, não dentro de uma.
  3. Defeito real e anterior a esta branch (credit-analysis-documents, CA11): a rota /settings/credit-analysis-documents exige credit_analysis_document_requirements:update, mas a página tem modo leitura justamente para quem não tem essa permissão — quem deveria vê-lo nunca chega lá. App.tsx:435, intocado desde a spec 0045. Vai para um /fix próprio.

Branch inteira, rodada 4 — os dois Critical eram testes que não podiam falhar

O botão Salvar não rodava a rolagem da RN12, e o teste que cobriria isso passava por coincidência. O PageHeader portala a ação para a barra do app, fora do <form>, e por isso ela chama handleSubmit(onSubmit)() no onClick. O segundo argumento — o onInvalid que leva ao primeiro erro — só estava no onSubmit do formulário, que dispara apenas por submissão implícita (Enter dentro de um campo). Clicando no botão, que é o caminho de todo mundo, sobrava o shouldFocusError do react-hook-form: ordem de registro, sem considerar o texto do erro nem o visualViewport. A receita que a RN12 rejeita, rodando no lugar da que ela pede, em dois dos três formulários em escopo.

E o e2e afirmava focado === true sobre o primeiro campo registrado — que nestes formulários é também o primeiro visual. Apagar irAoPrimeiroErro do repositório não mudaria o resultado do teste.

A correção da rodada 3 no script de normalização tinha entrado na função que não roda. O plano() só era chamado pelos testes; a produção executava um UPDATE ... regexp_replace(...) que reimplementava a mesma regra em SQL. Havia duas implementações, e elas divergiram no primeiro conserto — a guarda de "valor sem dígito fica como está" entrou no Python e não no SQL, deixando um teste verde sobre comportamento que o script não tinha. O docstring chegava a afirmar que os testes verificavam a concordância.

Consertado apagando a segunda implementação, não sincronizando-a: o main() agora lê valores distintos, executa o plano(), e grava UPDATE ... SET col = :depois WHERE col = :antes. A varredura de colisão também agrupa em Python. Não sobrou nenhuma função de texto do PostgreSQL decidindo nada, e um teste novo prende o UPDATE à saída do plano.

Um terceiro defeito não veio da revisão — apareceu ao conferir uma suposição do meu próprio prompt. A guarda que a rodada 3 introduziu decide "este campo existe?" perguntando ao DOM, e o MaskedController descartava o name ao repassar as props: nenhum campo mascarado seria encontrado, e a recusa da API em cnpj, phone_mobile ou owner_document — os campos desta spec — deixaria de marcar o campo. O atributo agora desce até o <input>, conferido no DOM e com teste de regressão.

E o reforço do teste do C1 também não podia falhar — isso foi medido, não deduzido. Removi o onInvalid, reconstruí o dev-remote e rodei: passou, com a caixa do campo parando no mesmo pixel dos dois lados. Neste formulário a ordem de registro coincide com a visual, e o contêiner que rola tem ~190px de curso, então o primeiro erro nunca sai da vista — e sem sair da vista não há rolagem a comparar. A limitação está escrita no próprio teste, no lugar da afirmação falsa que estava lá.

Branch inteira, rodada 5 — nenhum teste novo que não pudesse falhar

A auditoria abaixo funcionou: o revisor procurou o padrão das rodadas anteriores e não achou caso novo. Os dois Important que sobraram fecham sem tocar em regra de negócio.

A API ensinava, no Swagger, um CNPJ que ela mesma recusa. 12345678000190 é o número que a suíte usa como exemplo de inválido, e ele estava nos docstrings de POST /tenants e POST /onboarding/tenant — os dois endpoints por onde uma garantidora nova digita o primeiro CNPJ da vida dela. Os exemplos dos schemas tinham sido corrigidos junto com os tipos; os dos routers são texto solto num docstring, e nenhum grep de campo os alcança.

Corrigido, e virou teste: test_nenhum_exemplo_de_documento_na_documentacao_e_invalido varre os literais de onze e catorze dígitos da camada de API inteira, perto das palavras CPF ou CNPJ, e passa cada um pelo verificador da produção. Visto vermelho antes de ser dado por bom.

A rolagem da RN12a não alcançava erro de bloco. irAoPrimeiroErro procura [aria-invalid="true"], [data-erro="true"], e a segunda metade do seletor não era produzida por ninguém — era um gancho esperando. O erro do bloco do inquilino ou do imóvel não é de um campo: é um texto dizendo "escolha ou cadastre". Sem o atributo, a busca não achava nada e a tela não focava nem rolava — com a pessoa no fim do formulário em 375px, clicar em salvar não movia coisa alguma. Movimento sem resposta, na tela que originou a spec.

E dois Minors eram o mesmo defeito de fundo por outro ângulo:

  • _colisoes não usava a regra do plano. Dois CNPJs gravados como "n/a" e "-" normalizam ambos para vazio; a varredura os acusaria como par colidente e o script abortaria todas as tabelas por uma colisão que ele nunca causaria, porque o plano não tocaria em nenhum dos dois. Duas regras para uma decisão, que é exatamente o que a rodada 4 veio matar. De quebra, o relato agora nomeia o par, como a RN16f pede — um valor normalizado sozinho não diz quais linhas procurar.
  • A faixa do topo repetia o que já estava no campo. aplicarErrosDaApi devolvia a lista inteira; passou a devolver só a sobra, e a faixa some quando não há sobra. Mostrar "verifique os detalhes abaixo" com tudo já marcado nos campos é prometer detalhes que estão em outro lugar — a frase que abre a seção 1.

Branch inteira, rodada 6 — a correção da rodada 5 podia deixar a tela muda

O Critical é meu, e é da mesma família de todos os outros: uma suposição não conferida. Na rodada 5 fiz a faixa do topo mostrar só a sobra, para não repetir o que já está no campo — e defini sobra como "campo que não está registrado no formulário", supondo que campo registrado exibe o próprio erro. Nestes dois formulários isso é falso para quase todos: da imobiliária, treze campos são registrados e um liga error/helperText; do imóvel, doze e dois. O resto — inclusive um cep num <input type="hidden"> — recebia a marca do react-hook-form e não mostrava nada.

O desfecho era o pior possível. Digitando São Paulo no campo Estado da imobiliária: a API recusa state, o campo sai da sobra, a faixa some por estar vazia, o TextField não lê errors.state, e irAoPrimeiroErro não acha aria-invalid nenhum. A pessoa clica em Salvar e a tela não faz absolutamente nada — o defeito da seção 1 de volta, por outro caminho, dentro da correção dele.

A pergunta certa não é "está registrado" e sim "ficou visível", e ela se responde depois do repintar, lendo aria-invalid. Virou teste e2e, visto vermelho com o critério antigo: sem a correção, a faixa não existe.

O data-erro que acrescentei na rodada 5 estava no elemento errado. Ele foi para o Paper que envolve o bloco, e querySelectorAll devolve em ordem de documento — o ancestral vence qualquer campo com erro dentro dele. Havendo recusa num campo e sobra no bloco ao mesmo tempo, o foco ia para a moldura, o closest não achava .MuiFormControl-root, e a rolagem centralizava o bloco inteiro; em 375px o campo a consertar podia ficar fora da vista, com o teclado sem abrir porque o foco não estava num input. Mudou para o texto do erro, que no modo de cadastro é renderizado depois dos campos — onde ele de fato aparece.

E o teste que escrevi na rodada 5 prometia mais do que pegava. Ele pulava linha sem "CPF" ou "CNPJ", e docstring de router quebra linha: o exemplo de renters.py está na continuação, com a palavra na linha de cima. Agora a regra é por comprimento — catorze dígitos é sempre CNPJ e não precisa de contexto; onze é ambíguo com celular e só se cobra com a palavra na própria linha, porque a primeira tentativa de janela acusou dois telefones legítimos. Verificado: com o exemplo da linha de continuação corrompido, o teste acusa.

Branch inteira, rodada 7 — sem Critical, e o que sobrou foi rede que faltava

O achado que mais vale é o CA10 sem prova, e ele é o mesmo eixo que esta branch já quebrou uma vez: não havia teste algum exercitando PartnerAgencyCreate/Update pela porta com CNPJ de verificador inválido ou telefone pontuado. Os testes de tipo usam uma classe declarada no próprio arquivo de teste, que passa com o schema real intacto ou não — apagar AppCNPJ e AppPhone de partner_agency.py deixava a suíte verde e o PATCH voltava a responder 200 gravando pontuado. Era exatamente o Critical nº1 da rodada 1, sobrevivendo sem rede desde então. Escrito e visto vermelho com os quatro campos devolvidos a str.

E a documentação ensinava a assinatura antiga. Mudei aplicarErrosDaApi na rodada 6 e não atualizei o ui-patterns.md, que segue sendo o item de Fechamento do plano. Quem copiasse o bloco de lá tomaria erro de argumento faltando do tsc, e a saída natural — passar () => {} — reintroduz a faixa muda. O texto novo mostra a chamada inteira e explica por que o critério é "não apareceu" e não "não está registrado".

Três Minors eram consequência não percorrida das correções anteriores:

  • A RenterResponse ficou sem guarda. Ao trocar a introspecção tautológica por comportamento, cobri RenterSummary e PropertyResponse e esqueci a terceira classe que a §11 nomeia. Pôr AppCPF nela dava 500 em GET /renters/{id} de registro legado, com a suíte verde.
  • A varredura de exemplos não alcançava models nem db — e foi justamente em app/models/renter.py que esta spec precisou corrigir à mão um 000.000.000-00, texto que o Sphinx publica no wiki. O verificador cegava no arquivo que já tinha tido o defeito.
  • A rolagem media o layout antes de a faixa nascer. aoSobrar acrescenta um Alert no topo e empurra tudo uns 80px; medindo no mesmo quadro, em 375px com o teclado aberto o texto do erro saía da vista. Passou a rolar um quadro depois.

E a ordem do diálogo da RN14 era a de inserção, não a visual — a receita Object.keys(errs)[0] que a RN12 rejeita, sobrevivendo dentro do componente escrito para matá-la. Hoje inofensiva, porque só dois campos alcançam aquele caminho e ali as ordens coincidem; nada avisaria quando um terceiro entrasse fora de ordem.

A primeira correção lia a ordem do DOM, e não servia — o diálogo existe justamente para quando os campos não estão montados, e ali o DOM devolve tudo empatado, que é a ordem de inserção de novo. Os três mapas paralelos (rótulo, alvo, ordem) viraram uma lista declarada em ordem visual, conferida contra a ordem de renderização, de onde os três saem juntos. E o tipo das chaves de erro passou a derivar dessa lista: escrever uma chave que ninguém declarou virou erro de compilação — que é o que faltava quando uma revisão achou duas chaves presentes num mapa e ausentes no outro, defeito cujo efeito é mudo.

Tipar só o useState não bastava, e isso foi medido. Record<string, string> é atribuível a Partial<Record<K, string>>, então o objeto local do validate() continuava aceitando qualquer chave. A garantia só existe onde o tipo está — nos três pontos.

O efeito colateral da faxina, e como ele foi resolvido

A faxina do teardown quebrou três testes de outra spec, e isso é responsabilidade desta branch. list-pagination.spec.ts (spec 0022) exigia 1–25 de — que só acontece com 25 registros —, uma segunda página para avançar, e separador de milhar, que só existe com mil. Os três passavam porque o banco compartilhado tinha milhares de registros acumulados: eles mediam a poluição, não o produto. Removida a poluição, caíram, sem que nada da aplicação tivesse mudado.

A primeira substituta que escrevi para o separador comparava o rodapé com o total lido do próprio rodapé — coerente, e tautológica abaixo de mil. Registrei como pendência aceita; o usuário mandou resolver, e havia saída melhor.

O número passa a vir da resposta, não do banco. comTotalFalso() intercepta a chamada da lista e troca o total; nenhum registro é criado, o número é escolhido, e o que se mede é a régua da paginação — como o produto formata e navega dado um total —, que não precisa de banco para ser exercitada. Encher o banco seria recriar de propósito o que o teardown remove, e o teste voltaria a depender de quantos registros sobraram da rodada anterior.

O CA9c ganhou de quebra uma asserção honesta: o critério é o seletor dizer 25, não a faixa — numa lista curta o produto mostra 1–2 de 2 estando corretíssimo. E o separador foi visto vermelho: tirando o toLocaleString do ListPagination, o teste acusa de 2357, que é literalmente o defeito que a spec 0022 corrigiu.

Branch inteira, rodada 8 — a detecção certa com a correção errada

O Important que mais ensina é o do teclado virtual. areaVisivel() lê o visualViewport e decide corretamente que o campo não cabe — e a correção era scrollIntoView({ block: "center" }), que centraliza no contêiner que rola. Esse contêiner é o main, de altura fixa, que não encolhe quando o teclado abre: ele sobrepõe. O campo parava perto da metade da janela e o texto do erro, logo abaixo, caía sob o teclado. A detecção sabia; a correção ignorava o que ela tinha descoberto — meia-implementação do pior tipo, porque a tela se mexe e mesmo assim não mostra o que a pessoa foi ler. Agora a rolagem calcula o deslocamento contra a área visível e rola o ancestral certo.

E a RN13d dizia uma coisa e o código fazia outra — role e renter_id na faixa do topo, contra a tabela que os manda para o controle da linha. Aqui o código estava certo: os três campos do participante vêm do mesmo participants[i] e perdem o índice pelo mesmo loc[-1], então a exceção que a spec abriu para declared_income nunca foi sobre a renda — era sobre a linha. A regra foi emendada para dizer o que já queria dizer, com o motivo escrito.

Um Minor que era defeito de verdade: o aviso de sobra do MaskedField substituía a mensagem de erro real. Com o campo já marcado por uma recusa do servidor, colar dígitos a mais trocava CNPJ inválido — confira os números por CNPJ tem 14 dígitos, e a recusa sumia até a próxima tecla. Os dois são verdade ao mesmo tempo; o que se perdia era o mais grave.

Outro: partner_agency_id era mapeado inline, e o seletor de imobiliária não existe quando quem preenche é a própria imobiliária. A recusa marcava uma chave que nada renderiza, a rolagem não achava nada, e o diálogo abria listando "Imobiliária" com uma linha que não levava a lugar nenhum. Agora só se marca inline o que está montado — o mesmo critério que o outro formulário já usava.

Branch inteira, rodada 9 — um catch esquecido e uma medida cedo demais

Sobrou um catch com o defeito que a spec veio matar, e o cenário é curto: acrescentar corresponsável com 0 no campo de renda. A API tem gt=0 ali, responde 422 nomeando declared_income, e do addExtra saía só o e.message — que numa recusa de validação é "Alguns campos precisam ser corrigidos. Verifique os detalhes abaixo", sem detalhe abaixo. Todos os outros catch do arquivo já tinham sido convertidos; este passou em quatro rodadas. O único que fica sem conversão é o removeExtra, e agora está escrito por quê: é um DELETE, não nomeia campo.

E a área visível era medida antes de o teclado que ela existe para medir abrir. Em 375px o caminho dominante é tocar em Salvar na barra do topo, o que desfoca o campo e fecha o teclado; quando a conta é feita, visualViewport.height ainda é a janela inteira. O focus() reabre o teclado, mas a animação leva uns 250ms — dois quadros de requestAnimationFrame são 32ms. O campo parava centralizado em ~y400 de 812 e o teclado subia cobrindo dali para baixo. Agora a rolagem acontece duas vezes: já, para o caso sem teclado, e de novo no resize do visualViewport, que é o sinal exato de a área ter mudado — em vez de chutar a duração de uma animação que varia por aparelho.

Um Minor era escolha minha, malfeita na rodada anterior. O aviso de sobra e o erro do campo são os dois verdade, e falam de coisas diferentes: um do que está lá, outro de uma tentativa que não foi aceita. Escolher um dá defeito nos dois sentidos, e a implementação já teve os dois — a sobra apagando a recusa do servidor, e depois a recusa engolindo o aviso, com a colagem sendo recusada sem nada mudar na tela. Agora aparecem juntos, e o texto de ajuda continua fora quando não há erro real (RN11a).

E os geradores de documento das fixtures podiam devolver documento inválido. A revisão apontou o seed múltiplo de um bilhão (00000000000); ao escrever o teste apareceu 999999999 → 99999999999, que não é caso de borda óbvio nenhum. A classe é "base que produz dígito repetido", não "base zerada": documento assim é inválido por regra, não por conta. Os dois geradores agora conferem o que saiu e andam uma casa quando caem nesse conjunto.

A auditoria de red-green, feita antes da rodada 5

Três das quatro rodadas acharam teste que não pode falhar — mock testando mock, introspecção sempre verdadeira, asserção sobre o primeiro campo registrado quando a regra fala do primeiro visual, correção aplicada em função que a produção não chama. É o padrão da implementação inteira, e ele não se resolve prometendo cuidado.

Então cada teste novo da API foi visto vermelho com a regra removida, e restaurado:

Teste O que foi quebrado para vê-lo falhar
test_outra_imobiliaria_nao_encontra_pelo_documento_exato (CA22) o filtro partner_agency_id do renter_repo.search
..._do_meio_e_recusado_na_escrita_e_lido_na_leitura (CA10b) AppDocumento fora do PropertyUpdate
test_a_resposta_nao_valida_o_documento_legado (RN8c) AppDocumento posto no PropertyResponse
test_a_resposta_do_inquilino_tambem_nao_valida (RN8c) AppCPF posto no RenterSummary
test_colisao_aborta_antes_de_qualquer_update (CA27) conferir colisão e gravar tabela a tabela, em vez de varrer tudo antes
test_valor_sem_digito_nenhum_fica_como_esta (RN16d) a guarda do plano, voltando a gravar string vazia
test_o_update_grava_o_que_o_plano_decidiu o UPDATE voltando a decidir sozinho, com regexp_replace
test_valores_sem_digito_nao_inventam_colisao (RN16f) o filtro de _colisoes, voltando a acusar par que o plano não causaria
..._nenhum_exemplo_de_documento_na_documentacao_e_invalido o CNPJ inválido de volta no docstring do router

E três do e2e, cada um custando dois rebuilds — por isso são os mais caros da lista:

  • erro de bloco também leva a pessoa até ele ficou vermelho com o data-erro removido do SelectionBlock, acusando "nenhum bloco ficou marcado".
  • cai na faixa do topo em vez de sumir ficou vermelho com o critério antigo da sobra ("campo não registrado"): a faixa simplesmente não existe.
  • campo que tem onde mostrar não aparece também na faixa não foi morto de propósito — ele existe para medir uma corrida, e a mediu: a leitura de aria-invalid dentro do requestAnimationFrame acontece depois de o React repintar, então o campo com slot é marcado e não vai também para a faixa. Sem ele, a conta da sobra poderia estar errada para o lado do excesso — tudo indo para a faixa, a pessoa lendo a mesma frase duas vezes — e o outro teste passaria do mesmo jeito, porque usa um campo sem slot.

São os únicos testes de comportamento do apps/web nesta branch cuja morte foi verificada; os demais são lidos, não medidos.

O que essa auditoria não alcança é o apps/web, e é onde os dois defeitos desta classe sobreviveram mais tempo: sem suíte unitária, cada verificação custa um rebuild do dev-remote de alguns minutos. Foi assim que se descobriu que o reforço do teste do C1 também não falhava — e é o argumento mais concreto que esta spec produziu a favor de haver teste unitário ali.

Branch inteira, rodada 10 — aprovada, e o Minor que valia um Critical

Sem Critical e sem Important. Mas um dos seis Minor era um furo de verdade, e o revisor disse na triagem que o corrigiria antes do merge — com razão.

str.isdigit() não serve para dizer o que é dígito. Ele devolve True para numeral de outras escritas: um CPF em Arabic-Indic (١٣٩٧٧٨٦٧٢٨٨) passava por comprimento, por verificador — int('١') é 1 — e pela conferência de "só dígitos", e o valor não-ASCII ia para a coluna. Quebra a RN1 e o invariante que o CA23 cobra, e o script de normalização não o alcançava porque usava a mesma função. O caso vizinho é pior de ler: '⁸'.isdigit() é True e int('⁸') estoura, então a recusa saía com a mensagem em inglês da biblioteca padrão — que a RN11b proíbe exibir num campo.

A tela nunca produziu nada disso; o /\D/g do JavaScript é ASCII. Quem produz é integração — e é exatamente para ela que a RN8d existe, "senão a RN1 é falsa para qualquer escrita que não venha da tela". Corrigido nos quatro pontos, com teste parametrizado visto vermelho.

E dois Minors de comportamento fecharam junto:

  • Recusa que só produz sobra não rolava para lugar nenhum. A chamada de rolagem estava dentro do ramo do inline; quando todos os details[] caíam no bloco — recusa em renter_type, por exemplo —, o texto aparecia num bloco possivelmente fora da vista e nada se movia.
  • A segunda rolagem disparava em qualquer encolhimento da área visível. A barra de endereço do navegador móvel, que reaparece ao rolar, também encolhe — e puxava a página de volta ao campo debaixo de quem estava lendo. Agora exige um quinto da altura: teclado ocupa de 35% a 50%; barra de endereço, uns 7%.

Resultado: APROVADA na rodada 10 da revisão de branch.

Defeitos aceitos:

  • "Confira este campo" no MaskedField é texto inventado na implementação, e a RN11b diz que os textos vêm da tabela da seção 7. É fallback para erro que chega sem mensagem — um estado que a RN11b diz não poder existir. Fica: um aviso genérico é ruim e honesto; o texto de ajuda no lugar do erro, que era o comportamento anterior, era pior.
  • O diálogo da RN14 não tem teste e2e, e não é esquecimento: o único caminho que o abre é a recusa da API ao enviar da etapa dos documentos, e o submit da análise não recusa campo — ele valida transição de status. Os CA17 e CA18 ficam sem prova automatizada, com o motivo escrito aqui em vez de marcados sem base.
  • RN13a, a sobra do bloco: o que não casa no mapa vai para o erro do bloco do inquilino ou do imóvel, não para a faixa do topo que a regra especifica. É onde a pessoa está olhando, e o bloco é o que está aberto. Divergência consciente da letra da regra.
  • CA23 não fecha ao pé da letra, e a exceção é a RN16d. O critério cobra SELECT count(*) ... WHERE cpf ~ '[^0-9]' = 0 em todas as colunas da RN16a; um valor sem dígito nenhum — phone_landline = "não tem" — continua lá depois do script, porque normalizá-lo produziria string vazia, que não é o formato canônico da RN1: é um dado inútil que passaria a mentir. O critério e a regra se contradizem no papel, e a RN16d ganha: "não inventa dígito e não descarta linha". Coberto por test_valor_sem_digito_nenhum_fica_como_esta.

A janela da faxina do e2e — decidida pelo usuário em 2026-08-25, e o motivo tem prazo. O globalTeardown apaga o que nasceu depois que a suíte começou, contra o banco compartilhado do dev-remote. O critério não distingue o que a suíte criou do que outra pessoa criou nos ~15 minutos de execução, e audit_logs está na lista, então o rastro some junto. Levado ao usuário porque não estava na spec — entrou durante a implementação — e porque é decisão de quem usa o ambiente, não da branch.

Aprovada como está, com a razão registrada: "não temos equipe hoje". Hoje o dev-remote não é compartilhado de fato, então a única coisa que a janela pode apagar é o que a própria suíte criou, e o risco que a tornaria discutível não existe. A alternativa — não limpar — já quebrou a suíte duas vezes por acúmulo, e nesta mesma spec três testes caíram por isso.

O motivo é de hoje, e é isso que precisa ser relido quando houver equipe. Não é que o desenho tenha sido julgado seguro: é que a condição que o tornava arriscado não vale agora. Havendo mais alguém usando o dev-remote, as saídas já existem — E2E_SEM_LIMPEZA=1 desliga a faxina, DB_CONTAINER é obrigatório e sem default, e o teardown relata linha a linha o que removeu —, mas quem for reavaliar deve começar por aqui, e não descobrir a janela depois de perder trabalho.

18. Registro

O que esta spec entregou, em uma frase por app

API: documento e telefone só existem em dígitos, com verificador conferido, e a recusa nomeia o campo e traz um texto que a pessoa consegue ler. Web: os campos ganham máscara, o erro fica no campo com a frase certa, e a tela leva até ele. E2E: 21 cenários novos, e a suíte devolve o banco como o encontrou.

O que mudou de plano no caminho

O script de normalização entrou depois da spec escrita, a pedido do usuário: a base já tinha os dois formatos, e sem ele a busca continuaria não encontrando quem já existia. Ele é a seção RN16 inteira.

A rolagem para o primeiro erro também não estava na spec original — foi pedida durante a Parte II e virou a RN12, com a descoberta de que a receita do docs/ui-patterns.md (Object.keys(errs)[0]) ordena por registro, não por posição na tela.

A faxina do e2e nasceu de um defeito de teste, não de produto. Três specs falhavam porque o banco compartilhado tinha 3799 imobiliárias acumuladas e a do seed caíra para a posição 53 de uma busca que carrega 50. O globalTeardown resolve o acúmulo entre execuções; a janela dele foi levada ao usuário e aprovada com razão datada (ver Defeitos aceitos).

O que a implementação ensinou, e não estava na spec

Dez rodadas de revisão de branch, e a maior parte dos achados eram defeitos que a correção anterior criou. Não é desatenção difusa: é que o apps/web não tem como provar uma correção. tsc verde só diz que compila, e a primeira leitura da correção é a do revisor — dez minutos e ~270 mil tokens por vez.

Duas frases desta seção estavam erradas, e a correção é da spec 0047. A versão anterior dizia que "a fase da API fechou em uma rodada" — a tabela acima registra aquela rodada como REPROVADA (2 Critical, 6 Important), e as correções dela foram absorvidas pelas rodadas de branch, que revisavam os três apps. E dizia que os três "testes que não podiam falhar" "estavam todos em território web": só um estava (o do foco, na rodada 4); os outros dois eram do pydantic, na API. Os dois erros puxavam para o mesmo lado — o meu argumento —, e é por isso que ficam registrados em vez de apenas corrigidos.

O que a evidência de fato sustenta está na tabela de red-green abaixo: a existência de suíte não impede teste que não pode falhar — ela apareceu nos dois lados, e quem os pegou foi leitura humana nos dois. O que a suíte muda é o custo de conferir que o teste morre: os nove testes da API foram verificados em segundos; os três do web custaram dois rebuilds do dev-remote cada um.

A recomendação que sai daqui é uma spec para pôr suíte unitária no apps/web, com os casos desta branch como primeira leva: a precedência do helperText, a decisão entre marcar no campo e mostrar na faixa, qual elemento é alvo da rolagem, a recusa da colagem que não cabe. Nada disso precisa de navegador, e tudo isso custou rodada de portão.

Três defeitos alheios apareceram no caminho e não são desta spec:

  1. A rota /settings/credit-analysis-documents exige credit_analysis_document_requirements:update, e a página tem modo leitura justamente para quem não tem essa permissão — quem deveria vê-lo nunca chega lá (apps/web/src/App.tsx:435, intocado desde a spec 0045).
  2. O ADMIN_GLOBAL lista inquilino de todas as garantidoras.
  3. Dois testes de imobiliária são intermitentes por desempate de UUID sobre created_at empatado no segundo — já documentado no CLAUDE.md da raiz, e medido nesta branch em ~35% de falha ao longo de 17 execuções isoladas.

Fase apps/api — o que aconteceu

A suíte fechou em 740 verdes, 0 vermelhos, partindo de 54 falhas logo depois de os tipos entrarem. O caminho até lá tem duas lições que valem mais que o número.

A ordem das duas correções em massa importava, e eu errei na primeira vez. Troquei os documentos com verificador inválido antes de tirar a pontuação — e a despontuação criou documentos novos: 123.456.789-00 vira 12345678900, cujo verificador não fecha. Foram precisas duas passadas do mesmo script, na ordem certa: despontuar, depois corrigir verificador.

Regex não alcança documento montado em tempo de execução. Quatro testes construíam CNPJ com f-string a partir de um sufixo (f"3000000000{suffix:0>4}"), o que produz verificador arbitrário. Nasceu apps/api/tests/documentos.py — cpf_valido(seed) e cnpj_valido(seed), que preservam a intenção ("um documento derivado do sufixo") e o fazem existir de verdade.

Um teste ficou obsoleto e foi atualizado, não afrouxado. O test_search_by_cpf buscava pelo fragmento pontuado 222.222; com a gravação só em dígitos ele não acharia nada. O termo virou 529490, e o que o teste prova — busca por fragmento de documento encontra a pessoa certa — continua igual. E ganhou dois vizinhos que provam a RN15c: % não devolve a lista inteira, e _ casa com o caractere e não com "qualquer caractere".

O script de normalização foi exercitado em modo relatório contra o dev-remote: 160 linhas mudariam — 99 CPFs, 38 telefones de inquilino, 23 telefones de imobiliária. Bate com o que a seção 3 mediu. Não foi aplicado: --aplicar é decisão de quem opera.

Fase apps/web e apps/e2e — o que aconteceu

Duas coisas só apareceram escrevendo, e as duas seriam defeito silencioso. O camposComErroDaEtapa1 lia o estado errors logo depois do validate() — mas o React ainda não pintou, então ele leria o mapa anterior; virou uma ref com o mapa recém-calculado. E a ref do formulário existia sem estar ligada a elemento nenhum, o que faria a rolagem procurar no documento inteiro em vez de dentro do formulário.

Verificado no navegador, em 375px: 12345678000195 vira 12.345.678/0001-95, o telefone vira (11) 98765-4321, o inputmode é numeric, não há placeholder de formato, e o CNPJ com verificador errado acusa CNPJ inválido — confira os números no campo, com foco — enquanto os telefones sem erro seguem mostrando o texto de ajuda. É a distinção que estava quebrada.

A suíte e2e: 345 verdes, 10 vermelhas — e uma era minha

A minha: o onboarding-leva-ao-painel montava o CNPJ com um carimbo de tempo preenchido com zeros, o que dá verificador arbitrário. A RN8 passou a recusar, a garantidora nunca nascia, e o teste estourava esperando tenant_id. Corrigido calculando os verificadores a partir do mesmo carimbo — nasceu apps/e2e/fixtures/documentos.ts, irmão do apps/api/tests/documentos.py. O teste prova o mesmo; o documento passou a existir.

As outras nove são do ambiente, e a prova é aritmética. O dev-remote acumulou 3799 imobiliárias desde julho, e a do seed é de 28/07. O helper que os testes de garantia usam busca por Imobiliária E2E com limit=50 — e a busca de imobiliária extrai os dígitos do termo, encontrando o 2 de "E2E", o que acrescenta OR cnpj ILIKE '%2%' ao filtro. O conjunto vira 53 registros, e a do seed fica na posição 53: três fora do limite. Nada disso é desta branch — partner_agency_repo.py não foi tocado.

O mesmo acúmulo explica o resto: o properties procura essa imobiliária num autocomplete que carrega 50 opções, e as três de isolamento procuram o inquilino mais antigo dos 16 da imobiliária, com o DataGrid renderizando 14 no viewport do teste — os 15 mais novos são de execuções anteriores, o mais recente de 18/08. E o admin-interface é o único arquivo que faz login pela interface a cada teste: sete logins de ~20s contra um timeout de 20s. A composição das falhas mudou entre duas execuções com o mesmo código, e cada uma passa isolada.

Isto merece um trabalho próprio, e não é este. Três testes estão a poucas posições de falhar em definitivo, por dado acumulado num banco compartilhado. Está registrado aqui porque a próxima pessoa que vir a suíte vermelha vai suspeitar do próprio diff primeiro — e a conta acima é o que a poupa de procurar no lugar errado.

As citações da spec, depois da implementação

O make preflight acusa 23 citações, e as 23 estão certas. Conferi todas mecanicamente (arquivo existe, linha existe) e li uma a uma: apontam para porteiro, handler, helper e guarda, exatamente como a spec afirma. O que a verificação faz é casar o trecho em crase vizinho à citação com o conteúdo do arquivo — e na tabela de superfícies de leitura o vizinho é descrição em português, não código.

Duas classes de citação foram corrigidas de verdade no caminho, e essas eram defeito: caminhos abreviados (pages/agencies/index.tsx) viraram caminho completo a partir da raiz; e as citações cujo conteúdo esta implementação mudou — o cpf: str | None que virou AppCPF, o ilike sem escape — perderam o número de linha, porque mantê-lo apontaria para uma linha que hoje diz o oposto do que a frase afirma. A do validation_error_handler mudou de lugar e foi atualizada.

Anotações durante a implementação: o que mudou em relação ao planejado e por quê. Preencher com /spec implement.