016 — Referral attribution (sem fila) — share link, attribuição append-only, round-trip persistência
CEO matou a waitlist em 2026-06-03: “bobeira ter waitlist, vamos fazer landing final direta whats”. A versão anterior do 016 modelava Waitlist/WaitlistEntry/position/register — toda essa mecânica de fila foi removida integralmente. Casais agora entram direto via whats (wa.me/<bot>?text=..., ADR 007), sem fila intermediária, sem position, sem email obrigatório (ADR 006).
Mas attribution de referrals continua importando, por três motivos:
- K-factor analytics (CEO doc) — quem trouxe quantos casais, viralidade real medível.
- Contexto de onboarding amigável — quando o casal novo chega no chat, o agente sabe “te convidou Ana e Bruno” e pode personalizar a primeira interação.
- Audit log futuro — rastreabilidade de qual casal indicou qual, sem PII além do
Household.id.
Este cenário modela o domain mínimo que sustenta esses três usos. O aggregate Referrals orquestra ReferralLinks emitidos por Households existentes e ReferralAttributions registradas quando um novo Household nasce via referral. Sem position, sem fila, sem email, sem coupleName. Sem register ou linked. Apenas:
- emitir link (
issue) — idempotente porownerHouseholdId(mesmo casal sempre tem o mesmo token). - registrar attribuição (
attribute) — append-only fact: “novo Household X veio via shareToken Y”. - consultar —
findByShareToken,findAttributionsForInviter,list,size.
Persistência via ReferralsRepository segue o molde do 007 / 010 (port em application/, fake em infrastructure/).
Migration note (supersedes versão anterior do 016)
Section titled “Migration note (supersedes versão anterior do 016)”A versão anterior deste cenário modelava Waitlist (fila) com register({email, coupleName, ref?}), position mutável, delta -5 quando referrer indicava alguém, idempotência por email, e lookup por email. Tudo removido integralmente:
Waitlistaggregate → renomeadoReferrals(mesmo pattern singular orquestrando coleção).WaitlistEntryentity → renomeadoReferralLink(entity simples, não carrega position nem email).register({email, coupleName, ref?})→ split emissue({ownerHouseholdId})+attribute({inviteeHouseholdId, shareToken}).positionfield → removido. Não há fila.findByEmail→ removido. Email não vive aqui (anonymous-first, ADR 006).linked/referrerno result → removidos.attributeretornaReferralAttributiondireto ouundefined.- Delta hard-coded
-5→ removido. Sem reordenação porque não há ordem. - Idempotência por email → substituída por idempotência por
ownerHouseholdId(chave estável do Household). ShareTokenVO → preservado (mesma regra: non-empty, sem espaço).
Sem aliases, sem back-compat. Old waitlist mechanic está morta.
Scenario: Casal pede share link, recebe ShareToken
Section titled “Scenario: Casal pede share link, recebe ShareToken”- Given um Household “Ana e Bruno” existe com
id = "hh-ana-bruno"(criado fora do contexto referrals — via chat-as-onboarding, ADR 006) - And
referrals.size() === 0 - When chamamos
referrals.issue({ ownerHouseholdId: "hh-ana-bruno" }) - Then
result.link.shareTokené umShareTokennon-empty (string sem espaço) - And
result.link.ownerHouseholdId === "hh-ana-bruno" - And
result.link.idé um UUID estável (sobrevive a round-trip) - And
result.link.createdAté umaDate(now-ish) - And
referrals.size() === 1
Scenario: Idempotência por ownerHouseholdId — mesmo casal retorna mesmo link
Section titled “Scenario: Idempotência por ownerHouseholdId — mesmo casal retorna mesmo link”- Given
referrals.issue({ ownerHouseholdId: "hh-ana-bruno" })já gerou um link comshareToken = tok_abc123 - When chamamos
referrals.issue({ ownerHouseholdId: "hh-ana-bruno" })uma segunda vez - Then
result.link.shareToken.equals(tok_abc123)(mesmo token) - And
result.link.idé o mesmo id do link anterior (mesma entity, não duplicada) - And
referrals.size() === 1(não cresceu)
Scenario: Attribuição de referral quando inviter existe
Section titled “Scenario: Attribuição de referral quando inviter existe”- Given Household “Ana e Bruno” (
hh-ana-bruno) tem umReferralLinkcomshareToken = tok_abc123 - And Household “Carlos e Dani” (
hh-carlos-dani) acabou de nascer via whats (composition root passa oshareTokendo deep link pro contexto referrals) - When chamamos
referrals.attribute({ inviteeHouseholdId: "hh-carlos-dani", shareToken: ShareToken.of("tok_abc123") }) - Then
resulté umReferralAttributiondefinido (nãoundefined) - And
result.inviter === "hh-ana-bruno" - And
result.invitee === "hh-carlos-dani" - And
result.attributedAté umaDate(now-ish) - And
referrals.findAttributionsForInviter("hh-ana-bruno").length === 1 - And
referrals.findAttributionsForInviter("hh-ana-bruno")[0]é oresult
Scenario: Token inválido — attribute retorna undefined sem throw
Section titled “Scenario: Token inválido — attribute retorna undefined sem throw”- Given nenhum
ReferralLinkcomshareToken = tok_nopeexiste - When chamamos
referrals.attribute({ inviteeHouseholdId: "hh-x", shareToken: ShareToken.of("tok_nope") }) - Then
result === undefined(silent, sem throw — UX: link expirado/corrompido não quebra onboarding) - And
referrals.findAttributionsForInviter("hh-x").length === 0(nada foi gravado)
Scenario: Round-trip persistência preserva links e attributions
Section titled “Scenario: Round-trip persistência preserva links e attributions”- Given uma instância de
Referralscom 2ReferralLinks (Ana e Bruno + Carlos e Dani) e 1ReferralAttribution(Ana indicou Eve) - And um
InMemoryReferralsRepository - When chamamos
repo.save(referrals) - And depois
repo.load(referrals.id) - Then
loaded.size() === 2 - And
loaded.findByShareToken(anaToken)retorna oReferralLinkda Ana (mesmoid, mesmoownerHouseholdId, mesmocreatedAt) - And
loaded.findAttributionsForInviter("hh-ana-bruno").length === 1 - And essa attribution preserva
inviter,inviteeeattributedAt - And
repo.list()retorna[referrals]com 1 elemento
Modelo
Section titled “Modelo”class ShareToken { static of(value: string): ShareToken; // non-empty, sem espaço toString(): string; equals(other: ShareToken): boolean;}
// src/contexts/referrals/domain/value-objects/ReferralAttribution.tsinterface ReferralAttribution { readonly inviter: string; // ownerHouseholdId do referrer readonly invitee: string; // householdId do casal novo readonly attributedAt: Date;}
// src/contexts/referrals/domain/ReferralLink.tsinterface ReferralLink { readonly id: string; // UUID estável readonly shareToken: ShareToken; readonly ownerHouseholdId: string; readonly createdAt: Date;}
// src/contexts/referrals/domain/Referrals.tsclass Referrals { readonly id: string; // UUID estável (overload externo pra reconstrução)
static create(input?: { id?: string }): Referrals;
issue(input: { ownerHouseholdId: string }): { link: ReferralLink };
attribute(input: { inviteeHouseholdId: string; shareToken: ShareToken; }): ReferralAttribution | undefined;
findByShareToken(token: ShareToken): ReferralLink | undefined; findAttributionsForInviter(ownerHouseholdId: string): ReferralAttribution[]; list(): ReferralLink[]; size(): number;
// pattern 007: serialize/rehydrate pro adapter serializeLinks(): Array<{ ... }>; serializeAttributions(): Array<{ ... }>; static rehydrate(input: { id: string; links: Array<{...}>; attributions: Array<{...}>; }): Referrals;}
// src/contexts/referrals/application/ReferralsRepository.tsinterface ReferralsRepository { save(referrals: Referrals): Promise<void> | void; load(id: string): Promise<Referrals | undefined> | Referrals | undefined; list(): Promise<Referrals[]> | Referrals[];}- Aggregate root
Referrals— singular orquestrando coleção, mesmo padrão deBudget(singular orquestrandoRecurringExpense) e daWaitlistanterior. Uma instância em produção (singleton-ish), masrepo.list()mantido por consistência com 007/010 + tenancy futura. - Entity
ReferralLink— id UUID estável, imutável após criação. Não tem maisposition(não há fila); não tememail/coupleName(anonymous-first). Verbos do cenário (“emitir” + “consultar”) justificam Entity (id estável necessário pra round-trip e dedupe porownerHouseholdId). - VO
ReferralAttribution— fato append-only. POJO simples cominviter,invitee,attributedAt. Sem id próprio (chave natural = parinviter+invitee+attributedAt, sem necessidade de revogação até cenário pedir). Mesmo trajeto doIncomeEntryno 014 (gotcha “Append-only VO até verbo ‘editar’ aparecer”). - VO
ShareToken— preservado da versão anterior. Wrapper fino sobre string, regra non-empty + sem espaço,.toString()como fronteira pública de serialização. Atravessa boundary cross-context (wa-001 consome via deep link param, landing-002 consome via query string). HouseholdIdé string opaca aqui — não vira VO. Segue gotcha “Strings opacas viram VO só com verbo”: referrals só compara igualdade, não tem regra de formato nem aritmética sobre ids. Promove a VO se aparecer caso (ex: validação de UUID strict, mapping para waid, namespacing por tenant).Referrals.idUUID default + overload externo — simétrico aBudget.id(007),Goal/Household/Account(010), e àWaitlist.idanterior.Referrals.create()gera UUID;Referrals.create({ id })reconstrói pelo adapter.serialize*+rehydrate({...id, ...})— pattern recorrente do adapter (gotcha “serializeX()+rehydrate({...id, X})pareados pra adapter SQLite”). Dois arrays serializados (links + attributions) porque são duas coleções independentes dentro do aggregate.- Idempotência por
ownerHouseholdId— primeira consulta antes de criar link novo. Substitui idempotência por email da versão anterior. Chave estável + sem PII + alinhada com anonymous-first.
Decisões de design
Section titled “Decisões de design”- Port em
application/, Entity/aggregate/VO emdomain/. Mesma régua do 007: domain é puro, application é infra-facing. Domain nunca importa de application nem infrastructure. - Test fake é adapter legítimo, não mock inline.
InMemoryReferralsRepositoryemreferrals/infrastructure/segue o pattern dos repositórios anteriores (007/010). Mock inline na spec seria mais barato hoje, mas o fake vira reuso quando outros cenários (analytics K-factor, dashboard CMO) precisarem. attributesilent on invalid token — UX (parceiro chega com link expirado / corrompido) não quebra. Domain não loga (sem dependência de logger); caller (composition root no whats adapter) decide se instrumenta. Mesma régua deExpenseReconcilerque silenciosamente não casa o que não bate.- Split de
registeremissue+attribute— verbos separados pra responsabilidades separadas.issueemite link (idempotente por owner);attributeregistra fato append-only. Promove invariante “unique (inviter, invitee)” se aparecer demanda real. - Sem
revoke/rotatede token. Token vive enquanto o link vive. Promove se aparecer caso (“share link expira em 30 dias”, “casal quer trocar token após vazamento”). shareTokengerado pelo aggregate, não passado de fora — domain controla unicidade interna. Caller (UX) só recebe.- Não cria adapter SQLite agora. Round-trip aqui usa só o fake. Quando aparecer primeiro consumer prod (whats bot precisando persistir attributions entre restarts), cria
SqliteReferralsRepository+ spec colocada (ADR 003).
Impacto em outros cenários
Section titled “Impacto em outros cenários”- landing-001 (hero CTA) — refactored em paralelo. CTA primário é deep link
wa.me/<bot>?text=..., não form de waitlist. Backend mock dePOST /api/waitlistremovido. Se manter algum tipo de form de email, fica como secondary path / nurturing (fora deste cenário). - landing-002 (share viral) — refactored em paralelo. Visitante chega com
?ref=<shareToken>→ CTA deep link já carrega o token (ex:wa.me/<bot>?text=START%20<token>ou similar). Attribuição acontece dentro do whats flow (composition root chamareferrals.attribute(...)quando o Household novo é criado), não na landing. - wa-001 (onboarding via whats) — composition root resolve fluxo: (a) chat novo sem Household → cria Household via chat-as-onboarding; (b) se mensagem inicial contém
<shareToken>→ chamareferrals.attribute({ inviteeHouseholdId, shareToken })após criar o Household. Token inválido = silent skip. - CEO doc (K-factor analytics) — dashboard consome
referrals.findAttributionsForInviter(...)+ agrega. Sem mudança no domain core. - 007 / 010 — sem touchup. Quinto consumer do pattern Repository, mesmo molde.
- Decisão 006 (anonymous-first) — referenced lateralmente:
Household.id(que mapeia 1:1 comresourceId) é a chave usada aqui. - Decisão 007 (whats canal) — referenced lateralmente: deep link
wa.me/<bot>?text=...carrega oshareTokencomo param de attribution, não como onboarding-token-via-fila.
Fora de escopo
Section titled “Fora de escopo”- Expiração de
shareToken— token vive enquanto o link vive. Sem TTL, sem revogação, sem rotate. Promove se aparecer caso. - Single-use vs multi-use de token — token reusável N vezes (cada uso adiciona attribuição). UX viral por design. Anti-abuse fica em layer separada.
- Tenancy / multi-Referrals — domain suporta
repo.list()por consistência mas em produção é singleton. - Notificação ao inviter quando o invitee chega — efeito colateral fora do aggregate. Caller (worker / agent) decide.
- K-factor analytics agregadas — métricas (total convites, conversion rate) ficam em layer de read-model / dashboard, não no aggregate. Domain expõe primitive (
findAttributionsForInviter); agregação vive fora. HouseholdIdcomo VO — string opaca por enquanto.- Adapter SQLite real (
SqliteReferralsRepository) — fica em spec colocada futura (ADR 003). - Reconciliação com fluxo de Waitlist legacy — fila foi killed integralmente, não há código de migração porque não havia produção.
Próximo passo
Section titled “Próximo passo”- Criar
ShareTokenVO emsrc/contexts/referrals/domain/value-objects/. - Criar
ReferralAttributionVO emsrc/contexts/referrals/domain/value-objects/. - Criar
ReferralLinkEntity emsrc/contexts/referrals/domain/. - Criar
Referralsaggregate root emsrc/contexts/referrals/domain/comissue/attribute/findBy*/findAttributionsForInviter/list/size+serializeLinks/serializeAttributions/rehydrate. - Criar barrel
src/contexts/referrals/domain/index.ts. - Criar port
ReferralsRepositoryemsrc/contexts/referrals/application/. - Criar fake
InMemoryReferralsRepositoryemsrc/contexts/referrals/infrastructure/até este spec passar (tier domain). - Adapter
SqliteReferralsRepository+ spec colocada em sessão futura quando aparecer primeiro consumer prod.