Skip to content

Sprint 6 — Cross-validation visual + hardening da Sprint 7 - #1

Merged
lucasjosee merged 11 commits into
mainfrom
review/sprint6-cross-validation
Sep 9, 2026
Merged

lucasjosee merged 11 commits into
mainfrom
review/sprint6-cross-validation

Conversation

@lucasjosee

Copy link
Copy Markdown
Owner

Objetivo

PR aberto para viabilizar o review automatizado (/code-review ultra). Contém os dois commits que ainda não estavam no origin.

Commits

  • 71f6b9e — feat: implement visual cross-validation and sprint 7 hardening
  • b1e52af — docs: record current MVP status and remaining work

Escopo

68 arquivos, +4.178 / −1.513.

Backend

  • Módulo diagnosis/ completo (novo): service, repository, schema, controller, rotas e 3 arquivos de teste
  • POST /api/v1/diagnosis/cross-validate — segunda opinião do LLM multimodal, ancorada no resultado do CV local
  • sync.service.ts — guard de reconciliação: payload offline antigo não sobrescreve cross-validation já em estado terminal
  • Ajustes nos providers Claude e Gemini
  • scripts/audit-s3-security.ts — validação dos 4 controles de Public Access Block

Frontend

  • 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 local
  • lib/diagnosisFeedbackService.ts (novo) — feedback human-in-the-loop com opção de escolher a opinião do LLM
  • db/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-validation
  • lib/secureStorage.ts — bloqueio de storage web fora de __DEV__

Pontos que merecem atenção no review

  • Máquina de estados da cross-validation em camera.tsx, dentro de um arquivo de 1.969 linhas
  • Guard de reconciliação em sync.service.ts — regressão ali causa perda de segunda opinião
  • Idempotência por diagnostic_local_id + usuário autenticado
  • Checagem de posse do image_s3_key (prefixo diagnosticos/{userId}/)
  • Tratamento de 504 LLM_TIMEOUT e 502 LLM_UNAVAILABLE

Nota

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

@lucasjosee

Copy link
Copy Markdown
Owner Author

Claude Code review — 7 finding(s)

normal — frontend/app/camera.tsx:283-311

Retaking a photo leaves the previous diagnostic PENDING in fila_diagnosticos. persistDiagnosticAndStartCrossValidation() (camera.tsx:375) now INSERTs a row immediately after inference, but handleResetCamera (camera.tsx:466) and the reset block at the top of processDiagnostic (camera.tsx:270-280) only clear React state — no DELETE. On the next runFullSync, every abandoned capture is uploaded to the server. Fix: DELETE the row (and cancel any in-flight cross-validation upload) whenever the user retakes.

normal — frontend/lib/syncService.ts:101-112

Special diagnostics (Saudável/Fitotoxicidade) are persisted with doenca_id = NULL in fila_diagnosticos, and syncService.ts forwards that null straight into ai_result.doenca_id, which the server's Zod schema requires to be z.string().uuid(). A single such row poisons the whole batch — Zod .parse() throws on the first invalid item, Fastify returns 400, and up to 49 valid diagnostics riding in the same request never sync. This is pre-existing, but the PR meaningfully worsens it: now runs on every capture (fire-and-forget), so every online capture of a healthy or phytotoxic leaf becomes a permanent poison item in the sync queue. Fix: filter specials out of the payload before syncing (or don't insert them into the queue in the first place), or make doenca_id nullable server-side with a sentinel for specials.

normal — backend/src/modules/sync/sync.service.ts:20-49

The sync endpoint (backend/src/modules/sync/sync.service.ts) accepts item.image_s3_key verbatim on both the INSERT branch (line 75) and the newly added UPDATE branch (line 36), while the sibling cross-validation endpoint and the PR description both require image_s3_key to start with diagnosticos/{userId}/. This lets an authenticated user B poison their own diagnostic row with a key pointing into user A's prefix (via a leaked key), bypassing the ownership invariant this PR is explicitly claiming to establish. Fix by applying the same startsWith(diagnosticos/${userId}/) check inside syncDiagnostics (per-iteration or as a schema refinement bound to the JWT sub).

nit — frontend/lib/crossValidationService.ts:59-88

The try/catch in crossValidateDiagnostic (lib/crossValidationService.ts:51-88) wraps both api.post and the local dbDriver.execute UPDATE, so any error thrown by the SQLite UPDATE (realistic case: FK violation on llm_doenca_id when the local catalog is stale — migration v5 declares REFERENCES doencas(id) and initDatabase sets PRAGMA foreign_keys = ON) is coalesced into CrossValidationRequestError('LLM_UNAVAILABLE'). camera.tsx then calls markCrossValidationSkipped(localId, 'LLM_UNAVAILABLE'), and because sync.service.ts guards CONFIRMED/ENRICHED/DIVERGENT against overwrite, the client shows SKIPPED permanently while the server retains the real second opinion. Fix: move the DB UPDATE outside the try/catch (or wrap it in its own catch) so DB failures do not masquerade as LLM failures.

nit — frontend/db/sqlite.ts:530-549

Migration v5 backfills every pre-existing row in fila_diagnosticos with cross_validation_status='PENDING' because SQLite's ADD COLUMN ... NOT NULL DEFAULT 'PENDING' fills the default into all existing rows. Those legacy diagnostics predate cross-validation and will never be processed by the new flow, so when syncService.ts later ships them (the r.cross_validation_status ?? 'SKIPPED' fallback resolves to 'PENDING', not null), they land server-side as 'PENDING' indefinitely — semantically they should be 'SKIPPED'. Fix: use DEFAULT 'SKIPPED' or run UPDATE fila_diagnosticos SET cross_validation_status='SKIPPED' WHERE cross_validation_status='PENDING' right after the ADD COLUMN and before bumping user_version.

nit — backend/src/modules/diagnosis/cross-validation.service.ts:85-100

Idempotent retry loses llm_doenca_nome when the LLM names an off-catalog disease for a DIVERGENT result. The first response returns the raw LLM string via the catalogDisease?.nome ?? result.llm_doenca_nome fallback, but saveResult never persists that name; on retry, the early-return path re-derives the name from findDisease(record.llmDoencaId) which is null, so the same request returns null on subsequent calls. Fix by either persisting llm_doenca_nome or normalizing the first response to null when the disease is off-catalog so both paths agree.

nit — backend/src/modules/diagnosis/cross-validation.repository.ts:51-65

createPending() 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

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 and others added 2 commits September 9, 2026 08:08
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
@lucasjosee
lucasjosee marked this pull request as ready for review September 9, 2026 12:07
@lucasjosee
lucasjosee merged commit f9a0bab into main Sep 9, 2026
2 checks passed
@lucasjosee
lucasjosee deleted the review/sprint6-cross-validation 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