Replan — Data lake do WhatsApp resistente a falhas
Data: 2026-06-12 · Origem: sessão
grill-me(Clinton × Claude) Escopo: dois repos —wpp-tui(Collector / branchfeat/whatsapp-collector) ehe4rt-bot-apimódulointegration-whatsapp(branchfeat/integration-whatsapp-ingest). Substitui: plano0001-ingest-implementation.mde aspec.mdhistórica. Decisão de arquitetura: este plano motiva um ADR-0003 que supersede partes do ADR-0002.
1. Contexto
Por que replanejar
O objetivo foi reescrito: um data lake dos dados importantes do WhatsApp, resistente a falhas (nenhum evento importante se perde). A análise de 2026-06-07 e a re-análise de 2026-06-11 expuseram que o desenho vigente (ADR-0002) tem dois pilares que contradizem esse objetivo:
event_idaleatório (randomUUID()) — decisão do ADR-0002 que o Clinton reconheceu como erro. Ela desarma a dedup na origem (INSERT OR IGNOREvirou inerte) e faz re-emits do Baileys (reconexão,append, re-snapshot degroups.metadata) virarem linhas duplicadas no lake. O ADR chamou isso de "raro"; o código prova que é recorrente (toda reconexão).- Ingest assíncrono (job + 202 antes de persistir) — sob
QUEUE_CONNECTION=redis(prod), o202é dado antes da gravação. Se o job falhar, o evento some emfailed_jobse o bot já apagou do outbox → perda silenciosa. Hoje mascarado porQUEUE_CONNECTION=sync, mas prod será Redis.
Some-se a isso o poison payload (�/UTF-8 inválido que o Postgres recusa no jsonb), eventos de grupo descartados por bug de extração de JID, e o outbox sem dead-letter (retry eterno em falha permanente).
Estado atual (arquivos afetados)
Bot (wpp-tui):
src/collector/outbox.ts—event_id = randomUUID()(:47);INSERT OR IGNOREinerte.src/collector/extractors.ts— envelope enxuto{type,chat_jid,payload}; semevent_iddeterminístico.src/collector/event-router.ts— denylist + filtro@g.us.src/collector/webhook-sender.ts—markSentem qualquerres.ok; sem classificação de erro; sem timeout.src/hooks/use-socket.ts—router.handleEventpara todo evento (:185);groups.metadatare-emitido a cadaopen(:203,405).src/retention.ts— poda logs, não o outbox.
Backend (integration-whatsapp):
src/Ingest/Http/Controllers/WhatsAppWebhookController.php—dispatch(job)+202.src/Ingest/Jobs/ProcessWhatsAppEvent.php—firstOrCreate(event_id);Tries(3)/Backoff.src/Ingest/Http/Middleware/VerifyWhatsAppSignature.php— HMAC só do corpo; exigeStr::isUuid.database/migrations/...whatsapp_event_logs...—event_iduuid UNIQUE,payloadjsonb.
Constraints travados na sessão
- 🔒 Prod = Redis (async).
- 🔒 Governança/LGPD fora deste ciclo.
- 🔒
event_idvolta a determinístico. - 🔒 Filosofia: bot o mais burro possível dentro do necessário para montar a chave; backend guarda cru.
2. As 10 decisões
| # | Decisão | Resolução |
|---|---|---|
| 1 | Escopo de captura | Denylist (default-allow) + filtro @g.us. Payload CRU. "Importante" = tudo que não é o ruído conhecido. |
| 2 | Onde computar event_id |
No bot, UUIDv5 determinístico por tipo. Envelope segue enxuto. Revive INSERT OR IGNORE (dedup na origem). |
| 3 | Granularidade da dedup | Por conteúdo: re-emit idêntico deduplica; mudança real (trocou emoji, roster mudou) = linha nova. |
| 4 | Histórico/backfill | append sim (backfill idempotente de reconexão), messaging-history.set não (denylistado). |
| 5 | Durabilidade do ingest | Sem fila: controller faz firstOrCreate síncrono, 2xx só após commit. O lake não usa Redis — a resistência vem do outbox durável do bot + 2xx-pós-commit, independente do driver de fila. O QUEUE_CONNECTION=redis é app-wide (necessário ao moderation/bot-discord), não ao WhatsApp. Só um futuro passo derivado (não o write do lake) iria à fila. |
| 6 | Poison payload | Sanitizar (� + UTF-8 inválido) na borda do backend; manter jsonb consultável. |
| 7 | Resiliência do outbox | Classificar resposta: 5xx/rede/408/429 = retry; 401/403 = retry + alerta; 422/400 = dead-letter local. Estouro = alerta. Nunca auto-descarta. |
| 8 | Segurança do canal | Mínimo: event_id no material assinado do HMAC + segredo alinhado/fail-loud. Resto adiado. |
| 9 | Canonicalização de snapshot | ✅ Antes do hash de groups.metadata/deltas: ordenar participantes e dropar campos voláteis, senão cada reconexão vira "snapshot novo". |
| 10 | Observabilidade mínima | ✅ Heartbeat (status de conexão, outbox.count(), idade do item mais antigo) + alerta de desconexão. Ban-detection e healthcheck HTTP ficam para fase futura. |
3. O novo contrato (ponta a ponta)
WhatsApp ──► bot captura ──► outbox (event_id DETERMINÍSTICO = PK)
│ • INSERT OR IGNORE → dedup na ORIGEM
│ • durável: segura o evento até confirmar gravação
▼ (sender, background)
POST /api/webhooks/whatsapp
Headers: X-Event-Id (UUIDv5), X-Signature = HMAC(`${eventId}.${rawBody}`)
Body: { type, chat_jid, payload } ← CRU
▼
middleware: valida HMAC(eventId+body) + UUID
▼
controller → Action: sanitiza payload → firstOrCreate(event_id) SÍNCRONO → commit
▼
2xx só após commit ──► bot markSent (apaga do outbox) ← dedup no DESTINO
5xx/rede → retry · 401 → retry+alerta · 422 → dead-letter
Dupla proteção de idempotência: dedup na origem (INSERT OR IGNORE no outbox) e no destino (firstOrCreate + UNIQUE).
Chave determinística por tipo (UUIDv5)
| Tipo | Chave canônica | Efeito |
|---|---|---|
messages.upsert |
(type, remoteJid, key.id) |
notify e append do mesmo msg → mesma chave → backfill idempotente |
messages.reaction |
(type, target.key.id, reactor_jid, reaction.text) |
re-emit deduplica; troca de emoji/remoção = linha nova |
group-participants.update |
(type, group_jid, hash(payload canônico)) |
re-emit idêntico deduplica; delta diferente = linha nova |
groups.metadata |
(type, group_jid, hash(meta canônica)) |
snapshot idêntico deduplica; roster mudou = snapshot novo |
| genérico / desconhecido | (type, chat_jid, hash(payload canônico)) |
default seguro: qualquer re-emit idêntico deduplica |
senderTimestampMsda reação não entra na chave — é instável entre re-emissões (visto nos logs: mesma😂com 3 timestamps distintos). "canônico" (D9) =JSONcom chaves ordenadas, participantes ordenados porid, campos voláteis (timestamps de subject, etc.) removidos antes do hash.
4. Comportamento esperado (BDD)
Idempotência — re-emit (happy path do objetivo):
- Dado uma mensagem
key.id=Mjá no lake, Quando o Baileys reentregaMnumappendde reconexão, Então oevent_idé idêntico →INSERT OR IGNOREignora no bot (nem chega na rede) → nenhuma linha nova.
Idempotência — retry HTTP:
- Dado um item enviado mas cuja resposta se perdeu, Quando o bot reenvia o mesmo item, Então
firstOrCreateno backend não cria duplicata.
Mudança de estado preservada:
- Dado uma reação
😂de um usuário numa mensagem, Quando ele troca para☕, Então oevent_iddifere (emoji na chave) → nova linha; a timeline😂→☕fica completa.
Ingest síncrono sob Redis:
- Dado prod com Redis, Quando o evento chega, Então o controller grava antes de responder e o
2xxsignifica "persistido"; E Quando o DB está fora, Então responde5xx, o bot não apaga do outbox e re-tenta (sem perda).
Poison payload:
- Dado um payload com
�, Quando chega no ingest, Então o backend sanitiza, grava emjsonbe responde2xx(não trava, não perde, não duplica).
Falha permanente:
- Dado um item que o backend rejeita com
422, Quando o sender recebe, Então move paraoutbox_dead(preservado, fora do loop) + alerta — a fila ativa não entope. - Dado segredo dessincronizado (
401), Quando todo item falha, Então o bot mantém os itens e alerta (não descarta nada).
Bug corrigido (não perder dado importante):
- Dado um
group.member-tag.update(JID emgroupId), Quando chega ao extractor, Então o JID é resolvido porit.groupId→ não é mais descartado silenciosamente.
Compatibilidade:
- A tabela
whatsapp_event_logsnão muda de schema (event_iduuid UNIQUE comporta UUIDv5). Linhas antigas (com event_id random) coexistem; a dedup determinística só vale daqui pra frente.
5. Antes/depois (trechos-chave)
Bot — outbox.enqueue volta a receber o event_id determinístico:
- enqueue(body) {
- const eventId = randomUUID()
- insert.run(eventId, body.type, JSON.stringify(body), now, now)
- }
+ enqueue(event) { // { event_id, body }
+ insert.run(event.event_id, event.body.type, JSON.stringify(event.body), now, now)
+ } // INSERT OR IGNORE agora deduplica de fato
Bot — extractor calcula a chave (exemplo messages.upsert):
- return (d.messages ?? []).map((msg) => ({
- type: 'messages.upsert', chat_jid: msg.key?.remoteJid ?? null, payload: msg,
- }))
+ return (d.messages ?? []).map((msg) => ({
+ event_id: uuidv5(`messages.upsert|${msg.key?.remoteJid}|${msg.key?.id}`, NS),
+ body: { type: 'messages.upsert', chat_jid: msg.key?.remoteJid ?? null, payload: msg },
+ }))
Bot — fix do group.member-tag.update (genericExtractor):
- chat_jid: (it?.id) ?? (it?.key?.remoteJid) ?? null,
+ chat_jid: (it?.id) ?? (it?.groupId) ?? (it?.key?.remoteJid) ?? null,
Bot — sender classifica a resposta:
- const ok = await sendOnce(...)
- if (ok) outbox.markSent(row.event_id)
- else outbox.reschedule(row.event_id, attempts, next)
+ const res = await sendWithTimeout(...) // AbortController
+ if (res.status >= 200 && res.status < 300) outbox.markSent(row.event_id)
+ else if (res.status === 422 || res.status === 400) outbox.deadLetter(row.event_id, res.status)
+ else if (res.status === 401 || res.status === 403) { outbox.reschedule(...); alert('auth') }
+ else outbox.reschedule(...) // 5xx / rede / 408 / 429
Backend — ingest síncrono via Action (convenção: Action, não Service):
- dispatch(new ProcessWhatsAppEvent(eventId, type, chatJid, payload));
- return response()->json(['status' => 'accepted'], 202);
+ resolve(StoreWhatsAppEventAction::class)($eventId, $type, $chatJid, $payload); // síncrono
+ return response()->json(['status' => 'stored'], 201); // após commit
Backend — Action sanitiza antes do firstOrCreate:
// StoreWhatsAppEventAction (invokable)
$clean = $this->sanitize($payload); // remove �, JSON_INVALID_UTF8_SUBSTITUTE
WhatsAppEventLog::query()->firstOrCreate(
['event_id' => $eventId],
['type' => $type, 'chat_jid' => $chatJid, 'received_at' => now(), 'payload' => $clean],
);
Backend — HMAC cobre o event_id:
- $expected = hash_hmac('sha256', $request->getContent(), $secret);
+ $expected = hash_hmac('sha256', $eventId.'.'.$request->getContent(), $secret);
6. Sequência de implementação (fatias verticais)
Cada fatia é entregável e verificável de ponta a ponta.
- Idempotência determinística (bot) — helper UUIDv5 + chave por tipo +
enqueue(event_id)+INSERT OR IGNOREvivo. ✅ Verificar: reconexão não duplica. - Ingest síncrono + sanitização (backend) — controller→Action síncrona, drop do job, sanitizer, HMAC com event_id. ✅ Verificar: poison persiste, sem loss/loop; DB-down → 5xx → retry.
- Resiliência do outbox (bot) — classificação de resposta,
outbox_dead, timeout no fetch, alerta de estouro, poda por idade. ✅ Verificar: 422 → dead-letter; 401 → alerta sem descarte. - Segurança mínima (ambos) — event_id no HMAC (já na fatia 2 no backend; fechar no bot) + alinhar nome do env do segredo + fail-loud no 401 persistente.
- Observabilidade (bot) — heartbeat (status,
outbox.count(), idade do mais antigo) + alerta de desconexão. - Fix avulso (bot, a qualquer momento) —
group.member-tag.updateviagroupId. - Docs — ADR-0003 + atualizar
CONTEXT.md; apagar obsoletos (§7).
7. Reestruturação de documentação
Criar:
docs/adr/0003-deterministic-id-and-synchronous-ingest.md— supersede as partes do ADR-0002:event_idrandom → determinístico; ingest assíncrono → síncrono sem fila; + sanitização, dead-letter, dupla dedup.
Atualizar:
app-modules/integration-whatsapp/CONTEXT.md— glossário (event_id= determinístico por conteúdo; ingest síncrono; Redis só p/ futuro).wpp-tuidocs/coletor-whatsapp-overview.mdedocs/he4rt-platform-integration.md— novo contrato.
Apagar (obsoletos — git preserva o histórico):
app-modules/integration-whatsapp/docs/spec.md(já marcado histórico/superado).app-modules/integration-whatsapp/docs/plans/0001-ingest-implementation.md.wpp-tui/docs/plans/2026-05-21-whatsapp-collector-bot.mde…-bot-design.md.
Manter como histórico: ADR-0001 e ADR-0002 (registro de por que o desenho evoluiu).
8. Pontos confirmados / em aberto
- D9 (canonicalização) — ✅ confirmado: canonicalizar (ordenar participantes, dropar voláteis) antes do hash.
- D10 (observabilidade) — ✅ confirmado: mínimo (heartbeat + alerta de desconexão). Ban-detection e healthcheck HTTP → fase futura.
- Migração de dados antigos — ✅ não se aplica: ambiente é só dev, sem dado de prod. Começar limpo (
migrate:fresh); a dedup determinística vale desde a primeira linha. Sem etapa de migração. messages.upsertchave — ✅ confirmado:key.id + remoteJid.