Skip to content

Corrigir os 20 achados dos reviews (ultrareview + code-review max) - #2

Merged
lucasjosee merged 6 commits into
review/sprint6-cross-validationfrom
fix/review-findings
Sep 9, 2026
Merged

lucasjosee merged 6 commits into
review/sprint6-cross-validationfrom
fix/review-findings

Conversation

@lucasjosee

Copy link
Copy Markdown
Owner

Corrige os 20 achados únicos levantados pelo ultrareview sobre o PR #1 e pelo /code-review max sobre backend/src.

Base é a branch do PR #1 para que o diff mostre só as correções. Merge desta PR primeiro, depois o PR #1 leva tudo para a main.

Resultado

Antes Depois
Testes backend 46 65
Testes frontend 49 54
Typecheck limpo limpo

Cada correção seguiu TDD: teste que falha primeiro, falha verificada pelo motivo certo, depois a correção mínima. O RED mais informativo foi o do rate limit — expected '60' to be '10' em todas as rotas, provando o achado antes de tocar no código.

Os quatro que bloqueavam teste de campo

P1.1 — a fila de sync travava para sempre. Saudável e Fitotoxicidade são gravados com doenca_id nulo, mas o schema exigia uuid. O .parse() estourava no primeiro item inválido e devolvia 400 para o lote inteiro, derrubando até 49 diagnósticos válidos. Como o erro era anterior ao loop por item, eles nunca viravam FAILED: ficavam PENDING e a fila nunca mais drenava. Saudável é provavelmente o resultado mais comum em campo.

P1.2 — nenhum rate limit por rota estava ativo. Os cinco eram passados como config no app.register(), que é opção do plugin e não da rota. Tudo rodava no global de 60/min.

P1.3 — o veredito de cross-validation era forjável. O INSERT gravava crossValidationStatus e llm_* direto do cliente, sem o guard que a branch de UPDATE tinha. Dava para POSTar CONFIRMED com texto arbitrário e o /diagnosis/cross-validate devolvia aquilo como segunda opinião, sem carregar imagem nem chamar o LLM.

P1.4 — carga de imagem do S3 sem teto. Combinado com P1.2, DoS trivial.

Decisões que foram além da correção pontual

  • doenca_id e coordenadas viraram nullable. Descartar os diagnósticos especiais jogaria fora dado real de campo, que é o que o RF05 existe para preservar; e (0,0) é um ponto real no Golfo da Guiné.
  • Lote de sync ficou resiliente por item — corrigir só o nulo deixaria a fila igualmente frágil a qualquer incompatibilidade futura.
  • Nova coluna llm_doenca_nome — sem ela a repetição idempotente devolvia null onde a primeira chamada devolvia o que o LLM afirmou, e a RF08 proíbe ocultar divergência.
  • Rate limit desligável por RATE_LIMIT_ENABLED, ligado por padrão.
  • lib/diagnosticDraftService.ts extraído de camera.tsx, que tem 1.969 linhas e zero cobertura.

Bug latente descoberto no caminho

O errorResponseBuilder do rate limit devolvia objeto sem statusCode, então exceder o limite virava 500 genérico em vez de 429. Ficou invisível enquanto os limites nunca disparavam.

Atenção ao revisar

  • Três colunas alteradas no Postgres via drizzle-kit push — não há migrations versionadas neste projeto. As três são aditivas ou de alargamento (NOT NULL → nullable), seguras, mas exigem atenção em produção.
  • Fixtures de sync passaram a usar o userId real, já que a chave S3 agora precisa pertencer ao usuário autenticado. Os testes que quebraram foram fixture inválido, não regressão.

Fora do escopo

O CI do frontend continua quebrado e não foi tocado: react-native-vision-camera@5.0.11 publica import sem extensão (./VisionCamera), inválido em ESM, e o expo export --platform web carrega isso pelo loader ESM do Node 22. Passa localmente no Node 26. Não estava entre os achados.

Detalhamento completo, incluindo os refutados: docs/reviews/2026-08-26-achados-baseline.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH

lucasjosee and others added 6 commits September 9, 2026 07:43
Consolida os 20 achados únicos do ultrareview sobre o PR #1 e do
/code-review max sobre backend/src, priorizados por impacto real.

Serve de referência para separar bug pré-existente de regressão futura
durante a reformulação arquitetural.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
P1.1 — Fila travava para sempre com diagnóstico especial
  Saudável e Fitotoxicidade são gravados com doenca_id nulo, mas o schema
  exigia uuid. O .parse() estourava no primeiro item inválido e devolvia 400
  para o lote inteiro, derrubando até 49 diagnósticos válidos. Como o erro
  era anterior ao loop por item, eles nunca viravam FAILED: ficavam PENDING
  indefinidamente e a fila nunca mais drenava.

  - doenca_id agora é nullable no schema e na coluna do Postgres. Descartar
    esses diagnósticos jogaria fora dado real de campo, que é justamente o
    que o RF05 existe para preservar. O modelo já era assimétrico: llmDoencaId
    sempre foi nullable.
  - cada item é validado individualmente; um malformado vira failed_item em
    vez de derrubar os que viajam com ele.

P1.3 — Veredito de cross-validation era forjável
  O INSERT gravava crossValidationStatus e llm_* direto do cliente, sem o
  guard que a branch de UPDATE tinha. Um cliente podia POSTar CONFIRMED com
  texto arbitrário e o /diagnosis/cross-validate devolvia isso como segunda
  opinião, sem carregar imagem nem chamar o LLM — burlando a invariante de
  nunca ocultar divergência e contaminando o loop de feedback do RF06.
  Status terminal agora só nasce do pipeline do servidor.

P2.3 — image_s3_key não era revalidado contra o prefixo do usuário
P2.4 — gate de catálogo passou a valer também na branch de UPDATE
P2.5 — timestamp malformado virava RangeError e travava a fila em retry
P2.6 — llm_doenca_id é foreign key e não era validado no catálogo

Testes: +7 (46 -> 53). Fixtures de sync passaram a usar o userId real,
já que a chave S3 agora precisa pertencer ao usuário autenticado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
…e erros

P1.2 — Nenhum limite por rota estava ativo
  Os cinco limites eram passados como `config` no app.register(), que é opção
  do plugin e não da rota. O @fastify/rate-limit lê de routeOptions.config,
  preenchido só na definição da rota, então tudo rodava no global de 60/min:
  login sem proteção reforçada contra força bruta, e o endpoint de
  cross-validation (S3 + LLM multimodal, 15s) a 3x o volume planejado.

  Teste reproduz o defeito: antes da correção o header x-ratelimit-limit
  respondia '60' em todas as rotas.

  Bug latente revelado pela correção: o errorResponseBuilder devolvia um
  objeto sem statusCode, então exceder o limite virava 500 genérico em vez de
  429. Ficou invisível enquanto os limites nunca disparavam.

P3.3 — Balde era por IP, dimensão errada para endpoint pago por usuário
  Atrás de proxy toda a base dividia um balde; sem proxy, um aparelho em rede
  móvel troca de IP e escapa do teto. O keyGenerator agora chaveia pelo usuário
  autenticado. Como o plugin roda em onRequest, antes do preHandler que popula
  request.user, a verificação do token acontece no próprio keyGenerator — do
  contrário a chave por usuário nunca sairia do papel.

P3.8 — Log de erro não permitia localizar a falha
  Registrava só name e code; um TypeError em produção virava
  {errorName:'TypeError'} sem stack. Além disso o bloco redact era incompleto:
  o wildcard `*` do pino casa exatamente um nível, então o segredo no topo e o
  aninhado em dois níveis vazavam em texto claro.

Limitação agora desligável por RATE_LIMIT_ENABLED, ligada por padrão. As
suítes de integração exercitam num minuto muito mais requisições do que um
produtor real; o teste dedicado religa.

Testes: +7 (53 -> 60).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
P1.4 — Carga de imagem do S3 sem teto de tamanho
  O loader bufferizava o objeto inteiro e ainda gerava um base64 ~33% maior,
  sem cap de ContentLength. Como o presigned PUT não impõe content-length-range,
  o tamanho é controlado pelo cliente: poucas centenas de MB esgotavam a heap.
  Combinado com o rate limit inerte (corrigido no commit anterior), era DoS
  trivial. Teto de 10MB, o mesmo que o app já valida antes de enviar.

P2.2 — Falha deixava PENDING e o retry re-cobrava indefinidamente
  Timeout, erro de provider e JSON ilegível retornavam antes do saveResult.
  PENDING não é terminal, então o guard de idempotência não barrava e o cliente
  offline-first re-tentava sem teto, cada ciclo custando um GetObject no S3 mais
  uma inferência de visão paga. Agora persiste SKIPPED, que é o mesmo estado que
  o app já usa quando a segunda opinião falha.

P3.1 — extractJson quebrava com prosa depois do JSON
  A varredura primeiro-{ / último-} engolia qualquer chave em texto após o
  objeto. Atinge o Claude, que só é instruído em prosa a responder JSON,
  enquanto o Gemini força responseMimeType: application/json. Um veredito
  válido virava 502 e a inferência paga era descartada. Substituída por
  varredura balanceada que respeita strings, com blocos cercados.

P3.2 — createPending podia devolver 500 em vez do 200 idempotente
  mobile_local_id é único globalmente, mas findDiagnostic filtra por
  (userId, mobileLocalId). Num duplo toque ou retry dentro da janela de 15s do
  LLM os dois lookups erram e o segundo insert colide com 23505 cru.

P3.6 — Repetição idempotente perdia o nome da doença fora do catálogo
  Nova coluna llm_doenca_nome. Sem ela a primeira chamada devolvia a string do
  LLM e a repetição devolvia null, escondendo a divergência — justamente o que
  a RF08 proíbe.

P3.7 — Coordenadas (0,0) eram indistinguíveis de leitura real
  (0,0) é um ponto real no Golfo da Guiné. latitude/longitude agora são nulas
  até o sync preencher.

P3.9 — Seed falhava e saía com código 0
P3.10 — Validação duplicada no controller, já feita pelo handler global

Testes: +5 (60 -> 65).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
P2.1 — Retake deixava diagnóstico órfão na fila
  O INSERT saiu de handleSaveToHistory, que o produtor acionava, para o fluxo
  automático pós-inferência. handleResetCamera limpava só o state do React, sem
  DELETE, então cada tentativa descartada subia para o servidor no sync seguinte
  e ainda consumia um PUT no S3 — num app de campo onde 3 a 5 tentativas até um
  bom enquadramento é o comportamento normal documentado.

  A lógica foi extraída para lib/diagnosticDraftService.ts em vez de ficar
  dentro do camera.tsx (1.969 linhas, sem cobertura de teste): assim é
  testável, e o arquivo para de crescer. Só descarta o que ainda está PENDING —
  linha sincronizada é histórico do produtor, não rascunho. O descarte aguarda
  a promessa de persistência em voo, porque o local_id pode ainda não ter
  chegado ao state quando o produtor toca em Retake.

P3.4 — Falha do banco local se passava por indisponibilidade do LLM
  O try/catch envolvia o api.post E o UPDATE no SQLite. Uma falha local (ex.:
  FK de llm_doenca_id com catálogo desatualizado) virava LLM_UNAVAILABLE; o app
  marcava SKIPPED e o guard do servidor impedia a auto-correção no sync
  seguinte. Cliente e servidor divergiam permanentemente, com a segunda opinião
  real existindo no servidor e nunca aparecendo para o produtor.

P3.5 — Migration v5 deixava diagnósticos legados como PENDING
  ADD COLUMN NOT NULL DEFAULT preenche o default em todas as linhas existentes.
  Elas são anteriores à cross-validation e nunca voltam ao fluxo, mas como o
  valor não é nulo o fallback `?? 'SKIPPED'` do syncService não dispara e elas
  chegam ao servidor como PENDING para sempre. Backfill na v5 (onde toda linha
  é comprovadamente legada) e migração v6 para quem já rodou a v5 defeituosa.

Testes: +5 (49 -> 54). runMigrationsAndSeed exportada para permitir teste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
Mapeia cada achado ao commit que o corrigiu, registra as decisões que foram
além da correção pontual e o bug latente (429 virando 500) descoberto ao
ativar os rate limits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
@lucasjosee
lucasjosee merged commit 232e989 into review/sprint6-cross-validation Sep 9, 2026
@lucasjosee
lucasjosee deleted the fix/review-findings branch September 10, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant