wa-001 — Onboarding via WhatsApp — primeira mensagem cria Household direto, opcionalmente com referral
CEO killou a waitlist em 2026-06-03. A landing virou versão final (não pre-launch) — visitante clica CTA, abre wa.me/<bot>?text=oi, manda a mensagem. O bot cria o Household imediatamente. Sem token requerido, sem fila, sem signup (ADR 006 reforçado).
Opcional: se a primeira mensagem começa com ref-<shareToken> (ex: ref-tok_abc123 oi), o bot detecta o prefix, cria o Household normalmente e dispara Referrals.attribute({inviteeHouseholdId, shareToken}) (cenário 016 refactored). Se o token for inválido (ou inexistente), faz fallback silencioso pro onboarding normal — nenhum erro pro user. Attribution não é parte do happy path principal; é insumo de growth tracking opcional.
Breaking change: removida
HouseholdLookup.bootstrapFromOnboardingToken({chatId, senderId, token}). UseHouseholdLookup.bootstrapFromChat({chatId, senderId, referralToken?})em vez. Token-based onboarding flow foi eliminado junto com a waitlist.
ADR 007 escolheu WhatsApp via Baileys como único canal conversacional em produção; ADR 006 ancorou identidade em waid (anonymous-first, sem signup). Este cenário é o primeiro toque real do casal no canal: a primeira mensagem que cai no número do bot já decide se a conversa entra como Household existente ou vira onboarding novo (com ou sem referral attribution).
O BaileysAdapter real (descrito em ADR 007) traduz o evento Baileys pra WhatsAppMessageEvent e repassa pro orquestrador WhatsAppMessageRouter. Nesta spec o adapter é substituído pelo MockWhatsAppGateway (test fake, mora em agent/infrastructure/, segue o pattern InMemoryAgentMemory do 012), que expõe emit(event) pra spec disparar mensagens entrando e sentMessages[] pra observar o que o router enviou de volta.
O WhatsAppMessageRouter é capability-named (igual FeasibilityCheck em planning/ e o resto de agent/), vive em agent/application/ porque depende de ports (gateway, lookup, repository) e decide o fluxo:
- Se
householdLookup.findByChatId(chatId)resolve → fluxo normal: chamaAgentChat.ask({ messages, resourceId: household.id, threadId: chatId })(cenários 008/009/012/013). MappingresourceId = household.id/threadId = chatIdé o que o ADR 007 fixou. - Se o chat é novo:
- Parse opcional do prefix
ref-<shareToken>notextvia regex case-insensitive (/^ref-(\S+)/i). - Chama
householdLookup.bootstrapFromChat({ chatId, senderId, referralToken })(token undefined se não tiver prefix). O lookup criaHouseholdnovo com Member primário derivado do waid, persiste viaHouseholdRepository.save, linkachatId → household.ide, sereferralTokenfoi passado, chamaReferrals.attribute({inviteeHouseholdId, shareToken: ShareToken.of(referralToken)})— attribution silenciosa em caso de token inválido (retornaundefined). - Envia saudação personalizada via
gateway.sendMessage. Wording menciona o inviter (“te convidou Ana e Bruno”) quando attribution sucedeu; caso contrário, saudação genérica.
- Parse opcional do prefix
A spec roda no tier domain (ADR 002): MockWhatsAppGateway + MockLanguageModelV1 + simulateReadableStream. Sem rede, sem Baileys, sem LLM real. Mecânica do BaileysAdapter real fica em spec colocada src/contexts/agent/infrastructure/BaileysAdapter.spec.ts (sem doc Gherkin, ADR 003).
BOT_WAID e o resto dos detalhes whats inalterados — só o fluxo de entry mudou. waid continua string opaca (formato "5511999999999", sem + nem @s.whatsapp.net).
Scenario: Primeira mensagem “oi” cria Household direto, sem token
Section titled “Scenario: Primeira mensagem “oi” cria Household direto, sem token”- Given nenhum
Householdestá vinculado aochatId="dm-5511999999999" - And nenhum setup de
Referralsé necessário (não há prefixref-no text) - When o
MockWhatsAppGateway.emit({chatId: "dm-5511999999999", senderId: "5511999999999", text: "oi", isGroup: false, mentions: []})dispara o evento - Then
householdLookup.bootstrapFromChatfoi chamado com{ chatId: "dm-5511999999999", senderId: "5511999999999", referralToken: undefined } - And um
Householdnovo foi criado e persistido viaHouseholdRepository.save(size do repo era 0, agora é 1) - And
householdLookup.findByChatId("dm-5511999999999")retorna esse Household - And o gateway recebeu exatamente um
sendMessage({chatId: "dm-5511999999999", text})cujatextcumpre regex case-insensitive/oi|mel|casal|começar/ - And a saudação não menciona “convidou” / “te trouxe” (sem referral)
Scenario: Primeira mensagem “ref-tok_abc123 oi” registra attribution
Section titled “Scenario: Primeira mensagem “ref-tok_abc123 oi” registra attribution”- Given existe um inviter Household “Ana e Bruno” com
ReferralLinkcujoshareToken="tok_abc123"emreferrals/ - And nenhum
Householdestá vinculado aochatId="dm-5599999999999"ainda - When o
MockWhatsAppGateway.emit({chatId: "dm-5599999999999", senderId: "5599999999999", text: "ref-tok_abc123 oi", isGroup: false, mentions: []})dispara o evento - Then
householdLookup.bootstrapFromChatfoi chamado com{ ..., referralToken: "tok_abc123" } - And um
Householdnovo foi criado (size do repo era 1, agora é 2 — inviter + invitee) - And
Referrals.attribute({ inviteeHouseholdId: <novo Household.id>, shareToken: ShareToken.of("tok_abc123") })foi chamado e retornou uma attribution válida (referencia o inviter “Ana e Bruno”) - And o gateway recebeu exatamente um
sendMessage({chatId: "dm-5599999999999", text})cujatextcumpre regex case-insensitive/convidou|junto|Ana e Bruno/
Scenario: ref- faz fallback silencioso pra onboarding normal
Section titled “Scenario: ref- faz fallback silencioso pra onboarding normal”- Given nenhum link com token
tok_nopeexiste emreferrals(Referrals.attribute retornaráundefined) - And nenhum
Householdestá vinculado aochatId="dm-5500000000000" - When o
MockWhatsAppGateway.emit({chatId: "dm-5500000000000", senderId: "5500000000000", text: "ref-tok_nope oi", isGroup: false, mentions: []})dispara o evento - Then o
Householdfoi criado normalmente (size do repo era 0, agora é 1) - And
Referrals.attribute({ inviteeHouseholdId, shareToken: ShareToken.of("tok_nope") })foi chamado mas retornouundefined(token desconhecido) - And a saudação enviada pelo gateway não menciona “convidou” / “te trouxe” (fallback silencioso — wording igual ao onboarding sem referral)
- And nenhum erro foi mostrado pro user
Scenario: Casal já vinculado manda nova mensagem — não re-onboarda
Section titled “Scenario: Casal já vinculado manda nova mensagem — não re-onboarda”- Given um
Household“Casa” já criado e vinculado achatId="dm-5511999999999"(Member primário Ana já registrada) - And o
AgentChatestá configurado com umBudgetToole comAgentMemory(InMemoryAgentMemory) - And o LLM mockado responde com texto qualquer (mock script controlado)
- When o
MockWhatsAppGateway.emit({chatId: "dm-5511999999999", senderId: "5511999999999", text: "qual o saldo?", isGroup: false, mentions: []})dispara o evento - Then
householdLookup.bootstrapFromChatNÃO foi chamado (nenhum re-onboarding) - And
agentChat.ask({ messages, resourceId: household.id, threadId: "dm-5511999999999" })foi chamado (mapping ADR 007) - And o gateway recebeu exatamente um
sendMessage({chatId: "dm-5511999999999", text})com a resposta que o LLM mockado devolveu
Modelo
Section titled “Modelo”- Context novo —
whatsapp/no nível de scenarios (mesmo agrupamento delanding/). Não cria contextwhatsapp/emsrc/— código vive emagent/(port + VO + router + adapter mock), porque WhatsApp é canal do agente, não domínio próprio. Convenção: cenários cross-stack (landing/whats) ganham subfolder emscenarios/; código vai pro context que orquestra. - Capability-named orchestrator —
WhatsAppMessageRouteremagent/application/(não emdomain/, porque depende de ports e persistência). Stateless: recebe evento, resolve dependências via DI, retornavoid. Mirror deFeasibilityCheck.evaluate, mas com I/O via ports. - Port
WhatsAppGateway—agent/application/whatsapp/. Métodos:onMessage(handler: (event: WhatsAppMessageEvent) => Promise<void>): void— assinatura observer/pub-sub; adapter real (Baileys) chama o handler a cada evento decriptado; mock invoca viaemit(event).sendMessage({chatId, text}): Promise<void>— manda texto pro chat (DM ou grupo).downloadMedia(mediaId): Promise<Uint8Array>— baixa mídia decriptada (cenário wa-002 usará pra fatura PDF).
- VO
WhatsAppMessageEvent—agent/domain/whatsapp/. POJO serializável:{ chatId, senderId, text?, media?: {mediaId, mimeType}, isGroup, mentions: string[] }. Sem behavior — adapter Baileys normaliza payload bruto pra esse shape. - Port
HouseholdLookup—agent/application/. Métodos:findByChatId(chatId): Promise<Household | undefined>— usa linkchatId → household.id+HouseholdRepository.load.linkChat({chatId, householdId}): Promise<void>— persiste o mapping (em tabela própria no adapter real; Map no fake).bootstrapFromChat({chatId, senderId, referralToken?}): Promise<Household>— cria Household novo (Member primário do waid), persiste viaHouseholdRepository, linka, e sereferralTokenfoi passado, chamaReferrals.attribute({inviteeHouseholdId, shareToken: ShareToken.of(referralToken)})(silent fallback se token inválido). Returns o Household criado.
- Test fake
MockWhatsAppGateway—agent/infrastructure/. Expõeemit(event)(dispara o handler registrado viaonMessage) esentMessages: Array<{chatId, text}>(push em cadasendMessage). Pattern doInMemoryAgentMemory: test fake é adapter legítimo, não mock inline. - Test fake
InMemoryHouseholdLookup—agent/infrastructure/.Map<chatId, householdId>+HouseholdRepository+Referralsinjetado via construtor. Bootstrap cria Household, persiste, linka, e opcionalmente chamaReferrals.attributequandoreferralTokenchega.
export interface WhatsAppMessageEvent { chatId: string; // DM id ou group id, opaco senderId: string; // waid do remetente (ex: "5511999999999") text?: string; media?: { mediaId: string; mimeType: string }; isGroup: boolean; mentions: string[]; // waids mencionados (vazio em DM)}
// src/contexts/agent/application/whatsapp/WhatsAppGateway.tsexport interface WhatsAppGateway { onMessage(handler: (event: WhatsAppMessageEvent) => Promise<void>): void; sendMessage(input: { chatId: string; text: string }): Promise<void>; downloadMedia(mediaId: string): Promise<Uint8Array>;}
// src/contexts/agent/application/HouseholdLookup.tsexport interface HouseholdLookup { findByChatId(chatId: string): Promise<Household | undefined>; linkChat(input: { chatId: string; householdId: string }): Promise<void>; bootstrapFromChat(input: { chatId: string; senderId: string; referralToken?: string; // opaque shareToken; lookup resolve via Referrals injetado }): Promise<Household>;}
// src/contexts/agent/application/WhatsAppMessageRouter.tsWhatsAppMessageRouter.create({ gateway: WhatsAppGateway; lookup: HouseholdLookup; agent: AgentChat; // já configurado com memory + tools}): WhatsAppMessageRouter
router.start(): void // registra handler via gateway.onMessageDecisões de design
Section titled “Decisões de design”- Router em
agent/application/, NÃO emdomain/— depende de ports (WhatsAppGateway,HouseholdLookup) e orquestra I/O. Mesma regra doBudgetRepository: domain é puro (input → output sem efeito colateral), application orquestra adapters. Router não é Domain Service. WhatsAppGatewayport espelha API mínima do Baileys —onMessage/sendMessage/downloadMediacobrem o uso real do canal. Mesma filosofia deAgentMemory↔ Mastra Memory: adapter wrapper fino → port copia forma (gotcha do 012). Reduz tradução noBaileysAdapter.WhatsAppMessageEventé VO POJO serializável — sem behavior, sem invariante. Adapter normaliza payload Baileys pra essa forma; spec constrói à mão.chatId/senderIdficam opacos (string, sem VO) pra não inflar o modelo.mentions[]entra agora porque wa-003 (group chat) vai usar pra detectar “@mel” em grupo.- Mapping
resourceId = household.id/threadId = chatIdfixado aqui — ADR 007 cravou; este cenário materializa. Router faz o mapping;AgentChatcontinua sem conhecerHousehold(gotcha “resourceId opaco no contract”). - Bootstrap é incondicional, referral é opcional — qualquer primeira mensagem cria Household. CEO killou waitlist (2026-06-03); não há mais “visitante desconhecido” que merece bounce.
referralTokené insumo de growth tracking, não gate. - Prefix
ref-<token>é convenção de deep link — landing-final gerawa.me/<bot>?text=ref-<shareToken>quando o user veio via?ref=na URL (cenário landing-002). Visitante orgânico abrewa.me/<bot>?text=oidireto. Router usa regex/^ref-(\S+)/ipra parsear; resto do text viramessagesnormal (não é consumido pela tool de attribution). - Silent fallback em token inválido —
Referrals.attribute(...)retornaundefinedquando token não existe. Router/lookup não throw, não loga, não muda saudação. UX: visitante que coloca lixo no link nunca sabe que tentou um referral inválido — entra normal e fim. Mirror do gotcha “ref inválido é silenciosamente ignorado” (cenário 016). waidé string opaca — formato"5511999999999"(DDI+DDD+número, sem+nem@s.whatsapp.net). Convenção do BaileysAdapter normalizar pra esse shape. Sem VOWhatsAppIdagora — promove quando aparecer regra de negócio (ex: distinguir DDI BR vs outros, validar comprimento).MockWhatsAppGateway.emité async no contract mas spec aguarda — gateway propaga pro handler que é async. Spec fazawait gateway.emit(event)pra garantir que o turno completou antes das asserções. Mirror do pattern deappendMessagesno 012.- Tier domain via fakes; Baileys real fica isolado —
MockWhatsAppGateway(fake) +InMemoryHouseholdLookup(fake) +InMemoryHouseholdRepository(já existe pelo 010). Spec roda em ms, determinístico.BaileysAdapterreal ganha spec colocada com adapter real + setup manual de QR pair (ADR 003, ADR 007). Referralsinjetado noInMemoryHouseholdLookup, não no router — attribution é detalhe do bootstrap, não do roteamento. Router só passareferralTokenopaco; lookup decide chamar (ou não) o portReferrals. Mantém router enxuto e centraliza a side-effect numa única camada (mesma régua de “composition root resolve dependências”).
Impacto em outros cenários
Section titled “Impacto em outros cenários”- Cenário 008 (AgentChat read-only) — fluxo normal pós-bootstrap reusa
AgentChat.askcomo hoje. Sem mudança contract. - Cenário 012 (chat persistido) —
resourceId/threadIdcontinuam opacos noAgentChat; este cenário fixa que valemHousehold.idechatIdno canal whats. Spec 012 não precisa touchup. - Cenário 010 (HouseholdRepository) — bootstrap consome
HouseholdRepository.save. Sem mudança contract. - Cenário 016 (referral attribution refactored) — segundo consumer do
Referrals.attribute(primeiro é a landing-002 direto). Domain deagent/recebereferralTokencomo string opaca; lookup resolve via portReferralsinjetado. - Cenário 018 (onboarding zero-state) — wa-001 cria o Household zero-state; 018 preenche via write tools. Sem touchup — 018 já assume Household pre-criado.
- Cenário landing-001/002 (landing final) — CTA agora é
wa.me/<bot>?text=oi(sem token) ouwa.me/<bot>?text=ref-<shareToken>(com referral). Landing precisa update separado pra refletir o killing da waitlist. - ADR 001 (stack) — Baileys entra na lista adopt quando primeiro consumer instalar (BaileysAdapter real).
Fora de escopo
Section titled “Fora de escopo”- Adapter real
BaileysAdapter— pair multi-device, QR code, reconexão, decrypt de mídia. Spec colocada gated por env (cenário separado). - Group chat (
isGroup: true,mentions[]) — cenário wa-003. wa-001 cobre só DM (isGroup false). - Upload de fatura PDF via whats — cenário wa-002. wa-001 não toca em mídia (
mediacontinua undefined em todos os eventos). - Validação de unicidade do
referralTokencross-attribution —Referrals.attributecuida (cenário 016 refactored). Router não conhece regras (single-use? multi-use? expiração?). - Múltiplos chats por mesmo waid — casal usa 2 celulares, mesma conta → 2
chatIddistintos. Mapeamento many-to-one (waids → Household) fica pra cenário futuro. - Rate limiting / anti-abuse — fora do domain. Adapter ou layer de orquestração decide.
- Persistência do mapping
chatId → household.id— adapter real (InMemoryHouseholdLookupno fake; tabelawhatsapp_linksno SQLite real). Schema fica em spec colocada do adapter de produção. - Recovery se waid não bate com cookie da landing — corner case fora do happy path (ex: clica no link no celular do amigo). Trata em cenário separado se aparecer.
- Wording exato da saudação personalizada — spec valida via regex permissivo (
/convidou|junto|Ana e Bruno/). LLM em prod renderiza; mock devolve string controlada.
Próximo passo
Section titled “Próximo passo”- Criar VO
WhatsAppMessageEventemsrc/contexts/agent/domain/whatsapp/. - Criar ports
WhatsAppGateway(emagent/application/whatsapp/) eHouseholdLookup(emagent/application/) combootstrapFromChat({chatId, senderId, referralToken?}). - Criar
WhatsAppMessageRouteremagent/application/comstart()que registra handler viagateway.onMessagee parse/^ref-(\S+)/ipro prefix. - Criar fakes
MockWhatsAppGateway+InMemoryHouseholdLookupemagent/infrastructure/até este spec passar (tier domain).InMemoryHouseholdLookuprecebeReferrals(port do cenário 016 refactored) via construtor. - Estender
agent/domain/index.tseagent/application/index.ts(barrels) com os novos exports. - Adapter real
BaileysAdapter+ spec colocada vêm em cenário separado (src/contexts/agent/infrastructure/BaileysAdapter.spec.ts, gated por env, ADR 007).