# Review — integracao-wms-desacoplada (Fase 2: receiver + tradução scaffold + idempotência + ciclo de report)

**Base**: `dev_team` (working tree não commitado — `project_git_workflow`: sem merge p/ main)
**HEAD**: `dc3886e91ca373224fb5ce9ca8dfd6ac2c4f75db`
**Arquivos tocados (em escopo)**: 10 produção novos + 3 produção alterados + 9 de teste = 22
**Linhas**: novos de produção ~895 (SQL+PHP); alterados +212/-42 (dos quais a Fase 2 no cron é só o bloco isolado ~30 linhas + 1 chamada — o resto é aprovação/estorno, fora do escopo)
**`php -l`**: limpo nos 12 PHP de produção/infra e nos 9 de teste (sweep re-executado aqui)

## Sumário

Entrega sólida e fiel ao techplan (passos 8-15). O padrão dominante está correto: `WmsOrdemSonda`
espelha `IntegracaoWmsConfigSonda`/`ScanCotaConfigSonda` (customerControl=false, isolamento explícito
por `block_empresa_id`, conjunto completo de auditoria/block sem `DEFAULT 1`); `TraducaoOrdemFactory`
espelha `OperacaoAdapterFactory` byte-a-byte (Factory+enum, mapa fechado, `create()` lança, mapa vazio
de propósito nesta fase); o receiver é opt-in e o loop de report é no-op seguro/idempotente. Sem raw SQL
de write, migrations idempotentes, bug crítico da Migration 003 (`block` NULL) **confirmado corrigido**
no arquivo. O único ponto que merece atenção antes de a Fase 3 ligar criação real de missão é a
**resolução de tenant a partir de `$_SERVER['REQUEST_URI']`** (M1): é *segura* (fail-closed, não dá
cross-tenant), mas é frágil e duplica a fonte-da-verdade do tenant que a linha autenticada de
`webhook_endpoints` já carrega. **Recomendação: merge da Fase 2** (majors aceitáveis com TODO), com M1
e o nit n1 (bloco morto) endereçados no arranque da Fase 3.

## Issues

### 🔴 Blocker (0 issues)

Nenhum. Checklist hard varrido: sem raw SQL em writes (Sonda usa `create()`/`update()`); não estende
`Inteligencia` V1 (só *consome* `MissaoLogistica` p/ leitura — ver m3); migration com `block_*` sem
`DEFAULT 1`; sem controller novo (naming N/A); lógica de negócio nos Laboratórios (não em Handler);
Validators dedicados (`OrdemEnvelopeValidator`/`OrdemIdentidadeValidator`); sem string de UI humana
(superfície é webhook/máquina — i18n corretamente N/A, ver Padrões V2); Laboratórios novos têm teste.

### 🟡 Major (1 issue)

#### M1 — Tenant re-derivado da URL crua em vez da linha autenticada do canal
**Onde**: `Laboratorios/ReceberOrdemLaboratorio.php:191-214` (`resolverCanalDaUrl()` / `resolverEmpresaIdDoCanal()`), acoplado a `Migrations/003_seed_webhook_endpoint_dev.sql:30-44`
**Problema**: o `InboundHandler` já resolveu e autenticou a linha `webhook_endpoints` do canal (que tem
`customer_id=7` **e** `block_empresa_id=7`), mas não a repassa ao target_lab (`new $targetLab($payload)`).
O Lab então **re-parseia** `$_SERVER['REQUEST_URI']` com `#/api/webhooks/([^/?]+)#` → `^wms-(\d+)$` p/
extrair o `empresaId`, ignorando a identidade já autenticada. Isso cria duas fontes-da-verdade do
tenant (o sufixo do nome do canal vs. as colunas da linha) cuja concordância **não é imposta em lugar
nenhum**. Uma seed futura que crie `wms-7` com `block_empresa_id=8` (typo/config) passaria o HMAC de
`wms-7` e gravaria a ordem no tenant **7** (do nome), descartando silenciosamente o 8 autenticado.
Também acopla a correção à forma da URL (qualquer rewrite/prefixo de proxy no REQUEST_URI quebra a
resolução).
**Segurança (respondendo o ponto 1 da tarefa)**: **não é explorável para cross-tenant**. Tanto o
`channel` do `InboundHandler` (route param do CoffeeCode) quanto o regex do Lab derivam do **mesmo
segmento de path**; qualquer divergência resulta em (a) HMAC falhando contra o secret do canal
router-resolvido → 401, ou (b) o regex do Lab não casar `^wms-(\d+)$` → `empresaId=0` → rejeição. O
único caminho "com sucesso" exige que ambos concordem em `wms-N` **e** que o HMAC valide com o secret
de `wms-N` — consistente. Não há como dissociar "canal usado p/ achar o secret" de "empresaId extraído
depois". **Confirmado seguro.** O problema é de robustez/manutenção, não de auth.
**Por que é major**: viola o princípio V2 de single-source-of-truth do tenant e re-deriva identidade de
uma fonte fora do controle do sinal (REQUEST_URI); correto hoje só por um invariante de nomeação não
verificado. Vira risco real quando a Fase 3 wire criação de missão (grava no tenant errado sem estourar).
**Como corrigir**: como o `InboundHandler` **já foi tocado nesta feature** (a linha de resposta), o
argumento "infra intocável" está enfraquecido. Repassar a identidade autenticada é o certo:
`new $targetLab($payload, $endpoint)` (2º parâmetro opcional para não quebrar outros target_labs) e o
Lab usa `$endpoint->block_empresa_id`. Se preferir não mexer na assinatura agora, ao menos **imponha o
invariante**: no Lab, resolver o `empresaId` do nome do canal e **rejeitar se divergir** do que a linha
autenticada carrega (exigiria o endpoint no Lab de qualquer modo), ou documentar como pré-condição
dura de seed. Aceitável entrar com TODO para a Fase 3, mas não deixar implícito.

### 🟢 Minor (4 issues)

#### m1 — Race de dedup dispara alerta de erro falso e não devolve resposta idempotente
**Onde**: `Laboratorios/ReceberOrdemLaboratorio.php:90-98` (dedup) + `:149-168` (`rejeitarEPersistir`) → `Sondas/WmsOrdemSonda.php:108-134` (`criar`→`create`)
**Problema (ponto 3 da tarefa)**: sob duas requisições concorrentes com o mesmo `external_ref`, ambas
passam pelo `buscarPorExternalRef` (null) e chamam `criar()`. A `UNIQUE(block_empresa_id, external_ref)`
barra a duplicata **física** (bom), e `ModelInteligencia::create()` (`controladores/_Controladores/ModelInteligencia.php:973-977`)
**captura** a `PDOException` e retorna `null` — ou seja, **não quebra com exceção não tratada** (o
cenário do ponto 3 está tratado no nível do banco). Porém: (a) o perdedor recebe `ordem_id=null` /
`status='rejeitada'` em vez da resposta idempotente `ja_processada`; e (b) `create()` chama
`sendFail()` (`:307-332`), que dispara **notificação de erro para o canal ops ("goldie")** a cada
colisão benigna — falso alarme.
**Como corrigir**: quando `criar()` retornar `null`, re-executar `buscarPorExternalRef()` e, se agora
existir, devolver o veredito idempotente (`ja_processada`) da linha vencedora; considerar
`turnOff(['queriesErrors'])` (ou tratar o SQLSTATE 23505 sem `sendFail`) p/ não paginar em corrida de
webhook. Risco prático baixo nesta fase (retries de WMS costumam ser sequenciais e tudo aqui é
rejeição), mas o custo sobe na Fase 3 (duas missões tentando nascer) — vale deixar resolvido antes.

#### m2 — HMAC de saída assinado com o mesmo segredo transmitido no header
**Onde**: `Provedores/Http/HttpWmsProvider.php:48-57`, `:89-95`
**Problema (ponto 7 da tarefa)**: o `X-Signature: sha256=<hmac(body, secret)>` usa como `secret` o
`bearerToken`, que é **enviado na mesma requisição** no header `Authorization: Bearer`. Um MITM/quem
lê a requisição obtém o segredo de assinatura junto — a HMAC deixa de oferecer proteção a tamper
(seu único propósito extra sobre o bearer). Para scaffold é **aceitável e está documentado**, com
override dedicado (`GENESIS_WMS_HTTP_HMAC_SECRET`) sem exigir mexer na Fase 1 — decisão razoável dado o
constraint. Não é gambiarra bloqueante.
**Como corrigir**: antes do primeiro WMS real (Fase 3), usar um segredo de assinatura **distinto** do
bearer (o override já existe; basta popular). Registrar como TODO no HttpWmsProvider.

#### m3 — Leitura de missão via V1 `Inteligencias\MissaoLogistica`
**Onde**: `Laboratorios/ReportarOrdemLaboratorio.php:5`, `:77-85`
**Problema**: `missaoEhDeOrigemWms()` lê a missão via `GalaxiaConstelacoes\...\Inteligencias\MissaoLogistica`
(V1). Não é blocker — **não estende** Inteligencia (a memória `feedback_sondas_v2` e o checklist são
sobre *estender* V1), e é **exatamente** o que os Laboratórios irmãos de `AlocacaoWms`
(`ConfirmarAlocacaoLaboratorio`, `RemoverAlocacaoLaboratorio`) já fazem e passaram no review da Fase 1.
Existe `MissaoLogisticaSonda` (V2) em `Dashboard/`/`Central2/`, mas não é a canônica deste domínio.
**Como corrigir**: nenhuma ação obrigatória; se/quando o domínio ganhar uma read-Sonda V2 oficial de
missão, migrar por consistência. Baixa prioridade.

#### m4 — `buscarPorMissaoId` sem guarda explícita de `block_empresa_id`
**Onde**: `Sondas/WmsOrdemSonda.php:86-99`
**Problema (ponto 4 da tarefa)**: `buscarPorMissaoId` faz `turnOff(['customerControl','companyControl'])`
e filtra **só** por `missao_logistica_id`. Hoje é seguro (id de missão é PK global única → pertence a um
único tenant; o cron passa ids do próprio tenant, vindos de `array_keys($porMissaoErp/Wms)`), então
**não há leak cross-tenant na prática**. Mas, ao contrário de `buscarPorExternalRef` (que filtra
`block_empresa_id` explicitamente), esta query não tem a rede de segurança defensiva do isolamento
explícito que o próprio techplan escolheu para esta tabela.
**Como corrigir**: aceitar `?int $empresaId = null` e, quando informado, adicionar
`AND block_empresa_id = :eid` — defesa em profundidade barata, alinhada ao restante da Sonda. O
`ReportarOrdemLaboratorio` roda sob sessão sintética por tenant e poderia passar o empresaId corrente.

### ⚪ Nit (5 issues)

- **n1 — Bloco morto com motivo enganoso e armadilha p/ Fase 3.** `ReceberOrdemLaboratorio.php:134-146`
  retorna `rejeitarEPersistir(..., 'Tradutor não implementado nesta versão.')`, mas é **inalcançável**
  na Fase 2 (o `catch` de `:120-132` sempre dispara com o mapa vazio; o Tester confirmou que o motivo
  real emitido é `"Tipo não suportado nesta versão: ..."`). É scaffold documentado, porém é **exatamente
  a linha que a Fase 3 precisa substituir** por `$tradutor->traduzir($data, $empresaId)` → criar missão →
  `atualizarStatus('traduzida', missao_id)`. Se a Fase 3 popular o Factory e **esquecer** este bloco, o
  Factory criaria o tradutor e a ordem **ainda** seria rejeitada. Sinalizar no handoff da Fase 3.
- **n2 — `OrdemTipo` em `Tradutores/` e não em `Enums/`.** `Tradutores/OrdemTipo.php`. O enum-espelho de
  referência (`OperacaoTipo`) vive em `Enums/`. Está onde o techplan mandou (co-locado com o Factory que
  ele dirige) e é coeso, mas destoa da convenção "backed enums em `Enums/`". Cosmético.
- **n3 — `block_filial_id => 1` chumbado.** `Sondas/WmsOrdemSonda.php:127`. A sessão sintética não define
  filial; assume filial única. Aceitável, mas é um valor mágico no insert.
- **n4 — Log em caminho fixo `/tmp/genesis_webhook.log`.** `Provedores/Http/HttpWmsProvider.php:72`.
  Espelha o `GalpaoProvider` da Fase 1 (consistente), mas caminho hardcoded.
- **n5 — Seed de dev com secret público + `ativo_entrada=1`.** `Migrations/003_...:36`. Habilita um
  receiver com secret notoriamente falso p/ a empresa 7. Não há runner de migração automático no repo
  (aplicação é manual/seletiva), e o arquivo é claramente `_dev` com aviso "troque antes de usar" → risco
  baixo. Garantir que não seja aplicado verbatim em prod / rotacionar o secret ao plugar de verdade.

## Verificação dos pontos críticos pedidos

1. **Segurança do receiver (regex pós-HMAC)** — ✅ **seguro** (fail-closed); ver M1 para a fragilidade
   arquitetural (não é buraco de auth).
2. **`ativo_entrada` como defesa em profundidade** — ✅ posicionado corretamente: logo após resolver
   tenant + sessão sintética e **antes** de qualquer outra Sonda (é a 1ª chamada de Sonda, `obterConfig`,
   antes do dedup `WmsOrdemSonda`). `ReceberOrdemLaboratorio.php:66-73`.
3. **Race de idempotência** — ✅ tratado no banco (UNIQUE barra dupe; `create()` captura a PDOException
   e retorna null, **sem exceção não tratada**). Refinamento em m1 (resposta idempotente + alerta falso).
4. **`ReportarOrdemLaboratorio` (AC2.4) e cross-tenant** — ✅ "pelo menos um canal enviado" bate com a
   decisão do techplan (canais independentes); `houvePushEnviado` usa `WmsMovimentoSonda::listarPorMissao`
   que mantém `customerControl=true` (filtra pelo tenant da sessão sintética do cron). Sem leak. Ressalva
   defensiva de `buscarPorMissaoId` em m4.
5. **Migrations idempotentes** — ✅ 002 (`CREATE TABLE/INDEX IF NOT EXISTS` + `ALTER ... ADD COLUMN IF
   NOT EXISTS`) e 003 (`INSERT ... ON CONFLICT DO NOTHING` + `UPDATE ... WHERE block IS NULL` de reparo +
   `INSERT ... ON CONFLICT DO UPDATE`) rodam 2x sem erro. **Confirmado: a Migration 003 atual inclui a
   coluna `block` no INSERT (`:32` e valor `7` em `:44`) + o UPDATE de reparo (`:50-51`)** — o bug crítico
   do `08-testes-fase2.md` está de fato corrigido no arquivo.
6. **Convenções do repo** — ✅ Sonda V2 com `propeties.db`; `block_*` sem DEFAULT; `find()->fetch(false)`
   devolve objeto único (não embrulhado — coerente com `modelinteligencia_save_gotchas`); i18n N/A
   (superfície de webhook/máquina, motivos persistidos em `wms_ordens.motivo` p/ observabilidade, não UI).
7. **`HttpWmsProvider` (bearer como HMAC)** — aceitável p/ a fase e documentado/overridável; ver m2.
8. **`TraducaoOrdemFactory` com mapa vazio** — ✅ "sempre rejeitar com motivo" correto; **nenhum caminho
   cria missão parcial** (o único caminho pós-`create()` bem-sucedido, `:134-146`, é inalcançável e
   mesmo assim chama `rejeitarEPersistir`). Ver n1 p/ o handoff da Fase 3.

## Pontos fortes

- **`TraducaoOrdemFactory` imita `OperacaoAdapterFactory` com precisão** (mapa privado fechado, `create()`
  lança `InvalidArgumentException`, `tiposSuportados()` ↔ `operacoesSuportadas()`) — Factory+enum onde o
  techplan pediu, sem cair no reflexo de Registry de string. Escolha de padrão V2 certeira.
- **`WmsOrdemSonda` espelha a Sonda de config da Fase 1** (customerControl=false, isolamento explícito por
  `block_empresa_id`, conjunto completo de auditoria/block, `usuario_cadastro` na DDL + `ALTER IF NOT
  EXISTS`) — aplicou a lição do B2 da Fase 1 e a memória `modelinteligencia_create_audit_columns`.
- **Idempotência bem desenhada**: UNIQUE composta por empresa (dedup por tenant, não global) + dedup que
  **preserva o veredito original** (inclusive rejeição, decisão 5 do backend) em vez de mascarar como
  sucesso — mais correto para AC6.3.
- **Fix do `InboundHandler` é mínimo e backward-compatible**: labs sem `response` → `[]` → `ok` default
  true → 200 (canais existentes intactos); labs com `response.ok=false` → 422 + corpo rico. Fecha o gap
  de AC3.3/3.5 que o próprio backend havia sinalizado, sem quebrar contrato de outros canais.
- **Isolamento cirúrgico no cron**: a mudança da Fase 2 é o bloco marcado + 1 chamada; nada da lógica de
  aprovação/estorno (outra feature) foi tocado — fronteiras claras pelos comentários, como prometido.
- **Ordem do pipeline correta e conservadora**: envelope-inválido → tenant → ativo_entrada → envelope →
  dedup → identidade → tradução; rejeição sempre persistida com motivo (observável), nunca missão parcial.

## Recomendação

✅ **Merge da Fase 2.** Zero blockers; o único major (M1) é uma fragilidade de robustez/manutenção
**sem impacto de segurança** (confirmado fail-closed) e pode entrar com TODO, desde que endereçado no
arranque da Fase 3 — que é justamente quando a resolução de tenant passa a gravar missão real. Tratar
junto o nit n1 (o bloco morto é a linha que a Fase 3 vai substituir) e, antes do primeiro WMS real, os
minors m1 (resposta idempotente na corrida) e m2 (segredo HMAC distinto do bearer). Arquitetura fiel às
referências V2 (Otp/OperacaoAdapter/ScanCota) e à Fase 1 já aprovada.
