Sprint 6 — Cross-validation visual + hardening da Sprint 7 - #1
Conversation
|
Claude Code review — 7 finding(s) normal — frontend/app/camera.tsx:283-311Retaking a photo leaves the previous diagnostic PENDING in normal — frontend/lib/syncService.ts:101-112Special diagnostics (Saudável/Fitotoxicidade) are persisted with normal — backend/src/modules/sync/sync.service.ts:20-49The sync endpoint ( nit — frontend/lib/crossValidationService.ts:59-88The try/catch in nit — frontend/db/sqlite.ts:530-549Migration v5 backfills every pre-existing row in nit — backend/src/modules/diagnosis/cross-validation.service.ts:85-100Idempotent retry loses nit — backend/src/modules/diagnosis/cross-validation.repository.ts:51-65createPending() writes latitude=0, longitude=0 (Null Island — a valid GPS coordinate in the Gulf of Guinea) when the cross-validation endpoint is called before Store & Forward sync (backend/src/modules/diagnosis/cross-validation.repository.ts:56-57). The normal path self-heals because sync.service.ts rewrites lat/lng unconditionally when the offline batch arrives, but any diagnostic whose sync never lands (crash, uninstall, extended offline before the sync batch runs) is permanently mislocated. Fix by accepting an optional location in the cross-validate payload or making latitude/longitude nullable until the sync hydrates them. Generated by Claude Code |
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
Corrigir os 20 achados dos reviews (ultrareview + code-review max)
…lugin O build:web quebrava no CI com ERR_MODULE_NOT_FOUND ao carregar react-native-vision-camera como config plugin do Expo. Causa raiz: o pacote publicado NÃO contém app.plugin.js — o campo `files` do package.json não o inclui, e nenhuma versão 5.x publica esse arquivo. Sem ele, o Expo cai para o main entry (lib/index.js), que é a biblioteca de runtime e usa import sem extensão (`./VisionCamera`), inválido no resolvedor ESM. Passava localmente porque havia um app.plugin.js escrito à mão dentro de node_modules, que é gitignored: existia só nesta máquina, o CI nunca o viu, e sumiria em qualquer npm ci. Era exatamente o que mascarava o problema. O plugin fazia só duas coisas — permissão de câmera no Android e NSCameraUsageDescription no iOS. Ambas são declarativas no app.json, então vendorizar 69 linhas de JS para reproduzi-lo seria pior. O vínculo nativo da vision-camera vem do autolinking (react-native.config.js), não do config plugin, então remover a entrada não afeta o build nativo. Verificado escondendo o arquivo escrito à mão para simular a condição do CI: build:web passa. Os outros três plugins (expo-router, react-native-fast-tflite, llama.rn) publicam app.plugin.js corretamente e não têm esse problema. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pmgii3S5sfMZghFqQXhbkH
Corrigir build:web do CI: permissões de câmera declaradas no app.json
Objetivo
PR aberto para viabilizar o review automatizado (
/code-review ultra). Contém os dois commits que ainda não estavam noorigin.Commits
71f6b9e— feat: implement visual cross-validation and sprint 7 hardeningb1e52af— docs: record current MVP status and remaining workEscopo
68 arquivos, +4.178 / −1.513.
Backend
diagnosis/completo (novo): service, repository, schema, controller, rotas e 3 arquivos de testePOST /api/v1/diagnosis/cross-validate— segunda opinião do LLM multimodal, ancorada no resultado do CV localsync.service.ts— guard de reconciliação: payload offline antigo não sobrescreve cross-validation já em estado terminalscripts/audit-s3-security.ts— validação dos 4 controles de Public Access BlockFrontend
app/camera.tsx(+606) — máquina de estados da cross-validation na UI (PENDING/CONFIRMED/ENRICHED/DIVERGENT/SKIPPED)lib/crossValidationService.ts(novo) — upload presigned + chamada + persistência locallib/diagnosisFeedbackService.ts(novo) — feedback human-in-the-loop com opção de escolher a opinião do LLMdb/security.ts(novo) +db/sqlite.ts— gate de boot do SQLCipher (falha fechado, sem fallback em memória)db/sqlite.ts— migration v5 com os campos de cross-validationlib/secureStorage.ts— bloqueio de storage web fora de__DEV__Pontos que merecem atenção no review
camera.tsx, dentro de um arquivo de 1.969 linhassync.service.ts— regressão ali causa perda de segunda opiniãodiagnostic_local_id+ usuário autenticadoimage_s3_key(prefixodiagnosticos/{userId}/)504 LLM_TIMEOUTe502 LLM_UNAVAILABLENota
Não mergear antes do review. Dívida estrutural pré-existente (modelos mortos em
assets/, ausência de migrations no backend,WebDatabaseDriver) está fora deste diff e será tratada em mudanças próprias.🤖 Generated with Claude Code