0092 — Universal communication suppression (opt-out) across all outbound channels
- Status: Proposed
- Date: 2026-07-15
- Deciders: Fede
Context
On 2026-07-15, a Camellia tenant (unit 103) called Clara back after renewal outreach and explicitly asked not to be contacted again (conv_voice_6cd8488c-0377-488f-a975-c2f21e0b3a72). Clara acknowledged verbally and recorded nothing: no revocation row was written, no tool exists for the agent to record one, and the tenant remained fully eligible for future SMS, voice, and email outreach.
That is a live legal exposure, not just a UX gap. The controlling rules (deep-research pass 2026-07-15, all claims verified 3-0 against primary sources — FCC orders, Federal Register, FTC guidance):
- Revocation by any reasonable means — FCC 24-24 (47 CFR § 64.1200(a)(10), effective 2025-04-11): a consumer may revoke consent "in any reasonable manner that clearly expresses a desire not to receive further calls or text messages." A verbal opt-out during a call is per se valid. We may not require STOP-by-text or any exclusive method. Non-listed methods carry a rebuttable presumption of validity with the burden on us — which makes a provable audit trail mandatory.
- Revocation is cross-channel for voice + SMS — in force since 2025-04-11: "revocation extends to both robocalls and robotexts regardless of the medium used to communicate the revocation." A verbal opt-out on a call legally stops our texts to that number, and a STOP text legally stops our calls.
- Our voice agent is a robocall — FCC Declaratory Ruling 24-17 (2024-02-08): AI-generated voices are "artificial" under the TCPA. Clara's voice channel gets zero leniency relative to prerecorded robocalls; the full consent + revocation framework applies.
- 10 business days, hard ceiling — revocations must be honored "as soon as practicable," never more than 10 business days from receipt (TCPA for calls/texts; CAN-SPAM independently imposes the same window for email).
- One confirmation message allowed — a single opt-out confirmation (no marketing content) is permitted; it does not extend the clock.
- Cross-topic "revoke-all" — the provision making one revocation apply to all message topics from the sender is FCC-waived until 2027-01-31, but designing to it now is the recommended posture (and what the existing KB doc already advises).
- Twilio only covers part of this — Twilio's automatic STOP handling unifies SMS/MMS/RCS per messaging service. It does not touch voice or email, and it cannot see a verbal opt-out. The legal obligation is broader than the CPaaS default; enforcement must live in our own layer.
What we have today, and where it breaks:
- SMS/MMS: solid. Every send funnels through
sendSms/sendMms(src/lib/integrations/twilio/client.ts), gated bycheckSmsSendAllowed→ the append-onlyCONSENT#{phone}store. STOP webhooks and Twilio 21610 write revocations reactively. Only bypass is staff MFA (intentional). - Voice: no consent layer at all.
dispatchVoiceCall(renewal outreach) and the emergency-relay dialer place calls with no suppression check. The voice agent has no opt-out tool inVOICE_TOOL_SCHEMAS, so an in-call revocation cannot be recorded. - Email: two independent senders. SendGrid funnels through
sendEmail(bounce suppression + kill switches, but no opt-out list). Microsoft Graph (MicrosoftInboxClient.sendReply/sendNewMessage— prospect cadence email, Clara's inbound auto-replies, turnover vendor dispatch primary leg) bypassessendEmailentirely: no suppression of any kind. - Chat/SMS agent: no opt-out tool either — a conversational "please stop texting me" (as opposed to the literal STOP keyword) is recorded nowhere.
- The SMS gate fails open on lookup errors, which is the wrong default for a number with a known revocation.
Decision
Build a single communication suppression layer: one append-only store, one check function, enforced at every outbound choke point, fed by agent tools on every conversational channel.
1. Data model: append-only suppression events, identifier-keyed
Generalize the existing SMS-consent pattern (CONSENT#{phone}, append-only, grants never deleted) into channel-spanning suppression:
PK=SUPPRESS#PHONE#{e164}/SK={timestamp}#{action}— covers voice + SMS + MMS for that number as one unit, because the law ties revocation to the number, not the medium.PK=SUPPRESS#EMAIL#{normalized-address}/SK={timestamp}#{action}— covers email for that address (CAN-SPAM identifier).
Each event records: action (revoked | reinstated), sourceChannel (voice | sms | email | pm_dashboard | twilio_21610 | stop_keyword), verbatimSignal (the transcript quote, keyword, or click), conversationId/messageSid provenance, personId (when resolvable), recordedBy (agent tool | webhook | human), and scope (default all). Append-only, retained ≥ 4 years (TCPA statute of limitations) — this is what carries the rebuttable-presumption burden.
Person-level fan-out (best practice, beyond the legal floor): when a suppression is written and a personId is resolvable, also write suppression events for the person's other contact claims (their email when they revoked by phone, and vice versa), with sourceChannel marking the fan-out provenance. One "stop contacting me" means the person, not the identifier. The identifier-level records remain the enforcement keys so an unresolved caller still gets suppressed by number.
2. Enforcement: one check, every choke point, fail closed
New checkSuppression(identifier, channel, category) in src/lib/domain/compliance/, wired into:
| Channel | Insertion point | Today |
|---|---|---|
| SMS/MMS | checkSmsSendAllowed (extended to read the new store) |
gated (old store) |
| Email (SendGrid) | sendEmail in src/lib/integrations/email/client.ts |
not gated |
| Email (Graph) | inside MicrosoftInboxClient.sendReply / sendNewMessage (agents/clara/lib/email/inbox-client.ts) |
not gated — the bypass |
| Voice outbound | dispatchVoiceCall (renewal) + initiateEmergencyRelayCall |
not gated |
Rules:
- Fail closed when the suppression lookup errors on an
outreach-category send (flipping the current fail-open). Transactional/emergency categories may fail open with alogCritical. - A suppressed send returns a typed
SuppressedSendResult(not a throw), so Temporal activities recorddispatched_failed-style outcomes exactly as the SMS consent gate does today. - Staff MFA/auth codes keep their explicit bypass.
- Migration:
checkSmsSendAllowedreads both stores during transition; existingCONSENT#{phone}revocations are backfilled into the new store so history is preserved. Once the backfill is verified, theCONSENT#{phone}read is deleted — the dual read is a transition step, not a permanent parallel path. - Cross-runtime boundary (ADR-0001/0002):
MicrosoftInboxClientand the chat-agent tool live in theagents/clararuntime, which may not import fromsrc/(ESLintCROSS_RUNTIME_PATTERNS). The suppression store (DynamoDBSUPPRESS#…partitions) is the single source of truth; the check logic is implemented once per runtime —src/lib/domain/compliance/for the Next.js/Temporal side, a sibling module underagents/clara/lib/for the agents side — mirroring how the SMS path is already split (src/lib/integrations/twilio/client.tsvsagents/clara/lib/messaging/adapters/twilio-sms.ts).
3. Message categories and the exemption boundary
Every outbound send declares a category: outreach (renewal offers, prospect cadence, nudges — anything promotional or unsolicited), transactional (replies within a conversation the person is actively holding, work-order scheduling they requested, payment/lease documents they asked for), emergency_safety (gas leak, flood, fire, urgent habitability), staff_auth (MFA).
- Suppression always blocks
outreachon every channel. emergency_safetyis exempt (narrow FCC/FTC exemption categories) — sent with anexemptOverrideaudit field.transactionalto an opted-out person is allowed only when it responds to their own inbound request, and each such send is audit-logged. Anything with promotional content isoutreach— the higher tier wins, matching the existing KB rule that one promotional sentence upgrades the whole message.
4. Agent tools: record the revocation where it happens
- New voice tool
opt_out_of_communicationsinVOICE_TOOL_SCHEMAS+ the[tool]route + ElevenLabs agent config (synced viasync-specialist-agent.ts), prompted so the agent calls it on any clear "don't contact me" expression, then confirms verbally and ends gracefully. The tool writes the suppression event with the transcript quote asverbatimSignal. - Same tool for the chat agent (SMS/email conversations), catching conversational revocations that the STOP-keyword webhook can't see.
- Post-call safety net: the call-analysis pipeline (
callAnalysis.transcriptSummarygrading incall-ended) flags opt-out-intent calls where the tool was not invoked and files them for PM review — the tool is the primary path, this catches misses. (The 2026-07-15 call would have been caught by exactly this.) - One-time confirmation: on SMS-sourced revocations, the standard confirmation text (no marketing). On voice, the agent's verbal acknowledgment is the confirmation; no follow-up message.
5. Surfaces
- PM dashboard: a "Do not contact" indicator on tenant/prospect detail (plain English, per the UI-language rule), with the date and how they opted out; a PM-initiated opt-out/reinstate action (reinstatement requires the person's documented request and writes its own audit event).
- Renewal/outreach planners (auto-start, cadence walker) skip suppressed recipients before composing, so suppressed tenants surface as "needs a human touch" PM tasks instead of silently dropped sends — an opted-out tenant with an expiring lease still needs renewing, just by a human.
Entity classification (per ADR-0027)
| Entity | Class | Naming | Spine trace (canonical) OR derived-from / rebuilt-by / drift-tolerance (derived) |
|---|---|---|---|
SuppressionEvent |
canonical | bare name | Spine trace: via personId when resolved; identifier (PHONE#/EMAIL#) is the enforcement key for unresolved contacts |
Consequences
- A verbal "stop contacting me" to Clara on any channel becomes a durable, provable, immediately-enforced all-channel suppression — meeting the FCC standard already in force and the 2027 revoke-all prong ahead of schedule.
- Every new outbound feature inherits compliance for free by using the channel clients; adding a send path that bypasses
sendSms/sendMms/sendEmail/MicrosoftInboxClient/the voice dispatchers becomes the thing code review must catch. - The Graph email bypass gets its first suppression/kill-switch layer, which also closes the pre-existing bounce-suppression gap on that path.
- Suppressed-but-expiring tenants shift renewal work back to humans; the PM task surface must make that visible or renewals will silently stall.
- Fail-closed on lookup errors trades a small availability risk on outreach sends for elimination of the worst failure mode (texting a revoked number during a DB blip).
- Immediate follow-ups: backfill the 2026-07-15 Camellia opt-out into the new store the moment it exists (interim: manual
revokeSmsConsentwrite); audit recent voice transcripts for other unrecorded opt-outs; extend~/.claude/pm-domain-knowledge/tcpa-sms-consent-for-leasing.mdwith the verified voice/CAN-SPAM findings. - Out of scope here, flagged as open questions from the research (no surviving verified claims): state mini-TCPA specifics (FL/OK/WA), National DNC registry obligations for prospect cold-calls (we do not cold-call today), and the exact FCC emergency-exemption boundary. Revisit with counsel before any pure-marketing voice campaign.
Alternatives considered
- Channel-scoped suppression (STOP only stops SMS) — rejected: illegal for voice+SMS since 2025-04-11 (cross-channel provision), and splitting scope invites the exact class-action pattern the FCC rule targets.
- Rely on Twilio's built-in STOP handling — rejected as the enforcement layer: it covers SMS/MMS/RCS only, per messaging service, and cannot see verbal or email revocations. Kept as defense-in-depth behind our gate (21610 already self-heals our store).
- Person-keyed store only (no identifier keys) — rejected: unknown callers and unresolved numbers must still be suppressible; the law keys revocation to the number. Identifier-keyed with person fan-out gets both.
- Prompt-only handling (tell the agent to "remember" opt-outs, no tool) — rejected: nothing durable is written (this incident), and prompt-only behaviors don't survive across conversations or channels.
- Mutable
doNotContactboolean on the person/tenant record — rejected: fails the audit-trail requirement (rebuttable presumption puts the burden of proof on us), and loses the reinstatement history. Append-only events with a derived current-state read is the same pattern the SMS consent store already proved.