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.
- 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.
- A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
- 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.
- 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, porquecpfecnpjsã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úblicoAGENCY_USERe 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.OPERATOReADMIN_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ê) eADMIN_GLOBAL(administra a plataforma, não o dado de negócio do cliente).
3. A jornada de hoje¶
O caminho, ponta a ponta¶
- A imobiliária fecha o aluguel e precisa da garantia. Abre a análise de crédito.
- 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.
- 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.
- 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.
- Preenche o resto — imóvel, pacote locatício, prazo — e salva.
- 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.
- Passa para a etapa de documentos, anexa o que precisa, e envia para análise.
- 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,cpfecnpjsãostr | Nonesem 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
messageedetails[]— a lista de campos com problema, montada emapps/api/app/core/exceptions.py:157-165—, mas a tela guarda só amessage(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:
- 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 comhelperText={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,:405e:417). Havendo erro sem mensagem — uma regra escrita comorequired: trueem vez derequired: "texto", uma função de validação devolvendofalse, 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. - 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 emnullouundefined. Um chamador que passeerror={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 deerror={nos usos deMaskedController), então este mecanismo é armadilha armada, não causa provada — mas está na mesma linha do defeito e sai junto. - Não há regra nenhuma nos telefones. Nenhum dos três
MaskedControllerde telefone receberules(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. - 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 (
validateCPFevalidateCNPJ, 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:38e:50,apps/api/app/schemas/tenant.py:41e:56,apps/api/app/schemas/onboarding.py:115e: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 existeAppCPF. - 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 listadetails[]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-522descreve rolar até o primeiro campo inválido no envio. Nenhum formulário do app faz isso: não há uma única chamada desetFocusoushouldFocusErroremapps/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:
- 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.
- 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.
- 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.
- 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ê.
- 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.
- 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:
- A citação que não provava o mecanismo.
CreditAnalysisForm.tsx:641era o cabeçalho dehandleSubmitForAnalysis; quem prova a afirmação são:644(oanalysisId ??que pula o gravar) e:516(oif (!validate()) return nullque ele contorna). As duas entraram no lugar da primeira. - O tutorial estava citado costurando dois campos —
tituloefrasedo 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 opublicoe odestinodeclarados no arquivo, que são o que de fato sustentam o argumento. - "Caminho obrigatório" lia-se como se fosse do papel, e três linhas abaixo o texto dizia que
OPERATOReADMIN_TENANTtambém abrem análise. O obrigatório é da tela; ficou escrito, para a Parte II não nascer escopada a um papel só. - 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_documentemapps/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.tsxrenderiza peloGuaranteeSections, com grade responsiva e um ponto só;apps/web/src/pages/renters/index.tsxcompartilhadocOfephoneOfentre 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.tse 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:1035e:1131, eapps/web/src/pages/credit-analyses/components/GuaranteeCreateDialog.tsx:283. Oapps/web/src/pages/credit-analyses/show.tsxaparece 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.
- 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.
- 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. - 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.
- 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.
- 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.
- 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.
- 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.
- 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-522usa 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.
- 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.
- 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.
- 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-512encaminha 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-00antes 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_documente todas as colunas de telefone — não vale paraowner_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
AppCPFe oAppCNPJexistente 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 | Nonepuro hoje (apps/api/app/schemas/renter.pye:71), nãoAppCNPJ— 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.cnpjeproperties.owner_documenttê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 (oAppCPFé 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:664afirma que a resposta devolve"222.222.222-22"pontuado — leitura que a RN1 invalida —, etest_tenant_isolation.py:645posta 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_documenttambé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. NasceAppPhone(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. Eowner_document(apps/api/app/schemas/property.pye:76) passa a exigir dígitos com onze ou catorze posições e verificador válido. Omax_lengthsai junto onde o tipo novo entra — o=18doowner_documente o=20de todas as colunas de telefone que recebemAppPhone(renter.py:53-55e:74-76,partner_agency.py:40-42e:52-54,tenant.py:43-45,:48-50,:58-60e:70-72,onboarding.py:116,:119-120,:123-125,:133-135e: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.pyeapps/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_mobilenã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 estefieldé, aqui. E quando não há rótulo, o que acontece com umfieldque o formulário não conhece (rg,cep, ou um índice de lista, já que a API monta o nome comloc[-1]emapps/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
AfterValidatorsai 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 deapps/api/app/core/exceptions.pynão o alcança porque_PYDANTIC_MESSAGESsó tem a chavevalue_error.email(:33), que é de pydantic v1 e nunca casa. Isso é invisível hoje só porque o web descartadetails[]— 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
fieldda API é casado com o campo da tela por um mapa declarado, não por coincidência de nome. A API nomeiacpf,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 postacpf/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, oparticipantsaninhado 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
roleerenter_idpara "o controle correspondente da linha", e abria exceção só paradeclared_income— mas os três campos vêm do mesmoparticipants[i]e perdem o índice pelo mesmoloc[-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:
- Escapar na tela não funciona sem o servidor cooperar. O
.ilike()daquele repositório é chamado sem cláusulaESCAPE. 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. _é legítimo. Aquela busca cobre tambémemail, e sublinhado em e-mail é comum. Remover o curinga em vez de escapá-lo quebraria a busca por e-mail; escapar preserva.- 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_countdo 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(cnpje os três telefones),tenants(cnpj, os três telefones e os trêscontact_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 transformariacontato@imob.com.bremcontatoimobcombr. - 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)departner_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_documentssem 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. Orbac_seed.py:156concede a ele todas as permissões menos duas de dossiê — inclusiverenters:list—, e orenter_repo.search_with_countsó aplicaRenter.tenant_id == tenant_idquando hátenant_id. Ou seja,GET /api/v1/rentersjá 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
/fixpró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 umgit revertde 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_GLOBALnã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 seede domake 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 temdowngradeporque 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 doAppCNPJque já existe (_validate_cnpjem:30-36, o tipo em:39): nasceAppCPF, e o_validate_cnpjpassa 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; oapps/webusacpf-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 —, sobapps/api/tests/, e o e2e a lê do mesmo arquivo. O formato é parte da decisão:apps/api/tests/é pacote Python eapps/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.pytem quatro classes, e o tipo entra em duas:RenterCreate(:48-49) eRenterUpdate(: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
AppCNPJque já existem herdam a conferência do verificador sem mudar de linha — e todos os seis já são de entrada (partner_agency.py:38e:50sãoCreate/Update;tenant.py:41e:56idem;onboarding.py:115e:131també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.pyescapa%e_antes doilikee passaescape="\\"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.pyremove o prefixoValue error,que o pydantic acrescenta à mensagem doAfterValidator. 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 doinit_db.pye dorbac_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.pyeapps/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
AppCNPJrecusando o pontuado, como já faz, e agora também o verificador errado; o script idempotente (segunda execução muda zero linhas), preservandoowner_contacte não alterando documento de comprimento inválido além da pontuação; e os negativos de acesso de 10.1 — oAGENCY_USERde 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:99e:100(RN11a e RN11c) alcançam todas as telas de uma vez.apps/web/src/shared/utils/masks.tsganha 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). Oapps/web/src/pages/renters/form.tsxjá temvalidatede documento;apps/web/src/pages/my-garantidora/index.tsxeapps/web/src/pages/onboarding/StepTenant.tsxtinham CNPJ editável comrules={{ 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/eadmin/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-145e:154-156,TenantListPage.tsx:293e:409,apps/web/src/admin/pages/dashboard/AdminDashboardPage.tsxe:206); quatro no detalhe do inquilino, porque ele tem dois layouts (apps/web/src/pages/renters/show.tsx:131,:145-147,:199e:217-219); e dois no dossiê da garantia (apps/web/src/pages/guarantees/components/GuaranteeSections.tsx:90e: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(osearchque vai para a listagem) eapps/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_e2ejá 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 colunacpfguarda13977867288. (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_documentcom 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-4321num 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 o00000-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, comCPF inválido — confira os números, sem chamar a API. (RN8) - [x] CA9.
POST /api/v1/renterscom"cpf": "13977867289"responde 422 nomeando o campocpf. (RN8, RN8a) - [x] CA10.
PATCHde imobiliária com CNPJ de catorze dígitos e verificador inválido responde 422 — hoje responde 200. (RN8b) - [x] CA10c.
POST /api/v1/renterscom"phone_landline": ""num campo opcional grava sem erro — string vazia é ausência, não telefone inválido. (RN8f) - [x] CA10d.
POST /api/v1/tenantscom"phone_mobile": ""— campo obrigatório — responde 422 comTelefone é obrigatório, e não com a mensagem de formato. (RN8f) - [x] CA10a.
POST /api/v1/renterscom"phone_mobile": "(11) 98765-4321"responde 422; com"11987654321", grava. (RN8d) - [x] CA10b.
PATCHde imóvel cujoowner_documentgravado tem doze dígitos responde 422 enquanto o documento não for corrigido, nomeando o campo — e oGETdo 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 nomeandocpf, 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
saveStep1que 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 osubmitda 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
DocumentsStepe 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-88na 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 Silvanos 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_USERda 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_TENANTda 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_contactgravado comocontato@imob.com.brcontinua igual depois do script. (RN16c) - [x] CA26. Um
owner_documentgravado como123.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) vira1234567890— perde a pontuação, e não ganha nem perde dígito. (RN16d) - [x] CA27. Com duas garantidoras gravadas como
12.345.678/0001-95e12345678000195, 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) etest_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—AppCPFeAppPhonenovos; verificador noAppCNPJ; 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 ocpf-cnpj-validatordo 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_lengthde onde o tipo novo entrou —owner_documente 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 prefixoValue 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 noilike(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:41e:56,apps/api/app/schemas/onboarding.py:115e: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.tsxprimeiro — é 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.tsxnão defineinputModeem 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-testidda 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.mdo 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 testemake lintpassando - [ ] 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.
- Os três telefones do
PartnerAgencyUpdateficaramstr | None. Meu regex exigiamax_length=20,com vírgula, e naquela classe os campos não têmdescription— terminam emmax_length=20). O efeito era concreto:PATCHde 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. AfterValidatornão revalida o retorno contra a anotação._validate_cnpjdevolveNonepara""(RN8f), e num campo obrigatório o pydantic aceita esseNonesem reclamar:POST /api/v1/tenantscom{"cnpj": ""}passou de 422 a 201 com CNPJ nulo. Eu tinha criado oAppPhoneRequiredpara o telefone e não vi que o documento obrigatório precisava do mesmo. Nasceu oAppCNPJRequired, 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.
- O
resolveEntitiesengolia a recusa antes docatchque a trataria. O cadastro de inquilino e o de imóvel dentro da análise capturavam oApiErrore faziamsetRenterError(err.message)— eerr.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_valueeguarantee_monthschegavam, porque vêm por outro caminho. - A RN5 não estava implementada, e o comentário dizia que estava. Ele mandava procurar a detecção de sobra "em
excedeu", eexcedeunão existe noapps/web. ComIMask, colar+55 11 98765-4321entregava55119876543truncado — número plausível e errado, sem erro para ler. Os ramosd.length > CAPACIDADE.*demasks.tseram 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. - 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; virouaplicarErrosDaApi, emshared/utils/foco-no-erro.ts, e os dois passam por lá. - 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 mesmosearchque 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:
declared_incomeentrou 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 comloc[-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.- Dois dos dezenove campos de telefone ficaram sem conferência — os
phone_landlinede 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. - No diálogo da RN14, toda linha levava ao mesmo campo.
irAoCamporecebia a chave e a usava só para decidir se fazia algo, chamando em seguidairAoPrimeiroErro. 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 olevarAoCampoe o mapa dedata-testidpor 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
ImportantsobreaplicarErrosDaApiera real e virou correção — erro posto em campo não registrado trava ohandleSubmitpara 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
Minorsobre opreflightnã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 dohandleSubmit. Depois de umreset(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: oregisterpõe onameno<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:
- Sessão perdida (
admin-interface,mobile-shell): o instantâneo da página mostra a tela de login. É a armadilha que oCLAUDE.mddo e2e descreve —page.gotoem 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. - 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. - Defeito real e anterior a esta branch (
credit-analysis-documents, CA11): a rota/settings/credit-analysis-documentsexigecredit_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/fixpró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:
_colisoesnão usava a regra doplano. 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 oplanonã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.
aplicarErrosDaApidevolvia 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
RenterResponseficou sem guarda. Ao trocar a introspecção tautológica por comportamento, cobriRenterSummaryePropertyResponsee esqueci a terceira classe que a §11 nomeia. PôrAppCPFnela dava 500 emGET /renters/{id}de registro legado, com a suíte verde. - A varredura de exemplos não alcançava
modelsnemdb— e foi justamente emapp/models/renter.pyque esta spec precisou corrigir à mão um000.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.
aoSobraracrescenta umAlertno 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
useStatenão bastava, e isso foi medido.Record<string, string>é atribuível aPartial<Record<K, string>>, então o objeto local dovalidate()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é eleficou vermelho com odata-erroremovido doSelectionBlock, acusando "nenhum bloco ficou marcado".cai na faixa do topo em vez de sumirficou 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 faixanão foi morto de propósito — ele existe para medir uma corrida, e a mediu: a leitura dearia-invaliddentro dorequestAnimationFrameacontece 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 osdetails[]caíam no bloco — recusa emrenter_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"noMaskedFieldé 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
submitda 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 portest_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=1desliga 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, 6Important), 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:
- A rota
/settings/credit-analysis-documentsexigecredit_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). - O
ADMIN_GLOBALlista inquilino de todas as garantidoras. - Dois testes de imobiliária são intermitentes por desempate de UUID sobre
created_atempatado no segundo — já documentado noCLAUDE.mdda 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.