wa-004 — Bring partner via DM solo (2º member entra no mesmo Household via DM próprio)
Pós-wa-001, Ana já criou o Household (1 member: ela). Ana quer trazer Bruno pra dentro do mesmo Household — sem signup, sem onboarding novo, sem group chat. Caminho deste cenário:
- Ana pede ao bot um link de convite pro parceiro (UX: comando natural no chat, ou botão na landing/dashboard read-only futura; detalhe de UX fora do escopo desta spec — aqui só o
Household.issuePartnerInvite()é exercitado). - Bot retorna deep link
wa.me/<bot>?text=partner-<token>pra Ana copiar/compartilhar. - Ana manda o link pro Bruno pelo WhatsApp pessoal dela.
- Bruno clica → abre conversa DM SOLO com o bot (não group, não grupo). Primeira mensagem dele já carrega o prefix.
- Bot detecta o prefix
partner-<token>notext, NÃO cria Household novo: resolve o token → encontra o Household da Ana → adiciona Bruno como Member (Household.addMember) → linka ochatIdDM do Bruno ao mesmoHousehold.id. - A partir daí: ambos os DMs (Ana e Bruno) apontam pro mesmo Household.
resourceIdcompartilhado, threads isoladas porchatId(mesmo mapping de wa-003 entre group e DM — ADR 004 + cenário 012).
Caminho alternativo ao wa-003 (group): casal que não quer abrir group dedicado de 3, ou casal cujo parceiro nunca esteve no DM do bot ainda. wa-003 e wa-004 são ortogonais — em prod ambos coexistem; user escolhe.
Distinção crítica vs cenário 016 (ref-<token>)
Section titled “Distinção crítica vs cenário 016 (ref-<token>)”Ambos os prefixes carregam token opaco, ambos parseados no router. Semântica é inversa:
| Prefix | Semântica | Aggregate impacto | Port chamado |
|---|---|---|---|
ref-<shareToken> | Novo casal entra via convite de outro casal | Cria Household novo + Referrals.attribute(...) | HouseholdLookup.bootstrapFromChat(...) |
partner-<token> | Mesmo casal, 2º member entra no Household existente | NÃO cria Household — chama Household.addMember(...) | HouseholdLookup.bringPartnerFromInvite(...) |
Misturar os dois cabe num parser estruturado (gotcha “prefix mágico documentado é OK até aparecer 2º prefix com semântica diferente — aí vira parser estruturado”). Este é exatamente o 2º prefix. Discutido em Decisões de design abaixo.
TTL hard-coded 7 dias
Section titled “TTL hard-coded 7 dias”PartnerInviteToken.expiresAt = issuedAt + 7 dias. Threshold mágico documentado (gotcha “threshold mágico documentado é OK até aparecer caso”). Quando aparecer caso “casais com vida corrida pedem 30 dias” ou “queremos rotacionar a cada 24h”, promove a parâmetro do issuePartnerInvite({ttlDays?}). Hoje: simples, suficiente.
Single-use
Section titled “Single-use”Token marcado consumedAt quando consumePartnerInvite retorna {ok: true}. Segunda tentativa com mesmo token → {ok: false, reason: "already-consumed"}. Mirror da decisão de 016 sobre ShareToken single-use opcional, mas aqui é default porque convite de parceiro é pessoal (vs share viral que é multi-use por design).
Scenario: Ana gera convite parceiro — token emitido + persistido
Section titled “Scenario: Ana gera convite parceiro — token emitido + persistido”- Given um
Household“Ana” criado em BRL comMemberAna (estado pós-wa-001 — Ana é o único member) - And
today = 2026-07-15T00:00:00Z(clock injetado pelo caller; domínio não consultaDate.now()) - And
household.partnerInvites().length === 0 - When chamamos
household.issuePartnerInvite({ today }) - Then o
result.tokené uma string non-empty (token opaco, gerado pelo aggregate — domain controla unicidade interna) - And o
result.issuedAtétoday(passado pelo caller, nãoDate.now()) - And o
result.expiresAt > today(TTL hard-coded 7 dias —expiresAt = issuedAt + 7d) - And o
result.consumedAtéundefined(token recém-emitido) - And
household.partnerInvites().length === 1(append-only — pattern doIncomeEntryem 014)
Scenario: Bruno abre link partner-<token> no DM próprio — vira 2º member
Section titled “Scenario: Bruno abre link partner-<token> no DM próprio — vira 2º member”- Given
Household“Ana” existe com Member Ana (id = hh-ana) e está persistido emHouseholdRepository - And o
chatId="dm-5511999999999"(DM da Ana) está linkado ahh-anaviahouseholdLookup.linkChat(...)(estado pós-wa-001) - And Ana gerou um
PartnerInviteTokencomtoken="partner_invite_xyz"viahousehold.issuePartnerInvite({today})(ver scenario anterior);household.partnerInvites().length === 1 - And
today = 2026-07-15(dentro do TTL de 7d) - And o
chatId="dm-5599888888888"(DM solo do Bruno) ainda não está linkado a nenhum Household —householdLookup.findByChatId("dm-5599888888888")retornaundefined - When
MockWhatsAppGateway.emit({ chatId: "dm-5599888888888", senderId: "5599888888888", text: "partner-partner_invite_xyz oi", isGroup: false, mentions: [] })dispara o evento (primeira mensagem do Bruno no DM) - And o router parseia o prefix via
/^partner-(\S+)/i→ extraipartnerToken = "partner_invite_xyz" - And o router chama
householdLookup.bringPartnerFromInvite({ chatId: "dm-5599888888888", senderId: "5599888888888", partnerToken: "partner_invite_xyz" }) - Then o
resulté o mesmo aggregate Household “Ana” (mesmoid = hh-ana); NENHUM Household novo foi criado —householdRepo.list().lengthcontinua1 - And
household.members().length === 2(Ana + novo Member derivado dosenderIddo Bruno — caller decide nome, ex: placeholder"Parceiro"ou waid-derived) - And o novo Member tem
idUUID estável (não reusa nenhum id existente) - And
householdLookup.findByChatId("dm-5599888888888")agora retorna o mesmo Household “Ana” (chatId DM do Bruno linkado) - And
householdLookup.findByChatId("dm-5511999999999")continua retornando o mesmo Household “Ana” (link da Ana intacto — many-to-one chatIds → Household) - And o token foi marcado consumed:
household.consumePartnerInvite("partner_invite_xyz", today).ok === true(segunda chamada — idempotente do ponto de vista do verdict, mas o estado interno só transiciona uma vez na primeira call efetiva do lookup) - And o
gateway.sentMessagescontém uma saudação enviada aochatIddo Bruno cumprindo regex/junto|casal|Ana|bem-vindo/i(wording personalizado mencionando o parceiro existente; LLM real renderiza, spec valida via regex permissivo)
Scenario: Token expirado — fallback gracioso, Member NÃO adicionado
Section titled “Scenario: Token expirado — fallback gracioso, Member NÃO adicionado”- Given
Household“Ana” existe compartnerInvites: [{ token: "old_token", issuedAt: 2026-07-01, expiresAt: 2026-07-08, consumedAt: undefined }](gerado 14 dias atrás, expirou há 7 dias) - And
today = 2026-07-15(depois deexpiresAt) - And
household.members().length === 1(só Ana) - When
householdLookup.bringPartnerFromInvite({ chatId: "dm-5577777777777", senderId: "5577777777777", partnerToken: "old_token" })é chamado - Then o
result === undefined(lookup falha silenciosa — não throw, não loga) - And
household.members().lengthpermanece1(Bruno NÃO foi adicionado) - And
householdLookup.findByChatId("dm-5577777777777")continua retornandoundefined(chatId não foi linkado) - And o verdict explícito do aggregate é
household.consumePartnerInvite("old_token", today)→{ ok: false, reason: "expired" } - And o router NÃO dispara
bootstrapFromChatautomaticamente neste caso — diferentemente do scenario 5 (token desconhecido), token expirado não vira onboarding novo por design (parceiro com link velho provavelmente quer o link novo, não virar Household separado). Router responde viagateway.sendMessagecom texto regex/expirou|expirado|outro link|peça um novo/i. (Wording deferido pra router — spec aqui valida o verdict do aggregate + ausência de side-effect; o teste do router específico fica no spec do wa-004 que dirige o fluxo completo.)
Scenario: Token já usado — bloqueia segunda tentativa
Section titled “Scenario: Token já usado — bloqueia segunda tentativa”- Given
Household“Ana” compartnerInvites: [{ token: "partner_invite_xyz", issuedAt: 2026-07-15, expiresAt: 2026-07-22, consumedAt: 2026-07-16 }](Bruno já entrou via scenario 2 — token marcado consumed) - And
today = 2026-07-17(dentro do TTL original — token não expirou, só foi consumido) - And
household.members().length === 2(Ana + Bruno, já no Household) - When alguém (ex: um amigo curioso que pegou o link) tenta
householdLookup.bringPartnerFromInvite({ chatId: "dm-5500000000001", senderId: "5500000000001", partnerToken: "partner_invite_xyz" }) - Then o
result === undefined - And
household.members().lengthpermanece2(não cresce — protege Household de leakage pra terceiro) - And
householdLookup.findByChatId("dm-5500000000001")continua retornandoundefined - And o verdict explícito é
household.consumePartnerInvite("partner_invite_xyz", today)→{ ok: false, reason: "already-consumed" } - And o router responde com texto regex
/já foi usado|outro link|peça um novo|expirou/i(wording deferido)
Scenario: Token inválido (desconhecido) — silent fallback pra onboarding normal
Section titled “Scenario: Token inválido (desconhecido) — silent fallback pra onboarding normal”- Given nenhum
HouseholdnoHouseholdRepositorytempartnerInvitescontendo o token"tok_nope" - And o
chatId="dm-5500000000002"ainda não está linkado a nenhum Household - When
householdLookup.bringPartnerFromInvite({ chatId: "dm-5500000000002", senderId: "5500000000002", partnerToken: "tok_nope" })é chamado - Then o
result === undefined(lookup não encontrou Household que owna o token) - And
householdRepo.list().lengthnão muda dentro da chamada dobringPartnerFromInvite(o lookup não cria nada — caller decide o fallback) - And o caller (router wa-001 estendido) deve detectar
result === undefined+ token desconhecido (não expirado/consumed em nenhum Household) e fazer fallback prahouseholdLookup.bootstrapFromChat({ chatId: "dm-5500000000002", senderId: "5500000000002", referralToken: undefined })— cria Household novo (parceiro com link lixo vira casal novo sem attribution, mesma régua silent do wa-001 ref-inválido) - And o user nunca vê erro — saudação é a de onboarding normal (regex
/oi|mel|casal|começar/i, mesma do wa-001 cenário 1)
Nota de teste: spec deste cenário valida apenas o retorno
undefineddo lookup (responsabilidade doHouseholdLookup). O fluxo de fallback (router chamabootstrapFromChatquandobringPartnerFromInviteretornaundefined) é responsabilidade do router e fica documentado aqui no doc Gherkin pra orientar implementação, mas a asserção fica fora do spec wa-004 (pertence ao spec do router estendido — provavelmente um wa-001 refactored ou spec colocada). Mantém o spec wa-004 focado no contract do aggregate + lookup.
Modelo
Section titled “Modelo”- Sem context novo —
household/ganha um VO (PartnerInviteToken) e dois métodos no aggregate (issuePartnerInvite,consumePartnerInvite).agent/application/HouseholdLookupganha um método (bringPartnerFromInvite). - Sem aggregate novo — convite de parceiro é fato append-only dentro do
Household(pattern doIncomeEntryem 014). Promove a Entity própria (PartnerInvitecomid/revoke) se aparecer caso “Ana quer revogar link antes de expirar”. - Sem Domain Service novo — verbos
issuePartnerInvite/consumePartnerInvitemoram no aggregate (estado interno: lista append-only de tokens).bringPartnerFromInvitemora no port (cruza I/O: load Household via repository + addMember + linkChat). - Token gerado pelo aggregate, não passado de fora — mesmo princípio do
ShareTokenem 016 (“shareTokengerado pelo aggregate”, “domain controla unicidade interna”). Caller recebe pronto.
// src/contexts/household/domain/value-objects/PartnerInviteToken.ts (planned)export interface PartnerInviteToken { readonly token: string; // opaco, gerado pelo Household (UUID-like) readonly issuedAt: Date; readonly expiresAt: Date; // = issuedAt + 7d (TTL hard-coded) readonly consumedAt?: Date; // single-use; undefined enquanto não consumido}
// src/contexts/household/domain/Household.ts (estende — já existe)class Household { // ...métodos existentes (addMember, assignIncome, monthlyIncome, ...)
issuePartnerInvite(input: { today: Date }): PartnerInviteToken; // Gera token opaco non-empty, expiresAt = today + 7d. // Append-only em this._partnerInvites; retorna o token recém-emitido.
partnerInvites(): PartnerInviteToken[]; // Snapshot da lista append-only (mesmo padrão de members()).
consumePartnerInvite( token: string, today: Date, ): { ok: true } | { ok: false; reason: "expired" | "already-consumed" | "unknown" }; // Verdict + side-effect: ao retornar { ok: true }, marca consumedAt internamente. // - "unknown": token não está em this._partnerInvites // - "expired": today > tokenRecord.expiresAt // - "already-consumed": tokenRecord.consumedAt !== undefined // Idempotência: segunda chamada de consumePartnerInvite com mesmo token // (após primeira sucesso) retorna { ok: false, reason: "already-consumed" }.}
// src/contexts/agent/application/HouseholdLookup.ts (estende — já existe)interface HouseholdLookup { findByChatId(chatId: string): Promise<Household | undefined>; findBySenderId(senderId: string): Promise<Household | undefined>; // wa-003 linkChat(input: { chatId: string; householdId: string }): Promise<void>; bootstrapFromChat(input: { // wa-001 chatId: string; senderId: string; referralToken?: string; }): Promise<Household>; chatIdsFor(householdId: string): Promise<string[]>; // 019
// wa-004 (novo): bringPartnerFromInvite(input: { chatId: string; // DM solo do parceiro chegando senderId: string; // waid do parceiro (vira Member derivado) partnerToken: string; // opaco; lookup resolve via Household.consumePartnerInvite today?: Date; // opcional; default new Date() (caller injeta nos specs) }): Promise<Household | undefined>; // Semântica: // 1. Itera households conhecidos (via HouseholdRepository.list ou index interno). // 2. Tenta consumePartnerInvite(token, today) em cada um. // 3. Se algum retornar { ok: true }: chama household.addMember(Member.create({name})), // household.save (via HouseholdRepository), linkChat({chatId, householdId}), // retorna o Household. // 4. Se nenhum aceita (todos retornam { ok: false }): retorna undefined. // Caller decide fallback (wa-001 → bootstrapFromChat).}Decisões de design
Section titled “Decisões de design”- Segundo prefix mágico — gatilho pra promover a parser estruturado, mas ainda dentro do limite — gotcha existente: “prefix mágico documentado é OK até aparecer 2º prefix com semântica diferente — aí vira parser estruturado”. Este é exatamente o 2º prefix. Decisão consciente: manter regex isolado por prefix por enquanto (
/^partner-(\S+)/iseparado do/^ref-(\S+)/i), porque os dois são mutuamente exclusivos (mensagem começa com um ou outro, nunca ambos) e o router só precisa de umif/else if/else. Quando aparecer 3º prefix (reminder-,goal-,?) — aí vira parser estruturado tipo{kind: "partner"|"ref"|..., token: string}. Não promover por antecipação. Cenário 022 (eval harness) é o lugar natural pra testar combinações de prefix se virar regra real. partner-<token>vsref-<shareToken>— semântica inversa, ports diferentes —ref-cria Household NOVO viabootstrapFromChat(cenário 016 + wa-001).partner-atrai 2º member pra Household EXISTENTE viabringPartnerFromInvite. Dois ports separados em vez de overload mantém intent explícito + facilita evolução (TTL/single-use departner-≠ multi-use deref-por design).- Token mora no aggregate
Household, não emReferrals—Referralsé cross-household (rastreia attribution entre casais distintos);PartnerInviteTokené intra-household (binding pro parceiro do mesmo casal). Mistura semantica diferente → mistura cleanup, audit, persistência. MantémReferralsenxuto e ancora ciclo de vida do convite no aggregate dono do convite. - TTL 7d hard-coded — threshold mágico documentado (gotcha existente, repetido aqui). Promove a parâmetro (
issuePartnerInvite({ttlDays?})) quando aparecer caso. Hoje: 7d cobre 99% dos casos reais (“mando hoje, parceiro abre no fim de semana”). - Single-use por default — convite pessoal entre os dois do casal. Multi-use só faria sentido pra “Ana quer convidar vários parceiros” — semanticamente inconsistente com couple-first. Decisão diferente de
ShareToken(16), que é multi-use por design (viral). Promove a flag se aparecer “polirelacionamento de 3 members” (esperar paciência razoável). - Member derivado do
senderId, sem nome real — caller (bringPartnerFromInvite) criaMember.create({name})com placeholder. Doc deixa em aberto: pode ser"Parceiro"literal, waid-derived ("Parceiro 5599888888888"), ou string que LLM extrai depois via conversa natural. Spec não trava o wording, só checamembers().length === 2+ novo Member ≠ Ana. UX/system prompt do agent prod refina nome depois (cenário 018AddMemberToolcobre a edição). bringPartnerFromInviteretornaHousehold | undefined, não throw — UX silent fallback. Lookup itera Households tentando consumir token; se ninguém aceita, retornaundefinede caller decide. Mirror doattributeem 016 (gotcha “attributesilent on invalid token”) e dofindByChatId(retorna undefined em vez de throw).- Verdict de
consumePartnerInviteé discriminated union, não throw —{ok: true} | {ok: false, reason}. Caller (HouseholdLookup.bringPartnerFromInvite+ router) precisa distinguir os 3 motivos de falha pra UX:expired: peça novo link (mas não cria Household novo — preserva relação).already-consumed: link já foi usado, possivelmente por outro (intencionalmente?). Peça novo link.unknown: nenhum Household tem esse token → caller faz fallback prabootstrapFromChat(vira novo casal). Régua: sóunknownvira onboarding novo;expired/already-consumedficam no fluxo “peça novo link”.
- Append-only em
partnerInvites: PartnerInviteToken[], não Map — VO sem id próprio (mesmo padrão deIncomeEntryem 014). Promove a Entity (comrevoke(),regenerate()) se aparecer verbo “Ana quer revogar link antes de Bruno usar”. Hoje: append-only + consumed flag basta. todayinjetado, semDate.now()no domínio —issuePartnerInvite({today})econsumePartnerInvite(token, today)recebem clock externo. Mesma fronteira deFeasibilityCheck.evaluate({today})+ReminderService.evaluate({today}). Spec usa datas UTC fixas; gotcha “Aritmética de data em UTC, sempre” se aplica ao cálculoexpiresAt = issuedAt + 7d.- Many-to-one
chatId → Household.id— depois dobringPartnerFromInvite, oHouseholdLookuptem doischatIdlinkados ao mesmoHousehold.id(DM Ana + DM Bruno). wa-003 já documentou esse caso parcialmente (DM + group). wa-004 confirma: lookup armazenaMap<chatId, householdId>simples — manychatIdapontam pra umhouseholdId. Cenário 019 (chatIdsFor(householdId)) consome essa estrutura. - wa-004 é alternativo a wa-003, não substituto — UX prod oferece ambos. wa-003 é zero-friction quando casal já tem group; wa-004 é zero-friction quando não tem. Promove a primary path se um dos dois ganhar (telemetria futura). Hoje: ambos viáveis, decisão do casal.
Impacto em outros cenários
Section titled “Impacto em outros cenários”- wa-001 (onboarding via WhatsApp) — router precisa estender o parser pra reconhecer segundo prefix. Pseudo-código do router refactored:
Touchup no spec wa-001 quando este cenário implementar; alternativamente, o spec do router fica em wa-004 e wa-001 segue cobrindo só ref/no-prefix.if (text.startsWith("partner-")) {const partnerToken = parse(/^partner-(\S+)/i)const result = await lookup.bringPartnerFromInvite({chatId, senderId, partnerToken, today})if (result) → saudação personalizada ("bem-vindo, casal Ana e ...")else → fallback pra bootstrapFromChat (silent onboarding normal)} else if (text.startsWith("ref-")) {...wa-001 referral path} else {bootstrap normal}
- wa-003 (group com parceiro) — caminho ortogonal, não muda. wa-003 cobre group bind via
findBySenderId(Ana já tá no DM, vira group bind). wa-004 cobre DM solo do Bruno via convite. Em prod ambos viáveis; sem touchup em wa-003. - Cenário 005 (Household) — sem mudança de contract.
addMemberreusado. Aggregate ganhaissuePartnerInvite/consumePartnerInvite/partnerInvitescomo verbos novos (extensão, não breaking). - Cenário 010 (HouseholdRepository) — adapter SQLite precisa serializar
partnerInvites: PartnerInviteToken[](tabela própria ou JSON column). Mecânica fica emSqliteHouseholdRepository.spec.tscolocada (ADR 003) — adicionar patternserializePartnerInvites()+rehydrate({...partnerInvites})quando primeira sessão prod precisar persistir. - Cenário 018 (AddMemberTool) —
AddMemberToolcontinua válido pra edição de nome do Member já adicionado via wa-004 (placeholder"Parceiro"→"Bruno"via conversa). wa-004 + 018 se compõem; sem conflito. - Cenário 016 (Referrals) — sem touchup. Distinção semantica documentada explicitamente neste doc. Quando aparecer 3º prefix, refatorar parser estruturado em ambos.
Fora de escopo
Section titled “Fora de escopo”- Revogação manual de token antes de consumir — “Ana errou link, quer cancelar antes do Bruno abrir”. Promove
PartnerInviteTokena Entity comrevoke()quando aparecer. Hoje: token expira em 7d e Ana pede novo. - Múltiplos partner invites simultâneos — Ana gera 2 tokens, manda 1 pro Bruno, manda outro pra terceiro. Domain aguenta (lista append-only), mas UX casal+parceiro não justifica testar. Cenário futuro se aparecer caso “casal poliamoroso, 3 members”.
- TTL configurável per-Household — hoje hard-coded 7d. Override por parâmetro fica pra cenário com demanda real.
- Rotação de waid (parceiro trocou de número após bind) —
findByChatIdcontinua resolvendo via chatId antigo; chatId novo precisa de re-bind. Cenário futuro (mesmo TODO do wa-003). - Notificação ao inviter quando invitee abre o link — “Ana, o Bruno entrou!” é efeito colateral. Fora do aggregate. Worker/agent decide.
- Persistência real do
PartnerInviteTokenem SQLite — adapterSqliteHouseholdRepositoryprecisa estender schema. Mecânica em spec colocada futura (ADR 003). - Persistência do mapping
chatId → Household.idpós-bind — adapter real (InMemoryHouseholdLookupno fake; tabelawhatsapp_linksno SQLite real). Mecânica em spec colocada. - Wording exato da saudação — spec valida via regex permissivo. LLM em prod renderiza.
- Rate limiting de
bringPartnerFromInvite— terceiro spamando tokens aleatórios. Mitigação fica no adapter Baileys / layer de orquestração. - Convite por email/SMS além de WhatsApp link — wa-004 cobre só o canal whats. Outros canais entram em cenário próprio.
- Rollback se
addMembersucede maslinkChatfalha — cross-aggregate transaction. Aceito como trade-off (gotcha 013 “transactions cross-aggregate fora do escopo”). Recovery futura via comando “removi Bruno sem querer”.
Próximo passo
Section titled “Próximo passo”- Criar VO
PartnerInviteTokenemsrc/contexts/household/domain/value-objects/. - Estender
Household(src/contexts/household/domain/Household.ts) comissuePartnerInvite({today})+partnerInvites()+consumePartnerInvite(token, today)+ estado interno privado_partnerInvites: PartnerInviteToken[]. - Estender port
HouseholdLookup(src/contexts/agent/application/HouseholdLookup.ts) combringPartnerFromInvite({chatId, senderId, partnerToken, today?}). - Estender fake
InMemoryHouseholdLookup(src/contexts/agent/infrastructure/InMemoryHouseholdLookup.ts) com a impl: iteraHouseholdRepository.list()chamandoconsumePartnerInvite; ao primeiro{ok: true}, chamaaddMember+linkChat, persiste viarepo.save, retorna Household. - Atualizar barrel
agent/application/index.ts+household/domain/index.ts+household/domain/value-objects/index.ts(ou equivalentes) com novos exports. - Estender
WhatsAppMessageRouter(src/contexts/agent/application/whatsapp/WhatsAppMessageRouter.ts) com parse do segundo prefix/^partner-(\S+)/i+ branchbringPartnerFromInvite→ fallbackbootstrapFromChatquandoundefined. Saudação distinta pro caso success. - Adapter real (
SqliteHouseholdRepository) ganha persistência departnerInvitesem sessão futura — spec colocada sem doc Gherkin (ADR 003).