ADR-0013: Lead messaging consent & opt-out (FIT-206)
Status: Accepted Date: 2026-06-27 Context owner: Saar Issue: FIT-206 · gates FIT-157 (automations) · relates to FIT-140 (WhatsApp ISV)
TL;DR
The automations engine (ADR-0011) treats leads as first-class subjects and will send email / SMS / WhatsApp to non-members. A lead left contact details for an inquiry — that is not consent to automated outreach (GDPR; stricter under Israel’s Amendment 40 spam law). This was ADR-0011’s open question and the hard blocker before any lead automation sends in prod.
The decision, in one rule per channel:
- Email = opt-out (soft opt-in). A lead is messageable; every lead email carries a
working unsubscribe link +
List-Unsubscribe; opting out suppresses permanently. - SMS / WhatsApp = explicit opt-in. Never sent without a recorded grant.
Enforced at the dispatcher choke-point (AutomationDispatcherService.dispatch()): a
lead without valid consent is skipped, visibly — automation_step_runs records
skipped(lead_opted_out | no_explicit_consent), never a silent drop. The gate is
unconditional (fail-closed compliance, not a feature flag): a lead with no consent rows
at all is email-allowed, SMS/WhatsApp-blocked, so no backfill is needed.
Consent policy — source × channel matrix
The lawful basis per leadSource. Enforcement is two rules (email opt-out, SMS/WA opt-in);
the source only sets the default state + basis tag recorded at lead creation.
leadSource | SMS | Email basis | ||
|---|---|---|---|---|
minisite, website, qr, instagram, facebook, course_purchase | implied (soft opt-in) | explicit opt-in | explicit opt-in | soft_opt_in |
whatsapp (Click-to-WhatsApp) | implied | explicit opt-in | service-window grant (lead messaged first → Meta 24h window) | soft_opt_in |
referral, walk_in, phone_call, manual | implied (coach-asserted) | explicit opt-in | explicit opt-in | staff_entered |
- Email is allowed for every source under the opt-out model; the
basiscolumn records why (self-submitted soft opt-in vs coach-asserted) for the audit trail. The mandatory unsubscribe link is what makes this defensible. - SMS / WhatsApp are blocked for every source until an explicit grant exists. The one
exception is the CTWA service window: a lead who messages the gym first opens Meta’s
24-hour customer-care window, which is an opt-in to reply on WhatsApp — granted at
ingestion (
via='service_window'). Templated/marketing beyond the window still needs explicit opt-in; that lives with FIT-140 . - Members are out of scope — member messaging consent is the existing
notification_prefspath; this gate only fires forsubjectType='lead'.
Schema
libs/db/src/lib/schema/lead-consent.ts (+ enums in enums.ts):
lead_consent— current state, org-scoped,UNIQUE(lead_id, organization_id, channel). Columns:channel(email|sms|whatsapp),status(granted|withdrawn),via(capture|service_window|staff|api|unsubscribe_link|stop_keyword),basis(free-text lawful-basis tag),source_at_capture,granted_at,withdrawn_at. The gate is one indexed read on this table.lead_consent_events— append-only ledger (granted|withdrawn+via+ ip/ua), proof of opt-in/opt-out for audits (GDPR Art. 7(1), Amendment 40). Mirrors thelegal_consentsprecedent.
Consent is org-scoped because it describes the relationship between one org and one person.
Enforcement
AutomationDispatcherService.dispatch() — inside the per-channel fallback loop, after the
supportsLeads check, for a lead recipient it calls
LeadConsentService.isAllowed(leadId, orgId, channel). A block sets the skip reason and
continues to the fallback channel; if no channel passes, the existing
recordOutcome({ status:'skipped', error }) writes the visible ledger row. push never
reaches this for a lead (supportsLeads=false).
email → allowed UNLESS lead_consent.status = 'withdrawn' (reason: lead_opted_out)
sms/wa → allowed ONLY IF lead_consent.status = 'granted' (reason: no_explicit_consent)Opt-out paths
- Email — every lead automation email gets a localized unsubscribe footer (lead’s
locale) + aList-Unsubscribeheader, both pointing at the web confirmation page (/{lang}/unsubscribe?token=). The token is an HMAC-signed, self-describing, non-expiring value (an opt-out link must work forever). Mutation happens only on an explicit POST (POST /lead-consent/unsubscribe, public) — there is no mutating GET, so email security-scanners prefetching links can’t auto-unsubscribe a lead. - WhatsApp — inbound
STOP/UNSUBSCRIBE/הסר/עצור/ביטולfrom a known lead withdraws WhatsApp consent (via='stop_keyword'). The inbound path is live for CTWA; the handler keys offleads.phonewithin the org. - SMS —
STOPhandling is documented and deferred to FIT-140 (SMS outbound is a stub); it will call the sameLeadConsentService.withdraw(..., via:'stop_keyword').
Consequences
- Unblocks FIT-157 lead automations — the dispatcher cannot message a non-consenting or opted-out lead.
- No feature flag. Per the repo’s FF policy, behavioral changes default to current
behavior — but here “current behavior” is no gate (the bug), and lead automations have
never sent (
AUTOMATIONS_ENABLED=false). So the gate is always-on; any future PostHog lever could only tighten (e.g. force explicit opt-in for email too), never loosen below this baseline. - No backfill — channel defaults make legacy leads behave correctly (email allowed, SMS/WA blocked).
- Deferred: (1) mapping a specific Meta lead-form consent question to an SMS/WhatsApp
grant — left off in v1 because the field is org/form-specific and heuristic parsing risks
a wrong-direction false opt-in; the coach can grant via the lead drawer, or it lands
with form-field mapping. (2) SMS
STOP(FIT-140). (3) Per-channel WhatsApp template-vs- session-window distinction (FIT-140).
References
- ADR-0011 — automations engine; this resolves its “Lead messaging consent / opt-out?” open question.
apps/api/src/lead-consent/— service, controller, tokens.apps/api/src/automations/automation-dispatcher.service.ts— the gate.apps/api/src/automations/channels/email.channel.ts— unsubscribe footer + header.apps/api/src/lead-ingestion/whatsapp-ingestion.service.ts— inbound STOP.