0015 — A recusa do servidor não aponta o campo em três formulários¶
| Status | CORRIGIDO |
| Branch | fix/0015-recusa-da-api-nao-aponta-o-campo (mesclada na main em 5f690d5) |
| Aberto em | 2026-08-29 |
| Fechado em | 2026-08-30 — v0.4.2 em produção |
| Severidade | MÉDIA |
| Apps afetados | web |
| Revisão do diagnóstico | fechada na rodada 7 por decisão do usuário — ver a seção 5 |
| Revisão da correção | sem o laço automático do /fix implement (sem subagente de contexto limpo) — o usuário acompanhou e revisou os 16 itens em conversa direta e autorizou o fechamento explicitamente. Ver a seção 11. |
Status:
RASCUNHO→DIAGNOSTICADO→EM CORREÇÃO→CORRIGIDO
O fix atravessa dois portões, cada um revisado por um subagente de contexto limpo, em até 3 rodadas. Máquina do plugin superpowers, régua em .claude/rubrics/. Qualquer Critical reprova; Important em aberto reprova salvo decisão registrada do usuário.
- O diagnóstico (seções 1 a 4) é revisado na seção 5 (reprodução × causa raiz). É o portão barato: sem ele, o segundo mede o conserto contra um palpite.
- A correção (o diff e os testes) é revisada na seção 11 (correção × regressão), com duas perguntas — atacou a causa ou o sintoma, e o que prova que não volta.
1. Sintoma¶
A tela manda conferir detalhes que ela não mostra. Salvando um formulário cujo campo o servidor recusa, aparece uma faixa vermelha no topo com:
Alguns campos precisam ser corrigidos. Verifique os detalhes abaixo e tente novamente.
E não há nada abaixo. Nenhum campo fica vermelho, nenhuma mensagem diz qual é o problema, e o formulário fica idêntico ao que estava antes do clique. A pessoa relê a tela inteira sem ter o que reler.
Acontece em três dos seis formulários que a spec 0046 tratou — e, como a seção 4 mostra, em muitas outras superfícies de entrada de dados pelo produto afora, incluindo a decisão de crédito. As três abaixo são por onde o defeito foi encontrado:
| Tela | Arquivo |
|---|---|
| Minha Garantidora | apps/web/src/pages/my-garantidora/index.tsx |
| Cadastro de inquilino | apps/web/src/pages/renters/form.tsx |
| Onboarding, dados da garantidora | apps/web/src/pages/onboarding/StepTenant.tsx |
Nos outros três — imobiliária, imóvel e análise de crédito — a mesma recusa marca o campo e mostra a mensagem nele.
É o mesmo defeito que a spec 0046 corrigiu, e que a seção 1 da spec 0047 descreve como "o erro aparecer sem dizer o que fazer". A 0046 o corrigiu em três telas; nestas três não.
2. Reprodução¶
O campo é o Estado, e a escolha dele não é acaso — ver a nota do e-mail abaixo, que explica o caminho que não serve.
- Abrir Minha Garantidora autenticado como
ADMIN_TENANT. - No campo Estado, escrever
São Paulopor extenso. - Clicar em Salvar.
O campo é texto livre na tela (apps/web/src/pages/my-garantidora/index.tsx:337, um register("state") sem rules e sem limite de tamanho), e o servidor o declara max_length=2 (apps/api/app/schemas/tenant.py:67). Nove caracteres produzem um 422 real, com {"field": "state"} no details[].
- Ambiente: local, dev-remote ou produção — mas o dev-remote precisa de um passo a mais, ver a pré-condição abaixo.
- Superfície: os dois viewports, e igualmente. O defeito é de tratamento de resposta, não de layout; nada muda em 375px, e o PWA não agrava.
- Dados/pré-condições, e esta é a parte que engana: a garantidora precisa ter Razão social, CNPJ válido e Celular já gravados. São os três campos com
rulesno formulário (apps/web/src/pages/my-garantidora/index.tsx:171,:198e:244), e ohandleSubmitreprova antes de chamar o envio se qualquer um faltar.
A garantidora que o seed_e2e cria não tem celular — apps/api/app/db/seed_e2e.py:208 monta o Tenant só com nome, CNPJ e onboarding_step. Num dev-remote recém-semeado, os passos acima produzem uma tela que não faz nada: nenhuma requisição sai, e o que se vê é o sósia do sintoma, por uma causa que nada tem a ver com este defeito.
Preencher o Celular é incondicional, e não uma alternativa. Uma versão anterior desta nota oferecia "ou reproduza em city, street ou neighborhood, que são register puro". A parte sobre register puro é verdadeira e não ajuda: o required do celular reprova o handleSubmit inteiro, qualquer que seja o campo em que se digite. Medido, no seed como está: digitar 120 caracteres em Cidade e salvar produz zero requisições. A alternativa levava de volta ao mesmo sósia que o parágrafo existe para evitar.
O e-mail parece o caminho e não é — o navegador intercepta antes. A primeira versão deste registro mandava pôr
contatono e-mail comercial, com o argumento de que a tela não confere e-mail. A metade sobrerulesé verdadeira; a conclusão é falsa: os campos sãotype="email"(apps/web/src/pages/my-garantidora/index.tsx:204eapps/web/src/pages/my-garantidora/index.tsx:362), nenhum formulário do repositório usanoValidate—grep -rn noValidate apps/web/srcdevolve zero — e o Salvar étype="submit"dentro do<form>. A validação de restrição do navegador aborta o envio e mostra o balão "Inclua um '@' no endereço de e-mail": nenhuma requisição sai, e a faixa nunca aparece.Isso importa além do roteiro: um critério de aceite escrito sobre aquele caminho passaria antes e depois da correção, medindo um trajeto que não chega ao servidor.
Também reproduz por teste, e ele já existe. A suíte da spec 0047 registra o estado defeituoso de propósito:
apps/web/src/pages/renters/form.test.tsx:117afirmaaria-invalid="false"e que a mensagem específica não está no DOM — é a asserção forte;apps/web/src/pages/my-garantidora/index.test.tsx:156afirma só que o texto do campo não é a mensagem. É mais fraca, e é ela que precisa virar vermelha naquela tela.
A resposta que o caso simula ali é um 422 nomeando o campo do CNPJ. CNPJ duplicado, no servidor, é 409 sem details[] — o raise ConflictError de apps/api/app/services/tenant_service.py:54. O defeito que o caso demonstra é real, e vale para qualquer 422 com details; o que não é real é o exemplo escolhido, que o servidor não produz para aquele campo.
3. Esperado × Atual¶
- Esperado: cada mensagem de
details[]aparece no campo a que pertence, marcando-o; o que não tiver onde ser lido — campo registrado semerror/helperText, ou nome que não existe na tela — vai para a faixa do topo, com o rótulo do campo em português. É o que a spec 0046 desenhou. - Atual: todas as mensagens de
details[]são descartadas. A faixa exibe a frase genérica da API, que anuncia detalhes inexistentes — exceto na área admin, onde é pior:apps/web/src/admin/services/adminApi.ts:57lê a chavedetail, que este envelope não tem, e o que aparece é"Erro 422". Nem a mensagem do servidor sobrevive.
4. Causa raiz¶
Onde. Os três catch do envio, cada um numa linha só:
apps/web/src/pages/my-garantidora/index.tsx:126—setSaveError(err instanceof Error ? err.message : "Tentar novamente")apps/web/src/pages/renters/form.tsx:149—setError(e instanceof Error ? e.message : "Tentar novamente")apps/web/src/pages/onboarding/StepTenant.tsx:71—setError(err instanceof Error ? err.message : "Tentar novamente")
O mecanismo. O erro chega às telas carregando duas coisas — .message e .details — e o catch lê só a primeira. Mas não há um transporte só, e essa é a informação que muda o alvo da correção — a tabela deles está no Eixo 1, adiante, com os cinco que existem hoje.
A segunda linha é a mais perigosa, e só apareceu na quinta rodada do portão. A primeira linha de aplicarErrosDaApi (apps/web/src/shared/utils/foco-no-erro.ts:223) é uma guarda de tipo — if (!(e instanceof ApiError) || e.details.length === 0) return false. Um ApiValidationError falha nessa guarda: a helper devolve false sem marcar campo nenhum, mesmo com details cheio. Chamar a helper ali compila e não faz nada — a faixa genérica continua sozinha, e o sintoma sobrevive num lugar onde o registro diz que foi entendido. A classe não é lida por ninguém hoje: grep -rn ApiValidationError apps/web/src acha só a declaração e o throw.
O .details do transporte principal vem de body.error?.details — e o servidor sempre preenche esse campo numa recusa de validação: apps/api/app/core/exceptions.py:158 transforma cada erro do Pydantic em {field, message} e devolve a frase "Alguns campos precisam ser corrigidos. Verifique os detalhes abaixo e tente novamente." como mensagem geral. O handler é global (apps/api/app/main.py:511), não por endpoint.
As três linhas acima leem só o .message. O .details existe no objeto, com o nome do campo e o texto em português, e é jogado fora ali — nada mais o lê depois. Por isso a faixa mostra exatamente a frase que promete detalhes, e não há detalhe nenhum: a promessa e o descarte são a mesma resposta HTTP, lida pela metade.
Não é regressão: é lacuna de origem. As três linhas nasceram com as próprias telas e não foram tocadas pela spec 0046 — conferido com git log -L sobre o trecho da Minha Garantidora, que só mudou na remoção de i18n da spec 0009.
Há dois tratamentos certos no repositório, e não um — isto muda o alvo da correção.
aplicarErrosDaApi(apps/web/src/shared/utils/foco-no-erro.ts:217) é chamada por duas telas:apps/web/src/pages/agencies/form.tsx:130eapps/web/src/pages/properties/form.tsx:175. Ela marca cada campo que existe no DOM, devolve para a faixa o que não ficou comaria-invaliddepois do repintar, e respondefalsequando o erro não é recusa por campo.- A análise de crédito trata corretamente e não a usa: tem implementação própria,
tratarRecusaDaApi(apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:548). O motivo está no cabeçalho da própria helper: aquele formulário guarda erro sob chave própria —renterDoc,propStreet— e precisa traduzir o nome da API antes de marcar.
Das três telas do sintoma, a helper pronta serve em duas. Em my-garantidora e em renters/form os campos são registrados com o nome da API (email, contact_email, cnpj, cpf, state) e o erro chega como ApiError — ali é chamar a função que existe. O StepTenant é servido pelo ApiValidationError e não passa na guarda: consertá-lo exige tratar aquele transporte também, e não só trocar a linha do catch.
O alcance é maior que três telas, e a régua que o define levou quatro rodadas de portão para ficar de pé. Vale escrever a régua antes da lista, porque foi a falta dela que fez a lista mudar quatro vezes.
A régua do escopo, uma só, e a unidade é o catch: todo catch que trata a recusa de um envio de dado digitado pelo usuário e lê só .message.
A unidade importa: um arquivo costuma ter vários pontos de tratamento, de naturezas diferentes. O DocumentsPanel tem cinco catch de bloco — observação digitada (dentro), substituição de arquivo, exclusão, download e pré-visualização (fora) —, mais dois .catch(); o InviteListPage tem quatro, e só a emissão do convite está dentro. Dizer "arquivo" faria a régua incluir carregamento de lista, que não envia nada.
E "
catch" é estreito demais por pouco:apps/web/src/components/shared/DocumentsPanel.tsx:427tratava a recusa do envio numPromise.allSettledlendo só a mensagem do erro, semcatchnenhum — corrigido no item 2 desta lista. Era o únicoallSettleddosrc, então o furo era de um sítio só, mas uma varredura porcatcho teria perdido. A unidade é o ponto onde a recusa do envio é lida, e não a palavra-chave.
As três partes fazem trabalho:
- "dado digitado" exclui tela de lista que só apaga ou alterna um booleano (
pages/users/index.tsx,admin/pages/tenants/TenantDetailPage.tsx): não há campo, não hádetails[]possível, e não há como escrever regressão — só sobraria um teste que passa contra qualquer código; - "superfície", e não "arquivo com
TextField", porque em vários casos o campo mora num diálogo filho e ocatchmora na tela. O exemplo éapps/web/src/pages/credit-analyses/show.tsx:1276, o diálogo de reprovação: ele monta umConfirmDialogsemreasonMaxLength, então oreasonnão tem limite na tela e alcança omax_length=500do schema. (OPendDialognão serve de exemplo: ele espelha o limite emapps/web/src/pages/credit-analyses/components/PendDialog.tsx:284.) Buscar por campo no mesmo arquivo perde justamente esses — foi assim que a tela mais importante do produto ficou fora das três primeiras listas; - "lendo só
.message" exclui quem já trata —agencies/form.tsx,properties/form.tsxeCreditAnalysisForm.tsx— e exclui quem apenas repassa o erro sem o descartar:providers/api-fetch.ts, que o constrói com odetailsinteiro, epages/renters/api.ts, que devolve o que oapiReqlevantou.
Não confundir "repassa" com "reconstrói", e a diferença tem nome próprio: os módulos da tabela de transportes — shared/api/documents.ts, admin/services/adminApi.ts e providers/auth.ts — constroem um erro novo e descartam o details antes de a tela existir. Uma versão anterior desta linha punha o documents.ts entre os que "repassam", e quem confiasse nela classificaria o painel de documentos no balde errado. Nesses três, o conserto é do módulo, e não de cada tela que ele serve.
E um caso ficava dentro pela intenção e fora pela letra: apps/web/src/pages/users/form.tsx:155 lia o details só para acender um booleano no campo, e descartava as mensagens — corrigido no item 15 desta lista. Não lia "só .message", e tinha o mesmo defeito: a pessoa via o campo vermelho sem saber o que havia de errado nele.
A correção NÃO é uniforme, e descrevê-la por "famílias" foi o terceiro defeito desta seção. A régua diz onde a causa morde; o que "tratar a recusa" significa depende de dois eixos independentes, e não de uma classificação só. Foi a mistura dos dois que produziu, na versão anterior, uma tabela que mandava chamar a helper em duas telas que não têm formulário — e tsc não pega: passar um setError de useState compila, a helper devolve true, e o que chega à faixa é ["email"], o nome cru do campo. Pior que o sintoma de hoje.
Eixo 1 — por qual transporte o erro chega. Decide se o details sequer alcança a tela.
| Transporte | Onde | O que chega à tela | Quem é servido |
|---|---|---|---|
ApiError com .details | apps/web/src/providers/api-fetch.ts:131 | mensagem e details | quase todo o produto |
ApiValidationError — classe própria, não estende ApiError | lançada em apps/web/src/pages/onboarding/api.ts:55, declarada em apps/web/src/pages/onboarding/api.ts:10 | mensagem e details, num tipo que a helper não reconhece | StepTenant, register, verify-email, accept-invite |
Error cru, details descartado | apps/web/src/shared/api/documents.ts:47 (mensagemDeFalha) | só a mensagem | DocumentsPanel, e todo upload de documento |
Error cru, lendo a chave errada | apps/web/src/admin/services/adminApi.ts:57 — lê err.detail, e este envelope usa {error:{…}} | "Erro 422" — nem a mensagem do servidor | toda a área admin, incluindo a emissão de convite |
reconstruído no provedor de autenticação, details descartado | apps/web/src/providers/auth.ts:79 (login), :170 (recuperação, que descarta até a mensagem do servidor), :196 (redefinição) | só a mensagem, e numa delas nem isso | login, recuperação e redefinição de senha |
Os dois transportes do meio só apareceram na sétima rodada, e servem sítios que este mesmo registro nomeia em escopo: o Promise.allSettled do DocumentsPanel recebe o Error cru do documents.ts, e a emissão de convite do InviteListPage recebe o "Erro 422" do adminApi. Nos dois, chamar a helper compila e devolve false — a mesma forma do defeito da classe ApiValidationError. Medido: com o 422 real da API, o módulo de documentos entrega um Error sem details, e aplicarErrosDaApi não marca campo nenhum.
Eixo 2 — quem é dono do estado de erro do campo. Decide o que se faz com o details depois de tê-lo.
| Dono do estado de erro | Exemplos | O que "tratar" exige |
|---|---|---|
react-hook-form | my-garantidora, renters/form, StepTenant, register, verify-email, accept-invite | o setError do formulário — é o que aplicarErrosDaApi consome. Os quatro últimos são o cruzamento difícil: têm formulário e são servidos pelo ApiValidationError |
| estado próprio da página | apps/web/src/pages/users/form.tsx:80, apps/web/src/pages/roles/form.tsx:36 (useState<string \| null>) | a mensagem não tem onde pousar. O users/form chega a acender um booleano por campo (emailComErro, :89), mas o TextField não tem helperText: marca o campo e não diz o que há de errado nele |
o campo mora num componente e o catch, noutro | o ConfirmDialog de reprovação em apps/web/src/pages/credit-analyses/show.tsx:1270 | prop nova no componente; hoje ele só aceita error da caixa inteira (apps/web/src/components/shared/ConfirmDialog.tsx:85), e o de reprovação nem isso recebe |
| campo por linha, com estado indexado | DocumentsPanel, cujo notesDraft é por documento (:311) e cujo catch é do próprio painel (:460) | conserto interno, sem prop nova — mas o estado de erro precisa ser mapeado por documento, e o ConfirmDialog (um campo, um diálogo) não é molde |
| não há campo | listas que só apagam ou alternam | fora de escopo |
O conserto é o cruzamento dos dois, e é por isso que não há receita única. As telas de conta são o exemplo que derruba qualquer classificação de um eixo só: register, verify-email e accept-invite usam react-hook-form — e portanto parecem o caso fácil — e são servidas pelo ApiValidationError, que não passa na guarda da helper. Chamar aplicarErrosDaApi nelas compila e não faz nada.
O precedente da análise de crédito não serve para
credit-analyses/show.tsx, e a versão anterior desta seção dizia que servia. OtratarRecusaDaApi(apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:548) pressupõe uma página com formulário próprio:formRef, um estadosetErrorsque cada campo consome, e quatro mapas locais de nome e ordem. Ele resolve "tenho campos, sob chaves próprias em vez dereact-hook-form". A tela de exibição da análise não tem campo nenhum — o único recusável mora dentro doConfirmDialog, cujo estado é privado. Ali o conserto passa por mudar o componente compartilhado, e isso é trabalho de outra natureza.
A segunda régua, essa sim, é de teste e não de escopo. Onde não há 422 com details[] alcançável, a correção continua valendo — é barata e resiste ao dia em que o schema apertar —, mas não há o que afirmar num teste.
Prova de regressão, porém, só onde há 422 com details[] alcançável hoje — senão o teste não teria o que afirmar. Alcançável quer dizer: o schema da API tem restrição de campo (max_length, min_length, gt=, validador) que a tela não espelha, e nada intercepta antes — nem validação nativa do HTML, nem rules do formulário.
Confirmados por leitura de schema, e são a espinha da regressão:
| Superfície | O que estoura | Onde está a restrição |
|---|---|---|
pages/my-garantidora/index.tsx | state com mais de 2 letras | apps/api/app/schemas/tenant.py:67 |
pages/credit-analyses/show.tsx | reason acima de 500 caracteres | apps/api/app/schemas/decision.py:62 — o diálogo de reprovação (apps/web/src/pages/credit-analyses/show.tsx:1270) não recebe reasonMaxLength, então nada limita na tela |
pages/register/index.tsx | name acima de 255 caracteres | apps/api/app/schemas/auth.py:52 — a tela espelha só o minLength: 2 (apps/web/src/pages/register/index.tsx:319) |
Uma quarta linha saiu daqui, e o motivo é o mesmo erro das listas antigas. O
BillingPayDialogestava na tabela por teramount_paid: gt=0no schema (apps/api/app/schemas/billing.py:50). Mas o critério tem duas metades — schema e tela —, e a tela espelha:apps/web/src/pages/guarantees/components/BillingPayDialog.tsx:43calculaisValid = amountPaid && parseFloat(...) > 0e o botão édisabled={!isValid || loading}(:71). Valor zero ou negativo não sai. A tabela tinha sido montada lendo só o schema, que é literalmente metade do critério que o parágrafo acima escreve.Numa das três, o sintoma nem é o da seção 1, e quem escrever a regressão precisa saber: em
credit-analyses/show.tsxocatch(:424) fazsetErrorda página, mas o diálogo continua aberto — e o próprioapps/web/src/components/shared/ConfirmDialog.tsx:81-86documenta que nesse caso a faixa fica atrás do backdrop. Ali não há faixa vazia: não há faixa visível nenhuma.
Quatro varreduras por padrão de texto produziram quatro listas (quinze, dezoito, vinte, e uma quarta com falso positivo), sempre pelo mesmo motivo: grep acha o arquivo e perde o vizinho, e não distingue quem envia dado digitado de quem só transporta o erro. O que fechou a enumeração não foi uma quinta varredura — foi trocar a fonte por uma lista fechada: o inventário de telas do produto, hoje documentado em apps/web/CLAUDE.md (seção "Inventário de telas — a unidade de escopo de uma correção") (todo item do menu do tenant, todo item do menu do SaaS admin, e as telas de fora do menu — login, registro, recuperação de senha, verificação de e-mail, aceite de convite, onboarding). Cada item do inventário foi lido e classificado pela régua acima, em vez de buscado por texto.
O resultado, tela a tela (Tenant, SaaS admin, Fora do menu). "OK"/"N/A" tem o porquê numa frase; "JÁ CORRIGIDO NESTE FIX" são as três telas do sintoma original, viradas no commit 4dc5c43; "DEFEITO" é o que resta.
Tenant¶
| Item do inventário | catch | Veredicto | Mecanismo |
|---|---|---|---|
| Painel | apps/web/src/pages/operations-center/index.tsx:95 | N/A | só carregamento, sem campo |
| Análise de Crédito, lista | pages/credit-analyses/index.tsx:197,212 | N/A | carregamento e disparo sem campo |
| Análise de Crédito, lista — cancelar | pages/credit-analyses/index.tsx:217-225 | DEFEITO | ConfirmDialog com motivo (dado digitado) não recebe a prop error — a faixa fica atrás do backdrop |
| Análise de Crédito, formulário | apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:548 | OK | tratarRecusaDaApi nos quatro envios |
| Análise de Crédito, exibição | pages/credit-analyses/show.tsx:417-425 | DEFEITO | run() (cancelar e reprovar) só lê .message; nenhum dos dois ConfirmDialog recebe error — o próprio arquivo já resolve isso certo no diálogo de desistência (:1337) |
| Garantias (lista, encerrar, notas, pagamento, criação, assinatura) | pages/guarantees/*, components/Billing*Dialog.tsx, components/SignatureDialog.tsx | OK/N/A | campos sem restrição de schema alcançável, ou já mirrorizados no cliente, ou erro exibido no Alert do próprio diálogo |
| Inquilinos, formulário | apps/web/src/pages/renters/form.tsx:164 | JÁ CORRIGIDO | aplicarErrosDaApi |
| Imóveis, formulário | apps/web/src/pages/properties/form.tsx:175 | OK | aplicarErrosDaApi |
| Imobiliárias, formulário | apps/web/src/pages/agencies/form.tsx:130 | OK | aplicarErrosDaApi |
| Minha Garantidora | apps/web/src/pages/my-garantidora/index.tsx:150 | JÁ CORRIGIDO | aplicarErrosDaApi |
| Equipe, formulário | pages/users/form.tsx:152-156 | DEFEITO | lê details só para acender booleano (emailComErro); TextField sem helperText |
| Perfis de Acesso, formulário | pages/roles/form.tsx:151-152 | DEFEITO | só .message, em error de página; name/description sem marcação própria |
| Parâmetros | pages/settings/index.tsx:486-487 | DEFEITO | estado próprio via useState; só .message |
| Tipos de Documento | pages/settings/document-types/index.tsx:136-137 | DEFEITO | handleSave só .message (handleDelete é N/A, sem campo) |
| Tipos de Pendência | pages/settings/pendency-types/index.tsx:147-153 | DEFEITO | nunca lê .details — decide por regex sobre o texto da mensagem (/nome/i, /TAKEN/i); funciona por sorte, não por leitura estruturada |
| Documentos da Análise | pages/settings/credit-analysis-documents/index.tsx:80-81 | N/A | grade de toggle, sem campo de texto |
| Auditoria | apps/web/src/pages/audit-logs/index.tsx:85 | N/A | carregamento; TextField são filtros, não envio validável |
| Ajuda | — | N/A | nenhum catch no diretório |
DocumentsPanel — upload | components/shared/DocumentsPanel.tsx:394-406 | DEFEITO | Promise.allSettled, (r.reason as Error).message descarta .details |
DocumentsPanel — observação | components/shared/DocumentsPanel.tsx:448-463 | DEFEITO | estado por documento (:311), catch só .message em error de página |
DocumentsPanel — substituir/excluir/download/preview | components/shared/DocumentsPanel.tsx:488-603 | N/A | sem campo de texto validável |
SaaS admin¶
| Item do inventário | catch | Veredicto | Mecanismo |
|---|---|---|---|
| Painel | apps/web/src/admin/pages/dashboard/AdminDashboardPage.tsx:86 | N/A | carregamento |
| Garantidoras (suspender/reativar/exclusão) | admin/pages/tenants/* | N/A | sem campo enviado ao servidor (a exclusão confere o nome só no cliente) |
| Convites — emissão | admin/pages/invites/InviteListPage.tsx:165-172 | DEFEITO | contact_name com max_length=255 alcançável (apps/api/app/schemas/tenant_invite.py:28, sem espelho no cliente); só .message |
| Convites — revogação | admin/pages/invites/InviteListPage.tsx:182-187 | N/A | sem campo |
| Usuários | admin/pages/users/GlobalUserListPage.tsx:92-95 | N/A | só leitura, sem diálogo com campo |
Fora do menu¶
| Item do inventário | catch | Veredicto | Mecanismo |
|---|---|---|---|
| SignIn (login) | apps/web/src/pages/login/index.tsx:52 | DEFEITO, baixo impacto | só .message; hoje sem details[] alcançável (type="email" intercepta antes), mas o descarte é o mesmo mecanismo |
| SignUp (registro) | pages/register/index.tsx:89-90 | DEFEITO | react-hook-form presente, mas o catch usa setError de página, não o do formulário, e não chama aplicarErrosDaApi; name acima de 255 é alcançável (apps/api/app/schemas/auth.py:52). O transporte (ApiValidationError) já está corrigido desde o 4dc5c43 — falta só a tela consumi-lo |
| SignRecover, passo 1 | pages/forgotPassword/index.tsx:44,47 | DEFEITO | react-hook-form presente, sem setError usado |
| SignRecover, passo 2 | pages/resetPassword/index.tsx:51,54 | DEFEITO | mesmo padrão |
| SignOut (logout) | apps/web/src/providers/auth.ts:156 | N/A | sem campo, sem recusa de validação possível |
| Verificação de e-mail | pages/verify-email/index.tsx:51-52 | DEFEITO | react-hook-form (register("code")), catch grava em error de página em vez de setError do formulário |
| Aceite de convite | pages/accept-invite/index.tsx:47-48 | DEFEITO | mesmo padrão, register("password")/register("confirmPassword") |
| Onboarding | apps/web/src/pages/onboarding/StepTenant.tsx:88 | JÁ CORRIGIDO | aplicarErrosDaApi; index.tsx:79-82 (handleCancel) é N/A, sem campo |
Dezesseis defeitos confirmados, e é o número que substitui "vinte a trinta arquivos" da versão anterior desta seção — o inventário fechado tornou a contagem exata em vez de estimada:
admin/pages/invites/InviteListPage.tsx:165-172components/shared/DocumentsPanel.tsx:394-406components/shared/DocumentsPanel.tsx:448-463pages/accept-invite/index.tsx:47-48pages/credit-analyses/index.tsx:217-225pages/credit-analyses/show.tsx:417-425pages/forgotPassword/index.tsx:44,47apps/web/src/pages/login/index.tsx:52pages/register/index.tsx:89-90pages/resetPassword/index.tsx:51,54pages/roles/form.tsx:151-152pages/settings/document-types/index.tsx:136-137pages/settings/index.tsx:486-487pages/settings/pendency-types/index.tsx:147-153pages/users/form.tsx:152-156pages/verify-email/index.tsx:51-52
Nenhum é falso positivo por nome de arquivo — cada um foi lido antes de entrar na lista, e cada linha é conferível no próprio arquivo hoje, antes do diff.
O que o defeito NÃO alcança, e vale delimitar porque a correção não pode quebrá-lo: erro de negócio sem details[] — como o ConflictError de CNPJ já cadastrado (apps/api/app/services/tenant_service.py:54, no caminho de atualização que esta tela usa) — continua correto hoje. Ele traz só uma mensagem, e a faixa do topo é o lugar dela. É exatamente o que o false de aplicarErrosDaApi preserva.
Escopo decidido pelo usuário em 2026-08-29: todas as superfícies, e não só as três.
A alternativa era corrigir as três telas do achado da spec 0047 — as que já têm teste afirmando o defeito — e registrar o resto como trabalho seguinte. Foi recusada, e o motivo é o que o portão apontou: aprovar um conserto de três deixa a mesma faixa vazia viva em Configurações, no cadastro de usuário e na decisão de crédito, com a causa idêntica e o defeito já entendido. Corrigir um pedaço de uma lacuna conhecida cria duas versões do mesmo comportamento no produto.
A contagem mudou quatro vezes — quinze, dezoito, vinte, e uma quarta lista com falso positivo — e por isso ela deixou de ser o escopo por busca de texto. O que a decisão do usuário fixou é o princípio: todas. O que fechou o número foi trocar a fonte —
greppor inventário fechado (apps/web/CLAUDE.md) — e aplicar a régua item a item: 16 defeitos confirmados, listados acima, cada um lido antes de entrar na conta.Fica como fix e não como spec porque é defeito — o comportamento existe, está desenhado desde a 0046, e não foi aplicado onde devia. Tamanho não muda a natureza do trabalho; muda o número de vezes que a mesma correção se repete. Prova de regressão só onde há 422 alcançável, pela régua acima — senão o fix geraria dezenas de testes que passariam contra qualquer código.
5. Revisão do diagnóstico¶
Preenchido pelo laço do /fix new, rubrica .claude/rubrics/diagnostico.md. Registrar todas as rodadas, inclusive as reprovadas.
| Rodada | Critical | Important | Minor | Veredicto |
|---|---|---|---|---|
| 1 | 1 | 2 | 2 | REPROVADO |
| 2 | 1 | 1 | 2 | REPROVADO |
| 3 | — | 3 | 1 | REPROVADO |
| 4 | — | 2 | 2 | REPROVADO |
| 5 | 1 | 2 | 3 | REPROVADO |
| 6 | 1 | 4 | 3 | REPROVADO |
| 7 | 1 | 1 | 3 | REPROVADO |
As três rodadas acharam a mesma classe de defeito, e nas três a causa foi a mesma: eu afirmei comportamento sem medir.
- Rodada 1 — a reprodução mandava usar um e-mail malformado. O
type="email"do HTML aborta o envio: nenhuma requisição sai. - Rodada 2 — trocada para o campo Estado, ela esbarrava no
requireddo celular, que a garantidora doseed_e2enão tem. Também zero requisições, e o que se vê é o sósia do sintoma. - Rodada 3 — a reprodução principal passou a funcionar (medido: 1 PATCH, faixa aparece), mas a nota que documentava o sósia oferecia uma alternativa que caía nele de novo.
O revisor mediu nas três. Eu não medi em nenhuma antes de escrever, e é por isso que o mesmo defeito voltou três vezes com roupa diferente: uma reprodução plausível é indistinguível de uma que funciona, até alguém rodar.
E o alcance mudou em todas as quatro — quinze, dezoito, vinte, e uma quarta lista com falso positivo —, sempre com a omissão sendo irmã de um arquivo já listado. A rodada 4 achou a causa disso, e ela é estrutural, não desatenção: o registro usava duas réguas ao mesmo tempo — enumerava por "envia formulário e lê só .message" e excluía por "tem 422 com details[] alcançável". Com duas réguas, toda varredura acha mais um, para sempre.
O conserto foi separar as duas. Uma régua define o escopo (envia dado digitado, trata lendo só .message); a outra define onde há prova de regressão (422 com details[] alcançável hoje). A correção é inofensiva onde não há o que tratar — aplicarErrosDaApi devolve false com details vazio —, então aplicá-la amplamente não custa nada, e o teste vai só onde tem o que afirmar.
Resultado: fechado por decisão do usuário na rodada 7 (2026-08-29), e não "aprovado". As sete reprovaram, e as correções da sétima não passaram por revisão. O laço parou por decisão, não por convergência.
A decisão é defensável, e vale dizer por quê. O que o portão mede — reprodução, mecanismo, localização, alcance — está sólido nos três primeiros desde a rodada 3, e medido por revisores diferentes. O quarto, o alcance, é o que não fecha; e as sete rodadas mostraram por que ele não fecha num documento: cada uma acrescentou um sítio ou um transporte que a anterior não tinha aberto, e nenhuma reescrita evita isso — só abrir cada arquivo evita, que é o trabalho da implementação. Lá, cada afirmação vira código que compila ou não, e teste que fica vermelho ou verde.
O que fica em risco, dito sem eufemismo: a implementação vai encontrar sítios que este registro não previu, e possivelmente um sexto transporte. O registro dá a régua e os dois eixos para classificá-los; não dá a lista.
A rodada 5 achou o defeito mais grave das cinco, e ele não era de redação. A seção 4 fixava como alvo "chamar a helper que já existe", e isso compila e não faz nada em duas telas: apps/web/src/pages/onboarding/api.ts:55 lança ApiValidationError, uma classe própria que não estende ApiError — e a primeira linha de aplicarErrosDaApi (apps/web/src/shared/utils/foco-no-erro.ts:223) é uma guarda instanceof ApiError. StepTenant — uma das três telas do sintoma — e register são servidos por ela. A helper devolveria false, a faixa genérica continuaria sozinha, e o sintoma sobreviveria no lugar onde o registro afirmava tê-lo entendido.
Junto veio o segundo: a correção não é uniforme. aplicarErrosDaApi exige um UseFormSetError do react-hook-form, e credit-analyses/show.tsx — a tela que a seção 4 chama de mais importante do produto — não usa react-hook-form em lugar nenhum. A seção passou a enumerar quatro famílias de sítio, com o que "tratar a recusa" significa em cada uma.
É o que a lista fazia sem querer. Removê-la foi certo — quatro listas congeladas saíram erradas —, mas ela forçava a leitura de cada sítio, e é lendo cada um que se descobre que eles não são a mesma coisa. A régua sozinha não carregava isso; as famílias carregam.
A rodada 6 derrubou a tabela de famílias que a rodada 5 tinha feito nascer, e o Critical foi medido: duas telas listadas na família "página com react-hook-form" — users/form e roles/form — não usam react-hook-form. Passar o setError de um useState para aplicarErrosDaApi compila (tsc sai com zero), a helper devolve true, e o que chega à faixa é ["email"]: o nome cru do campo, sem mensagem. Pior que o sintoma de hoje, e dentro da tabela escrita para prevenir exatamente isso.
A causa foi misturar dois eixos independentes numa classificação só. O conserto depende de (a) por qual transporte o erro chega e (b) quem é dono do estado de erro do campo — e as telas de conta são a prova: register, verify-email e accept-invite usam react-hook-form (parecem o caso fácil) e são servidas pelo ApiValidationError, que não passa na guarda da helper. Qualquer família de um eixo só as classifica errado. A seção passou a ter as duas tabelas, e o conserto é o cruzamento.
Caíram junto três afirmações que eu não tinha conferido: o descarte no authProvider acontece em três lugares (apps/web/src/providers/auth.ts:79, :170, :196) e não num; o tratarRecusaDaApi não serve de precedente para credit-analyses/show.tsx, porque pressupõe formulário próprio e aquela tela não tem campo nenhum — o reason mora num ConfirmDialog que não expõe erro de campo; e há um sítio em escopo que não é catch (apps/web/src/components/shared/DocumentsPanel.tsx:404, um Promise.allSettled).
A rodada 7 achou mais dois transportes, e a forma do defeito foi idêntica à da rodada 5. apps/web/src/shared/api/documents.ts:47 e apps/web/src/admin/services/adminApi.ts:57 constroem um Error novo e descartam o details antes de a tela existir. O segundo é pior: lê a chave detail, que este envelope não tem, e o que chega à área admin é literalmente "Erro 422" — nem a mensagem do servidor. Os dois servem sítios que este registro já nomeava em escopo: o Promise.allSettled do painel de documentos e a emissão de convite.
São cinco transportes, e não três. E o registro chegou a afirmar que o documents.ts apenas "repassava" o erro — quem confiasse nessa frase classificaria o painel no balde errado.
O padrão das sete rodadas, dito sem enfeite. Três reprovaram a reprodução; quatro reprovaram o alcance ou o alvo. Em cada uma delas eu havia afirmado sem abrir o arquivo: que o e-mail chegava ao servidor, que o required não barrava, que havia três transportes, que duas telas usavam react-hook-form, que o documents.ts repassava. O revisor mediu em todas. O núcleo — reprodução, mecanismo, localização, delimitação — está estável desde a rodada 3; o que não estabiliza é a enumeração do que a causa alcança, e cada rodada acrescenta um sítio ou um transporte que a anterior não tinha aberto.
Pendências aceitas: nenhuma. Os achados das sete rodadas foram corrigidos. A enumeração dos sítios continua sendo produto da implementação, com os dois eixos para classificá-los — e com a advertência, agora medida sete vezes, de que abrir o arquivo é a única forma de saber.
6. Correção¶
Onde vai mexer, por camada. Um bug pode viver em um app só — seja explícito.
apps/api¶
- Onde: nenhum.
- O quê: sem impacto — o defeito é inteiro de tratamento de resposta no
apps/web; a API já preenchedetails[]corretamente desde antes deste fix (exceptions.py:158).
apps/web¶
- Onde: os cinco transportes (
providers/api-fetch.ts,providers/auth.ts,shared/api/documents.ts,admin/services/adminApi.ts,pages/onboarding/api.ts) e os 16 pontos de tratamento listados na seção 4. - O quê: as duas metades, feitas. Primeira (commit
4dc5c43): os cinco transportes passam a preservar.detailsaté a tela, e as três telas do sintoma original (Minha Garantidora, cadastro de inquilino, onboarding) chamamaplicarErrosDaApi. Segunda: os 16 pontos da checklist da seção 4, um commit por item —aplicarErrosDaApionde o dono do erro éreact-hook-form(SignUp, SignRecover ×2, verificação de e-mail, aceite de convite, SignIn); properror/reasonMaxLengthnova noConfirmDialogonde o campo mora num diálogo filho (cancelamento e reprovação de análise de crédito); marcação por item onde o estado é indexado (DocumentsPanel, observação e upload); e tratamento local (semreact-hook-form) comhelperText/mensagem completa onde antes só acendia um booleano ou decidia por regex sobre o texto (Equipe, Perfis de Acesso, Parâmetros, Tipos de Pendência, Tipos de Documento, emissão de convite no admin).
Dois achados no caminho, fora da lista original: o transporte de updatePassword (providers/auth.ts) calculava .details da resposta e não os incluía no objeto de erro devolvido — o comentário dizia que o campo viajava e o código ao lado não fazia isso; corrigido junto do item 10. E o e-mail de login, forgot-password, register e do cadastro de usuário passa por AppEmail na API, que confere entregabilidade em produção (validate_email(..., check_deliverability=True)) — um domínio sem MX recusa mesmo com formato válido, e o type="email" do navegador não pega isso. Descoberto nos itens 7 e 8 e replicado nos itens 9 e 15: é um 422 de campo genuinamente alcançável em produção, não hipotético como a maioria dos "sem alcançável hoje" desta lista.
Um achado relacionado, registrado e não corrigido: o diálogo "Devolver ao analista" (resumeOpen, credit-analyses/show.tsx) tem o mesmo run() sem error que os itens 5 e 6 corrigiam — mas sem campo de texto (é convite, não formulário), a régua da seção 4 não o inclui. Candidato a um próximo fix se a régua for reaplicada. - data-testid: acrescentados onde faltavam para tornar a correção testável — settings-* (nove campos e o botão Salvar, item 13) e settings-save-button não existiam antes deste fix. - Responsividade & PWA: a correção vale para desktop e mobile (não conserta um breakpoint quebrando o outro); se toca asset/estilo/layout, considerar o cache do service worker (PWA) na hora de validar
apps/e2e¶
- Teste de regressão: o cenário que falha hoje e passa depois da correção
Se algum app não é afetado, escreva "sem impacto" explicitamente.
7. Risco / impacto colateral¶
Nenhuma migração, nenhum cálculo de dinheiro tocado — o fix é só como o apps/web lê e exibe uma resposta que a API já enviava certa. O risco real é regressão de comportamento correto em 16 arquivos espalhados por login, cadastro, análise de crédito e configurações: cada item tem teste próprio com red-green (passa → reverte a correção → falha → restaura → passa), e a suíte inteira do apps/web (113 casos, 27 arquivos) foi rodada depois do último item — verde. render.tsx, helper compartilhado por toda a suíte, ganhou um parâmetro (rota) no item 4; a suíte inteira foi rodada logo depois para confirmar que nenhum caso existente regrediu.
Uma mudança de comportamento deliberada, não colateral: providers/auth.ts e a tela de Login/Registro/Esqueci a senha agora recusam por campo quando o servidor rejeita o e-mail por domínio não entregável (AppEmail) — antes essa recusa também acontecia, só não dizia onde.
8. Critérios de aceite¶
Verificável por quem não escreveu a correção. O primeiro deve ser sempre "o bug não reproduz mais".
- [x] CA1. Seguindo os passos da seção 2, o comportamento agora é o esperado — digitar
São Pauloem Estado (Minha Garantidora) marca o campo com a mensagem do servidor, sem a faixa genérica. - [x] CA2. Garantia central deste fix — mensagem no topo sem marca no campo não pode mais acontecer. Verificado nos 16 pontos, um teste por item com red-green.
- [x] CA3. Cancelamento e reprovação de análise de crédito (
ConfirmDialog) recebemerror; a mensagem aparece dentro do diálogo aberto (itens 5 e 6). - [x] CA4. Nome que a API manda e a UI não registra continua indo para a faixa com o rótulo em português — comportamento preexistente de
aplicarErrosDaApi, não regredido em nenhum dos 16 pontos (testado explicitamente no item 1). - [x] CA5. Erro de negócio sem
details[](ConflictErrorde nome duplicado em Tipos de Documento e Tipos de Pendência) continua só na faixa — verificado nos itens 12 e 14, que tratam exatamente esse limite.
9. Plano¶
- [x] Escrever o teste que reproduz o bug e vê-lo falhar — 16 arquivos de teste, um por item, cada um visto vermelho antes da correção
- [x] Corrigir a causa raiz — só
apps/web, semapps/apinemapps/e2eneste fix - [x] Red-green verificado: os 16 itens, cada um passa → reverte → falha → restaura → passa, com o comando e a saída registrados no commit do item
- [x]
make test-websem apontamento novo — suíte inteira rodada ao final (113 casos, 27 arquivos, verde).make lintnão roda nesta máquina (semuvno host — ver Estado do ferramental doCLAUDE.md);tsc --noEmitlimpo, checado arquivo a arquivo a cada item - [x] Validar no browser de verdade, desktop e mobile, contra o dev-remote —
/devremote-rebuild web(opnpm buildlocal já tinha passado limpo antes), depois amostragem de 3 dos 16 itens com Playwright disparando o 422 real da API (não simulado): Parâmetros (item 13), Perfis de Acesso (item 11, desktop e mobile) e Registro (item 9, desktop e mobile). Os três marcam o campo com a mensagem do servidor. Não é os 16 — é amostra, a pedido do usuário.
10. Decisões em aberto¶
Nenhuma — o diagnóstico fechou na rodada 7 (seção 5) e a correção não exigiu decisão nova: os 16 pontos seguiram os padrões já presentes no repositório (aplicarErrosDaApi, tratarRecusaDaApi, a prop error do ConfirmDialog).
11. Revisão da correção¶
Sem o laço automático do /fix implement. Não houve subagente de contexto limpo julgando o diff contra .claude/rubrics/correcao.md — o trabalho inteiro (os 16 itens, o deploy no dev-remote e depois em produção) aconteceu em conversa direta com o usuário, que acompanhou cada correção e pediu explicitamente o fechamento (2026-08-30, "Pode fechar então"), depois de ver a v0.4.2 no ar e a amostragem em browser confirmando três das telas corrigidas.
| Rodada | Critical | Important | Minor | Veredicto |
|---|---|---|---|---|
| — | — | — | — | Sem rodada de subagente; fechado por decisão direta do usuário |
O teste sobreviveria à reversão? Sim, nos 16 itens — não por impressão: cada commit desta lista registra o comando do red-green (revertida a correção, o teste do item volta a falhar pelo mecanismo exato do defeito; restaurada, passa) na própria mensagem, e a seção 9 resume o total (113 casos, 27 arquivos, verde ao final).
Defeitos aceitos: nenhum apontado — não houve revisão formal para apontar algo a aceitar. O que ficou fora do escopo desta lista está registrado na seção 6 (o diálogo "Devolver ao analista", resumeOpen em credit-analyses/show.tsx, mesmo run() sem error dos itens 5 e 6, mas sem campo de texto — candidato a um próximo fix).
12. Registro¶
Uma aresta de ambiente, encontrada ao rodar o portão — 2026-08-29.
O make preflight reprovou com a suíte vermelha, e ela estava verde. A causa não era teste: o pnpm install --frozen-lockfile saía com ERR_PNPM_IGNORED_BUILDS, porque o pnpm 11 desta máquina não lê o campo pnpm.onlyBuiltDependencies do package.json — ele avisa que ignorou — enquanto o pnpm 9 do container de produção lê, e é de lá que veio a correção que desbloqueou o deploy da 0.4.1.
Tentei as três formas de escrever a decisão para o pnpm 11 — pnpm-workspace.yaml com e sem packages:, e only-built-dependencies[] no .npmrc — e nenhuma foi aceita. O que resolveu aqui foi rodar a instalação uma vez com --config.dangerouslyAllowAllBuilds=true: o script do esbuild correu, e as instalações seguintes passam.
Fica registrado como aresta, e não como resolvido: num checkout novo com pnpm 11, o make test-web provavelmente vai pedir esse passo uma vez. Não mexi de novo no pnpm-workspace.yaml de propósito — foi ele que derrubou o build da imagem de produção, e o package.json sozinho está provado em produção. Trocar uma aresta de máquina de desenvolvimento por um deploy quebrado seria péssimo negócio.
Anotações durante a correção: o que era, o que virou, o que divergiu do plano. Preencher com /fix implement.
Os 16 pontos corrigidos, um commit por item, em conversa direta com o usuário — 2026-08-29.
Não passou pelo laço automático do /fix implement (sem subagente revisor de contexto limpo); por isso a seção 11 segue com uma rodada vazia, e o Status do cabeçalho continua DIAGNOSTICADO em vez de CORRIGIDO — essa transição pede o portão da seção 11, que não rodou. O que aconteceu:
- Cada um dos 16 itens da seção 4 ganhou teste próprio (
*.test.tsx) e correção, nesta ordem: escrever o teste, vê-lo falhar, corrigir, ver passar, reverter só a correção para confirmar que volta a falhar, restaurar. Um commit por item, citando o mecanismo exato. - Dois achados no caminho, fora da lista original de 16, registrados na seção 6: o transporte de
updatePasswordtinha o mesmo defeito que o commit anterior já dizia ter corrigido (calculava.detailse não os devolvia); e o e-mail de quatro telas (login, registro, esqueci a senha, cadastro de usuário) tem um 422 de campo genuinamente alcançável em produção viaAppEmail— achado que a seção 4 original tinha catalogado como "baixo impacto" para o login e não tinha visto nos outros três. render.tsx, o preparo compartilhado da suíte, ganhou um parâmetro (rota) para telas que leemuseSearchParams— usado pela primeira vez no item 4. Suíte inteira rodada logo depois (93 casos) e de novo ao final (113 casos, 27 arquivos): verde nas duas vezes.- O
make preflightpegou 16 citaçõesarquivo:linhaerradas na seção 4 depois da correção — 13 sem o prefixo de app, duas apontando para código que a própria correção substituiu. Corrigidas num commit à parte. - Não feito: a validação no browser contra o dev-remote (Plano, último item) e o laço formal de revisão da seção 11. Ambos pedem decisão do usuário — o primeiro custa um
/devremote-rebuild, o segundo é o portão que decideCORRIGIDO.
Build, deploy no dev-remote e amostragem — 2026-08-29/30.
Antes do rebuild, cd apps/web && pnpm build (mesmo comando do Dockerfile, tsc -p tsconfig.build.json && refine build) rodou limpo — EXIT:0, sem apontamento novo. /devremote-rebuild web reconstruiu e recriou o container (api não muda neste fix); app=200 e o hash do bundle (index-2HTIk57M.js) confirmam que o dev-remote está servindo o build novo, não o cacheado.
A validação em browser pedida foi por amostragem, não os 16 itens: um script Playwright avulso (fora do e2e — não é a suíte, é o script de screenshot que o CLAUDE.md já descreve para conferir tela sem pagar a suíte inteira), logado como gestor@e2e.test contra https://dev.garantora.com.br/app, disparando o 422 real da API em três telas — Parâmetros, Perfis de Acesso e Registro — e capturando desktop e mobile. Os três confirmam o campo marcado com a mensagem do servidor. O script rodou de dentro de apps/e2e/ (para resolver o playwright do node_modules de lá) como arquivo temporário, removido depois — não ficou artefato no repositório, e nenhuma das três tentativas foi salva de fato (as três terminam em 422, por desenho).
Ainda não fechado, até aqui: o laço formal de revisão da seção 11 (sem subagente de contexto limpo) — por isso o Status do cabeçalho seguia DIAGNOSTICADO.
Merge, deploy em produção e fechamento — 2026-08-30.
fix/0015-recusa-da-api-nao-aponta-o-campo mesclada na main por fast-forward (nada a resolver — a main não tinha andado desde que a branch nasceu dela) e empurrada. /ship publicou a v0.4.2: sem migration, make seed rodou (RBAC + admin idempotente, sem retirar permissão nenhuma), health respondeu {"status":"ok","db":"ok","commit":"5f690d5...", "version":"0.4.2"} e a raiz HTTP 200 — as duas verificações de fora que autorizam a tag, que foi criada e empurrada (v0.4.2) só depois de as duas passarem.
O usuário pediu o fechamento explicitamente, ciente de que a seção 11 não passou pelo laço automático — ver o que ficou registrado lá. Status agora CORRIGIDO.